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)]
557mod tests {
558 use super::*;
559
560 #[test]
561 fn test_material_generation() {
562 let mut gen = MaterialGenerator::new(42);
563 let material = gen.generate_material("1000", NaiveDate::from_ymd_opt(2024, 1, 1).unwrap());
564
565 assert!(!material.material_id.is_empty());
566 assert!(!material.description.is_empty());
567 assert!(material.standard_cost > Decimal::ZERO);
568 assert!(material.list_price >= material.standard_cost);
569 }
570
571 #[test]
572 fn test_material_pool_generation() {
573 let mut gen = MaterialGenerator::new(42);
574 let pool =
575 gen.generate_material_pool(50, "1000", NaiveDate::from_ymd_opt(2024, 1, 1).unwrap());
576
577 assert_eq!(pool.materials.len(), 50);
578
579 let raw_count = pool
581 .materials
582 .iter()
583 .filter(|m| m.material_type == MaterialType::RawMaterial)
584 .count();
585 let finished_count = pool
586 .materials
587 .iter()
588 .filter(|m| m.material_type == MaterialType::FinishedGood)
589 .count();
590
591 assert!(raw_count > 0);
592 assert!(finished_count > 0);
593 }
594
595 #[test]
596 fn test_material_with_bom() {
597 let mut gen = MaterialGenerator::new(42);
598 let material =
599 gen.generate_material_with_bom("1000", NaiveDate::from_ymd_opt(2024, 1, 1).unwrap(), 3);
600
601 assert_eq!(material.material_type, MaterialType::FinishedGood);
602 assert!(material.bom_components.is_some());
603 assert_eq!(material.bom_components.as_ref().unwrap().len(), 3);
604 }
605
606 #[test]
607 fn test_material_pool_with_bom() {
608 let mut gen = MaterialGenerator::new(42);
609 let pool = gen.generate_material_pool_with_bom(
610 100,
611 0.5,
612 "1000",
613 NaiveDate::from_ymd_opt(2024, 1, 1).unwrap(),
614 );
615
616 assert_eq!(pool.materials.len(), 100);
617
618 let bom_count = pool
620 .materials
621 .iter()
622 .filter(|m| m.bom_components.is_some())
623 .count();
624
625 assert!(bom_count > 0);
626 }
627
628 #[test]
629 fn test_deterministic_generation() {
630 let mut gen1 = MaterialGenerator::new(42);
631 let mut gen2 = MaterialGenerator::new(42);
632
633 let material1 =
634 gen1.generate_material("1000", NaiveDate::from_ymd_opt(2024, 1, 1).unwrap());
635 let material2 =
636 gen2.generate_material("1000", NaiveDate::from_ymd_opt(2024, 1, 1).unwrap());
637
638 assert_eq!(material1.material_id, material2.material_id);
639 assert_eq!(material1.description, material2.description);
640 assert_eq!(material1.standard_cost, material2.standard_cost);
641 }
642
643 #[test]
644 fn test_material_margin() {
645 let mut gen = MaterialGenerator::new(42);
646
647 for _ in 0..10 {
648 let material =
649 gen.generate_material("1000", NaiveDate::from_ymd_opt(2024, 1, 1).unwrap());
650
651 assert!(
653 material.list_price >= material.standard_cost,
654 "List price {} should be >= standard cost {}",
655 material.list_price,
656 material.standard_cost
657 );
658
659 let margin = material.gross_margin_percent();
661 assert!(
662 margin >= Decimal::from(15) && margin <= Decimal::from(55),
663 "Margin {} should be within expected range",
664 margin
665 );
666 }
667 }
668}