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