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.gen_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 });
289 }
290
291 let mut material = self.generate_material_of_type(
293 MaterialType::FinishedGood,
294 company_code,
295 effective_date,
296 );
297
298 material.bom_components = Some(components);
299
300 material
301 }
302
303 pub fn generate_material_pool(
305 &mut self,
306 count: usize,
307 company_code: &str,
308 effective_date: NaiveDate,
309 ) -> MaterialPool {
310 debug!(count, company_code, %effective_date, "Generating material pool");
311 let mut pool = MaterialPool::new();
312
313 for _ in 0..count {
314 let material = self.generate_material(company_code, effective_date);
315 pool.add_material(material);
316 }
317
318 pool
319 }
320
321 pub fn generate_material_pool_with_bom(
323 &mut self,
324 count: usize,
325 bom_rate: f64,
326 company_code: &str,
327 effective_date: NaiveDate,
328 ) -> MaterialPool {
329 let mut pool = MaterialPool::new();
330
331 let raw_count = (count as f64 * 0.4) as usize;
333 for _ in 0..raw_count {
334 let material = self.generate_material_of_type(
335 MaterialType::RawMaterial,
336 company_code,
337 effective_date,
338 );
339 pool.add_material(material);
340 }
341
342 let semi_count = (count as f64 * 0.2) as usize;
343 for _ in 0..semi_count {
344 let material = self.generate_material_of_type(
345 MaterialType::SemiFinished,
346 company_code,
347 effective_date,
348 );
349 pool.add_material(material);
350 }
351
352 let finished_count = count - raw_count - semi_count;
354 for _ in 0..finished_count {
355 let material = if self.rng.gen::<f64>() < bom_rate && !self.created_materials.is_empty()
356 {
357 self.generate_material_with_bom_from_existing(company_code, effective_date)
358 } else {
359 self.generate_material_of_type(
360 MaterialType::FinishedGood,
361 company_code,
362 effective_date,
363 )
364 };
365 pool.add_material(material);
366 }
367
368 pool
369 }
370
371 fn generate_material_with_bom_from_existing(
373 &mut self,
374 company_code: &str,
375 effective_date: NaiveDate,
376 ) -> Material {
377 let mut material = self.generate_material_of_type(
378 MaterialType::FinishedGood,
379 company_code,
380 effective_date,
381 );
382
383 let component_count = self.rng.gen_range(2..=5).min(self.created_materials.len());
385 let mut components = Vec::new();
386
387 for i in 0..component_count {
388 if let Some(component_material_id) = self.created_materials.get(i) {
389 components.push(BomComponent {
390 component_material_id: component_material_id.clone(),
391 quantity: Decimal::from(self.rng.gen_range(1..5)),
392 uom: "EA".to_string(),
393 position: (i + 1) as u16 * 10,
394 scrap_percentage: Decimal::ZERO,
395 is_optional: false,
396 });
397 }
398 }
399
400 if !components.is_empty() {
401 material.bom_components = Some(components);
402 }
403
404 material
405 }
406
407 fn select_material_type(&mut self) -> MaterialType {
409 let roll: f64 = self.rng.gen();
410 let mut cumulative = 0.0;
411
412 for (mat_type, prob) in &self.config.material_type_distribution {
413 cumulative += prob;
414 if roll < cumulative {
415 return *mat_type;
416 }
417 }
418
419 MaterialType::FinishedGood
420 }
421
422 fn select_valuation_method(&mut self) -> ValuationMethod {
424 let roll: f64 = self.rng.gen();
425 let mut cumulative = 0.0;
426
427 for (method, prob) in &self.config.valuation_method_distribution {
428 cumulative += prob;
429 if roll < cumulative {
430 return *method;
431 }
432 }
433
434 ValuationMethod::StandardCost
435 }
436
437 fn select_description(&mut self, material_type: &MaterialType) -> &'static str {
439 for (mat_type, descriptions) in MATERIAL_DESCRIPTIONS {
440 if mat_type == material_type {
441 let idx = self.rng.gen_range(0..descriptions.len());
442 return descriptions[idx];
443 }
444 }
445 "Generic Material"
446 }
447
448 fn select_material_group(&mut self, material_type: &MaterialType) -> MaterialGroup {
450 match material_type {
451 MaterialType::FinishedGood => {
452 let options = [
453 MaterialGroup::Electronics,
454 MaterialGroup::Mechanical,
455 MaterialGroup::FinishedGoods,
456 ];
457 options[self.rng.gen_range(0..options.len())]
458 }
459 MaterialType::RawMaterial => {
460 let options = [
461 MaterialGroup::Chemicals,
462 MaterialGroup::Chemical,
463 MaterialGroup::Mechanical,
464 ];
465 options[self.rng.gen_range(0..options.len())]
466 }
467 MaterialType::SemiFinished => {
468 let options = [MaterialGroup::Electronics, MaterialGroup::Mechanical];
469 options[self.rng.gen_range(0..options.len())]
470 }
471 MaterialType::TradingGood => MaterialGroup::FinishedGoods,
472 MaterialType::OperatingSupplies => MaterialGroup::Services,
473 MaterialType::Packaging | MaterialType::SparePart => MaterialGroup::Consumables,
474 _ => MaterialGroup::Consumables,
475 }
476 }
477
478 fn generate_standard_cost(&mut self) -> Decimal {
480 let min = self.config.standard_cost_range.0;
481 let max = self.config.standard_cost_range.1;
482 let range = (max - min).to_string().parse::<f64>().unwrap_or(0.0);
483 let offset =
484 Decimal::from_f64_retain(self.rng.gen::<f64>() * range).unwrap_or(Decimal::ZERO);
485 (min + offset).round_dp(2)
486 }
487
488 fn generate_list_price(&mut self, standard_cost: Decimal) -> Decimal {
490 let (min_margin, max_margin) = self.config.gross_margin_range;
491 let margin = min_margin + self.rng.gen::<f64>() * (max_margin - min_margin);
492 let markup = Decimal::from_f64_retain(1.0 / (1.0 - margin)).unwrap_or(Decimal::from(2));
493 (standard_cost * markup).round_dp(2)
494 }
495
496 fn generate_safety_stock(&mut self) -> Decimal {
498 Decimal::from(self.rng.gen_range(10..500))
499 }
500
501 fn generate_account_determination(
503 &mut self,
504 material_type: &MaterialType,
505 ) -> MaterialAccountDetermination {
506 match material_type {
507 MaterialType::FinishedGood | MaterialType::TradingGood => {
508 MaterialAccountDetermination {
509 inventory_account: "140000".to_string(),
510 cogs_account: "500000".to_string(),
511 revenue_account: "400000".to_string(),
512 purchase_expense_account: "500000".to_string(),
513 price_difference_account: "590000".to_string(),
514 gr_ir_account: "290000".to_string(),
515 }
516 }
517 MaterialType::RawMaterial | MaterialType::SemiFinished => {
518 MaterialAccountDetermination {
519 inventory_account: "141000".to_string(),
520 cogs_account: "510000".to_string(),
521 revenue_account: "400000".to_string(),
522 purchase_expense_account: "510000".to_string(),
523 price_difference_account: "591000".to_string(),
524 gr_ir_account: "290000".to_string(),
525 }
526 }
527 MaterialType::OperatingSupplies => MaterialAccountDetermination {
528 inventory_account: "".to_string(),
529 cogs_account: "520000".to_string(),
530 revenue_account: "410000".to_string(),
531 purchase_expense_account: "520000".to_string(),
532 price_difference_account: "".to_string(),
533 gr_ir_account: "290000".to_string(),
534 },
535 _ => MaterialAccountDetermination {
536 inventory_account: "145000".to_string(),
537 cogs_account: "530000".to_string(),
538 revenue_account: "400000".to_string(),
539 purchase_expense_account: "530000".to_string(),
540 price_difference_account: "595000".to_string(),
541 gr_ir_account: "290000".to_string(),
542 },
543 }
544 }
545
546 pub fn reset(&mut self) {
548 self.rng = seeded_rng(self.seed, 0);
549 self.material_counter = 0;
550 self.created_materials.clear();
551 }
552}
553
554#[cfg(test)]
555#[allow(clippy::unwrap_used)]
556mod tests {
557 use super::*;
558
559 #[test]
560 fn test_material_generation() {
561 let mut gen = MaterialGenerator::new(42);
562 let material = gen.generate_material("1000", NaiveDate::from_ymd_opt(2024, 1, 1).unwrap());
563
564 assert!(!material.material_id.is_empty());
565 assert!(!material.description.is_empty());
566 assert!(material.standard_cost > Decimal::ZERO);
567 assert!(material.list_price >= material.standard_cost);
568 }
569
570 #[test]
571 fn test_material_pool_generation() {
572 let mut gen = MaterialGenerator::new(42);
573 let pool =
574 gen.generate_material_pool(50, "1000", NaiveDate::from_ymd_opt(2024, 1, 1).unwrap());
575
576 assert_eq!(pool.materials.len(), 50);
577
578 let raw_count = pool
580 .materials
581 .iter()
582 .filter(|m| m.material_type == MaterialType::RawMaterial)
583 .count();
584 let finished_count = pool
585 .materials
586 .iter()
587 .filter(|m| m.material_type == MaterialType::FinishedGood)
588 .count();
589
590 assert!(raw_count > 0);
591 assert!(finished_count > 0);
592 }
593
594 #[test]
595 fn test_material_with_bom() {
596 let mut gen = MaterialGenerator::new(42);
597 let material =
598 gen.generate_material_with_bom("1000", NaiveDate::from_ymd_opt(2024, 1, 1).unwrap(), 3);
599
600 assert_eq!(material.material_type, MaterialType::FinishedGood);
601 assert!(material.bom_components.is_some());
602 assert_eq!(material.bom_components.as_ref().unwrap().len(), 3);
603 }
604
605 #[test]
606 fn test_material_pool_with_bom() {
607 let mut gen = MaterialGenerator::new(42);
608 let pool = gen.generate_material_pool_with_bom(
609 100,
610 0.5,
611 "1000",
612 NaiveDate::from_ymd_opt(2024, 1, 1).unwrap(),
613 );
614
615 assert_eq!(pool.materials.len(), 100);
616
617 let bom_count = pool
619 .materials
620 .iter()
621 .filter(|m| m.bom_components.is_some())
622 .count();
623
624 assert!(bom_count > 0);
625 }
626
627 #[test]
628 fn test_deterministic_generation() {
629 let mut gen1 = MaterialGenerator::new(42);
630 let mut gen2 = MaterialGenerator::new(42);
631
632 let material1 =
633 gen1.generate_material("1000", NaiveDate::from_ymd_opt(2024, 1, 1).unwrap());
634 let material2 =
635 gen2.generate_material("1000", NaiveDate::from_ymd_opt(2024, 1, 1).unwrap());
636
637 assert_eq!(material1.material_id, material2.material_id);
638 assert_eq!(material1.description, material2.description);
639 assert_eq!(material1.standard_cost, material2.standard_cost);
640 }
641
642 #[test]
643 fn test_material_margin() {
644 let mut gen = MaterialGenerator::new(42);
645
646 for _ in 0..10 {
647 let material =
648 gen.generate_material("1000", NaiveDate::from_ymd_opt(2024, 1, 1).unwrap());
649
650 assert!(
652 material.list_price >= material.standard_cost,
653 "List price {} should be >= standard cost {}",
654 material.list_price,
655 material.standard_cost
656 );
657
658 let margin = material.gross_margin_percent();
660 assert!(
661 margin >= Decimal::from(15) && margin <= Decimal::from(55),
662 "Margin {} should be within expected range",
663 margin
664 );
665 }
666 }
667}