feat(portal): add department business risk

This commit is contained in:
igor04091968
2026-06-04 12:52:40 +03:00
parent a30ab9cc95
commit 27e9ef2046
4 changed files with 386 additions and 3 deletions
+245
View File
@@ -297,6 +297,15 @@ impl Default for AgentCoverageSla {
}
}
#[derive(Clone, Debug, Serialize)]
struct BusinessRiskItem {
department: String,
trust_score: u8,
activity_score: u8,
trend: String,
risk_level: String,
}
#[derive(Debug, Serialize)]
struct HealthResponse {
ok: bool,
@@ -1914,6 +1923,7 @@ fn build_reports(
let dlp_block_value = dlp_block(snapshot);
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 trend = workforce_trend_json(snapshot);
let insight_items = workforce_insight_items(snapshot);
let workforce_policy_explain =
@@ -1978,6 +1988,14 @@ fn build_reports(
),
_ => {}
}
for item in business_risk.iter().take(3) {
if item.risk_level != "LOW" {
executive_points.push(format!(
"Риск подразделения {}: {} (trust={}%, activity={}%, trend={})",
item.department, item.risk_level, item.trust_score, item.activity_score, item.trend
));
}
}
let recommendations = owner_recommendations(snapshot, &summary);
let workforce_summary = ReportWorkforceSummary {
departments_count: department_items.len(),
@@ -2079,6 +2097,7 @@ fn build_reports(
"agent_quality_nodes": agent_quality_nodes,
"agent_quality_nodes_summary": agent_quality_nodes_summary,
"agent_coverage_sla": agent_coverage_sla,
"business_risk": business_risk,
"workforce_policy": workforce_policy_explain,
"workforce": {
"department_comparison": department_items,
@@ -2133,6 +2152,181 @@ fn workforce_rollup_items(snapshot: &Snapshot, key: &str) -> Vec<Value> {
.unwrap_or_default()
}
fn build_business_risk(snapshot: &Snapshot, department_items: &[Value]) -> Vec<BusinessRiskItem> {
let mut departments = BTreeMap::<String, Option<u8>>::new();
for item in department_items {
let department = item
.get("label")
.and_then(Value::as_str)
.filter(|value| !value.trim().is_empty())
.unwrap_or("Без подразделения")
.to_string();
let activity = item
.get("value")
.and_then(Value::as_str)
.and_then(first_percent_score);
departments.insert(department, activity);
}
for node in &snapshot.agent_coverage_sla.problem_nodes {
let department = if node.department.trim().is_empty() {
"Не задано"
} else {
node.department.as_str()
};
departments.entry(department.to_string()).or_insert(None);
}
let mut items = departments
.into_iter()
.map(|(department, activity)| business_risk_item(snapshot, &department, activity))
.collect::<Vec<_>>();
items.sort_by(|left, right| {
business_risk_rank(&right.risk_level)
.cmp(&business_risk_rank(&left.risk_level))
.then_with(|| left.trust_score.cmp(&right.trust_score))
.then_with(|| left.activity_score.cmp(&right.activity_score))
.then_with(|| left.department.cmp(&right.department))
});
items
}
fn business_risk_item(
snapshot: &Snapshot,
department: &str,
activity: Option<u8>,
) -> BusinessRiskItem {
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 = snapshot
.agent_coverage_sla
.problem_nodes
.iter()
.filter(|node| {
let node_department = if node.department.trim().is_empty() {
"Не задано"
} else {
node.department.as_str()
};
node_department == department
})
.count();
let trust_score = business_trust_score(snapshot, problem_nodes);
let mut score = 0u64;
if trust_score < 50 {
score += 40;
} else if trust_score < 75 {
score += 25;
} else if trust_score < 90 {
score += 10;
}
if activity_score < 35 {
score += 35;
} else if activity_score < 60 {
score += 20;
} else if activity_score < 75 {
score += 10;
}
if let Some(delta) = trend_delta {
if delta <= -20.0 {
score += 25;
} else if delta <= -5.0 {
score += 10;
}
} else if activity.is_none() {
score += 15;
}
score += (problem_nodes as u64).saturating_mul(15).min(30);
BusinessRiskItem {
department: department.to_string(),
trust_score,
activity_score,
trend,
risk_level: business_risk_level(score).to_string(),
}
}
fn first_percent_score(text: &str) -> Option<u8> {
let bytes = text.as_bytes();
for index in 0..bytes.len() {
if bytes[index] == b'%' {
let mut start = index;
while start > 0 && bytes[start - 1].is_ascii_digit() {
start -= 1;
}
if start < index {
let value = text[start..index].parse::<u8>().ok()?;
return Some(value.min(100));
}
}
}
None
}
fn department_trend_delta(snapshot: &Snapshot, department: &str) -> Option<f64> {
let trend = snapshot
.worktime_management
.payload
.as_ref()
.and_then(|payload| payload.get("trend"))
.and_then(Value::as_array)?;
let points = trend
.iter()
.filter_map(|day| {
day.get("department_rollups")
.and_then(Value::as_array)?
.iter()
.find(|item| item.get("name").and_then(Value::as_str) == Some(department))
.and_then(|item| item.get("portfolio_coverage_pct").and_then(Value::as_f64))
})
.collect::<Vec<_>>();
if points.len() < 2 {
return None;
}
Some(points.last()? - points.first()?)
}
fn business_risk_trend_label(delta: Option<f64>) -> String {
match delta {
Some(value) if value <= -5.0 => "FALLING".to_string(),
Some(value) if value >= 5.0 => "RISING".to_string(),
Some(_) => "STABLE".to_string(),
None => "UNKNOWN".to_string(),
}
}
fn business_trust_score(snapshot: &Snapshot, problem_nodes: usize) -> u8 {
let base = if snapshot.agent_coverage_sla.expected_nodes > 0 {
snapshot.agent_coverage_sla.coverage_pct
} else if snapshot.agent_quality_nodes_summary.total_nodes > 0 {
snapshot.agent_quality_nodes_summary.accepted_kpi_nodes_pct
} else {
50
};
base.saturating_sub((problem_nodes as u8).saturating_mul(20))
}
fn business_risk_level(score: u64) -> &'static str {
if score >= 80 {
"CRITICAL"
} else if score >= 55 {
"HIGH"
} else if score >= 25 {
"MEDIUM"
} else {
"LOW"
}
}
fn business_risk_rank(level: &str) -> u8 {
match level {
"CRITICAL" => 4,
"HIGH" => 3,
"MEDIUM" => 2,
"LOW" => 1,
_ => 0,
}
}
fn workforce_trend_json(snapshot: &Snapshot) -> Value {
snapshot
.worktime_management
@@ -3533,6 +3727,13 @@ fn render_report_markdown(
&snapshot.agent_quality_nodes_summary,
);
append_agent_coverage_sla_markdown(&mut text, &snapshot.agent_coverage_sla);
append_business_risk_markdown(
&mut text,
&build_business_risk(
snapshot,
&workforce_rollup_items(snapshot, "department_rollups"),
),
);
append_ueba_risk_markdown(&mut text, ueba_risk);
append_workforce_policy_markdown(&mut text, workforce_policy);
text.push_str("\nПримечание: DLP/case показатели являются derived detections/cases и требуют регламентной валидации перед подачей как подтвержденные инциденты.\n");
@@ -3688,6 +3889,20 @@ fn append_agent_coverage_sla_markdown(text: &mut String, sla: &AgentCoverageSla)
}
}
fn append_business_risk_markdown(text: &mut String, risks: &[BusinessRiskItem]) {
text.push_str("\n## Риски подразделений\n\n");
if risks.is_empty() {
text.push_str("- Риски подразделений пока не рассчитаны.\n");
return;
}
for item in risks.iter().take(10) {
text.push_str(&format!(
"- {}: level={}, trust={}%, activity={}%, trend={}\n",
item.department, item.risk_level, item.trust_score, item.activity_score, item.trend
));
}
}
fn append_ueba_risk_markdown(text: &mut String, risk: &Value) {
text.push_str("\n## UEBA риск\n\n");
text.push_str(&format!(
@@ -6055,6 +6270,20 @@ mod tests {
assert_eq!(sla.problem_nodes[0].status, "DEGRADED");
}
#[test]
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!(business_risk_level(0), "LOW");
assert_eq!(business_risk_level(25), "MEDIUM");
assert_eq!(business_risk_level(55), "HIGH");
assert_eq!(business_risk_level(80), "CRITICAL");
assert_eq!(business_risk_trend_label(Some(-6.0)), "FALLING");
assert_eq!(business_risk_trend_label(Some(6.0)), "RISING");
assert_eq!(business_risk_trend_label(Some(0.0)), "STABLE");
assert_eq!(business_risk_trend_label(None), "UNKNOWN");
}
#[test]
fn incident_id_is_stable() {
assert_eq!(
@@ -6504,6 +6733,9 @@ mod tests {
50
);
assert_eq!(report["agent_coverage_sla"]["sla_status"], "UNKNOWN");
assert_eq!(report["business_risk"].as_array().unwrap().len(), 1);
assert_eq!(report["business_risk"][0]["department"], "Бухгалтерия");
assert_eq!(report["business_risk"][0]["risk_level"], "MEDIUM");
assert!(
report["executive_points"]
.as_array()
@@ -6511,6 +6743,13 @@ mod tests {
.iter()
.any(|item| item.as_str().unwrap().contains("менее 80% узлов"))
);
assert!(
report["executive_points"]
.as_array()
.unwrap()
.iter()
.any(|item| item.as_str().unwrap().contains("Риск подразделения"))
);
assert!(
report["markdown"]
.as_str()
@@ -6523,6 +6762,12 @@ mod tests {
.unwrap()
.contains("## SLA покрытия агентов")
);
assert!(
report["markdown"]
.as_str()
.unwrap()
.contains("## Риски подразделений")
);
assert!(
report["markdown"]
.as_str()
@@ -23,9 +23,9 @@ async function postJson(path, payload) {
function statusClass(status) {
const s = String(status || "UNKNOWN").toLowerCase();
if (s === "ok" || s === "true") return "status-ok";
if (s === "warn" || s === "warning" || s === "fallback" || s === "stale") return "status-warn";
if (s === "degraded") return "status-degraded";
if (s === "ok" || s === "true" || s === "low") return "status-ok";
if (s === "warn" || s === "warning" || s === "fallback" || s === "stale" || s === "medium") return "status-warn";
if (s === "degraded" || s === "high") return "status-degraded";
if (s === "fail" || s === "false" || s === "error" || s === "critical" || s === "missing") return "status-fail";
return "status-unknown";
}
@@ -829,6 +829,7 @@ function renderOperator(data, report) {
${renderAgentQualityHistory(report?.agent_quality_history, report?.agent_quality_history_summary)}
${renderAgentQualityNodes(report?.agent_quality_nodes, report?.agent_quality_nodes_summary)}
${renderAgentCoverageSla(report?.agent_coverage_sla)}
${renderBusinessRisk(report?.business_risk)}
${renderOverviewAnalytics(report)}
<section class="dashboard-band">
<div class="band-head"><h3>Рабочая активность сотрудников</h3><span class="muted">загрузка, простои, перегруз и дисциплина процессов</span></div>
@@ -1597,6 +1598,62 @@ function renderAgentCoverageSla(sla) {
`;
}
function renderBusinessRisk(items) {
const rows = Array.isArray(items) ? items.slice(0, 10) : [];
const worst = rows[0]?.risk_level || "UNKNOWN";
return `
<section class="card business-risk-card">
<div class="section-head">
<div>
<h3>Риски подразделений</h3>
<p class="muted">Организационные зоны риска по доверию к KPI, активности, тренду и проблемным узлам.</p>
</div>
<span class="badge ${statusClass(worst)}">${ui(worst)}</span>
</div>
<div class="table-scroll">
<table class="data-table">
<thead>
<tr>
<th>Подразделение</th>
<th>Trust KPI</th>
<th>Активность</th>
<th>Тренд</th>
<th>Риск</th>
</tr>
</thead>
<tbody>
${rows.length ? rows.map(item => `
<tr>
<td><strong>${ui(item.department || "Без подразделения")}</strong></td>
<td>${escapeHtml(item.trust_score ?? 0)}%</td>
<td>${escapeHtml(item.activity_score ?? 0)}%</td>
<td>${ui(businessTrendText(item.trend))}</td>
<td><span class="badge ${statusClass(item.risk_level)}">${ui(item.risk_level || "UNKNOWN")}</span></td>
</tr>
`).join("") : `
<tr>
<td>Нет данных</td>
<td>-</td>
<td>-</td>
<td>нет данных</td>
<td><span class="badge status-unknown">UNKNOWN</span></td>
</tr>
`}
</tbody>
</table>
</div>
</section>
`;
}
function businessTrendText(value) {
const trend = String(value || "UNKNOWN").toUpperCase();
if (trend === "FALLING") return "падает";
if (trend === "RISING") return "растет";
if (trend === "STABLE") return "стабильно";
return "нет данных";
}
function renderReports(data) {
data = periodReport(data);
return `
@@ -1635,6 +1692,7 @@ function renderReports(data) {
${renderAgentQualityHistory(data.agent_quality_history, data.agent_quality_history_summary)}
${renderAgentQualityNodes(data.agent_quality_nodes, data.agent_quality_nodes_summary)}
${renderAgentCoverageSla(data.agent_coverage_sla)}
${renderBusinessRisk(data.business_risk)}
${renderUebaRisk(data.ueba_risk)}
${renderWorkforceIndexExplanation(data.workforce_policy)}
<h3 class="section-title">Срезы отчета</h3>
+79
View File
@@ -0,0 +1,79 @@
# Business Risk
`Business Risk` - управленческий слой AWatch-rus / DetMir, который показывает
не просто активность сотрудников, а зоны организационного риска по
подразделениям.
## Назначение
Слой предназначен для руководителя и собственника бизнеса. Он отвечает на
вопросы:
- где KPI активности нельзя считать надежным;
- где активность подразделения снижена;
- где есть падение тренда;
- где проблемные рабочие места портят доверие к отчету.
Business Risk не является автоматическим обвинением сотрудников или
подразделений. Это read-only приоритизация управленческой проверки.
## API
`GET /api/reports` отдает массив `business_risk`.
Элемент массива:
- `department` - подразделение;
- `trust_score` - доверие к KPI в процентах;
- `activity_score` - индекс активности подразделения в процентах;
- `trend` - `RISING`, `STABLE`, `FALLING` или `UNKNOWN`;
- `risk_level` - `LOW`, `MEDIUM`, `HIGH` или `CRITICAL`.
## Логика риска
На риск влияют:
- низкий `trust_score`;
- низкий `activity_score`;
- падающий тренд активности;
- отсутствие телеметрии;
- наличие проблемных узлов в SLA покрытия агентов.
`local_fallback` не считается подтвержденным KPI. Если телеметрия свежая, но
пришла из диагностического fallback-источника, она может подтверждать факт
наличия агента, но снижает доверие к KPI и повышает бизнес-риск.
## UI
В портале отображается карточка `Риски подразделений`.
Показывается ТОП-10 подразделений с максимальным риском:
- подразделение;
- trust KPI;
- активность;
- тренд;
- уровень риска.
## Markdown
Оперативный отчет содержит раздел:
```text
## Риски подразделений
```
Раздел можно использовать в PDF/Markdown-отчете для руководителя.
## Executive Summary
Executive summary показывает до 3 наиболее рискованных подразделений, если
их уровень риска выше `LOW`.
## Ограничения
- Это proxy-модель управленческого риска, а не юридическое заключение.
- Для точной интерпретации нужны корректные expected nodes, стабильная
telemetry и актуальные department rollups.
- Если данных по подразделению нет, риск может повышаться из-за недостаточной
доказательной базы, а не из-за фактической работы сотрудников.
+1
View File
@@ -130,6 +130,7 @@ async function main() {
"Стабильность агента за 7 дней",
"Качество данных по рабочим местам",
"Покрытие агентов",
"Риски подразделений",
"ТОП-5 лучших подразделений",
"ТОП-5 проблемных подразделений",
"Требует внимания",