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.layers.front_mut().unwrap();
316
317 if front.quantity <= remaining {
318 total_cost += front.quantity * front.unit_cost;
320 remaining -= front.quantity;
321 self.layers.pop_front();
322 } else {
323 total_cost += remaining * front.unit_cost;
325 front.quantity -= remaining;
326 remaining = Decimal::ZERO;
327 }
328 }
329
330 self.total_quantity -= quantity;
331 self.total_value -= total_cost;
332
333 Some(total_cost)
334 }
335
336 pub fn weighted_average_cost(&self) -> Decimal {
338 if self.total_quantity > Decimal::ZERO {
339 self.total_value / self.total_quantity
340 } else {
341 Decimal::ZERO
342 }
343 }
344}
345
346#[derive(Debug, Clone, Serialize, Deserialize)]
348pub struct CostVarianceAnalysis {
349 pub company_code: String,
351 pub period: String,
353 pub variances: Vec<MaterialCostVariance>,
355 pub total_price_variance: Decimal,
357 pub total_quantity_variance: Decimal,
359 pub generated_at: DateTime<Utc>,
361}
362
363#[derive(Debug, Clone, Serialize, Deserialize)]
365pub struct MaterialCostVariance {
366 pub material_id: String,
368 pub description: String,
370 pub standard_cost: Decimal,
372 pub actual_cost: Decimal,
374 pub quantity: Decimal,
376 pub price_variance: Decimal,
378 pub variance_percent: Decimal,
380 pub is_favorable: bool,
382}
383
384impl MaterialCostVariance {
385 pub fn new(
387 material_id: String,
388 description: String,
389 standard_cost: Decimal,
390 actual_cost: Decimal,
391 quantity: Decimal,
392 ) -> Self {
393 let price_variance = (standard_cost - actual_cost) * quantity;
394 let variance_percent = if actual_cost > Decimal::ZERO {
395 ((standard_cost - actual_cost) / actual_cost * dec!(100)).round_dp(2)
396 } else {
397 Decimal::ZERO
398 };
399 let is_favorable = price_variance > Decimal::ZERO;
400
401 Self {
402 material_id,
403 description,
404 standard_cost,
405 actual_cost,
406 quantity,
407 price_variance,
408 variance_percent,
409 is_favorable,
410 }
411 }
412}
413
414#[derive(Debug, Clone, Serialize, Deserialize)]
416pub struct InventoryTurnover {
417 pub company_code: String,
419 pub period_start: NaiveDate,
421 pub period_end: NaiveDate,
423 pub average_inventory: Decimal,
425 pub cogs: Decimal,
427 pub turnover_ratio: Decimal,
429 pub dio_days: Decimal,
431 pub by_material: Vec<MaterialTurnover>,
433}
434
435impl InventoryTurnover {
436 pub fn calculate(
438 company_code: String,
439 period_start: NaiveDate,
440 period_end: NaiveDate,
441 beginning_inventory: Decimal,
442 ending_inventory: Decimal,
443 cogs: Decimal,
444 ) -> Self {
445 let average_inventory = (beginning_inventory + ending_inventory) / dec!(2);
446 let days_in_period = (period_end - period_start).num_days() as i32;
447
448 let turnover_ratio = if average_inventory > Decimal::ZERO {
449 (cogs / average_inventory).round_dp(2)
450 } else {
451 Decimal::ZERO
452 };
453
454 let dio_days = if cogs > Decimal::ZERO {
455 (average_inventory / cogs * Decimal::from(days_in_period)).round_dp(1)
456 } else {
457 Decimal::ZERO
458 };
459
460 Self {
461 company_code,
462 period_start,
463 period_end,
464 average_inventory,
465 cogs,
466 turnover_ratio,
467 dio_days,
468 by_material: Vec::new(),
469 }
470 }
471}
472
473#[derive(Debug, Clone, Serialize, Deserialize)]
475pub struct MaterialTurnover {
476 pub material_id: String,
478 pub description: String,
480 pub average_inventory: Decimal,
482 pub usage: Decimal,
484 pub turnover_ratio: Decimal,
486 pub days_of_supply: Decimal,
488 pub classification: TurnoverClassification,
490}
491
492#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
494pub enum TurnoverClassification {
495 FastMoving,
497 Normal,
499 SlowMoving,
501 Dead,
503}
504
505#[cfg(test)]
506mod tests {
507 use super::*;
508
509 #[test]
510 fn test_fifo_tracker() {
511 let mut tracker = FIFOTracker::new("MAT001".to_string());
512
513 tracker.receive(
515 NaiveDate::from_ymd_opt(2024, 1, 1).unwrap(),
516 "GR001".to_string(),
517 dec!(100),
518 dec!(10),
519 );
520
521 tracker.receive(
523 NaiveDate::from_ymd_opt(2024, 1, 15).unwrap(),
524 "GR002".to_string(),
525 dec!(100),
526 dec!(12),
527 );
528
529 assert_eq!(tracker.total_quantity, dec!(200));
530 assert_eq!(tracker.total_value, dec!(2200)); let cost = tracker.issue(dec!(150)).unwrap();
534 assert_eq!(cost, dec!(1600)); assert_eq!(tracker.total_quantity, dec!(50));
536 }
537
538 #[test]
539 fn test_abc_analysis() {
540 let valuations = vec![
541 MaterialValuation {
542 material_id: "A".to_string(),
543 description: "High value".to_string(),
544 plant: "P1".to_string(),
545 storage_location: "S1".to_string(),
546 quantity: dec!(10),
547 unit: "EA".to_string(),
548 unit_cost: dec!(100),
549 total_value: dec!(1000),
550 valuation_method: ValuationMethod::StandardCost,
551 standard_cost: dec!(100),
552 price_variance: Decimal::ZERO,
553 },
554 MaterialValuation {
555 material_id: "B".to_string(),
556 description: "Medium value".to_string(),
557 plant: "P1".to_string(),
558 storage_location: "S1".to_string(),
559 quantity: dec!(50),
560 unit: "EA".to_string(),
561 unit_cost: dec!(10),
562 total_value: dec!(500),
563 valuation_method: ValuationMethod::StandardCost,
564 standard_cost: dec!(10),
565 price_variance: Decimal::ZERO,
566 },
567 MaterialValuation {
568 material_id: "C".to_string(),
569 description: "Low value".to_string(),
570 plant: "P1".to_string(),
571 storage_location: "S1".to_string(),
572 quantity: dec!(100),
573 unit: "EA".to_string(),
574 unit_cost: dec!(1),
575 total_value: dec!(100),
576 valuation_method: ValuationMethod::StandardCost,
577 standard_cost: dec!(1),
578 price_variance: Decimal::ZERO,
579 },
580 ];
581
582 let total = dec!(1600);
583 let analysis = ABCAnalysis::from_valuations(&valuations, total);
584
585 assert!(!analysis.a_items.is_empty());
587 }
588
589 #[test]
590 fn test_inventory_turnover() {
591 let turnover = InventoryTurnover::calculate(
592 "1000".to_string(),
593 NaiveDate::from_ymd_opt(2024, 1, 1).unwrap(),
594 NaiveDate::from_ymd_opt(2024, 12, 31).unwrap(),
595 dec!(100_000),
596 dec!(120_000),
597 dec!(1_000_000),
598 );
599
600 assert!(turnover.turnover_ratio > dec!(9));
603
604 assert!(turnover.dio_days > dec!(30) && turnover.dio_days < dec!(50));
606 }
607
608 #[test]
609 fn test_cost_variance() {
610 let variance = MaterialCostVariance::new(
611 "MAT001".to_string(),
612 "Test Material".to_string(),
613 dec!(10), dec!(11), dec!(100), );
617
618 assert_eq!(variance.price_variance, dec!(-100));
620 assert!(!variance.is_favorable);
621 }
622}