Skip to main content

datasynth_eval/coherence/
inventory_cogs.rs

1//! COGS and WIP coherence validators.
2//!
3//! Validates manufacturing cost flow integrity including:
4//! - Finished goods inventory reconciliation
5//! - Work-in-process reconciliation
6//! - Cost variance reconciliation
7//! - Intercompany elimination completeness
8
9use rust_decimal::Decimal;
10use serde::{Deserialize, Serialize};
11
12// ─── InventoryCOGSEvaluator ───────────────────────────────────────────────────
13
14/// Input data for COGS and inventory reconciliation checks.
15#[derive(Debug, Clone)]
16pub struct InventoryCOGSData {
17    /// Opening finished-goods balance.
18    pub opening_fg: Decimal,
19    /// Production completions transferred into FG.
20    pub production_completions: Decimal,
21    /// Cost of goods sold posted to P&L.
22    pub cogs: Decimal,
23    /// Scrap written off from FG.
24    pub scrap: Decimal,
25    /// Closing finished-goods balance.
26    pub closing_fg: Decimal,
27
28    /// Opening work-in-process balance.
29    pub opening_wip: Decimal,
30    /// Raw-material issues to production.
31    pub material_issues: Decimal,
32    /// Labor costs absorbed into WIP.
33    pub labor_absorbed: Decimal,
34    /// Overhead applied to WIP.
35    pub overhead_applied: Decimal,
36    /// Completions transferred out of WIP (into FG).
37    pub completions_out_of_wip: Decimal,
38    /// Scrap written off directly from WIP.
39    pub wip_scrap: Decimal,
40    /// Closing work-in-process balance.
41    pub closing_wip: Decimal,
42
43    /// Total manufacturing variance reported.
44    pub total_variance: Decimal,
45    /// Sum of individual component variances (material, labor, overhead, etc.).
46    pub sum_of_component_variances: Decimal,
47}
48
49/// Results of COGS and WIP coherence evaluation.
50#[derive(Debug, Clone, Serialize, Deserialize)]
51pub struct InventoryCOGSEvaluation {
52    /// Whether the FG roll-forward reconciles.
53    pub fg_reconciled: bool,
54    /// Difference between expected and actual closing FG.
55    pub fg_imbalance: Decimal,
56    /// Expected closing FG derived from the roll-forward equation.
57    pub fg_expected_closing: Decimal,
58
59    /// Whether the WIP roll-forward reconciles.
60    pub wip_reconciled: bool,
61    /// Difference between expected and actual closing WIP.
62    pub wip_imbalance: Decimal,
63    /// Expected closing WIP derived from the roll-forward equation.
64    pub wip_expected_closing: Decimal,
65
66    /// Whether total variance equals the sum of component variances.
67    pub variances_reconciled: bool,
68    /// Difference between total variance and sum of component variances.
69    pub variance_difference: Decimal,
70
71    /// Overall pass/fail status — all three checks must pass.
72    pub passes: bool,
73    /// Human-readable descriptions of failed checks.
74    pub failures: Vec<String>,
75}
76
77/// Evaluator for finished-goods, WIP, and variance reconciliation.
78pub struct InventoryCOGSEvaluator {
79    tolerance: Decimal,
80}
81
82impl InventoryCOGSEvaluator {
83    /// Create a new evaluator with the given absolute tolerance.
84    pub fn new(tolerance: Decimal) -> Self {
85        Self { tolerance }
86    }
87
88    /// Run all three reconciliation checks against `data`.
89    pub fn evaluate(&self, data: &InventoryCOGSData) -> InventoryCOGSEvaluation {
90        let mut failures = Vec::new();
91
92        // 1. FG Reconciliation
93        //    Opening FG + Completions - COGS - Scrap = Closing FG
94        let fg_expected_closing =
95            data.opening_fg + data.production_completions - data.cogs - data.scrap;
96        let fg_imbalance = (fg_expected_closing - data.closing_fg).abs();
97        let fg_reconciled = fg_imbalance <= self.tolerance;
98        if !fg_reconciled {
99            failures.push(format!(
100                "FG reconciliation failed: expected closing FG {} but got {} (imbalance {})",
101                fg_expected_closing, data.closing_fg, fg_imbalance
102            ));
103        }
104
105        // 2. WIP Reconciliation
106        //    Opening WIP + Materials + Labor + Overhead - Completions - Scrap = Closing WIP
107        let wip_expected_closing =
108            data.opening_wip + data.material_issues + data.labor_absorbed + data.overhead_applied
109                - data.completions_out_of_wip
110                - data.wip_scrap;
111        let wip_imbalance = (wip_expected_closing - data.closing_wip).abs();
112        let wip_reconciled = wip_imbalance <= self.tolerance;
113        if !wip_reconciled {
114            failures.push(format!(
115                "WIP reconciliation failed: expected closing WIP {} but got {} (imbalance {})",
116                wip_expected_closing, data.closing_wip, wip_imbalance
117            ));
118        }
119
120        // 3. Variance Reconciliation
121        //    Total variance = Sum of component variances
122        let variance_difference = (data.total_variance - data.sum_of_component_variances).abs();
123        let variances_reconciled = variance_difference <= self.tolerance;
124        if !variances_reconciled {
125            failures.push(format!(
126                "Variance reconciliation failed: total variance {} != component sum {} (difference {})",
127                data.total_variance, data.sum_of_component_variances, variance_difference
128            ));
129        }
130
131        let passes = failures.is_empty();
132
133        InventoryCOGSEvaluation {
134            fg_reconciled,
135            fg_imbalance,
136            fg_expected_closing,
137            wip_reconciled,
138            wip_imbalance,
139            wip_expected_closing,
140            variances_reconciled,
141            variance_difference,
142            passes,
143            failures,
144        }
145    }
146}
147
148impl Default for InventoryCOGSEvaluator {
149    fn default() -> Self {
150        Self::new(Decimal::new(1, 2)) // 0.01 tolerance
151    }
152}
153
154// ─── ICEliminationEvaluator ──────────────────────────────────────────────────
155
156/// Input data for intercompany elimination completeness checks.
157#[derive(Debug, Clone)]
158pub struct ICEliminationData {
159    /// Total number of matched IC pairs.
160    pub matched_pair_count: usize,
161    /// Total number of elimination journal entries generated.
162    pub elimination_entry_count: usize,
163    /// Gross IC transaction amount (seller side).
164    pub total_ic_amount: Decimal,
165    /// Total amount covered by elimination entries.
166    pub total_elimination_amount: Decimal,
167    /// Number of matched pairs that have at least one elimination entry.
168    pub pairs_with_eliminations: usize,
169}
170
171/// Results of intercompany elimination completeness evaluation.
172#[derive(Debug, Clone, Serialize, Deserialize)]
173pub struct ICEliminationEvaluation {
174    /// Whether every matched pair has a corresponding elimination entry.
175    pub all_pairs_eliminated: bool,
176    /// Fraction of matched pairs that have eliminations (0.0–1.0).
177    pub coverage_rate: f64,
178    /// Whether the total elimination amount reconciles to the IC amount.
179    pub amount_reconciled: bool,
180    /// Absolute difference between IC amount and elimination amount.
181    pub amount_difference: Decimal,
182    /// Overall pass/fail status.
183    pub passes: bool,
184    /// Human-readable descriptions of failed checks.
185    pub failures: Vec<String>,
186}
187
188/// Evaluator for intercompany elimination completeness.
189pub struct ICEliminationEvaluator {
190    tolerance: Decimal,
191}
192
193impl ICEliminationEvaluator {
194    /// Create a new evaluator with the given absolute tolerance.
195    pub fn new(tolerance: Decimal) -> Self {
196        Self { tolerance }
197    }
198
199    /// Run elimination coverage and amount reconciliation checks.
200    pub fn evaluate(&self, data: &ICEliminationData) -> ICEliminationEvaluation {
201        let mut failures = Vec::new();
202
203        // Coverage: every matched pair must have an elimination entry.
204        let coverage_rate = if data.matched_pair_count == 0 {
205            1.0
206        } else {
207            data.pairs_with_eliminations as f64 / data.matched_pair_count as f64
208        };
209        let all_pairs_eliminated = data.pairs_with_eliminations >= data.matched_pair_count;
210        if !all_pairs_eliminated {
211            failures.push(format!(
212                "IC elimination incomplete: {}/{} pairs have eliminations (coverage {:.1}%)",
213                data.pairs_with_eliminations,
214                data.matched_pair_count,
215                coverage_rate * 100.0
216            ));
217        }
218
219        // Amount reconciliation: elimination amount must equal IC amount.
220        let amount_difference = (data.total_ic_amount - data.total_elimination_amount).abs();
221        let amount_reconciled = amount_difference <= self.tolerance;
222        if !amount_reconciled {
223            failures.push(format!(
224                "IC elimination amount mismatch: IC amount {} vs elimination amount {} (difference {})",
225                data.total_ic_amount, data.total_elimination_amount, amount_difference
226            ));
227        }
228
229        let passes = failures.is_empty();
230
231        ICEliminationEvaluation {
232            all_pairs_eliminated,
233            coverage_rate,
234            amount_reconciled,
235            amount_difference,
236            passes,
237            failures,
238        }
239    }
240}
241
242impl Default for ICEliminationEvaluator {
243    fn default() -> Self {
244        Self::new(Decimal::new(1, 2)) // 0.01 tolerance
245    }
246}
247
248// ─── Unit tests ──────────────────────────────────────────────────────────────
249
250#[cfg(test)]
251#[allow(clippy::unwrap_used)]
252mod tests {
253    use super::*;
254
255    #[test]
256    fn test_cogs_all_balanced() {
257        let data = InventoryCOGSData {
258            opening_fg: Decimal::new(100_000, 0),
259            production_completions: Decimal::new(500_000, 0),
260            cogs: Decimal::new(450_000, 0),
261            scrap: Decimal::new(10_000, 0),
262            closing_fg: Decimal::new(140_000, 0),
263            opening_wip: Decimal::new(50_000, 0),
264            material_issues: Decimal::new(300_000, 0),
265            labor_absorbed: Decimal::new(150_000, 0),
266            overhead_applied: Decimal::new(100_000, 0),
267            completions_out_of_wip: Decimal::new(500_000, 0),
268            wip_scrap: Decimal::new(5_000, 0),
269            closing_wip: Decimal::new(95_000, 0),
270            total_variance: Decimal::new(15_000, 0),
271            sum_of_component_variances: Decimal::new(15_000, 0),
272        };
273
274        let eval = InventoryCOGSEvaluator::new(Decimal::new(1, 0));
275        let result = eval.evaluate(&data);
276        assert!(result.passes);
277        assert!(result.fg_reconciled);
278        assert!(result.wip_reconciled);
279        assert!(result.variances_reconciled);
280    }
281
282    #[test]
283    fn test_fg_imbalanced() {
284        let data = InventoryCOGSData {
285            opening_fg: Decimal::new(100_000, 0),
286            production_completions: Decimal::new(500_000, 0),
287            cogs: Decimal::new(450_000, 0),
288            scrap: Decimal::new(10_000, 0),
289            closing_fg: Decimal::new(999_000, 0), // Wrong
290            opening_wip: Decimal::ZERO,
291            material_issues: Decimal::ZERO,
292            labor_absorbed: Decimal::ZERO,
293            overhead_applied: Decimal::ZERO,
294            completions_out_of_wip: Decimal::ZERO,
295            wip_scrap: Decimal::ZERO,
296            closing_wip: Decimal::ZERO,
297            total_variance: Decimal::ZERO,
298            sum_of_component_variances: Decimal::ZERO,
299        };
300
301        let eval = InventoryCOGSEvaluator::new(Decimal::new(1, 0));
302        let result = eval.evaluate(&data);
303        assert!(!result.fg_reconciled);
304        assert!(!result.passes);
305        assert!(!result.failures.is_empty());
306    }
307
308    #[test]
309    fn test_ic_elimination_complete() {
310        let data = ICEliminationData {
311            matched_pair_count: 10,
312            elimination_entry_count: 10,
313            total_ic_amount: Decimal::new(1_000_000, 0),
314            total_elimination_amount: Decimal::new(1_000_000, 0),
315            pairs_with_eliminations: 10,
316        };
317
318        let eval = ICEliminationEvaluator::new(Decimal::new(1, 0));
319        let result = eval.evaluate(&data);
320        assert!(result.passes);
321        assert!(result.all_pairs_eliminated);
322        assert!((result.coverage_rate - 1.0).abs() < f64::EPSILON);
323    }
324
325    #[test]
326    fn test_ic_elimination_incomplete() {
327        let data = ICEliminationData {
328            matched_pair_count: 10,
329            elimination_entry_count: 7,
330            total_ic_amount: Decimal::new(1_000_000, 0),
331            total_elimination_amount: Decimal::new(700_000, 0),
332            pairs_with_eliminations: 7,
333        };
334
335        let eval = ICEliminationEvaluator::new(Decimal::new(1, 0));
336        let result = eval.evaluate(&data);
337        assert!(!result.passes);
338        assert!(!result.all_pairs_eliminated);
339        assert_eq!(result.coverage_rate, 0.7);
340    }
341}