datasynth_generators/project_accounting/
project_cost_generator.rs1use 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#[derive(Debug, Clone)]
17pub struct SourceDocument {
18 pub id: String,
20 pub entity_id: String,
22 pub date: NaiveDate,
24 pub amount: Decimal,
26 pub source_type: CostSourceType,
28 pub hours: Option<Decimal>,
30}
31
32pub struct ProjectCostGenerator {
34 rng: ChaCha8Rng,
35 config: CostAllocationConfig,
36 counter: u64,
37}
38
39impl ProjectCostGenerator {
40 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 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 let project = match pool.random_active_project(&mut self.rng) {
69 Some(p) => p,
70 None => continue,
71 };
72
73 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 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, }
116 }
117
118 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 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 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}