1use chrono::NaiveDate;
35use holiday_engine::{get_china_holidays, is_weekend, HolidayInfo};
36use serde::Serialize;
37use shift_algorithm::{get_shift_type_for_date, ShiftCycleConfig, ShiftType};
38use std::collections::{HashMap, HashSet};
39
40#[derive(Debug, Clone)]
43struct DayStatus {
44 date: NaiveDate,
45 is_rest: bool,
46 is_holiday: bool,
47 is_weekend: bool,
48 is_adjusted_work_day: bool,
49 holiday_name: Option<String>,
50}
51
52impl DayStatus {
53 fn is_off(&self) -> bool {
54 self.is_rest
55 || (self.is_holiday && !self.is_adjusted_work_day)
56 || (self.is_weekend && !self.is_adjusted_work_day)
57 }
58}
59
60#[derive(Debug, Clone, Serialize)]
66pub struct LeaveStrategy {
67 pub leave_days: u32,
69 pub total_break_days: u32,
71 pub leave_dates: Vec<NaiveDate>,
73 pub break_start: NaiveDate,
75 pub break_end: NaiveDate,
77 pub holiday_overlap: u32,
79 pub weekend_overlap: u32,
81 pub overlapping_holiday_names: Vec<String>,
83 pub efficiency: f64,
85 pub score: f64,
87}
88
89fn build_daily_status(
92 start_date: NaiveDate,
93 days: u32,
94 team_phase_offset: u32,
95 config: &ShiftCycleConfig,
96 holidays: &HashMap<NaiveDate, HolidayInfo>,
97) -> Vec<DayStatus> {
98 (0..days)
99 .map(|offset| {
100 let date = start_date + chrono::Duration::days(offset as i64);
101 let shift_type = get_shift_type_for_date(date, config, team_phase_offset);
102 let is_rest = matches!(shift_type, ShiftType::Rest | ShiftType::Study);
103 let holiday_info = holidays.get(&date);
104 let is_holiday = holiday_info.is_some_and(|h| h.is_holiday);
105 let is_adjusted_work_day = holiday_info.is_some_and(|h| !h.is_holiday);
106 let holiday_name = if is_holiday {
107 holiday_info.map(|h| h.name.to_string())
108 } else {
109 None
110 };
111 DayStatus { date, is_rest, is_holiday, is_weekend: is_weekend(date), is_adjusted_work_day, holiday_name }
112 })
113 .collect()
114}
115
116pub fn find_best_leave_plans(
149 today: NaiveDate,
150 days_to_analyze: u32,
151 config: &ShiftCycleConfig,
152 team_phase_offset: u32,
153 holidays: Option<&HashMap<NaiveDate, HolidayInfo>>,
154 max_leave_days: u32,
155) -> Vec<LeaveStrategy> {
156 if days_to_analyze < 1 || max_leave_days < 1 {
157 return vec![];
158 }
159
160 let hols = holidays.cloned().unwrap_or_else(get_china_holidays);
161 let status = build_daily_status(today, days_to_analyze, team_phase_offset, config, &hols);
162 let n = status.len();
163
164 let mut rest_before = vec![0u32; n];
165 let mut rest_after = vec![0u32; n];
166
167 for i in 1..n {
168 rest_before[i] = if status[i - 1].is_off() { rest_before[i - 1] + 1 } else { 0 };
169 }
170 for i in (0..n - 1).rev() {
171 rest_after[i] = if status[i + 1].is_off() { rest_after[i + 1] + 1 } else { 0 };
172 }
173
174 let mut strategies: Vec<LeaveStrategy> = Vec::new();
175 let min_leave_days = if max_leave_days == 1 { 1 } else { 2 };
176
177 for leave_days in min_leave_days..=max_leave_days {
178 for start_idx in 0..=(n as i32 - leave_days as i32) {
179 let start_idx = start_idx as usize;
180
181 let has_shift_rest = (0..leave_days).any(|j| status[start_idx + j as usize].is_rest);
182 if has_shift_rest { continue; }
183
184 let left_rest = rest_before[start_idx];
185 let right_rest = rest_after[start_idx + leave_days as usize - 1];
186 let total_break = left_rest + leave_days + right_rest;
187 if total_break <= leave_days { continue; }
188
189 let gap_start = start_idx as i32 - left_rest as i32;
190 let gap_end = start_idx as i32 + leave_days as i32 - 1 + right_rest as i32;
191 let break_start_date = status[gap_start as usize].date;
192 let break_end_date = status[gap_end as usize].date;
193
194 let mut holiday_overlap = 0u32;
195 let mut weekend_overlap = 0u32;
196 let mut holiday_names: HashSet<String> = HashSet::new();
197
198 for idx in gap_start..=gap_end {
199 let ds = &status[idx as usize];
200 if ds.is_holiday { holiday_overlap += 1; if let Some(ref name) = ds.holiday_name { holiday_names.insert(name.clone()); } }
201 if ds.is_weekend && !ds.is_adjusted_work_day { weekend_overlap += 1; }
202 }
203
204 let leave_date_list: Vec<NaiveDate> = (0..leave_days)
205 .map(|j| status[start_idx + j as usize].date)
206 .collect();
207 let efficiency = total_break as f64 / leave_days as f64;
208
209 strategies.push(LeaveStrategy {
210 leave_days, total_break_days: total_break,
211 leave_dates: leave_date_list, break_start: break_start_date,
212 break_end: break_end_date, holiday_overlap, weekend_overlap,
213 overlapping_holiday_names: holiday_names.into_iter().collect(),
214 efficiency, score: 0.0,
215 });
216 }
217 }
218
219 let mut deduped: HashMap<String, LeaveStrategy> = HashMap::new();
221 for s in strategies {
222 let key = format!("{}_{}", s.break_start, s.break_end);
223 match deduped.get(&key) {
224 Some(existing) if existing.leave_days <= s.leave_days => {}
225 _ => { deduped.insert(key, s); }
226 }
227 }
228 if deduped.is_empty() { return vec![]; }
229
230 let deduped_list: Vec<LeaveStrategy> = deduped.into_values().collect();
231
232 let max_efficiency = deduped_list.iter().map(|s| s.efficiency).fold(0.0f64, f64::max);
233 let max_break = deduped_list.iter().map(|s| s.total_break_days).max().unwrap_or(1);
234 let max_family_bonus = deduped_list.iter().map(|s| s.holiday_overlap * 2 + s.weekend_overlap).max().unwrap_or(1).max(1);
235
236 let mut scored: Vec<LeaveStrategy> = deduped_list.into_iter().map(|s| {
237 let eff_score = if max_efficiency > 0.0 { s.efficiency / max_efficiency } else { 0.0 };
238 let len_score = if max_break > 0 { s.total_break_days as f64 / max_break as f64 } else { 0.0 };
239 let family_bonus = (s.holiday_overlap * 2 + s.weekend_overlap) as f64;
240 let fam_score = family_bonus / max_family_bonus as f64;
241 let score = 0.50 * eff_score + 0.25 * len_score + 0.25 * fam_score;
242 LeaveStrategy { score, ..s }
243 }).collect();
244
245 scored.sort_by(|a, b| {
247 b.score.partial_cmp(&a.score)
248 .unwrap_or(std::cmp::Ordering::Equal)
249 .then_with(|| a.break_start.cmp(&b.break_start))
250 });
251 scored
252}
253
254#[cfg(test)]
255mod tests {
256 use super::*;
257 use shift_algorithm::cycle::default_config;
258
259 #[test]
260 fn returns_empty_for_zero_analyze_days() {
261 let config = default_config();
262 let today = NaiveDate::from_ymd_opt(2026, 5, 22).unwrap();
263 let plans = find_best_leave_plans(today, 0, &config, 0, None, 5);
264 assert!(plans.is_empty());
265 }
266
267 #[test]
268 fn returns_empty_for_zero_max_leave() {
269 let config = default_config();
270 let today = NaiveDate::from_ymd_opt(2026, 5, 22).unwrap();
271 let plans = find_best_leave_plans(today, 90, &config, 0, None, 0);
272 assert!(plans.is_empty());
273 }
274
275 #[test]
276 fn produces_strategies_with_default_config() {
277 let config = default_config();
278 let today = NaiveDate::from_ymd_opt(2026, 5, 22).unwrap();
279 let plans = find_best_leave_plans(today, 90, &config, 0, None, 5);
280 assert!(!plans.is_empty());
281 }
282
283 #[test]
284 fn strategies_are_sorted_by_score_desc() {
285 let config = default_config();
286 let today = NaiveDate::from_ymd_opt(2026, 5, 22).unwrap();
287 let plans = find_best_leave_plans(today, 90, &config, 0, None, 5);
288 for w in plans.windows(2) {
289 assert!(w[0].score >= w[1].score);
290 }
291 }
292
293 #[test]
294 fn total_break_exceeds_leave_days() {
295 let config = default_config();
296 let today = NaiveDate::from_ymd_opt(2026, 5, 22).unwrap();
297 let plans = find_best_leave_plans(today, 90, &config, 0, None, 5);
298 for s in &plans {
299 assert!(s.total_break_days > s.leave_days);
300 }
301 }
302
303 #[test]
304 fn leave_dates_count_matches() {
305 let config = default_config();
306 let today = NaiveDate::from_ymd_opt(2026, 5, 22).unwrap();
307 let plans = find_best_leave_plans(today, 90, &config, 0, None, 5);
308 for s in &plans {
309 assert_eq!(s.leave_dates.len() as u32, s.leave_days);
310 }
311 }
312
313 #[test]
314 fn no_duplicate_break_ranges() {
315 let config = default_config();
316 let today = NaiveDate::from_ymd_opt(2026, 5, 22).unwrap();
317 let plans = find_best_leave_plans(today, 180, &config, 0, None, 5);
318 let mut seen: HashSet<(NaiveDate, NaiveDate)> = HashSet::new();
319 for s in &plans {
320 let key = (s.break_start, s.break_end);
321 assert!(seen.insert(key));
322 }
323 }
324
325 #[test]
326 fn respects_max_leave_days() {
327 let config = default_config();
328 let today = NaiveDate::from_ymd_opt(2026, 5, 22).unwrap();
329 let plans = find_best_leave_plans(today, 90, &config, 0, None, 3);
330 for s in &plans {
331 assert!(s.leave_days <= 3);
332 }
333 }
334
335 #[test]
336 fn dates_within_analysis_window() {
337 let config = default_config();
338 let today = NaiveDate::from_ymd_opt(2026, 5, 22).unwrap();
339 let window = 60u32;
340 let end = today + chrono::Duration::days(window as i64);
341 let plans = find_best_leave_plans(today, window, &config, 0, None, 5);
342 for s in &plans {
343 assert!(s.break_start >= today);
344 assert!(s.break_end < end);
345 }
346 }
347
348 #[test]
349 fn includes_national_day_strategies() {
350 let config = default_config();
351 let today = NaiveDate::from_ymd_opt(2026, 9, 1).unwrap();
352 let plans = find_best_leave_plans(today, 60, &config, 0, None, 5);
353 let has_national_day = plans.iter().any(|s| {
354 s.overlapping_holiday_names.iter().any(|n| n.contains("国庆"))
355 });
356 assert!(has_national_day);
357 }
358
359 #[test]
360 fn different_team_produces_different_strategies() {
361 let config = default_config();
362 let today = NaiveDate::from_ymd_opt(2026, 5, 22).unwrap();
363 let plans_team1 = find_best_leave_plans(today, 90, &config, 0, None, 5);
364 let plans_team2 = find_best_leave_plans(today, 90, &config, 7, None, 5);
365 let keys1: HashSet<String> = plans_team1.iter().map(|s| format!("{}_{}", s.break_start, s.break_end)).collect();
366 let keys2: HashSet<String> = plans_team2.iter().map(|s| format!("{}_{}", s.break_start, s.break_end)).collect();
367 assert_ne!(keys1, keys2);
368 }
369
370 #[test]
371 fn custom_cycle_produces_strategies() {
372 use ShiftType::*;
373 let config = ShiftCycleConfig {
374 cycle: vec![Morning, Afternoon, Rest, Night, Rest, Morning, Afternoon],
375 cycle_length: 7,
376 reference_date: shift_algorithm::cycle::default_reference_date(),
377 total_teams: 2,
378 };
379 let today = NaiveDate::from_ymd_opt(2026, 5, 22).unwrap();
380 let plans = find_best_leave_plans(today, 90, &config, 0, None, 5);
381 assert!(!plans.is_empty());
382 }
383
384 #[test]
385 fn efficiency_is_at_least_one() {
386 let config = default_config();
387 let today = NaiveDate::from_ymd_opt(2026, 5, 22).unwrap();
388 let plans = find_best_leave_plans(today, 90, &config, 0, None, 5);
389 for s in &plans { assert!(s.efficiency >= 1.0); }
390 }
391
392 #[test]
393 fn score_is_between_zero_and_one() {
394 let config = default_config();
395 let today = NaiveDate::from_ymd_opt(2026, 5, 22).unwrap();
396 let plans = find_best_leave_plans(today, 90, &config, 0, None, 5);
397 for s in &plans { assert!((0.0..=1.01).contains(&s.score)); }
398 }
399}