feat(workforce): explain role profile scoring
This commit is contained in:
@@ -393,6 +393,8 @@ struct WorkforceRolePolicy {
|
|||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
label: Option<String>,
|
label: Option<String>,
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
|
description: Option<String>,
|
||||||
|
#[serde(default)]
|
||||||
planned_hours_per_day: Option<f64>,
|
planned_hours_per_day: Option<f64>,
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
default_weight: Option<f64>,
|
default_weight: Option<f64>,
|
||||||
@@ -409,6 +411,17 @@ struct WeightedActivity {
|
|||||||
app_seconds: i64,
|
app_seconds: i64,
|
||||||
weighted_seconds: i64,
|
weighted_seconds: i64,
|
||||||
matched_applications: usize,
|
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)]
|
#[derive(Debug)]
|
||||||
@@ -938,10 +951,10 @@ fn build_reports(
|
|||||||
let owner_items = workforce_rollup_items(snapshot, "owner_rollups");
|
let owner_items = workforce_rollup_items(snapshot, "owner_rollups");
|
||||||
let trend = workforce_trend_json(snapshot);
|
let trend = workforce_trend_json(snapshot);
|
||||||
let insight_items = workforce_insight_items(snapshot);
|
let insight_items = workforce_insight_items(snapshot);
|
||||||
let weighted = load_workforce_policy(workforce_policy_path)
|
let workforce_policy = load_workforce_policy(workforce_policy_path).ok().flatten();
|
||||||
.ok()
|
let weighted = workforce_policy
|
||||||
.flatten()
|
.as_ref()
|
||||||
.and_then(|policy| weighted_activity(snapshot, &policy, metrics.users_count));
|
.and_then(|policy| weighted_activity(snapshot, policy, metrics.users_count));
|
||||||
let headline = if summary.operator_ok && summary.severity == "OK" && metrics.open_incidents == 0
|
let headline = if summary.operator_ok && summary.severity == "OK" && metrics.open_incidents == 0
|
||||||
{
|
{
|
||||||
"Контур DetMir работает штатно, критичных действий не требуется"
|
"Контур DetMir работает штатно, критичных действий не требуется"
|
||||||
@@ -1051,7 +1064,7 @@ fn build_reports(
|
|||||||
"items": recommendations.iter().map(|item| report_item("Рекомендация", "INFO", item)).collect::<Vec<_>>()
|
"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": {
|
"workforce": {
|
||||||
"department_comparison": department_items,
|
"department_comparison": department_items,
|
||||||
"owner_comparison": owner_items,
|
"owner_comparison": owner_items,
|
||||||
@@ -1219,6 +1232,7 @@ fn weighted_activity(
|
|||||||
let mut app_seconds = 0_i64;
|
let mut app_seconds = 0_i64;
|
||||||
let mut weighted_seconds = 0_f64;
|
let mut weighted_seconds = 0_f64;
|
||||||
let mut matched_applications = 0_usize;
|
let mut matched_applications = 0_usize;
|
||||||
|
let mut app_details = Vec::new();
|
||||||
for app in apps {
|
for app in apps {
|
||||||
let name = app.get("application").and_then(Value::as_str).unwrap_or("");
|
let name = app.get("application").and_then(Value::as_str).unwrap_or("");
|
||||||
let seconds = app
|
let seconds = app
|
||||||
@@ -1230,19 +1244,35 @@ fn weighted_activity(
|
|||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
app_seconds += seconds;
|
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 {
|
if weight > 0.0 {
|
||||||
matched_applications += 1;
|
matched_applications += 1;
|
||||||
}
|
}
|
||||||
weighted_seconds += seconds as f64 * weight;
|
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 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 {
|
Some(WeightedActivity {
|
||||||
role: role.to_string(),
|
role: role.to_string(),
|
||||||
role_label: role_policy
|
role_label,
|
||||||
.label
|
|
||||||
.clone()
|
|
||||||
.unwrap_or_else(|| role.to_string()),
|
|
||||||
index: Some(
|
index: Some(
|
||||||
((weighted_seconds / planned_seconds as f64) * 100.0)
|
((weighted_seconds / planned_seconds as f64) * 100.0)
|
||||||
.round()
|
.round()
|
||||||
@@ -1252,23 +1282,26 @@ fn weighted_activity(
|
|||||||
app_seconds,
|
app_seconds,
|
||||||
weighted_seconds: weighted_seconds_i64,
|
weighted_seconds: weighted_seconds_i64,
|
||||||
matched_applications,
|
matched_applications,
|
||||||
|
explanation,
|
||||||
|
app_details,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
fn application_weight(
|
fn application_weight_match(
|
||||||
role_policy: &WorkforceRolePolicy,
|
role_policy: &WorkforceRolePolicy,
|
||||||
application: &str,
|
application: &str,
|
||||||
default_weight: f64,
|
default_weight: f64,
|
||||||
) -> f64 {
|
) -> (f64, String) {
|
||||||
let app = application.to_lowercase();
|
let app = application.to_lowercase();
|
||||||
role_policy
|
role_policy
|
||||||
.application_weights
|
.application_weights
|
||||||
.iter()
|
.iter()
|
||||||
.find_map(|(pattern, weight)| {
|
.find_map(|(pattern, weight)| {
|
||||||
let pattern = pattern.to_lowercase();
|
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 {
|
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 {
|
fn workforce_policy_json(
|
||||||
match weighted {
|
policy: Option<&WorkforcePolicy>,
|
||||||
Some(weighted) => json!({
|
weighted: Option<&WeightedActivity>,
|
||||||
|
policy_path: &Path,
|
||||||
|
) -> Value {
|
||||||
|
match (policy, weighted) {
|
||||||
|
(Some(policy), Some(weighted)) => json!({
|
||||||
"configured": true,
|
"configured": true,
|
||||||
"path": policy_path.display().to_string(),
|
"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": weighted.role,
|
||||||
"role_label": weighted.role_label,
|
"role_label": weighted.role_label,
|
||||||
|
"explanation": weighted.explanation,
|
||||||
"index": weighted.index,
|
"index": weighted.index,
|
||||||
"planned_seconds": weighted.planned_seconds,
|
"planned_seconds": weighted.planned_seconds,
|
||||||
"app_seconds": weighted.app_seconds,
|
"app_seconds": weighted.app_seconds,
|
||||||
"weighted_seconds": weighted.weighted_seconds,
|
"weighted_seconds": weighted.weighted_seconds,
|
||||||
"matched_applications": weighted.matched_applications,
|
"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,
|
"configured": false,
|
||||||
"path": policy_path.display().to_string(),
|
"path": policy_path.display().to_string(),
|
||||||
"note": "weighted activity requires role/application policy",
|
"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 {
|
fn default_workforce_role() -> String {
|
||||||
"default".to_string()
|
"default".to_string()
|
||||||
}
|
}
|
||||||
@@ -3273,6 +3346,10 @@ mod tests {
|
|||||||
"accountant".to_string(),
|
"accountant".to_string(),
|
||||||
WorkforceRolePolicy {
|
WorkforceRolePolicy {
|
||||||
label: Some("Бухгалтер".to_string()),
|
label: Some("Бухгалтер".to_string()),
|
||||||
|
description: Some(
|
||||||
|
"Бухгалтер: высокий вес 1С и офисных документов; развлекательные сайты не учитываются."
|
||||||
|
.to_string(),
|
||||||
|
),
|
||||||
planned_hours_per_day: Some(8.0),
|
planned_hours_per_day: Some(8.0),
|
||||||
default_weight: Some(0.2),
|
default_weight: Some(0.2),
|
||||||
application_weights: BTreeMap::from([
|
application_weights: BTreeMap::from([
|
||||||
|
|||||||
@@ -3,42 +3,123 @@
|
|||||||
"roles": {
|
"roles": {
|
||||||
"accountant": {
|
"accountant": {
|
||||||
"label": "Бухгалтер",
|
"label": "Бухгалтер",
|
||||||
|
"description": "Бухгалтер: высокий вес 1С, Excel и документов; браузер учитывается умеренно, развлекательная активность не влияет на индекс.",
|
||||||
"planned_hours_per_day": 8,
|
"planned_hours_per_day": 8,
|
||||||
"default_weight": 0.2,
|
"default_weight": 0.2,
|
||||||
"application_weights": {
|
"application_weights": {
|
||||||
"1с": 1.0,
|
"1с": 1.0,
|
||||||
|
"1c": 1.0,
|
||||||
|
"1cv8": 1.0,
|
||||||
"excel": 0.9,
|
"excel": 0.9,
|
||||||
"word": 0.7,
|
"word": 0.7,
|
||||||
|
"outlook": 0.7,
|
||||||
|
"mail": 0.7,
|
||||||
"rdp": 0.8,
|
"rdp": 0.8,
|
||||||
"browser": 0.4,
|
"browser": 0.4,
|
||||||
|
"chrome": 0.4,
|
||||||
|
"edge": 0.4,
|
||||||
"youtube": 0.0
|
"youtube": 0.0
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"sales": {
|
"operator": {
|
||||||
"label": "Продажи",
|
"label": "Оператор",
|
||||||
|
"description": "Оператор: основной вес у 1С/CRM/RDP и операционных форм; офис и почта учитываются как вспомогательная активность.",
|
||||||
"planned_hours_per_day": 8,
|
"planned_hours_per_day": 8,
|
||||||
"default_weight": 0.3,
|
"default_weight": 0.25,
|
||||||
"application_weights": {
|
"application_weights": {
|
||||||
|
"1с": 1.0,
|
||||||
|
"1c": 1.0,
|
||||||
"crm": 1.0,
|
"crm": 1.0,
|
||||||
"1с": 0.8,
|
"rdp": 0.9,
|
||||||
"browser": 0.8,
|
"browser": 0.6,
|
||||||
"mail": 0.8,
|
"chrome": 0.6,
|
||||||
"telegram": 0.4,
|
"edge": 0.6,
|
||||||
|
"excel": 0.6,
|
||||||
|
"outlook": 0.5,
|
||||||
|
"mail": 0.5,
|
||||||
|
"telegram": 0.3,
|
||||||
"youtube": 0.0
|
"youtube": 0.0
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"developer": {
|
"developer": {
|
||||||
"label": "Разработчик",
|
"label": "Разработчик",
|
||||||
|
"description": "Разработчик: высокий вес IDE, терминала, git и технического браузера; коммуникации учитываются ниже.",
|
||||||
"planned_hours_per_day": 8,
|
"planned_hours_per_day": 8,
|
||||||
"default_weight": 0.3,
|
"default_weight": 0.3,
|
||||||
"application_weights": {
|
"application_weights": {
|
||||||
"ide": 1.0,
|
"ide": 1.0,
|
||||||
"code": 1.0,
|
"code": 1.0,
|
||||||
|
"vscode": 1.0,
|
||||||
|
"visual studio": 1.0,
|
||||||
"terminal": 0.9,
|
"terminal": 0.9,
|
||||||
"browser": 0.7,
|
"powershell": 0.9,
|
||||||
|
"cmd": 0.8,
|
||||||
"git": 0.9,
|
"git": 0.9,
|
||||||
|
"browser": 0.7,
|
||||||
|
"chrome": 0.7,
|
||||||
|
"edge": 0.7,
|
||||||
|
"telegram": 0.3,
|
||||||
"youtube": 0.2
|
"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
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -52,13 +52,26 @@ Runtime-файл на сервере портала:
|
|||||||
- `default_role` задает роль для агрегированного отчета;
|
- `default_role` задает роль для агрегированного отчета;
|
||||||
- `planned_hours_per_day` задает плановый рабочий день для роли;
|
- `planned_hours_per_day` задает плановый рабочий день для роли;
|
||||||
- `default_weight` применяется к приложениям без явного правила;
|
- `default_weight` применяется к приложениям без явного правила;
|
||||||
- `application_weights` задает веса приложений по подстроке имени.
|
- `application_weights` задает веса приложений по подстроке имени;
|
||||||
|
- `description` объясняет бизнес-смысл роли и используется для прозрачной
|
||||||
|
интерпретации weighted KPI.
|
||||||
|
|
||||||
Пример логики:
|
Пример логики:
|
||||||
|
|
||||||
- бухгалтер: `1C = 1.0`, `Excel = 0.9`, `YouTube = 0.0`;
|
- бухгалтер: `1C = 1.0`, `Excel = 0.9`, `YouTube = 0.0`;
|
||||||
- продажи: `CRM = 1.0`, `browser = 0.8`, `mail = 0.8`;
|
- оператор: `1C/CRM/RDP = 0.9..1.0`, офис и почта ниже;
|
||||||
- разработчик: `IDE/code = 1.0`, `terminal = 0.9`, `browser = 0.7`.
|
- разработчик: `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-файла нужно перезапустить портал:
|
После изменения runtime-файла нужно перезапустить портал:
|
||||||
|
|
||||||
|
|||||||
@@ -35,6 +35,10 @@ 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`;
|
||||||
|
- пример policy содержит типовые роли `accountant`, `operator`, `developer`,
|
||||||
|
`admin`, `manager`, `sales`;
|
||||||
|
- JSON портала возвращает объяснение расчета weighted KPI: активную роль,
|
||||||
|
каталог ролей, planned/app/weighted seconds и matched rule по приложениям;
|
||||||
- отчет использует Worktime management snapshot и показывает сравнение
|
- отчет использует Worktime management snapshot и показывает сравнение
|
||||||
подразделений/ответственных за текущий день;
|
подразделений/ответственных за текущий день;
|
||||||
- JSON отчета содержит `workforce.department_comparison`,
|
- JSON отчета содержит `workforce.department_comparison`,
|
||||||
|
|||||||
Reference in New Issue
Block a user