Skip to main content

datasynth_generators/subledger/
inventory_valuation_generator.rs

1//! Inventory Valuation Generator.
2//!
3//! Produces `InventoryValuationReport` records by applying IAS 2 / ASC 330
4//! lower-of-cost-or-NRV logic to the set of `InventoryPosition` entries in
5//! the subledger snapshot.
6//!
7//! ## NRV estimation
8//!
9//! Because the synthetic dataset does not separately track market prices, the
10//! generator models Net Realisable Value (NRV) as:
11//!
12//! ```text
13//! nrv_per_unit = unit_cost * nrv_factor
14//! ```
15//!
16//! where `nrv_factor` is sampled around the configured `avg_nrv_factor` with
17//! `nrv_factor_variation` applied symmetrically.  A factor < 1.0 means the
18//! position is impaired and triggers a write-down amount:
19//!
20//! ```text
21//! write_down = quantity * max(0, cost - nrv_per_unit)
22//! ```
23
24use 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/// Configuration for the inventory valuation generator.
35#[derive(Debug, Clone)]
36pub struct InventoryValuationGeneratorConfig {
37    /// Average ratio of NRV to cost (1.0 = no impairment on average).
38    /// Values below 1.0 introduce write-downs.
39    pub avg_nrv_factor: f64,
40    /// Symmetric variation applied to `avg_nrv_factor` per material.
41    pub nrv_factor_variation: f64,
42    /// Seed offset used to keep the RNG independent of other generators.
43    pub seed_offset: u64,
44}
45
46impl Default for InventoryValuationGeneratorConfig {
47    fn default() -> Self {
48        Self {
49            avg_nrv_factor: 1.05, // NRV slightly above cost on average
50            nrv_factor_variation: 0.15,
51            seed_offset: 900,
52        }
53    }
54}
55
56/// Per-material valuation line including NRV and potential write-down.
57#[derive(Debug, Clone, Serialize, Deserialize)]
58pub struct InventoryValuationLine {
59    /// Material ID.
60    pub material_id: String,
61    /// Material description.
62    pub description: String,
63    /// Plant.
64    pub plant: String,
65    /// Storage location.
66    pub storage_location: String,
67    /// Quantity on hand.
68    pub quantity: Decimal,
69    /// Unit of measure.
70    pub unit: String,
71    /// Cost per unit (from subledger position).
72    pub cost_per_unit: Decimal,
73    /// Total cost value.
74    pub total_cost: Decimal,
75    /// Estimated NRV per unit.
76    pub nrv_per_unit: Decimal,
77    /// Total NRV.
78    pub total_nrv: Decimal,
79    /// Write-down required (lower of cost vs NRV, per IAS 2).
80    /// Zero when NRV >= cost.
81    pub write_down_amount: Decimal,
82    /// Carrying value after write-down (min of cost and NRV).
83    pub carrying_value: Decimal,
84    /// Whether the position is impaired (write_down_amount > 0).
85    pub is_impaired: bool,
86}
87
88/// Inventory valuation report for a company/period, with write-down analysis.
89#[derive(Debug, Clone, Serialize, Deserialize)]
90pub struct InventoryValuationResult {
91    /// Company code.
92    pub company_code: String,
93    /// As-of date.
94    pub as_of_date: NaiveDate,
95    /// Per-material valuation lines.
96    pub lines: Vec<InventoryValuationLine>,
97    /// Total cost of all positions.
98    pub total_cost: Decimal,
99    /// Total NRV of all positions.
100    pub total_nrv: Decimal,
101    /// Total write-down required.
102    pub total_write_down: Decimal,
103    /// Carrying value after write-downs.
104    pub total_carrying_value: Decimal,
105    /// Count of impaired positions.
106    pub impaired_count: u32,
107    /// Underlying valuation report (sorted by value, with ABC analysis).
108    pub valuation_report: InventoryValuationReport,
109}
110
111/// Generator that applies lower-of-cost-or-NRV valuation to inventory positions.
112pub struct InventoryValuationGenerator {
113    config: InventoryValuationGeneratorConfig,
114    seed: u64,
115}
116
117impl InventoryValuationGenerator {
118    /// Creates a new generator with the given base seed.
119    pub fn new(config: InventoryValuationGeneratorConfig, seed: u64) -> Self {
120        Self { config, seed }
121    }
122
123    /// Generates an `InventoryValuationResult` for a company as of a date.
124    ///
125    /// * `company_code` — the company to value (filters positions by `company_code`).
126    /// * `positions` — full slice of inventory positions (may contain multiple companies).
127    /// * `as_of_date` — valuation date.
128    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            // Sample NRV factor for this position.
153            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            // IAS 2: carrying value = min(cost, NRV)
162            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        // Sort lines by write-down descending (most impaired first).
194        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        // Build the standard InventoryValuationReport as well.
199        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        // Set avg_nrv_factor = 0.8 so NRV is always below cost → write-down expected.
258        let cfg = InventoryValuationGeneratorConfig {
259            avg_nrv_factor: 0.8,
260            nrv_factor_variation: 0.0, // deterministic
261            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        // cost = 100 * 10 = 1000, nrv = 1000 * 0.8 = 800, write-down = 200
272        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        // Set avg_nrv_factor = 1.2 so NRV > cost → no write-down.
282        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)), // different company
336        ];
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}