Skip to main content

stateset_core/models/
backorder.rs

1//! Backorder Management domain models
2//!
3//! Models for tracking and managing backorders when inventory is unavailable.
4
5use chrono::{DateTime, Utc};
6use rust_decimal::Decimal;
7use serde::{Deserialize, Serialize};
8use std::str::FromStr;
9use strum::{Display, EnumString};
10use uuid::Uuid;
11
12// ============================================================================
13// Core Backorder Types
14// ============================================================================
15
16/// A backorder record.
17#[derive(Debug, Clone, Serialize, Deserialize)]
18pub struct Backorder {
19    pub id: Uuid,
20    pub backorder_number: String,
21    pub order_id: Uuid,
22    pub order_line_id: Option<Uuid>,
23    pub customer_id: Uuid,
24    pub sku: String,
25    pub quantity_ordered: Decimal,
26    pub quantity_fulfilled: Decimal,
27    pub quantity_remaining: Decimal,
28    pub status: BackorderStatus,
29    pub priority: BackorderPriority,
30    pub expected_date: Option<DateTime<Utc>>,
31    pub promised_date: Option<DateTime<Utc>>,
32    pub source_location_id: Option<i32>,
33    pub notes: Option<String>,
34    pub created_at: DateTime<Utc>,
35    pub updated_at: DateTime<Utc>,
36}
37
38/// A backorder fulfillment record.
39#[derive(Debug, Clone, Serialize, Deserialize)]
40pub struct BackorderFulfillment {
41    pub id: Uuid,
42    pub backorder_id: Uuid,
43    pub quantity: Decimal,
44    pub source_type: FulfillmentSourceType,
45    pub source_id: Option<Uuid>,
46    pub notes: Option<String>,
47    pub fulfilled_at: DateTime<Utc>,
48    pub fulfilled_by: Option<String>,
49}
50
51/// Backorder allocation (reserved inventory for backorder).
52#[derive(Debug, Clone, Serialize, Deserialize)]
53pub struct BackorderAllocation {
54    pub id: Uuid,
55    pub backorder_id: Uuid,
56    pub sku: String,
57    pub quantity: Decimal,
58    pub location_id: Option<i32>,
59    pub lot_id: Option<Uuid>,
60    pub status: AllocationStatus,
61    pub allocated_at: DateTime<Utc>,
62    pub expires_at: Option<DateTime<Utc>>,
63}
64
65// ============================================================================
66// Enums
67// ============================================================================
68
69/// Backorder status.
70#[derive(Debug, Clone, Copy, PartialEq, Eq, strum::Display, Serialize, Deserialize, Default)]
71#[strum(serialize_all = "snake_case")]
72#[serde(rename_all = "snake_case")]
73#[non_exhaustive]
74pub enum BackorderStatus {
75    #[default]
76    Pending,
77    PartiallyFulfilled,
78    Allocated,
79    ReadyToShip,
80    Fulfilled,
81    Cancelled,
82}
83
84impl FromStr for BackorderStatus {
85    type Err = String;
86    fn from_str(s: &str) -> Result<Self, Self::Err> {
87        match s.trim().to_ascii_lowercase().as_str() {
88            "pending" => Ok(Self::Pending),
89            "partially_fulfilled" | "partiallyfulfilled" => Ok(Self::PartiallyFulfilled),
90            "allocated" => Ok(Self::Allocated),
91            "ready_to_ship" | "readytoship" => Ok(Self::ReadyToShip),
92            "fulfilled" => Ok(Self::Fulfilled),
93            "cancelled" | "canceled" => Ok(Self::Cancelled),
94            _ => Err(format!("Unknown backorder status: {s}")),
95        }
96    }
97}
98
99/// Backorder priority.
100#[derive(
101    Debug, Clone, Copy, PartialEq, Eq, Display, EnumString, Serialize, Deserialize, Default,
102)]
103#[strum(serialize_all = "snake_case", ascii_case_insensitive)]
104#[serde(rename_all = "snake_case")]
105#[non_exhaustive]
106pub enum BackorderPriority {
107    Low,
108    #[default]
109    Normal,
110    High,
111    Critical,
112}
113
114/// Source type for fulfillment.
115#[derive(
116    Debug, Clone, Copy, PartialEq, Eq, Display, EnumString, Serialize, Deserialize, Default,
117)]
118#[strum(serialize_all = "snake_case", ascii_case_insensitive)]
119#[serde(rename_all = "snake_case")]
120#[non_exhaustive]
121pub enum FulfillmentSourceType {
122    #[default]
123    Inventory,
124    #[strum(serialize = "purchase_order", serialize = "purchaseorder", serialize = "po")]
125    PurchaseOrder,
126    Transfer,
127    Production,
128}
129
130/// Allocation status.
131#[derive(
132    Debug, Clone, Copy, PartialEq, Eq, Display, EnumString, Serialize, Deserialize, Default,
133)]
134#[strum(serialize_all = "snake_case", ascii_case_insensitive)]
135#[serde(rename_all = "snake_case")]
136#[non_exhaustive]
137pub enum AllocationStatus {
138    #[default]
139    Reserved,
140    Confirmed,
141    Released,
142    Expired,
143}
144
145// ============================================================================
146// Input Types
147// ============================================================================
148
149/// Input for creating a backorder.
150#[derive(Debug, Clone, Serialize, Deserialize)]
151pub struct CreateBackorder {
152    pub order_id: Uuid,
153    pub order_line_id: Option<Uuid>,
154    pub customer_id: Uuid,
155    pub sku: String,
156    pub quantity: Decimal,
157    pub priority: Option<BackorderPriority>,
158    pub expected_date: Option<DateTime<Utc>>,
159    pub promised_date: Option<DateTime<Utc>>,
160    pub source_location_id: Option<i32>,
161    pub notes: Option<String>,
162}
163
164/// Input for updating a backorder.
165#[derive(Debug, Clone, Serialize, Deserialize, Default)]
166pub struct UpdateBackorder {
167    pub priority: Option<BackorderPriority>,
168    pub expected_date: Option<DateTime<Utc>>,
169    pub promised_date: Option<DateTime<Utc>>,
170    pub source_location_id: Option<i32>,
171    pub notes: Option<String>,
172}
173
174/// Input for fulfilling a backorder.
175#[derive(Debug, Clone, Serialize, Deserialize)]
176pub struct FulfillBackorder {
177    pub backorder_id: Uuid,
178    pub quantity: Decimal,
179    pub source_type: FulfillmentSourceType,
180    pub source_id: Option<Uuid>,
181    pub notes: Option<String>,
182    pub fulfilled_by: Option<String>,
183}
184
185/// Input for allocating inventory to a backorder.
186#[derive(Debug, Clone, Serialize, Deserialize)]
187pub struct AllocateBackorder {
188    pub backorder_id: Uuid,
189    pub quantity: Decimal,
190    pub location_id: Option<i32>,
191    pub lot_id: Option<Uuid>,
192    pub expires_at: Option<DateTime<Utc>>,
193}
194
195// ============================================================================
196// Filter Types
197// ============================================================================
198
199/// Filter for listing backorders.
200#[derive(Debug, Clone, Serialize, Deserialize, Default)]
201pub struct BackorderFilter {
202    pub order_id: Option<Uuid>,
203    pub customer_id: Option<Uuid>,
204    pub sku: Option<String>,
205    pub status: Option<BackorderStatus>,
206    pub priority: Option<BackorderPriority>,
207    pub expected_before: Option<DateTime<Utc>>,
208    pub limit: Option<u32>,
209    pub offset: Option<u32>,
210}
211
212// ============================================================================
213// Summary Types
214// ============================================================================
215
216/// Backorder summary by SKU.
217#[derive(Debug, Clone, Serialize, Deserialize)]
218pub struct SkuBackorderSummary {
219    pub sku: String,
220    pub total_quantity: Decimal,
221    pub backorder_count: i32,
222    pub oldest_date: Option<DateTime<Utc>>,
223    pub earliest_expected: Option<DateTime<Utc>>,
224}
225
226/// Overall backorder summary.
227#[derive(Debug, Clone, Serialize, Deserialize)]
228pub struct BackorderSummary {
229    pub total_backorders: i32,
230    pub total_quantity: Decimal,
231    pub pending_count: i32,
232    pub allocated_count: i32,
233    pub critical_count: i32,
234    pub overdue_count: i32,
235}
236
237// ============================================================================
238// Helper Functions
239// ============================================================================
240
241/// Generate a backorder number.
242#[must_use]
243pub fn generate_backorder_number() -> String {
244    let timestamp = chrono::Utc::now().format("%Y%m%d").to_string();
245    let suffix = Uuid::new_v4().simple().to_string();
246    let random = suffix[..12].to_uppercase();
247    format!("BO-{timestamp}-{random}")
248}
249
250#[cfg(test)]
251mod tests {
252    use super::*;
253
254    #[test]
255    fn test_backorder_status_from_str() {
256        assert_eq!(BackorderStatus::from_str("pending").unwrap(), BackorderStatus::Pending);
257        assert_eq!(
258            BackorderStatus::from_str("partiallyfulfilled").unwrap(),
259            BackorderStatus::PartiallyFulfilled
260        );
261        assert!(BackorderStatus::from_str("nope").is_err());
262    }
263
264    #[test]
265    fn test_backorder_priority_from_str() {
266        assert_eq!(BackorderPriority::from_str("low").unwrap(), BackorderPriority::Low);
267        assert_eq!(BackorderPriority::from_str("critical").unwrap(), BackorderPriority::Critical);
268        assert!(BackorderPriority::from_str("nope").is_err());
269    }
270
271    #[test]
272    fn test_fulfillment_source_type_from_str() {
273        assert_eq!(
274            FulfillmentSourceType::from_str("purchaseorder").unwrap(),
275            FulfillmentSourceType::PurchaseOrder
276        );
277        assert_eq!(
278            FulfillmentSourceType::from_str("transfer").unwrap(),
279            FulfillmentSourceType::Transfer
280        );
281        assert!(FulfillmentSourceType::from_str("nope").is_err());
282    }
283
284    #[test]
285    fn test_allocation_status_from_str() {
286        assert_eq!(AllocationStatus::from_str("confirmed").unwrap(), AllocationStatus::Confirmed);
287        assert!(AllocationStatus::from_str("nope").is_err());
288    }
289
290    #[test]
291    fn backorder_numbers_are_unique_under_tight_loops() {
292        let mut seen = std::collections::HashSet::new();
293
294        for _ in 0..10_000 {
295            let number = generate_backorder_number();
296            assert!(number.starts_with("BO-"));
297            assert!(seen.insert(number));
298        }
299    }
300}