use rust_decimal::Decimal;
use serde::{Deserialize, Serialize};
use sqlx::PgPool;
use uuid::Uuid;
use crate::infrastructure::persistence::{QuantRepository, WarehouseRepository};
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
pub struct ScopedAvailability {
pub item_id: Uuid,
pub on_hand_qty: Decimal,
pub reserved_qty: Decimal,
pub available_qty: Decimal,
pub externally_held_qty: Decimal,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
pub struct CheckoutDemand {
pub item_id: Uuid,
pub held_qty: Decimal,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
pub struct CheckoutFreeQty {
pub item_id: Uuid,
pub available_qty: Decimal,
pub free_qty: Decimal,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
pub struct SoldOutVerdict {
pub sold_out: bool,
pub per_item: Vec<ScopedAvailability>,
}
#[derive(Debug, thiserror::Error)]
pub enum AvailabilityScopeError {
#[error("availability pivot warehouse {0} is unknown")]
UnknownWarehouse(Uuid),
#[error("availability pivot warehouse {0} is a warehouse group, not a stock warehouse")]
GroupPivot(Uuid),
#[error("the availability item set is empty: a sold-out verdict over no items would be vacuously true")]
EmptyItemSet,
#[error("item {0} appears more than once in one availability request")]
DuplicateItem(Uuid),
#[error("held quantity for item {0} is negative")]
NegativeHeld(Uuid),
#[error("availability scope port unwired: {0}")]
Unwired(&'static str),
#[error(transparent)]
Database(#[from] sqlx::Error),
}
#[async_trait::async_trait]
pub trait AvailabilityScopePort: Send + Sync {
async fn display_availability(
&self,
item_ids: &[Uuid],
warehouse_id: Uuid,
) -> Result<Vec<ScopedAvailability>, AvailabilityScopeError>;
async fn checkout_free_qty(
&self,
demands: &[CheckoutDemand],
warehouse_id: Uuid,
) -> Result<Vec<CheckoutFreeQty>, AvailabilityScopeError>;
async fn sold_out(
&self,
item_ids: &[Uuid],
warehouse_id: Uuid,
) -> Result<SoldOutVerdict, AvailabilityScopeError>;
}
#[derive(Debug, Default, Clone, Copy)]
pub struct RefusingAvailabilityScopePort;
impl RefusingAvailabilityScopePort {
fn refused() -> AvailabilityScopeError {
AvailabilityScopeError::Unwired(
"no availability scope adapter is installed in this composition",
)
}
}
#[async_trait::async_trait]
impl AvailabilityScopePort for RefusingAvailabilityScopePort {
async fn display_availability(
&self,
_item_ids: &[Uuid],
_warehouse_id: Uuid,
) -> Result<Vec<ScopedAvailability>, AvailabilityScopeError> {
Err(Self::refused())
}
async fn checkout_free_qty(
&self,
_demands: &[CheckoutDemand],
_warehouse_id: Uuid,
) -> Result<Vec<CheckoutFreeQty>, AvailabilityScopeError> {
Err(Self::refused())
}
async fn sold_out(
&self,
_item_ids: &[Uuid],
_warehouse_id: Uuid,
) -> Result<SoldOutVerdict, AvailabilityScopeError> {
Err(Self::refused())
}
}
#[derive(Clone)]
pub struct AvailabilityScopeRead {
db_pool: PgPool,
quants: std::sync::Arc<QuantRepository>,
warehouses: std::sync::Arc<WarehouseRepository>,
}
impl AvailabilityScopeRead {
pub fn new(db_pool: PgPool) -> Self {
Self {
quants: std::sync::Arc::new(QuantRepository::new(db_pool.clone())),
warehouses: std::sync::Arc::new(WarehouseRepository::new(db_pool.clone())),
db_pool,
}
}
fn rpool(&self) -> PgPool {
crate::request_pool::current().unwrap_or_else(|| self.db_pool.clone())
}
async fn validate_pivot(&self, warehouse_id: Uuid) -> Result<(), AvailabilityScopeError> {
match self.warehouses.fetch_pivot_warehouse(&self.rpool(), warehouse_id).await? {
None => Err(AvailabilityScopeError::UnknownWarehouse(warehouse_id)),
Some(true) => Err(AvailabilityScopeError::GroupPivot(warehouse_id)),
Some(false) => Ok(()),
}
}
async fn scoped_rows(
&self,
item_ids: &[Uuid],
warehouse_id: Uuid,
) -> Result<Vec<ScopedAvailability>, AvailabilityScopeError> {
let rows = self
.quants
.fetch_warehouse_on_hand(&self.rpool(), item_ids, warehouse_id)
.await?;
let by_item: std::collections::HashMap<Uuid, (Decimal, Decimal)> = rows
.into_iter()
.map(|r| (r.item_id, (r.on_hand_qty, r.reserved_qty)))
.collect();
let mut held: std::collections::HashMap<Uuid, Decimal> =
std::collections::HashMap::new();
{
let mut conn = self.rpool().acquire().await?;
let ledger: Vec<(Uuid, Decimal)> = sqlx::query_as(
r#"SELECT item_id, COALESCE(SUM(qty), 0)
FROM inventory.stock_reservations
WHERE warehouse_id = $1 AND status = 'held'
AND expires_at > now()
GROUP BY item_id"#,
)
.bind(warehouse_id)
.fetch_all(&mut *conn)
.await?;
for (item, qty) in ledger {
held.insert(item, qty);
}
}
Ok(item_ids
.iter()
.map(|id| {
let (on_hand, reserved) =
by_item.get(id).copied().unwrap_or((Decimal::ZERO, Decimal::ZERO));
let externally_held = held.get(id).copied().unwrap_or(Decimal::ZERO);
ScopedAvailability {
item_id: *id,
on_hand_qty: on_hand,
reserved_qty: reserved,
externally_held_qty: externally_held,
available_qty: on_hand - reserved - externally_held,
}
})
.collect())
}
fn guard_item_set(item_ids: &[Uuid]) -> Result<(), AvailabilityScopeError> {
if item_ids.is_empty() {
return Err(AvailabilityScopeError::EmptyItemSet);
}
let mut seen = std::collections::HashSet::with_capacity(item_ids.len());
for id in item_ids {
if !seen.insert(*id) {
return Err(AvailabilityScopeError::DuplicateItem(*id));
}
}
Ok(())
}
}
#[async_trait::async_trait]
impl AvailabilityScopePort for AvailabilityScopeRead {
async fn display_availability(
&self,
item_ids: &[Uuid],
warehouse_id: Uuid,
) -> Result<Vec<ScopedAvailability>, AvailabilityScopeError> {
Self::guard_item_set(item_ids)?;
self.validate_pivot(warehouse_id).await?;
self.scoped_rows(item_ids, warehouse_id).await
}
async fn checkout_free_qty(
&self,
demands: &[CheckoutDemand],
warehouse_id: Uuid,
) -> Result<Vec<CheckoutFreeQty>, AvailabilityScopeError> {
if demands.is_empty() {
return Err(AvailabilityScopeError::EmptyItemSet);
}
let item_ids: Vec<Uuid> = demands.iter().map(|d| d.item_id).collect();
Self::guard_item_set(&item_ids)?;
for d in demands {
if d.held_qty < Decimal::ZERO {
return Err(AvailabilityScopeError::NegativeHeld(d.item_id));
}
}
self.validate_pivot(warehouse_id).await?;
let rows = self.scoped_rows(&item_ids, warehouse_id).await?;
Ok(rows
.into_iter()
.zip(demands.iter())
.map(|(row, demand)| CheckoutFreeQty {
item_id: row.item_id,
available_qty: row.available_qty,
free_qty: (row.available_qty - demand.held_qty).max(Decimal::ZERO),
})
.collect())
}
async fn sold_out(
&self,
item_ids: &[Uuid],
warehouse_id: Uuid,
) -> Result<SoldOutVerdict, AvailabilityScopeError> {
Self::guard_item_set(item_ids)?;
self.validate_pivot(warehouse_id).await?;
let per_item = self.scoped_rows(item_ids, warehouse_id).await?;
let sold_out = per_item.iter().all(|row| row.available_qty <= Decimal::ZERO);
Ok(SoldOutVerdict { sold_out, per_item })
}
}
#[cfg(test)]
mod tests {
use super::*;
#[tokio::test]
async fn unwired_port_refuses_every_scope() {
let port = RefusingAvailabilityScopePort;
let id = Uuid::new_v4();
assert!(matches!(
port.display_availability(&[id], Uuid::new_v4()).await,
Err(AvailabilityScopeError::Unwired(_))
));
assert!(matches!(
port.checkout_free_qty(
&[CheckoutDemand { item_id: id, held_qty: Decimal::ZERO }],
Uuid::new_v4()
)
.await,
Err(AvailabilityScopeError::Unwired(_))
));
assert!(matches!(
port.sold_out(&[id], Uuid::new_v4()).await,
Err(AvailabilityScopeError::Unwired(_))
));
}
}