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::uuid_factory::{DeterministicUuidFactory, GeneratorType};
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 uuid_factory: DeterministicUuidFactory,
36 config: CostAllocationConfig,
37 counter: u64,
38}
39
40impl ProjectCostGenerator {
41 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 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 let project = match pool.random_active_project(&mut self.rng) {
71 Some(p) => p,
72 None => continue,
73 };
74
75 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 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, }
118 }
119
120 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 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 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}