Skip to main content

datasynth_generators/disruption/
mod.rs

1//! Operational disruption modeling.
2//!
3//! Models realistic operational disruptions that can be injected into generated data:
4//! - System outages (missing data windows)
5//! - Migration artifacts (format changes, dual-running periods)
6//! - Process changes (workflow shifts, policy changes)
7//! - Data recovery patterns (backfill, catch-up processing)
8
9use chrono::NaiveDate;
10use datasynth_core::utils::seeded_rng;
11use rand::Rng;
12use rand_chacha::ChaCha8Rng;
13use serde::{Deserialize, Serialize};
14use std::collections::HashMap;
15
16/// Types of operational disruptions.
17#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
18pub enum DisruptionType {
19    /// System outage causing missing data
20    SystemOutage(OutageConfig),
21    /// System migration with format changes
22    SystemMigration(MigrationConfig),
23    /// Process or policy change
24    ProcessChange(ProcessChangeConfig),
25    /// Data recovery or backfill
26    DataRecovery(RecoveryConfig),
27    /// Regulatory compliance change
28    RegulatoryChange(RegulatoryConfig),
29}
30
31/// Configuration for a system outage.
32#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
33pub struct OutageConfig {
34    /// Start of the outage
35    pub start_date: NaiveDate,
36    /// End of the outage
37    pub end_date: NaiveDate,
38    /// Affected systems/modules
39    pub affected_systems: Vec<String>,
40    /// Whether data was completely lost vs just delayed
41    pub data_loss: bool,
42    /// Recovery mode (if not complete loss)
43    pub recovery_mode: Option<RecoveryMode>,
44    /// Outage cause for labeling
45    pub cause: OutageCause,
46}
47
48/// Cause of an outage.
49#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
50pub enum OutageCause {
51    /// Planned maintenance
52    PlannedMaintenance,
53    /// Unplanned system failure
54    SystemFailure,
55    /// Network connectivity issues
56    NetworkOutage,
57    /// Database issues
58    DatabaseFailure,
59    /// Third-party service unavailable
60    VendorOutage,
61    /// Security incident
62    SecurityIncident,
63    /// Natural disaster
64    Disaster,
65}
66
67/// How data was recovered after an outage.
68#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
69pub enum RecoveryMode {
70    /// Transactions processed after recovery with original dates
71    BackdatedRecovery,
72    /// Transactions processed with recovery date
73    CurrentDateRecovery,
74    /// Mix of both approaches
75    MixedRecovery,
76    /// Manual journal entries to reconcile
77    ManualReconciliation,
78}
79
80/// Configuration for a system migration.
81#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
82pub struct MigrationConfig {
83    /// Migration go-live date
84    pub go_live_date: NaiveDate,
85    /// Dual-running period start (before go-live)
86    pub dual_run_start: Option<NaiveDate>,
87    /// Dual-running period end (after go-live)
88    pub dual_run_end: Option<NaiveDate>,
89    /// Source system name
90    pub source_system: String,
91    /// Target system name
92    pub target_system: String,
93    /// Format changes applied
94    pub format_changes: Vec<FormatChange>,
95    /// Account mapping changes
96    pub account_remapping: HashMap<String, String>,
97    /// Data quality issues during migration
98    pub migration_issues: Vec<MigrationIssue>,
99}
100
101/// Types of format changes during migration.
102#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
103pub enum FormatChange {
104    /// Date format change (e.g., MM/DD/YYYY to YYYY-MM-DD)
105    DateFormat {
106        old_format: String,
107        new_format: String,
108    },
109    /// Amount precision change
110    AmountPrecision { old_decimals: u8, new_decimals: u8 },
111    /// Currency code format
112    CurrencyCode {
113        old_format: String,
114        new_format: String,
115    },
116    /// Account number format
117    AccountFormat {
118        old_pattern: String,
119        new_pattern: String,
120    },
121    /// Reference number format
122    ReferenceFormat {
123        old_pattern: String,
124        new_pattern: String,
125    },
126    /// Text encoding change
127    TextEncoding {
128        old_encoding: String,
129        new_encoding: String,
130    },
131    /// Field length change
132    FieldLength {
133        field: String,
134        old_length: usize,
135        new_length: usize,
136    },
137}
138
139/// Issues that can occur during migration.
140#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
141pub enum MigrationIssue {
142    /// Duplicate records created
143    DuplicateRecords { affected_count: usize },
144    /// Missing records not migrated
145    MissingRecords { affected_count: usize },
146    /// Truncated data
147    TruncatedData {
148        field: String,
149        affected_count: usize,
150    },
151    /// Encoding corruption
152    EncodingCorruption { affected_count: usize },
153    /// Mismatched balances
154    BalanceMismatch { variance: f64 },
155    /// Orphaned references
156    OrphanedReferences { affected_count: usize },
157}
158
159/// Configuration for process changes.
160#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
161pub struct ProcessChangeConfig {
162    /// Effective date of the change
163    pub effective_date: NaiveDate,
164    /// Type of process change
165    pub change_type: ProcessChangeType,
166    /// Transition period length in days
167    pub transition_days: u32,
168    /// Whether retroactive changes were applied
169    pub retroactive: bool,
170}
171
172/// Types of process changes.
173#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
174pub enum ProcessChangeType {
175    /// Approval threshold change
176    ApprovalThreshold {
177        old_threshold: f64,
178        new_threshold: f64,
179    },
180    /// New approval level added
181    NewApprovalLevel { level_name: String, threshold: f64 },
182    /// Approval level removed
183    RemovedApprovalLevel { level_name: String },
184    /// Segregation of duties change
185    SodPolicyChange {
186        new_conflicts: Vec<(String, String)>,
187    },
188    /// Account posting rules change
189    PostingRuleChange { affected_accounts: Vec<String> },
190    /// Vendor management change
191    VendorPolicyChange { policy_name: String },
192    /// Period close procedure change
193    CloseProcessChange {
194        old_close_day: u8,
195        new_close_day: u8,
196    },
197    /// Document retention change
198    RetentionPolicyChange { old_years: u8, new_years: u8 },
199}
200
201/// Configuration for data recovery scenarios.
202#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
203pub struct RecoveryConfig {
204    /// When recovery started
205    pub recovery_start: NaiveDate,
206    /// When recovery completed
207    pub recovery_end: NaiveDate,
208    /// Period being recovered
209    pub affected_period_start: NaiveDate,
210    /// Period being recovered end
211    pub affected_period_end: NaiveDate,
212    /// Recovery approach
213    pub recovery_type: RecoveryType,
214    /// Quality of recovered data
215    pub data_quality: RecoveredDataQuality,
216}
217
218/// Types of data recovery.
219#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
220pub enum RecoveryType {
221    /// Full backup restoration
222    BackupRestore,
223    /// Reconstruction from source documents
224    SourceReconstruction,
225    /// Interface file reprocessing
226    InterfaceReplay,
227    /// Manual entry from paper records
228    ManualReentry,
229    /// Partial recovery with estimates
230    PartialWithEstimates,
231}
232
233/// Quality level of recovered data.
234#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
235pub enum RecoveredDataQuality {
236    /// Complete and accurate
237    Complete,
238    /// Minor discrepancies
239    MinorDiscrepancies,
240    /// Estimated values used
241    EstimatedValues,
242    /// Significant gaps remain
243    PartialRecovery,
244}
245
246/// Configuration for regulatory changes.
247#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
248pub struct RegulatoryConfig {
249    /// Effective date
250    pub effective_date: NaiveDate,
251    /// Regulation name
252    pub regulation_name: String,
253    /// Type of regulatory change
254    pub change_type: RegulatoryChangeType,
255    /// Grace period in days
256    pub grace_period_days: u32,
257}
258
259/// Types of regulatory changes.
260#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
261pub enum RegulatoryChangeType {
262    /// New reporting requirement
263    NewReporting { report_name: String },
264    /// Changed chart of accounts structure
265    CoaRestructure,
266    /// New tax rules
267    TaxChange { jurisdiction: String },
268    /// Revenue recognition change
269    RevenueRecognition,
270    /// Lease accounting change
271    LeaseAccounting,
272    /// Data privacy requirement
273    DataPrivacy { regulation: String },
274}
275
276/// A disruption event with timing and effects.
277#[derive(Debug, Clone, Serialize, Deserialize)]
278pub struct DisruptionEvent {
279    /// Unique identifier
280    pub event_id: String,
281    /// Type of disruption
282    pub disruption_type: DisruptionType,
283    /// Detailed description
284    pub description: String,
285    /// Impact severity (1-5)
286    pub severity: u8,
287    /// Affected company codes
288    pub affected_companies: Vec<String>,
289    /// Labels for ML training
290    pub labels: HashMap<String, String>,
291}
292
293/// Manages disruption scenarios for data generation.
294pub struct DisruptionManager {
295    /// Active disruption events
296    events: Vec<DisruptionEvent>,
297    /// Event counter for ID generation
298    event_counter: u64,
299}
300
301impl DisruptionManager {
302    /// Create a new disruption manager.
303    pub fn new() -> Self {
304        Self {
305            events: Vec::new(),
306            event_counter: 0,
307        }
308    }
309
310    /// Add a disruption event.
311    pub fn add_event(
312        &mut self,
313        disruption_type: DisruptionType,
314        description: &str,
315        severity: u8,
316        affected_companies: Vec<String>,
317    ) -> String {
318        self.event_counter += 1;
319        let event_id = format!("DISRUPT-{:06}", self.event_counter);
320
321        let labels = self.generate_labels(&disruption_type);
322
323        let event = DisruptionEvent {
324            event_id: event_id.clone(),
325            disruption_type,
326            description: description.to_string(),
327            severity,
328            affected_companies,
329            labels,
330        };
331
332        self.events.push(event);
333        event_id
334    }
335
336    /// Generate ML labels for a disruption type.
337    fn generate_labels(&self, disruption_type: &DisruptionType) -> HashMap<String, String> {
338        let mut labels = HashMap::new();
339
340        match disruption_type {
341            DisruptionType::SystemOutage(config) => {
342                labels.insert("disruption_category".to_string(), "outage".to_string());
343                labels.insert("cause".to_string(), format!("{:?}", config.cause));
344                labels.insert("data_loss".to_string(), config.data_loss.to_string());
345            }
346            DisruptionType::SystemMigration(config) => {
347                labels.insert("disruption_category".to_string(), "migration".to_string());
348                labels.insert("source_system".to_string(), config.source_system.clone());
349                labels.insert("target_system".to_string(), config.target_system.clone());
350            }
351            DisruptionType::ProcessChange(config) => {
352                labels.insert(
353                    "disruption_category".to_string(),
354                    "process_change".to_string(),
355                );
356                labels.insert(
357                    "change_type".to_string(),
358                    format!("{:?}", config.change_type),
359                );
360                labels.insert("retroactive".to_string(), config.retroactive.to_string());
361            }
362            DisruptionType::DataRecovery(config) => {
363                labels.insert("disruption_category".to_string(), "recovery".to_string());
364                labels.insert(
365                    "recovery_type".to_string(),
366                    format!("{:?}", config.recovery_type),
367                );
368                labels.insert(
369                    "data_quality".to_string(),
370                    format!("{:?}", config.data_quality),
371                );
372            }
373            DisruptionType::RegulatoryChange(config) => {
374                labels.insert("disruption_category".to_string(), "regulatory".to_string());
375                labels.insert("regulation".to_string(), config.regulation_name.clone());
376                labels.insert(
377                    "change_type".to_string(),
378                    format!("{:?}", config.change_type),
379                );
380            }
381        }
382
383        labels
384    }
385
386    /// Check if a date falls within any outage period.
387    pub fn is_in_outage(&self, date: NaiveDate, company_code: &str) -> Option<&DisruptionEvent> {
388        self.events.iter().find(|event| {
389            if !event.affected_companies.contains(&company_code.to_string())
390                && !event.affected_companies.is_empty()
391            {
392                return false;
393            }
394
395            match &event.disruption_type {
396                DisruptionType::SystemOutage(config) => {
397                    date >= config.start_date && date <= config.end_date
398                }
399                _ => false,
400            }
401        })
402    }
403
404    /// Check if a date is in a migration dual-run period.
405    pub fn is_in_dual_run(&self, date: NaiveDate, company_code: &str) -> Option<&DisruptionEvent> {
406        self.events.iter().find(|event| {
407            if !event.affected_companies.contains(&company_code.to_string())
408                && !event.affected_companies.is_empty()
409            {
410                return false;
411            }
412
413            match &event.disruption_type {
414                DisruptionType::SystemMigration(config) => {
415                    let start = config.dual_run_start.unwrap_or(config.go_live_date);
416                    let end = config.dual_run_end.unwrap_or(config.go_live_date);
417                    date >= start && date <= end
418                }
419                _ => false,
420            }
421        })
422    }
423
424    /// Get format changes applicable to a date.
425    pub fn get_format_changes(&self, date: NaiveDate, company_code: &str) -> Vec<&FormatChange> {
426        let mut changes = Vec::new();
427
428        for event in &self.events {
429            if !event.affected_companies.contains(&company_code.to_string())
430                && !event.affected_companies.is_empty()
431            {
432                continue;
433            }
434
435            if let DisruptionType::SystemMigration(config) = &event.disruption_type {
436                if date >= config.go_live_date {
437                    changes.extend(config.format_changes.iter());
438                }
439            }
440        }
441
442        changes
443    }
444
445    /// Get active process changes for a date.
446    pub fn get_active_process_changes(
447        &self,
448        date: NaiveDate,
449        company_code: &str,
450    ) -> Vec<&ProcessChangeConfig> {
451        self.events
452            .iter()
453            .filter(|event| {
454                event.affected_companies.contains(&company_code.to_string())
455                    || event.affected_companies.is_empty()
456            })
457            .filter_map(|event| match &event.disruption_type {
458                DisruptionType::ProcessChange(config) if date >= config.effective_date => {
459                    Some(config)
460                }
461                _ => None,
462            })
463            .collect()
464    }
465
466    /// Check if a date is in a recovery period.
467    pub fn is_in_recovery(&self, date: NaiveDate, company_code: &str) -> Option<&DisruptionEvent> {
468        self.events.iter().find(|event| {
469            if !event.affected_companies.contains(&company_code.to_string())
470                && !event.affected_companies.is_empty()
471            {
472                return false;
473            }
474
475            match &event.disruption_type {
476                DisruptionType::DataRecovery(config) => {
477                    date >= config.recovery_start && date <= config.recovery_end
478                }
479                _ => false,
480            }
481        })
482    }
483
484    /// Get all events.
485    pub fn events(&self) -> &[DisruptionEvent] {
486        &self.events
487    }
488
489    /// Get events affecting a specific company.
490    pub fn events_for_company(&self, company_code: &str) -> Vec<&DisruptionEvent> {
491        self.events
492            .iter()
493            .filter(|e| {
494                e.affected_companies.contains(&company_code.to_string())
495                    || e.affected_companies.is_empty()
496            })
497            .collect()
498    }
499}
500
501impl Default for DisruptionManager {
502    fn default() -> Self {
503        Self::new()
504    }
505}
506
507/// Seed discriminator for the disruption generator RNG stream.
508const DISRUPTION_SEED_DISCRIMINATOR: u64 = 0xD1_5E;
509
510/// Bulk generator that creates realistic disruption events over a date range.
511///
512/// Produces approximately 2 events per year, rotating through the five
513/// disruption categories: system outage, system migration, process change,
514/// data recovery, and regulatory change.
515pub struct DisruptionGenerator {
516    rng: ChaCha8Rng,
517    event_counter: usize,
518}
519
520impl DisruptionGenerator {
521    /// Create a new disruption generator with the given seed.
522    pub fn new(seed: u64) -> Self {
523        Self {
524            rng: seeded_rng(seed, DISRUPTION_SEED_DISCRIMINATOR),
525            event_counter: 0,
526        }
527    }
528
529    /// Generate disruption events spanning `[start_date, end_date)`.
530    ///
531    /// Events are returned sorted by their primary date (outage start, go-live,
532    /// effective date, recovery start, or regulatory effective date).
533    pub fn generate(
534        &mut self,
535        start_date: NaiveDate,
536        end_date: NaiveDate,
537        company_codes: &[String],
538    ) -> Vec<DisruptionEvent> {
539        let total_days = (end_date - start_date).num_days().max(1) as f64;
540        let total_years = total_days / 365.25;
541        let expected_count = (2.0 * total_years).round().max(1.0) as usize;
542
543        let mut events = Vec::with_capacity(expected_count);
544
545        for i in 0..expected_count {
546            // Pick a random date within the range
547            let day_offset = self.rng.random_range(0..total_days as i64);
548            let event_date = start_date + chrono::Duration::days(day_offset);
549
550            // Pick affected companies (1-N from the provided list, or all if empty)
551            let affected = if company_codes.is_empty() {
552                Vec::new()
553            } else {
554                let count = self.rng.random_range(1..=company_codes.len().min(3));
555                let mut selected = Vec::with_capacity(count);
556                for _ in 0..count {
557                    let idx = self.rng.random_range(0..company_codes.len());
558                    let code = company_codes[idx].clone();
559                    if !selected.contains(&code) {
560                        selected.push(code);
561                    }
562                }
563                selected
564            };
565
566            let severity: u8 = self.rng.random_range(1..=5);
567
568            // Rotate through disruption categories
569            let disruption_type = match i % 5 {
570                0 => self.build_outage(event_date),
571                1 => self.build_migration(event_date),
572                2 => self.build_process_change(event_date),
573                3 => self.build_recovery(event_date),
574                _ => self.build_regulatory_change(event_date),
575            };
576
577            self.event_counter += 1;
578            let event_id = format!("DISRUPT-{:06}", self.event_counter);
579
580            let description = match &disruption_type {
581                DisruptionType::SystemOutage(c) => {
582                    format!(
583                        "System outage ({:?}) affecting {:?}",
584                        c.cause, c.affected_systems
585                    )
586                }
587                DisruptionType::SystemMigration(c) => {
588                    format!(
589                        "Migration from {} to {} on {}",
590                        c.source_system, c.target_system, c.go_live_date
591                    )
592                }
593                DisruptionType::ProcessChange(c) => {
594                    format!(
595                        "Process change ({:?}) effective {}",
596                        c.change_type, c.effective_date
597                    )
598                }
599                DisruptionType::DataRecovery(c) => {
600                    format!(
601                        "Data recovery ({:?}) from {} to {}",
602                        c.recovery_type, c.recovery_start, c.recovery_end
603                    )
604                }
605                DisruptionType::RegulatoryChange(c) => {
606                    format!(
607                        "Regulatory change: {} effective {}",
608                        c.regulation_name, c.effective_date
609                    )
610                }
611            };
612
613            let mut labels = HashMap::new();
614            labels.insert(
615                "disruption_category".to_string(),
616                match &disruption_type {
617                    DisruptionType::SystemOutage(_) => "outage",
618                    DisruptionType::SystemMigration(_) => "migration",
619                    DisruptionType::ProcessChange(_) => "process_change",
620                    DisruptionType::DataRecovery(_) => "recovery",
621                    DisruptionType::RegulatoryChange(_) => "regulatory",
622                }
623                .to_string(),
624            );
625            labels.insert("severity".to_string(), severity.to_string());
626
627            events.push(DisruptionEvent {
628                event_id,
629                disruption_type,
630                description,
631                severity,
632                affected_companies: affected,
633                labels,
634            });
635        }
636
637        // Sort by the primary date of each event
638        events.sort_by_key(|e| self.primary_date(e));
639        events
640    }
641
642    /// Extract the primary date from a disruption event for sorting.
643    fn primary_date(&self, event: &DisruptionEvent) -> NaiveDate {
644        match &event.disruption_type {
645            DisruptionType::SystemOutage(c) => c.start_date,
646            DisruptionType::SystemMigration(c) => c.go_live_date,
647            DisruptionType::ProcessChange(c) => c.effective_date,
648            DisruptionType::DataRecovery(c) => c.recovery_start,
649            DisruptionType::RegulatoryChange(c) => c.effective_date,
650        }
651    }
652
653    /// Build a system outage disruption type.
654    fn build_outage(&mut self, base_date: NaiveDate) -> DisruptionType {
655        let duration_days = self.rng.random_range(1..=5);
656        let end_date = base_date + chrono::Duration::days(duration_days);
657
658        let systems = ["GL", "AP", "AR", "MM", "SD", "FI", "CO"];
659        let system_count = self.rng.random_range(1..=3);
660        let affected_systems: Vec<String> = (0..system_count)
661            .map(|_| {
662                let idx = self.rng.random_range(0..systems.len());
663                systems[idx].to_string()
664            })
665            .collect();
666
667        let causes = [
668            OutageCause::PlannedMaintenance,
669            OutageCause::SystemFailure,
670            OutageCause::NetworkOutage,
671            OutageCause::DatabaseFailure,
672            OutageCause::VendorOutage,
673            OutageCause::SecurityIncident,
674            OutageCause::Disaster,
675        ];
676        let cause = causes[self.rng.random_range(0..causes.len())].clone();
677
678        let data_loss = self.rng.random_bool(0.2);
679        let recovery_mode = if data_loss {
680            None
681        } else {
682            let modes = [
683                RecoveryMode::BackdatedRecovery,
684                RecoveryMode::CurrentDateRecovery,
685                RecoveryMode::MixedRecovery,
686                RecoveryMode::ManualReconciliation,
687            ];
688            Some(modes[self.rng.random_range(0..modes.len())].clone())
689        };
690
691        DisruptionType::SystemOutage(OutageConfig {
692            start_date: base_date,
693            end_date,
694            affected_systems,
695            data_loss,
696            recovery_mode,
697            cause,
698        })
699    }
700
701    /// Build a system migration disruption type.
702    fn build_migration(&mut self, base_date: NaiveDate) -> DisruptionType {
703        let dual_run_before = self.rng.random_range(7..=30);
704        let dual_run_after = self.rng.random_range(7..=30);
705
706        let source_systems = ["Legacy ERP", "SAP ECC", "Oracle 11i", "JDE"];
707        let target_systems = ["SAP S/4HANA", "Oracle Cloud", "Workday", "NetSuite"];
708
709        let src_idx = self.rng.random_range(0..source_systems.len());
710        let tgt_idx = self.rng.random_range(0..target_systems.len());
711
712        DisruptionType::SystemMigration(MigrationConfig {
713            go_live_date: base_date,
714            dual_run_start: Some(base_date - chrono::Duration::days(dual_run_before)),
715            dual_run_end: Some(base_date + chrono::Duration::days(dual_run_after)),
716            source_system: source_systems[src_idx].to_string(),
717            target_system: target_systems[tgt_idx].to_string(),
718            format_changes: vec![FormatChange::DateFormat {
719                old_format: "MM/DD/YYYY".to_string(),
720                new_format: "YYYY-MM-DD".to_string(),
721            }],
722            account_remapping: HashMap::new(),
723            migration_issues: Vec::new(),
724        })
725    }
726
727    /// Build a process change disruption type.
728    fn build_process_change(&mut self, base_date: NaiveDate) -> DisruptionType {
729        let transition_days = self.rng.random_range(14..=90);
730        let retroactive = self.rng.random_bool(0.15);
731
732        let change_type = match self.rng.random_range(0..4) {
733            0 => {
734                let old_threshold = self.rng.random_range(5000.0..50000.0);
735                let new_threshold = old_threshold * self.rng.random_range(0.5..1.5);
736                ProcessChangeType::ApprovalThreshold {
737                    old_threshold,
738                    new_threshold,
739                }
740            }
741            1 => ProcessChangeType::NewApprovalLevel {
742                level_name: "Director Review".to_string(),
743                threshold: self.rng.random_range(10000.0..100000.0),
744            },
745            2 => ProcessChangeType::PostingRuleChange {
746                affected_accounts: vec!["4100".to_string(), "4200".to_string()],
747            },
748            _ => {
749                let old_day = self.rng.random_range(3..=10);
750                let new_day = self.rng.random_range(3..=10);
751                ProcessChangeType::CloseProcessChange {
752                    old_close_day: old_day,
753                    new_close_day: new_day,
754                }
755            }
756        };
757
758        DisruptionType::ProcessChange(ProcessChangeConfig {
759            effective_date: base_date,
760            change_type,
761            transition_days,
762            retroactive,
763        })
764    }
765
766    /// Build a data recovery disruption type.
767    fn build_recovery(&mut self, base_date: NaiveDate) -> DisruptionType {
768        let affected_duration = self.rng.random_range(3..=14);
769        let recovery_duration = self.rng.random_range(2..=10);
770        let recovery_start = base_date;
771        let recovery_end = base_date + chrono::Duration::days(recovery_duration);
772        let affected_period_start = base_date - chrono::Duration::days(affected_duration);
773        let affected_period_end = base_date;
774
775        let recovery_types = [
776            RecoveryType::BackupRestore,
777            RecoveryType::SourceReconstruction,
778            RecoveryType::InterfaceReplay,
779            RecoveryType::ManualReentry,
780            RecoveryType::PartialWithEstimates,
781        ];
782        let data_qualities = [
783            RecoveredDataQuality::Complete,
784            RecoveredDataQuality::MinorDiscrepancies,
785            RecoveredDataQuality::EstimatedValues,
786            RecoveredDataQuality::PartialRecovery,
787        ];
788
789        DisruptionType::DataRecovery(RecoveryConfig {
790            recovery_start,
791            recovery_end,
792            affected_period_start,
793            affected_period_end,
794            recovery_type: recovery_types[self.rng.random_range(0..recovery_types.len())].clone(),
795            data_quality: data_qualities[self.rng.random_range(0..data_qualities.len())].clone(),
796        })
797    }
798
799    /// Build a regulatory change disruption type.
800    fn build_regulatory_change(&mut self, base_date: NaiveDate) -> DisruptionType {
801        let grace_period_days = self.rng.random_range(30..=180);
802
803        let regulations = [
804            ("IFRS 17", RegulatoryChangeType::RevenueRecognition),
805            ("ASC 842", RegulatoryChangeType::LeaseAccounting),
806            (
807                "GDPR Extension",
808                RegulatoryChangeType::DataPrivacy {
809                    regulation: "GDPR".to_string(),
810                },
811            ),
812            ("SOX Update", RegulatoryChangeType::CoaRestructure),
813            (
814                "Local Tax Reform",
815                RegulatoryChangeType::TaxChange {
816                    jurisdiction: "US-Federal".to_string(),
817                },
818            ),
819        ];
820
821        let idx = self.rng.random_range(0..regulations.len());
822        let (name, change_type) = regulations[idx].clone();
823
824        DisruptionType::RegulatoryChange(RegulatoryConfig {
825            effective_date: base_date,
826            regulation_name: name.to_string(),
827            change_type,
828            grace_period_days,
829        })
830    }
831}
832
833/// Effects that a disruption can have on generated data.
834#[derive(Debug, Clone, Default)]
835pub struct DisruptionEffect {
836    /// Skip generating data for this date
837    pub skip_generation: bool,
838    /// Apply format transformation
839    pub format_transform: Option<FormatChange>,
840    /// Add recovery/backfill markers
841    pub add_recovery_markers: bool,
842    /// Duplicate to secondary system
843    pub duplicate_to_system: Option<String>,
844    /// Apply process rule changes
845    pub process_changes: Vec<ProcessChangeType>,
846    /// Labels to add to generated records
847    pub labels: HashMap<String, String>,
848}
849
850/// Apply disruption effects to determine how data should be generated.
851pub fn compute_disruption_effect(
852    manager: &DisruptionManager,
853    date: NaiveDate,
854    company_code: &str,
855) -> DisruptionEffect {
856    let mut effect = DisruptionEffect::default();
857
858    // Check for outage
859    if let Some(outage_event) = manager.is_in_outage(date, company_code) {
860        if let DisruptionType::SystemOutage(config) = &outage_event.disruption_type {
861            if config.data_loss {
862                effect.skip_generation = true;
863            } else {
864                effect.add_recovery_markers = true;
865            }
866            effect
867                .labels
868                .insert("outage_event".to_string(), outage_event.event_id.clone());
869        }
870    }
871
872    // Check for dual-run
873    if let Some(migration_event) = manager.is_in_dual_run(date, company_code) {
874        if let DisruptionType::SystemMigration(config) = &migration_event.disruption_type {
875            effect.duplicate_to_system = Some(config.target_system.clone());
876            effect.labels.insert(
877                "migration_event".to_string(),
878                migration_event.event_id.clone(),
879            );
880        }
881    }
882
883    // Check for format changes
884    let format_changes = manager.get_format_changes(date, company_code);
885    if let Some(first_change) = format_changes.first() {
886        effect.format_transform = Some((*first_change).clone());
887    }
888
889    // Check for process changes
890    for process_change in manager.get_active_process_changes(date, company_code) {
891        effect
892            .process_changes
893            .push(process_change.change_type.clone());
894    }
895
896    // Check for recovery period
897    if let Some(recovery_event) = manager.is_in_recovery(date, company_code) {
898        effect.add_recovery_markers = true;
899        effect.labels.insert(
900            "recovery_event".to_string(),
901            recovery_event.event_id.clone(),
902        );
903    }
904
905    effect
906}
907
908#[cfg(test)]
909#[allow(clippy::unwrap_used)]
910mod tests {
911    use super::*;
912
913    #[test]
914    fn test_outage_detection() {
915        let mut manager = DisruptionManager::new();
916
917        let outage = OutageConfig {
918            start_date: NaiveDate::from_ymd_opt(2024, 3, 15).unwrap(),
919            end_date: NaiveDate::from_ymd_opt(2024, 3, 17).unwrap(),
920            affected_systems: vec!["GL".to_string()],
921            data_loss: false,
922            recovery_mode: Some(RecoveryMode::BackdatedRecovery),
923            cause: OutageCause::SystemFailure,
924        };
925
926        manager.add_event(
927            DisruptionType::SystemOutage(outage),
928            "GL system outage",
929            3,
930            vec!["1000".to_string()],
931        );
932
933        // During outage
934        assert!(manager
935            .is_in_outage(NaiveDate::from_ymd_opt(2024, 3, 16).unwrap(), "1000")
936            .is_some());
937
938        // Before outage
939        assert!(manager
940            .is_in_outage(NaiveDate::from_ymd_opt(2024, 3, 14).unwrap(), "1000")
941            .is_none());
942
943        // Different company
944        assert!(manager
945            .is_in_outage(NaiveDate::from_ymd_opt(2024, 3, 16).unwrap(), "2000")
946            .is_none());
947    }
948
949    #[test]
950    fn test_migration_dual_run() {
951        let mut manager = DisruptionManager::new();
952
953        let migration = MigrationConfig {
954            go_live_date: NaiveDate::from_ymd_opt(2024, 7, 1).unwrap(),
955            dual_run_start: Some(NaiveDate::from_ymd_opt(2024, 6, 15).unwrap()),
956            dual_run_end: Some(NaiveDate::from_ymd_opt(2024, 7, 15).unwrap()),
957            source_system: "Legacy".to_string(),
958            target_system: "S4HANA".to_string(),
959            format_changes: vec![FormatChange::DateFormat {
960                old_format: "MM/DD/YYYY".to_string(),
961                new_format: "YYYY-MM-DD".to_string(),
962            }],
963            account_remapping: HashMap::new(),
964            migration_issues: Vec::new(),
965        };
966
967        manager.add_event(
968            DisruptionType::SystemMigration(migration),
969            "S/4HANA migration",
970            4,
971            vec![], // All companies
972        );
973
974        // During dual-run
975        assert!(manager
976            .is_in_dual_run(NaiveDate::from_ymd_opt(2024, 6, 20).unwrap(), "1000")
977            .is_some());
978
979        // After dual-run
980        assert!(manager
981            .is_in_dual_run(NaiveDate::from_ymd_opt(2024, 7, 20).unwrap(), "1000")
982            .is_none());
983    }
984
985    #[test]
986    fn test_process_change() {
987        let mut manager = DisruptionManager::new();
988
989        let process_change = ProcessChangeConfig {
990            effective_date: NaiveDate::from_ymd_opt(2024, 4, 1).unwrap(),
991            change_type: ProcessChangeType::ApprovalThreshold {
992                old_threshold: 10000.0,
993                new_threshold: 5000.0,
994            },
995            transition_days: 30,
996            retroactive: false,
997        };
998
999        manager.add_event(
1000            DisruptionType::ProcessChange(process_change),
1001            "Lower approval threshold",
1002            2,
1003            vec!["1000".to_string()],
1004        );
1005
1006        // After change
1007        let changes = manager
1008            .get_active_process_changes(NaiveDate::from_ymd_opt(2024, 5, 1).unwrap(), "1000");
1009        assert_eq!(changes.len(), 1);
1010
1011        // Before change
1012        let changes = manager
1013            .get_active_process_changes(NaiveDate::from_ymd_opt(2024, 3, 1).unwrap(), "1000");
1014        assert_eq!(changes.len(), 0);
1015    }
1016
1017    #[test]
1018    fn test_compute_disruption_effect() {
1019        let mut manager = DisruptionManager::new();
1020
1021        let outage = OutageConfig {
1022            start_date: NaiveDate::from_ymd_opt(2024, 3, 15).unwrap(),
1023            end_date: NaiveDate::from_ymd_opt(2024, 3, 17).unwrap(),
1024            affected_systems: vec!["GL".to_string()],
1025            data_loss: true,
1026            recovery_mode: None,
1027            cause: OutageCause::SystemFailure,
1028        };
1029
1030        manager.add_event(
1031            DisruptionType::SystemOutage(outage),
1032            "GL system outage with data loss",
1033            5,
1034            vec!["1000".to_string()],
1035        );
1036
1037        let effect = compute_disruption_effect(
1038            &manager,
1039            NaiveDate::from_ymd_opt(2024, 3, 16).unwrap(),
1040            "1000",
1041        );
1042
1043        assert!(effect.skip_generation);
1044        assert!(effect.labels.contains_key("outage_event"));
1045    }
1046
1047    #[test]
1048    fn test_disruption_generator_produces_events() {
1049        let mut gen = DisruptionGenerator::new(42);
1050        let start = NaiveDate::from_ymd_opt(2024, 1, 1).unwrap();
1051        let end = NaiveDate::from_ymd_opt(2025, 12, 31).unwrap();
1052        let companies = vec!["1000".to_string(), "2000".to_string()];
1053
1054        let events = gen.generate(start, end, &companies);
1055
1056        // ~2 events per year over 2 years => ~4 events
1057        assert!(!events.is_empty());
1058        assert!(
1059            events.len() >= 2,
1060            "expected at least 2 events, got {}",
1061            events.len()
1062        );
1063
1064        // Verify sorted by date
1065        for window in events.windows(2) {
1066            let d0 = primary_date_of(&window[0]);
1067            let d1 = primary_date_of(&window[1]);
1068            assert!(d0 <= d1, "events should be sorted by date");
1069        }
1070
1071        // Verify all events have valid fields
1072        for event in &events {
1073            assert!(!event.event_id.is_empty());
1074            assert!(!event.description.is_empty());
1075            assert!(event.severity >= 1 && event.severity <= 5);
1076            assert!(event.labels.contains_key("disruption_category"));
1077        }
1078    }
1079
1080    #[test]
1081    fn test_disruption_generator_deterministic() {
1082        let start = NaiveDate::from_ymd_opt(2024, 1, 1).unwrap();
1083        let end = NaiveDate::from_ymd_opt(2024, 12, 31).unwrap();
1084        let companies = vec!["C001".to_string()];
1085
1086        let events1 = DisruptionGenerator::new(99).generate(start, end, &companies);
1087        let events2 = DisruptionGenerator::new(99).generate(start, end, &companies);
1088
1089        assert_eq!(events1.len(), events2.len());
1090        for (a, b) in events1.iter().zip(events2.iter()) {
1091            assert_eq!(a.event_id, b.event_id);
1092            assert_eq!(a.severity, b.severity);
1093        }
1094    }
1095
1096    /// Helper to extract primary date without needing access to private method.
1097    fn primary_date_of(event: &DisruptionEvent) -> NaiveDate {
1098        match &event.disruption_type {
1099            DisruptionType::SystemOutage(c) => c.start_date,
1100            DisruptionType::SystemMigration(c) => c.go_live_date,
1101            DisruptionType::ProcessChange(c) => c.effective_date,
1102            DisruptionType::DataRecovery(c) => c.recovery_start,
1103            DisruptionType::RegulatoryChange(c) => c.effective_date,
1104        }
1105    }
1106}