feat(workforce): explain role profile scoring

This commit is contained in:
igor04091968
2026-06-03 14:07:25 +03:00
parent 7bc1d4170a
commit 3d50a1bea7
4 changed files with 204 additions and 29 deletions
+95 -18
View File
@@ -393,6 +393,8 @@ struct WorkforceRolePolicy {
#[serde(default)]
label: Option<String>,
#[serde(default)]
description: Option<String>,
#[serde(default)]
planned_hours_per_day: Option<f64>,
#[serde(default)]
default_weight: Option<f64>,
@@ -409,6 +411,17 @@ struct WeightedActivity {
app_seconds: i64,
weighted_seconds: i64,
matched_applications: usize,
explanation: String,
app_details: Vec<AppWeightDetail>,
}
#[derive(Debug)]
struct AppWeightDetail {
application: String,
seconds: i64,
weight: f64,
weighted_seconds: i64,
matched_rule: String,
}
#[derive(Debug)]
@@ -938,10 +951,10 @@ fn build_reports(
let owner_items = workforce_rollup_items(snapshot, "owner_rollups");
let trend = workforce_trend_json(snapshot);
let insight_items = workforce_insight_items(snapshot);
let weighted = load_workforce_policy(workforce_policy_path)
.ok()
.flatten()
.and_then(|policy| weighted_activity(snapshot, &policy, metrics.users_count));
let workforce_policy = load_workforce_policy(workforce_policy_path).ok().flatten();
let weighted = workforce_policy
.as_ref()
.and_then(|policy| weighted_activity(snapshot, policy, metrics.users_count));
let headline = if summary.operator_ok && summary.severity == "OK" && metrics.open_incidents == 0
{
"Контур DetMir работает штатно, критичных действий не требуется"
@@ -1051,7 +1064,7 @@ fn build_reports(
"items": recommendations.iter().map(|item| report_item("Рекомендация", "INFO", item)).collect::<Vec<_>>()
}
],
"workforce_policy": workforce_policy_json(weighted.as_ref(), workforce_policy_path),
"workforce_policy": workforce_policy_json(workforce_policy.as_ref(), weighted.as_ref(), workforce_policy_path),
"workforce": {
"department_comparison": department_items,
"owner_comparison": owner_items,
@@ -1219,6 +1232,7 @@ fn weighted_activity(
let mut app_seconds = 0_i64;
let mut weighted_seconds = 0_f64;
let mut matched_applications = 0_usize;
let mut app_details = Vec::new();
for app in apps {
let name = app.get("application").and_then(Value::as_str).unwrap_or("");
let seconds = app
@@ -1230,19 +1244,35 @@ fn weighted_activity(
continue;
}
app_seconds += seconds;
let weight = application_weight(role_policy, name, default_weight);
let (weight, matched_rule) = application_weight_match(role_policy, name, default_weight);
if weight > 0.0 {
matched_applications += 1;
}
weighted_seconds += seconds as f64 * weight;
app_details.push(AppWeightDetail {
application: name.to_string(),
seconds,
weight,
weighted_seconds: (seconds as f64 * weight).round() as i64,
matched_rule,
});
}
app_details.sort_by_key(|item| -item.weighted_seconds);
app_details.truncate(12);
let weighted_seconds_i64 = weighted_seconds.round() as i64;
let role_label = role_policy
.label
.clone()
.unwrap_or_else(|| role.to_string());
let explanation = role_policy.description.clone().unwrap_or_else(|| {
format!(
"Роль {}: индекс = взвешенное время приложений / плановое время роли.",
role_label
)
});
Some(WeightedActivity {
role: role.to_string(),
role_label: role_policy
.label
.clone()
.unwrap_or_else(|| role.to_string()),
role_label,
index: Some(
((weighted_seconds / planned_seconds as f64) * 100.0)
.round()
@@ -1252,23 +1282,26 @@ fn weighted_activity(
app_seconds,
weighted_seconds: weighted_seconds_i64,
matched_applications,
explanation,
app_details,
})
}
fn application_weight(
fn application_weight_match(
role_policy: &WorkforceRolePolicy,
application: &str,
default_weight: f64,
) -> f64 {
) -> (f64, String) {
let app = application.to_lowercase();
role_policy
.application_weights
.iter()
.find_map(|(pattern, weight)| {
let pattern = pattern.to_lowercase();
(!pattern.is_empty() && app.contains(&pattern)).then_some(weight.clamp(0.0, 1.0))
(!pattern.is_empty() && app.contains(&pattern))
.then_some((weight.clamp(0.0, 1.0), pattern))
})
.unwrap_or(default_weight)
.unwrap_or((default_weight, "default_weight".to_string()))
}
fn weighted_activity_kpi(weighted: Option<&WeightedActivity>) -> Value {
@@ -1309,20 +1342,43 @@ fn weighted_activity_item(weighted: Option<&WeightedActivity>, policy_path: &Pat
}
}
fn workforce_policy_json(weighted: Option<&WeightedActivity>, policy_path: &Path) -> Value {
match weighted {
Some(weighted) => json!({
fn workforce_policy_json(
policy: Option<&WorkforcePolicy>,
weighted: Option<&WeightedActivity>,
policy_path: &Path,
) -> Value {
match (policy, weighted) {
(Some(policy), Some(weighted)) => json!({
"configured": true,
"path": policy_path.display().to_string(),
"default_role": policy.default_role,
"roles_count": policy.roles.len(),
"available_roles": workforce_role_catalog(policy),
"role": weighted.role,
"role_label": weighted.role_label,
"explanation": weighted.explanation,
"index": weighted.index,
"planned_seconds": weighted.planned_seconds,
"app_seconds": weighted.app_seconds,
"weighted_seconds": weighted.weighted_seconds,
"matched_applications": weighted.matched_applications,
"app_details": weighted.app_details.iter().map(|item| json!({
"application": item.application,
"seconds": item.seconds,
"weight": item.weight,
"weighted_seconds": item.weighted_seconds,
"matched_rule": item.matched_rule,
})).collect::<Vec<_>>(),
}),
None => json!({
(Some(policy), None) => json!({
"configured": true,
"path": policy_path.display().to_string(),
"default_role": policy.default_role,
"roles_count": policy.roles.len(),
"available_roles": workforce_role_catalog(policy),
"note": "role/application policy exists, but weighted activity could not be calculated",
}),
(None, _) => json!({
"configured": false,
"path": policy_path.display().to_string(),
"note": "weighted activity requires role/application policy",
@@ -1330,6 +1386,23 @@ fn workforce_policy_json(weighted: Option<&WeightedActivity>, policy_path: &Path
}
}
fn workforce_role_catalog(policy: &WorkforcePolicy) -> Vec<Value> {
policy
.roles
.iter()
.map(|(role, item)| {
json!({
"role": role,
"label": item.label.clone().unwrap_or_else(|| role.to_string()),
"description": item.description.clone().unwrap_or_default(),
"planned_hours_per_day": item.planned_hours_per_day.unwrap_or(8.0),
"default_weight": item.default_weight.unwrap_or(0.0),
"application_rules": item.application_weights.len(),
})
})
.collect()
}
fn default_workforce_role() -> String {
"default".to_string()
}
@@ -3273,6 +3346,10 @@ mod tests {
"accountant".to_string(),
WorkforceRolePolicy {
label: Some("Бухгалтер".to_string()),
description: Some(
"Бухгалтер: высокий вес 1С и офисных документов; развлекательные сайты не учитываются."
.to_string(),
),
planned_hours_per_day: Some(8.0),
default_weight: Some(0.2),
application_weights: BTreeMap::from([
+89 -8
View File
@@ -3,42 +3,123 @@
"roles": {
"accountant": {
"label": "Бухгалтер",
"description": "Бухгалтер: высокий вес 1С, Excel и документов; браузер учитывается умеренно, развлекательная активность не влияет на индекс.",
"planned_hours_per_day": 8,
"default_weight": 0.2,
"application_weights": {
"1с": 1.0,
"1c": 1.0,
"1cv8": 1.0,
"excel": 0.9,
"word": 0.7,
"outlook": 0.7,
"mail": 0.7,
"rdp": 0.8,
"browser": 0.4,
"chrome": 0.4,
"edge": 0.4,
"youtube": 0.0
}
},
"sales": {
"label": "Продажи",
"operator": {
"label": "Оператор",
"description": "Оператор: основной вес у 1С/CRM/RDP и операционных форм; офис и почта учитываются как вспомогательная активность.",
"planned_hours_per_day": 8,
"default_weight": 0.3,
"default_weight": 0.25,
"application_weights": {
"1с": 1.0,
"1c": 1.0,
"crm": 1.0,
"1с": 0.8,
"browser": 0.8,
"mail": 0.8,
"telegram": 0.4,
"rdp": 0.9,
"browser": 0.6,
"chrome": 0.6,
"edge": 0.6,
"excel": 0.6,
"outlook": 0.5,
"mail": 0.5,
"telegram": 0.3,
"youtube": 0.0
}
},
"developer": {
"label": "Разработчик",
"description": "Разработчик: высокий вес IDE, терминала, git и технического браузера; коммуникации учитываются ниже.",
"planned_hours_per_day": 8,
"default_weight": 0.3,
"application_weights": {
"ide": 1.0,
"code": 1.0,
"vscode": 1.0,
"visual studio": 1.0,
"terminal": 0.9,
"browser": 0.7,
"powershell": 0.9,
"cmd": 0.8,
"git": 0.9,
"browser": 0.7,
"chrome": 0.7,
"edge": 0.7,
"telegram": 0.3,
"youtube": 0.2
}
},
"admin": {
"label": "Администратор",
"description": "Администратор: высокий вес RDP, терминала, консоли управления, мониторинга и технических браузерных интерфейсов.",
"planned_hours_per_day": 8,
"default_weight": 0.3,
"application_weights": {
"rdp": 1.0,
"terminal": 1.0,
"powershell": 1.0,
"cmd": 0.9,
"ssh": 1.0,
"proxmox": 1.0,
"grafana": 0.9,
"prometheus": 0.9,
"browser": 0.7,
"chrome": 0.7,
"edge": 0.7,
"telegram": 0.4,
"youtube": 0.1
}
},
"manager": {
"label": "Менеджер",
"description": "Менеджер: высокий вес CRM, почты, документов, коммуникаций и бизнес-браузера; 1С учитывается как рабочий контекст.",
"planned_hours_per_day": 8,
"default_weight": 0.35,
"application_weights": {
"crm": 1.0,
"browser": 0.8,
"chrome": 0.8,
"edge": 0.8,
"mail": 0.8,
"outlook": 0.8,
"excel": 0.8,
"word": 0.7,
"telegram": 0.6,
"1с": 0.7,
"1c": 0.7,
"youtube": 0.1
}
},
"sales": {
"label": "Продажи",
"description": "Продажи: высокий вес CRM, браузера, почты и 1С; коммуникации учитываются как часть рабочего процесса.",
"planned_hours_per_day": 8,
"default_weight": 0.3,
"application_weights": {
"crm": 1.0,
"1с": 0.8,
"1c": 0.8,
"browser": 0.8,
"chrome": 0.8,
"edge": 0.8,
"mail": 0.8,
"outlook": 0.8,
"telegram": 0.4,
"youtube": 0.0
}
}
}
}
+16 -3
View File
@@ -52,13 +52,26 @@ Runtime-файл на сервере портала:
- `default_role` задает роль для агрегированного отчета;
- `planned_hours_per_day` задает плановый рабочий день для роли;
- `default_weight` применяется к приложениям без явного правила;
- `application_weights` задает веса приложений по подстроке имени.
- `application_weights` задает веса приложений по подстроке имени;
- `description` объясняет бизнес-смысл роли и используется для прозрачной
интерпретации weighted KPI.
Пример логики:
- бухгалтер: `1C = 1.0`, `Excel = 0.9`, `YouTube = 0.0`;
- продажи: `CRM = 1.0`, `browser = 0.8`, `mail = 0.8`;
- разработчик: `IDE/code = 1.0`, `terminal = 0.9`, `browser = 0.7`.
- оператор: `1C/CRM/RDP = 0.9..1.0`, офис и почта ниже;
- разработчик: `IDE/code = 1.0`, `terminal = 0.9`, `browser = 0.7`;
- администратор: `RDP/SSH/terminal/monitoring = 0.9..1.0`;
- менеджер/продажи: `CRM/mail/browser/documents = 0.7..1.0`.
JSON отчета содержит объяснение расчета:
- активная роль;
- доступные роли;
- плановое время;
- фактическое app time;
- взвешенное время;
- matched rule по каждому приложению из top breakdown.
После изменения runtime-файла нужно перезапустить портал:
+4
View File
@@ -35,6 +35,10 @@ baseline и раздела `Phase 8: Post-MVP Enhancements`.
- отчет поддерживает `Взвешенную активность` при наличии
`/etc/detmir-portal-workforce-policy.json`; публичный пример лежит в
`configs/detmir-workforce-policy.example.json`;
- пример policy содержит типовые роли `accountant`, `operator`, `developer`,
`admin`, `manager`, `sales`;
- JSON портала возвращает объяснение расчета weighted KPI: активную роль,
каталог ролей, planned/app/weighted seconds и matched rule по приложениям;
- отчет использует Worktime management snapshot и показывает сравнение
подразделений/ответственных за текущий день;
- JSON отчета содержит `workforce.department_comparison`,