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