1use 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#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
18pub enum DisruptionType {
19 SystemOutage(OutageConfig),
21 SystemMigration(MigrationConfig),
23 ProcessChange(ProcessChangeConfig),
25 DataRecovery(RecoveryConfig),
27 RegulatoryChange(RegulatoryConfig),
29}
30
31#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
33pub struct OutageConfig {
34 pub start_date: NaiveDate,
36 pub end_date: NaiveDate,
38 pub affected_systems: Vec<String>,
40 pub data_loss: bool,
42 pub recovery_mode: Option<RecoveryMode>,
44 pub cause: OutageCause,
46}
47
48#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
50pub enum OutageCause {
51 PlannedMaintenance,
53 SystemFailure,
55 NetworkOutage,
57 DatabaseFailure,
59 VendorOutage,
61 SecurityIncident,
63 Disaster,
65}
66
67#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
69pub enum RecoveryMode {
70 BackdatedRecovery,
72 CurrentDateRecovery,
74 MixedRecovery,
76 ManualReconciliation,
78}
79
80#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
82pub struct MigrationConfig {
83 pub go_live_date: NaiveDate,
85 pub dual_run_start: Option<NaiveDate>,
87 pub dual_run_end: Option<NaiveDate>,
89 pub source_system: String,
91 pub target_system: String,
93 pub format_changes: Vec<FormatChange>,
95 pub account_remapping: HashMap<String, String>,
97 pub migration_issues: Vec<MigrationIssue>,
99}
100
101#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
103pub enum FormatChange {
104 DateFormat {
106 old_format: String,
107 new_format: String,
108 },
109 AmountPrecision { old_decimals: u8, new_decimals: u8 },
111 CurrencyCode {
113 old_format: String,
114 new_format: String,
115 },
116 AccountFormat {
118 old_pattern: String,
119 new_pattern: String,
120 },
121 ReferenceFormat {
123 old_pattern: String,
124 new_pattern: String,
125 },
126 TextEncoding {
128 old_encoding: String,
129 new_encoding: String,
130 },
131 FieldLength {
133 field: String,
134 old_length: usize,
135 new_length: usize,
136 },
137}
138
139#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
141pub enum MigrationIssue {
142 DuplicateRecords { affected_count: usize },
144 MissingRecords { affected_count: usize },
146 TruncatedData {
148 field: String,
149 affected_count: usize,
150 },
151 EncodingCorruption { affected_count: usize },
153 BalanceMismatch { variance: f64 },
155 OrphanedReferences { affected_count: usize },
157}
158
159#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
161pub struct ProcessChangeConfig {
162 pub effective_date: NaiveDate,
164 pub change_type: ProcessChangeType,
166 pub transition_days: u32,
168 pub retroactive: bool,
170}
171
172#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
174pub enum ProcessChangeType {
175 ApprovalThreshold {
177 old_threshold: f64,
178 new_threshold: f64,
179 },
180 NewApprovalLevel { level_name: String, threshold: f64 },
182 RemovedApprovalLevel { level_name: String },
184 SodPolicyChange {
186 new_conflicts: Vec<(String, String)>,
187 },
188 PostingRuleChange { affected_accounts: Vec<String> },
190 VendorPolicyChange { policy_name: String },
192 CloseProcessChange {
194 old_close_day: u8,
195 new_close_day: u8,
196 },
197 RetentionPolicyChange { old_years: u8, new_years: u8 },
199}
200
201#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
203pub struct RecoveryConfig {
204 pub recovery_start: NaiveDate,
206 pub recovery_end: NaiveDate,
208 pub affected_period_start: NaiveDate,
210 pub affected_period_end: NaiveDate,
212 pub recovery_type: RecoveryType,
214 pub data_quality: RecoveredDataQuality,
216}
217
218#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
220pub enum RecoveryType {
221 BackupRestore,
223 SourceReconstruction,
225 InterfaceReplay,
227 ManualReentry,
229 PartialWithEstimates,
231}
232
233#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
235pub enum RecoveredDataQuality {
236 Complete,
238 MinorDiscrepancies,
240 EstimatedValues,
242 PartialRecovery,
244}
245
246#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
248pub struct RegulatoryConfig {
249 pub effective_date: NaiveDate,
251 pub regulation_name: String,
253 pub change_type: RegulatoryChangeType,
255 pub grace_period_days: u32,
257}
258
259#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
261pub enum RegulatoryChangeType {
262 NewReporting { report_name: String },
264 CoaRestructure,
266 TaxChange { jurisdiction: String },
268 RevenueRecognition,
270 LeaseAccounting,
272 DataPrivacy { regulation: String },
274}
275
276#[derive(Debug, Clone, Serialize, Deserialize)]
278pub struct DisruptionEvent {
279 pub event_id: String,
281 pub disruption_type: DisruptionType,
283 pub description: String,
285 pub severity: u8,
287 pub affected_companies: Vec<String>,
289 pub labels: HashMap<String, String>,
291}
292
293pub struct DisruptionManager {
295 events: Vec<DisruptionEvent>,
297 event_counter: u64,
299}
300
301impl DisruptionManager {
302 pub fn new() -> Self {
304 Self {
305 events: Vec::new(),
306 event_counter: 0,
307 }
308 }
309
310 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 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 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 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 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 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 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 pub fn events(&self) -> &[DisruptionEvent] {
486 &self.events
487 }
488
489 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
507const DISRUPTION_SEED_DISCRIMINATOR: u64 = 0xD1_5E;
509
510pub struct DisruptionGenerator {
516 rng: ChaCha8Rng,
517 event_counter: usize,
518}
519
520impl DisruptionGenerator {
521 pub fn new(seed: u64) -> Self {
523 Self {
524 rng: seeded_rng(seed, DISRUPTION_SEED_DISCRIMINATOR),
525 event_counter: 0,
526 }
527 }
528
529 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 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 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 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 events.sort_by_key(|e| self.primary_date(e));
639 events
640 }
641
642 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 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 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 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 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 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#[derive(Debug, Clone, Default)]
835pub struct DisruptionEffect {
836 pub skip_generation: bool,
838 pub format_transform: Option<FormatChange>,
840 pub add_recovery_markers: bool,
842 pub duplicate_to_system: Option<String>,
844 pub process_changes: Vec<ProcessChangeType>,
846 pub labels: HashMap<String, String>,
848}
849
850pub fn compute_disruption_effect(
852 manager: &DisruptionManager,
853 date: NaiveDate,
854 company_code: &str,
855) -> DisruptionEffect {
856 let mut effect = DisruptionEffect::default();
857
858 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 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 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 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 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 assert!(manager
935 .is_in_outage(NaiveDate::from_ymd_opt(2024, 3, 16).unwrap(), "1000")
936 .is_some());
937
938 assert!(manager
940 .is_in_outage(NaiveDate::from_ymd_opt(2024, 3, 14).unwrap(), "1000")
941 .is_none());
942
943 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![], );
973
974 assert!(manager
976 .is_in_dual_run(NaiveDate::from_ymd_opt(2024, 6, 20).unwrap(), "1000")
977 .is_some());
978
979 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 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 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 assert!(!events.is_empty());
1058 assert!(
1059 events.len() >= 2,
1060 "expected at least 2 events, got {}",
1061 events.len()
1062 );
1063
1064 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 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 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}