From 3c7e1de96ca4b33c80794ac83dd1b62001c0750a Mon Sep 17 00:00:00 2001 From: igor04091968 Date: Thu, 4 Jun 2026 13:23:39 +0300 Subject: [PATCH] feat(portal): add business risk timeline --- adk-rust/crates/detmir-portal/src/main.rs | 451 ++++++++++++++++-- .../crates/detmir-portal/src/static/app.css | 21 + .../crates/detmir-portal/src/static/app.js | 65 +++ docs/BUSINESS_RISK_RU.md | 37 ++ scripts/detmir-portal-tabs-smoke.mjs | 1 + 5 files changed, 546 insertions(+), 29 deletions(-) diff --git a/adk-rust/crates/detmir-portal/src/main.rs b/adk-rust/crates/detmir-portal/src/main.rs index e5f310c..a9ce5f2 100644 --- a/adk-rust/crates/detmir-portal/src/main.rs +++ b/adk-rust/crates/detmir-portal/src/main.rs @@ -1,4 +1,4 @@ -use std::collections::BTreeMap; +use std::collections::{BTreeMap, BTreeSet}; use std::fs::{self, File, OpenOptions}; use std::io::{Read, Write}; use std::path::{Path, PathBuf}; @@ -311,6 +311,24 @@ struct BusinessRiskItem { stale_nodes_count: usize, } +#[derive(Clone, Debug, Serialize)] +struct BusinessRiskHistoryItem { + date: String, + department: String, + risk_level: String, + trust_score: u8, + activity_score: u8, + reasons: Vec, +} + +#[derive(Clone, Debug, Default, Serialize)] +struct BusinessRiskHistorySummary { + departments_worsened: usize, + departments_improved: usize, + stable_high_risk: usize, + new_high_risk: usize, +} + #[derive(Debug, Serialize)] struct HealthResponse { ok: bool, @@ -717,6 +735,15 @@ struct ReportWorkforceSummary { trend_status: String, } +struct ReportMarkdownContext<'a> { + workforce: &'a ReportWorkforceSummary, + workforce_policy: &'a Value, + ueba_risk: &'a Value, + business_risk: &'a [BusinessRiskItem], + business_risk_history: &'a [BusinessRiskHistoryItem], + business_risk_history_summary: &'a BusinessRiskHistorySummary, +} + fn main() { let code = match run() { Ok(code) => code, @@ -1929,6 +1956,8 @@ fn build_reports( let department_items = workforce_rollup_items(snapshot, "department_rollups"); let owner_items = workforce_rollup_items(snapshot, "owner_rollups"); let business_risk = build_business_risk(snapshot, &department_items); + let business_risk_history = build_business_risk_history(snapshot); + let business_risk_history_summary = summarize_business_risk_history(&business_risk_history); let trend = workforce_trend_json(snapshot); let insight_items = workforce_insight_items(snapshot); let workforce_policy_explain = @@ -2006,6 +2035,11 @@ fn build_reports( )); } } + for department in stable_high_risk_departments(&business_risk_history, 3) { + executive_points.push(format!( + "Подразделение {department} сохраняет высокий риск несколько дней подряд." + )); + } let recommendations = owner_recommendations(snapshot, &summary); let workforce_summary = ReportWorkforceSummary { departments_count: department_items.len(), @@ -2019,8 +2053,14 @@ fn build_reports( &summary, &metrics, &recommendations, - &workforce_summary, - (&workforce_policy_explain, &ueba_risk), + ReportMarkdownContext { + workforce: &workforce_summary, + workforce_policy: &workforce_policy_explain, + ueba_risk: &ueba_risk, + business_risk: &business_risk, + business_risk_history: &business_risk_history, + business_risk_history_summary: &business_risk_history_summary, + }, ); json!({ "generated_at_utc": snapshot.generated_at_utc, @@ -2108,6 +2148,8 @@ fn build_reports( "agent_quality_nodes_summary": agent_quality_nodes_summary, "agent_coverage_sla": agent_coverage_sla, "business_risk": business_risk, + "business_risk_history": business_risk_history, + "business_risk_history_summary": business_risk_history_summary, "workforce_policy": workforce_policy_explain, "workforce": { "department_comparison": department_items, @@ -2207,6 +2249,33 @@ fn business_risk_item( let activity_score = activity.unwrap_or(0); let trend_delta = department_trend_delta(snapshot, department); let trend = business_risk_trend_label(trend_delta); + let (problem_nodes_count, missing_nodes_count, stale_nodes_count) = + business_risk_problem_counts(snapshot, department); + let trust_score = business_trust_score(snapshot, problem_nodes_count); + let assessment = business_risk_assessment( + trust_score, + activity_score, + trend_delta, + missing_nodes_count, + stale_nodes_count, + problem_nodes_count, + activity.is_none(), + ); + BusinessRiskItem { + department: department.to_string(), + trust_score, + activity_score, + trend, + risk_level: assessment.0, + reasons: assessment.1, + recommendation: assessment.2, + problem_nodes_count, + missing_nodes_count, + stale_nodes_count, + } +} + +fn business_risk_problem_counts(snapshot: &Snapshot, department: &str) -> (usize, usize, usize) { let department_problem_nodes = snapshot .agent_coverage_sla .problem_nodes @@ -2229,7 +2298,18 @@ fn business_risk_item( .iter() .filter(|node| node.status == "STALE") .count(); - let trust_score = business_trust_score(snapshot, problem_nodes_count); + (problem_nodes_count, missing_nodes_count, stale_nodes_count) +} + +fn business_risk_assessment( + trust_score: u8, + activity_score: u8, + trend_delta: Option, + missing_nodes_count: usize, + stale_nodes_count: usize, + problem_nodes_count: usize, + activity_missing: bool, +) -> (String, Vec, String) { let mut score = 0u64; let mut reasons = Vec::new(); if trust_score < 50 { @@ -2258,7 +2338,7 @@ fn business_risk_item( score += 10; reasons.push("падающий тренд".to_string()); } - } else if activity.is_none() { + } else if activity_missing { score += 15; reasons.push("нет свежей телеметрии".to_string()); } @@ -2279,18 +2359,11 @@ fn business_risk_item( stale_nodes_count, problem_nodes_count, ); - BusinessRiskItem { - department: department.to_string(), - trust_score, - activity_score, - trend, - risk_level: business_risk_level(score).to_string(), + ( + business_risk_level(score).to_string(), reasons, recommendation, - problem_nodes_count, - missing_nodes_count, - stale_nodes_count, - } + ) } fn business_risk_recommendation( @@ -2316,6 +2389,168 @@ fn business_risk_recommendation( "Держать подразделение под наблюдением; срочных действий не требуется.".to_string() } +fn build_business_risk_history(snapshot: &Snapshot) -> Vec { + let Some(trend) = snapshot + .worktime_management + .payload + .as_ref() + .and_then(|payload| payload.get("trend")) + .and_then(Value::as_array) + else { + return Vec::new(); + }; + + let mut previous_activity = BTreeMap::::new(); + let mut rows = Vec::new(); + for day in trend { + let date = day + .get("report_date") + .or_else(|| day.get("date")) + .and_then(Value::as_str) + .unwrap_or("unknown") + .to_string(); + let Some(rollups) = day.get("department_rollups").and_then(Value::as_array) else { + continue; + }; + let mut current_day = BTreeMap::::new(); + for item in rollups { + let department = item + .get("name") + .and_then(Value::as_str) + .filter(|value| !value.trim().is_empty()) + .unwrap_or("Без подразделения") + .to_string(); + let activity_score = item + .get("portfolio_coverage_pct") + .and_then(Value::as_f64) + .map(percent_to_score) + .unwrap_or(0); + let trend_delta = previous_activity + .get(&department) + .map(|previous| f64::from(activity_score) - f64::from(*previous)); + let (problem_nodes_count, missing_nodes_count, stale_nodes_count) = + business_risk_problem_counts(snapshot, &department); + let trust_score = business_trust_score(snapshot, problem_nodes_count); + let (risk_level, reasons, _) = business_risk_assessment( + trust_score, + activity_score, + trend_delta, + missing_nodes_count, + stale_nodes_count, + problem_nodes_count, + false, + ); + rows.push(BusinessRiskHistoryItem { + date: date.clone(), + department: department.clone(), + risk_level, + trust_score, + activity_score, + reasons, + }); + current_day.insert(department, activity_score); + } + previous_activity = current_day; + } + + rows.sort_by(|left, right| { + left.date + .cmp(&right.date) + .then_with(|| left.department.cmp(&right.department)) + }); + let mut dates = rows + .iter() + .map(|item| item.date.clone()) + .collect::>(); + dates.sort(); + dates.dedup(); + let keep_from = dates.len().saturating_sub(30); + let keep_dates = dates[keep_from..].iter().cloned().collect::>(); + rows.into_iter() + .filter(|item| keep_dates.contains(&item.date)) + .collect() +} + +fn summarize_business_risk_history( + history: &[BusinessRiskHistoryItem], +) -> BusinessRiskHistorySummary { + let mut by_department = BTreeMap::>::new(); + for item in history { + by_department + .entry(item.department.clone()) + .or_default() + .push(item); + } + let mut summary = BusinessRiskHistorySummary::default(); + for items in by_department.values_mut() { + items.sort_by(|left, right| left.date.cmp(&right.date)); + let Some(first) = items.first() else { + continue; + }; + let Some(last) = items.last() else { + continue; + }; + let first_rank = business_risk_rank(&first.risk_level); + let last_rank = business_risk_rank(&last.risk_level); + if last_rank > first_rank { + summary.departments_worsened += 1; + } else if last_rank < first_rank { + summary.departments_improved += 1; + } + if latest_high_risk_streak(items) >= 3 { + summary.stable_high_risk += 1; + } + if business_risk_is_high(&last.risk_level) + && !items + .iter() + .take(items.len().saturating_sub(1)) + .any(|item| business_risk_is_high(&item.risk_level)) + { + summary.new_high_risk += 1; + } + } + summary +} + +fn stable_high_risk_departments(history: &[BusinessRiskHistoryItem], limit: usize) -> Vec { + let mut by_department = BTreeMap::>::new(); + for item in history { + by_department + .entry(item.department.clone()) + .or_default() + .push(item); + } + let mut departments = by_department + .into_iter() + .filter_map(|(department, mut items)| { + items.sort_by(|left, right| left.date.cmp(&right.date)); + (latest_high_risk_streak(&items) >= 3).then_some(department) + }) + .collect::>(); + departments.sort(); + departments.truncate(limit); + departments +} + +fn latest_high_risk_streak(items: &[&BusinessRiskHistoryItem]) -> usize { + items + .iter() + .rev() + .take_while(|item| business_risk_is_high(&item.risk_level)) + .count() +} + +fn business_risk_is_high(level: &str) -> bool { + matches!(level, "HIGH" | "CRITICAL") +} + +fn percent_to_score(value: f64) -> u8 { + if !value.is_finite() { + return 0; + } + value.round().clamp(0.0, 100.0) as u8 +} + fn first_percent_score(text: &str) -> Option { let bytes = text.as_bytes(); for index in 0..bytes.len() { @@ -3717,10 +3952,8 @@ fn render_report_markdown( summary: &SummaryResponse, metrics: &ReportMetrics, recommendations: &[String], - workforce: &ReportWorkforceSummary, - explainability: (&Value, &Value), + context: ReportMarkdownContext<'_>, ) -> String { - let (workforce_policy, ueba_risk) = explainability; let mut text = String::new(); text.push_str("# DetMir оперативный отчет\n\n"); text.push_str(&format!("Дата снимка: {}\n\n", snapshot.generated_at_utc)); @@ -3763,13 +3996,16 @@ fn render_report_markdown( text.push_str(&format!("- Активные приложения: {}\n", metrics.apps_count)); text.push_str(&format!( "- Подразделения/ответственные: {}/{}\n", - workforce.departments_count, workforce.owners_count + context.workforce.departments_count, context.workforce.owners_count )); text.push_str(&format!( "- Автоматические выводы Workforce: {}\n", - workforce.insights_count + context.workforce.insights_count + )); + text.push_str(&format!( + "- Статус тренда: {}\n", + context.workforce.trend_status )); - text.push_str(&format!("- Статус тренда: {}\n", workforce.trend_status)); text.push_str(&format!( "- DLP технические сигналы: ok={}, warn={}, fail={}\n", metrics.dlp_ok, metrics.dlp_warn, metrics.dlp_fail @@ -3798,15 +4034,14 @@ fn render_report_markdown( &snapshot.agent_quality_nodes_summary, ); append_agent_coverage_sla_markdown(&mut text, &snapshot.agent_coverage_sla); - append_business_risk_markdown( + append_business_risk_markdown(&mut text, context.business_risk); + append_business_risk_history_markdown( &mut text, - &build_business_risk( - snapshot, - &workforce_rollup_items(snapshot, "department_rollups"), - ), + context.business_risk_history, + context.business_risk_history_summary, ); - append_ueba_risk_markdown(&mut text, ueba_risk); - append_workforce_policy_markdown(&mut text, workforce_policy); + append_ueba_risk_markdown(&mut text, context.ueba_risk); + append_workforce_policy_markdown(&mut text, context.workforce_policy); text.push_str("\nПримечание: DLP/case показатели являются derived detections/cases и требуют регламентной валидации перед подачей как подтвержденные инциденты.\n"); text } @@ -3988,6 +4223,44 @@ fn append_business_risk_markdown(text: &mut String, risks: &[BusinessRiskItem]) } } +fn append_business_risk_history_markdown( + text: &mut String, + history: &[BusinessRiskHistoryItem], + summary: &BusinessRiskHistorySummary, +) { + text.push_str("\n## Динамика бизнес-рисков\n\n"); + text.push_str(&format!("- Ухудшились: {}\n", summary.departments_worsened)); + text.push_str(&format!("- Улучшились: {}\n", summary.departments_improved)); + text.push_str(&format!( + "- Стабильно высокий риск: {}\n", + summary.stable_high_risk + )); + text.push_str(&format!( + "- Новый высокий риск: {}\n", + summary.new_high_risk + )); + if history.is_empty() { + text.push_str("- История бизнес-рисков пока не накоплена.\n"); + return; + } + for item in history.iter().rev().take(10) { + let reasons = if item.reasons.is_empty() { + "нет существенных причин".to_string() + } else { + item.reasons.join("; ") + }; + text.push_str(&format!( + "- {} · {}: level={}, trust={}%, activity={}%, причины: {}\n", + item.date, + item.department, + item.risk_level, + item.trust_score, + item.activity_score, + reasons + )); + } +} + fn append_ueba_risk_markdown(text: &mut String, risk: &Value) { text.push_str("\n## UEBA риск\n\n"); text.push_str(&format!( @@ -6359,6 +6632,8 @@ mod tests { fn business_risk_helpers_map_scores_and_percentages() { assert_eq!(first_percent_score("50% · active 1/2"), Some(50)); assert_eq!(first_percent_score("нет данных"), None); + assert_eq!(percent_to_score(49.6), 50); + assert_eq!(percent_to_score(101.0), 100); assert_eq!(business_risk_level(0), "LOW"); assert_eq!(business_risk_level(25), "MEDIUM"); assert_eq!(business_risk_level(55), "HIGH"); @@ -6369,6 +6644,87 @@ mod tests { assert_eq!(business_risk_trend_label(None), "UNKNOWN"); } + #[test] + fn business_risk_history_summarizes_negative_trends() { + let history = vec![ + BusinessRiskHistoryItem { + date: "2026-06-01".to_string(), + department: "Продажи".to_string(), + risk_level: "MEDIUM".to_string(), + trust_score: 80, + activity_score: 65, + reasons: vec!["падающий тренд".to_string()], + }, + BusinessRiskHistoryItem { + date: "2026-06-02".to_string(), + department: "Продажи".to_string(), + risk_level: "HIGH".to_string(), + trust_score: 70, + activity_score: 45, + reasons: vec!["низкая активность".to_string()], + }, + BusinessRiskHistoryItem { + date: "2026-06-03".to_string(), + department: "Продажи".to_string(), + risk_level: "HIGH".to_string(), + trust_score: 70, + activity_score: 40, + reasons: vec!["низкая активность".to_string()], + }, + BusinessRiskHistoryItem { + date: "2026-06-04".to_string(), + department: "Продажи".to_string(), + risk_level: "CRITICAL".to_string(), + trust_score: 40, + activity_score: 30, + reasons: vec!["низкий Trust KPI".to_string()], + }, + BusinessRiskHistoryItem { + date: "2026-06-01".to_string(), + department: "ИТ".to_string(), + risk_level: "HIGH".to_string(), + trust_score: 70, + activity_score: 40, + reasons: vec!["низкая активность".to_string()], + }, + BusinessRiskHistoryItem { + date: "2026-06-04".to_string(), + department: "ИТ".to_string(), + risk_level: "LOW".to_string(), + trust_score: 95, + activity_score: 90, + reasons: Vec::new(), + }, + BusinessRiskHistoryItem { + date: "2026-06-01".to_string(), + department: "Логистика".to_string(), + risk_level: "LOW".to_string(), + trust_score: 95, + activity_score: 90, + reasons: Vec::new(), + }, + BusinessRiskHistoryItem { + date: "2026-06-04".to_string(), + department: "Логистика".to_string(), + risk_level: "HIGH".to_string(), + trust_score: 65, + activity_score: 45, + reasons: vec!["низкая активность".to_string()], + }, + ]; + + let summary = summarize_business_risk_history(&history); + + assert_eq!(summary.departments_worsened, 2); + assert_eq!(summary.departments_improved, 1); + assert_eq!(summary.stable_high_risk, 1); + assert_eq!(summary.new_high_risk, 1); + assert_eq!( + stable_high_risk_departments(&history, 3), + vec!["Продажи".to_string()] + ); + } + #[test] fn incident_id_is_stable() { assert_eq!( @@ -6668,7 +7024,24 @@ mod tests { "trend": [ { "report_date": "2026-06-03", - "portfolio_coverage_pct": 50.0 + "portfolio_coverage_pct": 50.0, + "department_rollups": [ + {"name": "Бухгалтерия", "portfolio_coverage_pct": 50.0} + ] + }, + { + "report_date": "2026-06-04", + "portfolio_coverage_pct": 50.0, + "department_rollups": [ + {"name": "Бухгалтерия", "portfolio_coverage_pct": 50.0} + ] + }, + { + "report_date": "2026-06-05", + "portfolio_coverage_pct": 50.0, + "department_rollups": [ + {"name": "Бухгалтерия", "portfolio_coverage_pct": 50.0} + ] } ], "trend_insights": [ @@ -6838,6 +7211,20 @@ mod tests { assert_eq!(report["business_risk"][0]["problem_nodes_count"], 0); assert_eq!(report["business_risk"][0]["missing_nodes_count"], 0); assert_eq!(report["business_risk"][0]["stale_nodes_count"], 0); + assert_eq!(report["business_risk_history"].as_array().unwrap().len(), 3); + assert_eq!( + report["business_risk_history"][0]["department"], + "Бухгалтерия" + ); + assert!(report["business_risk_history"][0]["reasons"].is_array()); + assert_eq!( + report["business_risk_history_summary"]["departments_worsened"], + 0 + ); + assert_eq!( + report["business_risk_history_summary"]["stable_high_risk"], + 0 + ); assert!( report["executive_points"] .as_array() @@ -6870,6 +7257,12 @@ mod tests { .unwrap() .contains("## Риски подразделений") ); + assert!( + report["markdown"] + .as_str() + .unwrap() + .contains("## Динамика бизнес-рисков") + ); assert!(report["markdown"].as_str().unwrap().contains("причины:")); assert!( report["markdown"] diff --git a/adk-rust/crates/detmir-portal/src/static/app.css b/adk-rust/crates/detmir-portal/src/static/app.css index 61e66f9..deb2fee 100644 --- a/adk-rust/crates/detmir-portal/src/static/app.css +++ b/adk-rust/crates/detmir-portal/src/static/app.css @@ -343,6 +343,27 @@ h1 { min-height: 132px; } +.mini-kpi { + min-height: 96px; + padding: 12px; + border: 1px solid var(--line); + border-radius: 8px; + background: var(--panel-soft); +} + +.mini-kpi span, +.mini-kpi small { + display: block; + color: var(--muted); +} + +.mini-kpi strong { + display: block; + margin: 8px 0 4px; + font-size: 24px; + line-height: 1.1; +} + .kpi-value { margin: 10px 0 6px; font-size: 28px; diff --git a/adk-rust/crates/detmir-portal/src/static/app.js b/adk-rust/crates/detmir-portal/src/static/app.js index 61f20b8..4406737 100644 --- a/adk-rust/crates/detmir-portal/src/static/app.js +++ b/adk-rust/crates/detmir-portal/src/static/app.js @@ -830,6 +830,7 @@ function renderOperator(data, report) { ${renderAgentQualityNodes(report?.agent_quality_nodes, report?.agent_quality_nodes_summary)} ${renderAgentCoverageSla(report?.agent_coverage_sla)} ${renderBusinessRisk(report?.business_risk)} + ${renderBusinessRiskTimeline(report?.business_risk_history, report?.business_risk_history_summary)} ${renderOverviewAnalytics(report)}

