From 7bc1d4170a36b2717720cc735aaf121dc0a79baf Mon Sep 17 00:00:00 2001 From: igor04091968 Date: Wed, 3 Jun 2026 13:03:24 +0300 Subject: [PATCH] feat(workforce): add interpretation policy config --- adk-rust/crates/worktime-api/src/main.rs | 199 ++++++++++++++++-- ansible/deploy_aw_server.yml | 10 + ansible/group_vars/all.example.yml | 1 + ansible/group_vars/all.yml | 1 + ...orktime-interpretation-policy.example.json | 9 + docs/DETMIR_COMMERCIAL_MODULES_RU.md | 24 +++ docs/DETMIR_PORTAL_GUI_PLAN_RU.md | 3 + 7 files changed, 235 insertions(+), 12 deletions(-) create mode 100644 configs/worktime-interpretation-policy.example.json diff --git a/adk-rust/crates/worktime-api/src/main.rs b/adk-rust/crates/worktime-api/src/main.rs index 4073215..2b60a71 100644 --- a/adk-rust/crates/worktime-api/src/main.rs +++ b/adk-rust/crates/worktime-api/src/main.rs @@ -9,7 +9,8 @@ use std::{ use anyhow::{Context, Result, anyhow}; use chrono::{ - DateTime, Datelike, FixedOffset, Local, NaiveDate, SecondsFormat, TimeDelta, TimeZone, Utc, + DateTime, Datelike, FixedOffset, Local, NaiveDate, NaiveTime, SecondsFormat, TimeDelta, + TimeZone, Utc, }; use clap::Parser; use reqwest::{ @@ -48,6 +49,9 @@ struct Config { manager_trend_min_points: usize, manager_trend_delta_pct: f64, manager_off_hours_threshold_seconds: i64, + manager_night_work_after: Option, + manager_weekend_work_enabled: bool, + manager_interpretation_policy_configured: bool, manager_late_start_grace_minutes: i64, manager_early_finish_grace_minutes: i64, manager_critical_source_max_age_seconds: i64, @@ -102,6 +106,24 @@ struct AliasProfile { exclude: bool, } +#[derive(Debug, Clone, Default, Deserialize)] +struct InterpretationPolicy { + #[serde(default)] + overload_threshold: Option, + #[serde(default)] + underload_threshold: Option, + #[serde(default)] + drop_threshold_pct: Option, + #[serde(default)] + night_work_after: Option, + #[serde(default)] + weekend_work: Option, + #[serde(default)] + min_trend_points: Option, + #[serde(default)] + off_hours_threshold_seconds: Option, +} + #[derive(Debug, Clone)] struct CachedResponse { stored: Instant, @@ -154,6 +176,18 @@ fn env_usize(name: &str, fallback: usize) -> usize { .unwrap_or(fallback) } +fn env_bool(name: &str, fallback: bool) -> bool { + std::env::var(name) + .ok() + .map(|value| value.trim().to_lowercase()) + .and_then(|value| match value.as_str() { + "1" | "true" | "yes" | "on" => Some(true), + "0" | "false" | "no" | "off" => Some(false), + _ => None, + }) + .unwrap_or(fallback) +} + fn build_aw_api_base(raw_url: &str) -> String { let url = raw_url.trim().trim_end_matches('/'); if url.ends_with("/api/0") { @@ -163,16 +197,74 @@ fn build_aw_api_base(raw_url: &str) -> String { } } +fn load_interpretation_policy(path: &Path) -> Option { + if !path.exists() { + return None; + } + serde_json::from_str::(&fs::read_to_string(path).ok()?).ok() +} + +fn threshold_to_pct(value: f64) -> f64 { + if (0.0..=1.0).contains(&value) { + value * 100.0 + } else { + value + } +} + +fn parse_hhmm(value: &str) -> Option { + NaiveTime::parse_from_str(value.trim(), "%H:%M").ok() +} + fn load_config() -> Config { let default_sample_seconds = env_f64("AW_WORKTIME_DEFAULT_SAMPLE_SECONDS", 30.0).max(1.0); let max_sample_seconds = env_f64("AW_WORKTIME_MAX_SAMPLE_SECONDS", 300.0).max(default_sample_seconds); + let manager_interpretation_policy_path = PathBuf::from(env( + "AW_WORKTIME_MANAGER_INTERPRETATION_POLICY", + "/etc/activitywatch/worktime-interpretation-policy.json", + )); + let interpretation_policy = load_interpretation_policy(&manager_interpretation_policy_path); let manager_target_coverage_pct = env_i64("AW_WORKTIME_MANAGER_TARGET_COVERAGE_PCT", 75).clamp(1, 100); - let manager_low_coverage_pct = + let mut manager_low_coverage_pct = env_i64("AW_WORKTIME_MANAGER_LOW_COVERAGE_PCT", 35).clamp(1, 100); - let manager_overload_coverage_pct = + let mut manager_overload_coverage_pct = env_i64("AW_WORKTIME_MANAGER_OVERLOAD_COVERAGE_PCT", 115).clamp(100, 300); + let mut manager_trend_min_points = + env_usize("AW_WORKTIME_MANAGER_TREND_MIN_POINTS", 3).clamp(2, 31); + let mut manager_trend_delta_pct = + env_f64("AW_WORKTIME_MANAGER_TREND_DELTA_PCT", 10.0).clamp(1.0, 100.0); + let mut manager_off_hours_threshold_seconds = + env_i64("AW_WORKTIME_MANAGER_OFF_HOURS_THRESHOLD_SECONDS", 1800).max(60); + let mut manager_night_work_after = + parse_hhmm(&env("AW_WORKTIME_MANAGER_NIGHT_WORK_AFTER", "20:00")); + let mut manager_weekend_work_enabled = + env_bool("AW_WORKTIME_MANAGER_WEEKEND_WORK_ENABLED", true); + if let Some(policy) = interpretation_policy.as_ref() { + if let Some(value) = policy.underload_threshold { + manager_low_coverage_pct = threshold_to_pct(value).round().clamp(1.0, 100.0) as i64; + } + if let Some(value) = policy.overload_threshold { + manager_overload_coverage_pct = + threshold_to_pct(value).round().clamp(1.0, 300.0) as i64; + } + if let Some(value) = policy.drop_threshold_pct { + manager_trend_delta_pct = threshold_to_pct(value).clamp(1.0, 100.0); + } + if let Some(value) = policy.min_trend_points { + manager_trend_min_points = value.clamp(2, 31); + } + if let Some(value) = policy.off_hours_threshold_seconds { + manager_off_hours_threshold_seconds = value.max(60); + } + if let Some(value) = policy.night_work_after.as_deref().and_then(parse_hhmm) { + manager_night_work_after = Some(value); + } + if let Some(value) = policy.weekend_work { + manager_weekend_work_enabled = value; + } + } let report_cache_ttl_seconds = env_i64("AW_WORKTIME_REPORT_CACHE_TTL_SECONDS", 60).max(0); let report_stale_ttl_seconds = env_i64("AW_WORKTIME_REPORT_STALE_TTL_SECONDS", 900).max(report_cache_ttl_seconds); @@ -189,14 +281,12 @@ fn load_config() -> Config { manager_target_coverage_pct, manager_low_coverage_pct, manager_overload_coverage_pct, - manager_trend_min_points: env_usize("AW_WORKTIME_MANAGER_TREND_MIN_POINTS", 3).clamp(2, 31), - manager_trend_delta_pct: env_f64("AW_WORKTIME_MANAGER_TREND_DELTA_PCT", 10.0) - .clamp(1.0, 100.0), - manager_off_hours_threshold_seconds: env_i64( - "AW_WORKTIME_MANAGER_OFF_HOURS_THRESHOLD_SECONDS", - 1800, - ) - .max(60), + manager_trend_min_points, + manager_trend_delta_pct, + manager_off_hours_threshold_seconds, + manager_night_work_after, + manager_weekend_work_enabled, + manager_interpretation_policy_configured: interpretation_policy.is_some(), manager_late_start_grace_minutes: env_i64( "AW_WORKTIME_MANAGER_LATE_START_GRACE_MINUTES", 60, @@ -1540,6 +1630,16 @@ impl App { "trend": trend, "trend_scope": "portfolio", "trend_insights": insights, + "interpretation_policy": { + "configured": self.config.manager_interpretation_policy_configured, + "overload_threshold": self.config.manager_overload_coverage_pct as f64 / 100.0, + "underload_threshold": self.config.manager_low_coverage_pct as f64 / 100.0, + "drop_threshold_pct": self.config.manager_trend_delta_pct, + "night_work_after": self.config.manager_night_work_after.map(|time| time.format("%H:%M").to_string()), + "weekend_work": self.config.manager_weekend_work_enabled, + "min_trend_points": self.config.manager_trend_min_points, + "off_hours_threshold_seconds": self.config.manager_off_hours_threshold_seconds + }, "history": { "enabled": true, "saved_current_point": history_saved, @@ -2373,6 +2473,9 @@ fn add_off_hours_insights( report_date.weekday(), chrono::Weekday::Sat | chrono::Weekday::Sun ); + if is_weekend && !config.manager_weekend_work_enabled { + return; + } if is_weekend && calendar_total >= config.manager_off_hours_threshold_seconds { insights.push(insight( "weekend_work", @@ -2386,7 +2489,33 @@ fn add_off_hours_insights( ), "Проверить, была ли работа согласована и не маскирует ли она аврал или сбой графика.", )); - } else if off_hours >= config.manager_off_hours_threshold_seconds { + return; + } + if let Some(night_after) = config.manager_night_work_after { + let night_users = rows + .iter() + .filter(|row| { + row_local_time(row, "last_activity_local").is_some_and(|time| time >= night_after) + }) + .count(); + if night_users > 0 && off_hours >= config.manager_off_hours_threshold_seconds { + insights.push(insight( + "night_work", + "WARN", + "portfolio", + "Workforce", + "Есть ночная работа", + &format!( + "После {} зафиксирована активность; сотрудников: {}; вне рабочего окна: {}.", + night_after.format("%H:%M"), + night_users, + hhmm(off_hours) + ), + "Проверить согласование ночной работы, авралы и корректность графика.", + )); + } + } + if off_hours >= config.manager_off_hours_threshold_seconds { let users = rows .iter() .filter(|row| { @@ -2417,6 +2546,13 @@ fn add_off_hours_insights( } } +fn row_local_time(row: &Value, key: &str) -> Option { + row.get(key) + .and_then(Value::as_str) + .and_then(|value| DateTime::parse_from_rfc3339(value).ok()) + .map(|dt| dt.time()) +} + fn insight( code: &str, severity: &str, @@ -3225,6 +3361,45 @@ mod tests { ); } + #[test] + fn interpretation_policy_accepts_fraction_thresholds() { + let policy: InterpretationPolicy = serde_json::from_value(json!({ + "overload_threshold": 0.92, + "underload_threshold": 0.45, + "drop_threshold_pct": 20, + "night_work_after": "20:00", + "weekend_work": true + })) + .unwrap(); + assert_eq!(threshold_to_pct(policy.overload_threshold.unwrap()), 92.0); + assert_eq!(threshold_to_pct(policy.underload_threshold.unwrap()), 45.0); + assert_eq!(threshold_to_pct(policy.drop_threshold_pct.unwrap()), 20.0); + assert_eq!( + parse_hhmm(policy.night_work_after.as_deref().unwrap()).unwrap(), + NaiveTime::from_hms_opt(20, 0, 0).unwrap() + ); + assert_eq!(policy.weekend_work, Some(true)); + } + + #[test] + fn weekend_policy_can_disable_weekend_work_insight() { + let mut cfg = test_config(); + cfg.manager_weekend_work_enabled = false; + let insights = build_management_insights( + &cfg, + &json!({ + "calendar_total_active_seconds": 7200, + "workday_total_active_seconds": 0 + }), + &[], + &[], + &[], + &[], + NaiveDate::from_ymd_opt(2026, 5, 16).unwrap(), + ); + assert!(!insights.iter().any(|item| item["code"] == "weekend_work")); + } + #[test] fn true_active_apps_require_evidence() { let cfg = test_config(); diff --git a/ansible/deploy_aw_server.yml b/ansible/deploy_aw_server.yml index f22aea4..008b135 100644 --- a/ansible/deploy_aw_server.yml +++ b/ansible/deploy_aw_server.yml @@ -666,6 +666,7 @@ AW_WORKTIME_MANAGER_TREND_MIN_POINTS={{ aw_worktime_manager_trend_min_points | default(3) }} AW_WORKTIME_MANAGER_TREND_DELTA_PCT={{ aw_worktime_manager_trend_delta_pct | default(10) }} AW_WORKTIME_MANAGER_OFF_HOURS_THRESHOLD_SECONDS={{ aw_worktime_manager_off_hours_threshold_seconds | default(1800) }} + AW_WORKTIME_MANAGER_INTERPRETATION_POLICY={{ aw_worktime_interpretation_policy_path | default('/etc/activitywatch/worktime-interpretation-policy.json') }} AW_DLP_INFLUX_ENABLED={{ 'true' if (aw_dlp_influx_enabled | default(false) | bool) else 'false' }} AW_DLP_INFLUX_URL={{ aw_dlp_influx_url | default('') }} AW_DLP_INFLUX_ORG={{ aw_dlp_influx_org | default('proxmox') }} @@ -1098,6 +1099,15 @@ group: "{{ aw_server_group }}" mode: "0750" + - name: Установить initial policy интерпретации Workforce если отсутствует + ansible.builtin.copy: + dest: "{{ aw_worktime_interpretation_policy_path | default('/etc/activitywatch/worktime-interpretation-policy.json') }}" + owner: root + group: root + mode: "0644" + force: false + content: "{{ lookup('file', aw_repo_root + '/configs/worktime-interpretation-policy.example.json') }}" + - name: Проверить локальный Rust AW worktime API ansible.builtin.stat: path: "{{ aw_rust_release_dir }}/worktime-api" diff --git a/ansible/group_vars/all.example.yml b/ansible/group_vars/all.example.yml index b9a137d..7716c5d 100644 --- a/ansible/group_vars/all.example.yml +++ b/ansible/group_vars/all.example.yml @@ -26,6 +26,7 @@ aw_worktime_manager_overload_coverage_pct: 115 aw_worktime_manager_trend_min_points: 3 aw_worktime_manager_trend_delta_pct: 10 aw_worktime_manager_off_hours_threshold_seconds: 1800 +aw_worktime_interpretation_policy_path: "/etc/activitywatch/worktime-interpretation-policy.json" aw_dlp_influx_enabled: false aw_dlp_influx_url: "http://:8086" aw_dlp_influx_org: "proxmox" diff --git a/ansible/group_vars/all.yml b/ansible/group_vars/all.yml index 0eabe66..192e549 100644 --- a/ansible/group_vars/all.yml +++ b/ansible/group_vars/all.yml @@ -26,6 +26,7 @@ aw_worktime_manager_overload_coverage_pct: 115 aw_worktime_manager_trend_min_points: 3 aw_worktime_manager_trend_delta_pct: 10 aw_worktime_manager_off_hours_threshold_seconds: 1800 +aw_worktime_interpretation_policy_path: "/etc/activitywatch/worktime-interpretation-policy.json" aw_dlp_influx_enabled: true aw_dlp_influx_url: "http://192.0.2.10:8086" aw_dlp_influx_org: "proxmox" diff --git a/configs/worktime-interpretation-policy.example.json b/configs/worktime-interpretation-policy.example.json new file mode 100644 index 0000000..f9b5fa0 --- /dev/null +++ b/configs/worktime-interpretation-policy.example.json @@ -0,0 +1,9 @@ +{ + "overload_threshold": 0.92, + "underload_threshold": 0.45, + "drop_threshold_pct": 20, + "night_work_after": "20:00", + "weekend_work": true, + "min_trend_points": 3, + "off_hours_threshold_seconds": 1800 +} diff --git a/docs/DETMIR_COMMERCIAL_MODULES_RU.md b/docs/DETMIR_COMMERCIAL_MODULES_RU.md index 0d362a3..b2b1fdd 100644 --- a/docs/DETMIR_COMMERCIAL_MODULES_RU.md +++ b/docs/DETMIR_COMMERCIAL_MODULES_RU.md @@ -85,6 +85,30 @@ Worktime API сохраняет daily history как агрегированны - `AW_WORKTIME_MANAGEMENT_HISTORY_DIR`; - `AW_WORKTIME_MANAGEMENT_HISTORY_DAYS`; - `AW_WORKTIME_MANAGEMENT_HISTORY_RETENTION_DAYS`. + +Интерпретация трендов настраивается через customer policy: + +- пример: `configs/worktime-interpretation-policy.example.json`; +- runtime-файл: `/etc/activitywatch/worktime-interpretation-policy.json`; +- env-путь: `AW_WORKTIME_MANAGER_INTERPRETATION_POLICY`. + +Пример policy: + +```json +{ + "overload_threshold": 0.92, + "underload_threshold": 0.45, + "drop_threshold_pct": 20, + "night_work_after": "20:00", + "weekend_work": true +} +``` + +`overload_threshold` и `underload_threshold` можно задавать дробью +`0.92`/`0.45` или процентом `92`/`45`; внутри они нормализуются к процентам. +Если policy-файл отсутствует или отдельное поле не задано, используются +env/default значения: + - `AW_WORKTIME_MANAGER_OVERLOAD_COVERAGE_PCT`; - `AW_WORKTIME_MANAGER_TREND_MIN_POINTS`; - `AW_WORKTIME_MANAGER_TREND_DELTA_PCT`; diff --git a/docs/DETMIR_PORTAL_GUI_PLAN_RU.md b/docs/DETMIR_PORTAL_GUI_PLAN_RU.md index 0b689ea..9f01931 100644 --- a/docs/DETMIR_PORTAL_GUI_PLAN_RU.md +++ b/docs/DETMIR_PORTAL_GUI_PLAN_RU.md @@ -45,6 +45,9 @@ baseline и раздела `Phase 8: Post-MVP Enhancements`. `Выводы Workforce`; - insights включают текущую недогрузку/перегрузку, рост/падение тренда, просадку относительно нормы, работу вне рабочего окна и выходные; +- пороги интерпретации задаются customer policy + `/etc/activitywatch/worktime-interpretation-policy.json`, публичный пример: + `configs/worktime-interpretation-policy.example.json`; - `trend_status=daily_only` означает, что месячный тренд еще нельзя интерпретировать как полноценный отчет; для `monthly_ready` нужна накопленная daily history;