Skip to main content

datasynth_generators/master_data/
employee_generator.rs

1//! Employee generator with org hierarchy and approval limits.
2
3use 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/// Configuration for employee generation.
15#[derive(Debug, Clone)]
16pub struct EmployeeGeneratorConfig {
17    /// Distribution of job levels (level, probability)
18    pub job_level_distribution: Vec<(JobLevel, f64)>,
19    /// Approval limits by job level (level, limit)
20    pub approval_limits: Vec<(JobLevel, Decimal)>,
21    /// Name culture distribution
22    pub culture_distribution: Vec<(NameCulture, f64)>,
23    /// Email domain
24    pub email_domain: String,
25    /// Probability of employee being on leave
26    pub leave_rate: f64,
27    /// Probability of employee being terminated
28    pub termination_rate: f64,
29    /// Manager span of control (min, max direct reports)
30    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/// Department definitions for employee assignment.
70#[derive(Debug, Clone)]
71pub struct DepartmentDefinition {
72    /// Department code
73    pub code: String,
74    /// Department name
75    pub name: String,
76    /// Cost center
77    pub cost_center: String,
78    /// Target headcount
79    pub headcount: usize,
80    /// System roles for this department
81    pub system_roles: Vec<SystemRole>,
82    /// Transaction codes for this department
83    pub transaction_codes: Vec<String>,
84}
85
86impl DepartmentDefinition {
87    /// Finance department.
88    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    /// Procurement department.
112    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    /// Sales department.
130    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    /// Warehouse/Logistics department.
148    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    /// IT department.
165    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    /// Standard departments for a company.
177    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
188/// Generator for employee master data with org hierarchy.
189pub struct EmployeeGenerator {
190    rng: ChaCha8Rng,
191    seed: u64,
192    config: EmployeeGeneratorConfig,
193    name_generator: MultiCultureNameGenerator,
194    employee_counter: usize,
195    /// Optional country pack for locale-aware generation.
196    country_pack: Option<datasynth_core::CountryPack>,
197}
198
199impl EmployeeGenerator {
200    /// Create a new employee generator.
201    pub fn new(seed: u64) -> Self {
202        Self::with_config(seed, EmployeeGeneratorConfig::default())
203    }
204
205    /// Create a new employee generator with custom configuration.
206    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    /// Set the country pack for locale-aware generation.
222    ///
223    /// When set, the generator can use locale-specific names and
224    /// business rules from the country pack.  Currently the pack is
225    /// stored for future expansion; existing behaviour is unchanged
226    /// when no pack is provided.
227    pub fn set_country_pack(&mut self, pack: datasynth_core::CountryPack) {
228        self.country_pack = Some(pack);
229    }
230
231    /// Generate a single employee.
232    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        // Set additional fields
257        employee.email = email;
258        employee.job_level = job_level;
259
260        // Set department info
261        employee.department_id = Some(department.name.clone());
262        employee.cost_center = Some(department.cost_center.clone());
263
264        // Set dates
265        employee.hire_date = Some(hire_date);
266
267        // Set approval limits based on job level
268        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        // Assign system roles
287        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        // Assign transaction codes
295        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        // Set status
304        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    /// Generate an employee with specific job level.
314    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    /// Generate an employee pool for a department.
344    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        // Generate department head (Director or Manager)
356        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        // Generate remaining employees
369        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            // Assign manager (department head)
375            employee.manager_id = Some(dept_head_id.clone());
376
377            pool.add_employee(employee);
378        }
379
380        // Collect direct reports first to avoid borrow conflict
381        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        // Update direct reports for department head
389        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    /// Generate a full company employee pool with hierarchy.
401    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        // First, generate C-level executives
413        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        // Generate department pools
434        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            // Assign department heads to executives
440            for mut employee in dept_pool.employees {
441                if employee.manager_id.is_none() {
442                    // Department head reports to CFO (finance) or COO (others)
443                    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        // Update executive direct reports
454        self.update_direct_reports(&mut pool);
455
456        pool
457    }
458
459    /// Generate an executive employee.
460    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    /// Update direct reports for all managers.
497    fn update_direct_reports(&self, pool: &mut EmployeePool) {
498        // Collect manager -> direct reports mapping
499        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        // Update each manager's direct reports
512        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    /// Select job level based on distribution.
520    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    /// Get approval limit for job level.
535    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    /// Select employee status.
545    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    /// Reset the generator.
558    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        // Should have at least one manager
598        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        // Department head should have direct reports
606        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        // Should have executives
622        let executives: Vec<_> = pool
623            .employees
624            .iter()
625            .filter(|e| e.job_level == JobLevel::Executive)
626            .collect();
627        assert!(executives.len() >= 3); // CEO, CFO, COO
628
629        // Executives should have direct reports
630        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        // Every non-CEO employee should have a manager
646        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        // Most employees should have managers (some edge cases may exist)
654        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        // Setting a default country pack should not alter basic generation behaviour.
699        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}