feat(portal): export workforce index evidence
This commit is contained in:
@@ -407,12 +407,15 @@ struct WeightedActivity {
|
||||
role: String,
|
||||
role_label: String,
|
||||
index: Option<u8>,
|
||||
formula: String,
|
||||
planned_seconds: i64,
|
||||
app_seconds: i64,
|
||||
weighted_seconds: i64,
|
||||
matched_applications: usize,
|
||||
explanation: String,
|
||||
app_details: Vec<AppWeightDetail>,
|
||||
policy_audit: Value,
|
||||
employee_details: Vec<Value>,
|
||||
}
|
||||
|
||||
#[derive(Debug)]
|
||||
@@ -1001,6 +1004,7 @@ fn build_reports(
|
||||
&metrics,
|
||||
&recommendations,
|
||||
&workforce_summary,
|
||||
&workforce_policy_explain,
|
||||
);
|
||||
json!({
|
||||
"generated_at_utc": snapshot.generated_at_utc,
|
||||
@@ -1275,6 +1279,7 @@ fn weighted_activity(
|
||||
});
|
||||
}
|
||||
app_details.sort_by_key(|item| -item.weighted_seconds);
|
||||
let policy_audit = build_policy_audit(&app_details, default_weight);
|
||||
app_details.truncate(12);
|
||||
let weighted_seconds_i64 = weighted_seconds.round() as i64;
|
||||
let role_label = role_policy
|
||||
@@ -1295,15 +1300,114 @@ fn weighted_activity(
|
||||
.round()
|
||||
.clamp(0.0, 100.0) as u8,
|
||||
),
|
||||
formula: "index = weighted_seconds / planned_seconds × 100".to_string(),
|
||||
planned_seconds,
|
||||
app_seconds,
|
||||
weighted_seconds: weighted_seconds_i64,
|
||||
matched_applications,
|
||||
explanation,
|
||||
app_details,
|
||||
policy_audit,
|
||||
employee_details: employee_index_details(snapshot, &role_policy.label, planned_hours),
|
||||
})
|
||||
}
|
||||
|
||||
fn build_policy_audit(app_details: &[AppWeightDetail], default_weight: f64) -> Value {
|
||||
let default_items = app_details
|
||||
.iter()
|
||||
.filter(|item| item.matched_rule == "default_weight")
|
||||
.map(|item| {
|
||||
json!({
|
||||
"application": item.application,
|
||||
"seconds": item.seconds,
|
||||
"weight": item.weight,
|
||||
"weighted_seconds": item.weighted_seconds,
|
||||
"reason": "matched no explicit application rule"
|
||||
})
|
||||
})
|
||||
.collect::<Vec<_>>();
|
||||
let zero_weight_items = app_details
|
||||
.iter()
|
||||
.filter(|item| item.weight <= 0.0)
|
||||
.map(|item| {
|
||||
json!({
|
||||
"application": item.application,
|
||||
"seconds": item.seconds,
|
||||
"matched_rule": item.matched_rule
|
||||
})
|
||||
})
|
||||
.collect::<Vec<_>>();
|
||||
let default_seconds = app_details
|
||||
.iter()
|
||||
.filter(|item| item.matched_rule == "default_weight")
|
||||
.map(|item| item.seconds)
|
||||
.sum::<i64>();
|
||||
json!({
|
||||
"default_weight": default_weight,
|
||||
"total_applications": app_details.len(),
|
||||
"explicit_rule_applications": app_details.iter().filter(|item| item.matched_rule != "default_weight").count(),
|
||||
"default_weight_applications": default_items.len(),
|
||||
"default_weight_seconds": default_seconds,
|
||||
"zero_weight_applications": zero_weight_items.len(),
|
||||
"needs_review": default_items.into_iter().take(12).collect::<Vec<_>>(),
|
||||
"zero_weight_details": zero_weight_items.into_iter().take(12).collect::<Vec<_>>(),
|
||||
})
|
||||
}
|
||||
|
||||
fn employee_index_details(
|
||||
snapshot: &Snapshot,
|
||||
role_label: &Option<String>,
|
||||
planned_hours_per_day: f64,
|
||||
) -> Vec<Value> {
|
||||
let planned_seconds = (planned_hours_per_day * 3600.0).round() as i64;
|
||||
let Some(rows) = snapshot
|
||||
.worktime
|
||||
.payload
|
||||
.as_ref()
|
||||
.and_then(|payload| payload.get("rows"))
|
||||
.and_then(Value::as_array)
|
||||
else {
|
||||
return Vec::new();
|
||||
};
|
||||
let role = role_label.as_deref().unwrap_or("default");
|
||||
rows.iter()
|
||||
.map(|row| {
|
||||
let active_seconds = row
|
||||
.get("active_seconds")
|
||||
.and_then(Value::as_i64)
|
||||
.unwrap_or(0)
|
||||
.max(0);
|
||||
let index = if planned_seconds > 0 {
|
||||
((active_seconds as f64 / planned_seconds as f64) * 100.0)
|
||||
.round()
|
||||
.clamp(0.0, 100.0) as i64
|
||||
} else {
|
||||
0
|
||||
};
|
||||
json!({
|
||||
"user": row.get("user").and_then(Value::as_str).unwrap_or(""),
|
||||
"user_id": row.get("user_id").and_then(Value::as_str).unwrap_or(""),
|
||||
"role_label": role,
|
||||
"formula": "employee_index = active_seconds / planned_seconds × 100",
|
||||
"reason": format!(
|
||||
"active {} / plan {} => {}%",
|
||||
human_duration(active_seconds),
|
||||
human_duration(planned_seconds),
|
||||
index
|
||||
),
|
||||
"index": index,
|
||||
"status": workforce_index_status(Some(index as u8)),
|
||||
"active_seconds": active_seconds,
|
||||
"active_hhmm": row.get("active_hhmm").and_then(Value::as_str).unwrap_or(""),
|
||||
"planned_seconds": planned_seconds,
|
||||
"planned_hhmm": human_duration(planned_seconds),
|
||||
"last_activity": row.get("last_activity").and_then(Value::as_str).unwrap_or(""),
|
||||
"scope_note": "per-user app-weight attribution is not available in current worktime payload; app weights are portfolio-level"
|
||||
})
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
fn application_weight_match(
|
||||
role_policy: &WorkforceRolePolicy,
|
||||
application: &str,
|
||||
@@ -1413,11 +1517,14 @@ fn workforce_policy_json(
|
||||
"role": weighted.role,
|
||||
"role_label": weighted.role_label,
|
||||
"explanation": weighted.explanation,
|
||||
"formula": weighted.formula,
|
||||
"index": weighted.index,
|
||||
"planned_seconds": weighted.planned_seconds,
|
||||
"app_seconds": weighted.app_seconds,
|
||||
"weighted_seconds": weighted.weighted_seconds,
|
||||
"matched_applications": weighted.matched_applications,
|
||||
"policy_audit": weighted.policy_audit,
|
||||
"employee_details": weighted.employee_details,
|
||||
"app_details": weighted.app_details.iter().map(|item| json!({
|
||||
"application": item.application,
|
||||
"seconds": item.seconds,
|
||||
@@ -1487,6 +1594,7 @@ fn render_report_markdown(
|
||||
metrics: &ReportMetrics,
|
||||
recommendations: &[String],
|
||||
workforce: &ReportWorkforceSummary,
|
||||
workforce_policy: &Value,
|
||||
) -> String {
|
||||
let mut text = String::new();
|
||||
text.push_str("# DetMir оперативный отчет\n\n");
|
||||
@@ -1540,10 +1648,140 @@ fn render_report_markdown(
|
||||
for item in recommendations {
|
||||
text.push_str(&format!("- {item}\n"));
|
||||
}
|
||||
append_workforce_policy_markdown(&mut text, workforce_policy);
|
||||
text.push_str("\nПримечание: DLP/case показатели являются derived detections/cases и требуют регламентной валидации перед подачей как подтвержденные инциденты.\n");
|
||||
text
|
||||
}
|
||||
|
||||
fn append_workforce_policy_markdown(text: &mut String, policy: &Value) {
|
||||
if policy.get("configured").and_then(Value::as_bool) != Some(true) {
|
||||
text.push_str("\n## Почему такой индекс\n\n");
|
||||
text.push_str("- Role/application policy не настроена.\n");
|
||||
return;
|
||||
}
|
||||
text.push_str("\n## Почему такой индекс\n\n");
|
||||
text.push_str(&format!(
|
||||
"- Роль: {}\n",
|
||||
policy
|
||||
.get("role_label")
|
||||
.and_then(Value::as_str)
|
||||
.unwrap_or("-")
|
||||
));
|
||||
text.push_str(&format!(
|
||||
"- Формула: {}\n",
|
||||
policy
|
||||
.get("formula")
|
||||
.and_then(Value::as_str)
|
||||
.unwrap_or("index = weighted_seconds / planned_seconds × 100")
|
||||
));
|
||||
text.push_str(&format!(
|
||||
"- Индекс: {}\n",
|
||||
workforce_index_text(
|
||||
policy
|
||||
.get("index")
|
||||
.and_then(Value::as_u64)
|
||||
.map(|value| value as u8)
|
||||
)
|
||||
));
|
||||
text.push_str(&format!(
|
||||
"- План/App/Weighted: {}/{}/{}\n",
|
||||
human_duration(
|
||||
policy
|
||||
.get("planned_seconds")
|
||||
.and_then(Value::as_i64)
|
||||
.unwrap_or(0)
|
||||
),
|
||||
human_duration(
|
||||
policy
|
||||
.get("app_seconds")
|
||||
.and_then(Value::as_i64)
|
||||
.unwrap_or(0)
|
||||
),
|
||||
human_duration(
|
||||
policy
|
||||
.get("weighted_seconds")
|
||||
.and_then(Value::as_i64)
|
||||
.unwrap_or(0)
|
||||
)
|
||||
));
|
||||
if let Some(explanation) = policy.get("explanation").and_then(Value::as_str) {
|
||||
text.push_str(&format!("- Объяснение: {explanation}\n"));
|
||||
}
|
||||
text.push_str("\n### Top приложений\n\n");
|
||||
for item in policy
|
||||
.get("app_details")
|
||||
.and_then(Value::as_array)
|
||||
.into_iter()
|
||||
.flatten()
|
||||
.take(12)
|
||||
{
|
||||
text.push_str(&format!(
|
||||
"- {}: raw {}, weight {:.0}%, weighted {}, rule `{}`\n",
|
||||
item.get("application")
|
||||
.and_then(Value::as_str)
|
||||
.unwrap_or("-"),
|
||||
human_duration(item.get("seconds").and_then(Value::as_i64).unwrap_or(0)),
|
||||
item.get("weight").and_then(Value::as_f64).unwrap_or(0.0) * 100.0,
|
||||
human_duration(
|
||||
item.get("weighted_seconds")
|
||||
.and_then(Value::as_i64)
|
||||
.unwrap_or(0)
|
||||
),
|
||||
item.get("matched_rule")
|
||||
.and_then(Value::as_str)
|
||||
.unwrap_or("-")
|
||||
));
|
||||
}
|
||||
let default_items = policy
|
||||
.get("policy_audit")
|
||||
.and_then(|audit| audit.get("needs_review"))
|
||||
.and_then(Value::as_array)
|
||||
.cloned()
|
||||
.unwrap_or_default();
|
||||
if !default_items.is_empty() {
|
||||
text.push_str("\n### Аудит policy: default_weight\n\n");
|
||||
for item in default_items.iter().take(12) {
|
||||
text.push_str(&format!(
|
||||
"- {}: raw {}, default weight {:.0}%\n",
|
||||
item.get("application")
|
||||
.and_then(Value::as_str)
|
||||
.unwrap_or("-"),
|
||||
human_duration(item.get("seconds").and_then(Value::as_i64).unwrap_or(0)),
|
||||
item.get("weight").and_then(Value::as_f64).unwrap_or(0.0) * 100.0
|
||||
));
|
||||
}
|
||||
}
|
||||
let employee_items = policy
|
||||
.get("employee_details")
|
||||
.and_then(Value::as_array)
|
||||
.cloned()
|
||||
.unwrap_or_default();
|
||||
if !employee_items.is_empty() {
|
||||
text.push_str("\n### Drill-down по сотрудникам\n\n");
|
||||
for item in employee_items.iter().take(12) {
|
||||
text.push_str(&format!(
|
||||
"- {}: {}%, active {}, plan {}, formula `{}`\n",
|
||||
item.get("user").and_then(Value::as_str).unwrap_or("-"),
|
||||
item.get("index").and_then(Value::as_i64).unwrap_or(0),
|
||||
human_duration(
|
||||
item.get("active_seconds")
|
||||
.and_then(Value::as_i64)
|
||||
.unwrap_or(0)
|
||||
),
|
||||
human_duration(
|
||||
item.get("planned_seconds")
|
||||
.and_then(Value::as_i64)
|
||||
.unwrap_or(0)
|
||||
),
|
||||
item.get("formula").and_then(Value::as_str).unwrap_or("-")
|
||||
));
|
||||
if let Some(reason) = item.get("reason").and_then(Value::as_str) {
|
||||
text.push_str(&format!(" - reason: {reason}\n"));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn worktime_totals(snapshot: &Snapshot) -> (usize, i64, usize) {
|
||||
let Some(payload) = snapshot.worktime.payload.as_ref() else {
|
||||
return (0, 0, 0);
|
||||
@@ -3499,6 +3737,21 @@ mod tests {
|
||||
assert_eq!(explain["role"], "accountant");
|
||||
assert_eq!(explain["roles_count"], 1);
|
||||
assert_eq!(explain["app_details"].as_array().unwrap().len(), 1);
|
||||
assert_eq!(
|
||||
explain["formula"],
|
||||
"index = weighted_seconds / planned_seconds × 100"
|
||||
);
|
||||
assert!(explain["planned_seconds"].as_i64().unwrap() > 0);
|
||||
assert!(explain["weighted_seconds"].as_i64().unwrap() > 0);
|
||||
assert!(explain["policy_audit"].is_object());
|
||||
assert!(explain["employee_details"].as_array().unwrap().len() == 1);
|
||||
assert_eq!(
|
||||
explain["employee_details"][0]["formula"],
|
||||
"employee_index = active_seconds / planned_seconds × 100"
|
||||
);
|
||||
assert!(explain["employee_details"][0].get("reason").is_some());
|
||||
assert!(explain["app_details"][0].get("matched_rule").is_some());
|
||||
assert!(explain["app_details"][0].get("weight").is_some());
|
||||
assert!(explain.get("workforce").is_none());
|
||||
assert!(explain.get("sections").is_none());
|
||||
assert!(explain.get("markdown").is_none());
|
||||
|
||||
@@ -223,6 +223,35 @@ h1 {
|
||||
grid-template-columns: minmax(140px, 1fr) minmax(260px, 2fr) auto;
|
||||
}
|
||||
|
||||
.audit-block,
|
||||
.employee-drilldown,
|
||||
.audit-note {
|
||||
margin-top: 12px;
|
||||
padding-top: 12px;
|
||||
border-top: 1px solid var(--line);
|
||||
}
|
||||
|
||||
.audit-block h4,
|
||||
.employee-drilldown h4 {
|
||||
margin: 0 0 6px;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.audit-note {
|
||||
display: grid;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.employee-index-row {
|
||||
grid-template-columns: minmax(140px, 1fr) minmax(280px, 2fr) auto;
|
||||
}
|
||||
|
||||
.report-actions {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
margin: 10px 0;
|
||||
}
|
||||
|
||||
.compact-list {
|
||||
gap: 6px;
|
||||
}
|
||||
@@ -318,6 +347,24 @@ pre {
|
||||
.incident-row { grid-template-columns: 1fr; }
|
||||
.evidence-row { grid-template-columns: 1fr; }
|
||||
.app-weight-row { grid-template-columns: 1fr; }
|
||||
.employee-index-row { grid-template-columns: 1fr; }
|
||||
.section-head { flex-direction: column; }
|
||||
.actions { justify-content: flex-start; }
|
||||
}
|
||||
|
||||
@media print {
|
||||
body { background: #fff; }
|
||||
.tabs,
|
||||
.report-actions,
|
||||
.links,
|
||||
.small-button { display: none !important; }
|
||||
.shell {
|
||||
max-width: none;
|
||||
padding: 0;
|
||||
}
|
||||
.card,
|
||||
.content-panel {
|
||||
break-inside: avoid;
|
||||
border-color: #cbd5e1;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -184,6 +184,7 @@ function renderWorkforceIndexExplanation(policy) {
|
||||
`;
|
||||
}
|
||||
const details = Array.isArray(policy.app_details) ? policy.app_details.slice(0, 12) : [];
|
||||
const employees = Array.isArray(policy.employee_details) ? policy.employee_details.slice(0, 12) : [];
|
||||
const weightedTotal = Math.max(1, Number(policy.weighted_seconds || 0));
|
||||
const appRows = details.length === 0
|
||||
? `<div class="row compact-row"><strong>Нет приложений</strong><span class="muted">Нет top breakdown для weighted KPI</span><span></span></div>`
|
||||
@@ -203,6 +204,7 @@ function renderWorkforceIndexExplanation(policy) {
|
||||
<div>
|
||||
<h3>Почему такой индекс?</h3>
|
||||
<p class="muted">${escapeHtml(policy.explanation || "Индекс = взвешенное время приложений / плановое время роли.")}</p>
|
||||
<p class="muted small">Формула: ${escapeHtml(policy.formula || "index = weighted_seconds / planned_seconds × 100")}.</p>
|
||||
</div>
|
||||
<span class="badge ${statusClass(workforceIndexStatus(policy.index))}">${escapeHtml(workforceIndexTextFromValue(policy.index))}</span>
|
||||
</div>
|
||||
@@ -213,10 +215,49 @@ function renderWorkforceIndexExplanation(policy) {
|
||||
<div><span class="muted">Weighted</span><strong>${escapeHtml(humanSeconds(policy.weighted_seconds))}</strong></div>
|
||||
</div>
|
||||
<div class="list compact-list app-weight-list">${appRows}</div>
|
||||
${renderPolicyAudit(policy.policy_audit)}
|
||||
${renderEmployeeIndexDetails(employees)}
|
||||
</section>
|
||||
`;
|
||||
}
|
||||
|
||||
function renderPolicyAudit(audit) {
|
||||
const items = Array.isArray(audit?.needs_review) ? audit.needs_review.slice(0, 12) : [];
|
||||
if (items.length === 0) {
|
||||
return `<div class="audit-note"><strong>Аудит policy</strong><span class="muted">Все top приложения попали под явные правила или данных для аудита нет.</span></div>`;
|
||||
}
|
||||
return `
|
||||
<div class="audit-block">
|
||||
<h4>Аудит policy: default_weight</h4>
|
||||
<p class="muted small">Эти приложения не нашли явного правила и требуют проверки классификации.</p>
|
||||
<div class="list compact-list">${items.map(item => `
|
||||
<div class="row compact-row">
|
||||
<strong>${escapeHtml(item.application || "-")}</strong>
|
||||
<span class="muted">${escapeHtml(humanSeconds(item.seconds))} · default ${escapeHtml(pctText(item.weight))}</span>
|
||||
<span class="badge status-warn">review</span>
|
||||
</div>
|
||||
`).join("")}</div>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
function renderEmployeeIndexDetails(items) {
|
||||
if (!items.length) return "";
|
||||
return `
|
||||
<div class="employee-drilldown">
|
||||
<h4>Drill-down по сотрудникам</h4>
|
||||
<p class="muted small">Per-user индекс сейчас считается по активному времени; app-weight breakdown доступен на уровне портфеля.</p>
|
||||
<div class="list compact-list">${items.map(item => `
|
||||
<div class="row employee-index-row">
|
||||
<strong>${escapeHtml(item.user || "-")}</strong>
|
||||
<span class="muted">${escapeHtml(item.reason || `${item.formula || "employee_index = active_seconds / planned_seconds × 100"} · active ${humanSeconds(item.active_seconds)} / plan ${humanSeconds(item.planned_seconds)}`)}</span>
|
||||
<span class="badge ${statusClass(item.status)}">${escapeHtml(workforceIndexTextFromValue(item.index))}</span>
|
||||
</div>
|
||||
`).join("")}</div>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
function workforceIndexTextFromValue(value) {
|
||||
const n = Number(value);
|
||||
return Number.isFinite(n) ? `${Math.round(n)}%` : "Нет данных";
|
||||
@@ -373,6 +414,9 @@ function renderReports(data) {
|
||||
`).join("")}</div>
|
||||
</section>
|
||||
</div>
|
||||
<div class="report-actions">
|
||||
<button class="small-button" data-print-report="true">Печать / PDF</button>
|
||||
</div>
|
||||
<h3 class="section-title">Ключевые показатели</h3>
|
||||
${renderKpiCards(data.kpis)}
|
||||
${renderWorkforceIndexExplanation(data.workforce_policy)}
|
||||
@@ -427,6 +471,12 @@ document.addEventListener("click", event => {
|
||||
incidentAction(button).catch(showError);
|
||||
});
|
||||
|
||||
document.addEventListener("click", event => {
|
||||
const button = event.target.closest("[data-print-report]");
|
||||
if (!button) return;
|
||||
window.print();
|
||||
});
|
||||
|
||||
async function incidentAction(button) {
|
||||
const id = button.dataset.incidentId;
|
||||
const action = button.dataset.incidentAction;
|
||||
|
||||
@@ -68,14 +68,30 @@ JSON отчета содержит объяснение расчета:
|
||||
|
||||
- активная роль;
|
||||
- доступные роли;
|
||||
- формула `index = weighted_seconds / planned_seconds × 100`;
|
||||
- плановое время;
|
||||
- фактическое app time;
|
||||
- взвешенное время;
|
||||
- matched rule по каждому приложению из top breakdown.
|
||||
- audit-блок policy: какие приложения попали под `default_weight`;
|
||||
- drill-down по сотрудникам: формула, active/plan seconds, индекс и причина.
|
||||
|
||||
В портале это раскрывается в экране `Почему такой индекс?`: собственник видит
|
||||
роль, итоговый weighted KPI, план/app/weighted time и top-12 приложений с весом
|
||||
и вкладом каждого приложения.
|
||||
и вкладом каждого приложения. Та же секция добавляется в Markdown export
|
||||
оперативного отчета, чтобы расчет можно было приложить к письму, PDF или
|
||||
коммерческому отчету без ручного пересказа GUI.
|
||||
|
||||
PDF-экспорт выполняется штатной печатью браузера из вкладки `Отчеты`
|
||||
(`Печать / PDF`). Печатный CSS скрывает навигацию и оставляет отчетные секции,
|
||||
включая `Почему такой индекс?`, audit `default_weight` и drill-down по
|
||||
сотрудникам.
|
||||
|
||||
Ограничение текущего contract: per-user индекс объясняется по персональному
|
||||
`active_seconds / planned_seconds`; per-user app-weight breakdown пока не
|
||||
доступен в worktime payload. Поэтому веса приложений и аудит `default_weight`
|
||||
являются portfolio-level объяснением, а не персональной раскладкой приложений.
|
||||
Это честнее, чем выводить ложную детализацию.
|
||||
|
||||
Для быстрой загрузки вкладки руководителя explainability-блок доступен отдельным
|
||||
легким endpoint:
|
||||
@@ -85,6 +101,12 @@ JSON отчета содержит объяснение расчета:
|
||||
Полный `/api/reports` продолжает включать тот же `workforce_policy`, но вкладка
|
||||
`Руководитель` не обязана грузить весь отчет ради одного KPI.
|
||||
|
||||
Минимальный JSON contract этого endpoint закреплен unit-тестом
|
||||
`workforce_policy_explain_is_lightweight_payload`: портал не должен потерять
|
||||
`formula`, `app_details[].matched_rule`, `app_details[].weight`,
|
||||
`policy_audit`, `employee_details[].formula` и `employee_details[].reason`, а
|
||||
легкий endpoint не должен случайно начать отдавать тяжелые поля полного отчета.
|
||||
|
||||
После изменения runtime-файла нужно перезапустить портал:
|
||||
|
||||
```bash
|
||||
|
||||
@@ -42,8 +42,23 @@ baseline и раздела `Phase 8: Post-MVP Enhancements`.
|
||||
- вкладки `Руководитель` и `Отчеты` показывают экран `Почему такой индекс?`
|
||||
с ролью, формулой, плановым временем, app time, weighted time и top
|
||||
приложениями с весом/правилом/вкладом;
|
||||
- Markdown export отчета включает тот же explainability-блок:
|
||||
`Почему такой индекс?`, top приложений, audit `default_weight` и drill-down
|
||||
по сотрудникам;
|
||||
- вкладка `Отчеты` имеет действие `Печать / PDF`; печатный CSS оставляет
|
||||
отчетные секции и скрывает навигацию;
|
||||
- formula hint закреплен прямо в UI:
|
||||
`index = weighted_seconds / planned_seconds × 100`;
|
||||
- policy audit показывает приложения, попавшие под `default_weight`, потому что
|
||||
это основной источник ошибок классификации ролей;
|
||||
- employee drill-down показывает персональную причину индекса:
|
||||
`active / plan => index`; per-user app-weight breakdown пока невозможен,
|
||||
потому что текущий worktime payload отдает приложения только на уровне
|
||||
портфеля;
|
||||
- вкладка `Руководитель` получает этот блок через легкий endpoint
|
||||
`/api/workforce/policy/explain`, без загрузки полного `/api/reports`;
|
||||
- contract легкого endpoint защищен unit-тестом
|
||||
`workforce_policy_explain_is_lightweight_payload`;
|
||||
- отчет использует Worktime management snapshot и показывает сравнение
|
||||
подразделений/ответственных за текущий день;
|
||||
- JSON отчета содержит `workforce.department_comparison`,
|
||||
|
||||
Reference in New Issue
Block a user