1use chrono::{DateTime, NaiveDate, Utc};
4use rust_decimal::Decimal;
5use serde::{Deserialize, Serialize};
6use std::collections::HashMap;
7
8#[derive(Debug, Clone, Serialize, Deserialize)]
10pub struct InventoryPosition {
11 pub material_id: String,
13 pub description: String,
15 pub plant: String,
17 pub storage_location: String,
19 pub company_code: String,
21 pub quantity_on_hand: Decimal,
23 pub unit: String,
25 pub quantity_reserved: Decimal,
27 pub quantity_available: Decimal,
29 pub quantity_in_inspection: Decimal,
31 pub quantity_blocked: Decimal,
33 pub quantity_in_transit: Decimal,
35 pub valuation: PositionValuation,
37 pub last_movement_date: Option<NaiveDate>,
39 pub last_count_date: Option<NaiveDate>,
41 pub min_stock: Option<Decimal>,
43 pub max_stock: Option<Decimal>,
45 pub reorder_point: Option<Decimal>,
47 pub safety_stock: Option<Decimal>,
49 pub status: StockStatus,
51 pub batches: Vec<BatchStock>,
53 pub serial_numbers: Vec<SerialNumber>,
55 pub updated_at: DateTime<Utc>,
57}
58
59impl InventoryPosition {
60 pub fn new(
62 material_id: String,
63 description: String,
64 plant: String,
65 storage_location: String,
66 company_code: String,
67 unit: String,
68 ) -> Self {
69 Self {
70 material_id,
71 description,
72 plant,
73 storage_location,
74 company_code,
75 quantity_on_hand: Decimal::ZERO,
76 unit,
77 quantity_reserved: Decimal::ZERO,
78 quantity_available: Decimal::ZERO,
79 quantity_in_inspection: Decimal::ZERO,
80 quantity_blocked: Decimal::ZERO,
81 quantity_in_transit: Decimal::ZERO,
82 valuation: PositionValuation::default(),
83 last_movement_date: None,
84 last_count_date: None,
85 min_stock: None,
86 max_stock: None,
87 reorder_point: None,
88 safety_stock: None,
89 status: StockStatus::Normal,
90 batches: Vec::new(),
91 serial_numbers: Vec::new(),
92 updated_at: Utc::now(),
93 }
94 }
95
96 pub fn calculate_available(&mut self) {
98 self.quantity_available = self.quantity_on_hand
99 - self.quantity_reserved
100 - self.quantity_in_inspection
101 - self.quantity_blocked;
102 }
103
104 pub fn add_quantity(&mut self, quantity: Decimal, cost: Decimal, date: NaiveDate) {
106 self.quantity_on_hand += quantity;
107 self.valuation.update_on_receipt(quantity, cost);
108 self.last_movement_date = Some(date);
109 self.calculate_available();
110 self.update_status();
111 self.updated_at = Utc::now();
112 }
113
114 pub fn remove_quantity(&mut self, quantity: Decimal, date: NaiveDate) -> Option<Decimal> {
116 if quantity > self.quantity_available {
117 return None;
118 }
119
120 let cost = self.valuation.calculate_issue_cost(quantity);
121 self.quantity_on_hand -= quantity;
122 self.last_movement_date = Some(date);
123 self.calculate_available();
124 self.update_status();
125 self.updated_at = Utc::now();
126
127 Some(cost)
128 }
129
130 pub fn reserve(&mut self, quantity: Decimal) -> bool {
132 if quantity > self.quantity_available {
133 return false;
134 }
135 self.quantity_reserved += quantity;
136 self.calculate_available();
137 self.updated_at = Utc::now();
138 true
139 }
140
141 pub fn release_reservation(&mut self, quantity: Decimal) {
143 self.quantity_reserved = (self.quantity_reserved - quantity).max(Decimal::ZERO);
144 self.calculate_available();
145 self.updated_at = Utc::now();
146 }
147
148 pub fn block(&mut self, quantity: Decimal) {
150 self.quantity_blocked += quantity;
151 self.calculate_available();
152 self.updated_at = Utc::now();
153 }
154
155 pub fn unblock(&mut self, quantity: Decimal) {
157 self.quantity_blocked = (self.quantity_blocked - quantity).max(Decimal::ZERO);
158 self.calculate_available();
159 self.updated_at = Utc::now();
160 }
161
162 fn update_status(&mut self) {
164 if self.quantity_on_hand <= Decimal::ZERO {
165 self.status = StockStatus::OutOfStock;
166 } else if let Some(safety) = self.safety_stock {
167 if self.quantity_on_hand <= safety {
168 self.status = StockStatus::BelowSafety;
169 } else if let Some(reorder) = self.reorder_point {
170 if self.quantity_on_hand <= reorder {
171 self.status = StockStatus::BelowReorder;
172 } else {
173 self.status = StockStatus::Normal;
174 }
175 } else {
176 self.status = StockStatus::Normal;
177 }
178 } else {
179 self.status = if self.quantity_on_hand > Decimal::ZERO {
180 StockStatus::Normal
181 } else {
182 StockStatus::OutOfStock
183 };
184 }
185 }
186
187 pub fn with_stock_levels(
189 mut self,
190 min: Decimal,
191 max: Decimal,
192 reorder: Decimal,
193 safety: Decimal,
194 ) -> Self {
195 self.min_stock = Some(min);
196 self.max_stock = Some(max);
197 self.reorder_point = Some(reorder);
198 self.safety_stock = Some(safety);
199 self.update_status();
200 self
201 }
202
203 pub fn total_value(&self) -> Decimal {
205 self.quantity_on_hand * self.valuation.unit_cost
206 }
207
208 pub fn needs_reorder(&self) -> bool {
210 self.reorder_point
211 .map(|rp| self.quantity_available <= rp)
212 .unwrap_or(false)
213 }
214
215 pub fn days_of_supply(&self, average_daily_usage: Decimal) -> Option<Decimal> {
217 if average_daily_usage > Decimal::ZERO {
218 Some((self.quantity_available / average_daily_usage).round_dp(1))
219 } else {
220 None
221 }
222 }
223}
224
225#[derive(Debug, Clone, Default, Serialize, Deserialize)]
227pub struct PositionValuation {
228 pub method: ValuationMethod,
230 pub standard_cost: Decimal,
232 pub unit_cost: Decimal,
234 pub total_value: Decimal,
236 pub price_variance: Decimal,
238 pub last_price_change: Option<NaiveDate>,
240}
241
242impl PositionValuation {
243 pub fn update_on_receipt(&mut self, receipt_qty: Decimal, cost: Decimal) {
253 match self.method {
254 ValuationMethod::StandardCost => {
255 let actual_cost = cost;
256 let standard_cost = receipt_qty * self.standard_cost;
257 self.price_variance += actual_cost - standard_cost;
258 self.total_value += standard_cost;
259 }
260 ValuationMethod::MovingAverage => {
261 let existing_qty = if self.unit_cost > Decimal::ZERO {
266 self.total_value / self.unit_cost
267 } else {
268 Decimal::ZERO
269 };
270 let new_qty = existing_qty + receipt_qty;
271 self.total_value += cost;
272 if new_qty > Decimal::ZERO {
273 self.unit_cost = (self.total_value / new_qty).round_dp(4);
274 }
275 }
276 ValuationMethod::FIFO | ValuationMethod::LIFO => {
277 self.total_value += cost;
278 }
279 }
280 }
281
282 pub fn calculate_issue_cost(&mut self, quantity: Decimal) -> Decimal {
284 let cost = quantity * self.unit_cost;
285 self.total_value = (self.total_value - cost).max(Decimal::ZERO);
286 cost
287 }
288}
289
290#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize)]
292pub enum ValuationMethod {
293 #[default]
295 StandardCost,
296 MovingAverage,
298 FIFO,
300 LIFO,
302}
303
304#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize)]
306pub enum StockStatus {
307 #[default]
309 Normal,
310 BelowReorder,
312 BelowSafety,
314 OutOfStock,
316 OverMax,
318 Obsolete,
320}
321
322#[derive(Debug, Clone, Serialize, Deserialize)]
324pub struct BatchStock {
325 pub batch_number: String,
327 pub quantity: Decimal,
329 pub manufacture_date: Option<NaiveDate>,
331 pub expiration_date: Option<NaiveDate>,
333 pub supplier_batch: Option<String>,
335 pub status: BatchStatus,
337 pub unit_cost: Decimal,
339}
340
341impl BatchStock {
342 pub fn new(batch_number: String, quantity: Decimal, unit_cost: Decimal) -> Self {
344 Self {
345 batch_number,
346 quantity,
347 manufacture_date: None,
348 expiration_date: None,
349 supplier_batch: None,
350 status: BatchStatus::Unrestricted,
351 unit_cost,
352 }
353 }
354
355 pub fn is_expired(&self, as_of_date: NaiveDate) -> bool {
357 self.expiration_date
358 .map(|exp| as_of_date > exp)
359 .unwrap_or(false)
360 }
361
362 pub fn is_expiring_soon(&self, as_of_date: NaiveDate, days: i64) -> bool {
364 self.expiration_date
365 .map(|exp| {
366 let threshold = as_of_date + chrono::Duration::days(days);
367 as_of_date <= exp && exp <= threshold
368 })
369 .unwrap_or(false)
370 }
371}
372
373#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize)]
375pub enum BatchStatus {
376 #[default]
378 Unrestricted,
379 InInspection,
381 Blocked,
383 Expired,
385 Reserved,
387}
388
389#[derive(Debug, Clone, Serialize, Deserialize)]
391pub struct SerialNumber {
392 pub serial_number: String,
394 pub status: SerialStatus,
396 pub receipt_date: NaiveDate,
398 pub issue_date: Option<NaiveDate>,
400 pub customer_id: Option<String>,
402 pub warranty_expiration: Option<NaiveDate>,
404}
405
406#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize)]
408pub enum SerialStatus {
409 #[default]
411 InStock,
412 Reserved,
414 Issued,
416 InRepair,
418 Scrapped,
420}
421
422#[derive(Debug, Clone, Serialize, Deserialize)]
424pub struct InventorySummary {
425 pub company_code: String,
427 pub as_of_date: NaiveDate,
429 pub by_plant: HashMap<String, PlantInventorySummary>,
431 pub total_value: Decimal,
433 pub total_sku_count: u32,
435 pub below_reorder_count: u32,
437 pub out_of_stock_count: u32,
439}
440
441#[derive(Debug, Clone, Serialize, Deserialize)]
443pub struct PlantInventorySummary {
444 pub plant: String,
446 pub total_value: Decimal,
448 pub sku_count: u32,
450 pub below_reorder_count: u32,
452 pub out_of_stock_count: u32,
454 pub total_quantity: Decimal,
456}
457
458impl InventorySummary {
459 pub fn from_positions(
461 company_code: String,
462 positions: &[InventoryPosition],
463 as_of_date: NaiveDate,
464 ) -> Self {
465 let mut by_plant: HashMap<String, PlantInventorySummary> = HashMap::new();
466 let mut total_value = Decimal::ZERO;
467 let mut total_sku_count = 0u32;
468 let mut below_reorder_count = 0u32;
469 let mut out_of_stock_count = 0u32;
470
471 for pos in positions.iter().filter(|p| p.company_code == company_code) {
472 let plant_summary =
473 by_plant
474 .entry(pos.plant.clone())
475 .or_insert_with(|| PlantInventorySummary {
476 plant: pos.plant.clone(),
477 total_value: Decimal::ZERO,
478 sku_count: 0,
479 below_reorder_count: 0,
480 out_of_stock_count: 0,
481 total_quantity: Decimal::ZERO,
482 });
483
484 let value = pos.total_value();
485 plant_summary.total_value += value;
486 plant_summary.sku_count += 1;
487 plant_summary.total_quantity += pos.quantity_on_hand;
488
489 total_value += value;
490 total_sku_count += 1;
491
492 match pos.status {
493 StockStatus::BelowReorder | StockStatus::BelowSafety => {
494 plant_summary.below_reorder_count += 1;
495 below_reorder_count += 1;
496 }
497 StockStatus::OutOfStock => {
498 plant_summary.out_of_stock_count += 1;
499 out_of_stock_count += 1;
500 }
501 _ => {}
502 }
503 }
504
505 Self {
506 company_code,
507 as_of_date,
508 by_plant,
509 total_value,
510 total_sku_count,
511 below_reorder_count,
512 out_of_stock_count,
513 }
514 }
515}
516
517#[cfg(test)]
518#[allow(clippy::unwrap_used)]
519mod tests {
520 use super::*;
521 use rust_decimal_macros::dec;
522
523 fn create_test_position() -> InventoryPosition {
524 InventoryPosition::new(
525 "MAT001".to_string(),
526 "Test Material".to_string(),
527 "PLANT01".to_string(),
528 "SLOC01".to_string(),
529 "1000".to_string(),
530 "EA".to_string(),
531 )
532 }
533
534 #[test]
535 fn test_add_quantity() {
536 let mut pos = create_test_position();
537 pos.valuation.unit_cost = dec!(10);
538
539 pos.add_quantity(
540 dec!(100),
541 dec!(1000),
542 NaiveDate::from_ymd_opt(2024, 1, 15).unwrap(),
543 );
544
545 assert_eq!(pos.quantity_on_hand, dec!(100));
546 assert_eq!(pos.quantity_available, dec!(100));
547 }
548
549 #[test]
550 fn test_reserve_quantity() {
551 let mut pos = create_test_position();
552 pos.quantity_on_hand = dec!(100);
553 pos.calculate_available();
554
555 assert!(pos.reserve(dec!(30)));
556 assert_eq!(pos.quantity_reserved, dec!(30));
557 assert_eq!(pos.quantity_available, dec!(70));
558
559 assert!(!pos.reserve(dec!(80)));
561 }
562
563 #[test]
564 fn test_stock_status() {
565 let mut pos =
566 create_test_position().with_stock_levels(dec!(10), dec!(200), dec!(50), dec!(20));
567
568 pos.add_quantity(
570 dec!(100),
571 dec!(1000),
572 NaiveDate::from_ymd_opt(2024, 1, 1).unwrap(),
573 );
574 assert_eq!(pos.status, StockStatus::Normal);
575
576 let _ = pos.remove_quantity(dec!(70), NaiveDate::from_ymd_opt(2024, 1, 1).unwrap());
578 assert_eq!(pos.status, StockStatus::BelowReorder);
580 }
581
582 #[test]
583 fn test_batch_expiration() {
584 let batch = BatchStock {
585 batch_number: "BATCH001".to_string(),
586 quantity: dec!(100),
587 manufacture_date: Some(NaiveDate::from_ymd_opt(2024, 1, 1).unwrap()),
588 expiration_date: Some(NaiveDate::from_ymd_opt(2024, 6, 30).unwrap()),
589 supplier_batch: None,
590 status: BatchStatus::Unrestricted,
591 unit_cost: dec!(10),
592 };
593
594 let before = NaiveDate::from_ymd_opt(2024, 6, 1).unwrap();
595 let after = NaiveDate::from_ymd_opt(2024, 7, 1).unwrap();
596
597 assert!(!batch.is_expired(before));
598 assert!(batch.is_expired(after));
599 assert!(batch.is_expiring_soon(before, 30));
600 }
601}