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)]
131mod tests {
132    use super::*;
133    use datasynth_core::models::{Project, ProjectType};
134    use rust_decimal_macros::dec;
135
136    fn d(s: &str) -> NaiveDate {
137        NaiveDate::parse_from_str(s, "%Y-%m-%d").unwrap()
138    }
139
140    fn test_pool() -> ProjectPool {
141        let mut pool = ProjectPool::new();
142        for i in 0..5 {
143            let mut project = Project::new(
144                &format!("PRJ-{:03}", i + 1),
145                &format!("Test Project {}", i + 1),
146                ProjectType::Customer,
147            )
148            .with_budget(Decimal::from(1_000_000))
149            .with_company("TEST");
150
151            project.add_wbs_element(
152                datasynth_core::models::WbsElement::new(
153                    &format!("PRJ-{:03}.01", i + 1),
154                    &format!("PRJ-{:03}", i + 1),
155                    "Phase 1",
156                )
157                .with_budget(Decimal::from(500_000)),
158            );
159            project.add_wbs_element(
160                datasynth_core::models::WbsElement::new(
161                    &format!("PRJ-{:03}.02", i + 1),
162                    &format!("PRJ-{:03}", i + 1),
163                    "Phase 2",
164                )
165                .with_budget(Decimal::from(500_000)),
166            );
167
168            pool.add_project(project);
169        }
170        pool
171    }
172
173    fn test_time_entries(count: usize) -> Vec<SourceDocument> {
174        (0..count)
175            .map(|i| SourceDocument {
176                id: format!("TE-{:04}", i + 1),
177                entity_id: "TEST".to_string(),
178                date: d("2024-03-15"),
179                amount: dec!(750),
180                source_type: CostSourceType::TimeEntry,
181                hours: Some(dec!(8)),
182            })
183            .collect()
184    }
185
186    #[test]
187    fn test_project_cost_linking() {
188        let pool = test_pool();
189        let time_entries = test_time_entries(100);
190        let config = CostAllocationConfig {
191            time_entry_project_rate: 0.60,
192            ..Default::default()
193        };
194
195        let mut gen = ProjectCostGenerator::new(config, 42);
196        let cost_lines = gen.link_documents(&pool, &time_entries);
197
198        // ~60% of 100 time entries should be linked (with variance)
199        let linked_count = cost_lines.len();
200        assert!(
201            (40..=80).contains(&linked_count),
202            "Expected ~60 linked, got {}",
203            linked_count
204        );
205
206        // All linked entries should reference valid projects and WBS elements
207        for line in &cost_lines {
208            assert!(
209                pool.projects
210                    .iter()
211                    .any(|p| p.project_id == line.project_id),
212                "Cost line should reference a valid project"
213            );
214            assert_eq!(line.cost_category, CostCategory::Labor);
215            assert_eq!(line.source_type, CostSourceType::TimeEntry);
216            assert!(line.hours.is_some());
217        }
218    }
219
220    #[test]
221    fn test_zero_rate_links_nothing() {
222        let pool = test_pool();
223        let docs = test_time_entries(50);
224        let config = CostAllocationConfig {
225            time_entry_project_rate: 0.0,
226            expense_project_rate: 0.0,
227            purchase_order_project_rate: 0.0,
228            vendor_invoice_project_rate: 0.0,
229        };
230
231        let mut gen = ProjectCostGenerator::new(config, 42);
232        let cost_lines = gen.link_documents(&pool, &docs);
233        assert!(cost_lines.is_empty(), "Zero rate should produce no links");
234    }
235
236    #[test]
237    fn test_full_rate_links_everything() {
238        let pool = test_pool();
239        let docs = test_time_entries(50);
240        let config = CostAllocationConfig {
241            time_entry_project_rate: 1.0,
242            ..Default::default()
243        };
244
245        let mut gen = ProjectCostGenerator::new(config, 42);
246        let cost_lines = gen.link_documents(&pool, &docs);
247        assert_eq!(cost_lines.len(), 50, "100% rate should link all documents");
248    }
249
250    #[test]
251    fn test_expense_linking() {
252        let pool = test_pool();
253        let expenses: Vec<SourceDocument> = (0..50)
254            .map(|i| SourceDocument {
255                id: format!("EXP-{:04}", i + 1),
256                entity_id: "TEST".to_string(),
257                date: d("2024-03-15"),
258                amount: dec!(350),
259                source_type: CostSourceType::ExpenseReport,
260                hours: None,
261            })
262            .collect();
263
264        let config = CostAllocationConfig {
265            expense_project_rate: 1.0,
266            ..Default::default()
267        };
268
269        let mut gen = ProjectCostGenerator::new(config, 42);
270        let cost_lines = gen.link_documents(&pool, &expenses);
271
272        assert_eq!(cost_lines.len(), 50);
273        for line in &cost_lines {
274            assert_eq!(line.cost_category, CostCategory::Travel);
275            assert_eq!(line.source_type, CostSourceType::ExpenseReport);
276            assert!(line.hours.is_none());
277        }
278    }
279
280    #[test]
281    fn test_deterministic_linking() {
282        let pool = test_pool();
283        let docs = test_time_entries(100);
284        let config = CostAllocationConfig::default();
285
286        let mut gen1 = ProjectCostGenerator::new(config.clone(), 42);
287        let lines1 = gen1.link_documents(&pool, &docs);
288
289        let mut gen2 = ProjectCostGenerator::new(config, 42);
290        let lines2 = gen2.link_documents(&pool, &docs);
291
292        assert_eq!(lines1.len(), lines2.len());
293        for (l1, l2) in lines1.iter().zip(lines2.iter()) {
294            assert_eq!(l1.project_id, l2.project_id);
295            assert_eq!(l1.wbs_id, l2.wbs_id);
296            assert_eq!(l1.amount, l2.amount);
297        }
298    }
299}