diff --git a/crates/fidc-core/src/engine.rs b/crates/fidc-core/src/engine.rs index 4029c4a..dc25d1c 100644 --- a/crates/fidc-core/src/engine.rs +++ b/crates/fidc-core/src/engine.rs @@ -4121,6 +4121,12 @@ fn collect_scheduled_decisions( // execution date. Triggering schedules with the execution date would let a // T+1 calendar state suppress or create T-day signals. for rule in scheduler.triggered_rules_at(decision_date, stage, current_time, rules) { + let distinct_timed_minute_count = rules + .iter() + .filter(|candidate| candidate.stage == stage && candidate.name == rule.name) + .filter_map(|candidate| candidate.time_rule.as_ref()?.minute_of_day()) + .collect::>() + .len(); publish_phase_event( strategy, process_event_bus, @@ -4136,7 +4142,13 @@ fn collect_scheduled_decisions( process_events, execution_date, ProcessEventKind::PreScheduled, - format!("scheduled:{}:{}:pre", rule.name, stage_label(stage)), + scheduled_event_detail( + rule, + stage, + current_time, + distinct_timed_minute_count, + "pre", + ), )?; combined.merge_from(strategy.on_scheduled( &StrategyContext { @@ -4172,12 +4184,39 @@ fn collect_scheduled_decisions( process_events, execution_date, ProcessEventKind::PostScheduled, - format!("scheduled:{}:{}:post", rule.name, stage_label(stage)), + scheduled_event_detail( + rule, + stage, + current_time, + distinct_timed_minute_count, + "post", + ), )?; } Ok(combined) } +fn scheduled_event_detail( + rule: &ScheduleRule, + stage: ScheduleStage, + current_time: Option, + distinct_timed_minute_count: usize, + phase: &str, +) -> String { + if distinct_timed_minute_count > 1 + && rule.time_rule.is_some() + && let Some(time) = current_time + { + return format!( + "scheduled:{}:{}:{}:{phase}", + rule.name, + stage_label(stage), + time.format("%H:%M") + ); + } + format!("scheduled:{}:{}:{phase}", rule.name, stage_label(stage)) +} + fn collect_scheduled_decisions_for_stage( strategy: &mut S, scheduler: &Scheduler<'_>, @@ -5636,6 +5675,34 @@ mod tests { ); } + #[test] + fn scheduled_event_detail_records_actual_time_only_for_timed_rules() { + let timed = ScheduleRule::daily("timed", ScheduleStage::OnDay) + .with_time_rule(ScheduleTimeRule::physical_time(10, 18)); + let untimed = ScheduleRule::daily("untimed", ScheduleStage::OnDay); + + assert_eq!( + super::scheduled_event_detail( + &timed, + ScheduleStage::OnDay, + NaiveTime::from_hms_opt(10, 18, 0), + 2, + "pre", + ), + "scheduled:timed:on_day:10:18:pre" + ); + assert_eq!( + super::scheduled_event_detail( + &untimed, + ScheduleStage::OnDay, + NaiveTime::from_hms_opt(15, 0, 0), + 0, + "post", + ), + "scheduled:untimed:on_day:post" + ); + } + #[test] fn current_close_order_at_1500_loads_and_uses_post_close_matching_window() { let date = d(2026, 7, 6);