datasynth_generators/project_accounting/
project_generator.rs1use 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
15pub struct ProjectGenerator {
17 rng: ChaCha8Rng,
18 uuid_factory: DeterministicUuidFactory,
19 config: ProjectAccountingConfig,
20}
21
22impl ProjectGenerator {
23 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 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 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 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 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 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 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 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 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 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}