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 template_provider: Option<datasynth_core::templates::SharedTemplateProvider>,
149}
150
151impl MaterialGenerator {
152 pub fn new(seed: u64) -> Self {
154 Self::with_config(seed, MaterialGeneratorConfig::default())
155 }
156
157 pub fn with_config(seed: u64, config: MaterialGeneratorConfig) -> Self {
159 Self {
160 rng: seeded_rng(seed, 0),
161 seed,
162 config,
163 material_counter: 0,
164 created_materials: Vec::new(),
165 country_pack: None,
166 template_provider: None,
167 }
168 }
169
170 pub fn set_country_pack(&mut self, pack: datasynth_core::CountryPack) {
172 self.country_pack = Some(pack);
173 }
174
175 pub fn set_template_provider(
181 &mut self,
182 provider: datasynth_core::templates::SharedTemplateProvider,
183 ) {
184 self.template_provider = Some(provider);
185 }
186
187 pub fn set_counter_offset(&mut self, offset: usize) {
194 self.material_counter = offset;
195 }
196
197 pub fn generate_material(
199 &mut self,
200 _company_code: &str,
201 _effective_date: NaiveDate,
202 ) -> Material {
203 self.material_counter += 1;
204
205 let material_id = format!("MAT-{:06}", self.material_counter);
206 let material_type = self.select_material_type();
207 let description = self.select_description(&material_type);
208
209 let mut material =
210 Material::new(material_id.clone(), description.to_string(), material_type);
211
212 material.material_group = self.select_material_group(&material_type);
214
215 material.valuation_method = self.select_valuation_method();
217
218 let standard_cost = self.generate_standard_cost();
220 material.standard_cost = standard_cost;
221 material.purchase_price = standard_cost;
222 material.list_price = self.generate_list_price(standard_cost);
223
224 material.base_uom = if material_type == MaterialType::OperatingSupplies {
226 UnitOfMeasure::hour()
227 } else {
228 UnitOfMeasure::each()
229 };
230
231 material.account_determination = self.generate_account_determination(&material_type);
233
234 if material_type != MaterialType::OperatingSupplies {
236 material.safety_stock = self.generate_safety_stock();
237 material.reorder_point = material.safety_stock * Decimal::from(2);
238 }
239
240 self.created_materials.push(material_id);
242
243 material
244 }
245
246 pub fn generate_material_of_type(
248 &mut self,
249 material_type: MaterialType,
250 _company_code: &str,
251 _effective_date: NaiveDate,
252 ) -> Material {
253 self.material_counter += 1;
254
255 let material_id = format!("MAT-{:06}", self.material_counter);
256 let description = self.select_description(&material_type);
257
258 let mut material =
259 Material::new(material_id.clone(), description.to_string(), material_type);
260
261 material.material_group = self.select_material_group(&material_type);
262 material.valuation_method = self.select_valuation_method();
263
264 let standard_cost = self.generate_standard_cost();
265 material.standard_cost = standard_cost;
266 material.purchase_price = standard_cost;
267 material.list_price = self.generate_list_price(standard_cost);
268
269 material.base_uom = if material_type == MaterialType::OperatingSupplies {
270 UnitOfMeasure::hour()
271 } else {
272 UnitOfMeasure::each()
273 };
274
275 material.account_determination = self.generate_account_determination(&material_type);
276
277 if material_type != MaterialType::OperatingSupplies {
278 material.safety_stock = self.generate_safety_stock();
279 material.reorder_point = material.safety_stock * Decimal::from(2);
280 }
281
282 self.created_materials.push(material_id);
283
284 material
285 }
286
287 pub fn generate_material_with_bom(
289 &mut self,
290 company_code: &str,
291 effective_date: NaiveDate,
292 component_count: usize,
293 ) -> Material {
294 let mut components = Vec::new();
296 for i in 0..component_count {
297 let component_type = if i % 2 == 0 {
298 MaterialType::RawMaterial
299 } else {
300 MaterialType::SemiFinished
301 };
302 let component =
303 self.generate_material_of_type(component_type, company_code, effective_date);
304
305 let quantity = Decimal::from(self.rng.random_range(1..10));
306 components.push(BomComponent {
307 component_material_id: component.material_id.clone(),
308 quantity,
309 uom: component.base_uom.code.clone(),
310 position: (i + 1) as u16 * 10,
311 scrap_percentage: Decimal::ZERO,
312 is_optional: false,
313 id: None,
314 entity_code: None,
315 parent_material: None,
316 component_description: None,
317 level: None,
318 is_phantom: false,
319 });
320 }
321
322 let mut material = self.generate_material_of_type(
324 MaterialType::FinishedGood,
325 company_code,
326 effective_date,
327 );
328
329 material.bom_components = Some(components);
330
331 material
332 }
333
334 pub fn generate_material_pool(
336 &mut self,
337 count: usize,
338 company_code: &str,
339 effective_date: NaiveDate,
340 ) -> MaterialPool {
341 debug!(count, company_code, %effective_date, "Generating material pool");
342 let mut pool = MaterialPool::new();
343
344 for _ in 0..count {
345 let material = self.generate_material(company_code, effective_date);
346 pool.add_material(material);
347 }
348
349 pool
350 }
351
352 pub fn generate_material_pool_with_bom(
354 &mut self,
355 count: usize,
356 bom_rate: f64,
357 company_code: &str,
358 effective_date: NaiveDate,
359 ) -> MaterialPool {
360 let mut pool = MaterialPool::new();
361
362 let raw_count = (count as f64 * 0.4) as usize;
364 for _ in 0..raw_count {
365 let material = self.generate_material_of_type(
366 MaterialType::RawMaterial,
367 company_code,
368 effective_date,
369 );
370 pool.add_material(material);
371 }
372
373 let semi_count = (count as f64 * 0.2) as usize;
374 for _ in 0..semi_count {
375 let material = self.generate_material_of_type(
376 MaterialType::SemiFinished,
377 company_code,
378 effective_date,
379 );
380 pool.add_material(material);
381 }
382
383 let finished_count = count - raw_count - semi_count;
385 for _ in 0..finished_count {
386 let material =
387 if self.rng.random::<f64>() < bom_rate && !self.created_materials.is_empty() {
388 self.generate_material_with_bom_from_existing(company_code, effective_date)
389 } else {
390 self.generate_material_of_type(
391 MaterialType::FinishedGood,
392 company_code,
393 effective_date,
394 )
395 };
396 pool.add_material(material);
397 }
398
399 pool
400 }
401
402 fn generate_material_with_bom_from_existing(
404 &mut self,
405 company_code: &str,
406 effective_date: NaiveDate,
407 ) -> Material {
408 let mut material = self.generate_material_of_type(
409 MaterialType::FinishedGood,
410 company_code,
411 effective_date,
412 );
413
414 let component_count = self
416 .rng
417 .random_range(2..=5)
418 .min(self.created_materials.len());
419 let mut components = Vec::new();
420
421 for i in 0..component_count {
422 if let Some(component_material_id) = self.created_materials.get(i) {
423 components.push(BomComponent {
424 component_material_id: component_material_id.clone(),
425 quantity: Decimal::from(self.rng.random_range(1..5)),
426 uom: "EA".to_string(),
427 position: (i + 1) as u16 * 10,
428 scrap_percentage: Decimal::ZERO,
429 is_optional: false,
430 id: None,
431 entity_code: None,
432 parent_material: None,
433 component_description: None,
434 level: None,
435 is_phantom: false,
436 });
437 }
438 }
439
440 if !components.is_empty() {
441 material.bom_components = Some(components);
442 }
443
444 material
445 }
446
447 fn select_material_type(&mut self) -> MaterialType {
449 let roll: f64 = self.rng.random();
450 let mut cumulative = 0.0;
451
452 for (mat_type, prob) in &self.config.material_type_distribution {
453 cumulative += prob;
454 if roll < cumulative {
455 return *mat_type;
456 }
457 }
458
459 MaterialType::FinishedGood
460 }
461
462 fn select_valuation_method(&mut self) -> ValuationMethod {
464 let roll: f64 = self.rng.random();
465 let mut cumulative = 0.0;
466
467 for (method, prob) in &self.config.valuation_method_distribution {
468 cumulative += prob;
469 if roll < cumulative {
470 return *method;
471 }
472 }
473
474 ValuationMethod::StandardCost
475 }
476
477 fn select_description(&mut self, material_type: &MaterialType) -> String {
486 let type_key = Self::material_type_to_key(material_type);
487
488 if let Some(ref provider) = self.template_provider {
494 let candidate = provider.get_material_description(type_key, &mut self.rng);
495 let generic_fallback = format!("{type_key} material");
496 if candidate != generic_fallback {
497 return candidate;
498 }
499 }
500
501 for (mat_type, descriptions) in MATERIAL_DESCRIPTIONS {
502 if mat_type == material_type {
503 let idx = self.rng.random_range(0..descriptions.len());
504 return descriptions[idx].to_string();
505 }
506 }
507 "Generic Material".to_string()
508 }
509
510 fn material_type_to_key(material_type: &MaterialType) -> &'static str {
514 match material_type {
515 MaterialType::FinishedGood => "finished_good",
516 MaterialType::RawMaterial => "raw_material",
517 MaterialType::SemiFinished => "semi_finished",
518 MaterialType::TradingGood => "trading_good",
519 MaterialType::OperatingSupplies => "operating_supplies",
520 MaterialType::Packaging => "packaging",
521 MaterialType::Service => "service",
522 MaterialType::SparePart => "spare_part",
523 }
524 }
525
526 fn select_material_group(&mut self, material_type: &MaterialType) -> MaterialGroup {
528 match material_type {
529 MaterialType::FinishedGood => {
530 let options = [
531 MaterialGroup::Electronics,
532 MaterialGroup::Mechanical,
533 MaterialGroup::FinishedGoods,
534 ];
535 options[self.rng.random_range(0..options.len())]
536 }
537 MaterialType::RawMaterial => {
538 let options = [
539 MaterialGroup::Chemicals,
540 MaterialGroup::Chemical,
541 MaterialGroup::Mechanical,
542 ];
543 options[self.rng.random_range(0..options.len())]
544 }
545 MaterialType::SemiFinished => {
546 let options = [MaterialGroup::Electronics, MaterialGroup::Mechanical];
547 options[self.rng.random_range(0..options.len())]
548 }
549 MaterialType::TradingGood => MaterialGroup::FinishedGoods,
550 MaterialType::OperatingSupplies => MaterialGroup::Services,
551 MaterialType::Packaging | MaterialType::SparePart => MaterialGroup::Consumables,
552 _ => MaterialGroup::Consumables,
553 }
554 }
555
556 fn generate_standard_cost(&mut self) -> Decimal {
558 let min = self.config.standard_cost_range.0;
559 let max = self.config.standard_cost_range.1;
560 let range = (max - min).to_string().parse::<f64>().unwrap_or(0.0);
561 let offset =
562 Decimal::from_f64_retain(self.rng.random::<f64>() * range).unwrap_or(Decimal::ZERO);
563 (min + offset).round_dp(2)
564 }
565
566 fn generate_list_price(&mut self, standard_cost: Decimal) -> Decimal {
568 let (min_margin, max_margin) = self.config.gross_margin_range;
569 let margin = min_margin + self.rng.random::<f64>() * (max_margin - min_margin);
570 let markup = Decimal::from_f64_retain(1.0 / (1.0 - margin)).unwrap_or(Decimal::from(2));
571 (standard_cost * markup).round_dp(2)
572 }
573
574 fn generate_safety_stock(&mut self) -> Decimal {
576 Decimal::from(self.rng.random_range(10..500))
577 }
578
579 fn generate_account_determination(
581 &mut self,
582 material_type: &MaterialType,
583 ) -> MaterialAccountDetermination {
584 match material_type {
585 MaterialType::FinishedGood | MaterialType::TradingGood => {
586 MaterialAccountDetermination {
587 inventory_account: "140000".to_string(),
588 cogs_account: "500000".to_string(),
589 revenue_account: "400000".to_string(),
590 purchase_expense_account: "500000".to_string(),
591 price_difference_account: "590000".to_string(),
592 gr_ir_account: "290000".to_string(),
593 }
594 }
595 MaterialType::RawMaterial | MaterialType::SemiFinished => {
596 MaterialAccountDetermination {
597 inventory_account: "141000".to_string(),
598 cogs_account: "510000".to_string(),
599 revenue_account: "400000".to_string(),
600 purchase_expense_account: "510000".to_string(),
601 price_difference_account: "591000".to_string(),
602 gr_ir_account: "290000".to_string(),
603 }
604 }
605 MaterialType::OperatingSupplies => MaterialAccountDetermination {
606 inventory_account: "".to_string(),
607 cogs_account: "520000".to_string(),
608 revenue_account: "410000".to_string(),
609 purchase_expense_account: "520000".to_string(),
610 price_difference_account: "".to_string(),
611 gr_ir_account: "290000".to_string(),
612 },
613 _ => MaterialAccountDetermination {
614 inventory_account: "145000".to_string(),
615 cogs_account: "530000".to_string(),
616 revenue_account: "400000".to_string(),
617 purchase_expense_account: "530000".to_string(),
618 price_difference_account: "595000".to_string(),
619 gr_ir_account: "290000".to_string(),
620 },
621 }
622 }
623
624 pub fn reset(&mut self) {
626 self.rng = seeded_rng(self.seed, 0);
627 self.material_counter = 0;
628 self.created_materials.clear();
629 }
630}
631
632#[cfg(test)]
633#[allow(clippy::unwrap_used)]
634mod tests {
635 use super::*;
636
637 #[test]
638 fn test_material_generation() {
639 let mut gen = MaterialGenerator::new(42);
640 let material = gen.generate_material("1000", NaiveDate::from_ymd_opt(2024, 1, 1).unwrap());
641
642 assert!(!material.material_id.is_empty());
643 assert!(!material.description.is_empty());
644 assert!(material.standard_cost > Decimal::ZERO);
645 assert!(material.list_price >= material.standard_cost);
646 }
647
648 #[test]
649 fn test_material_pool_generation() {
650 let mut gen = MaterialGenerator::new(42);
651 let pool =
652 gen.generate_material_pool(50, "1000", NaiveDate::from_ymd_opt(2024, 1, 1).unwrap());
653
654 assert_eq!(pool.materials.len(), 50);
655
656 let raw_count = pool
658 .materials
659 .iter()
660 .filter(|m| m.material_type == MaterialType::RawMaterial)
661 .count();
662 let finished_count = pool
663 .materials
664 .iter()
665 .filter(|m| m.material_type == MaterialType::FinishedGood)
666 .count();
667
668 assert!(raw_count > 0);
669 assert!(finished_count > 0);
670 }
671
672 #[test]
673 fn test_material_with_bom() {
674 let mut gen = MaterialGenerator::new(42);
675 let material =
676 gen.generate_material_with_bom("1000", NaiveDate::from_ymd_opt(2024, 1, 1).unwrap(), 3);
677
678 assert_eq!(material.material_type, MaterialType::FinishedGood);
679 assert!(material.bom_components.is_some());
680 assert_eq!(material.bom_components.as_ref().unwrap().len(), 3);
681 }
682
683 #[test]
684 fn test_material_pool_with_bom() {
685 let mut gen = MaterialGenerator::new(42);
686 let pool = gen.generate_material_pool_with_bom(
687 100,
688 0.5,
689 "1000",
690 NaiveDate::from_ymd_opt(2024, 1, 1).unwrap(),
691 );
692
693 assert_eq!(pool.materials.len(), 100);
694
695 let bom_count = pool
697 .materials
698 .iter()
699 .filter(|m| m.bom_components.is_some())
700 .count();
701
702 assert!(bom_count > 0);
703 }
704
705 #[test]
706 fn test_deterministic_generation() {
707 let mut gen1 = MaterialGenerator::new(42);
708 let mut gen2 = MaterialGenerator::new(42);
709
710 let material1 =
711 gen1.generate_material("1000", NaiveDate::from_ymd_opt(2024, 1, 1).unwrap());
712 let material2 =
713 gen2.generate_material("1000", NaiveDate::from_ymd_opt(2024, 1, 1).unwrap());
714
715 assert_eq!(material1.material_id, material2.material_id);
716 assert_eq!(material1.description, material2.description);
717 assert_eq!(material1.standard_cost, material2.standard_cost);
718 }
719
720 #[test]
721 fn test_material_margin() {
722 let mut gen = MaterialGenerator::new(42);
723
724 for _ in 0..10 {
725 let material =
726 gen.generate_material("1000", NaiveDate::from_ymd_opt(2024, 1, 1).unwrap());
727
728 assert!(
730 material.list_price >= material.standard_cost,
731 "List price {} should be >= standard cost {}",
732 material.list_price,
733 material.standard_cost
734 );
735
736 let margin = material.gross_margin_percent();
738 assert!(
739 margin >= Decimal::from(15) && margin <= Decimal::from(55),
740 "Margin {} should be within expected range",
741 margin
742 );
743 }
744 }
745}