Skip to main content

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}