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)]
254#[allow(clippy::unwrap_used)]
255mod tests {
256 use super::*;
257 use datasynth_core::models::ProjectType;
258
259 fn d(s: &str) -> NaiveDate {
260 NaiveDate::parse_from_str(s, "%Y-%m-%d").unwrap()
261 }
262
263 fn test_projects() -> Vec<Project> {
264 (0..5)
265 .map(|i| {
266 Project::new(
267 &format!("PRJ-{:03}", i + 1),
268 &format!("Project {}", i + 1),
269 ProjectType::Customer,
270 )
271 .with_budget(dec!(1000000))
272 .with_company("TEST")
273 })
274 .collect()
275 }
276
277 #[test]
278 fn test_change_order_generation() {
279 let projects = test_projects();
280 let config = ChangeOrderSchemaConfig {
281 enabled: true,
282 probability: 1.0, max_per_project: 2,
284 approval_rate: 0.75,
285 };
286
287 let mut gen = ChangeOrderGenerator::new(config, 42);
288 let cos = gen.generate(&projects, d("2024-01-01"), d("2024-12-31"));
289
290 assert!(!cos.is_empty(), "Should generate change orders");
291
292 for co in &cos {
293 assert!(
294 projects.iter().any(|p| p.project_id == co.project_id),
295 "Change order should reference valid project"
296 );
297 assert!(
298 co.cost_impact > Decimal::ZERO,
299 "Cost impact should be positive"
300 );
301 assert!(
302 co.schedule_impact_days >= 0,
303 "Schedule impact should be non-negative"
304 );
305 }
306 }
307
308 #[test]
309 fn test_change_order_approval_rate() {
310 let projects = test_projects();
311 let config = ChangeOrderSchemaConfig {
312 enabled: true,
313 probability: 1.0,
314 max_per_project: 3,
315 approval_rate: 1.0, };
317
318 let mut gen = ChangeOrderGenerator::new(config, 42);
319 let cos = gen.generate(&projects, d("2024-01-01"), d("2024-12-31"));
320
321 let approved = cos.iter().filter(|co| co.is_approved()).count();
322 let approval_pct = approved as f64 / cos.len() as f64;
324 assert!(
325 approval_pct >= 0.70,
326 "At 100% approval rate, most should be approved: {}/{} = {:.0}%",
327 approved,
328 cos.len(),
329 approval_pct * 100.0
330 );
331 }
332
333 #[test]
334 fn test_change_order_zero_probability() {
335 let projects = test_projects();
336 let config = ChangeOrderSchemaConfig {
337 enabled: true,
338 probability: 0.0,
339 max_per_project: 3,
340 approval_rate: 0.75,
341 };
342
343 let mut gen = ChangeOrderGenerator::new(config, 42);
344 let cos = gen.generate(&projects, d("2024-01-01"), d("2024-12-31"));
345
346 assert!(
347 cos.is_empty(),
348 "Zero probability should produce no change orders"
349 );
350 }
351
352 #[test]
353 fn test_milestone_generation() {
354 let projects = test_projects();
355 let config = MilestoneSchemaConfig {
356 enabled: true,
357 avg_per_project: 4,
358 payment_milestone_rate: 0.50,
359 };
360
361 let mut gen = MilestoneGenerator::new(config, 42);
362 let milestones = gen.generate(&projects, d("2024-01-01"), d("2024-12-31"), d("2024-06-30"));
363
364 assert_eq!(milestones.len(), 20, "5 projects * 4 milestones each");
365
366 for project in &projects {
368 let project_ms: Vec<_> = milestones
369 .iter()
370 .filter(|m| m.project_id == project.project_id)
371 .collect();
372 assert_eq!(project_ms.len(), 4);
373
374 for (i, ms) in project_ms.iter().enumerate() {
375 assert_eq!(ms.sequence, (i + 1) as u32);
376 }
377 }
378 }
379
380 #[test]
381 fn test_milestone_status_progression() {
382 let projects = vec![Project::new("PRJ-001", "Test", ProjectType::Customer)
383 .with_budget(dec!(500000))
384 .with_company("TEST")];
385 let config = MilestoneSchemaConfig {
386 enabled: true,
387 avg_per_project: 4,
388 payment_milestone_rate: 0.50,
389 };
390
391 let mut gen = MilestoneGenerator::new(config, 42);
392 let milestones = gen.generate(
393 &projects,
394 d("2024-01-01"),
395 d("2024-12-31"),
396 d("2024-06-30"), );
398
399 let early_ms: Vec<_> = milestones
401 .iter()
402 .filter(|m| m.planned_date <= d("2024-06-30"))
403 .collect();
404
405 for ms in &early_ms {
406 assert!(
407 ms.status == MilestoneStatus::Completed || ms.status == MilestoneStatus::Overdue,
408 "Past milestones should be completed or overdue, got {:?}",
409 ms.status
410 );
411 }
412 }
413
414 #[test]
415 fn test_milestone_payment_amounts() {
416 let projects = vec![Project::new("PRJ-001", "Test", ProjectType::Customer)
417 .with_budget(dec!(1000000))
418 .with_company("TEST")];
419 let config = MilestoneSchemaConfig {
420 enabled: true,
421 avg_per_project: 4,
422 payment_milestone_rate: 1.0, };
424
425 let mut gen = MilestoneGenerator::new(config, 42);
426 let milestones = gen.generate(&projects, d("2024-01-01"), d("2024-12-31"), d("2024-01-01"));
427
428 let total_payments: Decimal = milestones.iter().map(|m| m.payment_amount).sum();
429 assert_eq!(
430 total_payments,
431 dec!(1000000),
432 "Total payments should equal budget"
433 );
434 }
435
436 #[test]
437 fn test_deterministic_change_orders() {
438 let projects = test_projects();
439 let config = ChangeOrderSchemaConfig::default();
440
441 let mut gen1 = ChangeOrderGenerator::new(config.clone(), 42);
442 let cos1 = gen1.generate(&projects, d("2024-01-01"), d("2024-12-31"));
443
444 let mut gen2 = ChangeOrderGenerator::new(config, 42);
445 let cos2 = gen2.generate(&projects, d("2024-01-01"), d("2024-12-31"));
446
447 assert_eq!(cos1.len(), cos2.len());
448 for (a, b) in cos1.iter().zip(cos2.iter()) {
449 assert_eq!(a.project_id, b.project_id);
450 assert_eq!(a.cost_impact, b.cost_impact);
451 assert_eq!(a.status, b.status);
452 }
453 }
454}