Skip to main content

domain/
analytics.rs

1//! Source-derived analytics read models for resort operations.
2//!
3//! Analytics facts in this module sit after source ingestion and data-quality validation:
4//! raw Gingr/provider records are preserved as provenance, blocking hygiene findings stop
5//! projection, and nonblocking findings stay attached so manager briefs and labor-cost
6//! dashboards can explain their evidence instead of inventing operational truth.
7
8use serde::{Deserialize, Serialize};
9
10#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)]
11/// Version tag for a deterministic analytics projection.
12///
13/// This is the read-model side of the source-fact → validated-domain → workflow chain:
14/// it records which projection logic turned provider reservations into labor, demand,
15/// and manager-brief evidence so downstream reports can compare like with like.
16pub struct ProjectionVersion(String);
17
18impl ProjectionVersion {
19    /// Promotes a boundary projection-version string into a validated analytics value.
20    pub fn try_new(value: impl Into<String>) -> Result<Self> {
21        trimmed_non_empty(value, Error::EmptyProjectionVersion).map(Self)
22    }
23
24    /// Returns the projection-version identifier for storage/read-model boundaries.
25    pub fn as_str(&self) -> &str {
26        &self.0
27    }
28}
29
30/// Projected stay-fact boundary for reservation records that passed validation.
31pub mod stay {
32    use serde::{Deserialize, Serialize};
33
34    use crate::{analytics, data_quality, source};
35
36    #[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)]
37    /// Stable analytics id for a projected stay fact, distinct from provider record ids.
38    pub struct Id(String);
39
40    impl Id {
41        /// Validates and creates the analytics value.
42        pub fn try_new(value: impl Into<String>) -> analytics::Result<Self> {
43            analytics::trimmed_non_empty(value, analytics::Error::EmptyStayFactId).map(Self)
44        }
45
46        /// Returns the provider or domain identifier as a string slice.
47        pub fn as_str(&self) -> &str {
48            &self.0
49        }
50    }
51
52    #[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
53    /// Whether a stay projection is clean, reviewable, or blocked by source hygiene.
54    pub enum DataQualityStatus {
55        /// Source facts validated cleanly and can feed labor/read-model workflows directly.
56        Complete,
57        /// Projection is usable, but nonblocking hygiene issues should be visible to managers.
58        ManagerReviewRequired,
59        /// Source facts are not safe enough to power workflow or labor-cost decisions.
60        BlockingIssues,
61    }
62
63    #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
64    /// Source-derived stay fact used by analytics, manager briefs, and labor planning.
65    pub struct Fact {
66        id: Id,
67        provenance: source::Provenance,
68        reservation_record_id: source::record::Id,
69        customer_record_id: source::record::Id,
70        pet_record_id: source::record::Id,
71        location_record_id: source::record::Id,
72        service_type_record_id: source::record::Id,
73        projection_version: analytics::ProjectionVersion,
74        data_quality_status: DataQualityStatus,
75        data_quality_issues: Vec<data_quality::Issue>,
76    }
77
78    impl Fact {
79        /// Projects a validated stay fact from a source reservation snapshot.
80        ///
81        /// Blocking data-quality issues return the full issue set instead of producing a
82        /// fact; nonblocking issues stay attached as evidence for reviewable read models.
83        pub fn project_from_source_reservation(
84            id: Id,
85            source_reservation: &source::reservation::Snapshot,
86            projection_version: analytics::ProjectionVersion,
87        ) -> std::result::Result<Self, Vec<data_quality::Issue>> {
88            let issues = source_reservation
89                .data_quality_issues(source_reservation.provenance().pulled_at().clone());
90            if issues.iter().any(data_quality::Issue::workflow_blocking) {
91                return Err(issues);
92            }
93            let data_quality_status = if issues.is_empty() {
94                DataQualityStatus::Complete
95            } else {
96                DataQualityStatus::ManagerReviewRequired
97            };
98
99            let customer_record_id = source_reservation
100                .customer_record_id()
101                .expect("data_quality_issues guards customer presence")
102                .clone();
103            let pet_record_id = source_reservation
104                .pet_record_id()
105                .expect("data_quality_issues guards pet presence")
106                .clone();
107            let location_record_id = source_reservation
108                .location_record_id()
109                .expect("data_quality_issues guards location presence")
110                .clone();
111            let service_type_record_id = source_reservation
112                .service_type_record_id()
113                .expect("data_quality_issues guards service type presence")
114                .clone();
115
116            Ok(Self {
117                id,
118                provenance: source_reservation.provenance().clone(),
119                reservation_record_id: source_reservation.provenance().record_id().clone(),
120                customer_record_id,
121                pet_record_id,
122                location_record_id,
123                service_type_record_id,
124                projection_version,
125                data_quality_status,
126                data_quality_issues: issues,
127            })
128        }
129
130        /// Returns this analytics value's id.
131        pub const fn id(&self) -> &Id {
132            &self.id
133        }
134
135        /// Returns this analytics value's source system.
136        pub const fn source_system(&self) -> source::System {
137            self.provenance.source_system()
138        }
139
140        /// Returns this analytics value's provenance.
141        pub const fn provenance(&self) -> &source::Provenance {
142            &self.provenance
143        }
144
145        /// Returns this analytics value's reservation record id.
146        pub const fn reservation_record_id(&self) -> &source::record::Id {
147            &self.reservation_record_id
148        }
149
150        /// Returns this analytics value's customer record id.
151        pub const fn customer_record_id(&self) -> &source::record::Id {
152            &self.customer_record_id
153        }
154
155        /// Returns this analytics value's pet record id.
156        pub const fn pet_record_id(&self) -> &source::record::Id {
157            &self.pet_record_id
158        }
159
160        /// Returns this analytics value's location record id.
161        pub const fn location_record_id(&self) -> &source::record::Id {
162            &self.location_record_id
163        }
164
165        /// Returns this analytics value's service type record id.
166        pub const fn service_type_record_id(&self) -> &source::record::Id {
167            &self.service_type_record_id
168        }
169
170        /// Returns this analytics value's projection version.
171        pub const fn projection_version(&self) -> &analytics::ProjectionVersion {
172            &self.projection_version
173        }
174
175        /// Returns this analytics value's data quality status.
176        pub const fn data_quality_status(&self) -> DataQualityStatus {
177            self.data_quality_status
178        }
179
180        /// Nonblocking source data-quality issues preserved on the projected stay fact.
181        ///
182        /// Workflow-blocking issues are returned as projection errors instead of producing a fact.
183        pub fn data_quality_issues(&self) -> &[data_quality::Issue] {
184            &self.data_quality_issues
185        }
186    }
187}
188
189/// Aggregated service-demand facts used to compare booked work against labor capacity.
190pub mod service_demand {
191    use serde::{Deserialize, Serialize};
192
193    use crate::{analytics, data_quality, operations, source};
194
195    #[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)]
196    /// Provider or source identifier retained as the stable join key.
197    pub struct Id(String);
198
199    impl Id {
200        /// Validates and creates the analytics value.
201        pub fn try_new(value: impl Into<String>) -> analytics::Result<Self> {
202            analytics::trimmed_non_empty(value, analytics::Error::EmptyServiceDemandFactId)
203                .map(Self)
204        }
205
206        /// Returns the provider or domain identifier as a string slice.
207        pub fn as_str(&self) -> &str {
208            &self.0
209        }
210    }
211
212    #[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)]
213    /// Nonzero count of work units for a service line on an operating day.
214    pub struct DemandUnits(u32);
215
216    impl DemandUnits {
217        /// Promotes boundary input into a validated analytics domain value.
218        pub const fn try_new(value: u32) -> analytics::Result<Self> {
219            if value == 0 {
220                return Err(analytics::Error::EmptyDemandUnits);
221            }
222            Ok(Self(value))
223        }
224
225        /// Exposes the validated scalar for serialization and adapter boundaries.
226        pub const fn get(self) -> u32 {
227            self.0
228        }
229    }
230
231    #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
232    /// Source-backed service-demand fact for labor planning and exception reporting.
233    pub struct Fact {
234        id: Id,
235        operating_day: operations::operating_day::Key,
236        demand_units: DemandUnits,
237        source_record_refs: Vec<source::RecordRef>,
238        projection_version: analytics::ProjectionVersion,
239        data_quality_status: DataQualityStatus,
240        data_quality_issues: Vec<data_quality::Issue>,
241    }
242
243    #[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
244    /// Domain vocabulary for data quality status decisions in analytics workflows.
245    pub enum DataQualityStatus {
246        /// Complete analytics metric or operational summary dimension.
247        Complete,
248        /// Manager review required analytics metric or operational summary dimension.
249        ManagerReviewRequired,
250    }
251
252    impl Fact {
253        /// Validates and creates the analytics value.
254        pub fn try_new(
255            id: Id,
256            operating_day: operations::operating_day::Key,
257            demand_units: DemandUnits,
258            source_record_refs: Vec<source::RecordRef>,
259            projection_version: analytics::ProjectionVersion,
260            data_quality_issues: Vec<data_quality::Issue>,
261        ) -> Result<Self> {
262            if source_record_refs.is_empty() {
263                return Err(Error::MissingSourceEvidence);
264            }
265            let data_quality_status = if data_quality_issues.is_empty() {
266                DataQualityStatus::Complete
267            } else {
268                DataQualityStatus::ManagerReviewRequired
269            };
270
271            Ok(Self {
272                id,
273                operating_day,
274                demand_units,
275                source_record_refs,
276                projection_version,
277                data_quality_status,
278                data_quality_issues,
279            })
280        }
281
282        /// Returns this analytics value's id.
283        pub const fn id(&self) -> &Id {
284            &self.id
285        }
286
287        /// Returns this analytics value's operating day.
288        pub const fn operating_day(&self) -> &operations::operating_day::Key {
289            &self.operating_day
290        }
291
292        /// Returns this analytics value's demand units.
293        pub const fn demand_units(&self) -> DemandUnits {
294            self.demand_units
295        }
296
297        /// Returns the source record refs for this analytics value.
298        pub fn source_record_refs(&self) -> &[source::RecordRef] {
299            &self.source_record_refs
300        }
301
302        /// Returns this analytics value's projection version.
303        pub const fn projection_version(&self) -> &analytics::ProjectionVersion {
304            &self.projection_version
305        }
306
307        /// Returns this analytics value's data quality status.
308        pub const fn data_quality_status(&self) -> DataQualityStatus {
309            self.data_quality_status
310        }
311
312        /// Returns the data quality issues for this analytics value.
313        pub fn data_quality_issues(&self) -> &[data_quality::Issue] {
314            &self.data_quality_issues
315        }
316    }
317
318    #[derive(Debug, Clone, Copy, PartialEq, Eq, thiserror::Error)]
319    /// Validation failures returned by analytics domain constructors.
320    pub enum Error {
321        #[error("service demand facts require source evidence")]
322        /// A demand metric was attempted without source records to prove the underlying work.
323        MissingSourceEvidence,
324    }
325
326    /// Result type returned by fallible analytics operations.
327    pub type Result<T> = std::result::Result<T, Error>;
328}
329
330#[derive(Debug, Clone, Copy, PartialEq, Eq, thiserror::Error)]
331/// Validation failures returned by analytics domain constructors.
332pub enum Error {
333    #[error("stay fact id must not be empty")]
334    /// Signals that stay fact id was blank or missing during analytics validation.
335    EmptyStayFactId,
336    #[error("service demand fact id must not be empty")]
337    /// Signals that service demand fact id was blank or missing during analytics validation.
338    EmptyServiceDemandFactId,
339    #[error("service demand units must be greater than zero")]
340    /// Signals that demand units was blank or missing during analytics validation.
341    EmptyDemandUnits,
342    #[error("projection version must not be empty")]
343    /// Signals that projection version was blank or missing during analytics validation.
344    EmptyProjectionVersion,
345}
346
347/// Result type returned by fallible analytics operations.
348pub type Result<T> = std::result::Result<T, Error>;
349
350fn trimmed_non_empty(value: impl Into<String>, empty_error: Error) -> Result<String> {
351    let value = value.into().trim().to_string();
352    if value.is_empty() {
353        return Err(empty_error);
354    }
355    Ok(value)
356}