use rust_decimal::Decimal;
use serde::{Deserialize, Serialize};
#[derive(Debug, Clone)]
pub struct InventoryCOGSData {
pub opening_fg: Decimal,
pub production_completions: Decimal,
pub cogs: Decimal,
pub scrap: Decimal,
pub closing_fg: Decimal,
pub opening_wip: Decimal,
pub material_issues: Decimal,
pub labor_absorbed: Decimal,
pub overhead_applied: Decimal,
pub completions_out_of_wip: Decimal,
pub wip_scrap: Decimal,
pub closing_wip: Decimal,
pub total_variance: Decimal,
pub sum_of_component_variances: Decimal,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct InventoryCOGSEvaluation {
pub fg_reconciled: bool,
pub fg_imbalance: Decimal,
pub fg_expected_closing: Decimal,
pub wip_reconciled: bool,
pub wip_imbalance: Decimal,
pub wip_expected_closing: Decimal,
pub variances_reconciled: bool,
pub variance_difference: Decimal,
pub passes: bool,
pub failures: Vec<String>,
}
pub struct InventoryCOGSEvaluator {
tolerance: Decimal,
}
impl InventoryCOGSEvaluator {
pub fn new(tolerance: Decimal) -> Self {
Self { tolerance }
}
pub fn evaluate(&self, data: &InventoryCOGSData) -> InventoryCOGSEvaluation {
let mut failures = Vec::new();
let fg_expected_closing =
data.opening_fg + data.production_completions - data.cogs - data.scrap;
let fg_imbalance = (fg_expected_closing - data.closing_fg).abs();
let fg_reconciled = fg_imbalance <= self.tolerance;
if !fg_reconciled {
failures.push(format!(
"FG reconciliation failed: expected closing FG {} but got {} (imbalance {})",
fg_expected_closing, data.closing_fg, fg_imbalance
));
}
let wip_expected_closing =
data.opening_wip + data.material_issues + data.labor_absorbed + data.overhead_applied
- data.completions_out_of_wip
- data.wip_scrap;
let wip_imbalance = (wip_expected_closing - data.closing_wip).abs();
let wip_reconciled = wip_imbalance <= self.tolerance;
if !wip_reconciled {
failures.push(format!(
"WIP reconciliation failed: expected closing WIP {} but got {} (imbalance {})",
wip_expected_closing, data.closing_wip, wip_imbalance
));
}
let variance_difference = (data.total_variance - data.sum_of_component_variances).abs();
let variances_reconciled = variance_difference <= self.tolerance;
if !variances_reconciled {
failures.push(format!(
"Variance reconciliation failed: total variance {} != component sum {} (difference {})",
data.total_variance, data.sum_of_component_variances, variance_difference
));
}
let passes = failures.is_empty();
InventoryCOGSEvaluation {
fg_reconciled,
fg_imbalance,
fg_expected_closing,
wip_reconciled,
wip_imbalance,
wip_expected_closing,
variances_reconciled,
variance_difference,
passes,
failures,
}
}
}
impl Default for InventoryCOGSEvaluator {
fn default() -> Self {
Self::new(Decimal::new(1, 2)) }
}
#[derive(Debug, Clone)]
pub struct ICEliminationData {
pub matched_pair_count: usize,
pub elimination_entry_count: usize,
pub total_ic_amount: Decimal,
pub total_elimination_amount: Decimal,
pub pairs_with_eliminations: usize,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ICEliminationEvaluation {
pub all_pairs_eliminated: bool,
pub coverage_rate: f64,
pub amount_reconciled: bool,
pub amount_difference: Decimal,
pub passes: bool,
pub failures: Vec<String>,
}
pub struct ICEliminationEvaluator {
tolerance: Decimal,
}
impl ICEliminationEvaluator {
pub fn new(tolerance: Decimal) -> Self {
Self { tolerance }
}
pub fn evaluate(&self, data: &ICEliminationData) -> ICEliminationEvaluation {
let mut failures = Vec::new();
let coverage_rate = if data.matched_pair_count == 0 {
1.0
} else {
data.pairs_with_eliminations as f64 / data.matched_pair_count as f64
};
let all_pairs_eliminated = data.pairs_with_eliminations >= data.matched_pair_count;
if !all_pairs_eliminated {
failures.push(format!(
"IC elimination incomplete: {}/{} pairs have eliminations (coverage {:.1}%)",
data.pairs_with_eliminations,
data.matched_pair_count,
coverage_rate * 100.0
));
}
let amount_difference = (data.total_ic_amount - data.total_elimination_amount).abs();
let amount_reconciled = amount_difference <= self.tolerance;
if !amount_reconciled {
failures.push(format!(
"IC elimination amount mismatch: IC amount {} vs elimination amount {} (difference {})",
data.total_ic_amount, data.total_elimination_amount, amount_difference
));
}
let passes = failures.is_empty();
ICEliminationEvaluation {
all_pairs_eliminated,
coverage_rate,
amount_reconciled,
amount_difference,
passes,
failures,
}
}
}
impl Default for ICEliminationEvaluator {
fn default() -> Self {
Self::new(Decimal::new(1, 2)) }
}
#[cfg(test)]
#[allow(clippy::unwrap_used)]
mod tests {
use super::*;
#[test]
fn test_cogs_all_balanced() {
let data = InventoryCOGSData {
opening_fg: Decimal::new(100_000, 0),
production_completions: Decimal::new(500_000, 0),
cogs: Decimal::new(450_000, 0),
scrap: Decimal::new(10_000, 0),
closing_fg: Decimal::new(140_000, 0),
opening_wip: Decimal::new(50_000, 0),
material_issues: Decimal::new(300_000, 0),
labor_absorbed: Decimal::new(150_000, 0),
overhead_applied: Decimal::new(100_000, 0),
completions_out_of_wip: Decimal::new(500_000, 0),
wip_scrap: Decimal::new(5_000, 0),
closing_wip: Decimal::new(95_000, 0),
total_variance: Decimal::new(15_000, 0),
sum_of_component_variances: Decimal::new(15_000, 0),
};
let eval = InventoryCOGSEvaluator::new(Decimal::new(1, 0));
let result = eval.evaluate(&data);
assert!(result.passes);
assert!(result.fg_reconciled);
assert!(result.wip_reconciled);
assert!(result.variances_reconciled);
}
#[test]
fn test_fg_imbalanced() {
let data = InventoryCOGSData {
opening_fg: Decimal::new(100_000, 0),
production_completions: Decimal::new(500_000, 0),
cogs: Decimal::new(450_000, 0),
scrap: Decimal::new(10_000, 0),
closing_fg: Decimal::new(999_000, 0), opening_wip: Decimal::ZERO,
material_issues: Decimal::ZERO,
labor_absorbed: Decimal::ZERO,
overhead_applied: Decimal::ZERO,
completions_out_of_wip: Decimal::ZERO,
wip_scrap: Decimal::ZERO,
closing_wip: Decimal::ZERO,
total_variance: Decimal::ZERO,
sum_of_component_variances: Decimal::ZERO,
};
let eval = InventoryCOGSEvaluator::new(Decimal::new(1, 0));
let result = eval.evaluate(&data);
assert!(!result.fg_reconciled);
assert!(!result.passes);
assert!(!result.failures.is_empty());
}
#[test]
fn test_ic_elimination_complete() {
let data = ICEliminationData {
matched_pair_count: 10,
elimination_entry_count: 10,
total_ic_amount: Decimal::new(1_000_000, 0),
total_elimination_amount: Decimal::new(1_000_000, 0),
pairs_with_eliminations: 10,
};
let eval = ICEliminationEvaluator::new(Decimal::new(1, 0));
let result = eval.evaluate(&data);
assert!(result.passes);
assert!(result.all_pairs_eliminated);
assert!((result.coverage_rate - 1.0).abs() < f64::EPSILON);
}
#[test]
fn test_ic_elimination_incomplete() {
let data = ICEliminationData {
matched_pair_count: 10,
elimination_entry_count: 7,
total_ic_amount: Decimal::new(1_000_000, 0),
total_elimination_amount: Decimal::new(700_000, 0),
pairs_with_eliminations: 7,
};
let eval = ICEliminationEvaluator::new(Decimal::new(1, 0));
let result = eval.evaluate(&data);
assert!(!result.passes);
assert!(!result.all_pairs_eliminated);
assert_eq!(result.coverage_rate, 0.7);
}
}