use super::{block_on, map_db_error};
use chrono::{DateTime, Utc};
use rust_decimal::Decimal;
use sqlx::postgres::PgPool;
use sqlx::{FromRow, Postgres, QueryBuilder};
use stateset_core::{
CaptureStockSnapshot, CommerceError, Result, StockSnapshot, StockSnapshotFilter,
StockSnapshotId, StockSnapshotLine, StockSnapshotLineId, StockSnapshotRepository,
};
#[derive(Debug, Clone)]
pub struct PgStockSnapshotRepository {
pool: PgPool,
}
#[derive(FromRow)]
struct SnapshotRow {
id: StockSnapshotId,
label: Option<String>,
total_skus: i64,
total_units: Decimal,
captured_at: DateTime<Utc>,
}
#[derive(FromRow)]
struct LineRow {
id: StockSnapshotLineId,
stock_snapshot_id: StockSnapshotId,
product_id: uuid::Uuid,
sku: String,
quantity_on_hand: Decimal,
quantity_available: Decimal,
location: Option<String>,
}
impl PgStockSnapshotRepository {
#[must_use]
pub const fn new(pool: PgPool) -> Self {
Self { pool }
}
fn row_to_head(row: SnapshotRow) -> StockSnapshot {
StockSnapshot {
id: row.id,
label: row.label,
total_skus: row.total_skus.max(0) as u64,
total_units: row.total_units,
lines: Vec::new(),
captured_at: row.captured_at,
}
}
fn row_to_line(row: LineRow) -> StockSnapshotLine {
StockSnapshotLine {
id: row.id,
stock_snapshot_id: row.stock_snapshot_id,
product_id: row.product_id.into(),
sku: row.sku,
quantity_on_hand: row.quantity_on_hand,
quantity_available: row.quantity_available,
location: row.location,
}
}
async fn load_lines(&self, id: StockSnapshotId) -> Result<Vec<StockSnapshotLine>> {
let rows = sqlx::query_as::<_, LineRow>(
"SELECT * FROM stock_snapshot_lines WHERE stock_snapshot_id = $1 ORDER BY sku",
)
.bind(id)
.fetch_all(&self.pool)
.await
.map_err(map_db_error)?;
Ok(rows.into_iter().map(Self::row_to_line).collect())
}
async fn load_full(&self, id: StockSnapshotId) -> Result<Option<StockSnapshot>> {
let row = sqlx::query_as::<_, SnapshotRow>("SELECT * FROM stock_snapshots WHERE id = $1")
.bind(id)
.fetch_optional(&self.pool)
.await
.map_err(map_db_error)?;
let Some(row) = row else {
return Ok(None);
};
let mut head = Self::row_to_head(row);
head.lines = self.load_lines(id).await?;
Ok(Some(head))
}
pub async fn capture_async(&self, input: CaptureStockSnapshot) -> Result<StockSnapshot> {
if input.lines.is_empty() {
return Err(CommerceError::ValidationError(
"a stock snapshot requires at least one line".into(),
));
}
let id = StockSnapshotId::new();
let now = Utc::now();
let total_skus = input.total_skus();
let total_units = input.total_units();
let mut tx = self.pool.begin().await.map_err(map_db_error)?;
sqlx::query(
"INSERT INTO stock_snapshots (id, label, total_skus, total_units, captured_at)
VALUES ($1, $2, $3, $4, $5)",
)
.bind(id)
.bind(&input.label)
.bind(total_skus as i64)
.bind(total_units)
.bind(now)
.execute(tx.as_mut())
.await
.map_err(map_db_error)?;
for line in &input.lines {
sqlx::query(
"INSERT INTO stock_snapshot_lines (id, stock_snapshot_id, product_id, sku, quantity_on_hand, quantity_available, location)
VALUES ($1, $2, $3, $4, $5, $6, $7)",
)
.bind(StockSnapshotLineId::new())
.bind(id)
.bind(*line.product_id.as_uuid())
.bind(&line.sku)
.bind(line.quantity_on_hand)
.bind(line.quantity_available)
.bind(&line.location)
.execute(tx.as_mut())
.await
.map_err(map_db_error)?;
}
tx.commit().await.map_err(map_db_error)?;
self.load_full(id)
.await?
.ok_or_else(|| CommerceError::DatabaseError("Failed to create stock snapshot".into()))
}
pub async fn get_async(&self, id: StockSnapshotId) -> Result<Option<StockSnapshot>> {
self.load_full(id).await
}
pub async fn latest_async(&self) -> Result<Option<StockSnapshot>> {
let id: Option<StockSnapshotId> =
sqlx::query_scalar("SELECT id FROM stock_snapshots ORDER BY captured_at DESC LIMIT 1")
.fetch_optional(&self.pool)
.await
.map_err(map_db_error)?;
match id {
Some(id) => self.load_full(id).await,
None => Ok(None),
}
}
pub async fn list_async(&self, filter: StockSnapshotFilter) -> Result<Vec<StockSnapshot>> {
let mut builder = QueryBuilder::<Postgres>::new(
"SELECT * FROM stock_snapshots ORDER BY captured_at DESC",
);
builder.push(" LIMIT ").push_bind(super::effective_limit(filter.limit));
if let Some(offset) = filter.offset {
builder.push(" OFFSET ").push_bind(i64::from(offset));
}
let rows = builder
.build_query_as::<SnapshotRow>()
.fetch_all(&self.pool)
.await
.map_err(map_db_error)?;
Ok(rows.into_iter().map(Self::row_to_head).collect())
}
pub async fn delete_async(&self, id: StockSnapshotId) -> Result<()> {
let mut tx = self.pool.begin().await.map_err(map_db_error)?;
sqlx::query("DELETE FROM stock_snapshot_lines WHERE stock_snapshot_id = $1")
.bind(id)
.execute(tx.as_mut())
.await
.map_err(map_db_error)?;
sqlx::query("DELETE FROM stock_snapshots WHERE id = $1")
.bind(id)
.execute(tx.as_mut())
.await
.map_err(map_db_error)?;
tx.commit().await.map_err(map_db_error)?;
Ok(())
}
}
impl StockSnapshotRepository for PgStockSnapshotRepository {
fn capture(&self, input: CaptureStockSnapshot) -> Result<StockSnapshot> {
block_on(self.capture_async(input))
}
fn get(&self, id: StockSnapshotId) -> Result<Option<StockSnapshot>> {
block_on(self.get_async(id))
}
fn latest(&self) -> Result<Option<StockSnapshot>> {
block_on(self.latest_async())
}
fn list(&self, filter: StockSnapshotFilter) -> Result<Vec<StockSnapshot>> {
block_on(self.list_async(filter))
}
fn delete(&self, id: StockSnapshotId) -> Result<()> {
block_on(self.delete_async(id))
}
}