diff --git a/adk-rust/crates/detmir-portal/src/main.rs b/adk-rust/crates/detmir-portal/src/main.rs index d82694f..55a221e 100644 --- a/adk-rust/crates/detmir-portal/src/main.rs +++ b/adk-rust/crates/detmir-portal/src/main.rs @@ -407,12 +407,15 @@ struct WeightedActivity { role: String, role_label: String, index: Option, + formula: String, planned_seconds: i64, app_seconds: i64, weighted_seconds: i64, matched_applications: usize, explanation: String, app_details: Vec, + policy_audit: Value, + employee_details: Vec, } #[derive(Debug)] @@ -1001,6 +1004,7 @@ fn build_reports( &metrics, &recommendations, &workforce_summary, + &workforce_policy_explain, ); json!({ "generated_at_utc": snapshot.generated_at_utc, @@ -1275,6 +1279,7 @@ fn weighted_activity( }); } app_details.sort_by_key(|item| -item.weighted_seconds); + let policy_audit = build_policy_audit(&app_details, default_weight); app_details.truncate(12); let weighted_seconds_i64 = weighted_seconds.round() as i64; let role_label = role_policy @@ -1295,15 +1300,114 @@ fn weighted_activity( .round() .clamp(0.0, 100.0) as u8, ), + formula: "index = weighted_seconds / planned_seconds × 100".to_string(), planned_seconds, app_seconds, weighted_seconds: weighted_seconds_i64, matched_applications, explanation, app_details, + policy_audit, + employee_details: employee_index_details(snapshot, &role_policy.label, planned_hours), }) } +fn build_policy_audit(app_details: &[AppWeightDetail], default_weight: f64) -> Value { + let default_items = app_details + .iter() + .filter(|item| item.matched_rule == "default_weight") + .map(|item| { + json!({ + "application": item.application, + "seconds": item.seconds, + "weight": item.weight, + "weighted_seconds": item.weighted_seconds, + "reason": "matched no explicit application rule" + }) + }) + .collect::>(); + let zero_weight_items = app_details + .iter() + .filter(|item| item.weight <= 0.0) + .map(|item| { + json!({ + "application": item.application, + "seconds": item.seconds, + "matched_rule": item.matched_rule + }) + }) + .collect::>(); + let default_seconds = app_details + .iter() + .filter(|item| item.matched_rule == "default_weight") + .map(|item| item.seconds) + .sum::(); + json!({ + "default_weight": default_weight, + "total_applications": app_details.len(), + "explicit_rule_applications": app_details.iter().filter(|item| item.matched_rule != "default_weight").count(), + "default_weight_applications": default_items.len(), + "default_weight_seconds": default_seconds, + "zero_weight_applications": zero_weight_items.len(), + "needs_review": default_items.into_iter().take(12).collect::>(), + "zero_weight_details": zero_weight_items.into_iter().take(12).collect::>(), + }) +} + +fn employee_index_details( + snapshot: &Snapshot, + role_label: &Option, + planned_hours_per_day: f64, +) -> Vec { + let planned_seconds = (planned_hours_per_day * 3600.0).round() as i64; + let Some(rows) = snapshot + .worktime + .payload + .as_ref() + .and_then(|payload| payload.get("rows")) + .and_then(Value::as_array) + else { + return Vec::new(); + }; + let role = role_label.as_deref().unwrap_or("default"); + rows.iter() + .map(|row| { + let active_seconds = row + .get("active_seconds") + .and_then(Value::as_i64) + .unwrap_or(0) + .max(0); + let index = if planned_seconds > 0 { + ((active_seconds as f64 / planned_seconds as f64) * 100.0) + .round() + .clamp(0.0, 100.0) as i64 + } else { + 0 + }; + json!({ + "user": row.get("user").and_then(Value::as_str).unwrap_or(""), + "user_id": row.get("user_id").and_then(Value::as_str).unwrap_or(""), + "role_label": role, + "formula": "employee_index = active_seconds / planned_seconds × 100", + "reason": format!( + "active {} / plan {} => {}%", + human_duration(active_seconds), + human_duration(planned_seconds), + index + ), + "index": index, + "status": workforce_index_status(Some(index as u8)), + "active_seconds": active_seconds, + "active_hhmm": row.get("active_hhmm").and_then(Value::as_str).unwrap_or(""), + "planned_seconds": planned_seconds, + "planned_hhmm": human_duration(planned_seconds), + "last_activity": row.get("last_activity").and_then(Value::as_str).unwrap_or(""), + "scope_note": "per-user app-weight attribution is not available in current worktime payload; app weights are portfolio-level" + }) + }) + .collect() +} + fn application_weight_match( role_policy: &WorkforceRolePolicy, application: &str, @@ -1413,11 +1517,14 @@ fn workforce_policy_json( "role": weighted.role, "role_label": weighted.role_label, "explanation": weighted.explanation, + "formula": weighted.formula, "index": weighted.index, "planned_seconds": weighted.planned_seconds, "app_seconds": weighted.app_seconds, "weighted_seconds": weighted.weighted_seconds, "matched_applications": weighted.matched_applications, + "policy_audit": weighted.policy_audit, + "employee_details": weighted.employee_details, "app_details": weighted.app_details.iter().map(|item| json!({ "application": item.application, "seconds": item.seconds, @@ -1487,6 +1594,7 @@ fn render_report_markdown( metrics: &ReportMetrics, recommendations: &[String], workforce: &ReportWorkforceSummary, + workforce_policy: &Value, ) -> String { let mut text = String::new(); text.push_str("# DetMir оперативный отчет\n\n"); @@ -1540,10 +1648,140 @@ fn render_report_markdown( for item in recommendations { text.push_str(&format!("- {item}\n")); } + append_workforce_policy_markdown(&mut text, workforce_policy); text.push_str("\nПримечание: DLP/case показатели являются derived detections/cases и требуют регламентной валидации перед подачей как подтвержденные инциденты.\n"); text } +fn append_workforce_policy_markdown(text: &mut String, policy: &Value) { + if policy.get("configured").and_then(Value::as_bool) != Some(true) { + text.push_str("\n## Почему такой индекс\n\n"); + text.push_str("- Role/application policy не настроена.\n"); + return; + } + text.push_str("\n## Почему такой индекс\n\n"); + text.push_str(&format!( + "- Роль: {}\n", + policy + .get("role_label") + .and_then(Value::as_str) + .unwrap_or("-") + )); + text.push_str(&format!( + "- Формула: {}\n", + policy + .get("formula") + .and_then(Value::as_str) + .unwrap_or("index = weighted_seconds / planned_seconds × 100") + )); + text.push_str(&format!( + "- Индекс: {}\n", + workforce_index_text( + policy + .get("index") + .and_then(Value::as_u64) + .map(|value| value as u8) + ) + )); + text.push_str(&format!( + "- План/App/Weighted: {}/{}/{}\n", + human_duration( + policy + .get("planned_seconds") + .and_then(Value::as_i64) + .unwrap_or(0) + ), + human_duration( + policy + .get("app_seconds") + .and_then(Value::as_i64) + .unwrap_or(0) + ), + human_duration( + policy + .get("weighted_seconds") + .and_then(Value::as_i64) + .unwrap_or(0) + ) + )); + if let Some(explanation) = policy.get("explanation").and_then(Value::as_str) { + text.push_str(&format!("- Объяснение: {explanation}\n")); + } + text.push_str("\n### Top приложений\n\n"); + for item in policy + .get("app_details") + .and_then(Value::as_array) + .into_iter() + .flatten() + .take(12) + { + text.push_str(&format!( + "- {}: raw {}, weight {:.0}%, weighted {}, rule `{}`\n", + item.get("application") + .and_then(Value::as_str) + .unwrap_or("-"), + human_duration(item.get("seconds").and_then(Value::as_i64).unwrap_or(0)), + item.get("weight").and_then(Value::as_f64).unwrap_or(0.0) * 100.0, + human_duration( + item.get("weighted_seconds") + .and_then(Value::as_i64) + .unwrap_or(0) + ), + item.get("matched_rule") + .and_then(Value::as_str) + .unwrap_or("-") + )); + } + let default_items = policy + .get("policy_audit") + .and_then(|audit| audit.get("needs_review")) + .and_then(Value::as_array) + .cloned() + .unwrap_or_default(); + if !default_items.is_empty() { + text.push_str("\n### Аудит policy: default_weight\n\n"); + for item in default_items.iter().take(12) { + text.push_str(&format!( + "- {}: raw {}, default weight {:.0}%\n", + item.get("application") + .and_then(Value::as_str) + .unwrap_or("-"), + human_duration(item.get("seconds").and_then(Value::as_i64).unwrap_or(0)), + item.get("weight").and_then(Value::as_f64).unwrap_or(0.0) * 100.0 + )); + } + } + let employee_items = policy + .get("employee_details") + .and_then(Value::as_array) + .cloned() + .unwrap_or_default(); + if !employee_items.is_empty() { + text.push_str("\n### Drill-down по сотрудникам\n\n"); + for item in employee_items.iter().take(12) { + text.push_str(&format!( + "- {}: {}%, active {}, plan {}, formula `{}`\n", + item.get("user").and_then(Value::as_str).unwrap_or("-"), + item.get("index").and_then(Value::as_i64).unwrap_or(0), + human_duration( + item.get("active_seconds") + .and_then(Value::as_i64) + .unwrap_or(0) + ), + human_duration( + item.get("planned_seconds") + .and_then(Value::as_i64) + .unwrap_or(0) + ), + item.get("formula").and_then(Value::as_str).unwrap_or("-") + )); + if let Some(reason) = item.get("reason").and_then(Value::as_str) { + text.push_str(&format!(" - reason: {reason}\n")); + } + } + } +} + fn worktime_totals(snapshot: &Snapshot) -> (usize, i64, usize) { let Some(payload) = snapshot.worktime.payload.as_ref() else { return (0, 0, 0); @@ -3499,6 +3737,21 @@ mod tests { assert_eq!(explain["role"], "accountant"); assert_eq!(explain["roles_count"], 1); assert_eq!(explain["app_details"].as_array().unwrap().len(), 1); + assert_eq!( + explain["formula"], + "index = weighted_seconds / planned_seconds × 100" + ); + assert!(explain["planned_seconds"].as_i64().unwrap() > 0); + assert!(explain["weighted_seconds"].as_i64().unwrap() > 0); + assert!(explain["policy_audit"].is_object()); + assert!(explain["employee_details"].as_array().unwrap().len() == 1); + assert_eq!( + explain["employee_details"][0]["formula"], + "employee_index = active_seconds / planned_seconds × 100" + ); + assert!(explain["employee_details"][0].get("reason").is_some()); + assert!(explain["app_details"][0].get("matched_rule").is_some()); + assert!(explain["app_details"][0].get("weight").is_some()); 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.css b/adk-rust/crates/detmir-portal/src/static/app.css index 63b3dbb..71777a0 100644 --- a/adk-rust/crates/detmir-portal/src/static/app.css +++ b/adk-rust/crates/detmir-portal/src/static/app.css @@ -223,6 +223,35 @@ h1 { grid-template-columns: minmax(140px, 1fr) minmax(260px, 2fr) auto; } +.audit-block, +.employee-drilldown, +.audit-note { + margin-top: 12px; + padding-top: 12px; + border-top: 1px solid var(--line); +} + +.audit-block h4, +.employee-drilldown h4 { + margin: 0 0 6px; + font-size: 14px; +} + +.audit-note { + display: grid; + gap: 4px; +} + +.employee-index-row { + grid-template-columns: minmax(140px, 1fr) minmax(280px, 2fr) auto; +} + +.report-actions { + display: flex; + justify-content: flex-end; + margin: 10px 0; +} + .compact-list { gap: 6px; } @@ -318,6 +347,24 @@ pre { .incident-row { grid-template-columns: 1fr; } .evidence-row { grid-template-columns: 1fr; } .app-weight-row { grid-template-columns: 1fr; } + .employee-index-row { grid-template-columns: 1fr; } .section-head { flex-direction: column; } .actions { justify-content: flex-start; } } + +@media print { + body { background: #fff; } + .tabs, + .report-actions, + .links, + .small-button { display: none !important; } + .shell { + max-width: none; + padding: 0; + } + .card, + .content-panel { + break-inside: avoid; + border-color: #cbd5e1; + } +} diff --git a/adk-rust/crates/detmir-portal/src/static/app.js b/adk-rust/crates/detmir-portal/src/static/app.js index a348ccd..c566946 100644 --- a/adk-rust/crates/detmir-portal/src/static/app.js +++ b/adk-rust/crates/detmir-portal/src/static/app.js @@ -184,6 +184,7 @@ function renderWorkforceIndexExplanation(policy) { `; } const details = Array.isArray(policy.app_details) ? policy.app_details.slice(0, 12) : []; + const employees = Array.isArray(policy.employee_details) ? policy.employee_details.slice(0, 12) : []; const weightedTotal = Math.max(1, Number(policy.weighted_seconds || 0)); const appRows = details.length === 0 ? `
Нет приложенийНет top breakdown для weighted KPI
` @@ -203,6 +204,7 @@ function renderWorkforceIndexExplanation(policy) {

Почему такой индекс?

${escapeHtml(policy.explanation || "Индекс = взвешенное время приложений / плановое время роли.")}

+

Формула: ${escapeHtml(policy.formula || "index = weighted_seconds / planned_seconds × 100")}.

${escapeHtml(workforceIndexTextFromValue(policy.index))} @@ -213,10 +215,49 @@ function renderWorkforceIndexExplanation(policy) {
Weighted${escapeHtml(humanSeconds(policy.weighted_seconds))}
${appRows}
+ ${renderPolicyAudit(policy.policy_audit)} + ${renderEmployeeIndexDetails(employees)} `; } +function renderPolicyAudit(audit) { + const items = Array.isArray(audit?.needs_review) ? audit.needs_review.slice(0, 12) : []; + if (items.length === 0) { + return `
Аудит policyВсе top приложения попали под явные правила или данных для аудита нет.
`; + } + return ` +
+

Аудит policy: default_weight

+

Эти приложения не нашли явного правила и требуют проверки классификации.

+
${items.map(item => ` +
+ ${escapeHtml(item.application || "-")} + ${escapeHtml(humanSeconds(item.seconds))} · default ${escapeHtml(pctText(item.weight))} + review +
+ `).join("")}
+
+ `; +} + +function renderEmployeeIndexDetails(items) { + if (!items.length) return ""; + return ` +
+

Drill-down по сотрудникам

+

Per-user индекс сейчас считается по активному времени; app-weight breakdown доступен на уровне портфеля.

+
${items.map(item => ` +
+ ${escapeHtml(item.user || "-")} + ${escapeHtml(item.reason || `${item.formula || "employee_index = active_seconds / planned_seconds × 100"} · active ${humanSeconds(item.active_seconds)} / plan ${humanSeconds(item.planned_seconds)}`)} + ${escapeHtml(workforceIndexTextFromValue(item.index))} +
+ `).join("")}
+
+ `; +} + function workforceIndexTextFromValue(value) { const n = Number(value); return Number.isFinite(n) ? `${Math.round(n)}%` : "Нет данных"; @@ -373,6 +414,9 @@ function renderReports(data) { `).join("")} +
+ +

Ключевые показатели

${renderKpiCards(data.kpis)} ${renderWorkforceIndexExplanation(data.workforce_policy)} @@ -427,6 +471,12 @@ document.addEventListener("click", event => { incidentAction(button).catch(showError); }); +document.addEventListener("click", event => { + const button = event.target.closest("[data-print-report]"); + if (!button) return; + window.print(); +}); + async function incidentAction(button) { const id = button.dataset.incidentId; const action = button.dataset.incidentAction; diff --git a/docs/DETMIR_COMMERCIAL_MODULES_RU.md b/docs/DETMIR_COMMERCIAL_MODULES_RU.md index 6d489a5..621e7eb 100644 --- a/docs/DETMIR_COMMERCIAL_MODULES_RU.md +++ b/docs/DETMIR_COMMERCIAL_MODULES_RU.md @@ -68,14 +68,30 @@ JSON отчета содержит объяснение расчета: - активная роль; - доступные роли; +- формула `index = weighted_seconds / planned_seconds × 100`; - плановое время; - фактическое app time; - взвешенное время; - matched rule по каждому приложению из top breakdown. +- audit-блок policy: какие приложения попали под `default_weight`; +- drill-down по сотрудникам: формула, active/plan seconds, индекс и причина. В портале это раскрывается в экране `Почему такой индекс?`: собственник видит роль, итоговый weighted KPI, план/app/weighted time и top-12 приложений с весом -и вкладом каждого приложения. +и вкладом каждого приложения. Та же секция добавляется в Markdown export +оперативного отчета, чтобы расчет можно было приложить к письму, PDF или +коммерческому отчету без ручного пересказа GUI. + +PDF-экспорт выполняется штатной печатью браузера из вкладки `Отчеты` +(`Печать / PDF`). Печатный CSS скрывает навигацию и оставляет отчетные секции, +включая `Почему такой индекс?`, audit `default_weight` и drill-down по +сотрудникам. + +Ограничение текущего contract: per-user индекс объясняется по персональному +`active_seconds / planned_seconds`; per-user app-weight breakdown пока не +доступен в worktime payload. Поэтому веса приложений и аудит `default_weight` +являются portfolio-level объяснением, а не персональной раскладкой приложений. +Это честнее, чем выводить ложную детализацию. Для быстрой загрузки вкладки руководителя explainability-блок доступен отдельным легким endpoint: @@ -85,6 +101,12 @@ JSON отчета содержит объяснение расчета: Полный `/api/reports` продолжает включать тот же `workforce_policy`, но вкладка `Руководитель` не обязана грузить весь отчет ради одного KPI. +Минимальный JSON contract этого endpoint закреплен unit-тестом +`workforce_policy_explain_is_lightweight_payload`: портал не должен потерять +`formula`, `app_details[].matched_rule`, `app_details[].weight`, +`policy_audit`, `employee_details[].formula` и `employee_details[].reason`, а +легкий endpoint не должен случайно начать отдавать тяжелые поля полного отчета. + После изменения runtime-файла нужно перезапустить портал: ```bash diff --git a/docs/DETMIR_PORTAL_GUI_PLAN_RU.md b/docs/DETMIR_PORTAL_GUI_PLAN_RU.md index 606f1fd..8267c75 100644 --- a/docs/DETMIR_PORTAL_GUI_PLAN_RU.md +++ b/docs/DETMIR_PORTAL_GUI_PLAN_RU.md @@ -42,8 +42,23 @@ baseline и раздела `Phase 8: Post-MVP Enhancements`. - вкладки `Руководитель` и `Отчеты` показывают экран `Почему такой индекс?` с ролью, формулой, плановым временем, app time, weighted time и top приложениями с весом/правилом/вкладом; +- Markdown export отчета включает тот же explainability-блок: + `Почему такой индекс?`, top приложений, audit `default_weight` и drill-down + по сотрудникам; +- вкладка `Отчеты` имеет действие `Печать / PDF`; печатный CSS оставляет + отчетные секции и скрывает навигацию; +- formula hint закреплен прямо в UI: + `index = weighted_seconds / planned_seconds × 100`; +- policy audit показывает приложения, попавшие под `default_weight`, потому что + это основной источник ошибок классификации ролей; +- employee drill-down показывает персональную причину индекса: + `active / plan => index`; per-user app-weight breakdown пока невозможен, + потому что текущий worktime payload отдает приложения только на уровне + портфеля; - вкладка `Руководитель` получает этот блок через легкий endpoint `/api/workforce/policy/explain`, без загрузки полного `/api/reports`; +- contract легкого endpoint защищен unit-тестом + `workforce_policy_explain_is_lightweight_payload`; - отчет использует Worktime management snapshot и показывает сравнение подразделений/ответственных за текущий день; - JSON отчета содержит `workforce.department_comparison`,