Skip to main content

datasynth_generators/subledger/
inventory_generator.rs

1//! Inventory generator.
2
3use chrono::NaiveDate;
4use datasynth_core::utils::seeded_rng;
5use rand::Rng;
6use rand_chacha::ChaCha8Rng;
7use rust_decimal::Decimal;
8use rust_decimal_macros::dec;
9
10use tracing::debug;
11
12use datasynth_core::models::subledger::inventory::{
13    InventoryMovement, InventoryPosition, MovementType, PositionValuation, ReferenceDocType,
14    ValuationMethod,
15};
16use datasynth_core::models::{JournalEntry, JournalEntryLine};
17
18/// Configuration for inventory generation.
19#[derive(Debug, Clone)]
20pub struct InventoryGeneratorConfig {
21    /// Default valuation method.
22    pub default_valuation_method: ValuationMethod,
23    /// Average unit cost.
24    pub avg_unit_cost: Decimal,
25    /// Unit cost variation.
26    pub cost_variation: Decimal,
27    /// Average movement quantity.
28    pub avg_movement_quantity: Decimal,
29    /// Quantity variation.
30    pub quantity_variation: Decimal,
31}
32
33impl Default for InventoryGeneratorConfig {
34    fn default() -> Self {
35        Self {
36            default_valuation_method: ValuationMethod::MovingAverage,
37            avg_unit_cost: dec!(100),
38            cost_variation: dec!(0.5),
39            avg_movement_quantity: dec!(50),
40            quantity_variation: dec!(0.8),
41        }
42    }
43}
44
45/// Generator for inventory transactions.
46pub struct InventoryGenerator {
47    config: InventoryGeneratorConfig,
48    rng: ChaCha8Rng,
49    movement_counter: u64,
50    /// Currency code for generated movements and journal entries.
51    currency: String,
52}
53
54impl InventoryGenerator {
55    /// Creates a new inventory generator with the specified currency.
56    pub fn new_with_currency(
57        config: InventoryGeneratorConfig,
58        rng: ChaCha8Rng,
59        currency: String,
60    ) -> Self {
61        Self {
62            config,
63            rng,
64            movement_counter: 0,
65            currency,
66        }
67    }
68
69    /// Creates a new inventory generator (defaults to USD).
70    pub fn new(config: InventoryGeneratorConfig, rng: ChaCha8Rng) -> Self {
71        Self::new_with_currency(config, rng, "USD".to_string())
72    }
73
74    /// Creates a new inventory generator from a seed, constructing the RNG internally.
75    pub fn with_seed(config: InventoryGeneratorConfig, seed: u64) -> Self {
76        Self::new(config, seeded_rng(seed, 0))
77    }
78
79    /// Generates an initial inventory position.
80    pub fn generate_position(
81        &mut self,
82        company_code: &str,
83        plant: &str,
84        storage_location: &str,
85        material_id: &str,
86        description: &str,
87        initial_quantity: Decimal,
88        unit_cost: Option<Decimal>,
89        _currency: &str,
90    ) -> InventoryPosition {
91        debug!(company_code, plant, material_id, %initial_quantity, "Generating inventory position");
92        let cost = unit_cost.unwrap_or_else(|| self.generate_unit_cost());
93        let total_value = (initial_quantity * cost).round_dp(2);
94
95        let mut position = InventoryPosition::new(
96            material_id.to_string(),
97            description.to_string(),
98            plant.to_string(),
99            storage_location.to_string(),
100            company_code.to_string(),
101            "EA".to_string(),
102        );
103
104        position.quantity_on_hand = initial_quantity;
105        position.quantity_available = initial_quantity;
106        position.valuation = PositionValuation {
107            method: self.config.default_valuation_method,
108            standard_cost: cost,
109            unit_cost: cost,
110            total_value,
111            price_variance: Decimal::ZERO,
112            last_price_change: None,
113        };
114        position.min_stock = Some(dec!(10));
115        position.max_stock = Some(dec!(1000));
116        position.reorder_point = Some(dec!(50));
117
118        position
119    }
120
121    /// Generates a goods receipt (inventory increase).
122    pub fn generate_goods_receipt(
123        &mut self,
124        position: &InventoryPosition,
125        receipt_date: NaiveDate,
126        quantity: Decimal,
127        unit_cost: Decimal,
128        po_number: Option<&str>,
129    ) -> (InventoryMovement, JournalEntry) {
130        self.movement_counter += 1;
131        let document_number = format!("INVMV{:08}", self.movement_counter);
132        let batch_number = format!("BATCH{:06}", self.rng.random::<u32>() % 1000000);
133
134        let mut movement = InventoryMovement::new(
135            document_number,
136            1, // item_number
137            position.company_code.clone(),
138            receipt_date,
139            MovementType::GoodsReceipt,
140            position.material_id.clone(),
141            position.description.clone(),
142            position.plant.clone(),
143            position.storage_location.clone(),
144            quantity,
145            position.unit.clone(),
146            unit_cost,
147            self.currency.clone(),
148            "SYSTEM".to_string(),
149        );
150
151        movement.batch_number = Some(batch_number);
152        if let Some(po) = po_number {
153            movement.reference_doc_type = Some(ReferenceDocType::PurchaseOrder);
154            movement.reference_doc_number = Some(po.to_string());
155        }
156        movement.reason_code = Some("Goods Receipt from PO".to_string());
157
158        let je = self.generate_goods_receipt_je(&movement);
159        (movement, je)
160    }
161
162    /// Generates a goods issue (inventory decrease).
163    pub fn generate_goods_issue(
164        &mut self,
165        position: &InventoryPosition,
166        issue_date: NaiveDate,
167        quantity: Decimal,
168        cost_center: Option<&str>,
169        production_order: Option<&str>,
170    ) -> (InventoryMovement, JournalEntry) {
171        self.movement_counter += 1;
172        let document_number = format!("INVMV{:08}", self.movement_counter);
173
174        let unit_cost = position.valuation.unit_cost;
175
176        let mut movement = InventoryMovement::new(
177            document_number,
178            1, // item_number
179            position.company_code.clone(),
180            issue_date,
181            MovementType::GoodsIssue,
182            position.material_id.clone(),
183            position.description.clone(),
184            position.plant.clone(),
185            position.storage_location.clone(),
186            quantity,
187            position.unit.clone(),
188            unit_cost,
189            self.currency.clone(),
190            "SYSTEM".to_string(),
191        );
192
193        movement.cost_center = cost_center.map(|s| s.to_string());
194        if let Some(po) = production_order {
195            movement.reference_doc_type = Some(ReferenceDocType::ProductionOrder);
196            movement.reference_doc_number = Some(po.to_string());
197        }
198        movement.reason_code = Some("Goods Issue to Production".to_string());
199
200        let je = self.generate_goods_issue_je(&movement);
201        (movement, je)
202    }
203
204    /// Generates a stock transfer between locations.
205    pub fn generate_transfer(
206        &mut self,
207        position: &InventoryPosition,
208        transfer_date: NaiveDate,
209        quantity: Decimal,
210        to_plant: &str,
211        to_storage_location: &str,
212    ) -> (InventoryMovement, InventoryMovement, JournalEntry) {
213        // Issue from source
214        self.movement_counter += 1;
215        let issue_id = format!("INVMV{:08}", self.movement_counter);
216
217        // Receipt at destination
218        self.movement_counter += 1;
219        let receipt_id = format!("INVMV{:08}", self.movement_counter);
220
221        let unit_cost = position.valuation.unit_cost;
222
223        let mut issue = InventoryMovement::new(
224            issue_id,
225            1, // item_number
226            position.company_code.clone(),
227            transfer_date,
228            MovementType::TransferOut,
229            position.material_id.clone(),
230            position.description.clone(),
231            position.plant.clone(),
232            position.storage_location.clone(),
233            quantity,
234            position.unit.clone(),
235            unit_cost,
236            self.currency.clone(),
237            "SYSTEM".to_string(),
238        );
239        issue.reference_doc_type = Some(ReferenceDocType::MaterialDocument);
240        issue.reference_doc_number = Some(receipt_id.clone());
241        issue.reason_code = Some(format!("Transfer to {}/{}", to_plant, to_storage_location));
242
243        let mut receipt = InventoryMovement::new(
244            receipt_id,
245            1, // item_number
246            position.company_code.clone(),
247            transfer_date,
248            MovementType::TransferIn,
249            position.material_id.clone(),
250            position.description.clone(),
251            to_plant.to_string(),
252            to_storage_location.to_string(),
253            quantity,
254            position.unit.clone(),
255            unit_cost,
256            self.currency.clone(),
257            "SYSTEM".to_string(),
258        );
259        receipt.reference_doc_type = Some(ReferenceDocType::MaterialDocument);
260        receipt.reference_doc_number = Some(issue.document_number.clone());
261        receipt.reason_code = Some(format!(
262            "Transfer from {}/{}",
263            position.plant, position.storage_location
264        ));
265
266        // For intra-company transfer, no GL impact unless different plants have different valuations
267        let je = self.generate_transfer_je(&issue, &receipt);
268
269        (issue, receipt, je)
270    }
271
272    /// Generates an inventory adjustment.
273    pub fn generate_adjustment(
274        &mut self,
275        position: &InventoryPosition,
276        adjustment_date: NaiveDate,
277        quantity_change: Decimal,
278        reason: &str,
279    ) -> (InventoryMovement, JournalEntry) {
280        self.movement_counter += 1;
281        let document_number = format!("INVMV{:08}", self.movement_counter);
282
283        let movement_type = if quantity_change > Decimal::ZERO {
284            MovementType::InventoryAdjustmentIn
285        } else {
286            MovementType::InventoryAdjustmentOut
287        };
288
289        let unit_cost = position.valuation.unit_cost;
290
291        let mut movement = InventoryMovement::new(
292            document_number,
293            1, // item_number
294            position.company_code.clone(),
295            adjustment_date,
296            movement_type,
297            position.material_id.clone(),
298            position.description.clone(),
299            position.plant.clone(),
300            position.storage_location.clone(),
301            quantity_change.abs(),
302            position.unit.clone(),
303            unit_cost,
304            self.currency.clone(),
305            "SYSTEM".to_string(),
306        );
307        movement.reference_doc_type = Some(ReferenceDocType::PhysicalInventoryDoc);
308        movement.reference_doc_number = Some(format!("PI{:08}", self.movement_counter));
309        movement.reason_code = Some(reason.to_string());
310
311        let je = self.generate_adjustment_je(&movement, quantity_change > Decimal::ZERO);
312        (movement, je)
313    }
314
315    fn generate_unit_cost(&mut self) -> Decimal {
316        let base = self.config.avg_unit_cost;
317        let variation = base * self.config.cost_variation;
318        let random: f64 = self.rng.random_range(-1.0..1.0);
319        (base + variation * Decimal::try_from(random).unwrap_or_default())
320            .max(dec!(1))
321            .round_dp(2)
322    }
323
324    fn generate_goods_receipt_je(&self, movement: &InventoryMovement) -> JournalEntry {
325        let mut je = JournalEntry::new_simple(
326            format!("JE-{}", movement.document_number),
327            movement.company_code.clone(),
328            movement.posting_date,
329            format!("Goods Receipt {}", movement.material_id),
330        );
331
332        // Debit Inventory
333        je.add_line(JournalEntryLine {
334            line_number: 1,
335            gl_account: "1300".to_string(),
336            debit_amount: movement.value,
337            cost_center: movement.cost_center.clone(),
338            profit_center: None,
339            reference: Some(movement.document_number.clone()),
340            assignment: Some(movement.material_id.clone()),
341            text: Some(movement.description.clone()),
342            quantity: Some(movement.quantity),
343            unit: Some(movement.unit.clone()),
344            ..Default::default()
345        });
346
347        // Credit GR/IR Clearing
348        je.add_line(JournalEntryLine {
349            line_number: 2,
350            gl_account: "2100".to_string(),
351            credit_amount: movement.value,
352            reference: movement.reference_doc_number.clone(),
353            ..Default::default()
354        });
355
356        je
357    }
358
359    fn generate_goods_issue_je(&self, movement: &InventoryMovement) -> JournalEntry {
360        let mut je = JournalEntry::new_simple(
361            format!("JE-{}", movement.document_number),
362            movement.company_code.clone(),
363            movement.posting_date,
364            format!("Goods Issue {}", movement.material_id),
365        );
366
367        // Debit Cost of Goods Sold or WIP
368        let debit_account =
369            if movement.reference_doc_type == Some(ReferenceDocType::ProductionOrder) {
370                "1350".to_string() // WIP
371            } else {
372                "5100".to_string() // COGS
373            };
374
375        je.add_line(JournalEntryLine {
376            line_number: 1,
377            gl_account: debit_account,
378            debit_amount: movement.value,
379            cost_center: movement.cost_center.clone(),
380            profit_center: None,
381            reference: Some(movement.document_number.clone()),
382            assignment: Some(movement.material_id.clone()),
383            text: Some(movement.description.clone()),
384            quantity: Some(movement.quantity),
385            unit: Some(movement.unit.clone()),
386            ..Default::default()
387        });
388
389        // Credit Inventory
390        je.add_line(JournalEntryLine {
391            line_number: 2,
392            gl_account: "1300".to_string(),
393            credit_amount: movement.value,
394            reference: Some(movement.document_number.clone()),
395            assignment: Some(movement.material_id.clone()),
396            quantity: Some(movement.quantity),
397            unit: Some(movement.unit.clone()),
398            ..Default::default()
399        });
400
401        je
402    }
403
404    fn generate_transfer_je(
405        &self,
406        issue: &InventoryMovement,
407        _receipt: &InventoryMovement,
408    ) -> JournalEntry {
409        // For intra-company transfer with same valuation, this might be a memo entry
410        // or could involve plant-specific inventory accounts
411        let mut je = JournalEntry::new_simple(
412            format!("JE-XFER-{}", issue.document_number),
413            issue.company_code.clone(),
414            issue.posting_date,
415            format!("Stock Transfer {}", issue.material_id),
416        );
417
418        // Debit Inventory at destination (using same account for simplicity)
419        je.add_line(JournalEntryLine {
420            line_number: 1,
421            gl_account: "1300".to_string(),
422            debit_amount: issue.value,
423            reference: Some(issue.document_number.clone()),
424            assignment: Some(issue.material_id.clone()),
425            quantity: Some(issue.quantity),
426            unit: Some(issue.unit.clone()),
427            ..Default::default()
428        });
429
430        // Credit Inventory at source
431        je.add_line(JournalEntryLine {
432            line_number: 2,
433            gl_account: "1300".to_string(),
434            credit_amount: issue.value,
435            reference: Some(issue.document_number.clone()),
436            assignment: Some(issue.material_id.clone()),
437            quantity: Some(issue.quantity),
438            unit: Some(issue.unit.clone()),
439            ..Default::default()
440        });
441
442        je
443    }
444
445    fn generate_adjustment_je(
446        &self,
447        movement: &InventoryMovement,
448        is_increase: bool,
449    ) -> JournalEntry {
450        let mut je = JournalEntry::new_simple(
451            format!("JE-{}", movement.document_number),
452            movement.company_code.clone(),
453            movement.posting_date,
454            format!("Inventory Adjustment {}", movement.material_id),
455        );
456
457        if is_increase {
458            // Debit Inventory
459            je.add_line(JournalEntryLine {
460                line_number: 1,
461                gl_account: "1300".to_string(),
462                debit_amount: movement.value,
463                reference: Some(movement.document_number.clone()),
464                assignment: Some(movement.material_id.clone()),
465                text: Some(movement.reason_code.clone().unwrap_or_default()),
466                quantity: Some(movement.quantity),
467                unit: Some(movement.unit.clone()),
468                ..Default::default()
469            });
470
471            // Credit Inventory Adjustment Account
472            je.add_line(JournalEntryLine {
473                line_number: 2,
474                gl_account: "4950".to_string(),
475                credit_amount: movement.value,
476                cost_center: movement.cost_center.clone(),
477                reference: Some(movement.document_number.clone()),
478                ..Default::default()
479            });
480        } else {
481            // Debit Inventory Adjustment Account (expense)
482            je.add_line(JournalEntryLine {
483                line_number: 1,
484                gl_account: "6950".to_string(),
485                debit_amount: movement.value,
486                cost_center: movement.cost_center.clone(),
487                reference: Some(movement.document_number.clone()),
488                text: Some(movement.reason_code.clone().unwrap_or_default()),
489                ..Default::default()
490            });
491
492            // Credit Inventory
493            je.add_line(JournalEntryLine {
494                line_number: 2,
495                gl_account: "1300".to_string(),
496                credit_amount: movement.value,
497                reference: Some(movement.document_number.clone()),
498                assignment: Some(movement.material_id.clone()),
499                quantity: Some(movement.quantity),
500                unit: Some(movement.unit.clone()),
501                ..Default::default()
502            });
503        }
504
505        je
506    }
507}
508
509#[cfg(test)]
510#[allow(clippy::unwrap_used)]
511mod tests {
512    use super::*;
513    use rand::SeedableRng;
514
515    #[test]
516    fn test_generate_position() {
517        let rng = ChaCha8Rng::seed_from_u64(12345);
518        let mut generator = InventoryGenerator::new(InventoryGeneratorConfig::default(), rng);
519
520        let position = generator.generate_position(
521            "1000",
522            "PLANT01",
523            "WH01",
524            "MAT001",
525            "Raw Material A",
526            dec!(100),
527            None,
528            "USD",
529        );
530
531        assert_eq!(position.quantity_on_hand, dec!(100));
532        assert!(position.valuation.unit_cost > Decimal::ZERO);
533    }
534
535    #[test]
536    fn test_generate_goods_receipt() {
537        let rng = ChaCha8Rng::seed_from_u64(12345);
538        let mut generator = InventoryGenerator::new(InventoryGeneratorConfig::default(), rng);
539
540        let position = generator.generate_position(
541            "1000",
542            "PLANT01",
543            "WH01",
544            "MAT001",
545            "Raw Material A",
546            dec!(100),
547            Some(dec!(50)),
548            "USD",
549        );
550
551        let (movement, je) = generator.generate_goods_receipt(
552            &position,
553            NaiveDate::from_ymd_opt(2024, 1, 15).unwrap(),
554            dec!(50),
555            dec!(50),
556            Some("PO001"),
557        );
558
559        assert_eq!(movement.movement_type, MovementType::GoodsReceipt);
560        assert_eq!(movement.quantity, dec!(50));
561        assert!(je.is_balanced());
562    }
563
564    #[test]
565    fn test_generate_goods_issue() {
566        let rng = ChaCha8Rng::seed_from_u64(12345);
567        let mut generator = InventoryGenerator::new(InventoryGeneratorConfig::default(), rng);
568
569        let position = generator.generate_position(
570            "1000",
571            "PLANT01",
572            "WH01",
573            "MAT001",
574            "Raw Material A",
575            dec!(100),
576            Some(dec!(50)),
577            "USD",
578        );
579
580        let (movement, je) = generator.generate_goods_issue(
581            &position,
582            NaiveDate::from_ymd_opt(2024, 1, 20).unwrap(),
583            dec!(30),
584            Some("CC100"),
585            None,
586        );
587
588        assert_eq!(movement.movement_type, MovementType::GoodsIssue);
589        assert_eq!(movement.quantity, dec!(30));
590        assert!(je.is_balanced());
591    }
592}