1use chrono::NaiveDate;
25use rand::RngExt;
26use rand::SeedableRng;
27use rand_chacha::ChaCha8Rng;
28use rust_decimal::Decimal;
29use rust_decimal_macros::dec;
30use serde::{Deserialize, Serialize};
31
32use datasynth_core::models::subledger::inventory::{InventoryPosition, InventoryValuationReport};
33
34#[derive(Debug, Clone)]
36pub struct InventoryValuationGeneratorConfig {
37 pub avg_nrv_factor: f64,
40 pub nrv_factor_variation: f64,
42 pub seed_offset: u64,
44}
45
46impl Default for InventoryValuationGeneratorConfig {
47 fn default() -> Self {
48 Self {
49 avg_nrv_factor: 1.05, nrv_factor_variation: 0.15,
51 seed_offset: 900,
52 }
53 }
54}
55
56#[derive(Debug, Clone, Serialize, Deserialize)]
58pub struct InventoryValuationLine {
59 pub material_id: String,
61 pub description: String,
63 pub plant: String,
65 pub storage_location: String,
67 pub quantity: Decimal,
69 pub unit: String,
71 pub cost_per_unit: Decimal,
73 pub total_cost: Decimal,
75 pub nrv_per_unit: Decimal,
77 pub total_nrv: Decimal,
79 pub write_down_amount: Decimal,
82 pub carrying_value: Decimal,
84 pub is_impaired: bool,
86}
87
88#[derive(Debug, Clone, Serialize, Deserialize)]
90pub struct InventoryValuationResult {
91 pub company_code: String,
93 pub as_of_date: NaiveDate,
95 pub lines: Vec<InventoryValuationLine>,
97 pub total_cost: Decimal,
99 pub total_nrv: Decimal,
101 pub total_write_down: Decimal,
103 pub total_carrying_value: Decimal,
105 pub impaired_count: u32,
107 pub valuation_report: InventoryValuationReport,
109}
110
111pub struct InventoryValuationGenerator {
113 config: InventoryValuationGeneratorConfig,
114 seed: u64,
115}
116
117impl InventoryValuationGenerator {
118 pub fn new(config: InventoryValuationGeneratorConfig, seed: u64) -> Self {
120 Self { config, seed }
121 }
122
123 pub fn generate(
129 &self,
130 company_code: &str,
131 positions: &[InventoryPosition],
132 as_of_date: NaiveDate,
133 ) -> InventoryValuationResult {
134 let mut rng = ChaCha8Rng::seed_from_u64(self.seed + self.config.seed_offset);
135
136 let company_positions: Vec<&InventoryPosition> = positions
137 .iter()
138 .filter(|p| p.company_code == company_code)
139 .collect();
140
141 let mut lines = Vec::with_capacity(company_positions.len());
142 let mut total_cost = Decimal::ZERO;
143 let mut total_nrv = Decimal::ZERO;
144 let mut total_write_down = Decimal::ZERO;
145 let mut impaired_count = 0u32;
146
147 for pos in &company_positions {
148 let cost_per_unit = pos.valuation.unit_cost;
149 let quantity = pos.quantity_on_hand;
150 let total_cost_pos = (quantity * cost_per_unit).round_dp(2);
151
152 let variation: f64 = rng
154 .random_range(-self.config.nrv_factor_variation..=self.config.nrv_factor_variation);
155 let nrv_factor = (self.config.avg_nrv_factor + variation).max(0.0);
156 let nrv_factor_dec = Decimal::try_from(nrv_factor).unwrap_or(dec!(1));
157
158 let nrv_per_unit = (cost_per_unit * nrv_factor_dec).round_dp(4);
159 let total_nrv_pos = (quantity * nrv_per_unit).round_dp(2);
160
161 let write_down = (total_cost_pos - total_nrv_pos)
163 .max(Decimal::ZERO)
164 .round_dp(2);
165 let carrying_value = total_cost_pos - write_down;
166 let is_impaired = write_down > Decimal::ZERO;
167
168 if is_impaired {
169 impaired_count += 1;
170 }
171
172 total_cost += total_cost_pos;
173 total_nrv += total_nrv_pos;
174 total_write_down += write_down;
175
176 lines.push(InventoryValuationLine {
177 material_id: pos.material_id.clone(),
178 description: pos.description.clone(),
179 plant: pos.plant.clone(),
180 storage_location: pos.storage_location.clone(),
181 quantity,
182 unit: pos.unit.clone(),
183 cost_per_unit,
184 total_cost: total_cost_pos,
185 nrv_per_unit,
186 total_nrv: total_nrv_pos,
187 write_down_amount: write_down,
188 carrying_value,
189 is_impaired,
190 });
191 }
192
193 lines.sort_by_key(|b| std::cmp::Reverse(b.write_down_amount));
195
196 let total_carrying_value = total_cost - total_write_down;
197
198 let valuation_report = InventoryValuationReport::from_positions(
200 company_code.to_string(),
201 positions,
202 as_of_date,
203 );
204
205 InventoryValuationResult {
206 company_code: company_code.to_string(),
207 as_of_date,
208 lines,
209 total_cost,
210 total_nrv,
211 total_write_down,
212 total_carrying_value,
213 impaired_count,
214 valuation_report,
215 }
216 }
217}
218
219#[cfg(test)]
220mod tests {
221 use super::*;
222 use datasynth_core::models::subledger::inventory::{
223 InventoryPosition, PositionValuation, ValuationMethod,
224 };
225 use rust_decimal_macros::dec;
226
227 fn make_position(
228 material_id: &str,
229 company: &str,
230 qty: Decimal,
231 unit_cost: Decimal,
232 ) -> InventoryPosition {
233 let mut pos = InventoryPosition::new(
234 material_id.to_string(),
235 format!("Material {material_id}"),
236 "PLANT01".to_string(),
237 "SL001".to_string(),
238 company.to_string(),
239 "EA".to_string(),
240 );
241 pos.quantity_on_hand = qty;
242 pos.quantity_available = qty;
243 pos.valuation = PositionValuation {
244 method: ValuationMethod::StandardCost,
245 standard_cost: unit_cost,
246 unit_cost,
247 total_value: qty * unit_cost,
248 price_variance: Decimal::ZERO,
249 last_price_change: None,
250 };
251 pos
252 }
253
254 #[test]
255 fn test_nrv_write_down_when_cost_exceeds_nrv() {
256 let cfg = InventoryValuationGeneratorConfig {
258 avg_nrv_factor: 0.8,
259 nrv_factor_variation: 0.0, seed_offset: 0,
261 };
262 let gen = InventoryValuationGenerator::new(cfg, 42);
263 let positions = vec![make_position("MAT001", "1000", dec!(100), dec!(10))];
264 let result = gen.generate(
265 "1000",
266 &positions,
267 NaiveDate::from_ymd_opt(2024, 12, 31).unwrap(),
268 );
269
270 assert_eq!(result.lines.len(), 1);
272 assert!(result.lines[0].is_impaired, "Position should be impaired");
273 assert_eq!(result.lines[0].write_down_amount, dec!(200));
274 assert_eq!(result.total_write_down, dec!(200));
275 assert_eq!(result.impaired_count, 1);
276 }
277
278 #[test]
279 fn test_no_write_down_when_nrv_exceeds_cost() {
280 let cfg = InventoryValuationGeneratorConfig {
282 avg_nrv_factor: 1.2,
283 nrv_factor_variation: 0.0,
284 seed_offset: 1,
285 };
286 let gen = InventoryValuationGenerator::new(cfg, 77);
287 let positions = vec![make_position("MAT002", "1000", dec!(50), dec!(20))];
288 let result = gen.generate(
289 "1000",
290 &positions,
291 NaiveDate::from_ymd_opt(2024, 12, 31).unwrap(),
292 );
293
294 assert_eq!(result.lines.len(), 1);
295 assert!(
296 !result.lines[0].is_impaired,
297 "Position should not be impaired"
298 );
299 assert_eq!(result.total_write_down, Decimal::ZERO);
300 assert_eq!(result.impaired_count, 0);
301 }
302
303 #[test]
304 fn test_carrying_value_equals_cost_minus_writedown() {
305 let cfg = InventoryValuationGeneratorConfig {
306 avg_nrv_factor: 0.9,
307 nrv_factor_variation: 0.0,
308 seed_offset: 2,
309 };
310 let gen = InventoryValuationGenerator::new(cfg, 55);
311 let positions = vec![make_position("MAT003", "1000", dec!(200), dec!(5))];
312 let result = gen.generate(
313 "1000",
314 &positions,
315 NaiveDate::from_ymd_opt(2024, 12, 31).unwrap(),
316 );
317
318 let line = &result.lines[0];
319 assert_eq!(
320 line.carrying_value,
321 line.total_cost - line.write_down_amount,
322 "carrying_value = total_cost - write_down"
323 );
324 assert_eq!(
325 result.total_carrying_value,
326 result.total_cost - result.total_write_down,
327 );
328 }
329
330 #[test]
331 fn test_filters_to_company() {
332 let positions = vec![
333 make_position("MAT010", "1000", dec!(10), dec!(100)),
334 make_position("MAT011", "2000", dec!(20), dec!(50)), ];
336 let cfg = InventoryValuationGeneratorConfig::default();
337 let gen = InventoryValuationGenerator::new(cfg, 1);
338 let result = gen.generate(
339 "1000",
340 &positions,
341 NaiveDate::from_ymd_opt(2024, 12, 31).unwrap(),
342 );
343
344 assert_eq!(result.lines.len(), 1, "Only MAT010 belongs to company 1000");
345 assert_eq!(result.lines[0].material_id, "MAT010");
346 }
347
348 #[test]
349 fn test_empty_positions_returns_zero_totals() {
350 let cfg = InventoryValuationGeneratorConfig::default();
351 let gen = InventoryValuationGenerator::new(cfg, 0);
352 let result = gen.generate("1000", &[], NaiveDate::from_ymd_opt(2024, 12, 31).unwrap());
353
354 assert!(result.lines.is_empty());
355 assert_eq!(result.total_cost, Decimal::ZERO);
356 assert_eq!(result.total_write_down, Decimal::ZERO);
357 assert_eq!(result.impaired_count, 0);
358 }
359}