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        // v5.9.0: align Employee.user_id with UserPool's user_id format so a
269        // UserPool built from this employee pool produces user_ids that join
270        // back to `employees.user_id`.  Previously this was `u{:06}` which
271        // was disjoint from the username-style ids JE.created_by used.
272        let user_id = name.to_user_id(self.employee_counter);
273        let email = self.name_generator.generate_email(&name);
274
275        let job_level = self.select_job_level();
276        let approval_limit = self.get_approval_limit(&job_level);
277
278        let mut employee = Employee::new(
279            employee_id,
280            user_id,
281            name.first_name.clone(),
282            name.last_name.clone(),
283            company_code.to_string(),
284        );
285
286        // Set additional fields
287        employee.email = email;
288        employee.job_level = job_level;
289
290        // Set department info
291        employee.department_id = Some(department.name.clone());
292        employee.cost_center = Some(department.cost_center.clone());
293
294        // Set dates
295        employee.hire_date = Some(hire_date);
296
297        // Set approval limits based on job level
298        employee.approval_limit = approval_limit;
299        employee.can_approve_pr = matches!(
300            job_level,
301            JobLevel::Manager | JobLevel::Director | JobLevel::VicePresident | JobLevel::Executive
302        );
303        employee.can_approve_po = matches!(
304            job_level,
305            JobLevel::Senior
306                | JobLevel::Manager
307                | JobLevel::Director
308                | JobLevel::VicePresident
309                | JobLevel::Executive
310        );
311        employee.can_approve_je = matches!(
312            job_level,
313            JobLevel::Manager | JobLevel::Director | JobLevel::VicePresident | JobLevel::Executive
314        );
315
316        // Assign system roles
317        if !department.system_roles.is_empty() {
318            let role_idx = self.rng.random_range(0..department.system_roles.len());
319            employee
320                .system_roles
321                .push(department.system_roles[role_idx].clone());
322        }
323
324        // Assign transaction codes
325        for tcode in &department.transaction_codes {
326            employee.transaction_codes.push(TransactionCodeAuth {
327                tcode: tcode.clone(),
328                activity: datasynth_core::models::ActivityType::Create,
329                active: true,
330            });
331        }
332
333        // Set annual base salary based on job level (USD-equivalent ranges).
334        // A small random variance (±10 %) is applied for realism.
335        let (salary_min, salary_max): (u64, u64) = match job_level {
336            JobLevel::Staff => (40_000, 60_000),
337            JobLevel::Senior => (60_000, 90_000),
338            JobLevel::Lead => (75_000, 105_000),
339            JobLevel::Supervisor => (70_000, 100_000),
340            JobLevel::Manager => (80_000, 120_000),
341            JobLevel::Director => (100_000, 160_000),
342            JobLevel::VicePresident => (130_000, 200_000),
343            JobLevel::Executive => (150_000, 250_000),
344        };
345        let salary_range = salary_max - salary_min;
346        let salary_raw =
347            salary_min + (self.rng.random::<f64>() * salary_range as f64).round() as u64;
348        employee.base_salary = Decimal::from(salary_raw);
349
350        // Set status
351        employee.status = self.select_status();
352        if employee.status == EmployeeStatus::Terminated {
353            employee.termination_date =
354                Some(hire_date + chrono::Duration::days(self.rng.random_range(365..1825) as i64));
355        }
356
357        employee
358    }
359
360    /// Generate an employee with specific job level.
361    pub fn generate_employee_with_level(
362        &mut self,
363        company_code: &str,
364        department: &DepartmentDefinition,
365        job_level: JobLevel,
366        hire_date: NaiveDate,
367    ) -> Employee {
368        let mut employee = self.generate_employee(company_code, department, hire_date);
369        employee.job_level = job_level;
370        employee.approval_limit = self.get_approval_limit(&job_level);
371        employee.can_approve_pr = matches!(
372            job_level,
373            JobLevel::Manager | JobLevel::Director | JobLevel::VicePresident | JobLevel::Executive
374        );
375        employee.can_approve_po = matches!(
376            job_level,
377            JobLevel::Senior
378                | JobLevel::Manager
379                | JobLevel::Director
380                | JobLevel::VicePresident
381                | JobLevel::Executive
382        );
383        employee.can_approve_je = matches!(
384            job_level,
385            JobLevel::Manager | JobLevel::Director | JobLevel::VicePresident | JobLevel::Executive
386        );
387        employee
388    }
389
390    /// Generate an employee pool for a department.
391    pub fn generate_department_pool(
392        &mut self,
393        company_code: &str,
394        department: &DepartmentDefinition,
395        hire_date_range: (NaiveDate, NaiveDate),
396    ) -> EmployeePool {
397        let mut pool = EmployeePool::new();
398
399        let (start_date, end_date) = hire_date_range;
400        let days_range = (end_date - start_date).num_days() as u64;
401
402        // Generate department head (Director or Manager)
403        let head_level = if department.headcount >= 15 {
404            JobLevel::Director
405        } else {
406            JobLevel::Manager
407        };
408        let hire_date =
409            start_date + chrono::Duration::days(self.rng.random_range(0..=days_range / 2) as i64);
410        let dept_head =
411            self.generate_employee_with_level(company_code, department, head_level, hire_date);
412        let dept_head_id = dept_head.employee_id.clone();
413        pool.add_employee(dept_head);
414
415        // Generate remaining employees
416        for _ in 1..department.headcount {
417            let hire_date =
418                start_date + chrono::Duration::days(self.rng.random_range(0..=days_range) as i64);
419            let mut employee = self.generate_employee(company_code, department, hire_date);
420
421            // Assign manager (department head)
422            employee.manager_id = Some(dept_head_id.clone());
423
424            pool.add_employee(employee);
425        }
426
427        // Collect direct reports first to avoid borrow conflict
428        let direct_reports: Vec<String> = pool
429            .employees
430            .iter()
431            .filter(|e| e.manager_id.as_ref() == Some(&dept_head_id))
432            .map(|e| e.employee_id.clone())
433            .collect();
434
435        // Update direct reports for department head
436        if let Some(head) = pool
437            .employees
438            .iter_mut()
439            .find(|e| e.employee_id == dept_head_id)
440        {
441            head.direct_reports = direct_reports;
442        }
443
444        pool
445    }
446
447    /// Generate a full company employee pool with hierarchy.
448    pub fn generate_company_pool(
449        &mut self,
450        company_code: &str,
451        hire_date_range: (NaiveDate, NaiveDate),
452    ) -> EmployeePool {
453        debug!(company_code, "Generating employee company pool");
454        let mut pool = EmployeePool::new();
455
456        let (start_date, end_date) = hire_date_range;
457        let _days_range = (end_date - start_date).num_days() as u64;
458
459        // First, generate C-level executives
460        let ceo = self.generate_executive(company_code, "CEO", start_date);
461        let ceo_id = ceo.employee_id.clone();
462        pool.add_employee(ceo);
463
464        let cfo = self.generate_executive(company_code, "CFO", start_date);
465        let cfo_id = cfo.employee_id.clone();
466        pool.employees
467            .last_mut()
468            .expect("just added CEO")
469            .manager_id = Some(ceo_id.clone());
470        pool.add_employee(cfo);
471
472        let coo = self.generate_executive(company_code, "COO", start_date);
473        let coo_id = coo.employee_id.clone();
474        pool.employees
475            .last_mut()
476            .expect("just added CFO")
477            .manager_id = Some(ceo_id.clone());
478        pool.add_employee(coo);
479
480        // Generate department pools.
481        //
482        // v3.2.1+: department display names (e.g. "Finance",
483        // "Procurement") can come from the template provider. We map
484        // each factory-produced DepartmentDefinition through
485        // `resolve_department_name` so user-supplied names replace the
486        // embedded English strings where specified.
487        let mut departments = DepartmentDefinition::standard_departments(company_code);
488        for dept in departments.iter_mut() {
489            // Department code has the shape `{COMPANY}-{FIN|PROC|SALES|WH|IT}`.
490            // Extract the suffix and lowercase it to match the template key.
491            let code_suffix = dept.code.rsplit('-').next().unwrap_or("");
492            let key = match code_suffix {
493                "FIN" => "finance",
494                "PROC" => "procurement",
495                "SALES" => "sales",
496                "WH" => "warehouse",
497                "IT" => "it",
498                _ => "",
499            };
500            if !key.is_empty() {
501                let resolved = self.resolve_department_name(key, &dept.name);
502                dept.name = resolved;
503            }
504        }
505        let departments = departments;
506
507        for dept in &departments {
508            let dept_pool = self.generate_department_pool(company_code, dept, hire_date_range);
509
510            // Assign department heads to executives
511            //
512            // v3.2.1: match on the code suffix (`*-FIN`) instead of the
513            // display name so user-supplied department names (e.g.
514            // German "Finanzen") don't break the CFO/COO routing.
515            let is_finance = dept.code.ends_with("-FIN");
516            for mut employee in dept_pool.employees {
517                if employee.manager_id.is_none() {
518                    employee.manager_id = if is_finance {
519                        Some(cfo_id.clone())
520                    } else {
521                        Some(coo_id.clone())
522                    };
523                }
524                pool.add_employee(employee);
525            }
526        }
527
528        // Update executive direct reports
529        self.update_direct_reports(&mut pool);
530
531        pool
532    }
533
534    /// Generate an executive employee.
535    fn generate_executive(
536        &mut self,
537        company_code: &str,
538        title: &str,
539        hire_date: NaiveDate,
540    ) -> Employee {
541        self.employee_counter += 1;
542
543        let name = self.name_generator.generate_name(&mut self.rng);
544        let employee_id = format!("EMP-{}-{:06}", company_code, self.employee_counter);
545        // v5.9.0: see `generate_employee` for the format change rationale.
546        let user_id = name.to_user_id(self.employee_counter);
547        let email = self.name_generator.generate_email(&name);
548
549        let mut employee = Employee::new(
550            employee_id,
551            user_id,
552            name.first_name.clone(),
553            name.last_name.clone(),
554            company_code.to_string(),
555        );
556
557        employee.email = email;
558        employee.job_level = JobLevel::Executive;
559        employee.job_title = title.to_string();
560        employee.department_id = Some("Executive".to_string());
561        employee.cost_center = Some(format!("CC-{company_code}-EXEC"));
562        employee.hire_date = Some(hire_date);
563        employee.approval_limit = Decimal::from(100_000_000);
564        employee.can_approve_pr = true;
565        employee.can_approve_po = true;
566        employee.can_approve_je = true;
567        employee.system_roles.push(SystemRole::Executive);
568
569        employee
570    }
571
572    /// Update direct reports for all managers.
573    fn update_direct_reports(&self, pool: &mut EmployeePool) {
574        // Collect manager -> direct reports mapping
575        let mut direct_reports_map: std::collections::HashMap<String, Vec<String>> =
576            std::collections::HashMap::new();
577
578        for employee in &pool.employees {
579            if let Some(manager_id) = &employee.manager_id {
580                direct_reports_map
581                    .entry(manager_id.clone())
582                    .or_default()
583                    .push(employee.employee_id.clone());
584            }
585        }
586
587        // Update each manager's direct reports
588        for employee in &mut pool.employees {
589            if let Some(reports) = direct_reports_map.get(&employee.employee_id) {
590                employee.direct_reports = reports.clone();
591            }
592        }
593    }
594
595    /// Select job level based on distribution.
596    fn select_job_level(&mut self) -> JobLevel {
597        let roll: f64 = self.rng.random();
598        let mut cumulative = 0.0;
599
600        for (level, prob) in &self.config.job_level_distribution {
601            cumulative += prob;
602            if roll < cumulative {
603                return *level;
604            }
605        }
606
607        JobLevel::Staff
608    }
609
610    /// Get approval limit for job level.
611    fn get_approval_limit(&self, job_level: &JobLevel) -> Decimal {
612        for (level, limit) in &self.config.approval_limits {
613            if level == job_level {
614                return *limit;
615            }
616        }
617        Decimal::from(1_000)
618    }
619
620    /// Select employee status.
621    fn select_status(&mut self) -> EmployeeStatus {
622        let roll: f64 = self.rng.random();
623
624        if roll < self.config.termination_rate {
625            EmployeeStatus::Terminated
626        } else if roll < self.config.termination_rate + self.config.leave_rate {
627            EmployeeStatus::OnLeave
628        } else {
629            EmployeeStatus::Active
630        }
631    }
632
633    /// Generate 2-5 change history events for a single employee.
634    ///
635    /// Always starts with a `Hired` event on `hire_date`.  Up to 4 subsequent
636    /// events (promotions, salary adjustments, or transfers) are spread
637    /// uniformly over the employee's tenure, ending with a `Terminated` event
638    /// for terminated employees.
639    pub fn generate_change_history(
640        &mut self,
641        employee: &Employee,
642        period_end: NaiveDate,
643    ) -> Vec<EmployeeChangeEvent> {
644        let hire_date = match employee.hire_date {
645            Some(d) => d,
646            None => return Vec::new(),
647        };
648
649        let mut events: Vec<EmployeeChangeEvent> = Vec::with_capacity(5);
650
651        // 1. Always: Hired event
652        events.push(EmployeeChangeEvent::hired(
653            employee.employee_id.clone(),
654            hire_date,
655        ));
656
657        // Tenure in days (capped at period_end or termination date)
658        let tenure_end = employee
659            .termination_date
660            .unwrap_or(period_end)
661            .min(period_end);
662        let tenure_days = (tenure_end - hire_date).num_days().max(1);
663
664        // Skip change history for very short tenures (< 60 days)
665        if tenure_days < 60 {
666            return events;
667        }
668
669        // 2. Generate 1-4 additional events
670        let additional_count = self.rng.random_range(1u32..=4);
671        // Build sorted random offsets within tenure (days from hire)
672        let mut offsets: Vec<i64> = (0..additional_count)
673            .map(|_| self.rng.random_range(30i64..tenure_days))
674            .collect();
675        offsets.sort_unstable();
676
677        let event_types = [
678            EmployeeEventType::Promoted,
679            EmployeeEventType::SalaryAdjustment,
680            EmployeeEventType::Transfer,
681        ];
682
683        for offset in offsets {
684            let event_date = hire_date + chrono::Duration::days(offset);
685
686            // Pick a random non-termination event type
687            let idx = self.rng.random_range(0..event_types.len());
688            let event_type = event_types[idx];
689
690            let (old_val, new_val) = match event_type {
691                EmployeeEventType::Promoted => {
692                    let old = format!("{:?}", employee.job_level);
693                    let new = format!("{:?}_promoted", employee.job_level);
694                    (Some(old), Some(new))
695                }
696                EmployeeEventType::SalaryAdjustment => {
697                    let pct = self.rng.random_range(2u32..=15);
698                    let old = employee.base_salary.to_string();
699                    let new_salary =
700                        employee.base_salary * rust_decimal::Decimal::new(100 + pct as i64, 2);
701                    (Some(old), Some(new_salary.round_dp(2).to_string()))
702                }
703                EmployeeEventType::Transfer => {
704                    let old = employee
705                        .department_id
706                        .clone()
707                        .unwrap_or_else(|| "unknown".to_string());
708                    let new = format!("{}_new", old);
709                    (Some(old), Some(new))
710                }
711                _ => (None, None),
712            };
713
714            events.push(EmployeeChangeEvent {
715                employee_id: employee.employee_id.clone(),
716                event_date,
717                event_type,
718                old_value: old_val,
719                new_value: new_val,
720                effective_date: event_date,
721            });
722        }
723
724        // 3. If terminated: add Terminated event
725        if employee.status == EmployeeStatus::Terminated {
726            if let Some(term_date) = employee.termination_date {
727                let term_capped = term_date.min(period_end);
728                events.push(EmployeeChangeEvent {
729                    employee_id: employee.employee_id.clone(),
730                    event_date: term_capped,
731                    event_type: EmployeeEventType::Terminated,
732                    old_value: Some("active".to_string()),
733                    new_value: Some("terminated".to_string()),
734                    effective_date: term_capped,
735                });
736            }
737        }
738
739        events
740    }
741
742    /// Generate change history for all employees in a pool.
743    pub fn generate_all_change_history(
744        &mut self,
745        pool: &EmployeePool,
746        period_end: NaiveDate,
747    ) -> Vec<EmployeeChangeEvent> {
748        pool.employees
749            .iter()
750            .flat_map(|e| self.generate_change_history(e, period_end))
751            .collect()
752    }
753
754    /// Reset the generator.
755    pub fn reset(&mut self) {
756        self.rng = seeded_rng(self.seed, 0);
757        self.employee_counter = 0;
758    }
759}
760
761#[cfg(test)]
762mod tests {
763    use super::*;
764
765    #[test]
766    fn test_employee_generation() {
767        let mut gen = EmployeeGenerator::new(42);
768        let dept = DepartmentDefinition::finance("1000");
769        let employee =
770            gen.generate_employee("1000", &dept, NaiveDate::from_ymd_opt(2024, 1, 1).unwrap());
771
772        assert!(!employee.employee_id.is_empty());
773        assert!(!employee.display_name.is_empty());
774        assert!(!employee.email.is_empty());
775        assert!(employee.approval_limit > Decimal::ZERO);
776    }
777
778    #[test]
779    fn test_department_pool() {
780        let mut gen = EmployeeGenerator::new(42);
781        let dept = DepartmentDefinition::finance("1000");
782        let pool = gen.generate_department_pool(
783            "1000",
784            &dept,
785            (
786                NaiveDate::from_ymd_opt(2020, 1, 1).unwrap(),
787                NaiveDate::from_ymd_opt(2024, 1, 1).unwrap(),
788            ),
789        );
790
791        assert_eq!(pool.employees.len(), dept.headcount);
792
793        // Should have at least one manager
794        let managers: Vec<_> = pool
795            .employees
796            .iter()
797            .filter(|e| matches!(e.job_level, JobLevel::Manager | JobLevel::Director))
798            .collect();
799        assert!(!managers.is_empty());
800
801        // Department head should have direct reports
802        let dept_head = managers.first().unwrap();
803        assert!(!dept_head.direct_reports.is_empty());
804    }
805
806    #[test]
807    fn test_company_pool() {
808        let mut gen = EmployeeGenerator::new(42);
809        let pool = gen.generate_company_pool(
810            "1000",
811            (
812                NaiveDate::from_ymd_opt(2020, 1, 1).unwrap(),
813                NaiveDate::from_ymd_opt(2024, 1, 1).unwrap(),
814            ),
815        );
816
817        // Should have executives
818        let executives: Vec<_> = pool
819            .employees
820            .iter()
821            .filter(|e| e.job_level == JobLevel::Executive)
822            .collect();
823        assert!(executives.len() >= 3); // CEO, CFO, COO
824
825        // Executives should have direct reports
826        let cfo = pool.employees.iter().find(|e| e.job_title == "CFO");
827        assert!(cfo.is_some());
828    }
829
830    #[test]
831    fn test_hierarchy() {
832        let mut gen = EmployeeGenerator::new(42);
833        let pool = gen.generate_company_pool(
834            "1000",
835            (
836                NaiveDate::from_ymd_opt(2020, 1, 1).unwrap(),
837                NaiveDate::from_ymd_opt(2024, 1, 1).unwrap(),
838            ),
839        );
840
841        // Every non-CEO employee should have a manager
842        let non_ceo_without_manager: Vec<_> = pool
843            .employees
844            .iter()
845            .filter(|e| e.job_title != "CEO")
846            .filter(|e| e.manager_id.is_none())
847            .collect();
848
849        // Most employees should have managers (some edge cases may exist)
850        assert!(non_ceo_without_manager.len() <= 1);
851    }
852
853    #[test]
854    fn test_deterministic_generation() {
855        let mut gen1 = EmployeeGenerator::new(42);
856        let mut gen2 = EmployeeGenerator::new(42);
857
858        let dept = DepartmentDefinition::finance("1000");
859        let employee1 =
860            gen1.generate_employee("1000", &dept, NaiveDate::from_ymd_opt(2024, 1, 1).unwrap());
861        let employee2 =
862            gen2.generate_employee("1000", &dept, NaiveDate::from_ymd_opt(2024, 1, 1).unwrap());
863
864        assert_eq!(employee1.employee_id, employee2.employee_id);
865        assert_eq!(employee1.display_name, employee2.display_name);
866    }
867
868    #[test]
869    fn test_approval_limits() {
870        let mut gen = EmployeeGenerator::new(42);
871        let dept = DepartmentDefinition::finance("1000");
872
873        let staff = gen.generate_employee_with_level(
874            "1000",
875            &dept,
876            JobLevel::Staff,
877            NaiveDate::from_ymd_opt(2024, 1, 1).unwrap(),
878        );
879        let manager = gen.generate_employee_with_level(
880            "1000",
881            &dept,
882            JobLevel::Manager,
883            NaiveDate::from_ymd_opt(2024, 1, 1).unwrap(),
884        );
885
886        assert!(manager.approval_limit > staff.approval_limit);
887        assert!(!staff.can_approve_pr);
888        assert!(manager.can_approve_pr);
889    }
890
891    #[test]
892    fn test_country_pack_does_not_break_generation() {
893        let mut gen = EmployeeGenerator::new(42);
894        // Setting a default country pack should not alter basic generation behaviour.
895        gen.set_country_pack(datasynth_core::CountryPack::default());
896
897        let dept = DepartmentDefinition::finance("1000");
898        let employee =
899            gen.generate_employee("1000", &dept, NaiveDate::from_ymd_opt(2024, 1, 1).unwrap());
900
901        assert!(!employee.employee_id.is_empty());
902        assert!(!employee.display_name.is_empty());
903        assert!(!employee.email.is_empty());
904        assert!(employee.approval_limit > Decimal::ZERO);
905    }
906}