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