datasynth_generators/project_accounting/
change_order_generator.rs1use chrono::NaiveDate;
6use datasynth_config::schema::{ChangeOrderSchemaConfig, MilestoneSchemaConfig};
7use datasynth_core::models::{
8 ChangeOrder, ChangeOrderStatus, ChangeReason, MilestoneStatus, Project, ProjectMilestone,
9};
10use datasynth_core::utils::seeded_rng;
11use rand::prelude::*;
12use rand_chacha::ChaCha8Rng;
13use rust_decimal::Decimal;
14use rust_decimal_macros::dec;
15
16pub struct ChangeOrderGenerator {
18 rng: ChaCha8Rng,
19 config: ChangeOrderSchemaConfig,
20 counter: u64,
21}
22
23impl ChangeOrderGenerator {
24 pub fn new(config: ChangeOrderSchemaConfig, seed: u64) -> Self {
26 Self {
27 rng: seeded_rng(seed, 0),
28 config,
29 counter: 0,
30 }
31 }
32
33 pub fn generate(
35 &mut self,
36 projects: &[Project],
37 start_date: NaiveDate,
38 end_date: NaiveDate,
39 ) -> Vec<ChangeOrder> {
40 let mut change_orders = Vec::new();
41 let period_days = (end_date - start_date).num_days().max(1);
42
43 for project in projects {
44 if !project.allows_postings() {
45 continue;
46 }
47
48 if self.rng.random::<f64>() >= self.config.probability {
50 continue;
51 }
52
53 let co_count = self.rng.random_range(1..=self.config.max_per_project);
54
55 for number in 1..=co_count {
56 self.counter += 1;
57
58 let day_offset = self.rng.random_range(1..period_days);
60 let submitted_date = start_date + chrono::Duration::days(day_offset);
61
62 let reason = self.pick_reason();
63 let description = self.description_for(reason);
64
65 let impact_pct: f64 = self.rng.random_range(0.02..0.15);
67 let cost_impact = (project.budget
68 * Decimal::from_f64_retain(impact_pct).unwrap_or(dec!(0.05)))
69 .round_dp(2);
70
71 let est_factor: f64 = self.rng.random_range(0.80..1.20);
73 let estimated_cost_impact = (cost_impact
74 * Decimal::from_f64_retain(est_factor).unwrap_or(dec!(1)))
75 .round_dp(2);
76
77 let schedule_days = self.rng.random_range(0..60i32);
79
80 let mut co = ChangeOrder::new(
81 format!("CO-{:06}", self.counter),
82 &project.project_id,
83 number,
84 submitted_date,
85 reason,
86 description,
87 )
88 .with_cost_impact(cost_impact, estimated_cost_impact)
89 .with_schedule_impact(schedule_days);
90
91 if self.rng.random::<f64>() < self.config.approval_rate {
93 let approval_delay = self.rng.random_range(3..30);
94 let approved_date = submitted_date + chrono::Duration::days(approval_delay);
95 if approved_date <= end_date {
96 co = co.approve(approved_date);
97 }
98 } else if self.rng.random::<f64>() < 0.7 {
99 co.status = ChangeOrderStatus::Rejected;
100 } else {
101 co.status = ChangeOrderStatus::UnderReview;
102 }
103
104 change_orders.push(co);
105 }
106 }
107
108 change_orders
109 }
110
111 fn pick_reason(&mut self) -> ChangeReason {
112 let roll: f64 = self.rng.random::<f64>();
113 if roll < 0.30 {
114 ChangeReason::ScopeChange
115 } else if roll < 0.50 {
116 ChangeReason::UnforeseenConditions
117 } else if roll < 0.65 {
118 ChangeReason::DesignError
119 } else if roll < 0.80 {
120 ChangeReason::RegulatoryChange
121 } else if roll < 0.92 {
122 ChangeReason::ValueEngineering
123 } else {
124 ChangeReason::ScheduleAcceleration
125 }
126 }
127
128 fn description_for(&self, reason: ChangeReason) -> String {
129 match reason {
130 ChangeReason::ScopeChange => {
131 "Client-requested modification to deliverable scope".to_string()
132 }
133 ChangeReason::UnforeseenConditions => {
134 "Unforeseen site conditions requiring additional work".to_string()
135 }
136 ChangeReason::DesignError => {
137 "Design specification correction and remediation".to_string()
138 }
139 ChangeReason::RegulatoryChange => {
140 "Regulatory compliance update requirement".to_string()
141 }
142 ChangeReason::ValueEngineering => {
143 "Value engineering cost reduction opportunity".to_string()
144 }
145 ChangeReason::ScheduleAcceleration => {
146 "Schedule acceleration to meet revised deadline".to_string()
147 }
148 }
149 }
150}
151
152pub struct MilestoneGenerator {
154 rng: ChaCha8Rng,
155 config: MilestoneSchemaConfig,
156 counter: u64,
157}
158
159impl MilestoneGenerator {
160 pub fn new(config: MilestoneSchemaConfig, seed: u64) -> Self {
162 Self {
163 rng: seeded_rng(seed, 0),
164 config,
165 counter: 0,
166 }
167 }
168
169 pub fn generate(
174 &mut self,
175 projects: &[Project],
176 start_date: NaiveDate,
177 end_date: NaiveDate,
178 reference_date: NaiveDate,
179 ) -> Vec<ProjectMilestone> {
180 let mut milestones = Vec::new();
181
182 for project in projects {
183 let ms_count = self.config.avg_per_project.max(1);
184 let period_days = (end_date - start_date).num_days().max(1);
185 let interval = period_days / ms_count as i64;
186
187 let milestone_names = [
188 "Requirements Complete",
189 "Design Approved",
190 "Foundation Complete",
191 "Structural Milestone",
192 "Integration Testing",
193 "User Acceptance",
194 "Go-Live",
195 "Project Closeout",
196 ];
197
198 for seq in 0..ms_count {
199 self.counter += 1;
200
201 let planned_date = start_date + chrono::Duration::days(interval * (seq as i64 + 1));
202 let name = milestone_names
203 .get(seq as usize)
204 .unwrap_or(&"Additional Milestone");
205
206 let mut ms = ProjectMilestone::new(
207 format!("MS-{:06}", self.counter),
208 &project.project_id,
209 *name,
210 planned_date,
211 seq + 1,
212 );
213
214 if let Some(wbs) = project.wbs_elements.first() {
216 ms = ms.with_wbs(&wbs.wbs_id);
217 }
218
219 if self.rng.random::<f64>() < self.config.payment_milestone_rate {
221 let payment_share = dec!(1) / Decimal::from(ms_count.max(1));
222 let payment = (project.budget * payment_share).round_dp(2);
223 ms = ms.with_payment(payment);
224 }
225
226 let weight = dec!(1) / Decimal::from(ms_count.max(1));
228 ms = ms.with_weight(weight.round_dp(4));
229
230 if planned_date <= reference_date {
232 if self.rng.random::<f64>() < 0.85 {
233 ms.status = MilestoneStatus::Completed;
235 let variance_days: i64 = self.rng.random_range(-5..15);
236 ms.actual_date = Some(planned_date + chrono::Duration::days(variance_days));
237 } else {
238 ms.status = MilestoneStatus::Overdue;
239 }
240 } else if planned_date <= reference_date + chrono::Duration::days(30) {
241 ms.status = MilestoneStatus::InProgress;
242 }
243 milestones.push(ms);
246 }
247 }
248
249 milestones
250 }
251}
252
253#[cfg(test)]
254mod tests {
255 use super::*;
256 use datasynth_core::models::ProjectType;
257
258 fn d(s: &str) -> NaiveDate {
259 NaiveDate::parse_from_str(s, "%Y-%m-%d").unwrap()
260 }
261
262 fn test_projects() -> Vec<Project> {
263 (0..5)
264 .map(|i| {
265 Project::new(
266 &format!("PRJ-{:03}", i + 1),
267 &format!("Project {}", i + 1),
268 ProjectType::Customer,
269 )
270 .with_budget(dec!(1000000))
271 .with_company("TEST")
272 })
273 .collect()
274 }
275
276 #[test]
277 fn test_change_order_generation() {
278 let projects = test_projects();
279 let config = ChangeOrderSchemaConfig {
280 enabled: true,
281 probability: 1.0, max_per_project: 2,
283 approval_rate: 0.75,
284 };
285
286 let mut gen = ChangeOrderGenerator::new(config, 42);
287 let cos = gen.generate(&projects, d("2024-01-01"), d("2024-12-31"));
288
289 assert!(!cos.is_empty(), "Should generate change orders");
290
291 for co in &cos {
292 assert!(
293 projects.iter().any(|p| p.project_id == co.project_id),
294 "Change order should reference valid project"
295 );
296 assert!(
297 co.cost_impact > Decimal::ZERO,
298 "Cost impact should be positive"
299 );
300 assert!(
301 co.schedule_impact_days >= 0,
302 "Schedule impact should be non-negative"
303 );
304 }
305 }
306
307 #[test]
308 fn test_change_order_approval_rate() {
309 let projects = test_projects();
310 let config = ChangeOrderSchemaConfig {
311 enabled: true,
312 probability: 1.0,
313 max_per_project: 3,
314 approval_rate: 1.0, };
316
317 let mut gen = ChangeOrderGenerator::new(config, 42);
318 let cos = gen.generate(&projects, d("2024-01-01"), d("2024-12-31"));
319
320 let approved = cos.iter().filter(|co| co.is_approved()).count();
321 let approval_pct = approved as f64 / cos.len() as f64;
323 assert!(
324 approval_pct >= 0.70,
325 "At 100% approval rate, most should be approved: {}/{} = {:.0}%",
326 approved,
327 cos.len(),
328 approval_pct * 100.0
329 );
330 }
331
332 #[test]
333 fn test_change_order_zero_probability() {
334 let projects = test_projects();
335 let config = ChangeOrderSchemaConfig {
336 enabled: true,
337 probability: 0.0,
338 max_per_project: 3,
339 approval_rate: 0.75,
340 };
341
342 let mut gen = ChangeOrderGenerator::new(config, 42);
343 let cos = gen.generate(&projects, d("2024-01-01"), d("2024-12-31"));
344
345 assert!(
346 cos.is_empty(),
347 "Zero probability should produce no change orders"
348 );
349 }
350
351 #[test]
352 fn test_milestone_generation() {
353 let projects = test_projects();
354 let config = MilestoneSchemaConfig {
355 enabled: true,
356 avg_per_project: 4,
357 payment_milestone_rate: 0.50,
358 };
359
360 let mut gen = MilestoneGenerator::new(config, 42);
361 let milestones = gen.generate(&projects, d("2024-01-01"), d("2024-12-31"), d("2024-06-30"));
362
363 assert_eq!(milestones.len(), 20, "5 projects * 4 milestones each");
364
365 for project in &projects {
367 let project_ms: Vec<_> = milestones
368 .iter()
369 .filter(|m| m.project_id == project.project_id)
370 .collect();
371 assert_eq!(project_ms.len(), 4);
372
373 for (i, ms) in project_ms.iter().enumerate() {
374 assert_eq!(ms.sequence, (i + 1) as u32);
375 }
376 }
377 }
378
379 #[test]
380 fn test_milestone_status_progression() {
381 let projects = vec![Project::new("PRJ-001", "Test", ProjectType::Customer)
382 .with_budget(dec!(500000))
383 .with_company("TEST")];
384 let config = MilestoneSchemaConfig {
385 enabled: true,
386 avg_per_project: 4,
387 payment_milestone_rate: 0.50,
388 };
389
390 let mut gen = MilestoneGenerator::new(config, 42);
391 let milestones = gen.generate(
392 &projects,
393 d("2024-01-01"),
394 d("2024-12-31"),
395 d("2024-06-30"), );
397
398 let early_ms: Vec<_> = milestones
400 .iter()
401 .filter(|m| m.planned_date <= d("2024-06-30"))
402 .collect();
403
404 for ms in &early_ms {
405 assert!(
406 ms.status == MilestoneStatus::Completed || ms.status == MilestoneStatus::Overdue,
407 "Past milestones should be completed or overdue, got {:?}",
408 ms.status
409 );
410 }
411 }
412
413 #[test]
414 fn test_milestone_payment_amounts() {
415 let projects = vec![Project::new("PRJ-001", "Test", ProjectType::Customer)
416 .with_budget(dec!(1000000))
417 .with_company("TEST")];
418 let config = MilestoneSchemaConfig {
419 enabled: true,
420 avg_per_project: 4,
421 payment_milestone_rate: 1.0, };
423
424 let mut gen = MilestoneGenerator::new(config, 42);
425 let milestones = gen.generate(&projects, d("2024-01-01"), d("2024-12-31"), d("2024-01-01"));
426
427 let total_payments: Decimal = milestones.iter().map(|m| m.payment_amount).sum();
428 assert_eq!(
429 total_payments,
430 dec!(1000000),
431 "Total payments should equal budget"
432 );
433 }
434
435 #[test]
436 fn test_deterministic_change_orders() {
437 let projects = test_projects();
438 let config = ChangeOrderSchemaConfig::default();
439
440 let mut gen1 = ChangeOrderGenerator::new(config.clone(), 42);
441 let cos1 = gen1.generate(&projects, d("2024-01-01"), d("2024-12-31"));
442
443 let mut gen2 = ChangeOrderGenerator::new(config, 42);
444 let cos2 = gen2.generate(&projects, d("2024-01-01"), d("2024-12-31"));
445
446 assert_eq!(cos1.len(), cos2.len());
447 for (a, b) in cos1.iter().zip(cos2.iter()) {
448 assert_eq!(a.project_id, b.project_id);
449 assert_eq!(a.cost_impact, b.cost_impact);
450 assert_eq!(a.status, b.status);
451 }
452 }
453}