use super::funejson::Value; use super::dates_add_days::add_days; use super::dates_day_of_week::day_of_week; use super::fleet_drivers_hours_data::{DRIVING_LIMITS, DRIVING_LIMITS_HISTORY}; const KINDS: [&str; 4] = ["driving", "work", "availability", "rest"]; const WEEK: i64 = 10080; fn is_iso_date(value: &str) -> bool { let bytes = value.as_bytes(); bytes.len() == 10 && bytes[4] == b'-' && bytes[7] == b'-' && bytes .iter() .enumerate() .all(|(i, b)| i == 4 || i == 7 || b.is_ascii_digit()) } fn parse_hh_mm(value: &str) -> Option { let bytes = value.as_bytes(); if bytes.len() != 5 || bytes[2] != b':' { return None; } if !(bytes[0].is_ascii_digit() && bytes[1].is_ascii_digit() && bytes[3].is_ascii_digit() && bytes[4].is_ascii_digit()) { return None; } let hours = ((bytes[0] - b'0') * 10 + (bytes[1] - b'0')) as i64; let minutes = ((bytes[3] - b'0') * 10 + (bytes[4] - b'0')) as i64; if hours > 23 || minutes > 59 { return None; } Some(hours * 60 + minutes) } fn limit_on(rule: &str, on_date: &str) -> i64 { let mut best: Option<(i64, &'static str)> = None; let mut earliest: Option<&'static str> = None; for row in DRIVING_LIMITS { if row.rule != rule { continue; } if earliest.map_or(true, |e| row.valid_from < e) { earliest = Some(row.valid_from); } if on_date < row.valid_from { continue; } if let Some(valid_to) = row.valid_to { if on_date > valid_to { continue; } } if best.map_or(true, |(_, from)| row.valid_from > from) { best = Some((row.value, row.valid_from)); } } if let Some((value, _)) = best { return value; } if DRIVING_LIMITS_HISTORY != "full" { if let Some(earliest) = earliest { if on_date < earliest { panic!( "no drivers' hours limit \"{}\" on {}: this build was installed with history={}, so it only carries limits from {}. Reinstall with history=full for older records.", rule, on_date, DRIVING_LIMITS_HISTORY, earliest ); } } } panic!("no drivers' hours limit \"{}\" on {}", rule, on_date) } fn local_time(start_date: &str, minute: i64) -> String { let day = minute.div_euclid(1440); let in_day = minute - day * 1440; format!("{}T{:02}:{:02}", add_days(start_date, day), in_day / 60, in_day % 60) } struct Pending { rule: &'static str, at_minute: i64, actual: i64, limit: i64, } /// Check a run of tachograph activities against the GB/EU drivers' hours /// rules on driving time and breaks (Regulation (EC) 561/2006, Articles 6 /// and 7). /// /// Activities are back to back from start_date/start_time, in local time. A /// rest of 45 minutes, or 15 then 30, ends a driving period; a rest of 9 hours /// or more is a daily rest and ends the daily driving time. Weeks are fixed /// weeks, Monday 00:00 to Sunday 24:00, so driving across Sunday midnight is /// split. /// /// # Panics /// Panics on a malformed date or time, an unknown kind, a length below one /// minute, or a date before the rules applied. pub fn check_drivers_hours(start_date: &str, start_time: &str, activities: &[DriverActivity]) -> DriversHoursCheck { if !is_iso_date(start_date) { panic!("startDate must be an ISO date (YYYY-MM-DD), received \"{}\"", start_date); } let start_minute = match parse_hh_mm(start_time) { Some(m) => m, None => panic!("startTime must be HH:MM, 24-hour, received \"{}\"", start_time), }; let driving_period = limit_on("driving-period", start_date); let full_break = limit_on("break", start_date); let split_first = limit_on("split-break-first", start_date); let split_second = limit_on("split-break-second", start_date); let daily = limit_on("daily-driving", start_date); let daily_extended = limit_on("daily-driving-extended", start_date); let extensions_allowed = limit_on("extended-days-per-week", start_date); let weekly_limit = limit_on("weekly-driving", start_date); let fortnight_limit = limit_on("fortnightly-driving", start_date); let daily_rest = limit_on("daily-rest-minimum", start_date); // Merge neighbours of the same kind: two rests back to back are one // uninterrupted rest, whatever the recording split them into. let mut segments: Vec<(String, i64)> = Vec::new(); for activity in activities { if !KINDS.contains(&activity.kind.as_str()) { panic!("unknown activity kind \"{}\"", activity.kind); } if activity.minutes < 1 { panic!("minutes must be a whole number of at least 1, received {}", activity.minutes); } match segments.last_mut() { Some(last) if last.0 == activity.kind => last.1 += activity.minutes, _ => segments.push((activity.kind.clone(), activity.minutes)), } } let week_base = (day_of_week(start_date) - 1) * 1440 + start_minute; let mut breaches: Vec = Vec::new(); let mut accumulated: i64 = 0; let mut first_part = false; let mut break_breach: Option = None; let mut day_driving: i64 = 0; let mut day_passed_daily: Option = None; let mut day_week: Option = None; let mut day_breach: Option = None; let mut extensions: Vec<(i64, i64)> = Vec::new(); let mut weekly: Vec<(i64, i64)> = Vec::new(); let mut weekly_breaches: Vec<(i64, usize)> = Vec::new(); let mut fortnight_breaches: Vec<(i64, usize)> = Vec::new(); fn get(map: &[(i64, i64)], key: i64) -> i64 { map.iter().find(|(k, _)| *k == key).map_or(0, |(_, v)| *v) } fn set(map: &mut Vec<(i64, i64)>, key: i64, value: i64) { match map.iter_mut().find(|(k, _)| *k == key) { Some(entry) => entry.1 = value, None => map.push((key, value)), } } let mut total_driving: i64 = 0; let mut offset: i64 = 0; let segment_count = segments.len(); for index in 0..=segment_count { // One pass past the end closes the open driving period and day. let (kind, minutes) = if index < segment_count { (segments[index].0.as_str(), segments[index].1) } else { ("end", 0) }; if kind == "driving" { total_driving += minutes; let mut start = offset; let end = offset + minutes; while start < end { let week = (week_base + start).div_euclid(WEEK); let chunk_end = end.min((week + 1) * WEEK - week_base); let m = chunk_end - start; if break_breach.is_none() && accumulated + m > driving_period { breaches.push(Pending { rule: "break", at_minute: start + (driving_period - accumulated), actual: 0, limit: driving_period }); break_breach = Some(breaches.len() - 1); } accumulated += m; if day_week.is_none() { day_week = Some(week); } if day_passed_daily.is_none() && day_driving + m > daily { day_passed_daily = Some(start + (daily - day_driving)); } if day_breach.is_none() && day_driving + m > daily_extended { breaches.push(Pending { rule: "daily-driving", at_minute: start + (daily_extended - day_driving), actual: 0, limit: daily_extended }); day_breach = Some(breaches.len() - 1); } day_driving += m; let before = get(&weekly, week); if !weekly_breaches.iter().any(|(w, _)| *w == week) && before + m > weekly_limit { breaches.push(Pending { rule: "weekly-driving", at_minute: start + (weekly_limit - before), actual: 0, limit: weekly_limit }); weekly_breaches.push((week, breaches.len() - 1)); } let pair_before = get(&weekly, week - 1) + before; if !fortnight_breaches.iter().any(|(w, _)| *w == week) && pair_before + m > fortnight_limit { breaches.push(Pending { rule: "fortnightly-driving", at_minute: start + (fortnight_limit - pair_before), actual: 0, limit: fortnight_limit }); fortnight_breaches.push((week, breaches.len() - 1)); } set(&mut weekly, week, before + m); start = chunk_end; } } else if kind == "rest" || kind == "end" { let is_end = kind == "end"; if is_end || minutes >= full_break || (first_part && minutes >= split_second) { if let Some(i) = break_breach { breaches[i].actual = accumulated; } accumulated = 0; first_part = false; break_breach = None; } else if minutes >= split_first && accumulated > 0 { first_part = true; } if is_end || minutes >= daily_rest { if day_driving > daily { if let Some(week) = day_week { let used = get(&extensions, week) + 1; set(&mut extensions, week, used); if used > extensions_allowed && day_driving <= daily_extended { if let Some(at) = day_passed_daily { breaches.push(Pending { rule: "daily-driving", at_minute: at, actual: day_driving, limit: daily }); } } } } if let Some(i) = day_breach { breaches[i].actual = day_driving; } day_driving = 0; day_passed_daily = None; day_week = None; day_breach = None; } } offset += minutes; } for (week, i) in &weekly_breaches { breaches[*i].actual = get(&weekly, *week); } for (week, i) in &fortnight_breaches { breaches[*i].actual = get(&weekly, *week - 1) + get(&weekly, *week); } // sort_by is stable, so breaches at the same minute keep the order found. breaches.sort_by(|a, b| a.at_minute.cmp(&b.at_minute)); let ordered: Vec = breaches .iter() .map(|b| HoursBreach { rule: b.rule.to_string(), at: local_time(start_date, start_minute + b.at_minute), actual: b.actual, limit: b.limit, }) .collect(); DriversHoursCheck { compliant: ordered.is_empty(), driving_minutes: total_driving, breaches: ordered } } /// Read one activity from vector JSON, refusing a length Rust's i64 cannot hold. pub fn driver_activity_from_value(value: &Value) -> DriverActivity { let minutes = value.get("minutes"); let whole = match minutes { Value::Int(i) => *i, Value::Float(f) => panic!("minutes must be a whole number of at least 1, received {}", f), _ => panic!("minutes must be a whole number of at least 1, received a non-number"), }; let kind = match value.get("kind") { Value::Str(s) => s.clone(), _ => panic!("unknown activity kind \"\""), }; DriverActivity { kind, minutes: whole } } pub fn hours_breach_to_value(breach: &HoursBreach) -> Value { Value::obj(vec![ ("rule", Value::str(&breach.rule)), ("at", Value::str(&breach.at)), ("actual", Value::Int(breach.actual)), ("limit", Value::Int(breach.limit)), ]) } pub fn drivers_hours_check_to_value(check: &DriversHoursCheck) -> Value { Value::obj(vec![ ("compliant", Value::Bool(check.compliant)), ("drivingMinutes", Value::Int(check.driving_minutes)), ("breaches", Value::Arr(check.breaches.iter().map(hours_breach_to_value).collect())), ]) } pub fn fune_vector(args: &[Value]) -> Value { let activities: Vec = args[2].as_arr().iter().map(driver_activity_from_value).collect(); drivers_hours_check_to_value(&check_drivers_hours(args[0].as_str(), args[1].as_str(), &activities)) }