datasynth_core/models/
project.rs

1//! Project and WBS (Work Breakdown Structure) models.
2//!
3//! Provides project master data for capital projects, internal projects,
4//! and associated WBS elements for cost tracking.
5
6use rand::seq::SliceRandom;
7use rand::Rng;
8use rust_decimal::Decimal;
9use serde::{Deserialize, Serialize};
10use std::collections::HashMap;
11
12/// Type of project.
13#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize, Default)]
14#[serde(rename_all = "snake_case")]
15pub enum ProjectType {
16    /// Capital expenditure project (assets)
17    #[default]
18    Capital,
19    /// Internal project (expensed)
20    Internal,
21    /// Research and development
22    RandD,
23    /// Customer project (billable)
24    Customer,
25    /// Maintenance project
26    Maintenance,
27    /// IT/Technology project
28    Technology,
29}
30
31impl ProjectType {
32    /// Check if this project type typically capitalizes costs.
33    pub fn is_capitalizable(&self) -> bool {
34        matches!(self, Self::Capital | Self::RandD)
35    }
36
37    /// Get typical account type for this project.
38    pub fn typical_account_prefix(&self) -> &'static str {
39        match self {
40            Self::Capital => "1",     // Assets
41            Self::Internal => "5",    // Expenses
42            Self::RandD => "1",       // Assets (capitalized) or "5" (expensed)
43            Self::Customer => "4",    // Revenue
44            Self::Maintenance => "5", // Expenses
45            Self::Technology => "1",  // Assets (often capitalized)
46        }
47    }
48}
49
50/// Status of a project.
51#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize, Default)]
52#[serde(rename_all = "snake_case")]
53pub enum ProjectStatus {
54    /// Project is planned but not started
55    #[default]
56    Planned,
57    /// Project is active
58    Active,
59    /// Project is on hold
60    OnHold,
61    /// Project is complete
62    Completed,
63    /// Project was cancelled
64    Cancelled,
65    /// Project is in closing phase
66    Closing,
67}
68
69impl ProjectStatus {
70    /// Check if project can receive postings.
71    pub fn allows_postings(&self) -> bool {
72        matches!(self, Self::Active | Self::Closing)
73    }
74}
75
76/// WBS (Work Breakdown Structure) element.
77#[derive(Debug, Clone, Serialize, Deserialize)]
78pub struct WbsElement {
79    /// WBS element ID (e.g., "P-001.01.001")
80    pub wbs_id: String,
81
82    /// Parent project ID
83    pub project_id: String,
84
85    /// Description
86    pub description: String,
87
88    /// Level in the hierarchy (1 = top level)
89    pub level: u8,
90
91    /// Parent WBS element ID (None for top-level)
92    pub parent_wbs: Option<String>,
93
94    /// Budget amount
95    pub budget: Decimal,
96
97    /// Actual costs to date
98    pub actual_costs: Decimal,
99
100    /// Is this element active for postings
101    pub is_active: bool,
102
103    /// Responsible cost center
104    pub responsible_cost_center: Option<String>,
105}
106
107impl WbsElement {
108    /// Create a new WBS element.
109    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    /// Set the level and parent.
124    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    /// Set the budget.
131    pub fn with_budget(mut self, budget: Decimal) -> Self {
132        self.budget = budget;
133        self
134    }
135
136    /// Calculate remaining budget.
137    pub fn remaining_budget(&self) -> Decimal {
138        self.budget - self.actual_costs
139    }
140
141    /// Check if over budget.
142    pub fn is_over_budget(&self) -> bool {
143        self.actual_costs > self.budget
144    }
145}
146
147/// Project master data.
148#[derive(Debug, Clone, Serialize, Deserialize)]
149pub struct Project {
150    /// Project ID (e.g., "P-001234")
151    pub project_id: String,
152
153    /// Project name
154    pub name: String,
155
156    /// Project description
157    pub description: String,
158
159    /// Type of project
160    pub project_type: ProjectType,
161
162    /// Current status
163    pub status: ProjectStatus,
164
165    /// Total budget
166    pub budget: Decimal,
167
168    /// Responsible cost center
169    pub responsible_cost_center: String,
170
171    /// WBS elements
172    pub wbs_elements: Vec<WbsElement>,
173
174    /// Company code
175    pub company_code: String,
176
177    /// Start date (YYYY-MM-DD)
178    pub start_date: Option<String>,
179
180    /// End date (YYYY-MM-DD)
181    pub end_date: Option<String>,
182}
183
184impl Project {
185    /// Create a new project.
186    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    /// Set the budget.
203    pub fn with_budget(mut self, budget: Decimal) -> Self {
204        self.budget = budget;
205        self
206    }
207
208    /// Set the company code.
209    pub fn with_company(mut self, company_code: &str) -> Self {
210        self.company_code = company_code.to_string();
211        self
212    }
213
214    /// Add a WBS element.
215    pub fn add_wbs_element(&mut self, element: WbsElement) {
216        self.wbs_elements.push(element);
217    }
218
219    /// Get active WBS elements.
220    pub fn active_wbs_elements(&self) -> Vec<&WbsElement> {
221        self.wbs_elements.iter().filter(|w| w.is_active).collect()
222    }
223
224    /// Check if project allows postings.
225    pub fn allows_postings(&self) -> bool {
226        self.status.allows_postings()
227    }
228
229    /// Get total actual costs across all WBS elements.
230    pub fn total_actual_costs(&self) -> Decimal {
231        self.wbs_elements.iter().map(|w| w.actual_costs).sum()
232    }
233
234    /// Check if project is over budget.
235    pub fn is_over_budget(&self) -> bool {
236        self.total_actual_costs() > self.budget
237    }
238}
239
240/// Pool of projects for transaction generation.
241#[derive(Debug, Clone, Default)]
242pub struct ProjectPool {
243    /// All projects
244    pub projects: Vec<Project>,
245    /// Index by project type
246    type_index: HashMap<ProjectType, Vec<usize>>,
247}
248
249impl ProjectPool {
250    /// Create a new empty project pool.
251    pub fn new() -> Self {
252        Self {
253            projects: Vec::new(),
254            type_index: HashMap::new(),
255        }
256    }
257
258    /// Add a project to the pool.
259    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    /// Get a random active project.
267    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    /// Get a random project of a specific type.
277    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    /// Rebuild the type index.
290    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    /// Generate a standard project pool.
301    pub fn standard(company_code: &str) -> Self {
302        let mut pool = Self::new();
303
304        // Capital projects
305        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            // Add WBS elements
339            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        // Internal projects
360        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        // Technology projects
396        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}