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