diff --git a/adk-rust/crates/detmir-portal/src/main.rs b/adk-rust/crates/detmir-portal/src/main.rs index 41ba314..2b5691f 100644 --- a/adk-rust/crates/detmir-portal/src/main.rs +++ b/adk-rust/crates/detmir-portal/src/main.rs @@ -415,6 +415,7 @@ struct WeightedActivity { struct ReportWorkforceSummary { departments_count: usize, owners_count: usize, + insights_count: usize, trend_status: String, } @@ -936,6 +937,7 @@ fn build_reports( 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 insight_items = workforce_insight_items(snapshot); let weighted = load_workforce_policy(workforce_policy_path) .ok() .flatten() @@ -972,6 +974,7 @@ fn build_reports( let workforce_summary = ReportWorkforceSummary { departments_count: department_items.len(), owners_count: owner_items.len(), + insights_count: insight_items.len(), trend_status: trend_status(&trend), }; let markdown = render_report_markdown( @@ -1022,6 +1025,10 @@ fn build_reports( report_item("Отчет", "OK", "готов к передаче руководителю") ] }, + { + "title": "Выводы Workforce", + "items": insight_items.clone() + }, { "title": "Подразделения сегодня", "items": department_items.clone() @@ -1049,6 +1056,7 @@ fn build_reports( "department_comparison": department_items, "owner_comparison": owner_items, "trend": trend, + "insights": insight_items, "trend_status": trend_status(&trend), "history_note": "Месячный тренд требует накопленной daily history; текущий слой показывает validated daily management snapshot." }, @@ -1107,6 +1115,51 @@ fn workforce_trend_json(snapshot: &Snapshot) -> Value { .unwrap_or_else(|| json!([])) } +fn workforce_insight_items(snapshot: &Snapshot) -> Vec { + snapshot + .worktime_management + .payload + .as_ref() + .and_then(|payload| payload.get("trend_insights")) + .and_then(Value::as_array) + .map(|items| { + items + .iter() + .map(|item| { + let title = item + .get("title") + .and_then(Value::as_str) + .unwrap_or("Вывод Workforce"); + let subject = item + .get("subject") + .and_then(Value::as_str) + .unwrap_or("Workforce"); + let severity = item + .get("severity") + .and_then(Value::as_str) + .unwrap_or("INFO"); + let evidence = item.get("evidence").and_then(Value::as_str).unwrap_or(""); + let recommendation = item + .get("recommendation") + .and_then(Value::as_str) + .unwrap_or(""); + report_item( + &format!("{title}: {subject}"), + severity, + format!("{evidence} {recommendation}").trim().to_string(), + ) + }) + .collect() + }) + .unwrap_or_else(|| { + vec![report_item( + "История еще накапливается", + "INFO", + "Worktime API пока не вернул trend_insights.", + )] + }) +} + fn trend_status(trend: &Value) -> String { let points = trend.as_array().map(Vec::len).unwrap_or(0); if points >= 20 { @@ -1337,6 +1390,10 @@ fn render_report_markdown( "- Подразделения/ответственные: {}/{}\n", workforce.departments_count, workforce.owners_count )); + text.push_str(&format!( + "- Автоматические выводы Workforce: {}\n", + workforce.insights_count + )); text.push_str(&format!("- Статус тренда: {}\n", workforce.trend_status)); text.push_str(&format!( "- DLP технические сигналы: ok={}, warn={}, fail={}\n", @@ -3075,6 +3132,17 @@ mod tests { "report_date": "2026-06-03", "portfolio_coverage_pct": 50.0 } + ], + "trend_insights": [ + { + "code": "history_insufficient", + "severity": "INFO", + "scope": "portfolio", + "subject": "Workforce", + "title": "История еще накапливается", + "evidence": "Накоплено 1 daily point.", + "recommendation": "Использовать текущий дневной срез." + } ] })), }, @@ -3137,6 +3205,7 @@ mod tests { assert!(report["kpis"].as_array().unwrap().len() >= 6); assert_eq!(report["workforce_policy"]["configured"], false); assert_eq!(report["workforce"]["trend_status"], "daily_only"); + assert_eq!(report["workforce"]["insights"].as_array().unwrap().len(), 1); assert_eq!( report["workforce"]["department_comparison"] .as_array() diff --git a/adk-rust/crates/worktime-api/src/main.rs b/adk-rust/crates/worktime-api/src/main.rs index 5f42f2e..4073215 100644 --- a/adk-rust/crates/worktime-api/src/main.rs +++ b/adk-rust/crates/worktime-api/src/main.rs @@ -44,6 +44,10 @@ struct Config { workday_end_hour: u32, manager_target_coverage_pct: i64, manager_low_coverage_pct: i64, + manager_overload_coverage_pct: i64, + manager_trend_min_points: usize, + manager_trend_delta_pct: f64, + manager_off_hours_threshold_seconds: i64, manager_late_start_grace_minutes: i64, manager_early_finish_grace_minutes: i64, manager_critical_source_max_age_seconds: i64, @@ -167,6 +171,8 @@ fn load_config() -> Config { env_i64("AW_WORKTIME_MANAGER_TARGET_COVERAGE_PCT", 75).clamp(1, 100); let manager_low_coverage_pct = env_i64("AW_WORKTIME_MANAGER_LOW_COVERAGE_PCT", 35).clamp(1, 100); + let manager_overload_coverage_pct = + env_i64("AW_WORKTIME_MANAGER_OVERLOAD_COVERAGE_PCT", 115).clamp(100, 300); let report_cache_ttl_seconds = env_i64("AW_WORKTIME_REPORT_CACHE_TTL_SECONDS", 60).max(0); let report_stale_ttl_seconds = env_i64("AW_WORKTIME_REPORT_STALE_TTL_SECONDS", 900).max(report_cache_ttl_seconds); @@ -182,6 +188,15 @@ fn load_config() -> Config { workday_end_hour: env_i64("AW_WORKTIME_MANAGER_END_HOUR", 18).clamp(0, 23) as u32, manager_target_coverage_pct, manager_low_coverage_pct, + manager_overload_coverage_pct, + manager_trend_min_points: env_usize("AW_WORKTIME_MANAGER_TREND_MIN_POINTS", 3).clamp(2, 31), + manager_trend_delta_pct: env_f64("AW_WORKTIME_MANAGER_TREND_DELTA_PCT", 10.0) + .clamp(1.0, 100.0), + manager_off_hours_threshold_seconds: env_i64( + "AW_WORKTIME_MANAGER_OFF_HOURS_THRESHOLD_SECONDS", + 1800, + ) + .max(60), manager_late_start_grace_minutes: env_i64( "AW_WORKTIME_MANAGER_LATE_START_GRACE_MINUTES", 60, @@ -1476,6 +1491,8 @@ impl App { "portfolio_coverage_pct": summary["portfolio_coverage_pct"], "actions_count": summary["actions_count"], "critical_actions_count": summary["critical_actions_count"], + "department_rollups": compact_rollup_points(&department_rollups), + "owner_rollups": compact_rollup_points(&owner_rollups), }); let history_saved = self.save_management_history_point( host, @@ -1493,6 +1510,15 @@ impl App { ¤t_trend_point, ); let trend_points = trend.len(); + let insights = build_management_insights( + &self.config, + &summary, + &roster, + &owner_rollups, + &department_rollups, + &trend, + report_date, + ); json!({ "generated_at_utc": to_iso_utc(Utc::now()), "host": host, @@ -1513,6 +1539,7 @@ impl App { "sources": sources, "trend": trend, "trend_scope": "portfolio", + "trend_insights": insights, "history": { "enabled": true, "saved_current_point": history_saved, @@ -2048,6 +2075,378 @@ fn inc(map: &mut Map, key: &str, amount: i64) { map.insert(key.into(), json!(old + amount)); } +fn compact_rollup_points(rollups: &[Value]) -> Vec { + rollups + .iter() + .map(|item| { + json!({ + "name": item.get("name").cloned().unwrap_or(json!("")), + "users_count": item.get("users_count").cloned().unwrap_or(json!(0)), + "active_users": item.get("active_users").cloned().unwrap_or(json!(0)), + "inactive_users": item.get("inactive_users").cloned().unwrap_or(json!(0)), + "below_target_users": item.get("below_target_users").cloned().unwrap_or(json!(0)), + "workday_total_active_seconds": item.get("workday_total_active_seconds").cloned().unwrap_or(json!(0)), + "workday_total_active_hhmm": item.get("workday_total_active_hhmm").cloned().unwrap_or(json!("00:00")), + "portfolio_coverage_pct": item.get("portfolio_coverage_pct").cloned().unwrap_or(json!(0.0)), + "actions_count": item.get("actions_count").cloned().unwrap_or(json!(0)), + "critical_actions_count": item.get("critical_actions_count").cloned().unwrap_or(json!(0)), + "high_actions_count": item.get("high_actions_count").cloned().unwrap_or(json!(0)), + }) + }) + .collect() +} + +fn build_management_insights( + config: &Config, + summary: &Value, + rows: &[Value], + owner_rollups: &[Value], + department_rollups: &[Value], + trend: &[Value], + report_date: NaiveDate, +) -> Vec { + let mut insights = Vec::new(); + let min_points = config.manager_trend_min_points; + let portfolio_values = trend_values(trend, "portfolio_coverage_pct"); + if portfolio_values.len() < min_points { + insights.push(insight( + "history_insufficient", + "INFO", + "portfolio", + "Workforce", + "История еще накапливается", + &format!( + "Накоплено {} daily point(s), для устойчивой интерпретации нужно минимум {}.", + portfolio_values.len(), + min_points + ), + "Использовать текущий дневной срез; недельные и месячные выводы включатся после накопления истории.", + )); + } else if monotonic_delta( + &portfolio_values, + min_points, + config.manager_trend_delta_pct, + true, + ) { + insights.push(insight( + "portfolio_activity_growing", + "OK", + "portfolio", + "Workforce", + "Активность растет несколько дней подряд", + &format!( + "Portfolio coverage вырос за последние {} точек минимум на {:.0} п.п.", + min_points, config.manager_trend_delta_pct + ), + "Проверить, связано ли улучшение с реальным ростом загрузки или с изменением состава сотрудников.", + )); + } else if monotonic_delta( + &portfolio_values, + min_points, + config.manager_trend_delta_pct, + false, + ) { + insights.push(insight( + "portfolio_activity_falling", + "WARN", + "portfolio", + "Workforce", + "Активность падает несколько дней подряд", + &format!( + "Portfolio coverage снизился за последние {} точек минимум на {:.0} п.п.", + min_points, config.manager_trend_delta_pct + ), + "Разобрать причины снижения: простой, отсутствие задач, сбой сбора или изменение рабочего процесса.", + )); + } + + add_rollup_current_insights( + &mut insights, + department_rollups, + "department", + config.manager_low_coverage_pct as f64, + config.manager_overload_coverage_pct as f64, + ); + add_rollup_current_insights( + &mut insights, + owner_rollups, + "owner", + config.manager_low_coverage_pct as f64, + config.manager_overload_coverage_pct as f64, + ); + add_rollup_history_insights( + &mut insights, + trend, + "department_rollups", + "department", + config.manager_low_coverage_pct as f64, + config.manager_trend_min_points, + config.manager_trend_delta_pct, + ); + add_rollup_history_insights( + &mut insights, + trend, + "owner_rollups", + "owner", + config.manager_low_coverage_pct as f64, + config.manager_trend_min_points, + config.manager_trend_delta_pct, + ); + add_off_hours_insights(&mut insights, config, summary, rows, report_date); + + insights.sort_by_key(|item| { + ( + insight_rank(item.get("severity").and_then(Value::as_str).unwrap_or("")), + item.get("scope") + .and_then(Value::as_str) + .unwrap_or("") + .to_string(), + item.get("subject") + .and_then(Value::as_str) + .unwrap_or("") + .to_string(), + ) + }); + insights.truncate(12); + insights +} + +fn trend_values(trend: &[Value], field: &str) -> Vec { + trend + .iter() + .filter_map(|item| item.get(field).and_then(Value::as_f64)) + .collect() +} + +fn monotonic_delta(values: &[f64], min_points: usize, min_delta: f64, increasing: bool) -> bool { + if values.len() < min_points { + return false; + } + let tail = &values[values.len() - min_points..]; + let monotonic = tail.windows(2).all(|pair| { + if increasing { + pair[1] >= pair[0] + } else { + pair[1] <= pair[0] + } + }); + monotonic + && if increasing { + tail[tail.len() - 1] - tail[0] >= min_delta + } else { + tail[0] - tail[tail.len() - 1] >= min_delta + } +} + +fn add_rollup_current_insights( + insights: &mut Vec, + rollups: &[Value], + scope: &str, + low_pct: f64, + overload_pct: f64, +) { + for item in rollups { + let users = item.get("users_count").and_then(Value::as_i64).unwrap_or(0); + if users <= 0 { + continue; + } + let subject = 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 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"); + if coverage < low_pct { + insights.push(insight( + "current_underload", + "WARN", + scope, + subject, + "Текущая недогрузка", + &format!("Coverage {coverage:.0}% ниже порога {low_pct:.0}%; active {active}/{users}; workday total {hhmm}."), + "Проверить план задач, отсутствие входа в систему и возможный сбой сбора данных.", + )); + } else if coverage >= overload_pct { + insights.push(insight( + "current_overload", + "WARN", + scope, + subject, + "Возможная перегрузка", + &format!("Coverage {coverage:.0}% выше порога {overload_pct:.0}%; active {active}/{users}; workday total {hhmm}."), + "Проверить переработку, распределение задач и риск выгорания.", + )); + } + } +} + +fn add_rollup_history_insights( + insights: &mut Vec, + trend: &[Value], + trend_key: &str, + scope: &str, + low_pct: f64, + min_points: usize, + min_delta: f64, +) { + let mut series: BTreeMap> = BTreeMap::new(); + for point in trend { + let Some(items) = point.get(trend_key).and_then(Value::as_array) else { + continue; + }; + for item in items { + let Some(name) = item.get("name").and_then(Value::as_str) else { + continue; + }; + let Some(coverage) = item.get("portfolio_coverage_pct").and_then(Value::as_f64) else { + continue; + }; + series.entry(name.to_string()).or_default().push(coverage); + } + } + for (subject, values) in series { + if values.len() < min_points { + continue; + } + let tail = &values[values.len() - min_points..]; + if tail.iter().all(|value| *value < low_pct) { + insights.push(insight( + "stable_underload", + "WARN", + scope, + &subject, + "Стабильная недогрузка", + &format!("Coverage ниже {low_pct:.0}% последние {} daily points.", tail.len()), + "Проверить устойчивую нехватку задач, неверный профиль роли или постоянную проблему сбора.", + )); + } + let avg_previous = if tail.len() > 1 { + tail[..tail.len() - 1].iter().sum::() / (tail.len() - 1) as f64 + } else { + tail[0] + }; + let current = tail[tail.len() - 1]; + if avg_previous - current >= min_delta { + insights.push(insight( + "drop_vs_norm", + "WARN", + scope, + &subject, + "Резкая просадка относительно своей нормы", + &format!( + "Текущий coverage {current:.0}% ниже среднего последних точек на {:.0} п.п.", + avg_previous - current + ), + "Проверить изменение задач, простой, отпуск/отсутствие и корректность сбора.", + )); + } + } +} + +fn add_off_hours_insights( + insights: &mut Vec, + config: &Config, + summary: &Value, + rows: &[Value], + report_date: NaiveDate, +) { + let calendar_total = summary + .get("calendar_total_active_seconds") + .and_then(Value::as_i64) + .unwrap_or(0); + let workday_total = summary + .get("workday_total_active_seconds") + .and_then(Value::as_i64) + .unwrap_or(0); + let off_hours = (calendar_total - workday_total).max(0); + let is_weekend = matches!( + report_date.weekday(), + chrono::Weekday::Sat | chrono::Weekday::Sun + ); + if is_weekend && calendar_total >= config.manager_off_hours_threshold_seconds { + insights.push(insight( + "weekend_work", + "WARN", + "portfolio", + "Workforce", + "Есть работа в выходной день", + &format!( + "Зафиксировано {} активности в календарный выходной.", + hhmm(calendar_total) + ), + "Проверить, была ли работа согласована и не маскирует ли она аврал или сбой графика.", + )); + } else if off_hours >= config.manager_off_hours_threshold_seconds { + let users = rows + .iter() + .filter(|row| { + let calendar = row + .get("calendar_active_seconds") + .and_then(Value::as_i64) + .unwrap_or(0); + let workday = row + .get("workday_active_seconds") + .and_then(Value::as_i64) + .unwrap_or(0); + calendar > workday + }) + .count(); + insights.push(insight( + "off_hours_work", + "WARN", + "portfolio", + "Workforce", + "Есть активность вне рабочего окна", + &format!( + "Вне рабочего окна зафиксировано {}; сотрудников: {}.", + hhmm(off_hours), + users + ), + "Проверить ночную/раннюю работу, регламент смен и корректность рабочего окна.", + )); + } +} + +fn insight( + code: &str, + severity: &str, + scope: &str, + subject: &str, + title: &str, + evidence: &str, + recommendation: &str, +) -> Value { + json!({ + "code": code, + "severity": severity, + "scope": scope, + "subject": subject, + "title": title, + "evidence": evidence, + "recommendation": recommendation, + }) +} + +fn insight_rank(severity: &str) -> i32 { + match severity { + "FAIL" => 0, + "WARN" => 1, + "OK" => 2, + "INFO" => 3, + _ => 4, + } +} + fn build_executive_summary(summary: &Value, actions: &[Value], sources: &[Value]) -> Value { let critical = actions .iter() @@ -2799,6 +3198,33 @@ mod tests { ); } + #[test] + fn management_insights_detect_falling_portfolio_trend() { + let cfg = test_config(); + let trend = vec![ + json!({"report_date": "2026-05-12", "portfolio_coverage_pct": 80.0}), + json!({"report_date": "2026-05-13", "portfolio_coverage_pct": 65.0}), + json!({"report_date": "2026-05-14", "portfolio_coverage_pct": 50.0}), + ]; + let insights = build_management_insights( + &cfg, + &json!({ + "calendar_total_active_seconds": 0, + "workday_total_active_seconds": 0 + }), + &[], + &[], + &[], + &trend, + NaiveDate::from_ymd_opt(2026, 5, 14).unwrap(), + ); + assert!( + insights + .iter() + .any(|item| item["code"] == "portfolio_activity_falling") + ); + } + #[test] fn true_active_apps_require_evidence() { let cfg = test_config(); diff --git a/ansible/deploy_aw_server.yml b/ansible/deploy_aw_server.yml index f6b9ccb..f22aea4 100644 --- a/ansible/deploy_aw_server.yml +++ b/ansible/deploy_aw_server.yml @@ -662,6 +662,10 @@ AW_WORKTIME_MANAGEMENT_HISTORY_DIR={{ aw_worktime_management_history_dir | default(aw_server_data_dir ~ '/worktime-management-history') }} AW_WORKTIME_MANAGEMENT_HISTORY_DAYS={{ aw_worktime_management_history_days | default(31) }} AW_WORKTIME_MANAGEMENT_HISTORY_RETENTION_DAYS={{ aw_worktime_management_history_retention_days | default(120) }} + AW_WORKTIME_MANAGER_OVERLOAD_COVERAGE_PCT={{ aw_worktime_manager_overload_coverage_pct | default(115) }} + AW_WORKTIME_MANAGER_TREND_MIN_POINTS={{ aw_worktime_manager_trend_min_points | default(3) }} + AW_WORKTIME_MANAGER_TREND_DELTA_PCT={{ aw_worktime_manager_trend_delta_pct | default(10) }} + AW_WORKTIME_MANAGER_OFF_HOURS_THRESHOLD_SECONDS={{ aw_worktime_manager_off_hours_threshold_seconds | default(1800) }} AW_DLP_INFLUX_ENABLED={{ 'true' if (aw_dlp_influx_enabled | default(false) | bool) else 'false' }} AW_DLP_INFLUX_URL={{ aw_dlp_influx_url | default('') }} AW_DLP_INFLUX_ORG={{ aw_dlp_influx_org | default('proxmox') }} diff --git a/ansible/group_vars/all.example.yml b/ansible/group_vars/all.example.yml index bd90d0a..b9a137d 100644 --- a/ansible/group_vars/all.example.yml +++ b/ansible/group_vars/all.example.yml @@ -22,6 +22,10 @@ aw_worktime_influx_token: "" aw_worktime_management_history_dir: "{{ aw_server_data_dir }}/worktime-management-history" aw_worktime_management_history_days: 31 aw_worktime_management_history_retention_days: 120 +aw_worktime_manager_overload_coverage_pct: 115 +aw_worktime_manager_trend_min_points: 3 +aw_worktime_manager_trend_delta_pct: 10 +aw_worktime_manager_off_hours_threshold_seconds: 1800 aw_dlp_influx_enabled: false aw_dlp_influx_url: "http://:8086" aw_dlp_influx_org: "proxmox" diff --git a/ansible/group_vars/all.yml b/ansible/group_vars/all.yml index 72c6294..0eabe66 100644 --- a/ansible/group_vars/all.yml +++ b/ansible/group_vars/all.yml @@ -22,6 +22,10 @@ aw_worktime_influx_token: "{{ lookup('env', 'AW_WORKTIME_INFLUX_TOKEN') }}" aw_worktime_management_history_dir: "{{ aw_server_data_dir }}/worktime-management-history" aw_worktime_management_history_days: 31 aw_worktime_management_history_retention_days: 120 +aw_worktime_manager_overload_coverage_pct: 115 +aw_worktime_manager_trend_min_points: 3 +aw_worktime_manager_trend_delta_pct: 10 +aw_worktime_manager_off_hours_threshold_seconds: 1800 aw_dlp_influx_enabled: true aw_dlp_influx_url: "http://192.0.2.10:8086" aw_dlp_influx_org: "proxmox" diff --git a/docs/DETMIR_COMMERCIAL_MODULES_RU.md b/docs/DETMIR_COMMERCIAL_MODULES_RU.md index 5f54e7c..0d362a3 100644 --- a/docs/DETMIR_COMMERCIAL_MODULES_RU.md +++ b/docs/DETMIR_COMMERCIAL_MODULES_RU.md @@ -85,6 +85,22 @@ Worktime API сохраняет daily history как агрегированны - `AW_WORKTIME_MANAGEMENT_HISTORY_DIR`; - `AW_WORKTIME_MANAGEMENT_HISTORY_DAYS`; - `AW_WORKTIME_MANAGEMENT_HISTORY_RETENTION_DAYS`. +- `AW_WORKTIME_MANAGER_OVERLOAD_COVERAGE_PCT`; +- `AW_WORKTIME_MANAGER_TREND_MIN_POINTS`; +- `AW_WORKTIME_MANAGER_TREND_DELTA_PCT`; +- `AW_WORKTIME_MANAGER_OFF_HOURS_THRESHOLD_SECONDS`. + +Слой автоматической интерпретации возвращает `trend_insights`: + +- текущая недогрузка/перегрузка подразделения или ответственного; +- рост/падение portfolio activity несколько daily points подряд; +- стабильная недогрузка после накопления минимальной истории; +- резкая просадка подразделения/ответственного относительно своей нормы; +- активность вне рабочего окна; +- работа в выходной день. + +Если истории мало, вывод должен быть честным: `history_insufficient`, без +продажи дневного среза как месячной аналитики. Правило честной интерпретации: diff --git a/docs/DETMIR_PORTAL_GUI_PLAN_RU.md b/docs/DETMIR_PORTAL_GUI_PLAN_RU.md index f23a98d..0b689ea 100644 --- a/docs/DETMIR_PORTAL_GUI_PLAN_RU.md +++ b/docs/DETMIR_PORTAL_GUI_PLAN_RU.md @@ -41,6 +41,10 @@ baseline и раздела `Phase 8: Post-MVP Enhancements`. `workforce.owner_comparison`, `workforce.trend` и `workforce.trend_status`; - daily history для `workforce.trend` накапливается в `worktime-api` как агрегированные trend-points; портал только отображает готовый массив; +- `worktime-api` возвращает `trend_insights`, а портал показывает их в секции + `Выводы Workforce`; +- insights включают текущую недогрузку/перегрузку, рост/падение тренда, + просадку относительно нормы, работу вне рабочего окна и выходные; - `trend_status=daily_only` означает, что месячный тренд еще нельзя интерпретировать как полноценный отчет; для `monthly_ready` нужна накопленная daily history;