use super::map_db_error;
use chrono::{DateTime, Utc};
use rust_decimal::Decimal;
use sqlx::FromRow;
use sqlx::postgres::PgPool;
use stateset_core::{
CommerceError, IngestOrder, MapPurgatoryLine, PurgatoryFilter, PurgatoryLineItem,
PurgatoryLineItemId, PurgatoryOrder, PurgatoryOrderId, PurgatoryRepository, Result,
};
use uuid::Uuid;
#[derive(Debug, Clone)]
pub struct PgPurgatoryRepository {
pool: PgPool,
}
#[derive(FromRow)]
struct PurgatoryOrderRow {
id: Uuid,
channel_id: Option<Uuid>,
external_order_id: String,
external_status: Option<String>,
is_posted: bool,
hold_reason: Option<String>,
metadata: serde_json::Value,
created_at: DateTime<Utc>,
updated_at: DateTime<Utc>,
}
#[derive(FromRow)]
struct PurgatoryLineRow {
id: Uuid,
purgatory_order_id: Uuid,
external_sku: String,
product_id: Option<Uuid>,
quantity: Decimal,
ignore_item: bool,
non_physical: bool,
}
impl PgPurgatoryRepository {
pub const fn new(pool: PgPool) -> Self {
Self { pool }
}
fn row_to_head(row: PurgatoryOrderRow) -> PurgatoryOrder {
PurgatoryOrder {
id: row.id.into(),
channel_id: row.channel_id.map(Into::into),
external_order_id: row.external_order_id,
external_status: row.external_status,
is_posted: row.is_posted,
hold_reason: row.hold_reason,
metadata: row.metadata,
items: Vec::new(),
created_at: row.created_at,
updated_at: row.updated_at,
}
}
fn row_to_line(row: PurgatoryLineRow) -> PurgatoryLineItem {
PurgatoryLineItem {
id: row.id.into(),
purgatory_order_id: row.purgatory_order_id.into(),
external_sku: row.external_sku,
product_id: row.product_id.map(Into::into),
quantity: row.quantity,
ignore_item: row.ignore_item,
non_physical: row.non_physical,
}
}
async fn fetch_full_conn(
conn: &mut sqlx::PgConnection,
id: Uuid,
) -> Result<Option<PurgatoryOrder>> {
let head =
sqlx::query_as::<_, PurgatoryOrderRow>("SELECT * FROM purgatory_orders WHERE id = $1")
.bind(id)
.fetch_optional(&mut *conn)
.await
.map_err(map_db_error)?;
let Some(head) = head else { return Ok(None) };
let lines = sqlx::query_as::<_, PurgatoryLineRow>(
"SELECT * FROM purgatory_line_items WHERE purgatory_order_id = $1 ORDER BY external_sku",
)
.bind(id)
.fetch_all(&mut *conn)
.await
.map_err(map_db_error)?;
let mut order = Self::row_to_head(head);
order.items = lines.into_iter().map(Self::row_to_line).collect();
Ok(Some(order))
}
async fn fetch_full_async(&self, id: Uuid) -> Result<Option<PurgatoryOrder>> {
let mut conn = self.pool.acquire().await.map_err(map_db_error)?;
Self::fetch_full_conn(conn.as_mut(), id).await
}
pub async fn ingest_async(&self, input: IngestOrder) -> Result<PurgatoryOrder> {
if input.items.is_empty() {
return Err(CommerceError::ValidationError(
"an ingested order requires at least one line".into(),
));
}
let id = PurgatoryOrderId::new();
let now = Utc::now();
let mut tx = self.pool.begin().await.map_err(map_db_error)?;
sqlx::query(
"INSERT INTO purgatory_orders (id, channel_id, external_order_id, external_status, is_posted, metadata, created_at, updated_at)
VALUES ($1, $2, $3, $4, FALSE, $5, $6, $6)",
)
.bind(Uuid::from(id))
.bind(input.channel_id.map(Uuid::from))
.bind(&input.external_order_id)
.bind(&input.external_status)
.bind(&input.metadata)
.bind(now)
.execute(tx.as_mut())
.await
.map_err(map_db_error)?;
for item in &input.items {
sqlx::query(
"INSERT INTO purgatory_line_items (id, purgatory_order_id, external_sku, product_id, quantity, ignore_item, non_physical)
VALUES ($1, $2, $3, $4, $5, FALSE, FALSE)",
)
.bind(Uuid::from(PurgatoryLineItemId::new()))
.bind(Uuid::from(id))
.bind(&item.external_sku)
.bind(item.product_id.map(Uuid::from))
.bind(item.quantity)
.execute(tx.as_mut())
.await
.map_err(map_db_error)?;
}
tx.commit().await.map_err(map_db_error)?;
self.fetch_full_async(id.into()).await?.ok_or(CommerceError::NotFound)
}
pub async fn get_async(&self, id: PurgatoryOrderId) -> Result<Option<PurgatoryOrder>> {
self.fetch_full_async(id.into()).await
}
pub async fn list_async(&self, filter: PurgatoryFilter) -> Result<Vec<PurgatoryOrder>> {
let limit = super::effective_limit(filter.limit);
let offset = i64::from(filter.offset.unwrap_or(0));
let is_posted = filter.is_posted.unwrap_or(false);
let mut query = String::from("SELECT * FROM purgatory_orders WHERE 1=1");
let mut param_idx = 1;
if filter.channel_id.is_some() {
query.push_str(&format!(" AND channel_id = ${param_idx}"));
param_idx += 1;
}
query.push_str(&format!(" AND is_posted = ${param_idx}"));
param_idx += 1;
query.push_str(&format!(
" ORDER BY created_at DESC LIMIT ${} OFFSET ${}",
param_idx,
param_idx + 1
));
let mut q = sqlx::query_as::<_, PurgatoryOrderRow>(&query);
if let Some(channel) = filter.channel_id {
q = q.bind(Uuid::from(channel));
}
let rows = q
.bind(is_posted)
.bind(limit)
.bind(offset)
.fetch_all(&self.pool)
.await
.map_err(map_db_error)?;
let mut orders: Vec<PurgatoryOrder> = rows.into_iter().map(Self::row_to_head).collect();
if orders.is_empty() {
return Ok(orders);
}
let ids: Vec<Uuid> = orders.iter().map(|o| Uuid::from(o.id)).collect();
let lines = sqlx::query_as::<_, PurgatoryLineRow>(
"SELECT * FROM purgatory_line_items WHERE purgatory_order_id = ANY($1) ORDER BY external_sku",
)
.bind(&ids)
.fetch_all(&self.pool)
.await
.map_err(map_db_error)?;
let mut by_parent: std::collections::HashMap<Uuid, Vec<PurgatoryLineItem>> =
std::collections::HashMap::with_capacity(orders.len());
for line in lines {
by_parent.entry(line.purgatory_order_id).or_default().push(Self::row_to_line(line));
}
for order in &mut orders {
order.items = by_parent.remove(&Uuid::from(order.id)).unwrap_or_default();
}
Ok(orders)
}
pub async fn map_line_async(
&self,
id: PurgatoryOrderId,
line_id: PurgatoryLineItemId,
input: MapPurgatoryLine,
) -> Result<PurgatoryOrder> {
let now = Utc::now();
let mut tx = self.pool.begin().await.map_err(map_db_error)?;
let mut sets: Vec<String> = vec![];
let mut param_idx = 1;
if input.product_id.is_some() {
sets.push(format!("product_id = ${param_idx}"));
param_idx += 1;
}
if input.ignore_item.is_some() {
sets.push(format!("ignore_item = ${param_idx}"));
param_idx += 1;
}
if input.non_physical.is_some() {
sets.push(format!("non_physical = ${param_idx}"));
param_idx += 1;
}
if !sets.is_empty() {
let sql = format!(
"UPDATE purgatory_line_items SET {} WHERE id = ${} AND purgatory_order_id = ${}",
sets.join(", "),
param_idx,
param_idx + 1
);
let mut q = sqlx::query(&sql);
if let Some(product_id) = input.product_id {
q = q.bind(Uuid::from(product_id));
}
if let Some(ignore) = input.ignore_item {
q = q.bind(ignore);
}
if let Some(non_physical) = input.non_physical {
q = q.bind(non_physical);
}
q.bind(Uuid::from(line_id))
.bind(Uuid::from(id))
.execute(tx.as_mut())
.await
.map_err(map_db_error)?;
}
sqlx::query("UPDATE purgatory_orders SET updated_at = $1 WHERE id = $2")
.bind(now)
.bind(Uuid::from(id))
.execute(tx.as_mut())
.await
.map_err(map_db_error)?;
tx.commit().await.map_err(map_db_error)?;
self.fetch_full_async(id.into()).await?.ok_or(CommerceError::NotFound)
}
pub async fn post_async(&self, id: PurgatoryOrderId) -> Result<PurgatoryOrder> {
let now = Utc::now();
let mut tx = self.pool.begin().await.map_err(map_db_error)?;
let order =
Self::fetch_full_conn(tx.as_mut(), id.into()).await?.ok_or(CommerceError::NotFound)?;
if order.is_posted {
return Err(CommerceError::Conflict("order is already posted".into()));
}
if !order.is_ready_to_post() {
return Err(CommerceError::ValidationError(format!(
"{} line(s) still unresolved",
order.unresolved_count()
)));
}
sqlx::query(
"UPDATE purgatory_orders SET is_posted = TRUE, hold_reason = NULL, updated_at = $1 WHERE id = $2",
)
.bind(now)
.bind(Uuid::from(id))
.execute(tx.as_mut())
.await
.map_err(map_db_error)?;
tx.commit().await.map_err(map_db_error)?;
self.fetch_full_async(id.into()).await?.ok_or(CommerceError::NotFound)
}
pub async fn delete_async(&self, id: PurgatoryOrderId) -> Result<()> {
let mut tx = self.pool.begin().await.map_err(map_db_error)?;
sqlx::query("DELETE FROM purgatory_line_items WHERE purgatory_order_id = $1")
.bind(Uuid::from(id))
.execute(tx.as_mut())
.await
.map_err(map_db_error)?;
sqlx::query("DELETE FROM purgatory_orders WHERE id = $1")
.bind(Uuid::from(id))
.execute(tx.as_mut())
.await
.map_err(map_db_error)?;
tx.commit().await.map_err(map_db_error)?;
Ok(())
}
}
impl PurgatoryRepository for PgPurgatoryRepository {
fn ingest(&self, input: IngestOrder) -> Result<PurgatoryOrder> {
super::block_on(self.ingest_async(input))
}
fn get(&self, id: PurgatoryOrderId) -> Result<Option<PurgatoryOrder>> {
super::block_on(self.get_async(id))
}
fn list(&self, filter: PurgatoryFilter) -> Result<Vec<PurgatoryOrder>> {
super::block_on(self.list_async(filter))
}
fn map_line(
&self,
id: PurgatoryOrderId,
line_id: PurgatoryLineItemId,
input: MapPurgatoryLine,
) -> Result<PurgatoryOrder> {
super::block_on(self.map_line_async(id, line_id, input))
}
fn post(&self, id: PurgatoryOrderId) -> Result<PurgatoryOrder> {
super::block_on(self.post_async(id))
}
fn delete(&self, id: PurgatoryOrderId) -> Result<()> {
super::block_on(self.delete_async(id))
}
}