use crate::distributions::drift::{DriftAdjustments, DriftConfig, DriftController};
use crate::models::{
organizational_event::{OrganizationalEvent, OrganizationalEventType},
process_evolution::{ProcessEvolutionEvent, ProcessEvolutionType},
technology_transition::{TechnologyTransitionEvent, TechnologyTransitionType},
};
use chrono::NaiveDate;
use serde::{Deserialize, Serialize};
use std::collections::HashMap;
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
#[serde(rename_all = "snake_case")]
pub enum EffectBlendingMode {
#[default]
Multiplicative,
Additive,
Maximum,
Minimum,
}
#[derive(Debug, Clone, Default)]
pub struct TimelineEffects {
pub drift: DriftAdjustments,
pub volume_multiplier: f64,
pub amount_multiplier: f64,
pub error_rate_delta: f64,
pub processing_time_multiplier: f64,
pub entity_changes: EntityChanges,
pub account_remapping: HashMap<String, String>,
pub control_changes: ControlChanges,
pub special_entries: Vec<SpecialEntryRequest>,
pub active_org_events: Vec<String>,
pub active_process_events: Vec<String>,
pub active_tech_events: Vec<String>,
pub in_parallel_posting: bool,
pub migration_phase: Option<String>,
}
impl TimelineEffects {
pub fn neutral() -> Self {
Self {
drift: DriftAdjustments::none(),
volume_multiplier: 1.0,
amount_multiplier: 1.0,
error_rate_delta: 0.0,
processing_time_multiplier: 1.0,
entity_changes: EntityChanges::default(),
account_remapping: HashMap::new(),
control_changes: ControlChanges::default(),
special_entries: Vec::new(),
active_org_events: Vec::new(),
active_process_events: Vec::new(),
active_tech_events: Vec::new(),
in_parallel_posting: false,
migration_phase: None,
}
}
pub fn combined_volume_multiplier(&self) -> f64 {
self.volume_multiplier * self.drift.combined_volume_multiplier()
}
pub fn combined_amount_multiplier(&self) -> f64 {
self.amount_multiplier * self.drift.combined_amount_multiplier()
}
pub fn total_error_rate(&self, base_error_rate: f64) -> f64 {
(base_error_rate + self.error_rate_delta).clamp(0.0, 1.0)
}
pub fn has_active_events(&self) -> bool {
!self.active_org_events.is_empty()
|| !self.active_process_events.is_empty()
|| !self.active_tech_events.is_empty()
}
}
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
pub struct EntityChanges {
pub entities_added: Vec<String>,
pub entities_removed: Vec<String>,
pub cost_center_remapping: HashMap<String, String>,
pub department_remapping: HashMap<String, String>,
}
impl EntityChanges {
pub fn has_changes(&self) -> bool {
!self.entities_added.is_empty()
|| !self.entities_removed.is_empty()
|| !self.cost_center_remapping.is_empty()
|| !self.department_remapping.is_empty()
}
}
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
pub struct ControlChanges {
pub controls_added: Vec<String>,
pub controls_modified: Vec<String>,
pub controls_removed: Vec<String>,
pub threshold_changes: HashMap<String, (f64, f64)>,
}
impl ControlChanges {
pub fn has_changes(&self) -> bool {
!self.controls_added.is_empty()
|| !self.controls_modified.is_empty()
|| !self.controls_removed.is_empty()
|| !self.threshold_changes.is_empty()
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct SpecialEntryRequest {
pub entry_type: String,
pub description: String,
pub debit_account: String,
pub credit_account: String,
pub amount: Option<rust_decimal::Decimal>,
pub related_event_id: String,
}
#[derive(Debug, Clone, Default)]
pub struct ActiveEventsSummary {
pub org_events: Vec<ActiveEventInfo>,
pub process_events: Vec<ActiveEventInfo>,
pub tech_events: Vec<ActiveEventInfo>,
}
#[derive(Debug, Clone)]
pub struct ActiveEventInfo {
pub event_id: String,
pub event_type: String,
pub progress: f64,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct EventTimelineConfig {
#[serde(default)]
pub org_events: Vec<OrganizationalEvent>,
#[serde(default)]
pub process_events: Vec<ProcessEvolutionEvent>,
#[serde(default)]
pub tech_events: Vec<TechnologyTransitionEvent>,
#[serde(default)]
pub effect_blending: EffectBlendingMode,
#[serde(default)]
pub drift_config: DriftConfig,
}
impl Default for EventTimelineConfig {
fn default() -> Self {
Self {
org_events: Vec::new(),
process_events: Vec::new(),
tech_events: Vec::new(),
effect_blending: EffectBlendingMode::Multiplicative,
drift_config: DriftConfig::default(),
}
}
}
pub struct EventTimeline {
org_events: Vec<OrganizationalEvent>,
process_events: Vec<ProcessEvolutionEvent>,
tech_events: Vec<TechnologyTransitionEvent>,
drift_controller: DriftController,
effect_blending: EffectBlendingMode,
start_date: NaiveDate,
}
impl EventTimeline {
pub fn new(
config: EventTimelineConfig,
seed: u64,
total_periods: u32,
start_date: NaiveDate,
) -> Self {
Self {
org_events: config.org_events,
process_events: config.process_events,
tech_events: config.tech_events,
drift_controller: DriftController::new(config.drift_config, seed, total_periods),
effect_blending: config.effect_blending,
start_date,
}
}
pub fn compute_effects_for_date(&self, date: NaiveDate) -> TimelineEffects {
let period = self.date_to_period(date);
let mut effects = TimelineEffects::neutral();
effects.drift = self.drift_controller.compute_adjustments(period);
effects.volume_multiplier = 1.0;
effects.amount_multiplier = 1.0;
effects.processing_time_multiplier = 1.0;
for event in &self.org_events {
if event.is_active_at(date) {
self.apply_org_event_effects(&mut effects, event, date);
}
}
for event in &self.process_events {
if event.is_active_at(date) {
self.apply_process_event_effects(&mut effects, event, date);
}
}
for event in &self.tech_events {
if event.is_active_at(date) {
self.apply_tech_event_effects(&mut effects, event, date);
}
}
effects
}
pub fn compute_effects_for_period(&self, period: u32) -> TimelineEffects {
let date = self.period_to_date(period);
self.compute_effects_for_date(date)
}
pub fn active_events_at(&self, period: u32) -> ActiveEventsSummary {
let date = self.period_to_date(period);
let mut summary = ActiveEventsSummary::default();
for event in &self.org_events {
if event.is_active_at(date) {
summary.org_events.push(ActiveEventInfo {
event_id: event.event_id.clone(),
event_type: event.event_type.type_name().to_string(),
progress: event.progress_at(date),
});
}
}
for event in &self.process_events {
if event.is_active_at(date) {
summary.process_events.push(ActiveEventInfo {
event_id: event.event_id.clone(),
event_type: event.event_type.type_name().to_string(),
progress: event.progress_at(date),
});
}
}
for event in &self.tech_events {
if event.is_active_at(date) {
summary.tech_events.push(ActiveEventInfo {
event_id: event.event_id.clone(),
event_type: event.event_type.type_name().to_string(),
progress: event.progress_at(date),
});
}
}
summary
}
pub fn in_parallel_run(&self, date: NaiveDate) -> Option<&TechnologyTransitionEvent> {
for event in &self.tech_events {
if let TechnologyTransitionType::ErpMigration(config) = &event.event_type {
if let Some(parallel_start) = config.phases.parallel_run_start {
if date >= parallel_start && date < config.phases.cutover_date {
return Some(event);
}
}
}
}
None
}
pub fn drift_controller(&self) -> &DriftController {
&self.drift_controller
}
fn date_to_period(&self, date: NaiveDate) -> u32 {
let days = (date - self.start_date).num_days();
(days / 30).max(0) as u32
}
fn period_to_date(&self, period: u32) -> NaiveDate {
self.start_date + chrono::Duration::days(period as i64 * 30)
}
fn apply_org_event_effects(
&self,
effects: &mut TimelineEffects,
event: &OrganizationalEvent,
date: NaiveDate,
) {
let progress = event.progress_at(date);
effects.active_org_events.push(event.event_id.clone());
match &event.event_type {
OrganizationalEventType::Acquisition(config) => {
let vol_mult = 1.0 + (config.volume_multiplier - 1.0) * progress;
self.blend_multiplier(&mut effects.volume_multiplier, vol_mult);
let error_rate = config.integration_phases.error_rate_at(date);
effects.error_rate_delta += error_rate;
if progress >= 0.0 {
effects
.entity_changes
.entities_added
.push(config.acquired_entity_code.clone());
}
if config.parallel_posting_days > 0 {
let parallel_end = config.acquisition_date
+ chrono::Duration::days(config.parallel_posting_days as i64);
if date >= config.acquisition_date && date <= parallel_end {
effects.in_parallel_posting = true;
}
}
if let Some(ppa) = &config.purchase_price_allocation {
if progress < 0.1 {
effects.special_entries.push(SpecialEntryRequest {
entry_type: "goodwill".to_string(),
description: format!(
"Goodwill from acquisition of {}",
config.acquired_entity_code
),
debit_account: "1800".to_string(), credit_account: "2100".to_string(), amount: Some(ppa.goodwill),
related_event_id: event.event_id.clone(),
});
}
}
}
OrganizationalEventType::Divestiture(config) => {
let vol_mult = 1.0 - (1.0 - config.volume_reduction) * progress;
self.blend_multiplier(&mut effects.volume_multiplier, vol_mult);
if config.remove_entity && progress >= 1.0 {
effects
.entity_changes
.entities_removed
.push(config.divested_entity_code.clone());
}
if progress < 1.0 {
effects.error_rate_delta += 0.02;
}
}
OrganizationalEventType::Reorganization(config) => {
for (old, new) in &config.cost_center_remapping {
effects
.entity_changes
.cost_center_remapping
.insert(old.clone(), new.clone());
}
for (old, new) in &config.department_remapping {
effects
.entity_changes
.department_remapping
.insert(old.clone(), new.clone());
}
effects.error_rate_delta += config.transition_error_rate * (1.0 - progress);
}
OrganizationalEventType::LeadershipChange(config) => {
effects.error_rate_delta += config.policy_change_error_rate * (1.0 - progress);
}
OrganizationalEventType::WorkforceReduction(config) => {
effects.error_rate_delta += config.error_rate_increase * (1.0 - progress * 0.5);
let time_mult = 1.0 + (config.processing_time_increase - 1.0) * (1.0 - progress);
self.blend_multiplier(&mut effects.processing_time_multiplier, time_mult);
let vol_mult = 1.0 - (config.reduction_percent * 0.3) * progress;
self.blend_multiplier(&mut effects.volume_multiplier, vol_mult);
if let Some(severance) = config.severance_costs {
if progress < 0.1 {
effects.special_entries.push(SpecialEntryRequest {
entry_type: "severance".to_string(),
description: "Workforce reduction severance costs".to_string(),
debit_account: "6500".to_string(), credit_account: "2200".to_string(), amount: Some(severance),
related_event_id: event.event_id.clone(),
});
}
}
}
OrganizationalEventType::Merger(config) => {
let vol_mult = 1.0 + (config.volume_multiplier - 1.0) * progress;
self.blend_multiplier(&mut effects.volume_multiplier, vol_mult);
let error_rate = config.integration_phases.error_rate_at(date);
effects.error_rate_delta += error_rate;
effects
.entity_changes
.entities_added
.push(config.merged_entity_code.clone());
}
}
}
fn apply_process_event_effects(
&self,
effects: &mut TimelineEffects,
event: &ProcessEvolutionEvent,
date: NaiveDate,
) {
let progress = event.progress_at(date);
effects.active_process_events.push(event.event_id.clone());
match &event.event_type {
ProcessEvolutionType::ApprovalWorkflowChange(config) => {
let time_mult = 1.0 + (config.time_delta - 1.0) * progress;
self.blend_multiplier(&mut effects.processing_time_multiplier, time_mult);
effects.error_rate_delta += config.error_rate_impact * (1.0 - progress);
}
ProcessEvolutionType::ProcessAutomation(config) => {
let time_mult = 1.0
- (1.0 - config.processing_time_reduction)
* config.automation_rate_at_progress(progress);
self.blend_multiplier(&mut effects.processing_time_multiplier, time_mult);
let error_rate = config.error_rate_at_progress(progress);
effects.error_rate_delta += error_rate - config.error_rate_before;
}
ProcessEvolutionType::PolicyChange(config) => {
effects.error_rate_delta += config.transition_error_rate * (1.0 - progress);
for control_id in &config.affected_controls {
effects
.control_changes
.controls_modified
.push(control_id.clone());
}
}
ProcessEvolutionType::ControlEnhancement(config) => {
effects.error_rate_delta -= config.error_reduction * progress;
let time_mult = 1.0 + (config.processing_time_impact - 1.0) * progress;
self.blend_multiplier(&mut effects.processing_time_multiplier, time_mult);
effects
.control_changes
.controls_modified
.push(config.control_id.clone());
}
}
}
fn apply_tech_event_effects(
&self,
effects: &mut TimelineEffects,
event: &TechnologyTransitionEvent,
date: NaiveDate,
) {
effects.active_tech_events.push(event.event_id.clone());
match &event.event_type {
TechnologyTransitionType::ErpMigration(config) => {
let phase = config.phases.phase_at(date);
effects.migration_phase = Some(format!("{phase:?}"));
effects.error_rate_delta +=
config.migration_issues.combined_error_rate() * phase.error_rate_multiplier();
self.blend_multiplier(
&mut effects.processing_time_multiplier,
phase.processing_time_multiplier(),
);
if matches!(
phase,
crate::models::technology_transition::MigrationPhase::ParallelRun
) {
effects.in_parallel_posting = true;
}
}
TechnologyTransitionType::ModuleImplementation(config) => {
let progress = event.progress_at(date);
effects.error_rate_delta += config.implementation_error_rate * (1.0 - progress);
let time_mult = 1.0 + 0.2 * (1.0 - progress);
self.blend_multiplier(&mut effects.processing_time_multiplier, time_mult);
}
TechnologyTransitionType::IntegrationUpgrade(config) => {
let progress = event.progress_at(date);
effects.error_rate_delta += config.transition_error_rate * (1.0 - progress);
}
}
}
fn blend_multiplier(&self, current: &mut f64, new: f64) {
*current = match self.effect_blending {
EffectBlendingMode::Multiplicative => *current * new,
EffectBlendingMode::Additive => *current + new - 1.0,
EffectBlendingMode::Maximum => current.max(new),
EffectBlendingMode::Minimum => current.min(new),
};
}
}
#[cfg(test)]
#[allow(clippy::unwrap_used)]
mod tests {
use super::*;
use crate::models::organizational_event::AcquisitionConfig;
#[test]
fn test_empty_timeline() {
let config = EventTimelineConfig::default();
let start = NaiveDate::from_ymd_opt(2024, 1, 1).unwrap();
let timeline = EventTimeline::new(config, 42, 12, start);
let effects = timeline.compute_effects_for_period(6);
assert!((effects.volume_multiplier - 1.0).abs() < 0.001);
assert!((effects.amount_multiplier - 1.0).abs() < 0.001);
assert!(!effects.has_active_events());
}
#[test]
fn test_acquisition_effects() {
let acq_date = NaiveDate::from_ymd_opt(2024, 3, 1).unwrap();
let acq_config = AcquisitionConfig {
acquired_entity_code: "ACME".to_string(),
acquisition_date: acq_date,
volume_multiplier: 1.35,
..Default::default()
};
let event =
OrganizationalEvent::new("ACQ-001", OrganizationalEventType::Acquisition(acq_config));
let config = EventTimelineConfig {
org_events: vec![event],
..Default::default()
};
let start = NaiveDate::from_ymd_opt(2024, 1, 1).unwrap();
let timeline = EventTimeline::new(config, 42, 12, start);
let before =
timeline.compute_effects_for_date(NaiveDate::from_ymd_opt(2024, 2, 1).unwrap());
assert!((before.volume_multiplier - 1.0).abs() < 0.001);
let during =
timeline.compute_effects_for_date(NaiveDate::from_ymd_opt(2024, 4, 1).unwrap());
assert!(during.volume_multiplier > 1.0);
assert!(during.has_active_events());
}
#[test]
fn test_timeline_effects_neutral() {
let effects = TimelineEffects::neutral();
assert!((effects.volume_multiplier - 1.0).abs() < 0.001);
assert!((effects.amount_multiplier - 1.0).abs() < 0.001);
assert!((effects.error_rate_delta).abs() < 0.001);
}
#[test]
fn test_active_events_summary() {
let config = EventTimelineConfig::default();
let start = NaiveDate::from_ymd_opt(2024, 1, 1).unwrap();
let timeline = EventTimeline::new(config, 42, 12, start);
let summary = timeline.active_events_at(6);
assert!(summary.org_events.is_empty());
assert!(summary.process_events.is_empty());
assert!(summary.tech_events.is_empty());
}
}