1use rand::seq::SliceRandom;
7use rand::Rng;
8use rust_decimal::Decimal;
9use serde::{Deserialize, Serialize};
10use std::collections::HashMap;
11
12#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize, Default)]
14#[serde(rename_all = "snake_case")]
15pub enum ProjectType {
16 #[default]
18 Capital,
19 Internal,
21 RandD,
23 Customer,
25 Maintenance,
27 Technology,
29}
30
31impl ProjectType {
32 pub fn is_capitalizable(&self) -> bool {
34 matches!(self, Self::Capital | Self::RandD)
35 }
36
37 pub fn typical_account_prefix(&self) -> &'static str {
39 match self {
40 Self::Capital => "1", Self::Internal => "5", Self::RandD => "1", Self::Customer => "4", Self::Maintenance => "5", Self::Technology => "1", }
47 }
48}
49
50#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize, Default)]
52#[serde(rename_all = "snake_case")]
53pub enum ProjectStatus {
54 #[default]
56 Planned,
57 Active,
59 OnHold,
61 Completed,
63 Cancelled,
65 Closing,
67}
68
69impl ProjectStatus {
70 pub fn allows_postings(&self) -> bool {
72 matches!(self, Self::Active | Self::Closing)
73 }
74}
75
76#[derive(Debug, Clone, Serialize, Deserialize)]
78pub struct WbsElement {
79 pub wbs_id: String,
81
82 pub project_id: String,
84
85 pub description: String,
87
88 pub level: u8,
90
91 pub parent_wbs: Option<String>,
93
94 pub budget: Decimal,
96
97 pub actual_costs: Decimal,
99
100 pub is_active: bool,
102
103 pub responsible_cost_center: Option<String>,
105}
106
107impl WbsElement {
108 pub fn new(wbs_id: &str, project_id: &str, description: &str) -> Self {
110 Self {
111 wbs_id: wbs_id.to_string(),
112 project_id: project_id.to_string(),
113 description: description.to_string(),
114 level: 1,
115 parent_wbs: None,
116 budget: Decimal::ZERO,
117 actual_costs: Decimal::ZERO,
118 is_active: true,
119 responsible_cost_center: None,
120 }
121 }
122
123 pub fn with_parent(mut self, parent_wbs: &str, level: u8) -> Self {
125 self.parent_wbs = Some(parent_wbs.to_string());
126 self.level = level;
127 self
128 }
129
130 pub fn with_budget(mut self, budget: Decimal) -> Self {
132 self.budget = budget;
133 self
134 }
135
136 pub fn remaining_budget(&self) -> Decimal {
138 self.budget - self.actual_costs
139 }
140
141 pub fn is_over_budget(&self) -> bool {
143 self.actual_costs > self.budget
144 }
145}
146
147#[derive(Debug, Clone, Serialize, Deserialize)]
149pub struct Project {
150 pub project_id: String,
152
153 pub name: String,
155
156 pub description: String,
158
159 pub project_type: ProjectType,
161
162 pub status: ProjectStatus,
164
165 pub budget: Decimal,
167
168 pub responsible_cost_center: String,
170
171 pub wbs_elements: Vec<WbsElement>,
173
174 pub company_code: String,
176
177 pub start_date: Option<String>,
179
180 pub end_date: Option<String>,
182}
183
184impl Project {
185 pub fn new(project_id: &str, name: &str, project_type: ProjectType) -> Self {
187 Self {
188 project_id: project_id.to_string(),
189 name: name.to_string(),
190 description: String::new(),
191 project_type,
192 status: ProjectStatus::Active,
193 budget: Decimal::ZERO,
194 responsible_cost_center: "1000".to_string(),
195 wbs_elements: Vec::new(),
196 company_code: "1000".to_string(),
197 start_date: None,
198 end_date: None,
199 }
200 }
201
202 pub fn with_budget(mut self, budget: Decimal) -> Self {
204 self.budget = budget;
205 self
206 }
207
208 pub fn with_company(mut self, company_code: &str) -> Self {
210 self.company_code = company_code.to_string();
211 self
212 }
213
214 pub fn add_wbs_element(&mut self, element: WbsElement) {
216 self.wbs_elements.push(element);
217 }
218
219 pub fn active_wbs_elements(&self) -> Vec<&WbsElement> {
221 self.wbs_elements.iter().filter(|w| w.is_active).collect()
222 }
223
224 pub fn allows_postings(&self) -> bool {
226 self.status.allows_postings()
227 }
228
229 pub fn total_actual_costs(&self) -> Decimal {
231 self.wbs_elements.iter().map(|w| w.actual_costs).sum()
232 }
233
234 pub fn is_over_budget(&self) -> bool {
236 self.total_actual_costs() > self.budget
237 }
238}
239
240#[derive(Debug, Clone, Default)]
242pub struct ProjectPool {
243 pub projects: Vec<Project>,
245 type_index: HashMap<ProjectType, Vec<usize>>,
247}
248
249impl ProjectPool {
250 pub fn new() -> Self {
252 Self {
253 projects: Vec::new(),
254 type_index: HashMap::new(),
255 }
256 }
257
258 pub fn add_project(&mut self, project: Project) {
260 let idx = self.projects.len();
261 let project_type = project.project_type;
262 self.projects.push(project);
263 self.type_index.entry(project_type).or_default().push(idx);
264 }
265
266 pub fn random_active_project(&self, rng: &mut impl Rng) -> Option<&Project> {
268 let active: Vec<_> = self
269 .projects
270 .iter()
271 .filter(|p| p.allows_postings())
272 .collect();
273 active.choose(rng).copied()
274 }
275
276 pub fn random_project_of_type(
278 &self,
279 project_type: ProjectType,
280 rng: &mut impl Rng,
281 ) -> Option<&Project> {
282 self.type_index
283 .get(&project_type)
284 .and_then(|indices| indices.choose(rng))
285 .map(|&idx| &self.projects[idx])
286 .filter(|p| p.allows_postings())
287 }
288
289 pub fn rebuild_index(&mut self) {
291 self.type_index.clear();
292 for (idx, project) in self.projects.iter().enumerate() {
293 self.type_index
294 .entry(project.project_type)
295 .or_default()
296 .push(idx);
297 }
298 }
299
300 pub fn standard(company_code: &str) -> Self {
302 let mut pool = Self::new();
303
304 let capital_projects = [
306 (
307 "PRJ-CAP-001",
308 "Data Center Expansion",
309 Decimal::from(5000000),
310 ),
311 (
312 "PRJ-CAP-002",
313 "Manufacturing Line Upgrade",
314 Decimal::from(2500000),
315 ),
316 (
317 "PRJ-CAP-003",
318 "Office Building Renovation",
319 Decimal::from(1500000),
320 ),
321 (
322 "PRJ-CAP-004",
323 "Fleet Vehicle Replacement",
324 Decimal::from(800000),
325 ),
326 (
327 "PRJ-CAP-005",
328 "Warehouse Automation",
329 Decimal::from(3000000),
330 ),
331 ];
332
333 for (id, name, budget) in capital_projects {
334 let mut project = Project::new(id, name, ProjectType::Capital)
335 .with_budget(budget)
336 .with_company(company_code);
337
338 project.add_wbs_element(
340 WbsElement::new(&format!("{}.01", id), id, "Planning & Design").with_budget(
341 budget * Decimal::from_f64_retain(0.1).expect("valid decimal fraction"),
342 ),
343 );
344 project.add_wbs_element(
345 WbsElement::new(&format!("{}.02", id), id, "Procurement").with_budget(
346 budget * Decimal::from_f64_retain(0.4).expect("valid decimal fraction"),
347 ),
348 );
349 project.add_wbs_element(
350 WbsElement::new(&format!("{}.03", id), id, "Implementation").with_budget(
351 budget * Decimal::from_f64_retain(0.4).expect("valid decimal fraction"),
352 ),
353 );
354 project.add_wbs_element(
355 WbsElement::new(&format!("{}.04", id), id, "Testing & Validation").with_budget(
356 budget * Decimal::from_f64_retain(0.1).expect("valid decimal fraction"),
357 ),
358 );
359
360 pool.add_project(project);
361 }
362
363 let internal_projects = [
365 (
366 "PRJ-INT-001",
367 "Process Improvement Initiative",
368 Decimal::from(250000),
369 ),
370 (
371 "PRJ-INT-002",
372 "Employee Training Program",
373 Decimal::from(150000),
374 ),
375 (
376 "PRJ-INT-003",
377 "Quality Certification",
378 Decimal::from(100000),
379 ),
380 ];
381
382 for (id, name, budget) in internal_projects {
383 let mut project = Project::new(id, name, ProjectType::Internal)
384 .with_budget(budget)
385 .with_company(company_code);
386
387 project.add_wbs_element(
388 WbsElement::new(&format!("{}.01", id), id, "Phase 1").with_budget(
389 budget * Decimal::from_f64_retain(0.5).expect("valid decimal fraction"),
390 ),
391 );
392 project.add_wbs_element(
393 WbsElement::new(&format!("{}.02", id), id, "Phase 2").with_budget(
394 budget * Decimal::from_f64_retain(0.5).expect("valid decimal fraction"),
395 ),
396 );
397
398 pool.add_project(project);
399 }
400
401 let tech_projects = [
403 (
404 "PRJ-IT-001",
405 "ERP System Implementation",
406 Decimal::from(2000000),
407 ),
408 ("PRJ-IT-002", "Cloud Migration", Decimal::from(1000000)),
409 (
410 "PRJ-IT-003",
411 "Cybersecurity Enhancement",
412 Decimal::from(500000),
413 ),
414 ];
415
416 for (id, name, budget) in tech_projects {
417 let mut project = Project::new(id, name, ProjectType::Technology)
418 .with_budget(budget)
419 .with_company(company_code);
420
421 project.add_wbs_element(
422 WbsElement::new(&format!("{}.01", id), id, "Assessment").with_budget(
423 budget * Decimal::from_f64_retain(0.15).expect("valid decimal fraction"),
424 ),
425 );
426 project.add_wbs_element(
427 WbsElement::new(&format!("{}.02", id), id, "Development").with_budget(
428 budget * Decimal::from_f64_retain(0.50).expect("valid decimal fraction"),
429 ),
430 );
431 project.add_wbs_element(
432 WbsElement::new(&format!("{}.03", id), id, "Deployment").with_budget(
433 budget * Decimal::from_f64_retain(0.25).expect("valid decimal fraction"),
434 ),
435 );
436 project.add_wbs_element(
437 WbsElement::new(&format!("{}.04", id), id, "Support").with_budget(
438 budget * Decimal::from_f64_retain(0.10).expect("valid decimal fraction"),
439 ),
440 );
441
442 pool.add_project(project);
443 }
444
445 pool
446 }
447}
448
449#[cfg(test)]
450#[allow(clippy::unwrap_used)]
451mod tests {
452 use super::*;
453 use rand::SeedableRng;
454 use rand_chacha::ChaCha8Rng;
455
456 #[test]
457 fn test_project_creation() {
458 let project = Project::new("P-001", "Test Project", ProjectType::Capital)
459 .with_budget(Decimal::from(1000000));
460
461 assert_eq!(project.project_id, "P-001");
462 assert!(project.allows_postings());
463 assert!(project.project_type.is_capitalizable());
464 }
465
466 #[test]
467 fn test_wbs_element() {
468 let wbs =
469 WbsElement::new("P-001.01", "P-001", "Phase 1").with_budget(Decimal::from(100000));
470
471 assert_eq!(wbs.remaining_budget(), Decimal::from(100000));
472 assert!(!wbs.is_over_budget());
473 }
474
475 #[test]
476 fn test_project_pool() {
477 let pool = ProjectPool::standard("1000");
478
479 assert!(!pool.projects.is_empty());
480
481 let mut rng = ChaCha8Rng::seed_from_u64(42);
482 let project = pool.random_active_project(&mut rng);
483 assert!(project.is_some());
484
485 let cap_project = pool.random_project_of_type(ProjectType::Capital, &mut rng);
486 assert!(cap_project.is_some());
487 }
488
489 #[test]
490 fn test_project_budget_tracking() {
491 let mut project =
492 Project::new("P-001", "Test", ProjectType::Capital).with_budget(Decimal::from(100000));
493
494 let mut wbs =
495 WbsElement::new("P-001.01", "P-001", "Phase 1").with_budget(Decimal::from(100000));
496 wbs.actual_costs = Decimal::from(50000);
497 project.add_wbs_element(wbs);
498
499 assert_eq!(project.total_actual_costs(), Decimal::from(50000));
500 assert!(!project.is_over_budget());
501 }
502}