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