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 pub generated_at: DateTime<Utc>,
32}
33
34impl InventoryValuationReport {
35 pub fn from_positions(
37 company_code: String,
38 positions: &[InventoryPosition],
39 as_of_date: NaiveDate,
40 ) -> Self {
41 let mut materials = Vec::new();
42 let mut total_value = Decimal::ZERO;
43 let mut total_quantity = Decimal::ZERO;
44 let mut by_plant: HashMap<String, Decimal> = HashMap::new();
45 let by_material_group: HashMap<String, Decimal> = HashMap::new();
46
47 for pos in positions.iter().filter(|p| p.company_code == company_code) {
48 let value = pos.total_value();
49 total_value += value;
50 total_quantity += pos.quantity_on_hand;
51
52 *by_plant.entry(pos.plant.clone()).or_default() += value;
53
54 materials.push(MaterialValuation {
55 material_id: pos.material_id.clone(),
56 description: pos.description.clone(),
57 plant: pos.plant.clone(),
58 storage_location: pos.storage_location.clone(),
59 quantity: pos.quantity_on_hand,
60 unit: pos.unit.clone(),
61 unit_cost: pos.valuation.unit_cost,
62 total_value: value,
63 valuation_method: pos.valuation.method,
64 standard_cost: pos.valuation.standard_cost,
65 price_variance: pos.valuation.price_variance,
66 });
67 }
68
69 materials.sort_by(|a, b| b.total_value.cmp(&a.total_value));
71
72 Self {
73 company_code,
74 as_of_date,
75 valuation_method: ValuationMethod::StandardCost,
76 materials,
77 total_value,
78 total_quantity,
79 by_plant,
80 by_material_group,
81 generated_at: Utc::now(),
82 }
83 }
84
85 pub fn top_materials(&self, n: usize) -> Vec<&MaterialValuation> {
87 self.materials.iter().take(n).collect()
88 }
89
90 pub fn abc_analysis(&self) -> ABCAnalysis {
92 ABCAnalysis::from_valuations(&self.materials, self.total_value)
93 }
94}
95
96#[derive(Debug, Clone, Serialize, Deserialize)]
98pub struct MaterialValuation {
99 pub material_id: String,
101 pub description: String,
103 pub plant: String,
105 pub storage_location: String,
107 pub quantity: Decimal,
109 pub unit: String,
111 pub unit_cost: Decimal,
113 pub total_value: Decimal,
115 pub valuation_method: ValuationMethod,
117 pub standard_cost: Decimal,
119 pub price_variance: Decimal,
121}
122
123#[derive(Debug, Clone, Serialize, Deserialize)]
125pub struct ABCAnalysis {
126 pub a_items: Vec<ABCItem>,
128 pub b_items: Vec<ABCItem>,
130 pub c_items: Vec<ABCItem>,
132 pub a_threshold: Decimal,
134 pub b_threshold: Decimal,
136 pub summary: ABCSummary,
138}
139
140impl ABCAnalysis {
141 pub fn from_valuations(valuations: &[MaterialValuation], total_value: Decimal) -> Self {
143 let a_threshold = dec!(80);
144 let b_threshold = dec!(95);
145
146 let mut sorted: Vec<_> = valuations.iter().collect();
147 sorted.sort_by(|a, b| b.total_value.cmp(&a.total_value));
148
149 let mut a_items = Vec::new();
150 let mut b_items = Vec::new();
151 let mut c_items = Vec::new();
152
153 let mut cumulative_value = Decimal::ZERO;
154
155 for val in sorted {
156 cumulative_value += val.total_value;
157 let cumulative_percent = if total_value > Decimal::ZERO {
158 cumulative_value / total_value * dec!(100)
159 } else {
160 Decimal::ZERO
161 };
162
163 let item = ABCItem {
164 material_id: val.material_id.clone(),
165 description: val.description.clone(),
166 value: val.total_value,
167 cumulative_percent,
168 };
169
170 if cumulative_percent <= a_threshold {
171 a_items.push(item);
172 } else if cumulative_percent <= b_threshold {
173 b_items.push(item);
174 } else {
175 c_items.push(item);
176 }
177 }
178
179 let summary = ABCSummary {
180 a_count: a_items.len() as u32,
181 a_value: a_items.iter().map(|i| i.value).sum(),
182 a_percent: if total_value > Decimal::ZERO {
183 a_items.iter().map(|i| i.value).sum::<Decimal>() / total_value * dec!(100)
184 } else {
185 Decimal::ZERO
186 },
187 b_count: b_items.len() as u32,
188 b_value: b_items.iter().map(|i| i.value).sum(),
189 b_percent: if total_value > Decimal::ZERO {
190 b_items.iter().map(|i| i.value).sum::<Decimal>() / total_value * dec!(100)
191 } else {
192 Decimal::ZERO
193 },
194 c_count: c_items.len() as u32,
195 c_value: c_items.iter().map(|i| i.value).sum(),
196 c_percent: if total_value > Decimal::ZERO {
197 c_items.iter().map(|i| i.value).sum::<Decimal>() / total_value * dec!(100)
198 } else {
199 Decimal::ZERO
200 },
201 };
202
203 Self {
204 a_items,
205 b_items,
206 c_items,
207 a_threshold,
208 b_threshold,
209 summary,
210 }
211 }
212}
213
214#[derive(Debug, Clone, Serialize, Deserialize)]
216pub struct ABCItem {
217 pub material_id: String,
219 pub description: String,
221 pub value: Decimal,
223 pub cumulative_percent: Decimal,
225}
226
227#[derive(Debug, Clone, Serialize, Deserialize)]
229pub struct ABCSummary {
230 pub a_count: u32,
232 pub a_value: Decimal,
234 pub a_percent: Decimal,
236 pub b_count: u32,
238 pub b_value: Decimal,
240 pub b_percent: Decimal,
242 pub c_count: u32,
244 pub c_value: Decimal,
246 pub c_percent: Decimal,
248}
249
250#[derive(Debug, Clone, Serialize, Deserialize)]
252pub struct FIFOLayer {
253 pub receipt_date: NaiveDate,
255 pub receipt_document: String,
257 pub quantity: Decimal,
259 pub unit_cost: Decimal,
261}
262
263#[derive(Debug, Clone, Serialize, Deserialize)]
265pub struct FIFOTracker {
266 pub material_id: String,
268 pub layers: VecDeque<FIFOLayer>,
270 pub total_quantity: Decimal,
272 pub total_value: Decimal,
274}
275
276impl FIFOTracker {
277 pub fn new(material_id: String) -> Self {
279 Self {
280 material_id,
281 layers: VecDeque::new(),
282 total_quantity: Decimal::ZERO,
283 total_value: Decimal::ZERO,
284 }
285 }
286
287 pub fn receive(
289 &mut self,
290 date: NaiveDate,
291 document: String,
292 quantity: Decimal,
293 unit_cost: Decimal,
294 ) {
295 self.layers.push_back(FIFOLayer {
296 receipt_date: date,
297 receipt_document: document,
298 quantity,
299 unit_cost,
300 });
301 self.total_quantity += quantity;
302 self.total_value += quantity * unit_cost;
303 }
304
305 pub fn issue(&mut self, quantity: Decimal) -> Option<Decimal> {
307 if quantity > self.total_quantity {
308 return None;
309 }
310
311 let mut remaining = quantity;
312 let mut total_cost = Decimal::ZERO;
313
314 while remaining > Decimal::ZERO && !self.layers.is_empty() {
315 let front = self
316 .layers
317 .front_mut()
318 .expect("FIFO layer exists when remaining > 0");
319
320 if front.quantity <= remaining {
321 total_cost += front.quantity * front.unit_cost;
323 remaining -= front.quantity;
324 self.layers.pop_front();
325 } else {
326 total_cost += remaining * front.unit_cost;
328 front.quantity -= remaining;
329 remaining = Decimal::ZERO;
330 }
331 }
332
333 self.total_quantity -= quantity;
334 self.total_value -= total_cost;
335
336 Some(total_cost)
337 }
338
339 pub fn weighted_average_cost(&self) -> Decimal {
341 if self.total_quantity > Decimal::ZERO {
342 self.total_value / self.total_quantity
343 } else {
344 Decimal::ZERO
345 }
346 }
347}
348
349#[derive(Debug, Clone, Serialize, Deserialize)]
351pub struct CostVarianceAnalysis {
352 pub company_code: String,
354 pub period: String,
356 pub variances: Vec<MaterialCostVariance>,
358 pub total_price_variance: Decimal,
360 pub total_quantity_variance: Decimal,
362 pub generated_at: DateTime<Utc>,
364}
365
366#[derive(Debug, Clone, Serialize, Deserialize)]
368pub struct MaterialCostVariance {
369 pub material_id: String,
371 pub description: String,
373 pub standard_cost: Decimal,
375 pub actual_cost: Decimal,
377 pub quantity: Decimal,
379 pub price_variance: Decimal,
381 pub variance_percent: Decimal,
383 pub is_favorable: bool,
385}
386
387impl MaterialCostVariance {
388 pub fn new(
390 material_id: String,
391 description: String,
392 standard_cost: Decimal,
393 actual_cost: Decimal,
394 quantity: Decimal,
395 ) -> Self {
396 let price_variance = (standard_cost - actual_cost) * quantity;
397 let variance_percent = if actual_cost > Decimal::ZERO {
398 ((standard_cost - actual_cost) / actual_cost * dec!(100)).round_dp(2)
399 } else {
400 Decimal::ZERO
401 };
402 let is_favorable = price_variance > Decimal::ZERO;
403
404 Self {
405 material_id,
406 description,
407 standard_cost,
408 actual_cost,
409 quantity,
410 price_variance,
411 variance_percent,
412 is_favorable,
413 }
414 }
415}
416
417#[derive(Debug, Clone, Serialize, Deserialize)]
419pub struct InventoryTurnover {
420 pub company_code: String,
422 pub period_start: NaiveDate,
424 pub period_end: NaiveDate,
426 pub average_inventory: Decimal,
428 pub cogs: Decimal,
430 pub turnover_ratio: Decimal,
432 pub dio_days: Decimal,
434 pub by_material: Vec<MaterialTurnover>,
436}
437
438impl InventoryTurnover {
439 pub fn calculate(
441 company_code: String,
442 period_start: NaiveDate,
443 period_end: NaiveDate,
444 beginning_inventory: Decimal,
445 ending_inventory: Decimal,
446 cogs: Decimal,
447 ) -> Self {
448 let average_inventory = (beginning_inventory + ending_inventory) / dec!(2);
449 let days_in_period = (period_end - period_start).num_days() as i32;
450
451 let turnover_ratio = if average_inventory > Decimal::ZERO {
452 (cogs / average_inventory).round_dp(2)
453 } else {
454 Decimal::ZERO
455 };
456
457 let dio_days = if cogs > Decimal::ZERO {
458 (average_inventory / cogs * Decimal::from(days_in_period)).round_dp(1)
459 } else {
460 Decimal::ZERO
461 };
462
463 Self {
464 company_code,
465 period_start,
466 period_end,
467 average_inventory,
468 cogs,
469 turnover_ratio,
470 dio_days,
471 by_material: Vec::new(),
472 }
473 }
474}
475
476#[derive(Debug, Clone, Serialize, Deserialize)]
478pub struct MaterialTurnover {
479 pub material_id: String,
481 pub description: String,
483 pub average_inventory: Decimal,
485 pub usage: Decimal,
487 pub turnover_ratio: Decimal,
489 pub days_of_supply: Decimal,
491 pub classification: TurnoverClassification,
493}
494
495#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
497pub enum TurnoverClassification {
498 FastMoving,
500 Normal,
502 SlowMoving,
504 Dead,
506}
507
508#[cfg(test)]
509#[allow(clippy::unwrap_used)]
510mod tests {
511 use super::*;
512
513 #[test]
514 fn test_fifo_tracker() {
515 let mut tracker = FIFOTracker::new("MAT001".to_string());
516
517 tracker.receive(
519 NaiveDate::from_ymd_opt(2024, 1, 1).unwrap(),
520 "GR001".to_string(),
521 dec!(100),
522 dec!(10),
523 );
524
525 tracker.receive(
527 NaiveDate::from_ymd_opt(2024, 1, 15).unwrap(),
528 "GR002".to_string(),
529 dec!(100),
530 dec!(12),
531 );
532
533 assert_eq!(tracker.total_quantity, dec!(200));
534 assert_eq!(tracker.total_value, dec!(2200)); let cost = tracker.issue(dec!(150)).unwrap();
538 assert_eq!(cost, dec!(1600)); assert_eq!(tracker.total_quantity, dec!(50));
540 }
541
542 #[test]
543 fn test_abc_analysis() {
544 let valuations = vec![
545 MaterialValuation {
546 material_id: "A".to_string(),
547 description: "High value".to_string(),
548 plant: "P1".to_string(),
549 storage_location: "S1".to_string(),
550 quantity: dec!(10),
551 unit: "EA".to_string(),
552 unit_cost: dec!(100),
553 total_value: dec!(1000),
554 valuation_method: ValuationMethod::StandardCost,
555 standard_cost: dec!(100),
556 price_variance: Decimal::ZERO,
557 },
558 MaterialValuation {
559 material_id: "B".to_string(),
560 description: "Medium value".to_string(),
561 plant: "P1".to_string(),
562 storage_location: "S1".to_string(),
563 quantity: dec!(50),
564 unit: "EA".to_string(),
565 unit_cost: dec!(10),
566 total_value: dec!(500),
567 valuation_method: ValuationMethod::StandardCost,
568 standard_cost: dec!(10),
569 price_variance: Decimal::ZERO,
570 },
571 MaterialValuation {
572 material_id: "C".to_string(),
573 description: "Low value".to_string(),
574 plant: "P1".to_string(),
575 storage_location: "S1".to_string(),
576 quantity: dec!(100),
577 unit: "EA".to_string(),
578 unit_cost: dec!(1),
579 total_value: dec!(100),
580 valuation_method: ValuationMethod::StandardCost,
581 standard_cost: dec!(1),
582 price_variance: Decimal::ZERO,
583 },
584 ];
585
586 let total = dec!(1600);
587 let analysis = ABCAnalysis::from_valuations(&valuations, total);
588
589 assert!(!analysis.a_items.is_empty());
591 }
592
593 #[test]
594 fn test_inventory_turnover() {
595 let turnover = InventoryTurnover::calculate(
596 "1000".to_string(),
597 NaiveDate::from_ymd_opt(2024, 1, 1).unwrap(),
598 NaiveDate::from_ymd_opt(2024, 12, 31).unwrap(),
599 dec!(100_000),
600 dec!(120_000),
601 dec!(1_000_000),
602 );
603
604 assert!(turnover.turnover_ratio > dec!(9));
607
608 assert!(turnover.dio_days > dec!(30) && turnover.dio_days < dec!(50));
610 }
611
612 #[test]
613 fn test_cost_variance() {
614 let variance = MaterialCostVariance::new(
615 "MAT001".to_string(),
616 "Test Material".to_string(),
617 dec!(10), dec!(11), dec!(100), );
621
622 assert_eq!(variance.price_variance, dec!(-100));
624 assert!(!variance.is_favorable);
625 }
626}