datasynth_generators/project_accounting/
project_generator.rs1use 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
14pub struct ProjectGenerator {
16 rng: ChaCha8Rng,
17 config: ProjectAccountingConfig,
18}
19
20impl ProjectGenerator {
21 pub fn new(config: ProjectAccountingConfig, seed: u64) -> Self {
23 Self {
24 rng: seeded_rng(seed, 0),
25 config,
26 }
27 }
28
29 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 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 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 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 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 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 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 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 config = ProjectAccountingConfig {
301 enabled: true,
302 project_count: 10,
303 ..Default::default()
304 };
305
306 let mut gen = ProjectGenerator::new(config, 42);
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 config = ProjectAccountingConfig {
323 enabled: true,
324 project_count: 100,
325 ..Default::default()
326 };
327
328 let mut gen = ProjectGenerator::new(config, 42);
329 let pool = gen.generate("TEST", d("2024-01-01"), d("2024-12-31"));
330
331 let customer_count = pool
332 .projects
333 .iter()
334 .filter(|p| p.project_type == ProjectType::Customer)
335 .count();
336
337 assert!(
339 (15..=50).contains(&customer_count),
340 "Expected ~30 customer projects, got {}",
341 customer_count
342 );
343 }
344
345 #[test]
346 fn test_wbs_hierarchy_depth() {
347 let mut config = ProjectAccountingConfig {
348 enabled: true,
349 project_count: 5,
350 ..Default::default()
351 };
352 config.wbs.max_depth = 2;
353
354 let mut gen = ProjectGenerator::new(config, 42);
355 let pool = gen.generate("TEST", d("2024-01-01"), d("2024-12-31"));
356
357 for project in &pool.projects {
358 let has_children = project.wbs_elements.iter().any(|w| w.parent_wbs.is_some());
359 assert!(
360 has_children,
361 "WBS should have child elements when max_depth > 1"
362 );
363 }
364 }
365
366 #[test]
367 fn test_deterministic_generation() {
368 let config = ProjectAccountingConfig::default();
369 let mut gen1 = ProjectGenerator::new(config.clone(), 42);
370 let pool1 = gen1.generate("TEST", d("2024-01-01"), d("2024-12-31"));
371
372 let mut gen2 = ProjectGenerator::new(config, 42);
373 let pool2 = gen2.generate("TEST", d("2024-01-01"), d("2024-12-31"));
374
375 assert_eq!(pool1.projects.len(), pool2.projects.len());
376 for (p1, p2) in pool1.projects.iter().zip(pool2.projects.iter()) {
377 assert_eq!(p1.project_id, p2.project_id);
378 assert_eq!(p1.project_type, p2.project_type);
379 assert_eq!(p1.budget, p2.budget);
380 assert_eq!(p1.wbs_elements.len(), p2.wbs_elements.len());
381 }
382 }
383}