Skip to main content

backbone_inventory/application/service/
inventory_read.rs

1//! Inventory read-models (hand-authored, user-owned) — the availability/stock-balance surface the
2//! brief names as consumed by selling + buying (`docs/erp/modules/backbone-inventory.md:11,51-52`).
3//!
4//! These project the `Bin` running balance into the DTOs a consumer actually needs:
5//!   - `AvailabilityView { item_id, warehouse_id, available_qty }` — `actual_qty − reserved_qty`,
6//!     the quantity a Sales Order may commit against.
7//!   - `StockBalance { item_id, warehouse_id, actual_qty, valuation_rate, stock_value }`.
8//! Re-exported from `crate::exports` as the stable public read surface.
9//!
10//! Tenancy (ADR-0029): the module is tenant-agnostic. Every read rides the ambient org scope
11//! the composing service set per request; the composing decorator owns isolation.
12
13use std::sync::Arc;
14
15use rust_decimal::Decimal;
16use serde::{Deserialize, Serialize};
17use sqlx::PgPool;
18use uuid::Uuid;
19
20use crate::infrastructure::persistence::{BinRepository, QuantRepository};
21
22/// The available-to-commit view over a bin: `available_qty = actual_qty − reserved_qty`.
23#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
24pub struct AvailabilityView {
25    pub item_id: Uuid,
26    pub warehouse_id: Uuid,
27    pub actual_qty: Decimal,
28    pub reserved_qty: Decimal,
29    pub available_qty: Decimal,
30}
31
32/// The quant-grain availability view at one LOCATION (the reservation triangle's READ arm, spec
33/// stock-business-logic §3 / stock.hook.yaml T2): `available_qty = quantity − reserved_quantity`
34/// summed over the location's quant rows — the authoritative pair, read off; never a second
35/// writer. `quant_count` is the dimension-tuple spread (item x lot x package x owner rows).
36#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
37pub struct QuantAvailability {
38    pub item_id: Uuid,
39    pub location_id: Uuid,
40    pub on_hand_qty: Decimal,
41    pub reserved_qty: Decimal,
42    pub available_qty: Decimal,
43    pub quant_count: i64,
44}
45
46/// The valuation view over a bin.
47#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
48pub struct StockBalance {
49    pub item_id: Uuid,
50    pub warehouse_id: Uuid,
51    pub actual_qty: Decimal,
52    pub valuation_rate: Decimal,
53    pub stock_value: Decimal,
54}
55
56/// Read-only queries over inventory balances (the consumer-facing read port).
57///
58/// This service only projects — the SQL lives in [`BinRepository`], per the module's 4-layer rule.
59#[derive(Clone)]
60pub struct InventoryReadService {
61    db_pool: PgPool,
62    bins: Arc<BinRepository>,
63    quants: Arc<QuantRepository>,
64}
65
66impl InventoryReadService {
67    pub fn new(db_pool: PgPool) -> Self {
68        Self {
69            bins: Arc::new(BinRepository::new(db_pool.clone())),
70            quants: Arc::new(QuantRepository::new(db_pool.clone())),
71            db_pool,
72        }
73    }
74
75    /// The database this call runs on: the composer's request pool when one
76    /// is bound (a tenant mount, or a relay consumer wrapped by the host),
77    /// else the composed pool (ADR-0029 pool law).
78    fn rpool(&self) -> PgPool {
79        crate::request_pool::current().unwrap_or_else(|| self.db_pool.clone())
80    }
81
82    /// Availability for one item in one warehouse. Returns a zeroed view (available 0) when no bin
83    /// exists yet — an un-received item is simply unavailable, not an error.
84    pub async fn availability(&self, item_id: Uuid, warehouse_id: Uuid) -> Result<AvailabilityView, sqlx::Error> {
85        let row = self.bins.fetch_availability(&self.rpool(), item_id, warehouse_id).await?;
86        let (actual, reserved) = match row {
87            Some(r) => (r.actual_qty, r.reserved_qty),
88            None => (Decimal::ZERO, Decimal::ZERO),
89        };
90        Ok(AvailabilityView { item_id, warehouse_id, actual_qty: actual, reserved_qty: reserved, available_qty: actual - reserved })
91    }
92
93    /// Availability for one item across every warehouse that holds a bin for it.
94    pub async fn availability_across_warehouses(&self, item_id: Uuid) -> Result<Vec<AvailabilityView>, sqlx::Error> {
95        let rows = self.bins.fetch_availability_across_warehouses(&self.rpool(), item_id).await?;
96        Ok(rows.into_iter().map(|r| {
97            let actual = r.actual_qty;
98            let reserved = r.reserved_qty;
99            AvailabilityView { item_id, warehouse_id: r.warehouse_id, actual_qty: actual, reserved_qty: reserved, available_qty: actual - reserved }
100        }).collect())
101    }
102
103    /// Valuation balance for one item in one warehouse (None if no bin exists).
104    pub async fn stock_balance(&self, item_id: Uuid, warehouse_id: Uuid) -> Result<Option<StockBalance>, sqlx::Error> {
105        let row = self.bins.fetch_balance(&self.rpool(), item_id, warehouse_id).await?;
106        Ok(row.map(|r| StockBalance {
107            item_id, warehouse_id,
108            actual_qty: r.actual_qty, valuation_rate: r.valuation_rate, stock_value: r.stock_value,
109        }))
110    }
111
112    /// Quant-grain availability at one location: `available = quantity − reserved` over the
113    /// location's quant rows (T2 — the READ arm of the reservation triangle; the warehouse-grain
114    /// [`Self::availability`] above projects the same invariant off the Bin balance). Zeroed view
115    /// when no quant exists — an unreceived item is unavailable, not an error.
116    pub async fn quant_availability(&self, item_id: Uuid, location_id: Uuid) -> Result<QuantAvailability, sqlx::Error> {
117        let row = self.quants.fetch_on_hand(&self.rpool(), item_id, location_id).await?;
118        Ok(QuantAvailability {
119            item_id,
120            location_id,
121            on_hand_qty: row.on_hand_qty,
122            reserved_qty: row.reserved_qty,
123            available_qty: row.on_hand_qty - row.reserved_qty,
124            quant_count: row.quant_count,
125        })
126    }
127}