datasynth_eval/coherence/
inventory_cogs.rs1use rust_decimal::Decimal;
10use serde::{Deserialize, Serialize};
11
12#[derive(Debug, Clone)]
16pub struct InventoryCOGSData {
17 pub opening_fg: Decimal,
19 pub production_completions: Decimal,
21 pub cogs: Decimal,
23 pub scrap: Decimal,
25 pub closing_fg: Decimal,
27
28 pub opening_wip: Decimal,
30 pub material_issues: Decimal,
32 pub labor_absorbed: Decimal,
34 pub overhead_applied: Decimal,
36 pub completions_out_of_wip: Decimal,
38 pub wip_scrap: Decimal,
40 pub closing_wip: Decimal,
42
43 pub total_variance: Decimal,
45 pub sum_of_component_variances: Decimal,
47}
48
49#[derive(Debug, Clone, Serialize, Deserialize)]
51pub struct InventoryCOGSEvaluation {
52 pub fg_reconciled: bool,
54 pub fg_imbalance: Decimal,
56 pub fg_expected_closing: Decimal,
58
59 pub wip_reconciled: bool,
61 pub wip_imbalance: Decimal,
63 pub wip_expected_closing: Decimal,
65
66 pub variances_reconciled: bool,
68 pub variance_difference: Decimal,
70
71 pub passes: bool,
73 pub failures: Vec<String>,
75}
76
77pub struct InventoryCOGSEvaluator {
79 tolerance: Decimal,
80}
81
82impl InventoryCOGSEvaluator {
83 pub fn new(tolerance: Decimal) -> Self {
85 Self { tolerance }
86 }
87
88 pub fn evaluate(&self, data: &InventoryCOGSData) -> InventoryCOGSEvaluation {
90 let mut failures = Vec::new();
91
92 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 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 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)) }
152}
153
154#[derive(Debug, Clone)]
158pub struct ICEliminationData {
159 pub matched_pair_count: usize,
161 pub elimination_entry_count: usize,
163 pub total_ic_amount: Decimal,
165 pub total_elimination_amount: Decimal,
167 pub pairs_with_eliminations: usize,
169}
170
171#[derive(Debug, Clone, Serialize, Deserialize)]
173pub struct ICEliminationEvaluation {
174 pub all_pairs_eliminated: bool,
176 pub coverage_rate: f64,
178 pub amount_reconciled: bool,
180 pub amount_difference: Decimal,
182 pub passes: bool,
184 pub failures: Vec<String>,
186}
187
188pub struct ICEliminationEvaluator {
190 tolerance: Decimal,
191}
192
193impl ICEliminationEvaluator {
194 pub fn new(tolerance: Decimal) -> Self {
196 Self { tolerance }
197 }
198
199 pub fn evaluate(&self, data: &ICEliminationData) -> ICEliminationEvaluation {
201 let mut failures = Vec::new();
202
203 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 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)) }
246}
247
248#[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), 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}