Skip to main content

datasynth_generators/project_accounting/
project_generator.rs

1//! Project and WBS hierarchy generator.
2//!
3//! Creates [`Project`] records with [`WbsElement`] hierarchies based on
4//! [`ProjectAccountingConfig`] settings, distributing project types according
5//! to configured weights.
6use chrono::NaiveDate;
7use datasynth_config::schema::{ProjectAccountingConfig, WbsSchemaConfig};
8use datasynth_core::models::{Project, ProjectPool, ProjectStatus, ProjectType, WbsElement};
9use datasynth_core::utils::seeded_rng;
10use rand::prelude::*;
11use rand_chacha::ChaCha8Rng;
12use rust_decimal::Decimal;
13
14/// Generates [`Project`] records with WBS hierarchies.
15pub struct ProjectGenerator {
16    rng: ChaCha8Rng,
17    config: ProjectAccountingConfig,
18}
19
20impl ProjectGenerator {
21    /// Create a new project generator.
22    pub fn new(config: ProjectAccountingConfig, seed: u64) -> Self {
23        Self {
24            rng: seeded_rng(seed, 0),
25            config,
26        }
27    }
28
29    /// Generate a pool of projects with WBS hierarchies.
30    pub fn generate(
31        &mut self,
32        company_code: &str,
33        start_date: NaiveDate,
34        end_date: NaiveDate,
35    ) -> ProjectPool {
36        let count = self.config.project_count as usize;
37        let mut pool = ProjectPool::new();
38
39        for i in 0..count {
40            let project_type = self.pick_project_type();
41            let project_id = format!("PRJ-{:04}", i + 1);
42            let budget = self.generate_budget(project_type);
43
44            let mut project = Project::new(
45                &project_id,
46                &self.project_name(project_type, i),
47                project_type,
48            )
49            .with_budget(budget)
50            .with_company(company_code);
51
52            project.start_date = Some(start_date.to_string());
53            project.end_date = Some(end_date.to_string());
54            project.status = self.pick_status();
55            project.description = self.project_description(project_type);
56            project.responsible_cost_center =
57                format!("{:04}", self.rng.random_range(1000..9999u32));
58
59            // Generate WBS hierarchy
60            let wbs_elements = self.generate_wbs(&project_id, budget, &self.config.wbs.clone());
61            for wbs in wbs_elements {
62                project.add_wbs_element(wbs);
63            }
64
65            pool.add_project(project);
66        }
67
68        pool
69    }
70
71    /// Pick a project type based on distribution weights.
72    fn pick_project_type(&mut self) -> ProjectType {
73        let dist = &self.config.project_types;
74        let total = dist.capital
75            + dist.internal
76            + dist.customer
77            + dist.r_and_d
78            + dist.maintenance
79            + dist.technology;
80
81        let roll: f64 = self.rng.random::<f64>() * total;
82        let mut cumulative = 0.0;
83
84        let types = [
85            (dist.capital, ProjectType::Capital),
86            (dist.internal, ProjectType::Internal),
87            (dist.customer, ProjectType::Customer),
88            (dist.r_and_d, ProjectType::RandD),
89            (dist.maintenance, ProjectType::Maintenance),
90            (dist.technology, ProjectType::Technology),
91        ];
92
93        for (weight, pt) in &types {
94            cumulative += weight;
95            if roll < cumulative {
96                return *pt;
97            }
98        }
99
100        ProjectType::Internal
101    }
102
103    /// Pick a project status (most should be Active).
104    fn pick_status(&mut self) -> ProjectStatus {
105        let roll: f64 = self.rng.random::<f64>();
106        if roll < 0.05 {
107            ProjectStatus::Planned
108        } else if roll < 0.80 {
109            ProjectStatus::Active
110        } else if roll < 0.90 {
111            ProjectStatus::Closing
112        } else if roll < 0.95 {
113            ProjectStatus::Completed
114        } else if roll < 0.98 {
115            ProjectStatus::OnHold
116        } else {
117            ProjectStatus::Cancelled
118        }
119    }
120
121    /// Generate a realistic budget based on project type.
122    fn generate_budget(&mut self, project_type: ProjectType) -> Decimal {
123        let (lo, hi) = match project_type {
124            ProjectType::Capital => (500_000.0, 10_000_000.0),
125            ProjectType::Internal => (50_000.0, 500_000.0),
126            ProjectType::Customer => (100_000.0, 5_000_000.0),
127            ProjectType::RandD => (200_000.0, 3_000_000.0),
128            ProjectType::Maintenance => (25_000.0, 300_000.0),
129            ProjectType::Technology => (100_000.0, 2_000_000.0),
130        };
131        let amount = self.rng.random_range(lo..hi);
132        Decimal::from_f64_retain(amount)
133            .unwrap_or(Decimal::from(500_000))
134            .round_dp(2)
135    }
136
137    /// Generate WBS elements for a project.
138    fn generate_wbs(
139        &mut self,
140        project_id: &str,
141        total_budget: Decimal,
142        wbs_config: &WbsSchemaConfig,
143    ) -> Vec<WbsElement> {
144        let mut elements = Vec::new();
145        let top_count = self
146            .rng
147            .random_range(wbs_config.min_elements_per_level..=wbs_config.max_elements_per_level);
148
149        let mut remaining_budget = total_budget;
150
151        for i in 0..top_count {
152            let wbs_id = format!("{}.{:02}", project_id, i + 1);
153            let phase_name = self.phase_name(i);
154
155            // Distribute budget: give equal shares, last element gets remainder
156            let budget = if i == top_count - 1 {
157                remaining_budget
158            } else {
159                let share = (total_budget / Decimal::from(top_count)).round_dp(2);
160                remaining_budget -= share;
161                share
162            };
163
164            let element = WbsElement::new(&wbs_id, project_id, &phase_name).with_budget(budget);
165            elements.push(element);
166
167            // Generate sub-levels if depth > 1
168            if wbs_config.max_depth > 1 {
169                let sub_count = self.rng.random_range(
170                    wbs_config.min_elements_per_level.min(3)
171                        ..=wbs_config.max_elements_per_level.min(4),
172                );
173                let mut sub_remaining = budget;
174
175                for j in 0..sub_count {
176                    let sub_wbs_id = format!("{}.{:03}", wbs_id, j + 1);
177                    let sub_name = format!("{} - Task {}", phase_name, j + 1);
178
179                    let sub_budget = if j == sub_count - 1 {
180                        sub_remaining
181                    } else {
182                        let share = (budget / Decimal::from(sub_count)).round_dp(2);
183                        sub_remaining -= share;
184                        share
185                    };
186
187                    let sub_element = WbsElement::new(&sub_wbs_id, project_id, &sub_name)
188                        .with_parent(&wbs_id, 2)
189                        .with_budget(sub_budget);
190                    elements.push(sub_element);
191                }
192            }
193        }
194
195        elements
196    }
197
198    fn phase_name(&self, index: u32) -> String {
199        let phases = [
200            "Planning & Design",
201            "Procurement",
202            "Implementation",
203            "Testing & Validation",
204            "Deployment",
205            "Closeout",
206        ];
207        phases
208            .get(index as usize)
209            .unwrap_or(&"Additional Work")
210            .to_string()
211    }
212
213    fn project_name(&self, project_type: ProjectType, index: usize) -> String {
214        let names: &[&str] = match project_type {
215            ProjectType::Capital => &[
216                "Data Center Expansion",
217                "Manufacturing Line Upgrade",
218                "Office Renovation",
219                "Fleet Replacement",
220                "Warehouse Automation",
221                "Plant Equipment Overhaul",
222                "New Facility Construction",
223            ],
224            ProjectType::Internal => &[
225                "Process Improvement Initiative",
226                "Employee Training Program",
227                "Quality Certification",
228                "Lean Six Sigma Rollout",
229                "Culture Transformation",
230                "Knowledge Management System",
231            ],
232            ProjectType::Customer => &[
233                "Enterprise ERP Implementation",
234                "Custom Software Build",
235                "Infrastructure Deployment",
236                "System Integration",
237                "Data Migration Project",
238                "Cloud Platform Build",
239            ],
240            ProjectType::RandD => &[
241                "Next-Gen Product Research",
242                "AI/ML Capability Study",
243                "Materials Science Investigation",
244                "Prototype Development",
245                "Emerging Tech Evaluation",
246                "Patent Portfolio Expansion",
247            ],
248            ProjectType::Maintenance => &[
249                "Annual Equipment Maintenance",
250                "HVAC System Overhaul",
251                "Network Infrastructure Refresh",
252                "Building Repairs",
253                "Software Licensing Renewal",
254                "Safety Compliance Update",
255            ],
256            ProjectType::Technology => &[
257                "ERP System Implementation",
258                "Cloud Migration",
259                "Cybersecurity Enhancement",
260                "Digital Transformation",
261                "IT Infrastructure Upgrade",
262                "Enterprise Data Platform",
263            ],
264        };
265        let name = names[index % names.len()];
266        if index < names.len() {
267            name.to_string()
268        } else {
269            format!("{} Phase {}", name, index / names.len() + 1)
270        }
271    }
272
273    fn project_description(&mut self, project_type: ProjectType) -> String {
274        match project_type {
275            ProjectType::Capital => {
276                "Capital expenditure project for asset acquisition or improvement.".to_string()
277            }
278            ProjectType::Internal => "Internal project for operational improvement.".to_string(),
279            ProjectType::Customer => {
280                "Customer-facing project with contracted deliverables.".to_string()
281            }
282            ProjectType::RandD => "Research and development initiative.".to_string(),
283            ProjectType::Maintenance => "Maintenance and sustainment activities.".to_string(),
284            ProjectType::Technology => "Technology infrastructure or platform project.".to_string(),
285        }
286    }
287}
288
289#[cfg(test)]
290#[allow(clippy::unwrap_used)]
291mod tests {
292    use super::*;
293
294    fn d(s: &str) -> NaiveDate {
295        NaiveDate::parse_from_str(s, "%Y-%m-%d").unwrap()
296    }
297
298    #[test]
299    fn test_generate_projects_default_config() {
300        let mut config = ProjectAccountingConfig::default();
301        config.enabled = true;
302        config.project_count = 10;
303
304        let mut gen = ProjectGenerator::new(config, 42);
305        let pool = gen.generate("TEST", d("2024-01-01"), d("2024-12-31"));
306
307        assert_eq!(pool.projects.len(), 10);
308        for project in &pool.projects {
309            assert!(
310                !project.wbs_elements.is_empty(),
311                "Each project should have WBS elements"
312            );
313            assert!(project.budget > Decimal::ZERO, "Budget should be positive");
314            assert_eq!(project.company_code, "TEST");
315        }
316    }
317
318    #[test]
319    fn test_project_type_distribution() {
320        let mut config = ProjectAccountingConfig::default();
321        config.enabled = true;
322        config.project_count = 100;
323
324        let mut gen = ProjectGenerator::new(config, 42);
325        let pool = gen.generate("TEST", d("2024-01-01"), d("2024-12-31"));
326
327        let customer_count = pool
328            .projects
329            .iter()
330            .filter(|p| p.project_type == ProjectType::Customer)
331            .count();
332
333        // With 0.30 weight for customer, expect roughly 30 out of 100
334        assert!(
335            customer_count >= 15 && customer_count <= 50,
336            "Expected ~30 customer projects, got {}",
337            customer_count
338        );
339    }
340
341    #[test]
342    fn test_wbs_hierarchy_depth() {
343        let mut config = ProjectAccountingConfig::default();
344        config.enabled = true;
345        config.project_count = 5;
346        config.wbs.max_depth = 2;
347
348        let mut gen = ProjectGenerator::new(config, 42);
349        let pool = gen.generate("TEST", d("2024-01-01"), d("2024-12-31"));
350
351        for project in &pool.projects {
352            let has_children = project.wbs_elements.iter().any(|w| w.parent_wbs.is_some());
353            assert!(
354                has_children,
355                "WBS should have child elements when max_depth > 1"
356            );
357        }
358    }
359
360    #[test]
361    fn test_deterministic_generation() {
362        let config = ProjectAccountingConfig::default();
363        let mut gen1 = ProjectGenerator::new(config.clone(), 42);
364        let pool1 = gen1.generate("TEST", d("2024-01-01"), d("2024-12-31"));
365
366        let mut gen2 = ProjectGenerator::new(config, 42);
367        let pool2 = gen2.generate("TEST", d("2024-01-01"), d("2024-12-31"));
368
369        assert_eq!(pool1.projects.len(), pool2.projects.len());
370        for (p1, p2) in pool1.projects.iter().zip(pool2.projects.iter()) {
371            assert_eq!(p1.project_id, p2.project_id);
372            assert_eq!(p1.project_type, p2.project_type);
373            assert_eq!(p1.budget, p2.budget);
374            assert_eq!(p1.wbs_elements.len(), p2.wbs_elements.len());
375        }
376    }
377}