backbone_inventory/application/service/availability_scope.rs
1//! The two-scope availability read (hand-authored, user-owned; see `metaphor.codegen.yaml`).
2//!
3//! The storefront-facing availability surface: ONE warehouse pivot, TWO read
4//! scopes, both computed FRESH per call off the reservation triangle
5//! (`stock_quants.quantity − reserved_quantity` summed over the pivot
6//! warehouse's internal locations). Nothing about a readiness check is ever
7//! persisted — no scope columns, no warning rows, no materialized verdicts
8//! (the upstream payment-readiness gate that persisted `shop_warning` writes
9//! during a validation probe is the anti-shape; a validation read that
10//! mutates rows is refused by construction here — these methods only SELECT).
11//!
12//! The two scopes, deliberately separate methods (upstream resolves them
13//! through deliberately different resolvers and reserves the checkout arm
14//! for a pickup-warehouse override):
15//! - **Display** — what shop grids and product pages show: fresh
16//! availability per item at the pivot warehouse.
17//! - **Checkout** — what the cart clamp and the payment gate consult: the
18//! same fresh availability MINUS the quantities the caller's own cart
19//! already holds, floored at zero. The caller supplies its held
20//! quantities; this module never reads (or knows about) carts.
21//!
22//! Both scopes are ADVISORY check-then-act reads: nothing is reserved, and
23//! enforcement remains where it already lives (the move engine's reservation
24//! and picking edges). Two concurrent carts can both pass — that is the
25//! recorded upstream posture, re-expressed deliberately rather than silently
26//! "fixed" by a half-reservation here.
27//!
28//! Fail-loud posture (the port pattern the composing service fills):
29//! - The warehouse pivot is a REQUIRED `Uuid`. There is no unset-pivot
30//! overload and no all-warehouses arm on this surface — a caller without
31//! a pivot must refuse on its own side; it can never ask this read to
32//! silently sum every warehouse.
33//! - A pivot warehouse that does not exist or is soft-deleted refuses
34//! typed (`UnknownWarehouse`) rather than projecting an all-sold-out
35//! storefront off a zero-row read.
36//! - A GROUPING warehouse node refuses typed as a pivot (`GroupPivot`) —
37//! pointing a shop at a group node is a misconfiguration that would
38//! otherwise read as permanently sold out.
39//! - An empty item set refuses typed (`EmptyItemSet`): a sold-out verdict
40//! over zero variants would be vacuously true.
41//! - The sold-out verdict evaluates EVERY item id the caller passes. The
42//! caller resolves the variant set (the template's variants or the single
43//! item) and passes all of them; this surface has no first-variant
44//! shortcut to get wrong — sold out means NO member of the set has
45//! availability.
46//!
47//! Tenancy (ADR-0029): the module is tenant-agnostic. Every read rides the
48//! ambient org scope the composing service set per request; the composing
49//! decorator owns isolation, and a pivot unknown under that scope refuses
50//! typed rather than projecting a silently-empty verdict.
51
52use rust_decimal::Decimal;
53use serde::{Deserialize, Serialize};
54use sqlx::PgPool;
55use uuid::Uuid;
56
57use crate::infrastructure::persistence::{QuantRepository, WarehouseRepository};
58
59/// One item's fresh availability at the pivot warehouse.
60#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
61pub struct ScopedAvailability {
62 pub item_id: Uuid,
63 /// SUM(quantity) over the pivot warehouse's internal quant rows.
64 pub on_hand_qty: Decimal,
65 /// SUM(reserved_quantity) over the same rows — the authoritative
66 /// reservation counter, read off.
67 pub reserved_qty: Decimal,
68 /// `on_hand_qty − reserved_qty`, computed per call. Never read off the
69 /// stored `available_quantity` column — freshness over cheapness.
70 pub available_qty: Decimal,
71 /// The checkout-intent ledger's held sum at this warehouse (active
72 /// holds from the reservation surface), already subtracted from
73 /// `available_qty` — surfaced so a screen can say "2 free, 1 held by
74 /// other carts" instead of an unexplained number.
75 pub externally_held_qty: Decimal,
76}
77
78/// One checkout-scope demand: the item plus the quantity the CALLER's own
79/// cart already holds for it. The caller sums its own lines per item before
80/// passing them here; this module never reads carts.
81#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
82pub struct CheckoutDemand {
83 pub item_id: Uuid,
84 pub held_qty: Decimal,
85}
86
87/// One checkout-scope answer: fresh availability at the pivot warehouse and
88/// the free quantity after the caller's own holdings, floored at zero.
89#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
90pub struct CheckoutFreeQty {
91 pub item_id: Uuid,
92 pub available_qty: Decimal,
93 /// `max(available_qty − held_qty, 0)` — the cart clamp's input. Never
94 /// negative.
95 pub free_qty: Decimal,
96}
97
98/// The sold-out verdict over a WHOLE item set (a template's full variant set,
99/// or a single item). `sold_out` is true only when EVERY member reads
100/// availability ≤ 0 — an absent (never-received) item counts as unavailable.
101#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
102pub struct SoldOutVerdict {
103 pub sold_out: bool,
104 /// Complete over the requested set, in input order, zeroed for items
105 /// with no quants at the pivot warehouse.
106 pub per_item: Vec<ScopedAvailability>,
107}
108
109/// The availability scope read's typed refusals.
110#[derive(Debug, thiserror::Error)]
111pub enum AvailabilityScopeError {
112 /// The pivot warehouse does not exist or is soft-deleted under the
113 /// ambient org scope. Carries the warehouse id.
114 #[error("availability pivot warehouse {0} is unknown")]
115 UnknownWarehouse(Uuid),
116 /// The pivot warehouse is a grouping node, not a concrete stock
117 /// warehouse. Carries the warehouse id.
118 #[error("availability pivot warehouse {0} is a warehouse group, not a stock warehouse")]
119 GroupPivot(Uuid),
120 /// The item set (or demand list) is empty — a vacuous read that would
121 /// answer "sold out" over nothing.
122 #[error("the availability item set is empty: a sold-out verdict over no items would be vacuously true")]
123 EmptyItemSet,
124 /// The same item appears twice in one request. The caller must sum its
125 /// own per-item quantities before asking.
126 #[error("item {0} appears more than once in one availability request")]
127 DuplicateItem(Uuid),
128 /// A demand carries a negative held quantity.
129 #[error("held quantity for item {0} is negative")]
130 NegativeHeld(Uuid),
131 /// The port has no adapter wired in this composition — the fail-loud
132 /// default. No availability number is fabricated, so no surface can
133 /// render a guessed stock state.
134 #[error("availability scope port unwired: {0}")]
135 Unwired(&'static str),
136 /// The database transport.
137 #[error(transparent)]
138 Database(#[from] sqlx::Error),
139}
140
141/// The two-scope availability read a composing service (the storefront, a
142/// feed renderer, a pickup flow) consults. Every method is a pure read over
143/// the stock estate; nothing is reserved and nothing is persisted.
144#[async_trait::async_trait]
145pub trait AvailabilityScopePort: Send + Sync {
146 /// Display scope: fresh per-item availability at the pivot warehouse,
147 /// complete over `item_ids` in input order (absent items zeroed — an
148 /// unreceived item is unavailable, not an error).
149 async fn display_availability(
150 &self,
151 item_ids: &[Uuid],
152 warehouse_id: Uuid,
153 ) -> Result<Vec<ScopedAvailability>, AvailabilityScopeError>;
154
155 /// Checkout scope: fresh per-item availability at the (possibly
156 /// pickup-overridden) pivot warehouse minus the caller's own held
157 /// quantities, floored at zero. Complete over the demand list's items.
158 async fn checkout_free_qty(
159 &self,
160 demands: &[CheckoutDemand],
161 warehouse_id: Uuid,
162 ) -> Result<Vec<CheckoutFreeQty>, AvailabilityScopeError>;
163
164 /// Sold-out verdict over the WHOLE item set (WSC's first-variant-only
165 /// template check is the bug class this refuses to re-express):
166 /// `sold_out` = no member has availability. Policy (may an out-of-stock
167 /// item still be ordered?) stays with the caller — this is raw stock
168 /// truth.
169 async fn sold_out(
170 &self,
171 item_ids: &[Uuid],
172 warehouse_id: Uuid,
173 ) -> Result<SoldOutVerdict, AvailabilityScopeError>;
174}
175
176/// The refusing default: no availability read ever succeeds. Fail-loud — an
177/// unwired composition surfaces the typed `Unwired` refusal on every scope,
178/// never a guessed zero (which would render the whole storefront sold out)
179/// and never a guessed abundance (which would oversell).
180#[derive(Debug, Default, Clone, Copy)]
181pub struct RefusingAvailabilityScopePort;
182
183impl RefusingAvailabilityScopePort {
184 fn refused() -> AvailabilityScopeError {
185 AvailabilityScopeError::Unwired(
186 "no availability scope adapter is installed in this composition",
187 )
188 }
189}
190
191#[async_trait::async_trait]
192impl AvailabilityScopePort for RefusingAvailabilityScopePort {
193 async fn display_availability(
194 &self,
195 _item_ids: &[Uuid],
196 _warehouse_id: Uuid,
197 ) -> Result<Vec<ScopedAvailability>, AvailabilityScopeError> {
198 Err(Self::refused())
199 }
200
201 async fn checkout_free_qty(
202 &self,
203 _demands: &[CheckoutDemand],
204 _warehouse_id: Uuid,
205 ) -> Result<Vec<CheckoutFreeQty>, AvailabilityScopeError> {
206 Err(Self::refused())
207 }
208
209 async fn sold_out(
210 &self,
211 _item_ids: &[Uuid],
212 _warehouse_id: Uuid,
213 ) -> Result<SoldOutVerdict, AvailabilityScopeError> {
214 Err(Self::refused())
215 }
216}
217
218/// The real, database-backed availability scope read. Implements
219/// [`AvailabilityScopePort`] so a composition can hold the trait and swap
220/// adapters (the refusing default in tests, this service in production).
221#[derive(Clone)]
222pub struct AvailabilityScopeRead {
223 db_pool: PgPool,
224 quants: std::sync::Arc<QuantRepository>,
225 warehouses: std::sync::Arc<WarehouseRepository>,
226}
227
228impl AvailabilityScopeRead {
229 pub fn new(db_pool: PgPool) -> Self {
230 Self {
231 quants: std::sync::Arc::new(QuantRepository::new(db_pool.clone())),
232 warehouses: std::sync::Arc::new(WarehouseRepository::new(db_pool.clone())),
233 db_pool,
234 }
235 }
236
237 /// Fail-loud pivot validation: the warehouse must exist, be live, and be
238 /// a concrete stock warehouse.
239 async fn validate_pivot(&self, warehouse_id: Uuid) -> Result<(), AvailabilityScopeError> {
240 match self.warehouses.fetch_pivot_warehouse(&self.db_pool, warehouse_id).await? {
241 None => Err(AvailabilityScopeError::UnknownWarehouse(warehouse_id)),
242 Some(true) => Err(AvailabilityScopeError::GroupPivot(warehouse_id)),
243 Some(false) => Ok(()),
244 }
245 }
246
247 /// The shared fresh aggregation: per-item on-hand/reserved sums over the
248 /// pivot warehouse's internal quant rows, zero-filled over the requested
249 /// set in input order. `item_ids` must be non-empty and duplicate-free
250 /// (the caller-side guards have already refused otherwise).
251 async fn scoped_rows(
252 &self,
253 item_ids: &[Uuid],
254 warehouse_id: Uuid,
255 ) -> Result<Vec<ScopedAvailability>, AvailabilityScopeError> {
256 let rows = self
257 .quants
258 .fetch_warehouse_on_hand(&self.db_pool, item_ids, warehouse_id)
259 .await?;
260 let by_item: std::collections::HashMap<Uuid, (Decimal, Decimal)> = rows
261 .into_iter()
262 .map(|r| (r.item_id, (r.on_hand_qty, r.reserved_qty)))
263 .collect();
264 // The reservation ledger's active holds subtract at the warehouse
265 // grain — the intent surface this read backs. Unexpired held lines
266 // only; swept ones have already given their units back.
267 let mut held: std::collections::HashMap<Uuid, Decimal> =
268 std::collections::HashMap::new();
269 {
270 let mut conn = self.db_pool.acquire().await?;
271 let ledger: Vec<(Uuid, Decimal)> = sqlx::query_as(
272 r#"SELECT item_id, COALESCE(SUM(qty), 0)
273 FROM inventory.stock_reservations
274 WHERE warehouse_id = $1 AND status = 'held'
275 AND expires_at > now()
276 GROUP BY item_id"#,
277 )
278 .bind(warehouse_id)
279 .fetch_all(&mut *conn)
280 .await?;
281 for (item, qty) in ledger {
282 held.insert(item, qty);
283 }
284 }
285 Ok(item_ids
286 .iter()
287 .map(|id| {
288 let (on_hand, reserved) =
289 by_item.get(id).copied().unwrap_or((Decimal::ZERO, Decimal::ZERO));
290 let externally_held = held.get(id).copied().unwrap_or(Decimal::ZERO);
291 ScopedAvailability {
292 item_id: *id,
293 on_hand_qty: on_hand,
294 reserved_qty: reserved,
295 externally_held_qty: externally_held,
296 available_qty: on_hand - reserved - externally_held,
297 }
298 })
299 .collect())
300 }
301
302 /// Refuse an empty or duplicate item set (shared guard for both
303 /// item-list methods).
304 fn guard_item_set(item_ids: &[Uuid]) -> Result<(), AvailabilityScopeError> {
305 if item_ids.is_empty() {
306 return Err(AvailabilityScopeError::EmptyItemSet);
307 }
308 let mut seen = std::collections::HashSet::with_capacity(item_ids.len());
309 for id in item_ids {
310 if !seen.insert(*id) {
311 return Err(AvailabilityScopeError::DuplicateItem(*id));
312 }
313 }
314 Ok(())
315 }
316}
317
318#[async_trait::async_trait]
319impl AvailabilityScopePort for AvailabilityScopeRead {
320 async fn display_availability(
321 &self,
322 item_ids: &[Uuid],
323 warehouse_id: Uuid,
324 ) -> Result<Vec<ScopedAvailability>, AvailabilityScopeError> {
325 Self::guard_item_set(item_ids)?;
326 self.validate_pivot(warehouse_id).await?;
327 self.scoped_rows(item_ids, warehouse_id).await
328 }
329
330 async fn checkout_free_qty(
331 &self,
332 demands: &[CheckoutDemand],
333 warehouse_id: Uuid,
334 ) -> Result<Vec<CheckoutFreeQty>, AvailabilityScopeError> {
335 if demands.is_empty() {
336 return Err(AvailabilityScopeError::EmptyItemSet);
337 }
338 let item_ids: Vec<Uuid> = demands.iter().map(|d| d.item_id).collect();
339 Self::guard_item_set(&item_ids)?;
340 for d in demands {
341 if d.held_qty < Decimal::ZERO {
342 return Err(AvailabilityScopeError::NegativeHeld(d.item_id));
343 }
344 }
345 self.validate_pivot(warehouse_id).await?;
346 let rows = self.scoped_rows(&item_ids, warehouse_id).await?;
347 Ok(rows
348 .into_iter()
349 .zip(demands.iter())
350 .map(|(row, demand)| CheckoutFreeQty {
351 item_id: row.item_id,
352 available_qty: row.available_qty,
353 free_qty: (row.available_qty - demand.held_qty).max(Decimal::ZERO),
354 })
355 .collect())
356 }
357
358 async fn sold_out(
359 &self,
360 item_ids: &[Uuid],
361 warehouse_id: Uuid,
362 ) -> Result<SoldOutVerdict, AvailabilityScopeError> {
363 Self::guard_item_set(item_ids)?;
364 self.validate_pivot(warehouse_id).await?;
365 let per_item = self.scoped_rows(item_ids, warehouse_id).await?;
366 let sold_out = per_item.iter().all(|row| row.available_qty <= Decimal::ZERO);
367 Ok(SoldOutVerdict { sold_out, per_item })
368 }
369}
370
371#[cfg(test)]
372mod tests {
373 use super::*;
374
375 #[tokio::test]
376 async fn unwired_port_refuses_every_scope() {
377 let port = RefusingAvailabilityScopePort;
378 let id = Uuid::new_v4();
379 assert!(matches!(
380 port.display_availability(&[id], Uuid::new_v4()).await,
381 Err(AvailabilityScopeError::Unwired(_))
382 ));
383 assert!(matches!(
384 port.checkout_free_qty(
385 &[CheckoutDemand { item_id: id, held_qty: Decimal::ZERO }],
386 Uuid::new_v4()
387 )
388 .await,
389 Err(AvailabilityScopeError::Unwired(_))
390 ));
391 assert!(matches!(
392 port.sold_out(&[id], Uuid::new_v4()).await,
393 Err(AvailabilityScopeError::Unwired(_))
394 ));
395 }
396}