domain/boarding/capacity.rs
1//! Boarding capacity decisions for room/suite availability.
2//!
3//! Capacity examples use semantic accommodation paths so a labor-saving agent can explain whether
4//! the front desk should confirm, waitlist, or route an exception for manager review:
5//!
6//! ```
7//! use domain::{boarding, entities};
8//! use uuid::Uuid;
9//!
10//! let luxury_suite = boarding::capacity::SegmentCounts::builder()
11//! .accommodation(boarding::accommodation::Kind::LuxuryDogSuite)
12//! .total(boarding::capacity::RoomCount::try_new(10).unwrap())
13//! .occupied(boarding::capacity::RoomCount::try_new(10).unwrap())
14//! .build();
15//! let snapshot = boarding::capacity::Snapshot::new(vec![
16//! boarding::capacity::NightlySegmentSnapshot::from_counts(luxury_suite),
17//! ])
18//! .unwrap();
19//! let request = boarding::capacity::Request::new(
20//! entities::LocationId(Uuid::nil()),
21//! entities::Species::Dog,
22//! boarding::accommodation::Preference::Specific(boarding::accommodation::Kind::LuxuryDogSuite),
23//! );
24//!
25//! assert_eq!(
26//! boarding::capacity::Policy.evaluate(&request, &snapshot),
27//! boarding::capacity::Decision::Waitlist {
28//! reason: boarding::capacity::WaitlistReason::EligibleSegmentFull,
29//! },
30//! );
31//! ```
32
33use super::*;
34use crate::policy;
35use bon::Builder;
36
37#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)]
38/// Non-negative count of rooms in a boarding accommodation segment.
39pub struct RoomCount(u16);
40
41impl RoomCount {
42 /// Promotes a source-system room count into the boarding capacity domain.
43 pub const fn try_new(value: u16) -> std::result::Result<Self, RoomCountError> {
44 Ok(Self(value))
45 }
46
47 /// Returns the raw room count for source adapters, reports, and serialization.
48 pub const fn get(self) -> u16 {
49 self.0
50 }
51}
52
53#[derive(Debug, Clone, Copy, PartialEq, Eq, thiserror::Error)]
54/// Validation errors for room-count promotion from boundary data.
55pub enum RoomCountError {}
56
57#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Builder)]
58/// Builder-facing source counts for one accommodation segment on a boarding night.
59pub struct SegmentCounts {
60 /// Accommodation segment these counts describe.
61 pub accommodation: accommodation::Kind,
62 total: RoomCount,
63 occupied: RoomCount,
64}
65
66#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
67/// Immutable nightly capacity snapshot for one accommodation segment.
68pub struct NightlySegmentSnapshot {
69 /// Accommodation segment these counts describe.
70 pub accommodation: accommodation::Kind,
71 total: RoomCount,
72 occupied: RoomCount,
73}
74
75impl NightlySegmentSnapshot {
76 /// Freezes builder-provided segment counts into a nightly snapshot used by capacity policy.
77 pub const fn from_counts(counts: SegmentCounts) -> Self {
78 Self {
79 accommodation: counts.accommodation,
80 total: counts.total,
81 occupied: counts.occupied,
82 }
83 }
84
85 /// Returns total rooms known for this accommodation segment.
86 pub const fn total(&self) -> RoomCount {
87 self.total
88 }
89
90 /// Returns occupied rooms already committed for this accommodation segment.
91 pub const fn occupied(&self) -> RoomCount {
92 self.occupied
93 }
94
95 /// Returns remaining rooms after committed occupancy, saturating at zero for dirty data.
96 pub const fn available_rooms(&self) -> RoomCount {
97 RoomCount(self.total.get().saturating_sub(self.occupied.get()))
98 }
99}
100
101#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
102/// Point-in-time boarding inventory evidence used to make confirm/waitlist/deny decisions.
103pub struct Snapshot {
104 segments: Vec<NightlySegmentSnapshot>,
105}
106
107impl Snapshot {
108 /// Creates a capacity snapshot from one or more nightly accommodation segments.
109 pub fn new(segments: Vec<NightlySegmentSnapshot>) -> std::result::Result<Self, SnapshotError> {
110 if segments.is_empty() {
111 return Err(SnapshotError::EmptyInventory);
112 }
113 Ok(Self { segments })
114 }
115
116 /// Returns the source-derived accommodation segments considered by capacity policy.
117 pub fn segments(&self) -> &[NightlySegmentSnapshot] {
118 &self.segments
119 }
120}
121
122#[derive(Debug, Clone, Copy, PartialEq, Eq, thiserror::Error)]
123/// Snapshot validation errors that prevent safe capacity automation.
124pub enum SnapshotError {
125 #[error("boarding capacity snapshot requires at least one accommodation segment")]
126 /// No inventory segments were available, so automation must not infer availability.
127 EmptyInventory,
128}
129
130#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
131/// Boarding capacity request for a location, species, and accommodation preference.
132pub struct Request {
133 /// Resort location whose room inventory is the authority for this check.
134 pub location_id: LocationId,
135 /// Pet species used to reject incompatible room types before availability is promised.
136 pub species: crate::entities::Species,
137 /// Accommodation preference requested by the guest or staff workflow.
138 pub accommodation: accommodation::Preference,
139}
140
141impl Request {
142 /// Creates a capacity request from already-identified location, species, and preference values.
143 pub const fn new(
144 location_id: LocationId,
145 species: crate::entities::Species,
146 accommodation: accommodation::Preference,
147 ) -> Self {
148 Self {
149 location_id,
150 species,
151 accommodation,
152 }
153 }
154}
155
156#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
157/// Capacity outcome an agent may present to staff when handling a boarding request.
158pub enum Decision {
159 /// A compatible accommodation segment has at least one available room.
160 Available {
161 /// Accommodation that can be offered from the available source inventory.
162 accommodation: accommodation::Kind,
163 },
164 /// Compatible accommodation exists but is currently full, so staff should route to waitlist.
165 Waitlist {
166 /// Source-grounded reason for the waitlist or denial outcome.
167 reason: WaitlistReason,
168 },
169 /// The request cannot be confirmed from the supplied source evidence and requires a review gate.
170 Deny {
171 /// Source-grounded reason for the waitlist or denial outcome.
172 reason: DenialReason,
173 /// Human approval gate required before overriding the denied capacity decision.
174 review_gate: policy::ReviewGate,
175 },
176}
177
178impl Decision {
179 /// Returns the human review gate required before staff override a denied capacity decision.
180 pub fn required_review_gate(&self) -> Option<policy::ReviewGate> {
181 match self {
182 Self::Deny { review_gate, .. } => Some(review_gate.clone()),
183 Self::Available { .. } | Self::Waitlist { .. } => None,
184 }
185 }
186}
187
188#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
189/// Reasons boarding capacity policy must deny confirmation from available evidence.
190pub enum DenialReason {
191 /// Requested accommodation type does not support the pet species.
192 SpeciesAccommodationMismatch,
193 /// No source inventory segment matches the requested compatible accommodation kinds.
194 NoEligibleSegment,
195 /// Local policy data required for the capacity check was unavailable.
196 PolicyUnavailable,
197}
198
199#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
200/// Reasons a boarding request should be waitlisted instead of confirmed.
201pub enum WaitlistReason {
202 /// The room type is valid for the pet, but all matching rooms are occupied.
203 EligibleSegmentFull,
204}
205
206#[derive(Debug, Clone, Default)]
207/// Deterministic boarding capacity policy that does not invent inventory.
208pub struct Policy;
209
210impl Policy {
211 /// Evaluates a boarding request against source-derived inventory and returns confirm, waitlist, or denial evidence.
212 pub fn evaluate(&self, request: &Request, snapshot: &Snapshot) -> Decision {
213 let mut compatible_but_full = false;
214
215 for wanted in request.accommodation.acceptable_kinds() {
216 if !wanted.supports_species(&request.species) {
217 return Decision::Deny {
218 reason: DenialReason::SpeciesAccommodationMismatch,
219 review_gate: policy::ReviewGate::ManagerApproval,
220 };
221 }
222
223 for segment in snapshot.segments() {
224 if segment.accommodation == *wanted {
225 if segment.available_rooms().get() > 0 {
226 return Decision::Available {
227 accommodation: *wanted,
228 };
229 }
230 compatible_but_full = true;
231 }
232 }
233 }
234
235 if compatible_but_full {
236 Decision::Waitlist {
237 reason: WaitlistReason::EligibleSegmentFull,
238 }
239 } else {
240 Decision::Deny {
241 reason: DenialReason::NoEligibleSegment,
242 review_gate: policy::ReviewGate::ManagerApproval,
243 }
244 }
245 }
246}