1use chrono::NaiveDate;
4use datasynth_core::models::{
5 Employee, EmployeePool, EmployeeStatus, JobLevel, SystemRole, TransactionCodeAuth,
6};
7use datasynth_core::templates::{MultiCultureNameGenerator, NameCulture};
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 EmployeeGeneratorConfig {
17 pub job_level_distribution: Vec<(JobLevel, f64)>,
19 pub approval_limits: Vec<(JobLevel, Decimal)>,
21 pub culture_distribution: Vec<(NameCulture, f64)>,
23 pub email_domain: String,
25 pub leave_rate: f64,
27 pub termination_rate: f64,
29 pub span_of_control: (usize, usize),
31}
32
33impl Default for EmployeeGeneratorConfig {
34 fn default() -> Self {
35 Self {
36 job_level_distribution: vec![
37 (JobLevel::Staff, 0.50),
38 (JobLevel::Senior, 0.25),
39 (JobLevel::Manager, 0.12),
40 (JobLevel::Director, 0.08),
41 (JobLevel::VicePresident, 0.04),
42 (JobLevel::Executive, 0.01),
43 ],
44 approval_limits: vec![
45 (JobLevel::Staff, Decimal::from(1_000)),
46 (JobLevel::Senior, Decimal::from(5_000)),
47 (JobLevel::Manager, Decimal::from(25_000)),
48 (JobLevel::Director, Decimal::from(100_000)),
49 (JobLevel::VicePresident, Decimal::from(500_000)),
50 (JobLevel::Executive, Decimal::from(10_000_000)),
51 ],
52 culture_distribution: vec![
53 (NameCulture::WesternUs, 0.40),
54 (NameCulture::Hispanic, 0.20),
55 (NameCulture::German, 0.10),
56 (NameCulture::French, 0.05),
57 (NameCulture::Chinese, 0.10),
58 (NameCulture::Japanese, 0.05),
59 (NameCulture::Indian, 0.10),
60 ],
61 email_domain: "company.com".to_string(),
62 leave_rate: 0.02,
63 termination_rate: 0.01,
64 span_of_control: (3, 8),
65 }
66 }
67}
68
69#[derive(Debug, Clone)]
71pub struct DepartmentDefinition {
72 pub code: String,
74 pub name: String,
76 pub cost_center: String,
78 pub headcount: usize,
80 pub system_roles: Vec<SystemRole>,
82 pub transaction_codes: Vec<String>,
84}
85
86impl DepartmentDefinition {
87 pub fn finance(company_code: &str) -> Self {
89 Self {
90 code: format!("{company_code}-FIN"),
91 name: "Finance".to_string(),
92 cost_center: format!("CC-{company_code}-FIN"),
93 headcount: 15,
94 system_roles: vec![
95 SystemRole::ApAccountant,
96 SystemRole::ArAccountant,
97 SystemRole::GeneralAccountant,
98 SystemRole::FinancialAnalyst,
99 ],
100 transaction_codes: vec![
101 "FB01".to_string(),
102 "FB02".to_string(),
103 "FB03".to_string(),
104 "F-28".to_string(),
105 "F-53".to_string(),
106 "FBL1N".to_string(),
107 ],
108 }
109 }
110
111 pub fn procurement(company_code: &str) -> Self {
113 Self {
114 code: format!("{company_code}-PROC"),
115 name: "Procurement".to_string(),
116 cost_center: format!("CC-{company_code}-PROC"),
117 headcount: 10,
118 system_roles: vec![SystemRole::Buyer, SystemRole::Approver],
119 transaction_codes: vec![
120 "ME21N".to_string(),
121 "ME22N".to_string(),
122 "ME23N".to_string(),
123 "MIGO".to_string(),
124 "ME2M".to_string(),
125 ],
126 }
127 }
128
129 pub fn sales(company_code: &str) -> Self {
131 Self {
132 code: format!("{company_code}-SALES"),
133 name: "Sales".to_string(),
134 cost_center: format!("CC-{company_code}-SALES"),
135 headcount: 20,
136 system_roles: vec![SystemRole::Creator, SystemRole::Approver],
137 transaction_codes: vec![
138 "VA01".to_string(),
139 "VA02".to_string(),
140 "VA03".to_string(),
141 "VL01N".to_string(),
142 "VF01".to_string(),
143 ],
144 }
145 }
146
147 pub fn warehouse(company_code: &str) -> Self {
149 Self {
150 code: format!("{company_code}-WH"),
151 name: "Warehouse".to_string(),
152 cost_center: format!("CC-{company_code}-WH"),
153 headcount: 12,
154 system_roles: vec![SystemRole::Creator, SystemRole::Viewer],
155 transaction_codes: vec![
156 "MIGO".to_string(),
157 "MB51".to_string(),
158 "MMBE".to_string(),
159 "LT01".to_string(),
160 ],
161 }
162 }
163
164 pub fn it(company_code: &str) -> Self {
166 Self {
167 code: format!("{company_code}-IT"),
168 name: "Information Technology".to_string(),
169 cost_center: format!("CC-{company_code}-IT"),
170 headcount: 8,
171 system_roles: vec![SystemRole::Admin],
172 transaction_codes: vec!["SU01".to_string(), "PFCG".to_string(), "SM21".to_string()],
173 }
174 }
175
176 pub fn standard_departments(company_code: &str) -> Vec<Self> {
178 vec![
179 Self::finance(company_code),
180 Self::procurement(company_code),
181 Self::sales(company_code),
182 Self::warehouse(company_code),
183 Self::it(company_code),
184 ]
185 }
186}
187
188pub struct EmployeeGenerator {
190 rng: ChaCha8Rng,
191 seed: u64,
192 config: EmployeeGeneratorConfig,
193 name_generator: MultiCultureNameGenerator,
194 employee_counter: usize,
195 country_pack: Option<datasynth_core::CountryPack>,
197}
198
199impl EmployeeGenerator {
200 pub fn new(seed: u64) -> Self {
202 Self::with_config(seed, EmployeeGeneratorConfig::default())
203 }
204
205 pub fn with_config(seed: u64, config: EmployeeGeneratorConfig) -> Self {
207 let mut name_gen =
208 MultiCultureNameGenerator::with_distribution(config.culture_distribution.clone());
209 name_gen.set_email_domain(&config.email_domain);
210
211 Self {
212 rng: seeded_rng(seed, 0),
213 seed,
214 name_generator: name_gen,
215 config,
216 employee_counter: 0,
217 country_pack: None,
218 }
219 }
220
221 pub fn set_country_pack(&mut self, pack: datasynth_core::CountryPack) {
228 self.country_pack = Some(pack);
229 }
230
231 pub fn generate_employee(
233 &mut self,
234 company_code: &str,
235 department: &DepartmentDefinition,
236 hire_date: NaiveDate,
237 ) -> Employee {
238 self.employee_counter += 1;
239
240 let name = self.name_generator.generate_name(&mut self.rng);
241 let employee_id = format!("EMP-{}-{:06}", company_code, self.employee_counter);
242 let user_id = format!("u{:06}", self.employee_counter);
243 let email = self.name_generator.generate_email(&name);
244
245 let job_level = self.select_job_level();
246 let approval_limit = self.get_approval_limit(&job_level);
247
248 let mut employee = Employee::new(
249 employee_id,
250 user_id,
251 name.first_name.clone(),
252 name.last_name.clone(),
253 company_code.to_string(),
254 );
255
256 employee.email = email;
258 employee.job_level = job_level;
259
260 employee.department_id = Some(department.name.clone());
262 employee.cost_center = Some(department.cost_center.clone());
263
264 employee.hire_date = Some(hire_date);
266
267 employee.approval_limit = approval_limit;
269 employee.can_approve_pr = matches!(
270 job_level,
271 JobLevel::Manager | JobLevel::Director | JobLevel::VicePresident | JobLevel::Executive
272 );
273 employee.can_approve_po = matches!(
274 job_level,
275 JobLevel::Senior
276 | JobLevel::Manager
277 | JobLevel::Director
278 | JobLevel::VicePresident
279 | JobLevel::Executive
280 );
281 employee.can_approve_je = matches!(
282 job_level,
283 JobLevel::Manager | JobLevel::Director | JobLevel::VicePresident | JobLevel::Executive
284 );
285
286 if !department.system_roles.is_empty() {
288 let role_idx = self.rng.random_range(0..department.system_roles.len());
289 employee
290 .system_roles
291 .push(department.system_roles[role_idx].clone());
292 }
293
294 for tcode in &department.transaction_codes {
296 employee.transaction_codes.push(TransactionCodeAuth {
297 tcode: tcode.clone(),
298 activity: datasynth_core::models::ActivityType::Create,
299 active: true,
300 });
301 }
302
303 employee.status = self.select_status();
305 if employee.status == EmployeeStatus::Terminated {
306 employee.termination_date =
307 Some(hire_date + chrono::Duration::days(self.rng.random_range(365..1825) as i64));
308 }
309
310 employee
311 }
312
313 pub fn generate_employee_with_level(
315 &mut self,
316 company_code: &str,
317 department: &DepartmentDefinition,
318 job_level: JobLevel,
319 hire_date: NaiveDate,
320 ) -> Employee {
321 let mut employee = self.generate_employee(company_code, department, hire_date);
322 employee.job_level = job_level;
323 employee.approval_limit = self.get_approval_limit(&job_level);
324 employee.can_approve_pr = matches!(
325 job_level,
326 JobLevel::Manager | JobLevel::Director | JobLevel::VicePresident | JobLevel::Executive
327 );
328 employee.can_approve_po = matches!(
329 job_level,
330 JobLevel::Senior
331 | JobLevel::Manager
332 | JobLevel::Director
333 | JobLevel::VicePresident
334 | JobLevel::Executive
335 );
336 employee.can_approve_je = matches!(
337 job_level,
338 JobLevel::Manager | JobLevel::Director | JobLevel::VicePresident | JobLevel::Executive
339 );
340 employee
341 }
342
343 pub fn generate_department_pool(
345 &mut self,
346 company_code: &str,
347 department: &DepartmentDefinition,
348 hire_date_range: (NaiveDate, NaiveDate),
349 ) -> EmployeePool {
350 let mut pool = EmployeePool::new();
351
352 let (start_date, end_date) = hire_date_range;
353 let days_range = (end_date - start_date).num_days() as u64;
354
355 let head_level = if department.headcount >= 15 {
357 JobLevel::Director
358 } else {
359 JobLevel::Manager
360 };
361 let hire_date =
362 start_date + chrono::Duration::days(self.rng.random_range(0..=days_range / 2) as i64);
363 let dept_head =
364 self.generate_employee_with_level(company_code, department, head_level, hire_date);
365 let dept_head_id = dept_head.employee_id.clone();
366 pool.add_employee(dept_head);
367
368 for _ in 1..department.headcount {
370 let hire_date =
371 start_date + chrono::Duration::days(self.rng.random_range(0..=days_range) as i64);
372 let mut employee = self.generate_employee(company_code, department, hire_date);
373
374 employee.manager_id = Some(dept_head_id.clone());
376
377 pool.add_employee(employee);
378 }
379
380 let direct_reports: Vec<String> = pool
382 .employees
383 .iter()
384 .filter(|e| e.manager_id.as_ref() == Some(&dept_head_id))
385 .map(|e| e.employee_id.clone())
386 .collect();
387
388 if let Some(head) = pool
390 .employees
391 .iter_mut()
392 .find(|e| e.employee_id == dept_head_id)
393 {
394 head.direct_reports = direct_reports;
395 }
396
397 pool
398 }
399
400 pub fn generate_company_pool(
402 &mut self,
403 company_code: &str,
404 hire_date_range: (NaiveDate, NaiveDate),
405 ) -> EmployeePool {
406 debug!(company_code, "Generating employee company pool");
407 let mut pool = EmployeePool::new();
408
409 let (start_date, end_date) = hire_date_range;
410 let _days_range = (end_date - start_date).num_days() as u64;
411
412 let ceo = self.generate_executive(company_code, "CEO", start_date);
414 let ceo_id = ceo.employee_id.clone();
415 pool.add_employee(ceo);
416
417 let cfo = self.generate_executive(company_code, "CFO", start_date);
418 let cfo_id = cfo.employee_id.clone();
419 pool.employees
420 .last_mut()
421 .expect("just added CEO")
422 .manager_id = Some(ceo_id.clone());
423 pool.add_employee(cfo);
424
425 let coo = self.generate_executive(company_code, "COO", start_date);
426 let coo_id = coo.employee_id.clone();
427 pool.employees
428 .last_mut()
429 .expect("just added CFO")
430 .manager_id = Some(ceo_id.clone());
431 pool.add_employee(coo);
432
433 let departments = DepartmentDefinition::standard_departments(company_code);
435
436 for dept in &departments {
437 let dept_pool = self.generate_department_pool(company_code, dept, hire_date_range);
438
439 for mut employee in dept_pool.employees {
441 if employee.manager_id.is_none() {
442 employee.manager_id = if dept.name == "Finance" {
444 Some(cfo_id.clone())
445 } else {
446 Some(coo_id.clone())
447 };
448 }
449 pool.add_employee(employee);
450 }
451 }
452
453 self.update_direct_reports(&mut pool);
455
456 pool
457 }
458
459 fn generate_executive(
461 &mut self,
462 company_code: &str,
463 title: &str,
464 hire_date: NaiveDate,
465 ) -> Employee {
466 self.employee_counter += 1;
467
468 let name = self.name_generator.generate_name(&mut self.rng);
469 let employee_id = format!("EMP-{}-{:06}", company_code, self.employee_counter);
470 let user_id = format!("exec{:04}", self.employee_counter);
471 let email = self.name_generator.generate_email(&name);
472
473 let mut employee = Employee::new(
474 employee_id,
475 user_id,
476 name.first_name.clone(),
477 name.last_name.clone(),
478 company_code.to_string(),
479 );
480
481 employee.email = email;
482 employee.job_level = JobLevel::Executive;
483 employee.job_title = title.to_string();
484 employee.department_id = Some("Executive".to_string());
485 employee.cost_center = Some(format!("CC-{company_code}-EXEC"));
486 employee.hire_date = Some(hire_date);
487 employee.approval_limit = Decimal::from(100_000_000);
488 employee.can_approve_pr = true;
489 employee.can_approve_po = true;
490 employee.can_approve_je = true;
491 employee.system_roles.push(SystemRole::Executive);
492
493 employee
494 }
495
496 fn update_direct_reports(&self, pool: &mut EmployeePool) {
498 let mut direct_reports_map: std::collections::HashMap<String, Vec<String>> =
500 std::collections::HashMap::new();
501
502 for employee in &pool.employees {
503 if let Some(manager_id) = &employee.manager_id {
504 direct_reports_map
505 .entry(manager_id.clone())
506 .or_default()
507 .push(employee.employee_id.clone());
508 }
509 }
510
511 for employee in &mut pool.employees {
513 if let Some(reports) = direct_reports_map.get(&employee.employee_id) {
514 employee.direct_reports = reports.clone();
515 }
516 }
517 }
518
519 fn select_job_level(&mut self) -> JobLevel {
521 let roll: f64 = self.rng.random();
522 let mut cumulative = 0.0;
523
524 for (level, prob) in &self.config.job_level_distribution {
525 cumulative += prob;
526 if roll < cumulative {
527 return *level;
528 }
529 }
530
531 JobLevel::Staff
532 }
533
534 fn get_approval_limit(&self, job_level: &JobLevel) -> Decimal {
536 for (level, limit) in &self.config.approval_limits {
537 if level == job_level {
538 return *limit;
539 }
540 }
541 Decimal::from(1_000)
542 }
543
544 fn select_status(&mut self) -> EmployeeStatus {
546 let roll: f64 = self.rng.random();
547
548 if roll < self.config.termination_rate {
549 EmployeeStatus::Terminated
550 } else if roll < self.config.termination_rate + self.config.leave_rate {
551 EmployeeStatus::OnLeave
552 } else {
553 EmployeeStatus::Active
554 }
555 }
556
557 pub fn reset(&mut self) {
559 self.rng = seeded_rng(self.seed, 0);
560 self.employee_counter = 0;
561 }
562}
563
564#[cfg(test)]
565#[allow(clippy::unwrap_used)]
566mod tests {
567 use super::*;
568
569 #[test]
570 fn test_employee_generation() {
571 let mut gen = EmployeeGenerator::new(42);
572 let dept = DepartmentDefinition::finance("1000");
573 let employee =
574 gen.generate_employee("1000", &dept, NaiveDate::from_ymd_opt(2024, 1, 1).unwrap());
575
576 assert!(!employee.employee_id.is_empty());
577 assert!(!employee.display_name.is_empty());
578 assert!(!employee.email.is_empty());
579 assert!(employee.approval_limit > Decimal::ZERO);
580 }
581
582 #[test]
583 fn test_department_pool() {
584 let mut gen = EmployeeGenerator::new(42);
585 let dept = DepartmentDefinition::finance("1000");
586 let pool = gen.generate_department_pool(
587 "1000",
588 &dept,
589 (
590 NaiveDate::from_ymd_opt(2020, 1, 1).unwrap(),
591 NaiveDate::from_ymd_opt(2024, 1, 1).unwrap(),
592 ),
593 );
594
595 assert_eq!(pool.employees.len(), dept.headcount);
596
597 let managers: Vec<_> = pool
599 .employees
600 .iter()
601 .filter(|e| matches!(e.job_level, JobLevel::Manager | JobLevel::Director))
602 .collect();
603 assert!(!managers.is_empty());
604
605 let dept_head = managers.first().unwrap();
607 assert!(!dept_head.direct_reports.is_empty());
608 }
609
610 #[test]
611 fn test_company_pool() {
612 let mut gen = EmployeeGenerator::new(42);
613 let pool = gen.generate_company_pool(
614 "1000",
615 (
616 NaiveDate::from_ymd_opt(2020, 1, 1).unwrap(),
617 NaiveDate::from_ymd_opt(2024, 1, 1).unwrap(),
618 ),
619 );
620
621 let executives: Vec<_> = pool
623 .employees
624 .iter()
625 .filter(|e| e.job_level == JobLevel::Executive)
626 .collect();
627 assert!(executives.len() >= 3); let cfo = pool.employees.iter().find(|e| e.job_title == "CFO");
631 assert!(cfo.is_some());
632 }
633
634 #[test]
635 fn test_hierarchy() {
636 let mut gen = EmployeeGenerator::new(42);
637 let pool = gen.generate_company_pool(
638 "1000",
639 (
640 NaiveDate::from_ymd_opt(2020, 1, 1).unwrap(),
641 NaiveDate::from_ymd_opt(2024, 1, 1).unwrap(),
642 ),
643 );
644
645 let non_ceo_without_manager: Vec<_> = pool
647 .employees
648 .iter()
649 .filter(|e| e.job_title != "CEO")
650 .filter(|e| e.manager_id.is_none())
651 .collect();
652
653 assert!(non_ceo_without_manager.len() <= 1);
655 }
656
657 #[test]
658 fn test_deterministic_generation() {
659 let mut gen1 = EmployeeGenerator::new(42);
660 let mut gen2 = EmployeeGenerator::new(42);
661
662 let dept = DepartmentDefinition::finance("1000");
663 let employee1 =
664 gen1.generate_employee("1000", &dept, NaiveDate::from_ymd_opt(2024, 1, 1).unwrap());
665 let employee2 =
666 gen2.generate_employee("1000", &dept, NaiveDate::from_ymd_opt(2024, 1, 1).unwrap());
667
668 assert_eq!(employee1.employee_id, employee2.employee_id);
669 assert_eq!(employee1.display_name, employee2.display_name);
670 }
671
672 #[test]
673 fn test_approval_limits() {
674 let mut gen = EmployeeGenerator::new(42);
675 let dept = DepartmentDefinition::finance("1000");
676
677 let staff = gen.generate_employee_with_level(
678 "1000",
679 &dept,
680 JobLevel::Staff,
681 NaiveDate::from_ymd_opt(2024, 1, 1).unwrap(),
682 );
683 let manager = gen.generate_employee_with_level(
684 "1000",
685 &dept,
686 JobLevel::Manager,
687 NaiveDate::from_ymd_opt(2024, 1, 1).unwrap(),
688 );
689
690 assert!(manager.approval_limit > staff.approval_limit);
691 assert!(!staff.can_approve_pr);
692 assert!(manager.can_approve_pr);
693 }
694
695 #[test]
696 fn test_country_pack_does_not_break_generation() {
697 let mut gen = EmployeeGenerator::new(42);
698 gen.set_country_pack(datasynth_core::CountryPack::default());
700
701 let dept = DepartmentDefinition::finance("1000");
702 let employee =
703 gen.generate_employee("1000", &dept, NaiveDate::from_ymd_opt(2024, 1, 1).unwrap());
704
705 assert!(!employee.employee_id.is_empty());
706 assert!(!employee.display_name.is_empty());
707 assert!(!employee.email.is_empty());
708 assert!(employee.approval_limit > Decimal::ZERO);
709 }
710}