Skip to main content

datasynth_generators/project_accounting/
change_order_generator.rs

1//! Change order and milestone generator.
2//!
3//! Probabilistically injects change orders with cost/schedule/revenue impacts
4//! and generates milestones with payment and completion tracking.
5
6use 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
17/// Generates [`ChangeOrder`] records for projects.
18pub struct ChangeOrderGenerator {
19    rng: ChaCha8Rng,
20    uuid_factory: DeterministicUuidFactory,
21    config: ChangeOrderSchemaConfig,
22    counter: u64,
23}
24
25impl ChangeOrderGenerator {
26    /// Create a new change order generator.
27    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    /// Generate change orders for a set of projects.
37    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            // Check if this project gets change orders
52            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                // Submit at a random point during the project
62                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                // Cost impact: 2-15% of project budget
69                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                // Estimated cost impact is usually close to contract impact
75                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                // Schedule impact: 0-60 days
81                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                // Approve based on config rate
95                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
155/// Generates [`ProjectMilestone`] records for projects.
156pub struct MilestoneGenerator {
157    rng: ChaCha8Rng,
158    uuid_factory: DeterministicUuidFactory,
159    config: MilestoneSchemaConfig,
160    counter: u64,
161}
162
163impl MilestoneGenerator {
164    /// Create a new milestone generator.
165    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    /// Generate milestones for a set of projects.
175    ///
176    /// Distributes milestones evenly across the project duration,
177    /// with payment milestones based on the configured rate.
178    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                // Assign to first WBS element if available
220                if let Some(wbs) = project.wbs_elements.first() {
221                    ms = ms.with_wbs(&wbs.wbs_id);
222                }
223
224                // Payment milestone?
225                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                // EVM weight
232                let weight = dec!(1) / Decimal::from(ms_count.max(1));
233                ms = ms.with_weight(weight.round_dp(4));
234
235                // Determine status based on reference date
236                if planned_date <= reference_date {
237                    if self.rng.gen::<f64>() < 0.85 {
238                        // Completed (possibly late)
239                        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                // Otherwise stays Pending
249
250                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, // Force all projects to get change orders
288            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, // All approved (subject to date constraints)
321        };
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        // Most should be approved; some submitted late may miss the window
328        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        // Check that milestones are sequenced
372        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"), // Reference: mid-year
402        );
403
404        // Early milestones should be completed or overdue
405        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, // All are payment milestones
428        };
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}