Skip to main content

datasynth_generators/project_accounting/
project_cost_generator.rs

1//! Project cost generator (linking pattern).
2//!
3//! Probabilistically links existing source documents (time entries, expense reports,
4//! purchase orders, vendor invoices) to project WBS elements, creating
5//! [`ProjectCostLine`] records based on configurable allocation rates.
6
7use chrono::NaiveDate;
8use datasynth_config::schema::CostAllocationConfig;
9use datasynth_core::models::{CostCategory, CostSourceType, ProjectCostLine, ProjectPool};
10use datasynth_core::utils::seeded_rng;
11use rand::prelude::*;
12use rand_chacha::ChaCha8Rng;
13use rust_decimal::Decimal;
14
15/// A minimal source document reference for linking.
16#[derive(Debug, Clone)]
17pub struct SourceDocument {
18    /// Document ID
19    pub id: String,
20    /// Entity (company code) that created the document
21    pub entity_id: String,
22    /// Date of the document
23    pub date: NaiveDate,
24    /// Amount on the document
25    pub amount: Decimal,
26    /// Source type
27    pub source_type: CostSourceType,
28    /// Hours (for time entries)
29    pub hours: Option<Decimal>,
30}
31
32/// Generates [`ProjectCostLine`] records by linking source documents to projects.
33pub struct ProjectCostGenerator {
34    rng: ChaCha8Rng,
35    config: CostAllocationConfig,
36    counter: u64,
37}
38
39impl ProjectCostGenerator {
40    /// Create a new project cost generator.
41    pub fn new(config: CostAllocationConfig, seed: u64) -> Self {
42        Self {
43            rng: seeded_rng(seed, 0),
44            config,
45            counter: 0,
46        }
47    }
48
49    /// Link source documents to projects, creating cost lines.
50    ///
51    /// Each document is probabilistically assigned to a project based on
52    /// the allocation rate for its source type. The assigned WBS element
53    /// is chosen randomly from the project's active elements.
54    pub fn link_documents(
55        &mut self,
56        pool: &ProjectPool,
57        documents: &[SourceDocument],
58    ) -> Vec<ProjectCostLine> {
59        let mut cost_lines = Vec::new();
60
61        for doc in documents {
62            let rate = self.rate_for(doc.source_type);
63            if self.rng.random::<f64>() >= rate {
64                continue;
65            }
66
67            // Pick a random active project
68            let project = match pool.random_active_project(&mut self.rng) {
69                Some(p) => p,
70                None => continue,
71            };
72
73            // Pick a random active WBS element
74            let active_wbs = project.active_wbs_elements();
75            if active_wbs.is_empty() {
76                continue;
77            }
78            let wbs = active_wbs[self.rng.random_range(0..active_wbs.len())];
79
80            self.counter += 1;
81            let cost_line_id = format!("PCL-{:06}", self.counter);
82            let category = self.category_for(doc.source_type);
83
84            let mut line = ProjectCostLine::new(
85                cost_line_id,
86                &project.project_id,
87                &wbs.wbs_id,
88                &doc.entity_id,
89                doc.date,
90                category,
91                doc.source_type,
92                &doc.id,
93                doc.amount,
94                "USD",
95            );
96
97            if let Some(hours) = doc.hours {
98                line = line.with_hours(hours);
99            }
100
101            cost_lines.push(line);
102        }
103
104        cost_lines
105    }
106
107    /// Get the allocation rate for a source type.
108    fn rate_for(&self, source_type: CostSourceType) -> f64 {
109        match source_type {
110            CostSourceType::TimeEntry => self.config.time_entry_project_rate,
111            CostSourceType::ExpenseReport => self.config.expense_project_rate,
112            CostSourceType::PurchaseOrder => self.config.purchase_order_project_rate,
113            CostSourceType::VendorInvoice => self.config.vendor_invoice_project_rate,
114            CostSourceType::JournalEntry => 0.0, // JEs aren't linked by default
115        }
116    }
117
118    /// Map source types to cost categories.
119    fn category_for(&self, source_type: CostSourceType) -> CostCategory {
120        match source_type {
121            CostSourceType::TimeEntry => CostCategory::Labor,
122            CostSourceType::ExpenseReport => CostCategory::Travel,
123            CostSourceType::PurchaseOrder => CostCategory::Material,
124            CostSourceType::VendorInvoice => CostCategory::Subcontractor,
125            CostSourceType::JournalEntry => CostCategory::Overhead,
126        }
127    }
128}
129
130#[cfg(test)]
131#[allow(clippy::unwrap_used)]
132mod tests {
133    use super::*;
134    use datasynth_core::models::{Project, ProjectType};
135    use rust_decimal_macros::dec;
136
137    fn d(s: &str) -> NaiveDate {
138        NaiveDate::parse_from_str(s, "%Y-%m-%d").unwrap()
139    }
140
141    fn test_pool() -> ProjectPool {
142        let mut pool = ProjectPool::new();
143        for i in 0..5 {
144            let mut project = Project::new(
145                &format!("PRJ-{:03}", i + 1),
146                &format!("Test Project {}", i + 1),
147                ProjectType::Customer,
148            )
149            .with_budget(Decimal::from(1_000_000))
150            .with_company("TEST");
151
152            project.add_wbs_element(
153                datasynth_core::models::WbsElement::new(
154                    &format!("PRJ-{:03}.01", i + 1),
155                    &format!("PRJ-{:03}", i + 1),
156                    "Phase 1",
157                )
158                .with_budget(Decimal::from(500_000)),
159            );
160            project.add_wbs_element(
161                datasynth_core::models::WbsElement::new(
162                    &format!("PRJ-{:03}.02", i + 1),
163                    &format!("PRJ-{:03}", i + 1),
164                    "Phase 2",
165                )
166                .with_budget(Decimal::from(500_000)),
167            );
168
169            pool.add_project(project);
170        }
171        pool
172    }
173
174    fn test_time_entries(count: usize) -> Vec<SourceDocument> {
175        (0..count)
176            .map(|i| SourceDocument {
177                id: format!("TE-{:04}", i + 1),
178                entity_id: "TEST".to_string(),
179                date: d("2024-03-15"),
180                amount: dec!(750),
181                source_type: CostSourceType::TimeEntry,
182                hours: Some(dec!(8)),
183            })
184            .collect()
185    }
186
187    #[test]
188    fn test_project_cost_linking() {
189        let pool = test_pool();
190        let time_entries = test_time_entries(100);
191        let config = CostAllocationConfig {
192            time_entry_project_rate: 0.60,
193            ..Default::default()
194        };
195
196        let mut gen = ProjectCostGenerator::new(config, 42);
197        let cost_lines = gen.link_documents(&pool, &time_entries);
198
199        // ~60% of 100 time entries should be linked (with variance)
200        let linked_count = cost_lines.len();
201        assert!(
202            linked_count >= 40 && linked_count <= 80,
203            "Expected ~60 linked, got {}",
204            linked_count
205        );
206
207        // All linked entries should reference valid projects and WBS elements
208        for line in &cost_lines {
209            assert!(
210                pool.projects
211                    .iter()
212                    .any(|p| p.project_id == line.project_id),
213                "Cost line should reference a valid project"
214            );
215            assert_eq!(line.cost_category, CostCategory::Labor);
216            assert_eq!(line.source_type, CostSourceType::TimeEntry);
217            assert!(line.hours.is_some());
218        }
219    }
220
221    #[test]
222    fn test_zero_rate_links_nothing() {
223        let pool = test_pool();
224        let docs = test_time_entries(50);
225        let config = CostAllocationConfig {
226            time_entry_project_rate: 0.0,
227            expense_project_rate: 0.0,
228            purchase_order_project_rate: 0.0,
229            vendor_invoice_project_rate: 0.0,
230        };
231
232        let mut gen = ProjectCostGenerator::new(config, 42);
233        let cost_lines = gen.link_documents(&pool, &docs);
234        assert!(cost_lines.is_empty(), "Zero rate should produce no links");
235    }
236
237    #[test]
238    fn test_full_rate_links_everything() {
239        let pool = test_pool();
240        let docs = test_time_entries(50);
241        let config = CostAllocationConfig {
242            time_entry_project_rate: 1.0,
243            ..Default::default()
244        };
245
246        let mut gen = ProjectCostGenerator::new(config, 42);
247        let cost_lines = gen.link_documents(&pool, &docs);
248        assert_eq!(cost_lines.len(), 50, "100% rate should link all documents");
249    }
250
251    #[test]
252    fn test_expense_linking() {
253        let pool = test_pool();
254        let expenses: Vec<SourceDocument> = (0..50)
255            .map(|i| SourceDocument {
256                id: format!("EXP-{:04}", i + 1),
257                entity_id: "TEST".to_string(),
258                date: d("2024-03-15"),
259                amount: dec!(350),
260                source_type: CostSourceType::ExpenseReport,
261                hours: None,
262            })
263            .collect();
264
265        let config = CostAllocationConfig {
266            expense_project_rate: 1.0,
267            ..Default::default()
268        };
269
270        let mut gen = ProjectCostGenerator::new(config, 42);
271        let cost_lines = gen.link_documents(&pool, &expenses);
272
273        assert_eq!(cost_lines.len(), 50);
274        for line in &cost_lines {
275            assert_eq!(line.cost_category, CostCategory::Travel);
276            assert_eq!(line.source_type, CostSourceType::ExpenseReport);
277            assert!(line.hours.is_none());
278        }
279    }
280
281    #[test]
282    fn test_deterministic_linking() {
283        let pool = test_pool();
284        let docs = test_time_entries(100);
285        let config = CostAllocationConfig::default();
286
287        let mut gen1 = ProjectCostGenerator::new(config.clone(), 42);
288        let lines1 = gen1.link_documents(&pool, &docs);
289
290        let mut gen2 = ProjectCostGenerator::new(config, 42);
291        let lines2 = gen2.link_documents(&pool, &docs);
292
293        assert_eq!(lines1.len(), lines2.len());
294        for (l1, l2) in lines1.iter().zip(lines2.iter()) {
295            assert_eq!(l1.project_id, l2.project_id);
296            assert_eq!(l1.wbs_id, l2.wbs_id);
297            assert_eq!(l1.amount, l2.amount);
298        }
299    }
300}