1use chrono::{DateTime, NaiveDate, Utc};
4use rust_decimal::Decimal;
5use rust_decimal_macros::dec;
6use serde::{Deserialize, Serialize};
7use std::collections::{HashMap, VecDeque};
8
9use super::{InventoryPosition, ValuationMethod};
10
11#[derive(Debug, Clone, Serialize, Deserialize)]
13pub struct InventoryValuationReport {
14 pub company_code: String,
16 pub as_of_date: NaiveDate,
18 pub valuation_method: ValuationMethod,
20 pub materials: Vec<MaterialValuation>,
22 pub total_value: Decimal,
24 pub total_quantity: Decimal,
26 pub by_plant: HashMap<String, Decimal>,
28 pub by_material_group: HashMap<String, Decimal>,
30 #[serde(with = "crate::serde_timestamp::utc")]
32 pub generated_at: DateTime<Utc>,
33}
34
35impl InventoryValuationReport {
36 pub fn from_positions(
38 company_code: String,
39 positions: &[InventoryPosition],
40 as_of_date: NaiveDate,
41 ) -> Self {
42 let mut materials = Vec::new();
43 let mut total_value = Decimal::ZERO;
44 let mut total_quantity = Decimal::ZERO;
45 let mut by_plant: HashMap<String, Decimal> = HashMap::new();
46 let by_material_group: HashMap<String, Decimal> = HashMap::new();
47
48 for pos in positions.iter().filter(|p| p.company_code == company_code) {
49 let value = pos.total_value();
50 total_value += value;
51 total_quantity += pos.quantity_on_hand;
52
53 *by_plant.entry(pos.plant.clone()).or_default() += value;
54
55 materials.push(MaterialValuation {
56 material_id: pos.material_id.clone(),
57 description: pos.description.clone(),
58 plant: pos.plant.clone(),
59 storage_location: pos.storage_location.clone(),
60 quantity: pos.quantity_on_hand,
61 unit: pos.unit.clone(),
62 unit_cost: pos.valuation.unit_cost,
63 total_value: value,
64 valuation_method: pos.valuation.method,
65 standard_cost: pos.valuation.standard_cost,
66 price_variance: pos.valuation.price_variance,
67 });
68 }
69
70 materials.sort_by(|a, b| b.total_value.cmp(&a.total_value));
72
73 Self {
74 company_code,
75 as_of_date,
76 valuation_method: ValuationMethod::StandardCost,
77 materials,
78 total_value,
79 total_quantity,
80 by_plant,
81 by_material_group,
82 generated_at: Utc::now(),
83 }
84 }
85
86 pub fn top_materials(&self, n: usize) -> Vec<&MaterialValuation> {
88 self.materials.iter().take(n).collect()
89 }
90
91 pub fn abc_analysis(&self) -> ABCAnalysis {
93 ABCAnalysis::from_valuations(&self.materials, self.total_value)
94 }
95}
96
97#[derive(Debug, Clone, Serialize, Deserialize)]
99pub struct MaterialValuation {
100 pub material_id: String,
102 pub description: String,
104 pub plant: String,
106 pub storage_location: String,
108 pub quantity: Decimal,
110 pub unit: String,
112 pub unit_cost: Decimal,
114 pub total_value: Decimal,
116 pub valuation_method: ValuationMethod,
118 pub standard_cost: Decimal,
120 pub price_variance: Decimal,
122}
123
124#[derive(Debug, Clone, Serialize, Deserialize)]
126pub struct ABCAnalysis {
127 pub a_items: Vec<ABCItem>,
129 pub b_items: Vec<ABCItem>,
131 pub c_items: Vec<ABCItem>,
133 pub a_threshold: Decimal,
135 pub b_threshold: Decimal,
137 pub summary: ABCSummary,
139}
140
141impl ABCAnalysis {
142 pub fn from_valuations(valuations: &[MaterialValuation], total_value: Decimal) -> Self {
144 let a_threshold = dec!(80);
145 let b_threshold = dec!(95);
146
147 let mut sorted: Vec<_> = valuations.iter().collect();
148 sorted.sort_by(|a, b| b.total_value.cmp(&a.total_value));
149
150 let mut a_items = Vec::new();
151 let mut b_items = Vec::new();
152 let mut c_items = Vec::new();
153
154 let mut cumulative_value = Decimal::ZERO;
155
156 for val in sorted {
157 cumulative_value += val.total_value;
158 let cumulative_percent = if total_value > Decimal::ZERO {
159 cumulative_value / total_value * dec!(100)
160 } else {
161 Decimal::ZERO
162 };
163
164 let item = ABCItem {
165 material_id: val.material_id.clone(),
166 description: val.description.clone(),
167 value: val.total_value,
168 cumulative_percent,
169 };
170
171 if cumulative_percent <= a_threshold {
172 a_items.push(item);
173 } else if cumulative_percent <= b_threshold {
174 b_items.push(item);
175 } else {
176 c_items.push(item);
177 }
178 }
179
180 let summary = ABCSummary {
181 a_count: a_items.len() as u32,
182 a_value: a_items.iter().map(|i| i.value).sum(),
183 a_percent: if total_value > Decimal::ZERO {
184 a_items.iter().map(|i| i.value).sum::<Decimal>() / total_value * dec!(100)
185 } else {
186 Decimal::ZERO
187 },
188 b_count: b_items.len() as u32,
189 b_value: b_items.iter().map(|i| i.value).sum(),
190 b_percent: if total_value > Decimal::ZERO {
191 b_items.iter().map(|i| i.value).sum::<Decimal>() / total_value * dec!(100)
192 } else {
193 Decimal::ZERO
194 },
195 c_count: c_items.len() as u32,
196 c_value: c_items.iter().map(|i| i.value).sum(),
197 c_percent: if total_value > Decimal::ZERO {
198 c_items.iter().map(|i| i.value).sum::<Decimal>() / total_value * dec!(100)
199 } else {
200 Decimal::ZERO
201 },
202 };
203
204 Self {
205 a_items,
206 b_items,
207 c_items,
208 a_threshold,
209 b_threshold,
210 summary,
211 }
212 }
213}
214
215#[derive(Debug, Clone, Serialize, Deserialize)]
217pub struct ABCItem {
218 pub material_id: String,
220 pub description: String,
222 pub value: Decimal,
224 pub cumulative_percent: Decimal,
226}
227
228#[derive(Debug, Clone, Serialize, Deserialize)]
230pub struct ABCSummary {
231 pub a_count: u32,
233 pub a_value: Decimal,
235 pub a_percent: Decimal,
237 pub b_count: u32,
239 pub b_value: Decimal,
241 pub b_percent: Decimal,
243 pub c_count: u32,
245 pub c_value: Decimal,
247 pub c_percent: Decimal,
249}
250
251#[derive(Debug, Clone, Serialize, Deserialize)]
253pub struct FIFOLayer {
254 pub receipt_date: NaiveDate,
256 pub receipt_document: String,
258 pub quantity: Decimal,
260 pub unit_cost: Decimal,
262}
263
264#[derive(Debug, Clone, Serialize, Deserialize)]
266pub struct FIFOTracker {
267 pub material_id: String,
269 pub layers: VecDeque<FIFOLayer>,
271 pub total_quantity: Decimal,
273 pub total_value: Decimal,
275}
276
277impl FIFOTracker {
278 pub fn new(material_id: String) -> Self {
280 Self {
281 material_id,
282 layers: VecDeque::new(),
283 total_quantity: Decimal::ZERO,
284 total_value: Decimal::ZERO,
285 }
286 }
287
288 pub fn receive(
290 &mut self,
291 date: NaiveDate,
292 document: String,
293 quantity: Decimal,
294 unit_cost: Decimal,
295 ) {
296 self.layers.push_back(FIFOLayer {
297 receipt_date: date,
298 receipt_document: document,
299 quantity,
300 unit_cost,
301 });
302 self.total_quantity += quantity;
303 self.total_value += quantity * unit_cost;
304 }
305
306 pub fn issue(&mut self, quantity: Decimal) -> Option<Decimal> {
308 if quantity > self.total_quantity {
309 return None;
310 }
311
312 let mut remaining = quantity;
313 let mut total_cost = Decimal::ZERO;
314
315 while remaining > Decimal::ZERO && !self.layers.is_empty() {
316 let front = self
317 .layers
318 .front_mut()
319 .expect("FIFO layer exists when remaining > 0");
320
321 if front.quantity <= remaining {
322 total_cost += front.quantity * front.unit_cost;
324 remaining -= front.quantity;
325 self.layers.pop_front();
326 } else {
327 total_cost += remaining * front.unit_cost;
329 front.quantity -= remaining;
330 remaining = Decimal::ZERO;
331 }
332 }
333
334 self.total_quantity -= quantity;
335 self.total_value -= total_cost;
336
337 Some(total_cost)
338 }
339
340 pub fn weighted_average_cost(&self) -> Decimal {
342 if self.total_quantity > Decimal::ZERO {
343 self.total_value / self.total_quantity
344 } else {
345 Decimal::ZERO
346 }
347 }
348}
349
350#[derive(Debug, Clone, Serialize, Deserialize)]
352pub struct CostVarianceAnalysis {
353 pub company_code: String,
355 pub period: String,
357 pub variances: Vec<MaterialCostVariance>,
359 pub total_price_variance: Decimal,
361 pub total_quantity_variance: Decimal,
363 #[serde(with = "crate::serde_timestamp::utc")]
365 pub generated_at: DateTime<Utc>,
366}
367
368#[derive(Debug, Clone, Serialize, Deserialize)]
370pub struct MaterialCostVariance {
371 pub material_id: String,
373 pub description: String,
375 pub standard_cost: Decimal,
377 pub actual_cost: Decimal,
379 pub quantity: Decimal,
381 pub price_variance: Decimal,
383 pub variance_percent: Decimal,
385 pub is_favorable: bool,
387}
388
389impl MaterialCostVariance {
390 pub fn new(
392 material_id: String,
393 description: String,
394 standard_cost: Decimal,
395 actual_cost: Decimal,
396 quantity: Decimal,
397 ) -> Self {
398 let price_variance = (standard_cost - actual_cost) * quantity;
399 let variance_percent = if actual_cost > Decimal::ZERO {
400 ((standard_cost - actual_cost) / actual_cost * dec!(100)).round_dp(2)
401 } else {
402 Decimal::ZERO
403 };
404 let is_favorable = price_variance > Decimal::ZERO;
405
406 Self {
407 material_id,
408 description,
409 standard_cost,
410 actual_cost,
411 quantity,
412 price_variance,
413 variance_percent,
414 is_favorable,
415 }
416 }
417}
418
419#[derive(Debug, Clone, Serialize, Deserialize)]
421pub struct InventoryTurnover {
422 pub company_code: String,
424 pub period_start: NaiveDate,
426 pub period_end: NaiveDate,
428 pub average_inventory: Decimal,
430 pub cogs: Decimal,
432 pub turnover_ratio: Decimal,
434 pub dio_days: Decimal,
436 pub by_material: Vec<MaterialTurnover>,
438}
439
440impl InventoryTurnover {
441 pub fn calculate(
443 company_code: String,
444 period_start: NaiveDate,
445 period_end: NaiveDate,
446 beginning_inventory: Decimal,
447 ending_inventory: Decimal,
448 cogs: Decimal,
449 ) -> Self {
450 let average_inventory = (beginning_inventory + ending_inventory) / dec!(2);
451 let days_in_period = (period_end - period_start).num_days() as i32;
452
453 let turnover_ratio = if average_inventory > Decimal::ZERO {
454 (cogs / average_inventory).round_dp(2)
455 } else {
456 Decimal::ZERO
457 };
458
459 let dio_days = if cogs > Decimal::ZERO {
460 (average_inventory / cogs * Decimal::from(days_in_period)).round_dp(1)
461 } else {
462 Decimal::ZERO
463 };
464
465 Self {
466 company_code,
467 period_start,
468 period_end,
469 average_inventory,
470 cogs,
471 turnover_ratio,
472 dio_days,
473 by_material: Vec::new(),
474 }
475 }
476}
477
478#[derive(Debug, Clone, Serialize, Deserialize)]
480pub struct MaterialTurnover {
481 pub material_id: String,
483 pub description: String,
485 pub average_inventory: Decimal,
487 pub usage: Decimal,
489 pub turnover_ratio: Decimal,
491 pub days_of_supply: Decimal,
493 pub classification: TurnoverClassification,
495}
496
497#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
499pub enum TurnoverClassification {
500 FastMoving,
502 Normal,
504 SlowMoving,
506 Dead,
508}
509
510#[cfg(test)]
511#[allow(clippy::unwrap_used)]
512mod tests {
513 use super::*;
514
515 #[test]
516 fn test_fifo_tracker() {
517 let mut tracker = FIFOTracker::new("MAT001".to_string());
518
519 tracker.receive(
521 NaiveDate::from_ymd_opt(2024, 1, 1).unwrap(),
522 "GR001".to_string(),
523 dec!(100),
524 dec!(10),
525 );
526
527 tracker.receive(
529 NaiveDate::from_ymd_opt(2024, 1, 15).unwrap(),
530 "GR002".to_string(),
531 dec!(100),
532 dec!(12),
533 );
534
535 assert_eq!(tracker.total_quantity, dec!(200));
536 assert_eq!(tracker.total_value, dec!(2200)); let cost = tracker.issue(dec!(150)).unwrap();
540 assert_eq!(cost, dec!(1600)); assert_eq!(tracker.total_quantity, dec!(50));
542 }
543
544 #[test]
545 fn test_abc_analysis() {
546 let valuations = vec![
547 MaterialValuation {
548 material_id: "A".to_string(),
549 description: "High value".to_string(),
550 plant: "P1".to_string(),
551 storage_location: "S1".to_string(),
552 quantity: dec!(10),
553 unit: "EA".to_string(),
554 unit_cost: dec!(100),
555 total_value: dec!(1000),
556 valuation_method: ValuationMethod::StandardCost,
557 standard_cost: dec!(100),
558 price_variance: Decimal::ZERO,
559 },
560 MaterialValuation {
561 material_id: "B".to_string(),
562 description: "Medium value".to_string(),
563 plant: "P1".to_string(),
564 storage_location: "S1".to_string(),
565 quantity: dec!(50),
566 unit: "EA".to_string(),
567 unit_cost: dec!(10),
568 total_value: dec!(500),
569 valuation_method: ValuationMethod::StandardCost,
570 standard_cost: dec!(10),
571 price_variance: Decimal::ZERO,
572 },
573 MaterialValuation {
574 material_id: "C".to_string(),
575 description: "Low value".to_string(),
576 plant: "P1".to_string(),
577 storage_location: "S1".to_string(),
578 quantity: dec!(100),
579 unit: "EA".to_string(),
580 unit_cost: dec!(1),
581 total_value: dec!(100),
582 valuation_method: ValuationMethod::StandardCost,
583 standard_cost: dec!(1),
584 price_variance: Decimal::ZERO,
585 },
586 ];
587
588 let total = dec!(1600);
589 let analysis = ABCAnalysis::from_valuations(&valuations, total);
590
591 assert!(!analysis.a_items.is_empty());
593 }
594
595 #[test]
596 fn test_inventory_turnover() {
597 let turnover = InventoryTurnover::calculate(
598 "1000".to_string(),
599 NaiveDate::from_ymd_opt(2024, 1, 1).unwrap(),
600 NaiveDate::from_ymd_opt(2024, 12, 31).unwrap(),
601 dec!(100_000),
602 dec!(120_000),
603 dec!(1_000_000),
604 );
605
606 assert!(turnover.turnover_ratio > dec!(9));
609
610 assert!(turnover.dio_days > dec!(30) && turnover.dio_days < dec!(50));
612 }
613
614 #[test]
615 fn test_cost_variance() {
616 let variance = MaterialCostVariance::new(
617 "MAT001".to_string(),
618 "Test Material".to_string(),
619 dec!(10), dec!(11), dec!(100), );
623
624 assert_eq!(variance.price_variance, dec!(-100));
626 assert!(!variance.is_favorable);
627 }
628}