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    /// 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}