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