diff --git a/adk-rust/crates/detmir-portal/src/main.rs b/adk-rust/crates/detmir-portal/src/main.rs index 2b5691f..4dcc551 100644 --- a/adk-rust/crates/detmir-portal/src/main.rs +++ b/adk-rust/crates/detmir-portal/src/main.rs @@ -393,6 +393,8 @@ struct WorkforceRolePolicy { #[serde(default)] label: Option, #[serde(default)] + description: Option, + #[serde(default)] planned_hours_per_day: Option, #[serde(default)] default_weight: Option, @@ -409,6 +411,17 @@ struct WeightedActivity { app_seconds: i64, weighted_seconds: i64, matched_applications: usize, + explanation: String, + app_details: Vec, +} + +#[derive(Debug)] +struct AppWeightDetail { + application: String, + seconds: i64, + weight: f64, + weighted_seconds: i64, + matched_rule: String, } #[derive(Debug)] @@ -938,10 +951,10 @@ fn build_reports( let owner_items = workforce_rollup_items(snapshot, "owner_rollups"); let trend = workforce_trend_json(snapshot); let insight_items = workforce_insight_items(snapshot); - let weighted = load_workforce_policy(workforce_policy_path) - .ok() - .flatten() - .and_then(|policy| weighted_activity(snapshot, &policy, metrics.users_count)); + let workforce_policy = load_workforce_policy(workforce_policy_path).ok().flatten(); + let weighted = workforce_policy + .as_ref() + .and_then(|policy| weighted_activity(snapshot, policy, metrics.users_count)); let headline = if summary.operator_ok && summary.severity == "OK" && metrics.open_incidents == 0 { "Контур DetMir работает штатно, критичных действий не требуется" @@ -1051,7 +1064,7 @@ fn build_reports( "items": recommendations.iter().map(|item| report_item("Рекомендация", "INFO", item)).collect::>() } ], - "workforce_policy": workforce_policy_json(weighted.as_ref(), workforce_policy_path), + "workforce_policy": workforce_policy_json(workforce_policy.as_ref(), weighted.as_ref(), workforce_policy_path), "workforce": { "department_comparison": department_items, "owner_comparison": owner_items, @@ -1219,6 +1232,7 @@ fn weighted_activity( let mut app_seconds = 0_i64; let mut weighted_seconds = 0_f64; let mut matched_applications = 0_usize; + let mut app_details = Vec::new(); for app in apps { let name = app.get("application").and_then(Value::as_str).unwrap_or(""); let seconds = app @@ -1230,19 +1244,35 @@ fn weighted_activity( continue; } app_seconds += seconds; - let weight = application_weight(role_policy, name, default_weight); + let (weight, matched_rule) = application_weight_match(role_policy, name, default_weight); if weight > 0.0 { matched_applications += 1; } weighted_seconds += seconds as f64 * weight; + app_details.push(AppWeightDetail { + application: name.to_string(), + seconds, + weight, + weighted_seconds: (seconds as f64 * weight).round() as i64, + matched_rule, + }); } + app_details.sort_by_key(|item| -item.weighted_seconds); + app_details.truncate(12); let weighted_seconds_i64 = weighted_seconds.round() as i64; + let role_label = role_policy + .label + .clone() + .unwrap_or_else(|| role.to_string()); + let explanation = role_policy.description.clone().unwrap_or_else(|| { + format!( + "Роль {}: индекс = взвешенное время приложений / плановое время роли.", + role_label + ) + }); Some(WeightedActivity { role: role.to_string(), - role_label: role_policy - .label - .clone() - .unwrap_or_else(|| role.to_string()), + role_label, index: Some( ((weighted_seconds / planned_seconds as f64) * 100.0) .round() @@ -1252,23 +1282,26 @@ fn weighted_activity( app_seconds, weighted_seconds: weighted_seconds_i64, matched_applications, + explanation, + app_details, }) } -fn application_weight( +fn application_weight_match( role_policy: &WorkforceRolePolicy, application: &str, default_weight: f64, -) -> f64 { +) -> (f64, String) { let app = application.to_lowercase(); role_policy .application_weights .iter() .find_map(|(pattern, weight)| { let pattern = pattern.to_lowercase(); - (!pattern.is_empty() && app.contains(&pattern)).then_some(weight.clamp(0.0, 1.0)) + (!pattern.is_empty() && app.contains(&pattern)) + .then_some((weight.clamp(0.0, 1.0), pattern)) }) - .unwrap_or(default_weight) + .unwrap_or((default_weight, "default_weight".to_string())) } fn weighted_activity_kpi(weighted: Option<&WeightedActivity>) -> Value { @@ -1309,20 +1342,43 @@ fn weighted_activity_item(weighted: Option<&WeightedActivity>, policy_path: &Pat } } -fn workforce_policy_json(weighted: Option<&WeightedActivity>, policy_path: &Path) -> Value { - match weighted { - Some(weighted) => json!({ +fn workforce_policy_json( + policy: Option<&WorkforcePolicy>, + weighted: Option<&WeightedActivity>, + policy_path: &Path, +) -> Value { + match (policy, weighted) { + (Some(policy), Some(weighted)) => json!({ "configured": true, "path": policy_path.display().to_string(), + "default_role": policy.default_role, + "roles_count": policy.roles.len(), + "available_roles": workforce_role_catalog(policy), "role": weighted.role, "role_label": weighted.role_label, + "explanation": weighted.explanation, "index": weighted.index, "planned_seconds": weighted.planned_seconds, "app_seconds": weighted.app_seconds, "weighted_seconds": weighted.weighted_seconds, "matched_applications": weighted.matched_applications, + "app_details": weighted.app_details.iter().map(|item| json!({ + "application": item.application, + "seconds": item.seconds, + "weight": item.weight, + "weighted_seconds": item.weighted_seconds, + "matched_rule": item.matched_rule, + })).collect::>(), }), - None => json!({ + (Some(policy), None) => json!({ + "configured": true, + "path": policy_path.display().to_string(), + "default_role": policy.default_role, + "roles_count": policy.roles.len(), + "available_roles": workforce_role_catalog(policy), + "note": "role/application policy exists, but weighted activity could not be calculated", + }), + (None, _) => json!({ "configured": false, "path": policy_path.display().to_string(), "note": "weighted activity requires role/application policy", @@ -1330,6 +1386,23 @@ fn workforce_policy_json(weighted: Option<&WeightedActivity>, policy_path: &Path } } +fn workforce_role_catalog(policy: &WorkforcePolicy) -> Vec { + policy + .roles + .iter() + .map(|(role, item)| { + json!({ + "role": role, + "label": item.label.clone().unwrap_or_else(|| role.to_string()), + "description": item.description.clone().unwrap_or_default(), + "planned_hours_per_day": item.planned_hours_per_day.unwrap_or(8.0), + "default_weight": item.default_weight.unwrap_or(0.0), + "application_rules": item.application_weights.len(), + }) + }) + .collect() +} + fn default_workforce_role() -> String { "default".to_string() } @@ -3273,6 +3346,10 @@ mod tests { "accountant".to_string(), WorkforceRolePolicy { label: Some("Бухгалтер".to_string()), + description: Some( + "Бухгалтер: высокий вес 1С и офисных документов; развлекательные сайты не учитываются." + .to_string(), + ), planned_hours_per_day: Some(8.0), default_weight: Some(0.2), application_weights: BTreeMap::from([ diff --git a/configs/detmir-workforce-policy.example.json b/configs/detmir-workforce-policy.example.json index 3b463c3..a1a0cd7 100644 --- a/configs/detmir-workforce-policy.example.json +++ b/configs/detmir-workforce-policy.example.json @@ -3,42 +3,123 @@ "roles": { "accountant": { "label": "Бухгалтер", + "description": "Бухгалтер: высокий вес 1С, Excel и документов; браузер учитывается умеренно, развлекательная активность не влияет на индекс.", "planned_hours_per_day": 8, "default_weight": 0.2, "application_weights": { "1с": 1.0, + "1c": 1.0, + "1cv8": 1.0, "excel": 0.9, "word": 0.7, + "outlook": 0.7, + "mail": 0.7, "rdp": 0.8, "browser": 0.4, + "chrome": 0.4, + "edge": 0.4, "youtube": 0.0 } }, - "sales": { - "label": "Продажи", + "operator": { + "label": "Оператор", + "description": "Оператор: основной вес у 1С/CRM/RDP и операционных форм; офис и почта учитываются как вспомогательная активность.", "planned_hours_per_day": 8, - "default_weight": 0.3, + "default_weight": 0.25, "application_weights": { + "1с": 1.0, + "1c": 1.0, "crm": 1.0, - "1с": 0.8, - "browser": 0.8, - "mail": 0.8, - "telegram": 0.4, + "rdp": 0.9, + "browser": 0.6, + "chrome": 0.6, + "edge": 0.6, + "excel": 0.6, + "outlook": 0.5, + "mail": 0.5, + "telegram": 0.3, "youtube": 0.0 } }, "developer": { "label": "Разработчик", + "description": "Разработчик: высокий вес IDE, терминала, git и технического браузера; коммуникации учитываются ниже.", "planned_hours_per_day": 8, "default_weight": 0.3, "application_weights": { "ide": 1.0, "code": 1.0, + "vscode": 1.0, + "visual studio": 1.0, "terminal": 0.9, - "browser": 0.7, + "powershell": 0.9, + "cmd": 0.8, "git": 0.9, + "browser": 0.7, + "chrome": 0.7, + "edge": 0.7, + "telegram": 0.3, "youtube": 0.2 } + }, + "admin": { + "label": "Администратор", + "description": "Администратор: высокий вес RDP, терминала, консоли управления, мониторинга и технических браузерных интерфейсов.", + "planned_hours_per_day": 8, + "default_weight": 0.3, + "application_weights": { + "rdp": 1.0, + "terminal": 1.0, + "powershell": 1.0, + "cmd": 0.9, + "ssh": 1.0, + "proxmox": 1.0, + "grafana": 0.9, + "prometheus": 0.9, + "browser": 0.7, + "chrome": 0.7, + "edge": 0.7, + "telegram": 0.4, + "youtube": 0.1 + } + }, + "manager": { + "label": "Менеджер", + "description": "Менеджер: высокий вес CRM, почты, документов, коммуникаций и бизнес-браузера; 1С учитывается как рабочий контекст.", + "planned_hours_per_day": 8, + "default_weight": 0.35, + "application_weights": { + "crm": 1.0, + "browser": 0.8, + "chrome": 0.8, + "edge": 0.8, + "mail": 0.8, + "outlook": 0.8, + "excel": 0.8, + "word": 0.7, + "telegram": 0.6, + "1с": 0.7, + "1c": 0.7, + "youtube": 0.1 + } + }, + "sales": { + "label": "Продажи", + "description": "Продажи: высокий вес CRM, браузера, почты и 1С; коммуникации учитываются как часть рабочего процесса.", + "planned_hours_per_day": 8, + "default_weight": 0.3, + "application_weights": { + "crm": 1.0, + "1с": 0.8, + "1c": 0.8, + "browser": 0.8, + "chrome": 0.8, + "edge": 0.8, + "mail": 0.8, + "outlook": 0.8, + "telegram": 0.4, + "youtube": 0.0 + } } } } diff --git a/docs/DETMIR_COMMERCIAL_MODULES_RU.md b/docs/DETMIR_COMMERCIAL_MODULES_RU.md index b2b1fdd..48e0bea 100644 --- a/docs/DETMIR_COMMERCIAL_MODULES_RU.md +++ b/docs/DETMIR_COMMERCIAL_MODULES_RU.md @@ -52,13 +52,26 @@ Runtime-файл на сервере портала: - `default_role` задает роль для агрегированного отчета; - `planned_hours_per_day` задает плановый рабочий день для роли; - `default_weight` применяется к приложениям без явного правила; -- `application_weights` задает веса приложений по подстроке имени. +- `application_weights` задает веса приложений по подстроке имени; +- `description` объясняет бизнес-смысл роли и используется для прозрачной + интерпретации weighted KPI. Пример логики: - бухгалтер: `1C = 1.0`, `Excel = 0.9`, `YouTube = 0.0`; -- продажи: `CRM = 1.0`, `browser = 0.8`, `mail = 0.8`; -- разработчик: `IDE/code = 1.0`, `terminal = 0.9`, `browser = 0.7`. +- оператор: `1C/CRM/RDP = 0.9..1.0`, офис и почта ниже; +- разработчик: `IDE/code = 1.0`, `terminal = 0.9`, `browser = 0.7`; +- администратор: `RDP/SSH/terminal/monitoring = 0.9..1.0`; +- менеджер/продажи: `CRM/mail/browser/documents = 0.7..1.0`. + +JSON отчета содержит объяснение расчета: + +- активная роль; +- доступные роли; +- плановое время; +- фактическое app time; +- взвешенное время; +- matched rule по каждому приложению из top breakdown. После изменения runtime-файла нужно перезапустить портал: diff --git a/docs/DETMIR_PORTAL_GUI_PLAN_RU.md b/docs/DETMIR_PORTAL_GUI_PLAN_RU.md index 9f01931..97f5b62 100644 --- a/docs/DETMIR_PORTAL_GUI_PLAN_RU.md +++ b/docs/DETMIR_PORTAL_GUI_PLAN_RU.md @@ -35,6 +35,10 @@ baseline и раздела `Phase 8: Post-MVP Enhancements`. - отчет поддерживает `Взвешенную активность` при наличии `/etc/detmir-portal-workforce-policy.json`; публичный пример лежит в `configs/detmir-workforce-policy.example.json`; +- пример policy содержит типовые роли `accountant`, `operator`, `developer`, + `admin`, `manager`, `sales`; +- JSON портала возвращает объяснение расчета weighted KPI: активную роль, + каталог ролей, planned/app/weighted seconds и matched rule по приложениям; - отчет использует Worktime management snapshot и показывает сравнение подразделений/ответственных за текущий день; - JSON отчета содержит `workforce.department_comparison`,