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")
341 .with_budget(budget * Decimal::from_f64_retain(0.1).unwrap()),
342 );
343 project.add_wbs_element(
344 WbsElement::new(&format!("{}.02", id), id, "Procurement")
345 .with_budget(budget * Decimal::from_f64_retain(0.4).unwrap()),
346 );
347 project.add_wbs_element(
348 WbsElement::new(&format!("{}.03", id), id, "Implementation")
349 .with_budget(budget * Decimal::from_f64_retain(0.4).unwrap()),
350 );
351 project.add_wbs_element(
352 WbsElement::new(&format!("{}.04", id), id, "Testing & Validation")
353 .with_budget(budget * Decimal::from_f64_retain(0.1).unwrap()),
354 );
355
356 pool.add_project(project);
357 }
358
359 let internal_projects = [
361 (
362 "PRJ-INT-001",
363 "Process Improvement Initiative",
364 Decimal::from(250000),
365 ),
366 (
367 "PRJ-INT-002",
368 "Employee Training Program",
369 Decimal::from(150000),
370 ),
371 (
372 "PRJ-INT-003",
373 "Quality Certification",
374 Decimal::from(100000),
375 ),
376 ];
377
378 for (id, name, budget) in internal_projects {
379 let mut project = Project::new(id, name, ProjectType::Internal)
380 .with_budget(budget)
381 .with_company(company_code);
382
383 project.add_wbs_element(
384 WbsElement::new(&format!("{}.01", id), id, "Phase 1")
385 .with_budget(budget * Decimal::from_f64_retain(0.5).unwrap()),
386 );
387 project.add_wbs_element(
388 WbsElement::new(&format!("{}.02", id), id, "Phase 2")
389 .with_budget(budget * Decimal::from_f64_retain(0.5).unwrap()),
390 );
391
392 pool.add_project(project);
393 }
394
395 let tech_projects = [
397 (
398 "PRJ-IT-001",
399 "ERP System Implementation",
400 Decimal::from(2000000),
401 ),
402 ("PRJ-IT-002", "Cloud Migration", Decimal::from(1000000)),
403 (
404 "PRJ-IT-003",
405 "Cybersecurity Enhancement",
406 Decimal::from(500000),
407 ),
408 ];
409
410 for (id, name, budget) in tech_projects {
411 let mut project = Project::new(id, name, ProjectType::Technology)
412 .with_budget(budget)
413 .with_company(company_code);
414
415 project.add_wbs_element(
416 WbsElement::new(&format!("{}.01", id), id, "Assessment")
417 .with_budget(budget * Decimal::from_f64_retain(0.15).unwrap()),
418 );
419 project.add_wbs_element(
420 WbsElement::new(&format!("{}.02", id), id, "Development")
421 .with_budget(budget * Decimal::from_f64_retain(0.50).unwrap()),
422 );
423 project.add_wbs_element(
424 WbsElement::new(&format!("{}.03", id), id, "Deployment")
425 .with_budget(budget * Decimal::from_f64_retain(0.25).unwrap()),
426 );
427 project.add_wbs_element(
428 WbsElement::new(&format!("{}.04", id), id, "Support")
429 .with_budget(budget * Decimal::from_f64_retain(0.10).unwrap()),
430 );
431
432 pool.add_project(project);
433 }
434
435 pool
436 }
437}
438
439#[cfg(test)]
440mod tests {
441 use super::*;
442 use rand::SeedableRng;
443 use rand_chacha::ChaCha8Rng;
444
445 #[test]
446 fn test_project_creation() {
447 let project = Project::new("P-001", "Test Project", ProjectType::Capital)
448 .with_budget(Decimal::from(1000000));
449
450 assert_eq!(project.project_id, "P-001");
451 assert!(project.allows_postings());
452 assert!(project.project_type.is_capitalizable());
453 }
454
455 #[test]
456 fn test_wbs_element() {
457 let wbs =
458 WbsElement::new("P-001.01", "P-001", "Phase 1").with_budget(Decimal::from(100000));
459
460 assert_eq!(wbs.remaining_budget(), Decimal::from(100000));
461 assert!(!wbs.is_over_budget());
462 }
463
464 #[test]
465 fn test_project_pool() {
466 let pool = ProjectPool::standard("1000");
467
468 assert!(!pool.projects.is_empty());
469
470 let mut rng = ChaCha8Rng::seed_from_u64(42);
471 let project = pool.random_active_project(&mut rng);
472 assert!(project.is_some());
473
474 let cap_project = pool.random_project_of_type(ProjectType::Capital, &mut rng);
475 assert!(cap_project.is_some());
476 }
477
478 #[test]
479 fn test_project_budget_tracking() {
480 let mut project =
481 Project::new("P-001", "Test", ProjectType::Capital).with_budget(Decimal::from(100000));
482
483 let mut wbs =
484 WbsElement::new("P-001.01", "P-001", "Phase 1").with_budget(Decimal::from(100000));
485 wbs.actual_costs = Decimal::from(50000);
486 project.add_wbs_element(wbs);
487
488 assert_eq!(project.total_actual_costs(), Decimal::from(50000));
489 assert!(!project.is_over_budget());
490 }
491}