diff --git a/adk-rust/crates/detmir-portal/src/main.rs b/adk-rust/crates/detmir-portal/src/main.rs index 4dcc551..d82694f 100644 --- a/adk-rust/crates/detmir-portal/src/main.rs +++ b/adk-rust/crates/detmir-portal/src/main.rs @@ -515,6 +515,13 @@ fn handle_request(request: Request, args: &Cli) -> Result<()> { let snapshot = build_snapshot(args); respond_json(request, &build_manager(&snapshot)) } + "/api/workforce/policy/explain" => { + let snapshot = build_snapshot(args); + respond_json( + request, + &build_workforce_policy_explain(&snapshot, &args.workforce_policy_path), + ) + } "/api/owner" => { let snapshot = build_snapshot(args); respond_json(request, &build_owner(&snapshot)) @@ -951,10 +958,7 @@ 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 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 workforce_policy_explain = build_workforce_policy_explain(snapshot, workforce_policy_path); let headline = if summary.operator_ok && summary.severity == "OK" && metrics.open_incidents == 0 { "Контур DetMir работает штатно, критичных действий не требуется" @@ -1007,7 +1011,7 @@ fn build_reports( "executive_points": executive_points, "kpis": [ report_kpi("Индекс активности", workforce_index_text(metrics.workforce_index), workforce_index_status(metrics.workforce_index), "proxy: активное время / плановое рабочее время"), - weighted_activity_kpi(weighted.as_ref()), + weighted_activity_kpi_from_policy(&workforce_policy_explain), report_kpi("Сотрудники", metrics.users_count.to_string(), worktime.status.clone(), "строки worktime за сегодня"), report_kpi("Активное время", human_duration(metrics.active_seconds), worktime.status.clone(), "сумма active_seconds"), report_kpi("Приложения", metrics.apps_count.to_string(), worktime.status.clone(), "true active applications"), @@ -1030,7 +1034,7 @@ fn build_reports( "title": "Работа и управляемость", "items": [ report_item("Индекс активности", workforce_index_status(metrics.workforce_index), workforce_index_text(metrics.workforce_index)), - weighted_activity_item(weighted.as_ref(), workforce_policy_path), + weighted_activity_item_from_policy(&workforce_policy_explain, workforce_policy_path), report_item("Worktime", worktime.status.clone(), worktime.text.clone()), report_item("Management report", snapshot.worktime_management.status.clone(), snapshot.worktime_management.summary.clone()), report_item("Активное время", worktime.status.clone(), human_duration(metrics.active_seconds)), @@ -1064,7 +1068,7 @@ fn build_reports( "items": recommendations.iter().map(|item| report_item("Рекомендация", "INFO", item)).collect::>() } ], - "workforce_policy": workforce_policy_json(workforce_policy.as_ref(), weighted.as_ref(), workforce_policy_path), + "workforce_policy": workforce_policy_explain, "workforce": { "department_comparison": department_items, "owner_comparison": owner_items, @@ -1204,6 +1208,19 @@ fn load_workforce_policy(path: &Path) -> Result> { Ok(Some(policy)) } +fn build_workforce_policy_explain(snapshot: &Snapshot, workforce_policy_path: &Path) -> Value { + let (users_count, _, _) = worktime_totals(snapshot); + let workforce_policy = load_workforce_policy(workforce_policy_path).ok().flatten(); + let weighted = workforce_policy + .as_ref() + .and_then(|policy| weighted_activity(snapshot, policy, users_count)); + workforce_policy_json( + workforce_policy.as_ref(), + weighted.as_ref(), + workforce_policy_path, + ) +} + fn weighted_activity( snapshot: &Snapshot, policy: &WorkforcePolicy, @@ -1304,41 +1321,80 @@ fn application_weight_match( .unwrap_or((default_weight, "default_weight".to_string())) } -fn weighted_activity_kpi(weighted: Option<&WeightedActivity>) -> Value { - match weighted { - Some(weighted) => report_kpi( +fn weighted_activity_kpi_from_policy(policy: &Value) -> Value { + if policy.get("configured").and_then(Value::as_bool) == Some(true) { + report_kpi( "Взвешенная активность", - workforce_index_text(weighted.index), - workforce_index_status(weighted.index), - &format!("role={} по весам приложений", weighted.role_label), - ), - None => report_kpi( + workforce_index_text( + policy + .get("index") + .and_then(Value::as_u64) + .map(|value| value as u8), + ), + workforce_index_status( + policy + .get("index") + .and_then(Value::as_u64) + .map(|value| value as u8), + ), + &format!( + "role={} по весам приложений", + policy + .get("role_label") + .and_then(Value::as_str) + .unwrap_or("неизвестная роль") + ), + ) + } else { + report_kpi( "Взвешенная активность", "не настроена".to_string(), "UNKNOWN".to_string(), "нужен workforce policy с весами приложений", - ), + ) } } -fn weighted_activity_item(weighted: Option<&WeightedActivity>, policy_path: &Path) -> Value { - match weighted { - Some(weighted) => report_item( +fn weighted_activity_item_from_policy(policy: &Value, policy_path: &Path) -> Value { + if policy.get("configured").and_then(Value::as_bool) == Some(true) { + report_item( "Взвешенная активность", - workforce_index_status(weighted.index), + workforce_index_status( + policy + .get("index") + .and_then(Value::as_u64) + .map(|value| value as u8), + ), format!( "{}; роль {}; weighted {}; apps {}", - workforce_index_text(weighted.index), - weighted.role_label, - human_duration(weighted.weighted_seconds), - weighted.matched_applications + workforce_index_text( + policy + .get("index") + .and_then(Value::as_u64) + .map(|value| value as u8) + ), + policy + .get("role_label") + .and_then(Value::as_str) + .unwrap_or("неизвестная роль"), + human_duration( + policy + .get("weighted_seconds") + .and_then(Value::as_i64) + .unwrap_or(0) + ), + policy + .get("matched_applications") + .and_then(Value::as_u64) + .unwrap_or(0) ), - ), - None => report_item( + ) + } else { + report_item( "Взвешенная активность", "UNKNOWN", format!("policy не настроена: {}", policy_path.display()), - ), + ) } } @@ -3365,4 +3421,86 @@ mod tests { assert_eq!(weighted.app_seconds, 7200); assert_eq!(weighted.index, Some(13)); } + + #[test] + fn workforce_policy_explain_is_lightweight_payload() { + let dir = tempfile::tempdir().unwrap(); + let policy_path = dir.path().join("workforce-policy.json"); + fs::write( + &policy_path, + r#"{ + "default_role": "accountant", + "roles": { + "accountant": { + "label": "Бухгалтер", + "description": "Бухгалтерский профиль", + "planned_hours_per_day": 8, + "default_weight": 0.2, + "application_weights": {"1с": 1.0} + } + } + }"#, + ) + .unwrap(); + let snapshot = Snapshot { + generated_at_utc: "2026-06-03T10:00:00Z".to_string(), + detmir_status: SourceStatus { + ok: true, + status: "OK".to_string(), + summary: "".to_string(), + error: None, + payload: None, + }, + detmir_check: SourceStatus { + ok: true, + status: "OK".to_string(), + summary: "".to_string(), + error: None, + payload: None, + }, + failed_units: SourceStatus { + ok: true, + status: "OK".to_string(), + summary: "".to_string(), + error: None, + payload: None, + }, + worktime: SourceStatus { + ok: true, + status: "OK".to_string(), + summary: "".to_string(), + error: None, + payload: Some(json!({ + "rows": [ + {"user": "USER-1", "active_seconds": 3600} + ], + "true_active_apps": [ + {"application": "1С", "proved_work_seconds": 3600} + ] + })), + }, + worktime_management: SourceStatus { + ok: true, + status: "OK".to_string(), + summary: "".to_string(), + error: None, + payload: None, + }, + one_c: SourceStatus { + ok: true, + status: "OK".to_string(), + summary: "".to_string(), + error: None, + payload: None, + }, + }; + let explain = build_workforce_policy_explain(&snapshot, &policy_path); + assert_eq!(explain["configured"], true); + assert_eq!(explain["role"], "accountant"); + assert_eq!(explain["roles_count"], 1); + assert_eq!(explain["app_details"].as_array().unwrap().len(), 1); + assert!(explain.get("workforce").is_none()); + assert!(explain.get("sections").is_none()); + assert!(explain.get("markdown").is_none()); + } } diff --git a/adk-rust/crates/detmir-portal/src/static/app.js b/adk-rust/crates/detmir-portal/src/static/app.js index 068ccb2..a348ccd 100644 --- a/adk-rust/crates/detmir-portal/src/static/app.js +++ b/adk-rust/crates/detmir-portal/src/static/app.js @@ -117,7 +117,7 @@ function renderOperator(data) { `; } -function renderManager(data, reports) { +function renderManager(data, policyExplain) { const workforceIndex = workforceIndexText(data.users_count, data.total_active_seconds); return `

Руководитель

@@ -140,7 +140,7 @@ function renderManager(data, reports) { `).join("")} - ${renderWorkforceIndexExplanation(reports?.workforce_policy)} + ${renderWorkforceIndexExplanation(policyExplain)}

Сотрудники

${(data.users || []).map(user => `
@@ -393,8 +393,8 @@ async function refresh() { const data = await loadJson(`/${state.tab}`); if (state.tab === "operator") content.innerHTML = renderOperator(data); if (state.tab === "manager") { - const reports = await loadJson("/reports").catch(() => null); - content.innerHTML = renderManager(data, reports); + const policyExplain = await loadJson("/workforce/policy/explain").catch(() => null); + content.innerHTML = renderManager(data, policyExplain); } if (state.tab === "owner") content.innerHTML = renderOwner(data); if (state.tab === "incidents") { diff --git a/docs/DETMIR_COMMERCIAL_MODULES_RU.md b/docs/DETMIR_COMMERCIAL_MODULES_RU.md index ca0d71c..6d489a5 100644 --- a/docs/DETMIR_COMMERCIAL_MODULES_RU.md +++ b/docs/DETMIR_COMMERCIAL_MODULES_RU.md @@ -77,6 +77,14 @@ JSON отчета содержит объяснение расчета: роль, итоговый weighted KPI, план/app/weighted time и top-12 приложений с весом и вкладом каждого приложения. +Для быстрой загрузки вкладки руководителя explainability-блок доступен отдельным +легким endpoint: + +- `GET /api/workforce/policy/explain` + +Полный `/api/reports` продолжает включать тот же `workforce_policy`, но вкладка +`Руководитель` не обязана грузить весь отчет ради одного KPI. + После изменения runtime-файла нужно перезапустить портал: ```bash diff --git a/docs/DETMIR_PORTAL_GUI_PLAN_RU.md b/docs/DETMIR_PORTAL_GUI_PLAN_RU.md index 0b70ae4..606f1fd 100644 --- a/docs/DETMIR_PORTAL_GUI_PLAN_RU.md +++ b/docs/DETMIR_PORTAL_GUI_PLAN_RU.md @@ -42,6 +42,8 @@ baseline и раздела `Phase 8: Post-MVP Enhancements`. - вкладки `Руководитель` и `Отчеты` показывают экран `Почему такой индекс?` с ролью, формулой, плановым временем, app time, weighted time и top приложениями с весом/правилом/вкладом; +- вкладка `Руководитель` получает этот блок через легкий endpoint + `/api/workforce/policy/explain`, без загрузки полного `/api/reports`; - отчет использует Worktime management snapshot и показывает сравнение подразделений/ответственных за текущий день; - JSON отчета содержит `workforce.department_comparison`,