1use chrono::{DateTime, Utc};
6use rust_decimal::Decimal;
7use serde::{Deserialize, Serialize};
8use std::str::FromStr;
9use strum::{Display, EnumString};
10use uuid::Uuid;
11
12#[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#[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#[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#[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#[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#[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#[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#[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#[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#[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#[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#[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#[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#[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#[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}