feat(portal): add business risk timeline

This commit is contained in:
igor04091968
2026-06-04 13:23:39 +03:00
parent 0341e114a9
commit 3c7e1de96c
5 changed files with 546 additions and 29 deletions
+422 -29
View File
@@ -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<String>,
}
#[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<f64>,
missing_nodes_count: usize,
stale_nodes_count: usize,
problem_nodes_count: usize,
activity_missing: bool,
) -> (String, Vec<String>, 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<BusinessRiskHistoryItem> {
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::<String, u8>::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::<String, u8>::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::<Vec<_>>();
dates.sort();
dates.dedup();
let keep_from = dates.len().saturating_sub(30);
let keep_dates = dates[keep_from..].iter().cloned().collect::<BTreeSet<_>>();
rows.into_iter()
.filter(|item| keep_dates.contains(&item.date))
.collect()
}
fn summarize_business_risk_history(
history: &[BusinessRiskHistoryItem],
) -> BusinessRiskHistorySummary {
let mut by_department = BTreeMap::<String, Vec<&BusinessRiskHistoryItem>>::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<String> {
let mut by_department = BTreeMap::<String, Vec<&BusinessRiskHistoryItem>>::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::<Vec<_>>();
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<u8> {
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"]
@@ -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;
@@ -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)}
<section class="dashboard-band">
<div class="band-head"><h3>Рабочая активность сотрудников</h3><span class="muted">загрузка, простои, перегруз и дисциплина процессов</span></div>
@@ -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 `
<section class="card business-risk-timeline-card">
<div class="section-head">
<div>
<h3>Динамика рисков</h3>
<p class="muted">Как менялся бизнес-риск подразделений по накопленной daily history.</p>
</div>
<span class="badge ${statusClass(status)}">${ui(status)}</span>
</div>
<div class="summary-grid kpi-grid compact-kpis">
${counters.map(([label, value, hint]) => `
<article class="mini-kpi">
<span>${ui(label)}</span>
<strong>${escapeHtml(value)}</strong>
<small>${ui(hint)}</small>
</article>
`).join("")}
</div>
<div class="table-scroll">
<table class="data-table">
<thead>
<tr>
<th>Дата</th>
<th>Подразделение</th>
<th>Риск</th>
<th>Причины</th>
</tr>
</thead>
<tbody>
${rows.length ? rows.map(item => `
<tr>
<td>${ui(item.date || "-")}</td>
<td><strong>${ui(item.department || "Без подразделения")}</strong></td>
<td><span class="badge ${statusClass(item.risk_level)}">${ui(item.risk_level || "UNKNOWN")}</span></td>
<td>${ui(Array.isArray(item.reasons) && item.reasons.length ? item.reasons.join("; ") : "нет существенных причин")}</td>
</tr>
`).join("") : `
<tr>
<td>-</td>
<td>История не накоплена</td>
<td><span class="badge status-unknown">UNKNOWN</span></td>
<td>Нужно дождаться daily history.</td>
</tr>
`}
</tbody>
</table>
</div>
</section>
`;
}
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)}
<h3 class="section-title">Срезы отчета</h3>
+37
View File
@@ -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-модель управленческого риска, а не юридическое заключение.
+1
View File
@@ -131,6 +131,7 @@ async function main() {
"Качество данных по рабочим местам",
"Покрытие агентов",
"Риски подразделений",
"Динамика рисков",
"ТОП-5 лучших подразделений",
"ТОП-5 проблемных подразделений",
"Требует внимания",