Рабочая активность сотрудников

загрузка, простои, перегруз и дисциплина процессов
@@ -1650,6 +1651,69 @@ function businessRiskReasons(item) { return `${reasons}. Trust ${item?.trust_score ?? 0}%, активность ${item?.activity_score ?? 0}%, тренд ${businessTrendText(item?.trend)}, проблемных узлов ${item?.problem_nodes_count ?? 0}`; } +function renderBusinessRiskTimeline(history, summary) { + const s = summary || {}; + const rows = Array.isArray(history) ? history.slice(-10).reverse() : []; + const status = Number(s.stable_high_risk || 0) > 0 || Number(s.new_high_risk || 0) > 0 + ? "WARN" + : "OK"; + const counters = [ + ["Ухудшились", s.departments_worsened ?? 0, "рост уровня риска за период"], + ["Улучшились", s.departments_improved ?? 0, "снижение уровня риска"], + ["Стабильно высокий риск", s.stable_high_risk ?? 0, "3+ дня HIGH/CRITICAL"], + ["Новый высокий риск", s.new_high_risk ?? 0, "последняя точка стала HIGH/CRITICAL"], + ]; + return ` +
+
+
+

Динамика рисков

+

Как менялся бизнес-риск подразделений по накопленной daily history.

+
+ ${ui(status)} +
+
+ ${counters.map(([label, value, hint]) => ` +
+ ${ui(label)} + ${escapeHtml(value)} + ${ui(hint)} +
+ `).join("")} +
+
+ + + + + + + + + + + ${rows.length ? rows.map(item => ` + + + + + + + `).join("") : ` + + + + + + + `} + +
ДатаПодразделениеРискПричины
${ui(item.date || "-")}${ui(item.department || "Без подразделения")}${ui(item.risk_level || "UNKNOWN")}${ui(Array.isArray(item.reasons) && item.reasons.length ? item.reasons.join("; ") : "нет существенных причин")}
-История не накопленаUNKNOWNНужно дождаться daily history.
+
+
+ `; +} + function businessTrendText(value) { const trend = String(value || "UNKNOWN").toUpperCase(); if (trend === "FALLING") return "падает"; @@ -1697,6 +1761,7 @@ function renderReports(data) { ${renderAgentQualityNodes(data.agent_quality_nodes, data.agent_quality_nodes_summary)} ${renderAgentCoverageSla(data.agent_coverage_sla)} ${renderBusinessRisk(data.business_risk)} + ${renderBusinessRiskTimeline(data.business_risk_history, data.business_risk_history_summary)} ${renderUebaRisk(data.ueba_risk)} ${renderWorkforceIndexExplanation(data.workforce_policy)}

Срезы отчета

diff --git a/docs/BUSINESS_RISK_RU.md b/docs/BUSINESS_RISK_RU.md index b6ce13d..c122fd6 100644 --- a/docs/BUSINESS_RISK_RU.md +++ b/docs/BUSINESS_RISK_RU.md @@ -34,6 +34,27 @@ Business Risk не является автоматическим обвинен - `missing_nodes_count` - сколько ожидаемых узлов не прислали telemetry; - `stale_nodes_count` - сколько узлов присылали telemetry, но данные устарели. +Также `GET /api/reports` отдает историю: + +- `business_risk_history` - timeline риска по подразделениям; +- `business_risk_history_summary` - сводка динамики. + +Элемент `business_risk_history`: + +- `date` - дата daily point; +- `department` - подразделение; +- `risk_level` - `LOW`, `MEDIUM`, `HIGH` или `CRITICAL`; +- `trust_score` - доверие к KPI на момент расчета; +- `activity_score` - активность подразделения; +- `reasons` - причины риска для этой точки. + +`business_risk_history_summary`: + +- `departments_worsened` - сколько подразделений ухудшили риск; +- `departments_improved` - сколько подразделений улучшили риск; +- `stable_high_risk` - сколько подразделений 3+ дня остаются в `HIGH`/`CRITICAL`; +- `new_high_risk` - сколько подразделений впервые вошли в `HIGH`/`CRITICAL`. + ## Логика риска На риск влияют: @@ -67,12 +88,21 @@ Business Risk не является автоматическим обвинен - причины; - рекомендация. +Карточка `Динамика рисков` показывает: + +- ухудшившиеся подразделения; +- улучшившиеся подразделения; +- стабильный высокий риск; +- новый высокий риск; +- последние точки timeline с причинами. + ## Markdown Оперативный отчет содержит раздел: ```text ## Риски подразделений +## Динамика бизнес-рисков ``` Раздел можно использовать в PDF/Markdown-отчете для руководителя. @@ -88,6 +118,13 @@ Executive summary показывает до 3 наиболее рискован Подразделение Бухгалтерия: HIGH — причина: низкий Trust KPI ``` +Если подразделение 3+ дня подряд находится в `HIGH` или `CRITICAL`, +добавляется отдельный вывод: + +```text +Подразделение Бухгалтерия сохраняет высокий риск несколько дней подряд. +``` + ## Ограничения - Это proxy-модель управленческого риска, а не юридическое заключение. diff --git a/scripts/detmir-portal-tabs-smoke.mjs b/scripts/detmir-portal-tabs-smoke.mjs index 380b260..f623131 100644 --- a/scripts/detmir-portal-tabs-smoke.mjs +++ b/scripts/detmir-portal-tabs-smoke.mjs @@ -131,6 +131,7 @@ async function main() { "Качество данных по рабочим местам", "Покрытие агентов", "Риски подразделений", + "Динамика рисков", "ТОП-5 лучших подразделений", "ТОП-5 проблемных подразделений", "Требует внимания",