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