1use chrono::NaiveDate;
4use datasynth_core::models::{
5 BomComponent, Material, MaterialAccountDetermination, MaterialGroup, MaterialPool,
6 MaterialType, UnitOfMeasure, ValuationMethod,
7};
8use datasynth_core::utils::seeded_rng;
9use rand::prelude::*;
10use rand_chacha::ChaCha8Rng;
11use rust_decimal::Decimal;
12use tracing::debug;
13
14#[derive(Debug, Clone)]
16pub struct MaterialGeneratorConfig {
17 pub material_type_distribution: Vec<(MaterialType, f64)>,
19 pub valuation_method_distribution: Vec<(ValuationMethod, f64)>,
21 pub bom_rate: f64,
23 pub default_uom: String,
25 pub gross_margin_range: (f64, f64),
27 pub standard_cost_range: (Decimal, Decimal),
29}
30
31impl Default for MaterialGeneratorConfig {
32 fn default() -> Self {
33 Self {
34 material_type_distribution: vec![
35 (MaterialType::FinishedGood, 0.30),
36 (MaterialType::RawMaterial, 0.35),
37 (MaterialType::SemiFinished, 0.15),
38 (MaterialType::TradingGood, 0.10),
39 (MaterialType::OperatingSupplies, 0.05),
40 (MaterialType::Packaging, 0.05),
41 ],
42 valuation_method_distribution: vec![
43 (ValuationMethod::StandardCost, 0.60),
44 (ValuationMethod::MovingAverage, 0.30),
45 (ValuationMethod::Fifo, 0.08),
46 (ValuationMethod::Lifo, 0.02),
47 ],
48 bom_rate: 0.25,
49 default_uom: "EA".to_string(),
50 gross_margin_range: (0.20, 0.50),
51 standard_cost_range: (Decimal::from(10), Decimal::from(10_000)),
52 }
53 }
54}
55
56const MATERIAL_DESCRIPTIONS: &[(MaterialType, &[&str])] = &[
58 (
59 MaterialType::FinishedGood,
60 &[
61 "Assembled Unit A",
62 "Complete Product B",
63 "Final Assembly C",
64 "Packaged Item D",
65 "Ready Product E",
66 "Finished Component F",
67 "Complete Module G",
68 "Final Product H",
69 ],
70 ),
71 (
72 MaterialType::RawMaterial,
73 &[
74 "Steel Plate Grade A",
75 "Aluminum Sheet 6061",
76 "Copper Wire AWG 12",
77 "Plastic Resin ABS",
78 "Raw Polymer Mix",
79 "Chemical Compound X",
80 "Base Material Y",
81 "Raw Stock Z",
82 ],
83 ),
84 (
85 MaterialType::SemiFinished,
86 &[
87 "Sub-Assembly Part A",
88 "Machined Component B",
89 "Intermediate Product C",
90 "Partial Assembly D",
91 "Semi-Complete Unit E",
92 "Work in Progress F",
93 "Partially Processed G",
94 "Intermediate Module H",
95 ],
96 ),
97 (
98 MaterialType::TradingGood,
99 &[
100 "Resale Item A",
101 "Trading Good B",
102 "Merchandise C",
103 "Distribution Item D",
104 "Wholesale Product E",
105 "Retail Item F",
106 "Trade Good G",
107 "Commercial Product H",
108 ],
109 ),
110 (
111 MaterialType::OperatingSupplies,
112 &[
113 "Cleaning Supplies",
114 "Office Supplies",
115 "Maintenance Supplies",
116 "Workshop Consumables",
117 "Safety Supplies",
118 "Facility Supplies",
119 "General Supplies",
120 "Operating Materials",
121 ],
122 ),
123 (
124 MaterialType::Packaging,
125 &[
126 "Cardboard Box Large",
127 "Plastic Container",
128 "Shipping Carton",
129 "Protective Wrap",
130 "Pallet Unit",
131 "Foam Insert",
132 "Label Roll",
133 "Tape Industrial",
134 ],
135 ),
136];
137
138pub struct MaterialGenerator {
140 rng: ChaCha8Rng,
141 seed: u64,
142 config: MaterialGeneratorConfig,
143 material_counter: usize,
144 created_materials: Vec<String>, country_pack: Option<datasynth_core::CountryPack>,
147}
148
149impl MaterialGenerator {
150 pub fn new(seed: u64) -> Self {
152 Self::with_config(seed, MaterialGeneratorConfig::default())
153 }
154
155 pub fn with_config(seed: u64, config: MaterialGeneratorConfig) -> Self {
157 Self {
158 rng: seeded_rng(seed, 0),
159 seed,
160 config,
161 material_counter: 0,
162 created_materials: Vec::new(),
163 country_pack: None,
164 }
165 }
166
167 pub fn set_country_pack(&mut self, pack: datasynth_core::CountryPack) {
169 self.country_pack = Some(pack);
170 }
171
172 pub fn generate_material(
174 &mut self,
175 _company_code: &str,
176 _effective_date: NaiveDate,
177 ) -> Material {
178 self.material_counter += 1;
179
180 let material_id = format!("MAT-{:06}", self.material_counter);
181 let material_type = self.select_material_type();
182 let description = self.select_description(&material_type);
183
184 let mut material =
185 Material::new(material_id.clone(), description.to_string(), material_type);
186
187 material.material_group = self.select_material_group(&material_type);
189
190 material.valuation_method = self.select_valuation_method();
192
193 let standard_cost = self.generate_standard_cost();
195 material.standard_cost = standard_cost;
196 material.purchase_price = standard_cost;
197 material.list_price = self.generate_list_price(standard_cost);
198
199 material.base_uom = if material_type == MaterialType::OperatingSupplies {
201 UnitOfMeasure::hour()
202 } else {
203 UnitOfMeasure::each()
204 };
205
206 material.account_determination = self.generate_account_determination(&material_type);
208
209 if material_type != MaterialType::OperatingSupplies {
211 material.safety_stock = self.generate_safety_stock();
212 material.reorder_point = material.safety_stock * Decimal::from(2);
213 }
214
215 self.created_materials.push(material_id);
217
218 material
219 }
220
221 pub fn generate_material_of_type(
223 &mut self,
224 material_type: MaterialType,
225 _company_code: &str,
226 _effective_date: NaiveDate,
227 ) -> Material {
228 self.material_counter += 1;
229
230 let material_id = format!("MAT-{:06}", self.material_counter);
231 let description = self.select_description(&material_type);
232
233 let mut material =
234 Material::new(material_id.clone(), description.to_string(), material_type);
235
236 material.material_group = self.select_material_group(&material_type);
237 material.valuation_method = self.select_valuation_method();
238
239 let standard_cost = self.generate_standard_cost();
240 material.standard_cost = standard_cost;
241 material.purchase_price = standard_cost;
242 material.list_price = self.generate_list_price(standard_cost);
243
244 material.base_uom = if material_type == MaterialType::OperatingSupplies {
245 UnitOfMeasure::hour()
246 } else {
247 UnitOfMeasure::each()
248 };
249
250 material.account_determination = self.generate_account_determination(&material_type);
251
252 if material_type != MaterialType::OperatingSupplies {
253 material.safety_stock = self.generate_safety_stock();
254 material.reorder_point = material.safety_stock * Decimal::from(2);
255 }
256
257 self.created_materials.push(material_id);
258
259 material
260 }
261
262 pub fn generate_material_with_bom(
264 &mut self,
265 company_code: &str,
266 effective_date: NaiveDate,
267 component_count: usize,
268 ) -> Material {
269 let mut components = Vec::new();
271 for i in 0..component_count {
272 let component_type = if i % 2 == 0 {
273 MaterialType::RawMaterial
274 } else {
275 MaterialType::SemiFinished
276 };
277 let component =
278 self.generate_material_of_type(component_type, company_code, effective_date);
279
280 let quantity = Decimal::from(self.rng.random_range(1..10));
281 components.push(BomComponent {
282 component_material_id: component.material_id.clone(),
283 quantity,
284 uom: component.base_uom.code.clone(),
285 position: (i + 1) as u16 * 10,
286 scrap_percentage: Decimal::ZERO,
287 is_optional: false,
288 id: None,
289 entity_code: None,
290 parent_material: None,
291 component_description: None,
292 level: None,
293 is_phantom: false,
294 });
295 }
296
297 let mut material = self.generate_material_of_type(
299 MaterialType::FinishedGood,
300 company_code,
301 effective_date,
302 );
303
304 material.bom_components = Some(components);
305
306 material
307 }
308
309 pub fn generate_material_pool(
311 &mut self,
312 count: usize,
313 company_code: &str,
314 effective_date: NaiveDate,
315 ) -> MaterialPool {
316 debug!(count, company_code, %effective_date, "Generating material pool");
317 let mut pool = MaterialPool::new();
318
319 for _ in 0..count {
320 let material = self.generate_material(company_code, effective_date);
321 pool.add_material(material);
322 }
323
324 pool
325 }
326
327 pub fn generate_material_pool_with_bom(
329 &mut self,
330 count: usize,
331 bom_rate: f64,
332 company_code: &str,
333 effective_date: NaiveDate,
334 ) -> MaterialPool {
335 let mut pool = MaterialPool::new();
336
337 let raw_count = (count as f64 * 0.4) as usize;
339 for _ in 0..raw_count {
340 let material = self.generate_material_of_type(
341 MaterialType::RawMaterial,
342 company_code,
343 effective_date,
344 );
345 pool.add_material(material);
346 }
347
348 let semi_count = (count as f64 * 0.2) as usize;
349 for _ in 0..semi_count {
350 let material = self.generate_material_of_type(
351 MaterialType::SemiFinished,
352 company_code,
353 effective_date,
354 );
355 pool.add_material(material);
356 }
357
358 let finished_count = count - raw_count - semi_count;
360 for _ in 0..finished_count {
361 let material =
362 if self.rng.random::<f64>() < bom_rate && !self.created_materials.is_empty() {
363 self.generate_material_with_bom_from_existing(company_code, effective_date)
364 } else {
365 self.generate_material_of_type(
366 MaterialType::FinishedGood,
367 company_code,
368 effective_date,
369 )
370 };
371 pool.add_material(material);
372 }
373
374 pool
375 }
376
377 fn generate_material_with_bom_from_existing(
379 &mut self,
380 company_code: &str,
381 effective_date: NaiveDate,
382 ) -> Material {
383 let mut material = self.generate_material_of_type(
384 MaterialType::FinishedGood,
385 company_code,
386 effective_date,
387 );
388
389 let component_count = self
391 .rng
392 .random_range(2..=5)
393 .min(self.created_materials.len());
394 let mut components = Vec::new();
395
396 for i in 0..component_count {
397 if let Some(component_material_id) = self.created_materials.get(i) {
398 components.push(BomComponent {
399 component_material_id: component_material_id.clone(),
400 quantity: Decimal::from(self.rng.random_range(1..5)),
401 uom: "EA".to_string(),
402 position: (i + 1) as u16 * 10,
403 scrap_percentage: Decimal::ZERO,
404 is_optional: false,
405 id: None,
406 entity_code: None,
407 parent_material: None,
408 component_description: None,
409 level: None,
410 is_phantom: false,
411 });
412 }
413 }
414
415 if !components.is_empty() {
416 material.bom_components = Some(components);
417 }
418
419 material
420 }
421
422 fn select_material_type(&mut self) -> MaterialType {
424 let roll: f64 = self.rng.random();
425 let mut cumulative = 0.0;
426
427 for (mat_type, prob) in &self.config.material_type_distribution {
428 cumulative += prob;
429 if roll < cumulative {
430 return *mat_type;
431 }
432 }
433
434 MaterialType::FinishedGood
435 }
436
437 fn select_valuation_method(&mut self) -> ValuationMethod {
439 let roll: f64 = self.rng.random();
440 let mut cumulative = 0.0;
441
442 for (method, prob) in &self.config.valuation_method_distribution {
443 cumulative += prob;
444 if roll < cumulative {
445 return *method;
446 }
447 }
448
449 ValuationMethod::StandardCost
450 }
451
452 fn select_description(&mut self, material_type: &MaterialType) -> &'static str {
454 for (mat_type, descriptions) in MATERIAL_DESCRIPTIONS {
455 if mat_type == material_type {
456 let idx = self.rng.random_range(0..descriptions.len());
457 return descriptions[idx];
458 }
459 }
460 "Generic Material"
461 }
462
463 fn select_material_group(&mut self, material_type: &MaterialType) -> MaterialGroup {
465 match material_type {
466 MaterialType::FinishedGood => {
467 let options = [
468 MaterialGroup::Electronics,
469 MaterialGroup::Mechanical,
470 MaterialGroup::FinishedGoods,
471 ];
472 options[self.rng.random_range(0..options.len())]
473 }
474 MaterialType::RawMaterial => {
475 let options = [
476 MaterialGroup::Chemicals,
477 MaterialGroup::Chemical,
478 MaterialGroup::Mechanical,
479 ];
480 options[self.rng.random_range(0..options.len())]
481 }
482 MaterialType::SemiFinished => {
483 let options = [MaterialGroup::Electronics, MaterialGroup::Mechanical];
484 options[self.rng.random_range(0..options.len())]
485 }
486 MaterialType::TradingGood => MaterialGroup::FinishedGoods,
487 MaterialType::OperatingSupplies => MaterialGroup::Services,
488 MaterialType::Packaging | MaterialType::SparePart => MaterialGroup::Consumables,
489 _ => MaterialGroup::Consumables,
490 }
491 }
492
493 fn generate_standard_cost(&mut self) -> Decimal {
495 let min = self.config.standard_cost_range.0;
496 let max = self.config.standard_cost_range.1;
497 let range = (max - min).to_string().parse::<f64>().unwrap_or(0.0);
498 let offset =
499 Decimal::from_f64_retain(self.rng.random::<f64>() * range).unwrap_or(Decimal::ZERO);
500 (min + offset).round_dp(2)
501 }
502
503 fn generate_list_price(&mut self, standard_cost: Decimal) -> Decimal {
505 let (min_margin, max_margin) = self.config.gross_margin_range;
506 let margin = min_margin + self.rng.random::<f64>() * (max_margin - min_margin);
507 let markup = Decimal::from_f64_retain(1.0 / (1.0 - margin)).unwrap_or(Decimal::from(2));
508 (standard_cost * markup).round_dp(2)
509 }
510
511 fn generate_safety_stock(&mut self) -> Decimal {
513 Decimal::from(self.rng.random_range(10..500))
514 }
515
516 fn generate_account_determination(
518 &mut self,
519 material_type: &MaterialType,
520 ) -> MaterialAccountDetermination {
521 match material_type {
522 MaterialType::FinishedGood | MaterialType::TradingGood => {
523 MaterialAccountDetermination {
524 inventory_account: "140000".to_string(),
525 cogs_account: "500000".to_string(),
526 revenue_account: "400000".to_string(),
527 purchase_expense_account: "500000".to_string(),
528 price_difference_account: "590000".to_string(),
529 gr_ir_account: "290000".to_string(),
530 }
531 }
532 MaterialType::RawMaterial | MaterialType::SemiFinished => {
533 MaterialAccountDetermination {
534 inventory_account: "141000".to_string(),
535 cogs_account: "510000".to_string(),
536 revenue_account: "400000".to_string(),
537 purchase_expense_account: "510000".to_string(),
538 price_difference_account: "591000".to_string(),
539 gr_ir_account: "290000".to_string(),
540 }
541 }
542 MaterialType::OperatingSupplies => MaterialAccountDetermination {
543 inventory_account: "".to_string(),
544 cogs_account: "520000".to_string(),
545 revenue_account: "410000".to_string(),
546 purchase_expense_account: "520000".to_string(),
547 price_difference_account: "".to_string(),
548 gr_ir_account: "290000".to_string(),
549 },
550 _ => MaterialAccountDetermination {
551 inventory_account: "145000".to_string(),
552 cogs_account: "530000".to_string(),
553 revenue_account: "400000".to_string(),
554 purchase_expense_account: "530000".to_string(),
555 price_difference_account: "595000".to_string(),
556 gr_ir_account: "290000".to_string(),
557 },
558 }
559 }
560
561 pub fn reset(&mut self) {
563 self.rng = seeded_rng(self.seed, 0);
564 self.material_counter = 0;
565 self.created_materials.clear();
566 }
567}
568
569#[cfg(test)]
570#[allow(clippy::unwrap_used)]
571mod tests {
572 use super::*;
573
574 #[test]
575 fn test_material_generation() {
576 let mut gen = MaterialGenerator::new(42);
577 let material = gen.generate_material("1000", NaiveDate::from_ymd_opt(2024, 1, 1).unwrap());
578
579 assert!(!material.material_id.is_empty());
580 assert!(!material.description.is_empty());
581 assert!(material.standard_cost > Decimal::ZERO);
582 assert!(material.list_price >= material.standard_cost);
583 }
584
585 #[test]
586 fn test_material_pool_generation() {
587 let mut gen = MaterialGenerator::new(42);
588 let pool =
589 gen.generate_material_pool(50, "1000", NaiveDate::from_ymd_opt(2024, 1, 1).unwrap());
590
591 assert_eq!(pool.materials.len(), 50);
592
593 let raw_count = pool
595 .materials
596 .iter()
597 .filter(|m| m.material_type == MaterialType::RawMaterial)
598 .count();
599 let finished_count = pool
600 .materials
601 .iter()
602 .filter(|m| m.material_type == MaterialType::FinishedGood)
603 .count();
604
605 assert!(raw_count > 0);
606 assert!(finished_count > 0);
607 }
608
609 #[test]
610 fn test_material_with_bom() {
611 let mut gen = MaterialGenerator::new(42);
612 let material =
613 gen.generate_material_with_bom("1000", NaiveDate::from_ymd_opt(2024, 1, 1).unwrap(), 3);
614
615 assert_eq!(material.material_type, MaterialType::FinishedGood);
616 assert!(material.bom_components.is_some());
617 assert_eq!(material.bom_components.as_ref().unwrap().len(), 3);
618 }
619
620 #[test]
621 fn test_material_pool_with_bom() {
622 let mut gen = MaterialGenerator::new(42);
623 let pool = gen.generate_material_pool_with_bom(
624 100,
625 0.5,
626 "1000",
627 NaiveDate::from_ymd_opt(2024, 1, 1).unwrap(),
628 );
629
630 assert_eq!(pool.materials.len(), 100);
631
632 let bom_count = pool
634 .materials
635 .iter()
636 .filter(|m| m.bom_components.is_some())
637 .count();
638
639 assert!(bom_count > 0);
640 }
641
642 #[test]
643 fn test_deterministic_generation() {
644 let mut gen1 = MaterialGenerator::new(42);
645 let mut gen2 = MaterialGenerator::new(42);
646
647 let material1 =
648 gen1.generate_material("1000", NaiveDate::from_ymd_opt(2024, 1, 1).unwrap());
649 let material2 =
650 gen2.generate_material("1000", NaiveDate::from_ymd_opt(2024, 1, 1).unwrap());
651
652 assert_eq!(material1.material_id, material2.material_id);
653 assert_eq!(material1.description, material2.description);
654 assert_eq!(material1.standard_cost, material2.standard_cost);
655 }
656
657 #[test]
658 fn test_material_margin() {
659 let mut gen = MaterialGenerator::new(42);
660
661 for _ in 0..10 {
662 let material =
663 gen.generate_material("1000", NaiveDate::from_ymd_opt(2024, 1, 1).unwrap());
664
665 assert!(
667 material.list_price >= material.standard_cost,
668 "List price {} should be >= standard cost {}",
669 material.list_price,
670 material.standard_cost
671 );
672
673 let margin = material.gross_margin_percent();
675 assert!(
676 margin >= Decimal::from(15) && margin <= Decimal::from(55),
677 "Margin {} should be within expected range",
678 margin
679 );
680 }
681 }
682}