feat(workforce): add department comparison layer

This commit is contained in:
igor04091968
2026-06-03 11:42:24 +03:00
parent 233524049e
commit c1119b460b
3 changed files with 228 additions and 1 deletions
+198 -1
View File
@@ -361,6 +361,7 @@ struct Snapshot {
detmir_check: SourceStatus, detmir_check: SourceStatus,
failed_units: SourceStatus, failed_units: SourceStatus,
worktime: SourceStatus, worktime: SourceStatus,
worktime_management: SourceStatus,
one_c: SourceStatus, one_c: SourceStatus,
} }
@@ -410,6 +411,13 @@ struct WeightedActivity {
matched_applications: usize, matched_applications: usize,
} }
#[derive(Debug)]
struct ReportWorkforceSummary {
departments_count: usize,
owners_count: usize,
trend_status: String,
}
fn main() { fn main() {
let code = match run() { let code = match run() {
Ok(code) => code, Ok(code) => code,
@@ -587,6 +595,14 @@ fn build_snapshot(args: &Cli) -> Snapshot {
), ),
timeout, timeout,
), ),
worktime_management: http_json_source(
"worktime_management",
&format!(
"{}/reports/worktime/management?format=json&allow_stale=1",
args.worktime_url.trim_end_matches('/')
),
timeout,
),
one_c: http_json_source( one_c: http_json_source(
"one_c", "one_c",
&format!("{}/api/health", args.one_c_url.trim_end_matches('/')), &format!("{}/api/health", args.one_c_url.trim_end_matches('/')),
@@ -763,6 +779,10 @@ fn build_health(snapshot: &Snapshot) -> HealthResponse {
sources.insert("detmir_check".to_string(), snapshot.detmir_check.ok); sources.insert("detmir_check".to_string(), snapshot.detmir_check.ok);
sources.insert("grafana_check".to_string(), grafana_data_ok(snapshot)); sources.insert("grafana_check".to_string(), grafana_data_ok(snapshot));
sources.insert("worktime_api".to_string(), snapshot.worktime.ok); sources.insert("worktime_api".to_string(), snapshot.worktime.ok);
sources.insert(
"worktime_management".to_string(),
snapshot.worktime_management.ok,
);
sources.insert("dlp_health".to_string(), dlp_ok(snapshot)); sources.insert("dlp_health".to_string(), dlp_ok(snapshot));
sources.insert("one_c".to_string(), snapshot.one_c.ok); sources.insert("one_c".to_string(), snapshot.one_c.ok);
HealthResponse { HealthResponse {
@@ -823,6 +843,7 @@ fn build_operator(snapshot: &Snapshot, incident_state: &IncidentStateFile) -> Va
"detmir_check": snapshot.detmir_check, "detmir_check": snapshot.detmir_check,
"failed_units": snapshot.failed_units, "failed_units": snapshot.failed_units,
"grafana_data": grafana_service(snapshot), "grafana_data": grafana_service(snapshot),
"worktime_management": snapshot.worktime_management,
"links": links(), "links": links(),
"incidents": build_incidents(snapshot, incident_state), "incidents": build_incidents(snapshot, incident_state),
}) })
@@ -912,6 +933,9 @@ fn build_reports(
let worktime = worktime_block(snapshot); let worktime = worktime_block(snapshot);
let one_c = one_c_block(snapshot); let one_c = one_c_block(snapshot);
let dlp_block_value = dlp_block(snapshot); 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 trend = workforce_trend_json(snapshot);
let weighted = load_workforce_policy(workforce_policy_path) let weighted = load_workforce_policy(workforce_policy_path)
.ok() .ok()
.flatten() .flatten()
@@ -945,7 +969,19 @@ fn build_reports(
), ),
]; ];
let recommendations = owner_recommendations(snapshot, &summary); let recommendations = owner_recommendations(snapshot, &summary);
let markdown = render_report_markdown(snapshot, headline, &summary, &metrics, &recommendations); let workforce_summary = ReportWorkforceSummary {
departments_count: department_items.len(),
owners_count: owner_items.len(),
trend_status: trend_status(&trend),
};
let markdown = render_report_markdown(
snapshot,
headline,
&summary,
&metrics,
&recommendations,
&workforce_summary,
);
json!({ json!({
"generated_at_utc": snapshot.generated_at_utc, "generated_at_utc": snapshot.generated_at_utc,
"period": "оперативный срез за сегодня и текущий runtime", "period": "оперативный срез за сегодня и текущий runtime",
@@ -959,6 +995,7 @@ fn build_reports(
report_kpi("Сотрудники", metrics.users_count.to_string(), worktime.status.clone(), "строки worktime за сегодня"), 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("Активное время", human_duration(metrics.active_seconds), worktime.status.clone(), "сумма active_seconds"),
report_kpi("Приложения", metrics.apps_count.to_string(), worktime.status.clone(), "true active applications"), report_kpi("Приложения", metrics.apps_count.to_string(), worktime.status.clone(), "true active applications"),
report_kpi("Подразделения", department_items.len().to_string(), snapshot.worktime_management.status.clone(), "сравнение групп за текущий день"),
report_kpi("DLP WARN/FAIL", format!("{}/{}", metrics.dlp_warn, metrics.dlp_fail), dlp_block_value.status.clone(), "технические сигналы DLP"), report_kpi("DLP WARN/FAIL", format!("{}/{}", metrics.dlp_warn, metrics.dlp_fail), dlp_block_value.status.clone(), "технические сигналы DLP"),
report_kpi("Evidence", format!("{}/{}", metrics.evidence_screenshots, metrics.evidence_total), evidence_status(evidence), "скриншоты / все evidence items"), report_kpi("Evidence", format!("{}/{}", metrics.evidence_screenshots, metrics.evidence_total), evidence_status(evidence), "скриншоты / все evidence items"),
report_kpi("Открытые вопросы", metrics.open_incidents.to_string(), incident_status(metrics.open_incidents), "не взятые в работу items") report_kpi("Открытые вопросы", metrics.open_incidents.to_string(), incident_status(metrics.open_incidents), "не взятые в работу items")
@@ -979,11 +1016,20 @@ fn build_reports(
report_item("Индекс активности", workforce_index_status(metrics.workforce_index), workforce_index_text(metrics.workforce_index)), 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(weighted.as_ref(), workforce_policy_path),
report_item("Worktime", worktime.status.clone(), worktime.text.clone()), 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)), report_item("Активное время", worktime.status.clone(), human_duration(metrics.active_seconds)),
report_item("Приложения", worktime.status.clone(), metrics.apps_count.to_string()), report_item("Приложения", worktime.status.clone(), metrics.apps_count.to_string()),
report_item("Отчет", "OK", "готов к передаче руководителю") report_item("Отчет", "OK", "готов к передаче руководителю")
] ]
}, },
{
"title": "Подразделения сегодня",
"items": department_items.clone()
},
{
"title": "Ответственные сегодня",
"items": owner_items.clone()
},
{ {
"title": "ИБ и evidence", "title": "ИБ и evidence",
"items": [ "items": [
@@ -999,11 +1045,89 @@ fn build_reports(
} }
], ],
"workforce_policy": workforce_policy_json(weighted.as_ref(), workforce_policy_path), "workforce_policy": workforce_policy_json(weighted.as_ref(), workforce_policy_path),
"workforce": {
"department_comparison": department_items,
"owner_comparison": owner_items,
"trend": trend,
"trend_status": trend_status(&trend),
"history_note": "Месячный тренд требует накопленной daily history; текущий слой показывает validated daily management snapshot."
},
"markdown": markdown, "markdown": markdown,
"links": links() "links": links()
}) })
} }
fn workforce_rollup_items(snapshot: &Snapshot, key: &str) -> Vec<Value> {
snapshot
.worktime_management
.payload
.as_ref()
.and_then(|payload| payload.get(key))
.and_then(Value::as_array)
.map(|items| {
items
.iter()
.filter(|item| item.get("users_count").and_then(Value::as_i64).unwrap_or(0) > 0)
.map(|item| {
let name = item
.get("name")
.and_then(Value::as_str)
.unwrap_or("Без группы");
let coverage = item
.get("portfolio_coverage_pct")
.and_then(Value::as_f64)
.unwrap_or(0.0);
let users = item.get("users_count").and_then(Value::as_i64).unwrap_or(0);
let active = item
.get("active_users")
.and_then(Value::as_i64)
.unwrap_or(0);
let hhmm = item
.get("workday_total_active_hhmm")
.and_then(Value::as_str)
.unwrap_or("00:00");
report_item(
name,
coverage_status(coverage),
format!("{coverage:.0}% · active {active}/{users} · {hhmm}"),
)
})
.collect()
})
.unwrap_or_default()
}
fn workforce_trend_json(snapshot: &Snapshot) -> Value {
snapshot
.worktime_management
.payload
.as_ref()
.and_then(|payload| payload.get("trend"))
.cloned()
.unwrap_or_else(|| json!([]))
}
fn trend_status(trend: &Value) -> String {
let points = trend.as_array().map(Vec::len).unwrap_or(0);
if points >= 20 {
"monthly_ready".to_string()
} else if points >= 7 {
"weekly_ready".to_string()
} else {
"daily_only".to_string()
}
}
fn coverage_status(coverage: f64) -> &'static str {
if coverage >= 75.0 {
"OK"
} else if coverage >= 35.0 {
"WARN"
} else {
"FAIL"
}
}
fn load_workforce_policy(path: &Path) -> Result<Option<WorkforcePolicy>> { fn load_workforce_policy(path: &Path) -> Result<Option<WorkforcePolicy>> {
if !path.exists() { if !path.exists() {
return Ok(None); return Ok(None);
@@ -1180,6 +1304,7 @@ fn render_report_markdown(
summary: &SummaryResponse, summary: &SummaryResponse,
metrics: &ReportMetrics, metrics: &ReportMetrics,
recommendations: &[String], recommendations: &[String],
workforce: &ReportWorkforceSummary,
) -> String { ) -> String {
let mut text = String::new(); let mut text = String::new();
text.push_str("# DetMir оперативный отчет\n\n"); text.push_str("# DetMir оперативный отчет\n\n");
@@ -1208,6 +1333,11 @@ fn render_report_markdown(
human_duration(metrics.active_seconds) human_duration(metrics.active_seconds)
)); ));
text.push_str(&format!("- Активные приложения: {}\n", metrics.apps_count)); text.push_str(&format!("- Активные приложения: {}\n", metrics.apps_count));
text.push_str(&format!(
"- Подразделения/ответственные: {}/{}\n",
workforce.departments_count, workforce.owners_count
));
text.push_str(&format!("- Статус тренда: {}\n", workforce.trend_status));
text.push_str(&format!( text.push_str(&format!(
"- DLP технические сигналы: ok={}, warn={}, fail={}\n", "- DLP технические сигналы: ok={}, warn={}, fail={}\n",
metrics.dlp_ok, metrics.dlp_warn, metrics.dlp_fail metrics.dlp_ok, metrics.dlp_warn, metrics.dlp_fail
@@ -2594,6 +2724,23 @@ fn source_summary(name: &str, payload: &Value) -> String {
.map(Vec::len) .map(Vec::len)
.unwrap_or(0) .unwrap_or(0)
), ),
"worktime_management" => format!(
"coverage={:.0}%, departments={}, owners={}",
payload
.pointer("/summary/portfolio_coverage_pct")
.and_then(Value::as_f64)
.unwrap_or(0.0),
payload
.get("department_rollups")
.and_then(Value::as_array)
.map(Vec::len)
.unwrap_or(0),
payload
.get("owner_rollups")
.and_then(Value::as_array)
.map(Vec::len)
.unwrap_or(0)
),
"one_c" => format!( "one_c" => format!(
"status={}, companies={}", "status={}, companies={}",
payload payload
@@ -2896,6 +3043,41 @@ mod tests {
] ]
})), })),
}, },
worktime_management: SourceStatus {
ok: true,
status: "OK".to_string(),
summary: "coverage=50%, departments=1, owners=1".to_string(),
error: None,
payload: Some(json!({
"summary": {
"portfolio_coverage_pct": 50.0
},
"department_rollups": [
{
"name": "Бухгалтерия",
"users_count": 2,
"active_users": 1,
"portfolio_coverage_pct": 50.0,
"workday_total_active_hhmm": "04:00"
}
],
"owner_rollups": [
{
"name": "Ответственный",
"users_count": 1,
"active_users": 1,
"portfolio_coverage_pct": 80.0,
"workday_total_active_hhmm": "06:24"
}
],
"trend": [
{
"report_date": "2026-06-03",
"portfolio_coverage_pct": 50.0
}
]
})),
},
one_c: SourceStatus { one_c: SourceStatus {
ok: true, ok: true,
status: "OK".to_string(), status: "OK".to_string(),
@@ -2954,6 +3136,14 @@ mod tests {
); );
assert!(report["kpis"].as_array().unwrap().len() >= 6); assert!(report["kpis"].as_array().unwrap().len() >= 6);
assert_eq!(report["workforce_policy"]["configured"], false); assert_eq!(report["workforce_policy"]["configured"], false);
assert_eq!(report["workforce"]["trend_status"], "daily_only");
assert_eq!(
report["workforce"]["department_comparison"]
.as_array()
.unwrap()
.len(),
1
);
} }
#[test] #[test]
@@ -2993,6 +3183,13 @@ mod tests {
] ]
})), })),
}, },
worktime_management: SourceStatus {
ok: true,
status: "OK".to_string(),
summary: "".to_string(),
error: None,
payload: Some(json!({})),
},
one_c: SourceStatus { one_c: SourceStatus {
ok: true, ok: true,
status: "OK".to_string(), status: "OK".to_string(),
+23
View File
@@ -23,6 +23,9 @@ Forensics усиливают продукт, но не должны перетя
- индекс активности: proxy `активное время / плановое рабочее время`; - индекс активности: proxy `активное время / плановое рабочее время`;
- взвешенная активность: только при настроенной role/application policy; - взвешенная активность: только при настроенной role/application policy;
- сравнение подразделений за текущий день;
- сравнение ответственных/владельцев процессов за текущий день;
- статус тренда: `daily_only`, `weekly_ready` или `monthly_ready`;
- активное время; - активное время;
- простой; - простой;
- число рабочих сессий; - число рабочих сессий;
@@ -67,6 +70,26 @@ sudo systemctl restart detmir-portal.service
`Индекс активности`. `Взвешенная активность` появляется только после настройки `Индекс активности`. `Взвешенная активность` появляется только после настройки
role/application policy. role/application policy.
### Сравнение подразделений и тренды
Портал использует validated management snapshot из Worktime API и показывает:
- подразделения: coverage, активные пользователи, суммарное активное время;
- ответственных/владельцев процессов: coverage, активные пользователи,
суммарное активное время;
- статус тренда по числу накопленных daily points.
Правило честной интерпретации:
- `daily_only` - есть только оперативный дневной срез, месячные выводы делать
нельзя;
- `weekly_ready` - накоплено достаточно точек для недельного сравнения;
- `monthly_ready` - накоплено достаточно точек для месячного отчета владельцу.
Месячный отчет должен строиться только после накопления daily history. Если
история содержит один день, интерфейс обязан показывать дневной срез, а не
выдавать его за тренд месяца.
Корректная формулировка для продажи: Корректная формулировка для продажи:
> DetMir помогает руководителю видеть загрузку сотрудников и бизнес-процессов > DetMir помогает руководителю видеть загрузку сотрудников и бизнес-процессов
+7
View File
@@ -35,6 +35,13 @@ baseline и раздела `Phase 8: Post-MVP Enhancements`.
- отчет поддерживает `Взвешенную активность` при наличии - отчет поддерживает `Взвешенную активность` при наличии
`/etc/detmir-portal-workforce-policy.json`; публичный пример лежит в `/etc/detmir-portal-workforce-policy.json`; публичный пример лежит в
`configs/detmir-workforce-policy.example.json`; `configs/detmir-workforce-policy.example.json`;
- отчет использует Worktime management snapshot и показывает сравнение
подразделений/ответственных за текущий день;
- JSON отчета содержит `workforce.department_comparison`,
`workforce.owner_comparison`, `workforce.trend` и `workforce.trend_status`;
- `trend_status=daily_only` означает, что месячный тренд еще нельзя
интерпретировать как полноценный отчет; для `monthly_ready` нужна накопленная
daily history;
- Ansible устанавливает initial workforce policy только если runtime-файл - Ansible устанавливает initial workforce policy только если runtime-файл
отсутствует, чтобы не перетирать клиентские веса ролей; отсутствует, чтобы не перетирать клиентские веса ролей;
- отчет содержит Markdown export для передачи руководителю или заказчику; - отчет содержит Markdown export для передачи руководителю или заказчику;