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 /// The database this call runs on: the composer's request pool when one
238 /// is bound (a tenant mount, or a relay consumer wrapped by the host),
239 /// else the composed pool (ADR-0029 pool law).
240 fn rpool(&self) -> PgPool {
241 crate::request_pool::current().unwrap_or_else(|| self.db_pool.clone())
242 }
243
244 /// Fail-loud pivot validation: the warehouse must exist, be live, and be
245 /// a concrete stock warehouse.
246 async fn validate_pivot(&self, warehouse_id: Uuid) -> Result<(), AvailabilityScopeError> {
247 match self.warehouses.fetch_pivot_warehouse(&self.rpool(), warehouse_id).await? {
248 None => Err(AvailabilityScopeError::UnknownWarehouse(warehouse_id)),
249 Some(true) => Err(AvailabilityScopeError::GroupPivot(warehouse_id)),
250 Some(false) => Ok(()),
251 }
252 }
253
254 /// The shared fresh aggregation: per-item on-hand/reserved sums over the
255 /// pivot warehouse's internal quant rows, zero-filled over the requested
256 /// set in input order. `item_ids` must be non-empty and duplicate-free
257 /// (the caller-side guards have already refused otherwise).
258 async fn scoped_rows(
259 &self,
260 item_ids: &[Uuid],
261 warehouse_id: Uuid,
262 ) -> Result<Vec<ScopedAvailability>, AvailabilityScopeError> {
263 let rows = self
264 .quants
265 .fetch_warehouse_on_hand(&self.rpool(), item_ids, warehouse_id)
266 .await?;
267 let by_item: std::collections::HashMap<Uuid, (Decimal, Decimal)> = rows
268 .into_iter()
269 .map(|r| (r.item_id, (r.on_hand_qty, r.reserved_qty)))
270 .collect();
271 // The reservation ledger's active holds subtract at the warehouse
272 // grain — the intent surface this read backs. Unexpired held lines
273 // only; swept ones have already given their units back.
274 let mut held: std::collections::HashMap<Uuid, Decimal> =
275 std::collections::HashMap::new();
276 {
277 let mut conn = self.rpool().acquire().await?;
278 let ledger: Vec<(Uuid, Decimal)> = sqlx::query_as(
279 r#"SELECT item_id, COALESCE(SUM(qty), 0)
280 FROM inventory.stock_reservations
281 WHERE warehouse_id = $1 AND status = 'held'
282 AND expires_at > now()
283 GROUP BY item_id"#,
284 )
285 .bind(warehouse_id)
286 .fetch_all(&mut *conn)
287 .await?;
288 for (item, qty) in ledger {
289 held.insert(item, qty);
290 }
291 }
292 Ok(item_ids
293 .iter()
294 .map(|id| {
295 let (on_hand, reserved) =
296 by_item.get(id).copied().unwrap_or((Decimal::ZERO, Decimal::ZERO));
297 let externally_held = held.get(id).copied().unwrap_or(Decimal::ZERO);
298 ScopedAvailability {
299 item_id: *id,
300 on_hand_qty: on_hand,
301 reserved_qty: reserved,
302 externally_held_qty: externally_held,
303 available_qty: on_hand - reserved - externally_held,
304 }
305 })
306 .collect())
307 }
308
309 /// Refuse an empty or duplicate item set (shared guard for both
310 /// item-list methods).
311 fn guard_item_set(item_ids: &[Uuid]) -> Result<(), AvailabilityScopeError> {
312 if item_ids.is_empty() {
313 return Err(AvailabilityScopeError::EmptyItemSet);
314 }
315 let mut seen = std::collections::HashSet::with_capacity(item_ids.len());
316 for id in item_ids {
317 if !seen.insert(*id) {
318 return Err(AvailabilityScopeError::DuplicateItem(*id));
319 }
320 }
321 Ok(())
322 }
323}
324
325#[async_trait::async_trait]
326impl AvailabilityScopePort for AvailabilityScopeRead {
327 async fn display_availability(
328 &self,
329 item_ids: &[Uuid],
330 warehouse_id: Uuid,
331 ) -> Result<Vec<ScopedAvailability>, AvailabilityScopeError> {
332 Self::guard_item_set(item_ids)?;
333 self.validate_pivot(warehouse_id).await?;
334 self.scoped_rows(item_ids, warehouse_id).await
335 }
336
337 async fn checkout_free_qty(
338 &self,
339 demands: &[CheckoutDemand],
340 warehouse_id: Uuid,
341 ) -> Result<Vec<CheckoutFreeQty>, AvailabilityScopeError> {
342 if demands.is_empty() {
343 return Err(AvailabilityScopeError::EmptyItemSet);
344 }
345 let item_ids: Vec<Uuid> = demands.iter().map(|d| d.item_id).collect();
346 Self::guard_item_set(&item_ids)?;
347 for d in demands {
348 if d.held_qty < Decimal::ZERO {
349 return Err(AvailabilityScopeError::NegativeHeld(d.item_id));
350 }
351 }
352 self.validate_pivot(warehouse_id).await?;
353 let rows = self.scoped_rows(&item_ids, warehouse_id).await?;
354 Ok(rows
355 .into_iter()
356 .zip(demands.iter())
357 .map(|(row, demand)| CheckoutFreeQty {
358 item_id: row.item_id,
359 available_qty: row.available_qty,
360 free_qty: (row.available_qty - demand.held_qty).max(Decimal::ZERO),
361 })
362 .collect())
363 }
364
365 async fn sold_out(
366 &self,
367 item_ids: &[Uuid],
368 warehouse_id: Uuid,
369 ) -> Result<SoldOutVerdict, AvailabilityScopeError> {
370 Self::guard_item_set(item_ids)?;
371 self.validate_pivot(warehouse_id).await?;
372 let per_item = self.scoped_rows(item_ids, warehouse_id).await?;
373 let sold_out = per_item.iter().all(|row| row.available_qty <= Decimal::ZERO);
374 Ok(SoldOutVerdict { sold_out, per_item })
375 }
376}
377
378#[cfg(test)]
379mod tests {
380 use super::*;
381
382 #[tokio::test]
383 async fn unwired_port_refuses_every_scope() {
384 let port = RefusingAvailabilityScopePort;
385 let id = Uuid::new_v4();
386 assert!(matches!(
387 port.display_availability(&[id], Uuid::new_v4()).await,
388 Err(AvailabilityScopeError::Unwired(_))
389 ));
390 assert!(matches!(
391 port.checkout_free_qty(
392 &[CheckoutDemand { item_id: id, held_qty: Decimal::ZERO }],
393 Uuid::new_v4()
394 )
395 .await,
396 Err(AvailabilityScopeError::Unwired(_))
397 ));
398 assert!(matches!(
399 port.sold_out(&[id], Uuid::new_v4()).await,
400 Err(AvailabilityScopeError::Unwired(_))
401 ));
402 }
403}