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, quantity: Decimal, cost: Decimal) {
245 match self.method {
246 ValuationMethod::StandardCost => {
247 let actual_cost = cost;
248 let standard_cost = quantity * self.standard_cost;
249 self.price_variance += actual_cost - standard_cost;
250 self.total_value += standard_cost;
251 }
252 ValuationMethod::MovingAverage => {
253 let new_total = self.total_value + cost;
254 self.total_value = new_total;
256 }
257 ValuationMethod::FIFO | ValuationMethod::LIFO => {
258 self.total_value += cost;
259 }
260 }
261 }
262
263 pub fn calculate_issue_cost(&mut self, quantity: Decimal) -> Decimal {
265 let cost = quantity * self.unit_cost;
266 self.total_value = (self.total_value - cost).max(Decimal::ZERO);
267 cost
268 }
269}
270
271#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize)]
273pub enum ValuationMethod {
274 #[default]
276 StandardCost,
277 MovingAverage,
279 FIFO,
281 LIFO,
283}
284
285#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize)]
287pub enum StockStatus {
288 #[default]
290 Normal,
291 BelowReorder,
293 BelowSafety,
295 OutOfStock,
297 OverMax,
299 Obsolete,
301}
302
303#[derive(Debug, Clone, Serialize, Deserialize)]
305pub struct BatchStock {
306 pub batch_number: String,
308 pub quantity: Decimal,
310 pub manufacture_date: Option<NaiveDate>,
312 pub expiration_date: Option<NaiveDate>,
314 pub supplier_batch: Option<String>,
316 pub status: BatchStatus,
318 pub unit_cost: Decimal,
320}
321
322impl BatchStock {
323 pub fn new(batch_number: String, quantity: Decimal, unit_cost: Decimal) -> Self {
325 Self {
326 batch_number,
327 quantity,
328 manufacture_date: None,
329 expiration_date: None,
330 supplier_batch: None,
331 status: BatchStatus::Unrestricted,
332 unit_cost,
333 }
334 }
335
336 pub fn is_expired(&self, as_of_date: NaiveDate) -> bool {
338 self.expiration_date
339 .map(|exp| as_of_date > exp)
340 .unwrap_or(false)
341 }
342
343 pub fn is_expiring_soon(&self, as_of_date: NaiveDate, days: i64) -> bool {
345 self.expiration_date
346 .map(|exp| {
347 let threshold = as_of_date + chrono::Duration::days(days);
348 as_of_date <= exp && exp <= threshold
349 })
350 .unwrap_or(false)
351 }
352}
353
354#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize)]
356pub enum BatchStatus {
357 #[default]
359 Unrestricted,
360 InInspection,
362 Blocked,
364 Expired,
366 Reserved,
368}
369
370#[derive(Debug, Clone, Serialize, Deserialize)]
372pub struct SerialNumber {
373 pub serial_number: String,
375 pub status: SerialStatus,
377 pub receipt_date: NaiveDate,
379 pub issue_date: Option<NaiveDate>,
381 pub customer_id: Option<String>,
383 pub warranty_expiration: Option<NaiveDate>,
385}
386
387#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize)]
389pub enum SerialStatus {
390 #[default]
392 InStock,
393 Reserved,
395 Issued,
397 InRepair,
399 Scrapped,
401}
402
403#[derive(Debug, Clone, Serialize, Deserialize)]
405pub struct InventorySummary {
406 pub company_code: String,
408 pub as_of_date: NaiveDate,
410 pub by_plant: HashMap<String, PlantInventorySummary>,
412 pub total_value: Decimal,
414 pub total_sku_count: u32,
416 pub below_reorder_count: u32,
418 pub out_of_stock_count: u32,
420}
421
422#[derive(Debug, Clone, Serialize, Deserialize)]
424pub struct PlantInventorySummary {
425 pub plant: String,
427 pub total_value: Decimal,
429 pub sku_count: u32,
431 pub below_reorder_count: u32,
433 pub out_of_stock_count: u32,
435 pub total_quantity: Decimal,
437}
438
439impl InventorySummary {
440 pub fn from_positions(
442 company_code: String,
443 positions: &[InventoryPosition],
444 as_of_date: NaiveDate,
445 ) -> Self {
446 let mut by_plant: HashMap<String, PlantInventorySummary> = HashMap::new();
447 let mut total_value = Decimal::ZERO;
448 let mut total_sku_count = 0u32;
449 let mut below_reorder_count = 0u32;
450 let mut out_of_stock_count = 0u32;
451
452 for pos in positions.iter().filter(|p| p.company_code == company_code) {
453 let plant_summary =
454 by_plant
455 .entry(pos.plant.clone())
456 .or_insert_with(|| PlantInventorySummary {
457 plant: pos.plant.clone(),
458 total_value: Decimal::ZERO,
459 sku_count: 0,
460 below_reorder_count: 0,
461 out_of_stock_count: 0,
462 total_quantity: Decimal::ZERO,
463 });
464
465 let value = pos.total_value();
466 plant_summary.total_value += value;
467 plant_summary.sku_count += 1;
468 plant_summary.total_quantity += pos.quantity_on_hand;
469
470 total_value += value;
471 total_sku_count += 1;
472
473 match pos.status {
474 StockStatus::BelowReorder | StockStatus::BelowSafety => {
475 plant_summary.below_reorder_count += 1;
476 below_reorder_count += 1;
477 }
478 StockStatus::OutOfStock => {
479 plant_summary.out_of_stock_count += 1;
480 out_of_stock_count += 1;
481 }
482 _ => {}
483 }
484 }
485
486 Self {
487 company_code,
488 as_of_date,
489 by_plant,
490 total_value,
491 total_sku_count,
492 below_reorder_count,
493 out_of_stock_count,
494 }
495 }
496}
497
498#[cfg(test)]
499mod tests {
500 use super::*;
501 use rust_decimal_macros::dec;
502
503 fn create_test_position() -> InventoryPosition {
504 InventoryPosition::new(
505 "MAT001".to_string(),
506 "Test Material".to_string(),
507 "PLANT01".to_string(),
508 "SLOC01".to_string(),
509 "1000".to_string(),
510 "EA".to_string(),
511 )
512 }
513
514 #[test]
515 fn test_add_quantity() {
516 let mut pos = create_test_position();
517 pos.valuation.unit_cost = dec!(10);
518
519 pos.add_quantity(
520 dec!(100),
521 dec!(1000),
522 NaiveDate::from_ymd_opt(2024, 1, 15).unwrap(),
523 );
524
525 assert_eq!(pos.quantity_on_hand, dec!(100));
526 assert_eq!(pos.quantity_available, dec!(100));
527 }
528
529 #[test]
530 fn test_reserve_quantity() {
531 let mut pos = create_test_position();
532 pos.quantity_on_hand = dec!(100);
533 pos.calculate_available();
534
535 assert!(pos.reserve(dec!(30)));
536 assert_eq!(pos.quantity_reserved, dec!(30));
537 assert_eq!(pos.quantity_available, dec!(70));
538
539 assert!(!pos.reserve(dec!(80)));
541 }
542
543 #[test]
544 fn test_stock_status() {
545 let mut pos =
546 create_test_position().with_stock_levels(dec!(10), dec!(200), dec!(50), dec!(20));
547
548 pos.add_quantity(
550 dec!(100),
551 dec!(1000),
552 NaiveDate::from_ymd_opt(2024, 1, 1).unwrap(),
553 );
554 assert_eq!(pos.status, StockStatus::Normal);
555
556 let _ = pos.remove_quantity(dec!(70), NaiveDate::from_ymd_opt(2024, 1, 1).unwrap());
558 assert_eq!(pos.status, StockStatus::BelowReorder);
560 }
561
562 #[test]
563 fn test_batch_expiration() {
564 let batch = BatchStock {
565 batch_number: "BATCH001".to_string(),
566 quantity: dec!(100),
567 manufacture_date: Some(NaiveDate::from_ymd_opt(2024, 1, 1).unwrap()),
568 expiration_date: Some(NaiveDate::from_ymd_opt(2024, 6, 30).unwrap()),
569 supplier_batch: None,
570 status: BatchStatus::Unrestricted,
571 unit_cost: dec!(10),
572 };
573
574 let before = NaiveDate::from_ymd_opt(2024, 6, 1).unwrap();
575 let after = NaiveDate::from_ymd_opt(2024, 7, 1).unwrap();
576
577 assert!(!batch.is_expired(before));
578 assert!(batch.is_expired(after));
579 assert!(batch.is_expiring_soon(before, 30));
580 }
581}