use chrono::{DateTime, Utc};
use rust_decimal::Decimal;
use serde::{Deserialize, Serialize};
use stateset_primitives::{InboundShipmentId, InboundShipmentItemId, ProductId, WarehouseId};
use strum::{Display, EnumString};
use uuid::Uuid;
#[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 InboundShipmentStatus {
#[default]
Pending,
InTransit,
Arrived,
PartiallyReceived,
Received,
Cancelled,
}
impl InboundShipmentStatus {
#[must_use]
pub const fn is_terminal(&self) -> bool {
matches!(self, Self::Received | Self::Cancelled)
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct InboundShipmentItem {
pub id: InboundShipmentItemId,
pub inbound_shipment_id: InboundShipmentId,
pub product_id: ProductId,
pub sku: String,
pub quantity_expected: Decimal,
pub quantity_received: Decimal,
}
impl InboundShipmentItem {
#[must_use]
pub fn quantity_outstanding(&self) -> Decimal {
(self.quantity_expected - self.quantity_received).max(Decimal::ZERO)
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct InboundShipment {
pub id: InboundShipmentId,
pub number: String,
pub supplier_id: Uuid,
pub purchase_order_id: Option<Uuid>,
pub warehouse_id: Option<WarehouseId>,
pub carrier: Option<String>,
pub tracking_number: Option<String>,
pub status: InboundShipmentStatus,
pub items: Vec<InboundShipmentItem>,
pub expected_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 InboundShipment {
#[must_use]
pub fn total_expected(&self) -> Decimal {
self.items.iter().map(|i| i.quantity_expected).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) -> InboundShipmentStatus {
if self.status == InboundShipmentStatus::Cancelled {
return InboundShipmentStatus::Cancelled;
}
let received = self.total_received();
if received <= Decimal::ZERO {
self.status
} else if received >= self.total_expected() {
InboundShipmentStatus::Received
} else {
InboundShipmentStatus::PartiallyReceived
}
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct CreateInboundShipmentItem {
pub product_id: ProductId,
pub sku: String,
pub quantity_expected: Decimal,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct CreateInboundShipment {
pub supplier_id: Uuid,
pub purchase_order_id: Option<Uuid>,
pub warehouse_id: Option<WarehouseId>,
pub carrier: Option<String>,
pub tracking_number: Option<String>,
pub expected_at: Option<DateTime<Utc>>,
pub items: Vec<CreateInboundShipmentItem>,
pub notes: Option<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
pub struct InboundShipmentFilter {
pub supplier_id: Option<Uuid>,
pub warehouse_id: Option<WarehouseId>,
pub status: Option<InboundShipmentStatus>,
pub limit: Option<u32>,
pub offset: Option<u32>,
}
#[cfg(test)]
mod tests {
use super::*;
use rust_decimal_macros::dec;
fn make_item(expected: Decimal, received: Decimal) -> InboundShipmentItem {
InboundShipmentItem {
id: InboundShipmentItemId::new(),
inbound_shipment_id: InboundShipmentId::new(),
product_id: ProductId::new(),
sku: "SKU-1".into(),
quantity_expected: expected,
quantity_received: received,
}
}
fn make(items: Vec<InboundShipmentItem>, status: InboundShipmentStatus) -> InboundShipment {
InboundShipment {
id: InboundShipmentId::new(),
number: "ASN-1".into(),
supplier_id: Uuid::nil(),
purchase_order_id: None,
warehouse_id: None,
carrier: Some("DHL".into()),
tracking_number: Some("1Z999".into()),
status,
items,
expected_at: None,
received_at: None,
notes: None,
created_at: Utc::now(),
updated_at: Utc::now(),
}
}
#[test]
fn outstanding_never_negative() {
assert_eq!(make_item(dec!(10), dec!(4)).quantity_outstanding(), dec!(6));
assert_eq!(make_item(dec!(10), dec!(12)).quantity_outstanding(), dec!(0));
}
#[test]
fn totals_sum_lines() {
let s = make(
vec![make_item(dec!(10), dec!(2)), make_item(dec!(5), dec!(5))],
InboundShipmentStatus::InTransit,
);
assert_eq!(s.total_expected(), dec!(15));
assert_eq!(s.total_received(), dec!(7));
}
#[test]
fn derive_status_progression() {
let none = make(vec![make_item(dec!(10), dec!(0))], InboundShipmentStatus::Arrived);
assert_eq!(none.derive_receipt_status(), InboundShipmentStatus::Arrived);
let partial = make(vec![make_item(dec!(10), dec!(4))], InboundShipmentStatus::Arrived);
assert_eq!(partial.derive_receipt_status(), InboundShipmentStatus::PartiallyReceived);
let full = make(vec![make_item(dec!(10), dec!(10))], InboundShipmentStatus::Arrived);
assert_eq!(full.derive_receipt_status(), InboundShipmentStatus::Received);
let cancelled = make(vec![make_item(dec!(10), dec!(10))], InboundShipmentStatus::Cancelled);
assert_eq!(cancelled.derive_receipt_status(), InboundShipmentStatus::Cancelled);
}
#[test]
fn terminal_states() {
assert!(InboundShipmentStatus::Received.is_terminal());
assert!(InboundShipmentStatus::Cancelled.is_terminal());
assert!(!InboundShipmentStatus::InTransit.is_terminal());
}
#[test]
fn status_roundtrip() {
for s in [
InboundShipmentStatus::Pending,
InboundShipmentStatus::InTransit,
InboundShipmentStatus::Arrived,
InboundShipmentStatus::PartiallyReceived,
InboundShipmentStatus::Received,
InboundShipmentStatus::Cancelled,
] {
assert_eq!(s.to_string().parse::<InboundShipmentStatus>().unwrap(), s);
}
}
}