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 set_counter_offset(&mut self, offset: usize) {
179 self.material_counter = offset;
180 }
181
182 pub fn generate_material(
184 &mut self,
185 _company_code: &str,
186 _effective_date: NaiveDate,
187 ) -> Material {
188 self.material_counter += 1;
189
190 let material_id = format!("MAT-{:06}", self.material_counter);
191 let material_type = self.select_material_type();
192 let description = self.select_description(&material_type);
193
194 let mut material =
195 Material::new(material_id.clone(), description.to_string(), material_type);
196
197 material.material_group = self.select_material_group(&material_type);
199
200 material.valuation_method = self.select_valuation_method();
202
203 let standard_cost = self.generate_standard_cost();
205 material.standard_cost = standard_cost;
206 material.purchase_price = standard_cost;
207 material.list_price = self.generate_list_price(standard_cost);
208
209 material.base_uom = if material_type == MaterialType::OperatingSupplies {
211 UnitOfMeasure::hour()
212 } else {
213 UnitOfMeasure::each()
214 };
215
216 material.account_determination = self.generate_account_determination(&material_type);
218
219 if material_type != MaterialType::OperatingSupplies {
221 material.safety_stock = self.generate_safety_stock();
222 material.reorder_point = material.safety_stock * Decimal::from(2);
223 }
224
225 self.created_materials.push(material_id);
227
228 material
229 }
230
231 pub fn generate_material_of_type(
233 &mut self,
234 material_type: MaterialType,
235 _company_code: &str,
236 _effective_date: NaiveDate,
237 ) -> Material {
238 self.material_counter += 1;
239
240 let material_id = format!("MAT-{:06}", self.material_counter);
241 let description = self.select_description(&material_type);
242
243 let mut material =
244 Material::new(material_id.clone(), description.to_string(), material_type);
245
246 material.material_group = self.select_material_group(&material_type);
247 material.valuation_method = self.select_valuation_method();
248
249 let standard_cost = self.generate_standard_cost();
250 material.standard_cost = standard_cost;
251 material.purchase_price = standard_cost;
252 material.list_price = self.generate_list_price(standard_cost);
253
254 material.base_uom = if material_type == MaterialType::OperatingSupplies {
255 UnitOfMeasure::hour()
256 } else {
257 UnitOfMeasure::each()
258 };
259
260 material.account_determination = self.generate_account_determination(&material_type);
261
262 if material_type != MaterialType::OperatingSupplies {
263 material.safety_stock = self.generate_safety_stock();
264 material.reorder_point = material.safety_stock * Decimal::from(2);
265 }
266
267 self.created_materials.push(material_id);
268
269 material
270 }
271
272 pub fn generate_material_with_bom(
274 &mut self,
275 company_code: &str,
276 effective_date: NaiveDate,
277 component_count: usize,
278 ) -> Material {
279 let mut components = Vec::new();
281 for i in 0..component_count {
282 let component_type = if i % 2 == 0 {
283 MaterialType::RawMaterial
284 } else {
285 MaterialType::SemiFinished
286 };
287 let component =
288 self.generate_material_of_type(component_type, company_code, effective_date);
289
290 let quantity = Decimal::from(self.rng.random_range(1..10));
291 components.push(BomComponent {
292 component_material_id: component.material_id.clone(),
293 quantity,
294 uom: component.base_uom.code.clone(),
295 position: (i + 1) as u16 * 10,
296 scrap_percentage: Decimal::ZERO,
297 is_optional: false,
298 id: None,
299 entity_code: None,
300 parent_material: None,
301 component_description: None,
302 level: None,
303 is_phantom: false,
304 });
305 }
306
307 let mut material = self.generate_material_of_type(
309 MaterialType::FinishedGood,
310 company_code,
311 effective_date,
312 );
313
314 material.bom_components = Some(components);
315
316 material
317 }
318
319 pub fn generate_material_pool(
321 &mut self,
322 count: usize,
323 company_code: &str,
324 effective_date: NaiveDate,
325 ) -> MaterialPool {
326 debug!(count, company_code, %effective_date, "Generating material pool");
327 let mut pool = MaterialPool::new();
328
329 for _ in 0..count {
330 let material = self.generate_material(company_code, effective_date);
331 pool.add_material(material);
332 }
333
334 pool
335 }
336
337 pub fn generate_material_pool_with_bom(
339 &mut self,
340 count: usize,
341 bom_rate: f64,
342 company_code: &str,
343 effective_date: NaiveDate,
344 ) -> MaterialPool {
345 let mut pool = MaterialPool::new();
346
347 let raw_count = (count as f64 * 0.4) as usize;
349 for _ in 0..raw_count {
350 let material = self.generate_material_of_type(
351 MaterialType::RawMaterial,
352 company_code,
353 effective_date,
354 );
355 pool.add_material(material);
356 }
357
358 let semi_count = (count as f64 * 0.2) as usize;
359 for _ in 0..semi_count {
360 let material = self.generate_material_of_type(
361 MaterialType::SemiFinished,
362 company_code,
363 effective_date,
364 );
365 pool.add_material(material);
366 }
367
368 let finished_count = count - raw_count - semi_count;
370 for _ in 0..finished_count {
371 let material =
372 if self.rng.random::<f64>() < bom_rate && !self.created_materials.is_empty() {
373 self.generate_material_with_bom_from_existing(company_code, effective_date)
374 } else {
375 self.generate_material_of_type(
376 MaterialType::FinishedGood,
377 company_code,
378 effective_date,
379 )
380 };
381 pool.add_material(material);
382 }
383
384 pool
385 }
386
387 fn generate_material_with_bom_from_existing(
389 &mut self,
390 company_code: &str,
391 effective_date: NaiveDate,
392 ) -> Material {
393 let mut material = self.generate_material_of_type(
394 MaterialType::FinishedGood,
395 company_code,
396 effective_date,
397 );
398
399 let component_count = self
401 .rng
402 .random_range(2..=5)
403 .min(self.created_materials.len());
404 let mut components = Vec::new();
405
406 for i in 0..component_count {
407 if let Some(component_material_id) = self.created_materials.get(i) {
408 components.push(BomComponent {
409 component_material_id: component_material_id.clone(),
410 quantity: Decimal::from(self.rng.random_range(1..5)),
411 uom: "EA".to_string(),
412 position: (i + 1) as u16 * 10,
413 scrap_percentage: Decimal::ZERO,
414 is_optional: false,
415 id: None,
416 entity_code: None,
417 parent_material: None,
418 component_description: None,
419 level: None,
420 is_phantom: false,
421 });
422 }
423 }
424
425 if !components.is_empty() {
426 material.bom_components = Some(components);
427 }
428
429 material
430 }
431
432 fn select_material_type(&mut self) -> MaterialType {
434 let roll: f64 = self.rng.random();
435 let mut cumulative = 0.0;
436
437 for (mat_type, prob) in &self.config.material_type_distribution {
438 cumulative += prob;
439 if roll < cumulative {
440 return *mat_type;
441 }
442 }
443
444 MaterialType::FinishedGood
445 }
446
447 fn select_valuation_method(&mut self) -> ValuationMethod {
449 let roll: f64 = self.rng.random();
450 let mut cumulative = 0.0;
451
452 for (method, prob) in &self.config.valuation_method_distribution {
453 cumulative += prob;
454 if roll < cumulative {
455 return *method;
456 }
457 }
458
459 ValuationMethod::StandardCost
460 }
461
462 fn select_description(&mut self, material_type: &MaterialType) -> &'static str {
464 for (mat_type, descriptions) in MATERIAL_DESCRIPTIONS {
465 if mat_type == material_type {
466 let idx = self.rng.random_range(0..descriptions.len());
467 return descriptions[idx];
468 }
469 }
470 "Generic Material"
471 }
472
473 fn select_material_group(&mut self, material_type: &MaterialType) -> MaterialGroup {
475 match material_type {
476 MaterialType::FinishedGood => {
477 let options = [
478 MaterialGroup::Electronics,
479 MaterialGroup::Mechanical,
480 MaterialGroup::FinishedGoods,
481 ];
482 options[self.rng.random_range(0..options.len())]
483 }
484 MaterialType::RawMaterial => {
485 let options = [
486 MaterialGroup::Chemicals,
487 MaterialGroup::Chemical,
488 MaterialGroup::Mechanical,
489 ];
490 options[self.rng.random_range(0..options.len())]
491 }
492 MaterialType::SemiFinished => {
493 let options = [MaterialGroup::Electronics, MaterialGroup::Mechanical];
494 options[self.rng.random_range(0..options.len())]
495 }
496 MaterialType::TradingGood => MaterialGroup::FinishedGoods,
497 MaterialType::OperatingSupplies => MaterialGroup::Services,
498 MaterialType::Packaging | MaterialType::SparePart => MaterialGroup::Consumables,
499 _ => MaterialGroup::Consumables,
500 }
501 }
502
503 fn generate_standard_cost(&mut self) -> Decimal {
505 let min = self.config.standard_cost_range.0;
506 let max = self.config.standard_cost_range.1;
507 let range = (max - min).to_string().parse::<f64>().unwrap_or(0.0);
508 let offset =
509 Decimal::from_f64_retain(self.rng.random::<f64>() * range).unwrap_or(Decimal::ZERO);
510 (min + offset).round_dp(2)
511 }
512
513 fn generate_list_price(&mut self, standard_cost: Decimal) -> Decimal {
515 let (min_margin, max_margin) = self.config.gross_margin_range;
516 let margin = min_margin + self.rng.random::<f64>() * (max_margin - min_margin);
517 let markup = Decimal::from_f64_retain(1.0 / (1.0 - margin)).unwrap_or(Decimal::from(2));
518 (standard_cost * markup).round_dp(2)
519 }
520
521 fn generate_safety_stock(&mut self) -> Decimal {
523 Decimal::from(self.rng.random_range(10..500))
524 }
525
526 fn generate_account_determination(
528 &mut self,
529 material_type: &MaterialType,
530 ) -> MaterialAccountDetermination {
531 match material_type {
532 MaterialType::FinishedGood | MaterialType::TradingGood => {
533 MaterialAccountDetermination {
534 inventory_account: "140000".to_string(),
535 cogs_account: "500000".to_string(),
536 revenue_account: "400000".to_string(),
537 purchase_expense_account: "500000".to_string(),
538 price_difference_account: "590000".to_string(),
539 gr_ir_account: "290000".to_string(),
540 }
541 }
542 MaterialType::RawMaterial | MaterialType::SemiFinished => {
543 MaterialAccountDetermination {
544 inventory_account: "141000".to_string(),
545 cogs_account: "510000".to_string(),
546 revenue_account: "400000".to_string(),
547 purchase_expense_account: "510000".to_string(),
548 price_difference_account: "591000".to_string(),
549 gr_ir_account: "290000".to_string(),
550 }
551 }
552 MaterialType::OperatingSupplies => MaterialAccountDetermination {
553 inventory_account: "".to_string(),
554 cogs_account: "520000".to_string(),
555 revenue_account: "410000".to_string(),
556 purchase_expense_account: "520000".to_string(),
557 price_difference_account: "".to_string(),
558 gr_ir_account: "290000".to_string(),
559 },
560 _ => MaterialAccountDetermination {
561 inventory_account: "145000".to_string(),
562 cogs_account: "530000".to_string(),
563 revenue_account: "400000".to_string(),
564 purchase_expense_account: "530000".to_string(),
565 price_difference_account: "595000".to_string(),
566 gr_ir_account: "290000".to_string(),
567 },
568 }
569 }
570
571 pub fn reset(&mut self) {
573 self.rng = seeded_rng(self.seed, 0);
574 self.material_counter = 0;
575 self.created_materials.clear();
576 }
577}
578
579#[cfg(test)]
580#[allow(clippy::unwrap_used)]
581mod tests {
582 use super::*;
583
584 #[test]
585 fn test_material_generation() {
586 let mut gen = MaterialGenerator::new(42);
587 let material = gen.generate_material("1000", NaiveDate::from_ymd_opt(2024, 1, 1).unwrap());
588
589 assert!(!material.material_id.is_empty());
590 assert!(!material.description.is_empty());
591 assert!(material.standard_cost > Decimal::ZERO);
592 assert!(material.list_price >= material.standard_cost);
593 }
594
595 #[test]
596 fn test_material_pool_generation() {
597 let mut gen = MaterialGenerator::new(42);
598 let pool =
599 gen.generate_material_pool(50, "1000", NaiveDate::from_ymd_opt(2024, 1, 1).unwrap());
600
601 assert_eq!(pool.materials.len(), 50);
602
603 let raw_count = pool
605 .materials
606 .iter()
607 .filter(|m| m.material_type == MaterialType::RawMaterial)
608 .count();
609 let finished_count = pool
610 .materials
611 .iter()
612 .filter(|m| m.material_type == MaterialType::FinishedGood)
613 .count();
614
615 assert!(raw_count > 0);
616 assert!(finished_count > 0);
617 }
618
619 #[test]
620 fn test_material_with_bom() {
621 let mut gen = MaterialGenerator::new(42);
622 let material =
623 gen.generate_material_with_bom("1000", NaiveDate::from_ymd_opt(2024, 1, 1).unwrap(), 3);
624
625 assert_eq!(material.material_type, MaterialType::FinishedGood);
626 assert!(material.bom_components.is_some());
627 assert_eq!(material.bom_components.as_ref().unwrap().len(), 3);
628 }
629
630 #[test]
631 fn test_material_pool_with_bom() {
632 let mut gen = MaterialGenerator::new(42);
633 let pool = gen.generate_material_pool_with_bom(
634 100,
635 0.5,
636 "1000",
637 NaiveDate::from_ymd_opt(2024, 1, 1).unwrap(),
638 );
639
640 assert_eq!(pool.materials.len(), 100);
641
642 let bom_count = pool
644 .materials
645 .iter()
646 .filter(|m| m.bom_components.is_some())
647 .count();
648
649 assert!(bom_count > 0);
650 }
651
652 #[test]
653 fn test_deterministic_generation() {
654 let mut gen1 = MaterialGenerator::new(42);
655 let mut gen2 = MaterialGenerator::new(42);
656
657 let material1 =
658 gen1.generate_material("1000", NaiveDate::from_ymd_opt(2024, 1, 1).unwrap());
659 let material2 =
660 gen2.generate_material("1000", NaiveDate::from_ymd_opt(2024, 1, 1).unwrap());
661
662 assert_eq!(material1.material_id, material2.material_id);
663 assert_eq!(material1.description, material2.description);
664 assert_eq!(material1.standard_cost, material2.standard_cost);
665 }
666
667 #[test]
668 fn test_material_margin() {
669 let mut gen = MaterialGenerator::new(42);
670
671 for _ in 0..10 {
672 let material =
673 gen.generate_material("1000", NaiveDate::from_ymd_opt(2024, 1, 1).unwrap());
674
675 assert!(
677 material.list_price >= material.standard_cost,
678 "List price {} should be >= standard cost {}",
679 material.list_price,
680 material.standard_cost
681 );
682
683 let margin = material.gross_margin_percent();
685 assert!(
686 margin >= Decimal::from(15) && margin <= Decimal::from(55),
687 "Margin {} should be within expected range",
688 margin
689 );
690 }
691 }
692}