use chrono::{DateTime, Utc};
use rust_decimal::Decimal;
use serde::{Deserialize, Serialize};
use stateset_primitives::{ProductId, TransferOrderId, TransferOrderItemId, WarehouseId};
use strum::{Display, EnumString};
#[derive(
Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default, Display, EnumString,
)]
#[serde(rename_all = "snake_case")]
#[strum(serialize_all = "snake_case", ascii_case_insensitive)]
#[non_exhaustive]
pub enum TransferOrderStatus {
#[default]
Draft,
Pending,
InTransit,
PartiallyReceived,
Received,
Cancelled,
}
impl TransferOrderStatus {
#[must_use]
pub const fn is_terminal(&self) -> bool {
matches!(self, Self::Received | Self::Cancelled)
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct TransferOrderItem {
pub id: TransferOrderItemId,
pub transfer_order_id: TransferOrderId,
pub product_id: ProductId,
pub sku: String,
pub quantity: Decimal,
pub quantity_shipped: Decimal,
pub quantity_received: Decimal,
}
impl TransferOrderItem {
#[must_use]
pub fn quantity_outstanding(&self) -> Decimal {
(self.quantity - self.quantity_received).max(Decimal::ZERO)
}
#[must_use]
pub fn is_fully_received(&self) -> bool {
self.quantity_received >= self.quantity
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct TransferOrder {
pub id: TransferOrderId,
pub number: String,
pub source_warehouse_id: WarehouseId,
pub destination_warehouse_id: WarehouseId,
pub status: TransferOrderStatus,
pub items: Vec<TransferOrderItem>,
pub expected_at: Option<DateTime<Utc>>,
pub shipped_at: Option<DateTime<Utc>>,
pub received_at: Option<DateTime<Utc>>,
pub notes: Option<String>,
pub created_at: DateTime<Utc>,
pub updated_at: DateTime<Utc>,
}
impl TransferOrder {
#[must_use]
pub fn total_quantity(&self) -> Decimal {
self.items.iter().map(|i| i.quantity).sum()
}
#[must_use]
pub fn total_received(&self) -> Decimal {
self.items.iter().map(|i| i.quantity_received).sum()
}
#[must_use]
pub fn derive_receipt_status(&self) -> TransferOrderStatus {
if self.status == TransferOrderStatus::Cancelled {
return TransferOrderStatus::Cancelled;
}
let received = self.total_received();
let total = self.total_quantity();
if received <= Decimal::ZERO {
self.status
} else if received >= total {
TransferOrderStatus::Received
} else {
TransferOrderStatus::PartiallyReceived
}
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct CreateTransferOrderItem {
pub product_id: ProductId,
pub quantity: Decimal,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct CreateTransferOrder {
pub source_warehouse_id: WarehouseId,
pub destination_warehouse_id: WarehouseId,
pub items: Vec<CreateTransferOrderItem>,
pub expected_at: Option<DateTime<Utc>>,
pub notes: Option<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
pub struct TransferOrderFilter {
pub status: Option<TransferOrderStatus>,
pub source_warehouse_id: Option<WarehouseId>,
pub destination_warehouse_id: Option<WarehouseId>,
pub limit: Option<u32>,
pub offset: Option<u32>,
}
#[cfg(test)]
mod tests {
use super::*;
use rust_decimal_macros::dec;
fn make_item(qty: Decimal, received: Decimal) -> TransferOrderItem {
TransferOrderItem {
id: TransferOrderItemId::new(),
transfer_order_id: TransferOrderId::new(),
product_id: ProductId::new(),
sku: "SKU-1".to_string(),
quantity: qty,
quantity_shipped: qty,
quantity_received: received,
}
}
fn make_order(items: Vec<TransferOrderItem>, status: TransferOrderStatus) -> TransferOrder {
TransferOrder {
id: TransferOrderId::new(),
number: "TO-1".to_string(),
source_warehouse_id: WarehouseId::new(),
destination_warehouse_id: WarehouseId::new(),
status,
items,
expected_at: None,
shipped_at: None,
received_at: None,
notes: None,
created_at: Utc::now(),
updated_at: Utc::now(),
}
}
#[test]
fn item_outstanding_and_fully_received() {
let item = make_item(dec!(10), dec!(4));
assert_eq!(item.quantity_outstanding(), dec!(6));
assert!(!item.is_fully_received());
let done = make_item(dec!(10), dec!(10));
assert_eq!(done.quantity_outstanding(), dec!(0));
assert!(done.is_fully_received());
}
#[test]
fn item_outstanding_never_negative() {
let over = make_item(dec!(10), dec!(12));
assert_eq!(over.quantity_outstanding(), dec!(0));
}
#[test]
fn totals_sum_across_lines() {
let order = make_order(
vec![make_item(dec!(10), dec!(2)), make_item(dec!(5), dec!(5))],
TransferOrderStatus::InTransit,
);
assert_eq!(order.total_quantity(), dec!(15));
assert_eq!(order.total_received(), dec!(7));
}
#[test]
fn derive_status_partial() {
let order = make_order(vec![make_item(dec!(10), dec!(2))], TransferOrderStatus::InTransit);
assert_eq!(order.derive_receipt_status(), TransferOrderStatus::PartiallyReceived);
}
#[test]
fn derive_status_full() {
let order = make_order(vec![make_item(dec!(10), dec!(10))], TransferOrderStatus::InTransit);
assert_eq!(order.derive_receipt_status(), TransferOrderStatus::Received);
}
#[test]
fn derive_status_none_keeps_current() {
let order = make_order(vec![make_item(dec!(10), dec!(0))], TransferOrderStatus::InTransit);
assert_eq!(order.derive_receipt_status(), TransferOrderStatus::InTransit);
}
#[test]
fn derive_status_cancelled_sticky() {
let order = make_order(vec![make_item(dec!(10), dec!(10))], TransferOrderStatus::Cancelled);
assert_eq!(order.derive_receipt_status(), TransferOrderStatus::Cancelled);
}
#[test]
fn terminal_states() {
assert!(TransferOrderStatus::Received.is_terminal());
assert!(TransferOrderStatus::Cancelled.is_terminal());
assert!(!TransferOrderStatus::InTransit.is_terminal());
}
#[test]
fn status_roundtrip() {
for s in [
TransferOrderStatus::Draft,
TransferOrderStatus::Pending,
TransferOrderStatus::InTransit,
TransferOrderStatus::PartiallyReceived,
TransferOrderStatus::Received,
TransferOrderStatus::Cancelled,
] {
let parsed: TransferOrderStatus = s.to_string().parse().unwrap();
assert_eq!(parsed, s);
}
}
}