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.
5use 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
16/// Generates [`ChangeOrder`] records for projects.
17pub struct ChangeOrderGenerator {
18    rng: ChaCha8Rng,
19    config: ChangeOrderSchemaConfig,
20    counter: u64,
21}
22
23impl ChangeOrderGenerator {
24    /// Create a new change order generator.
25    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    /// Generate change orders for a set of projects.
34    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            // Check if this project gets change orders
49            if self.rng.gen::<f64>() >= self.config.probability {
50                continue;
51            }
52
53            let co_count = self.rng.gen_range(1..=self.config.max_per_project);
54
55            for number in 1..=co_count {
56                self.counter += 1;
57
58                // Submit at a random point during the project
59                let day_offset = self.rng.gen_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                // Cost impact: 2-15% of project budget
66                let impact_pct: f64 = self.rng.gen_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                // Estimated cost impact is usually close to contract impact
72                let est_factor: f64 = self.rng.gen_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                // Schedule impact: 0-60 days
78                let schedule_days = self.rng.gen_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                // Approve based on config rate
92                if self.rng.gen::<f64>() < self.config.approval_rate {
93                    let approval_delay = self.rng.gen_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.gen::<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.gen::<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
152/// Generates [`ProjectMilestone`] records for projects.
153pub struct MilestoneGenerator {
154    rng: ChaCha8Rng,
155    config: MilestoneSchemaConfig,
156    counter: u64,
157}
158
159impl MilestoneGenerator {
160    /// Create a new milestone generator.
161    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    /// Generate milestones for a set of projects.
170    ///
171    /// Distributes milestones evenly across the project duration,
172    /// with payment milestones based on the configured rate.
173    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                // Assign to first WBS element if available
215                if let Some(wbs) = project.wbs_elements.first() {
216                    ms = ms.with_wbs(&wbs.wbs_id);
217                }
218
219                // Payment milestone?
220                if self.rng.gen::<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                // EVM weight
227                let weight = dec!(1) / Decimal::from(ms_count.max(1));
228                ms = ms.with_weight(weight.round_dp(4));
229
230                // Determine status based on reference date
231                if planned_date <= reference_date {
232                    if self.rng.gen::<f64>() < 0.85 {
233                        // Completed (possibly late)
234                        ms.status = MilestoneStatus::Completed;
235                        let variance_days: i64 = self.rng.gen_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                // Otherwise stays Pending
244
245                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, // Force all projects to get change orders
283            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, // All approved (subject to date constraints)
316        };
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        // Most should be approved; some submitted late may miss the window
323        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        // Check that milestones are sequenced
367        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"), // Reference: mid-year
397        );
398
399        // Early milestones should be completed or overdue
400        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, // All are payment milestones
423        };
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}