1use chrono::NaiveDate;
25use rand::Rng;
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(|a, b| b.write_down_amount.cmp(&a.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)]
220#[allow(clippy::unwrap_used)]
221mod tests {
222 use super::*;
223 use datasynth_core::models::subledger::inventory::{
224 InventoryPosition, PositionValuation, ValuationMethod,
225 };
226 use rust_decimal_macros::dec;
227
228 fn make_position(
229 material_id: &str,
230 company: &str,
231 qty: Decimal,
232 unit_cost: Decimal,
233 ) -> InventoryPosition {
234 let mut pos = InventoryPosition::new(
235 material_id.to_string(),
236 format!("Material {material_id}"),
237 "PLANT01".to_string(),
238 "SL001".to_string(),
239 company.to_string(),
240 "EA".to_string(),
241 );
242 pos.quantity_on_hand = qty;
243 pos.quantity_available = qty;
244 pos.valuation = PositionValuation {
245 method: ValuationMethod::StandardCost,
246 standard_cost: unit_cost,
247 unit_cost,
248 total_value: qty * unit_cost,
249 price_variance: Decimal::ZERO,
250 last_price_change: None,
251 };
252 pos
253 }
254
255 #[test]
256 fn test_nrv_write_down_when_cost_exceeds_nrv() {
257 let cfg = InventoryValuationGeneratorConfig {
259 avg_nrv_factor: 0.8,
260 nrv_factor_variation: 0.0, seed_offset: 0,
262 };
263 let gen = InventoryValuationGenerator::new(cfg, 42);
264 let positions = vec![make_position("MAT001", "1000", dec!(100), dec!(10))];
265 let result = gen.generate(
266 "1000",
267 &positions,
268 NaiveDate::from_ymd_opt(2024, 12, 31).unwrap(),
269 );
270
271 assert_eq!(result.lines.len(), 1);
273 assert!(result.lines[0].is_impaired, "Position should be impaired");
274 assert_eq!(result.lines[0].write_down_amount, dec!(200));
275 assert_eq!(result.total_write_down, dec!(200));
276 assert_eq!(result.impaired_count, 1);
277 }
278
279 #[test]
280 fn test_no_write_down_when_nrv_exceeds_cost() {
281 let cfg = InventoryValuationGeneratorConfig {
283 avg_nrv_factor: 1.2,
284 nrv_factor_variation: 0.0,
285 seed_offset: 1,
286 };
287 let gen = InventoryValuationGenerator::new(cfg, 77);
288 let positions = vec![make_position("MAT002", "1000", dec!(50), dec!(20))];
289 let result = gen.generate(
290 "1000",
291 &positions,
292 NaiveDate::from_ymd_opt(2024, 12, 31).unwrap(),
293 );
294
295 assert_eq!(result.lines.len(), 1);
296 assert!(
297 !result.lines[0].is_impaired,
298 "Position should not be impaired"
299 );
300 assert_eq!(result.total_write_down, Decimal::ZERO);
301 assert_eq!(result.impaired_count, 0);
302 }
303
304 #[test]
305 fn test_carrying_value_equals_cost_minus_writedown() {
306 let cfg = InventoryValuationGeneratorConfig {
307 avg_nrv_factor: 0.9,
308 nrv_factor_variation: 0.0,
309 seed_offset: 2,
310 };
311 let gen = InventoryValuationGenerator::new(cfg, 55);
312 let positions = vec![make_position("MAT003", "1000", dec!(200), dec!(5))];
313 let result = gen.generate(
314 "1000",
315 &positions,
316 NaiveDate::from_ymd_opt(2024, 12, 31).unwrap(),
317 );
318
319 let line = &result.lines[0];
320 assert_eq!(
321 line.carrying_value,
322 line.total_cost - line.write_down_amount,
323 "carrying_value = total_cost - write_down"
324 );
325 assert_eq!(
326 result.total_carrying_value,
327 result.total_cost - result.total_write_down,
328 );
329 }
330
331 #[test]
332 fn test_filters_to_company() {
333 let positions = vec![
334 make_position("MAT010", "1000", dec!(10), dec!(100)),
335 make_position("MAT011", "2000", dec!(20), dec!(50)), ];
337 let cfg = InventoryValuationGeneratorConfig::default();
338 let gen = InventoryValuationGenerator::new(cfg, 1);
339 let result = gen.generate(
340 "1000",
341 &positions,
342 NaiveDate::from_ymd_opt(2024, 12, 31).unwrap(),
343 );
344
345 assert_eq!(result.lines.len(), 1, "Only MAT010 belongs to company 1000");
346 assert_eq!(result.lines[0].material_id, "MAT010");
347 }
348
349 #[test]
350 fn test_empty_positions_returns_zero_totals() {
351 let cfg = InventoryValuationGeneratorConfig::default();
352 let gen = InventoryValuationGenerator::new(cfg, 0);
353 let result = gen.generate("1000", &[], NaiveDate::from_ymd_opt(2024, 12, 31).unwrap());
354
355 assert!(result.lines.is_empty());
356 assert_eq!(result.total_cost, Decimal::ZERO);
357 assert_eq!(result.total_write_down, Decimal::ZERO);
358 assert_eq!(result.impaired_count, 0);
359 }
360}