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, EmployeeChangeEvent, EmployeeEventType, EmployeePool, EmployeeStatus, JobLevel,
6    SystemRole, TransactionCodeAuth,
7};
8use datasynth_core::templates::{MultiCultureNameGenerator, NameCulture};
9use datasynth_core::utils::seeded_rng;
10use rand::prelude::*;
11use rand_chacha::ChaCha8Rng;
12use rust_decimal::Decimal;
13use tracing::debug;
14
15/// Configuration for employee generation.
16#[derive(Debug, Clone)]
17pub struct EmployeeGeneratorConfig {
18    /// Distribution of job levels (level, probability)
19    pub job_level_distribution: Vec<(JobLevel, f64)>,
20    /// Approval limits by job level (level, limit)
21    pub approval_limits: Vec<(JobLevel, Decimal)>,
22    /// Name culture distribution
23    pub culture_distribution: Vec<(NameCulture, f64)>,
24    /// Email domain
25    pub email_domain: String,
26    /// Probability of employee being on leave
27    pub leave_rate: f64,
28    /// Probability of employee being terminated
29    pub termination_rate: f64,
30    /// Manager span of control (min, max direct reports)
31    pub span_of_control: (usize, usize),
32}
33
34impl Default for EmployeeGeneratorConfig {
35    fn default() -> Self {
36        Self {
37            job_level_distribution: vec![
38                (JobLevel::Staff, 0.50),
39                (JobLevel::Senior, 0.25),
40                (JobLevel::Manager, 0.12),
41                (JobLevel::Director, 0.08),
42                (JobLevel::VicePresident, 0.04),
43                (JobLevel::Executive, 0.01),
44            ],
45            approval_limits: vec![
46                (JobLevel::Staff, Decimal::from(1_000)),
47                (JobLevel::Senior, Decimal::from(5_000)),
48                (JobLevel::Manager, Decimal::from(25_000)),
49                (JobLevel::Director, Decimal::from(100_000)),
50                (JobLevel::VicePresident, Decimal::from(500_000)),
51                (JobLevel::Executive, Decimal::from(10_000_000)),
52            ],
53            culture_distribution: vec![
54                (NameCulture::WesternUs, 0.40),
55                (NameCulture::Hispanic, 0.20),
56                (NameCulture::German, 0.10),
57                (NameCulture::French, 0.05),
58                (NameCulture::Chinese, 0.10),
59                (NameCulture::Japanese, 0.05),
60                (NameCulture::Indian, 0.10),
61            ],
62            email_domain: "company.com".to_string(),
63            leave_rate: 0.02,
64            termination_rate: 0.01,
65            span_of_control: (3, 8),
66        }
67    }
68}
69
70/// Department definitions for employee assignment.
71#[derive(Debug, Clone)]
72pub struct DepartmentDefinition {
73    /// Department code
74    pub code: String,
75    /// Department name
76    pub name: String,
77    /// Cost center
78    pub cost_center: String,
79    /// Target headcount
80    pub headcount: usize,
81    /// System roles for this department
82    pub system_roles: Vec<SystemRole>,
83    /// Transaction codes for this department
84    pub transaction_codes: Vec<String>,
85}
86
87impl DepartmentDefinition {
88    /// Finance department.
89    pub fn finance(company_code: &str) -> Self {
90        Self {
91            code: format!("{company_code}-FIN"),
92            name: "Finance".to_string(),
93            cost_center: format!("CC-{company_code}-FIN"),
94            headcount: 15,
95            system_roles: vec![
96                SystemRole::ApAccountant,
97                SystemRole::ArAccountant,
98                SystemRole::GeneralAccountant,
99                SystemRole::FinancialAnalyst,
100            ],
101            transaction_codes: vec![
102                "FB01".to_string(),
103                "FB02".to_string(),
104                "FB03".to_string(),
105                "F-28".to_string(),
106                "F-53".to_string(),
107                "FBL1N".to_string(),
108            ],
109        }
110    }
111
112    /// Procurement department.
113    pub fn procurement(company_code: &str) -> Self {
114        Self {
115            code: format!("{company_code}-PROC"),
116            name: "Procurement".to_string(),
117            cost_center: format!("CC-{company_code}-PROC"),
118            headcount: 10,
119            system_roles: vec![SystemRole::Buyer, SystemRole::Approver],
120            transaction_codes: vec![
121                "ME21N".to_string(),
122                "ME22N".to_string(),
123                "ME23N".to_string(),
124                "MIGO".to_string(),
125                "ME2M".to_string(),
126            ],
127        }
128    }
129
130    /// Sales department.
131    pub fn sales(company_code: &str) -> Self {
132        Self {
133            code: format!("{company_code}-SALES"),
134            name: "Sales".to_string(),
135            cost_center: format!("CC-{company_code}-SALES"),
136            headcount: 20,
137            system_roles: vec![SystemRole::Creator, SystemRole::Approver],
138            transaction_codes: vec![
139                "VA01".to_string(),
140                "VA02".to_string(),
141                "VA03".to_string(),
142                "VL01N".to_string(),
143                "VF01".to_string(),
144            ],
145        }
146    }
147
148    /// Warehouse/Logistics department.
149    pub fn warehouse(company_code: &str) -> Self {
150        Self {
151            code: format!("{company_code}-WH"),
152            name: "Warehouse".to_string(),
153            cost_center: format!("CC-{company_code}-WH"),
154            headcount: 12,
155            system_roles: vec![SystemRole::Creator, SystemRole::Viewer],
156            transaction_codes: vec![
157                "MIGO".to_string(),
158                "MB51".to_string(),
159                "MMBE".to_string(),
160                "LT01".to_string(),
161            ],
162        }
163    }
164
165    /// IT department.
166    pub fn it(company_code: &str) -> Self {
167        Self {
168            code: format!("{company_code}-IT"),
169            name: "Information Technology".to_string(),
170            cost_center: format!("CC-{company_code}-IT"),
171            headcount: 8,
172            system_roles: vec![SystemRole::Admin],
173            transaction_codes: vec!["SU01".to_string(), "PFCG".to_string(), "SM21".to_string()],
174        }
175    }
176
177    /// Standard departments for a company.
178    pub fn standard_departments(company_code: &str) -> Vec<Self> {
179        vec![
180            Self::finance(company_code),
181            Self::procurement(company_code),
182            Self::sales(company_code),
183            Self::warehouse(company_code),
184            Self::it(company_code),
185        ]
186    }
187}
188
189/// Generator for employee master data with org hierarchy.
190pub struct EmployeeGenerator {
191    rng: ChaCha8Rng,
192    seed: u64,
193    config: EmployeeGeneratorConfig,
194    name_generator: MultiCultureNameGenerator,
195    employee_counter: usize,
196    /// Optional country pack for locale-aware generation.
197    country_pack: Option<datasynth_core::CountryPack>,
198    /// Optional template provider for user-supplied department display names (v3.2.1+)
199    template_provider: Option<datasynth_core::templates::SharedTemplateProvider>,
200}
201
202impl EmployeeGenerator {
203    /// Create a new employee generator.
204    pub fn new(seed: u64) -> Self {
205        Self::with_config(seed, EmployeeGeneratorConfig::default())
206    }
207
208    /// Create a new employee generator with custom configuration.
209    pub fn with_config(seed: u64, config: EmployeeGeneratorConfig) -> Self {
210        let mut name_gen =
211            MultiCultureNameGenerator::with_distribution(config.culture_distribution.clone());
212        name_gen.set_email_domain(&config.email_domain);
213
214        Self {
215            rng: seeded_rng(seed, 0),
216            seed,
217            name_generator: name_gen,
218            config,
219            employee_counter: 0,
220            country_pack: None,
221            template_provider: None,
222        }
223    }
224
225    /// Set a template provider so user-supplied department names
226    /// override the factory defaults. (v3.2.1+)
227    pub fn set_template_provider(
228        &mut self,
229        provider: datasynth_core::templates::SharedTemplateProvider,
230    ) {
231        self.template_provider = Some(provider);
232    }
233
234    /// Resolve a department's display name via the template provider,
235    /// falling back to the factory-assigned `name` field when the
236    /// provider has no override for this `department_code`. Preserves
237    /// byte-identical output when no `templates.path` is set.
238    fn resolve_department_name(&mut self, department_code: &str, factory_name: &str) -> String {
239        if let Some(ref provider) = self.template_provider {
240            if let Some(custom) = provider.get_department_name(department_code, &mut self.rng) {
241                return custom;
242            }
243        }
244        factory_name.to_string()
245    }
246
247    /// Set the country pack for locale-aware generation.
248    ///
249    /// When set, the generator can use locale-specific names and
250    /// business rules from the country pack.  Currently the pack is
251    /// stored for future expansion; existing behaviour is unchanged
252    /// when no pack is provided.
253    pub fn set_country_pack(&mut self, pack: datasynth_core::CountryPack) {
254        self.country_pack = Some(pack);
255    }
256
257    /// Generate a single employee.
258    pub fn generate_employee(
259        &mut self,
260        company_code: &str,
261        department: &DepartmentDefinition,
262        hire_date: NaiveDate,
263    ) -> Employee {
264        self.employee_counter += 1;
265
266        let name = self.name_generator.generate_name(&mut self.rng);
267        let employee_id = format!("EMP-{}-{:06}", company_code, self.employee_counter);
268        let user_id = format!("u{:06}", self.employee_counter);
269        let email = self.name_generator.generate_email(&name);
270
271        let job_level = self.select_job_level();
272        let approval_limit = self.get_approval_limit(&job_level);
273
274        let mut employee = Employee::new(
275            employee_id,
276            user_id,
277            name.first_name.clone(),
278            name.last_name.clone(),
279            company_code.to_string(),
280        );
281
282        // Set additional fields
283        employee.email = email;
284        employee.job_level = job_level;
285
286        // Set department info
287        employee.department_id = Some(department.name.clone());
288        employee.cost_center = Some(department.cost_center.clone());
289
290        // Set dates
291        employee.hire_date = Some(hire_date);
292
293        // Set approval limits based on job level
294        employee.approval_limit = approval_limit;
295        employee.can_approve_pr = matches!(
296            job_level,
297            JobLevel::Manager | JobLevel::Director | JobLevel::VicePresident | JobLevel::Executive
298        );
299        employee.can_approve_po = matches!(
300            job_level,
301            JobLevel::Senior
302                | JobLevel::Manager
303                | JobLevel::Director
304                | JobLevel::VicePresident
305                | JobLevel::Executive
306        );
307        employee.can_approve_je = matches!(
308            job_level,
309            JobLevel::Manager | JobLevel::Director | JobLevel::VicePresident | JobLevel::Executive
310        );
311
312        // Assign system roles
313        if !department.system_roles.is_empty() {
314            let role_idx = self.rng.random_range(0..department.system_roles.len());
315            employee
316                .system_roles
317                .push(department.system_roles[role_idx].clone());
318        }
319
320        // Assign transaction codes
321        for tcode in &department.transaction_codes {
322            employee.transaction_codes.push(TransactionCodeAuth {
323                tcode: tcode.clone(),
324                activity: datasynth_core::models::ActivityType::Create,
325                active: true,
326            });
327        }
328
329        // Set annual base salary based on job level (USD-equivalent ranges).
330        // A small random variance (±10 %) is applied for realism.
331        let (salary_min, salary_max): (u64, u64) = match job_level {
332            JobLevel::Staff => (40_000, 60_000),
333            JobLevel::Senior => (60_000, 90_000),
334            JobLevel::Lead => (75_000, 105_000),
335            JobLevel::Supervisor => (70_000, 100_000),
336            JobLevel::Manager => (80_000, 120_000),
337            JobLevel::Director => (100_000, 160_000),
338            JobLevel::VicePresident => (130_000, 200_000),
339            JobLevel::Executive => (150_000, 250_000),
340        };
341        let salary_range = salary_max - salary_min;
342        let salary_raw =
343            salary_min + (self.rng.random::<f64>() * salary_range as f64).round() as u64;
344        employee.base_salary = Decimal::from(salary_raw);
345
346        // Set status
347        employee.status = self.select_status();
348        if employee.status == EmployeeStatus::Terminated {
349            employee.termination_date =
350                Some(hire_date + chrono::Duration::days(self.rng.random_range(365..1825) as i64));
351        }
352
353        employee
354    }
355
356    /// Generate an employee with specific job level.
357    pub fn generate_employee_with_level(
358        &mut self,
359        company_code: &str,
360        department: &DepartmentDefinition,
361        job_level: JobLevel,
362        hire_date: NaiveDate,
363    ) -> Employee {
364        let mut employee = self.generate_employee(company_code, department, hire_date);
365        employee.job_level = job_level;
366        employee.approval_limit = self.get_approval_limit(&job_level);
367        employee.can_approve_pr = matches!(
368            job_level,
369            JobLevel::Manager | JobLevel::Director | JobLevel::VicePresident | JobLevel::Executive
370        );
371        employee.can_approve_po = matches!(
372            job_level,
373            JobLevel::Senior
374                | JobLevel::Manager
375                | JobLevel::Director
376                | JobLevel::VicePresident
377                | JobLevel::Executive
378        );
379        employee.can_approve_je = matches!(
380            job_level,
381            JobLevel::Manager | JobLevel::Director | JobLevel::VicePresident | JobLevel::Executive
382        );
383        employee
384    }
385
386    /// Generate an employee pool for a department.
387    pub fn generate_department_pool(
388        &mut self,
389        company_code: &str,
390        department: &DepartmentDefinition,
391        hire_date_range: (NaiveDate, NaiveDate),
392    ) -> EmployeePool {
393        let mut pool = EmployeePool::new();
394
395        let (start_date, end_date) = hire_date_range;
396        let days_range = (end_date - start_date).num_days() as u64;
397
398        // Generate department head (Director or Manager)
399        let head_level = if department.headcount >= 15 {
400            JobLevel::Director
401        } else {
402            JobLevel::Manager
403        };
404        let hire_date =
405            start_date + chrono::Duration::days(self.rng.random_range(0..=days_range / 2) as i64);
406        let dept_head =
407            self.generate_employee_with_level(company_code, department, head_level, hire_date);
408        let dept_head_id = dept_head.employee_id.clone();
409        pool.add_employee(dept_head);
410
411        // Generate remaining employees
412        for _ in 1..department.headcount {
413            let hire_date =
414                start_date + chrono::Duration::days(self.rng.random_range(0..=days_range) as i64);
415            let mut employee = self.generate_employee(company_code, department, hire_date);
416
417            // Assign manager (department head)
418            employee.manager_id = Some(dept_head_id.clone());
419
420            pool.add_employee(employee);
421        }
422
423        // Collect direct reports first to avoid borrow conflict
424        let direct_reports: Vec<String> = pool
425            .employees
426            .iter()
427            .filter(|e| e.manager_id.as_ref() == Some(&dept_head_id))
428            .map(|e| e.employee_id.clone())
429            .collect();
430
431        // Update direct reports for department head
432        if let Some(head) = pool
433            .employees
434            .iter_mut()
435            .find(|e| e.employee_id == dept_head_id)
436        {
437            head.direct_reports = direct_reports;
438        }
439
440        pool
441    }
442
443    /// Generate a full company employee pool with hierarchy.
444    pub fn generate_company_pool(
445        &mut self,
446        company_code: &str,
447        hire_date_range: (NaiveDate, NaiveDate),
448    ) -> EmployeePool {
449        debug!(company_code, "Generating employee company pool");
450        let mut pool = EmployeePool::new();
451
452        let (start_date, end_date) = hire_date_range;
453        let _days_range = (end_date - start_date).num_days() as u64;
454
455        // First, generate C-level executives
456        let ceo = self.generate_executive(company_code, "CEO", start_date);
457        let ceo_id = ceo.employee_id.clone();
458        pool.add_employee(ceo);
459
460        let cfo = self.generate_executive(company_code, "CFO", start_date);
461        let cfo_id = cfo.employee_id.clone();
462        pool.employees
463            .last_mut()
464            .expect("just added CEO")
465            .manager_id = Some(ceo_id.clone());
466        pool.add_employee(cfo);
467
468        let coo = self.generate_executive(company_code, "COO", start_date);
469        let coo_id = coo.employee_id.clone();
470        pool.employees
471            .last_mut()
472            .expect("just added CFO")
473            .manager_id = Some(ceo_id.clone());
474        pool.add_employee(coo);
475
476        // Generate department pools.
477        //
478        // v3.2.1+: department display names (e.g. "Finance",
479        // "Procurement") can come from the template provider. We map
480        // each factory-produced DepartmentDefinition through
481        // `resolve_department_name` so user-supplied names replace the
482        // embedded English strings where specified.
483        let mut departments = DepartmentDefinition::standard_departments(company_code);
484        for dept in departments.iter_mut() {
485            // Department code has the shape `{COMPANY}-{FIN|PROC|SALES|WH|IT}`.
486            // Extract the suffix and lowercase it to match the template key.
487            let code_suffix = dept.code.rsplit('-').next().unwrap_or("");
488            let key = match code_suffix {
489                "FIN" => "finance",
490                "PROC" => "procurement",
491                "SALES" => "sales",
492                "WH" => "warehouse",
493                "IT" => "it",
494                _ => "",
495            };
496            if !key.is_empty() {
497                let resolved = self.resolve_department_name(key, &dept.name);
498                dept.name = resolved;
499            }
500        }
501        let departments = departments;
502
503        for dept in &departments {
504            let dept_pool = self.generate_department_pool(company_code, dept, hire_date_range);
505
506            // Assign department heads to executives
507            //
508            // v3.2.1: match on the code suffix (`*-FIN`) instead of the
509            // display name so user-supplied department names (e.g.
510            // German "Finanzen") don't break the CFO/COO routing.
511            let is_finance = dept.code.ends_with("-FIN");
512            for mut employee in dept_pool.employees {
513                if employee.manager_id.is_none() {
514                    employee.manager_id = if is_finance {
515                        Some(cfo_id.clone())
516                    } else {
517                        Some(coo_id.clone())
518                    };
519                }
520                pool.add_employee(employee);
521            }
522        }
523
524        // Update executive direct reports
525        self.update_direct_reports(&mut pool);
526
527        pool
528    }
529
530    /// Generate an executive employee.
531    fn generate_executive(
532        &mut self,
533        company_code: &str,
534        title: &str,
535        hire_date: NaiveDate,
536    ) -> Employee {
537        self.employee_counter += 1;
538
539        let name = self.name_generator.generate_name(&mut self.rng);
540        let employee_id = format!("EMP-{}-{:06}", company_code, self.employee_counter);
541        let user_id = format!("exec{:04}", self.employee_counter);
542        let email = self.name_generator.generate_email(&name);
543
544        let mut employee = Employee::new(
545            employee_id,
546            user_id,
547            name.first_name.clone(),
548            name.last_name.clone(),
549            company_code.to_string(),
550        );
551
552        employee.email = email;
553        employee.job_level = JobLevel::Executive;
554        employee.job_title = title.to_string();
555        employee.department_id = Some("Executive".to_string());
556        employee.cost_center = Some(format!("CC-{company_code}-EXEC"));
557        employee.hire_date = Some(hire_date);
558        employee.approval_limit = Decimal::from(100_000_000);
559        employee.can_approve_pr = true;
560        employee.can_approve_po = true;
561        employee.can_approve_je = true;
562        employee.system_roles.push(SystemRole::Executive);
563
564        employee
565    }
566
567    /// Update direct reports for all managers.
568    fn update_direct_reports(&self, pool: &mut EmployeePool) {
569        // Collect manager -> direct reports mapping
570        let mut direct_reports_map: std::collections::HashMap<String, Vec<String>> =
571            std::collections::HashMap::new();
572
573        for employee in &pool.employees {
574            if let Some(manager_id) = &employee.manager_id {
575                direct_reports_map
576                    .entry(manager_id.clone())
577                    .or_default()
578                    .push(employee.employee_id.clone());
579            }
580        }
581
582        // Update each manager's direct reports
583        for employee in &mut pool.employees {
584            if let Some(reports) = direct_reports_map.get(&employee.employee_id) {
585                employee.direct_reports = reports.clone();
586            }
587        }
588    }
589
590    /// Select job level based on distribution.
591    fn select_job_level(&mut self) -> JobLevel {
592        let roll: f64 = self.rng.random();
593        let mut cumulative = 0.0;
594
595        for (level, prob) in &self.config.job_level_distribution {
596            cumulative += prob;
597            if roll < cumulative {
598                return *level;
599            }
600        }
601
602        JobLevel::Staff
603    }
604
605    /// Get approval limit for job level.
606    fn get_approval_limit(&self, job_level: &JobLevel) -> Decimal {
607        for (level, limit) in &self.config.approval_limits {
608            if level == job_level {
609                return *limit;
610            }
611        }
612        Decimal::from(1_000)
613    }
614
615    /// Select employee status.
616    fn select_status(&mut self) -> EmployeeStatus {
617        let roll: f64 = self.rng.random();
618
619        if roll < self.config.termination_rate {
620            EmployeeStatus::Terminated
621        } else if roll < self.config.termination_rate + self.config.leave_rate {
622            EmployeeStatus::OnLeave
623        } else {
624            EmployeeStatus::Active
625        }
626    }
627
628    /// Generate 2-5 change history events for a single employee.
629    ///
630    /// Always starts with a `Hired` event on `hire_date`.  Up to 4 subsequent
631    /// events (promotions, salary adjustments, or transfers) are spread
632    /// uniformly over the employee's tenure, ending with a `Terminated` event
633    /// for terminated employees.
634    pub fn generate_change_history(
635        &mut self,
636        employee: &Employee,
637        period_end: NaiveDate,
638    ) -> Vec<EmployeeChangeEvent> {
639        let hire_date = match employee.hire_date {
640            Some(d) => d,
641            None => return Vec::new(),
642        };
643
644        let mut events: Vec<EmployeeChangeEvent> = Vec::with_capacity(5);
645
646        // 1. Always: Hired event
647        events.push(EmployeeChangeEvent::hired(
648            employee.employee_id.clone(),
649            hire_date,
650        ));
651
652        // Tenure in days (capped at period_end or termination date)
653        let tenure_end = employee
654            .termination_date
655            .unwrap_or(period_end)
656            .min(period_end);
657        let tenure_days = (tenure_end - hire_date).num_days().max(1);
658
659        // Skip change history for very short tenures (< 60 days)
660        if tenure_days < 60 {
661            return events;
662        }
663
664        // 2. Generate 1-4 additional events
665        let additional_count = self.rng.random_range(1u32..=4);
666        // Build sorted random offsets within tenure (days from hire)
667        let mut offsets: Vec<i64> = (0..additional_count)
668            .map(|_| self.rng.random_range(30i64..tenure_days))
669            .collect();
670        offsets.sort_unstable();
671
672        let event_types = [
673            EmployeeEventType::Promoted,
674            EmployeeEventType::SalaryAdjustment,
675            EmployeeEventType::Transfer,
676        ];
677
678        for offset in offsets {
679            let event_date = hire_date + chrono::Duration::days(offset);
680
681            // Pick a random non-termination event type
682            let idx = self.rng.random_range(0..event_types.len());
683            let event_type = event_types[idx];
684
685            let (old_val, new_val) = match event_type {
686                EmployeeEventType::Promoted => {
687                    let old = format!("{:?}", employee.job_level);
688                    let new = format!("{:?}_promoted", employee.job_level);
689                    (Some(old), Some(new))
690                }
691                EmployeeEventType::SalaryAdjustment => {
692                    let pct = self.rng.random_range(2u32..=15);
693                    let old = employee.base_salary.to_string();
694                    let new_salary =
695                        employee.base_salary * rust_decimal::Decimal::new(100 + pct as i64, 2);
696                    (Some(old), Some(new_salary.round_dp(2).to_string()))
697                }
698                EmployeeEventType::Transfer => {
699                    let old = employee
700                        .department_id
701                        .clone()
702                        .unwrap_or_else(|| "unknown".to_string());
703                    let new = format!("{}_new", old);
704                    (Some(old), Some(new))
705                }
706                _ => (None, None),
707            };
708
709            events.push(EmployeeChangeEvent {
710                employee_id: employee.employee_id.clone(),
711                event_date,
712                event_type,
713                old_value: old_val,
714                new_value: new_val,
715                effective_date: event_date,
716            });
717        }
718
719        // 3. If terminated: add Terminated event
720        if employee.status == EmployeeStatus::Terminated {
721            if let Some(term_date) = employee.termination_date {
722                let term_capped = term_date.min(period_end);
723                events.push(EmployeeChangeEvent {
724                    employee_id: employee.employee_id.clone(),
725                    event_date: term_capped,
726                    event_type: EmployeeEventType::Terminated,
727                    old_value: Some("active".to_string()),
728                    new_value: Some("terminated".to_string()),
729                    effective_date: term_capped,
730                });
731            }
732        }
733
734        events
735    }
736
737    /// Generate change history for all employees in a pool.
738    pub fn generate_all_change_history(
739        &mut self,
740        pool: &EmployeePool,
741        period_end: NaiveDate,
742    ) -> Vec<EmployeeChangeEvent> {
743        pool.employees
744            .iter()
745            .flat_map(|e| self.generate_change_history(e, period_end))
746            .collect()
747    }
748
749    /// Reset the generator.
750    pub fn reset(&mut self) {
751        self.rng = seeded_rng(self.seed, 0);
752        self.employee_counter = 0;
753    }
754}
755
756#[cfg(test)]
757#[allow(clippy::unwrap_used)]
758mod tests {
759    use super::*;
760
761    #[test]
762    fn test_employee_generation() {
763        let mut gen = EmployeeGenerator::new(42);
764        let dept = DepartmentDefinition::finance("1000");
765        let employee =
766            gen.generate_employee("1000", &dept, NaiveDate::from_ymd_opt(2024, 1, 1).unwrap());
767
768        assert!(!employee.employee_id.is_empty());
769        assert!(!employee.display_name.is_empty());
770        assert!(!employee.email.is_empty());
771        assert!(employee.approval_limit > Decimal::ZERO);
772    }
773
774    #[test]
775    fn test_department_pool() {
776        let mut gen = EmployeeGenerator::new(42);
777        let dept = DepartmentDefinition::finance("1000");
778        let pool = gen.generate_department_pool(
779            "1000",
780            &dept,
781            (
782                NaiveDate::from_ymd_opt(2020, 1, 1).unwrap(),
783                NaiveDate::from_ymd_opt(2024, 1, 1).unwrap(),
784            ),
785        );
786
787        assert_eq!(pool.employees.len(), dept.headcount);
788
789        // Should have at least one manager
790        let managers: Vec<_> = pool
791            .employees
792            .iter()
793            .filter(|e| matches!(e.job_level, JobLevel::Manager | JobLevel::Director))
794            .collect();
795        assert!(!managers.is_empty());
796
797        // Department head should have direct reports
798        let dept_head = managers.first().unwrap();
799        assert!(!dept_head.direct_reports.is_empty());
800    }
801
802    #[test]
803    fn test_company_pool() {
804        let mut gen = EmployeeGenerator::new(42);
805        let pool = gen.generate_company_pool(
806            "1000",
807            (
808                NaiveDate::from_ymd_opt(2020, 1, 1).unwrap(),
809                NaiveDate::from_ymd_opt(2024, 1, 1).unwrap(),
810            ),
811        );
812
813        // Should have executives
814        let executives: Vec<_> = pool
815            .employees
816            .iter()
817            .filter(|e| e.job_level == JobLevel::Executive)
818            .collect();
819        assert!(executives.len() >= 3); // CEO, CFO, COO
820
821        // Executives should have direct reports
822        let cfo = pool.employees.iter().find(|e| e.job_title == "CFO");
823        assert!(cfo.is_some());
824    }
825
826    #[test]
827    fn test_hierarchy() {
828        let mut gen = EmployeeGenerator::new(42);
829        let pool = gen.generate_company_pool(
830            "1000",
831            (
832                NaiveDate::from_ymd_opt(2020, 1, 1).unwrap(),
833                NaiveDate::from_ymd_opt(2024, 1, 1).unwrap(),
834            ),
835        );
836
837        // Every non-CEO employee should have a manager
838        let non_ceo_without_manager: Vec<_> = pool
839            .employees
840            .iter()
841            .filter(|e| e.job_title != "CEO")
842            .filter(|e| e.manager_id.is_none())
843            .collect();
844
845        // Most employees should have managers (some edge cases may exist)
846        assert!(non_ceo_without_manager.len() <= 1);
847    }
848
849    #[test]
850    fn test_deterministic_generation() {
851        let mut gen1 = EmployeeGenerator::new(42);
852        let mut gen2 = EmployeeGenerator::new(42);
853
854        let dept = DepartmentDefinition::finance("1000");
855        let employee1 =
856            gen1.generate_employee("1000", &dept, NaiveDate::from_ymd_opt(2024, 1, 1).unwrap());
857        let employee2 =
858            gen2.generate_employee("1000", &dept, NaiveDate::from_ymd_opt(2024, 1, 1).unwrap());
859
860        assert_eq!(employee1.employee_id, employee2.employee_id);
861        assert_eq!(employee1.display_name, employee2.display_name);
862    }
863
864    #[test]
865    fn test_approval_limits() {
866        let mut gen = EmployeeGenerator::new(42);
867        let dept = DepartmentDefinition::finance("1000");
868
869        let staff = gen.generate_employee_with_level(
870            "1000",
871            &dept,
872            JobLevel::Staff,
873            NaiveDate::from_ymd_opt(2024, 1, 1).unwrap(),
874        );
875        let manager = gen.generate_employee_with_level(
876            "1000",
877            &dept,
878            JobLevel::Manager,
879            NaiveDate::from_ymd_opt(2024, 1, 1).unwrap(),
880        );
881
882        assert!(manager.approval_limit > staff.approval_limit);
883        assert!(!staff.can_approve_pr);
884        assert!(manager.can_approve_pr);
885    }
886
887    #[test]
888    fn test_country_pack_does_not_break_generation() {
889        let mut gen = EmployeeGenerator::new(42);
890        // Setting a default country pack should not alter basic generation behaviour.
891        gen.set_country_pack(datasynth_core::CountryPack::default());
892
893        let dept = DepartmentDefinition::finance("1000");
894        let employee =
895            gen.generate_employee("1000", &dept, NaiveDate::from_ymd_opt(2024, 1, 1).unwrap());
896
897        assert!(!employee.employee_id.is_empty());
898        assert!(!employee.display_name.is_empty());
899        assert!(!employee.email.is_empty());
900        assert!(employee.approval_limit > Decimal::ZERO);
901    }
902}