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)]
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 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 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}