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 #[serde(with = "crate::serde_timestamp::utc")]
57 pub updated_at: DateTime<Utc>,
58}
59
60impl InventoryPosition {
61 pub fn new(
63 material_id: String,
64 description: String,
65 plant: String,
66 storage_location: String,
67 company_code: String,
68 unit: String,
69 ) -> Self {
70 Self {
71 material_id,
72 description,
73 plant,
74 storage_location,
75 company_code,
76 quantity_on_hand: Decimal::ZERO,
77 unit,
78 quantity_reserved: Decimal::ZERO,
79 quantity_available: Decimal::ZERO,
80 quantity_in_inspection: Decimal::ZERO,
81 quantity_blocked: Decimal::ZERO,
82 quantity_in_transit: Decimal::ZERO,
83 valuation: PositionValuation::default(),
84 last_movement_date: None,
85 last_count_date: None,
86 min_stock: None,
87 max_stock: None,
88 reorder_point: None,
89 safety_stock: None,
90 status: StockStatus::Normal,
91 batches: Vec::new(),
92 serial_numbers: Vec::new(),
93 updated_at: Utc::now(),
94 }
95 }
96
97 pub fn calculate_available(&mut self) {
99 self.quantity_available = self.quantity_on_hand
100 - self.quantity_reserved
101 - self.quantity_in_inspection
102 - self.quantity_blocked;
103 }
104
105 pub fn add_quantity(&mut self, quantity: Decimal, cost: Decimal, date: NaiveDate) {
107 self.quantity_on_hand += quantity;
108 self.valuation.update_on_receipt(quantity, cost);
109 self.last_movement_date = Some(date);
110 self.calculate_available();
111 self.update_status();
112 self.updated_at = Utc::now();
113 }
114
115 pub fn remove_quantity(&mut self, quantity: Decimal, date: NaiveDate) -> Option<Decimal> {
117 if quantity > self.quantity_available {
118 return None;
119 }
120
121 let cost = self.valuation.calculate_issue_cost(quantity);
122 self.quantity_on_hand -= quantity;
123 self.last_movement_date = Some(date);
124 self.calculate_available();
125 self.update_status();
126 self.updated_at = Utc::now();
127
128 Some(cost)
129 }
130
131 pub fn reserve(&mut self, quantity: Decimal) -> bool {
133 if quantity > self.quantity_available {
134 return false;
135 }
136 self.quantity_reserved += quantity;
137 self.calculate_available();
138 self.updated_at = Utc::now();
139 true
140 }
141
142 pub fn release_reservation(&mut self, quantity: Decimal) {
144 self.quantity_reserved = (self.quantity_reserved - quantity).max(Decimal::ZERO);
145 self.calculate_available();
146 self.updated_at = Utc::now();
147 }
148
149 pub fn block(&mut self, quantity: Decimal) {
151 self.quantity_blocked += quantity;
152 self.calculate_available();
153 self.updated_at = Utc::now();
154 }
155
156 pub fn unblock(&mut self, quantity: Decimal) {
158 self.quantity_blocked = (self.quantity_blocked - quantity).max(Decimal::ZERO);
159 self.calculate_available();
160 self.updated_at = Utc::now();
161 }
162
163 fn update_status(&mut self) {
165 if self.quantity_on_hand <= Decimal::ZERO {
166 self.status = StockStatus::OutOfStock;
167 } else if let Some(safety) = self.safety_stock {
168 if self.quantity_on_hand <= safety {
169 self.status = StockStatus::BelowSafety;
170 } else if let Some(reorder) = self.reorder_point {
171 if self.quantity_on_hand <= reorder {
172 self.status = StockStatus::BelowReorder;
173 } else {
174 self.status = StockStatus::Normal;
175 }
176 } else {
177 self.status = StockStatus::Normal;
178 }
179 } else {
180 self.status = if self.quantity_on_hand > Decimal::ZERO {
181 StockStatus::Normal
182 } else {
183 StockStatus::OutOfStock
184 };
185 }
186 }
187
188 pub fn with_stock_levels(
190 mut self,
191 min: Decimal,
192 max: Decimal,
193 reorder: Decimal,
194 safety: Decimal,
195 ) -> Self {
196 self.min_stock = Some(min);
197 self.max_stock = Some(max);
198 self.reorder_point = Some(reorder);
199 self.safety_stock = Some(safety);
200 self.update_status();
201 self
202 }
203
204 pub fn total_value(&self) -> Decimal {
206 self.quantity_on_hand * self.valuation.unit_cost
207 }
208
209 pub fn needs_reorder(&self) -> bool {
211 self.reorder_point
212 .map(|rp| self.quantity_available <= rp)
213 .unwrap_or(false)
214 }
215
216 pub fn days_of_supply(&self, average_daily_usage: Decimal) -> Option<Decimal> {
218 if average_daily_usage > Decimal::ZERO {
219 Some((self.quantity_available / average_daily_usage).round_dp(1))
220 } else {
221 None
222 }
223 }
224}
225
226#[derive(Debug, Clone, Default, Serialize, Deserialize)]
228pub struct PositionValuation {
229 pub method: ValuationMethod,
231 pub standard_cost: Decimal,
233 pub unit_cost: Decimal,
235 pub total_value: Decimal,
237 pub price_variance: Decimal,
239 pub last_price_change: Option<NaiveDate>,
241}
242
243impl PositionValuation {
244 pub fn update_on_receipt(&mut self, receipt_qty: Decimal, cost: Decimal) {
254 match self.method {
255 ValuationMethod::StandardCost => {
256 let actual_cost = cost;
257 let standard_cost = receipt_qty * self.standard_cost;
258 self.price_variance += actual_cost - standard_cost;
259 self.total_value += standard_cost;
260 }
261 ValuationMethod::MovingAverage => {
262 let existing_qty = if self.unit_cost > Decimal::ZERO {
267 self.total_value / self.unit_cost
268 } else {
269 Decimal::ZERO
270 };
271 let new_qty = existing_qty + receipt_qty;
272 self.total_value += cost;
273 if new_qty > Decimal::ZERO {
274 self.unit_cost = (self.total_value / new_qty).round_dp(4);
275 }
276 }
277 ValuationMethod::FIFO | ValuationMethod::LIFO => {
278 self.total_value += cost;
279 }
280 }
281 }
282
283 pub fn calculate_issue_cost(&mut self, quantity: Decimal) -> Decimal {
285 let cost = quantity * self.unit_cost;
286 self.total_value = (self.total_value - cost).max(Decimal::ZERO);
287 cost
288 }
289}
290
291#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize)]
293pub enum ValuationMethod {
294 #[default]
296 StandardCost,
297 MovingAverage,
299 FIFO,
301 LIFO,
303}
304
305#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize)]
307pub enum StockStatus {
308 #[default]
310 Normal,
311 BelowReorder,
313 BelowSafety,
315 OutOfStock,
317 OverMax,
319 Obsolete,
321}
322
323#[derive(Debug, Clone, Serialize, Deserialize)]
325pub struct BatchStock {
326 pub batch_number: String,
328 pub quantity: Decimal,
330 pub manufacture_date: Option<NaiveDate>,
332 pub expiration_date: Option<NaiveDate>,
334 pub supplier_batch: Option<String>,
336 pub status: BatchStatus,
338 pub unit_cost: Decimal,
340}
341
342impl BatchStock {
343 pub fn new(batch_number: String, quantity: Decimal, unit_cost: Decimal) -> Self {
345 Self {
346 batch_number,
347 quantity,
348 manufacture_date: None,
349 expiration_date: None,
350 supplier_batch: None,
351 status: BatchStatus::Unrestricted,
352 unit_cost,
353 }
354 }
355
356 pub fn is_expired(&self, as_of_date: NaiveDate) -> bool {
358 self.expiration_date
359 .map(|exp| as_of_date > exp)
360 .unwrap_or(false)
361 }
362
363 pub fn is_expiring_soon(&self, as_of_date: NaiveDate, days: i64) -> bool {
365 self.expiration_date
366 .map(|exp| {
367 let threshold = as_of_date + chrono::Duration::days(days);
368 as_of_date <= exp && exp <= threshold
369 })
370 .unwrap_or(false)
371 }
372}
373
374#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize)]
376pub enum BatchStatus {
377 #[default]
379 Unrestricted,
380 InInspection,
382 Blocked,
384 Expired,
386 Reserved,
388}
389
390#[derive(Debug, Clone, Serialize, Deserialize)]
392pub struct SerialNumber {
393 pub serial_number: String,
395 pub status: SerialStatus,
397 pub receipt_date: NaiveDate,
399 pub issue_date: Option<NaiveDate>,
401 pub customer_id: Option<String>,
403 pub warranty_expiration: Option<NaiveDate>,
405}
406
407#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize)]
409pub enum SerialStatus {
410 #[default]
412 InStock,
413 Reserved,
415 Issued,
417 InRepair,
419 Scrapped,
421}
422
423#[derive(Debug, Clone, Serialize, Deserialize)]
425pub struct InventorySummary {
426 pub company_code: String,
428 pub as_of_date: NaiveDate,
430 pub by_plant: HashMap<String, PlantInventorySummary>,
432 pub total_value: Decimal,
434 pub total_sku_count: u32,
436 pub below_reorder_count: u32,
438 pub out_of_stock_count: u32,
440}
441
442#[derive(Debug, Clone, Serialize, Deserialize)]
444pub struct PlantInventorySummary {
445 pub plant: String,
447 pub total_value: Decimal,
449 pub sku_count: u32,
451 pub below_reorder_count: u32,
453 pub out_of_stock_count: u32,
455 pub total_quantity: Decimal,
457}
458
459impl InventorySummary {
460 pub fn from_positions(
462 company_code: String,
463 positions: &[InventoryPosition],
464 as_of_date: NaiveDate,
465 ) -> Self {
466 let mut by_plant: HashMap<String, PlantInventorySummary> = HashMap::new();
467 let mut total_value = Decimal::ZERO;
468 let mut total_sku_count = 0u32;
469 let mut below_reorder_count = 0u32;
470 let mut out_of_stock_count = 0u32;
471
472 for pos in positions.iter().filter(|p| p.company_code == company_code) {
473 let plant_summary =
474 by_plant
475 .entry(pos.plant.clone())
476 .or_insert_with(|| PlantInventorySummary {
477 plant: pos.plant.clone(),
478 total_value: Decimal::ZERO,
479 sku_count: 0,
480 below_reorder_count: 0,
481 out_of_stock_count: 0,
482 total_quantity: Decimal::ZERO,
483 });
484
485 let value = pos.total_value();
486 plant_summary.total_value += value;
487 plant_summary.sku_count += 1;
488 plant_summary.total_quantity += pos.quantity_on_hand;
489
490 total_value += value;
491 total_sku_count += 1;
492
493 match pos.status {
494 StockStatus::BelowReorder | StockStatus::BelowSafety => {
495 plant_summary.below_reorder_count += 1;
496 below_reorder_count += 1;
497 }
498 StockStatus::OutOfStock => {
499 plant_summary.out_of_stock_count += 1;
500 out_of_stock_count += 1;
501 }
502 _ => {}
503 }
504 }
505
506 Self {
507 company_code,
508 as_of_date,
509 by_plant,
510 total_value,
511 total_sku_count,
512 below_reorder_count,
513 out_of_stock_count,
514 }
515 }
516}
517
518#[cfg(test)]
519#[allow(clippy::unwrap_used)]
520mod tests {
521 use super::*;
522 use rust_decimal_macros::dec;
523
524 fn create_test_position() -> InventoryPosition {
525 InventoryPosition::new(
526 "MAT001".to_string(),
527 "Test Material".to_string(),
528 "PLANT01".to_string(),
529 "SLOC01".to_string(),
530 "1000".to_string(),
531 "EA".to_string(),
532 )
533 }
534
535 #[test]
536 fn test_add_quantity() {
537 let mut pos = create_test_position();
538 pos.valuation.unit_cost = dec!(10);
539
540 pos.add_quantity(
541 dec!(100),
542 dec!(1000),
543 NaiveDate::from_ymd_opt(2024, 1, 15).unwrap(),
544 );
545
546 assert_eq!(pos.quantity_on_hand, dec!(100));
547 assert_eq!(pos.quantity_available, dec!(100));
548 }
549
550 #[test]
551 fn test_reserve_quantity() {
552 let mut pos = create_test_position();
553 pos.quantity_on_hand = dec!(100);
554 pos.calculate_available();
555
556 assert!(pos.reserve(dec!(30)));
557 assert_eq!(pos.quantity_reserved, dec!(30));
558 assert_eq!(pos.quantity_available, dec!(70));
559
560 assert!(!pos.reserve(dec!(80)));
562 }
563
564 #[test]
565 fn test_stock_status() {
566 let mut pos =
567 create_test_position().with_stock_levels(dec!(10), dec!(200), dec!(50), dec!(20));
568
569 pos.add_quantity(
571 dec!(100),
572 dec!(1000),
573 NaiveDate::from_ymd_opt(2024, 1, 1).unwrap(),
574 );
575 assert_eq!(pos.status, StockStatus::Normal);
576
577 let _ = pos.remove_quantity(dec!(70), NaiveDate::from_ymd_opt(2024, 1, 1).unwrap());
579 assert_eq!(pos.status, StockStatus::BelowReorder);
581 }
582
583 #[test]
584 fn test_batch_expiration() {
585 let batch = BatchStock {
586 batch_number: "BATCH001".to_string(),
587 quantity: dec!(100),
588 manufacture_date: Some(NaiveDate::from_ymd_opt(2024, 1, 1).unwrap()),
589 expiration_date: Some(NaiveDate::from_ymd_opt(2024, 6, 30).unwrap()),
590 supplier_batch: None,
591 status: BatchStatus::Unrestricted,
592 unit_cost: dec!(10),
593 };
594
595 let before = NaiveDate::from_ymd_opt(2024, 6, 1).unwrap();
596 let after = NaiveDate::from_ymd_opt(2024, 7, 1).unwrap();
597
598 assert!(!batch.is_expired(before));
599 assert!(batch.is_expired(after));
600 assert!(batch.is_expiring_soon(before, 30));
601 }
602}