From eb6036f2c98862986203b4c9dca57ff44751fd21 Mon Sep 17 00:00:00 2001 From: Mikhail Trubnikov Date: Wed, 6 May 2026 17:00:08 +1000 Subject: [PATCH] =?UTF-8?q?feat(core):=20WorkloadService=20-=20=D1=80?= =?UTF-8?q?=D0=B5=D0=B0=D0=BB=D0=B8=D0=B7=D0=B0=D1=86=D0=B8=D1=8F=20=D0=BB?= =?UTF-8?q?=D0=BE=D0=B3=D0=B8=D0=BA=D0=B8=20=D1=84=D0=BE=D1=80=D0=BC=D0=B8?= =?UTF-8?q?=D1=80=D0=BE=D0=B2=D0=B0=D0=BD=D0=B8=D1=8F=20=D0=BE=D1=82=D1=87?= =?UTF-8?q?=D0=B5=D1=82=D0=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- PARR.Core/DependencyInjection.cs | 1 + .../Implementations/WorkloadCacheService.cs | 124 +++++++++-- .../Implementations/WorkloadService.cs | 197 ++++++++++++++++++ .../Workload/Interfaces/IWorkloadService.cs | 9 + PARR.Domain/DTOs/Workload/WorkloadReport.cs | 93 +++++++++ PARR.Domain/Enums/WorkloadReportType.cs | 31 +++ 6 files changed, 443 insertions(+), 12 deletions(-) create mode 100644 PARR.Core/Services/Workload/Implementations/WorkloadService.cs create mode 100644 PARR.Core/Services/Workload/Interfaces/IWorkloadService.cs create mode 100644 PARR.Domain/DTOs/Workload/WorkloadReport.cs create mode 100644 PARR.Domain/Enums/WorkloadReportType.cs diff --git a/PARR.Core/DependencyInjection.cs b/PARR.Core/DependencyInjection.cs index edf05db9..89a31080 100644 --- a/PARR.Core/DependencyInjection.cs +++ b/PARR.Core/DependencyInjection.cs @@ -82,6 +82,7 @@ namespace PARR.Core #region Workload services.AddScoped(); + services.AddScoped(); #endregion diff --git a/PARR.Core/Services/Workload/Implementations/WorkloadCacheService.cs b/PARR.Core/Services/Workload/Implementations/WorkloadCacheService.cs index 48a479ac..44d8f582 100644 --- a/PARR.Core/Services/Workload/Implementations/WorkloadCacheService.cs +++ b/PARR.Core/Services/Workload/Implementations/WorkloadCacheService.cs @@ -1,11 +1,14 @@ using Microsoft.EntityFrameworkCore; using Microsoft.Extensions.Logging; +using NodaTime; using PARR.Core.Common.Interfaces; using PARR.Core.Repositories.Interfaces; using PARR.Core.Services.Shortcodes; using PARR.Core.Services.Workload.Models; using PARR.Domain.Cache.Models; +using PARR.Domain.DTOs.Workload; using PARR.Domain.Entities; +using PARR.Domain.Enums; namespace PARR.Core.Services.Workload.Implementations { @@ -33,7 +36,7 @@ namespace PARR.Core.Services.Workload.Implementations } - //----------- Настройки, вынести ----------- + //todo:----------- Настройки, вынести ----------- /// /// Ключ КЭШ шаблонов /// @@ -56,7 +59,6 @@ namespace PARR.Core.Services.Workload.Implementations /// /// Ключ КЭШ, отчет о загруженности, одна ЗО - /// Формировать ключ, основа + Hash ЗО /// private readonly string[] cacheWorkloadResponseAreaItemKey = { "workload", "response area", "item" }; @@ -67,7 +69,6 @@ namespace PARR.Core.Services.Workload.Implementations /// /// Ключ КЭШ, отчет о загруженности, одна РГ - /// Формировать ключ, основа + Hash РГ /// private readonly string[] cacheWorkloadWorkGroupItemKey = { "workload", "work group", "item" }; @@ -161,19 +162,22 @@ namespace PARR.Core.Services.Workload.Implementations /// /// Получить кэш РГ и ЗО всех шаблонов, получить недостающие поля из БД. /// Сравнит кол-во записей в БД и в КЭШ, если не хватает до 1000 шаблонов, доформирует кэш, - /// если больше 1000, вернет пустой список, так как считается кэш неактуальным. + /// если больше 1000, вернет null, так как считается кэш неактуальным. /// - /// - public async Task> GetTemplateReportDataAsync() + /// Дата начала + /// Продолжительность дней + /// Смещение часового пояса + /// null - если нет кэша или он не актуальный. Иначе, список шаблонов. + public async Task?> GetTemplateReportDataAsync(DateOnly dateStart, int durationDays, TimeSpan offset) { - logger.LogDebug("Получаю данные для формирования отчета."); + logger.LogDebug("Получаю данные для формирования отчета. DateStart {DateStart}, DurationDays {DurationDays}, Offset {Offset}", dateStart, durationDays, offset); // Получим кэш целиком var cacheData = await redisCacheService.GetAllHashFieldsAsync(redisCacheService.GetKey(cacheTemplateKey)); if (cacheData.Count == 0) { logger.LogDebug("Записей в кэш 0. Конец."); - return new Dictionary(); + return null; } // Сравним кол-во записей в БД и в КЭШ, вдруг там разница больше допустимой @@ -182,16 +186,24 @@ namespace PARR.Core.Services.Workload.Implementations { logger.LogInformation("Разница в количестве шаблонов в БД ({TemplateCount}) и в КЭШ ({CacheCount}) больше допустимой {MaxCacheDiff}. Считаем КЭШ неактуальным. Требуется повторное формирование КЭШа.", templatesCount, cacheData.Count, maxCacheDiff); - return new Dictionary(); + return null; } - // Получаем из БД только нужные данные для отчета + // Получаем из БД только нужные данные для отчета, попадающие в интервал + var dtStart = new DateTimeOffset(dateStart.Year, dateStart.Month, dateStart.Day, 0, 0, 0, offset); + var dtEnd = dtStart.AddDays(durationDays); + + logger.LogDebug("Фильтруем шаблоны в БД по интервалу: Start={DtStart:yyyy-MM-dd HH:mm:ss zzz}, End={DtEnd:yyyy-MM-dd HH:mm:ss zzz}", dtStart, dtEnd); + var templates = await templateRepository.Get() .AsNoTracking() + .Where(t => dtStart <= t.NextRun && t.NextRun < dtEnd) .Select(t => new { Id = t.Id, IsActiveTemplate = t.IsActiveTemplate, IsActiveSchedule = t.IsActiveSchedule, Name = t.Name, NextRun = t.NextRun }) .ToListAsync(); - var reportData = new Dictionary(); + logger.LogDebug("Загружено шаблонов из БД {Count}", templates.Count); + + var reportData = new List(); foreach (var template in templates) { @@ -220,7 +232,7 @@ namespace PARR.Core.Services.Workload.Implementations template.IsActiveTemplate, template.IsActiveSchedule, DateTimeOffset.UtcNow); - reportData.Add(template.Id, data); + reportData.Add(data); } logger.LogDebug("Сформировано итоговых данных для отчета, строк: {Count}", reportData.Count); @@ -279,5 +291,93 @@ namespace PARR.Core.Services.Workload.Implementations } + /// + /// Получить отчет Workload из кэша + /// + /// + /// + /// + /// + /// Наименование ЗО или РГ или null + /// + public async Task GetWorkloadReportAsync(WorkloadReportType reportType, DateOnly dateStart, int durationDays, TimeSpan offset, string? filterParam) + { + var key = GetReportCacheKey(reportType, dateStart, durationDays, offset, filterParam); + + return await redisCacheService.GetCachedDataAsync(key); + } + + + /// + /// Записать отчет Workload в кэш + /// + /// + /// + /// + /// + /// + /// Наименование ЗО или РГ или null + /// + public async Task SetWorkloadReport(WorkloadReport report, WorkloadReportType reportType, DateOnly dateStart, int durationDays, TimeSpan offset, string? filterParam) + { + var key = GetReportCacheKey(reportType, dateStart, durationDays, offset, filterParam); + await redisCacheService.SetCachedDataAsync(key, report, cacheWorkloadTtl); + } + + + /// + /// Формирует ключ для отчета Workload + /// + /// + /// + /// + /// + /// + /// + private string GetReportCacheKey(WorkloadReportType reportType, DateOnly dateStart, int durationDays, TimeSpan offset, string? filterParam = null) + { + // формируе ключ, вида: основа + дата начала + продолжительность + смещение + // Если есть параметр (filterParam), добавляется параметр в виде Hash + + var keyList = new List(); + + switch (reportType) + { + case WorkloadReportType.ResponseAreaAll: + keyList.AddRange(cacheWorkloadResponseAreaAllKey); + break; + case WorkloadReportType.ResponseAreaItem: + keyList.AddRange(cacheWorkloadResponseAreaItemKey); + break; + case WorkloadReportType.WorkGroupAll: + keyList.AddRange(cacheWorkloadWorkGroupAllKey); + break; + case WorkloadReportType.WorkGroupItem: + keyList.AddRange(cacheWorkloadWorkGroupItemKey); + break; + default: + logger.LogError("Недопустимое значение тип отчета '{ReportType}'", reportType); + throw new ArgumentException("Недопустимое значение тип отчета", nameof(reportType)); + } + + keyList.AddRange(new string[] { dateStart.ToString(), durationDays.ToString(), offset.ToString() }); + + var keyStr = string.Empty; + + if (filterParam == null) + keyStr = redisCacheService.GetKey(keyList.ToArray()); + else + keyStr = redisCacheService.GetKey(keyList.ToArray(), new string[] { filterParam }); + + logger.LogDebug("Сформирован ключ для кэш '{Key}'", keyStr); + + return keyStr; + } + + + + + + } } diff --git a/PARR.Core/Services/Workload/Implementations/WorkloadService.cs b/PARR.Core/Services/Workload/Implementations/WorkloadService.cs new file mode 100644 index 00000000..61068db1 --- /dev/null +++ b/PARR.Core/Services/Workload/Implementations/WorkloadService.cs @@ -0,0 +1,197 @@ +using Microsoft.Extensions.Logging; +using PARR.Core.Services.NextRunServices; +using PARR.Core.Services.Workload.Interfaces; +using PARR.Core.Services.Workload.Models; +using PARR.Domain.DTOs.Workload; +using PARR.Domain.Enums; + +namespace PARR.Core.Services.Workload.Implementations +{ + internal class WorkloadService : IWorkloadService + { + private readonly ILogger logger; + private readonly WorkloadCacheService workloadCacheService; + private readonly INextRunService nextRunService; + + public WorkloadService( + ILogger logger, + WorkloadCacheService workloadCacheService, + INextRunService nextRunService + ) + { + this.logger = logger; + this.workloadCacheService = workloadCacheService; + this.nextRunService = nextRunService; + } + + + public async Task GetWorkloadReport(WorkloadReportType reportType, DateOnly dateStart, int durationDays, TimeSpan offset, string? filterParam) + { + logger.LogDebug("Начинаю формировать отчет о загруженности по параметрам: reportType '{ReportType}', dateStart {DateStart}, durationDays {DurationDays}, offset {Offset}", + reportType.ToString(), dateStart, durationDays, offset); + + if ((reportType == WorkloadReportType.WorkGroupItem || reportType == WorkloadReportType.ResponseAreaItem) && string.IsNullOrEmpty(filterParam)) + { + logger.LogError("Для типа отчета {ReportType} не передан параметр.", reportType.ToString()); + throw new ArgumentNullException(nameof(filterParam), $"Для отчета типа {reportType.ToString()} параметр является обязательным."); + } + + + // Если есть отчет в КЭШ, вернуть + var cacheReport = await workloadCacheService.GetWorkloadReportAsync(reportType, dateStart, durationDays, offset, filterParam); + if (cacheReport != null) + { + logger.LogDebug("Вернул отчет из кэша."); + return cacheReport; + } + + // Если нет основного кэша, вернуть null, пусть пользователь нажимает кнопку сформировать отчет + var templates = await workloadCacheService.GetTemplateReportDataAsync(dateStart, durationDays, offset); + if (templates == null) + { + logger.LogDebug("Не могу рассчитать отчет Workload, так как нет основного кэш или он не актуальный."); + return null; + } + + // Есть основной кэш, построить отчет и сохранить в кэш отчета + logger.LogDebug("Основной кэш есть, начинаю строить отчет."); + + // Список дней + var reportDays = await GetReportDays(dateStart, durationDays); + + var displayName = string.Empty; + + // Формируем отчет в зависимости от типа + var statistics = new List(); + switch (reportType) + { + case WorkloadReportType.ResponseAreaAll: + displayName = "По зонам ответственности"; + foreach (var group in templates.GroupBy(t => t.ResponseArea)) + statistics.Add(BuildStatisticItem(group.Key, group, reportDays, offset)); + break; + case WorkloadReportType.ResponseAreaItem: + displayName = "По зоне ответственности"; + var filteredRA = templates.Where(t => t.ResponseArea.Equals(filterParam, StringComparison.OrdinalIgnoreCase)).ToList(); + statistics.Add(BuildStatisticItem(filterParam!, filteredRA, reportDays, offset)); + break; + case WorkloadReportType.WorkGroupAll: + displayName = "По рабочим группам"; + foreach (var group in templates.GroupBy(t => t.WorkGroup)) + statistics.Add(BuildStatisticItem(group.Key, group, reportDays, offset)); + break; + case WorkloadReportType.WorkGroupItem: + displayName = "По рабочей группе"; + var filteredWG = templates.Where(t => t.WorkGroup.Equals(filterParam, StringComparison.OrdinalIgnoreCase)).ToList(); + statistics.Add(BuildStatisticItem(filterParam!, filteredWG, reportDays, offset)); + break; + } + + var report = new WorkloadReport + { + Type = reportType, + DisplayName = displayName, + SubfilterName = filterParam, + ReportCacheCreatedAt = DateTimeOffset.UtcNow, + TemplateCacheCreatedAt = templates.FirstOrDefault()?.CacheDate ?? DateTimeOffset.MinValue, + Days = reportDays, + Statistics = statistics + }; + + // Сохраним в кэш + await workloadCacheService.SetWorkloadReport(report, reportType, dateStart, durationDays, offset, filterParam); + logger.LogDebug("Отчет построен и сохранен в кэш"); + + return report; + } + + /// + /// Получить список дней для отчета, с отметкой выходной/рабочий + /// + /// + /// + /// + private async Task> GetReportDays(DateOnly dateStart, int durationDays) + { + var dateEnd = dateStart.AddDays(durationDays); + var days = await nextRunService.GetWorkDaysAsync(dateStart, dateEnd, false); + var weekends = await nextRunService.GetWeekendsAsync(dateStart, dateEnd); + + return days.Select(t => new WorkloadDayItem { Date = t, IsWeekend = weekends.Contains(t) }) + .OrderBy(t => t.Date) + .ToList(); + } + + /// + /// Составить статистику по одному объекту за период + /// + /// + /// + /// + /// + private List GetDailyMetrics(List templates, List reportDays, TimeSpan offset) + { + var result = new List(); + + foreach (var dayItem in reportDays) + { + var dateStart = new DateTimeOffset(dayItem.Date.Year, dayItem.Date.Month, dayItem.Date.Day, 0, 0, 0, offset); + var dateEnd = dateStart.AddDays(1); + + var filteredItems = templates.Where(t => dateStart <= t.NextRun && t.NextRun < dateEnd); + + var dailyItem = new WorkloadDailyItem + { + Date = dayItem.Date, + IsWeekend = dayItem.IsWeekend, + Total = filteredItems.Count(), + Activated = filteredItems.Count(t => t.IsActiveTemplate && t.IsActiveSchedule), + Deactivated = filteredItems.Count(t => !t.IsActiveTemplate || !t.IsActiveSchedule) + }; + + result.Add(dailyItem); + } + + return result.OrderBy(t => t.Date).ToList(); + } + + /// + /// Заполнить Summary + /// + /// + /// + private WorkloadSummary GetSummary(List dailyMetrics, int allDays) + { + // Локальная функция для расчета одной метрики + static WorkloadSummaryItem Calc(List dailyMetrics, int allDays, Func selector) => new() + { + Count = dailyMetrics.Sum(selector), + Average = allDays > 0 ? Math.Round((double)dailyMetrics.Sum(selector) / allDays, 2) : 0 + }; + + return new WorkloadSummary + { + Total = Calc(dailyMetrics, allDays, t => t.Total), + Activated = Calc(dailyMetrics, allDays, t => t.Activated), + Deactivated = Calc(dailyMetrics, allDays, t => t.Deactivated) + }; + } + + /// + /// Формирует статистику по одной группе + /// + /// + /// + /// + /// + /// + private WorkloadStatisticItem BuildStatisticItem(string title, IEnumerable groupTemplates, List reportDays, TimeSpan offset) + { + var dailyMetrics = GetDailyMetrics(groupTemplates.ToList(), reportDays, offset); + var summary = GetSummary(dailyMetrics, reportDays.Count); + + return new WorkloadStatisticItem { DailyMetrics = dailyMetrics, Summary = summary, Title = title }; + } + + } +} diff --git a/PARR.Core/Services/Workload/Interfaces/IWorkloadService.cs b/PARR.Core/Services/Workload/Interfaces/IWorkloadService.cs new file mode 100644 index 00000000..f94c2fa9 --- /dev/null +++ b/PARR.Core/Services/Workload/Interfaces/IWorkloadService.cs @@ -0,0 +1,9 @@ +namespace PARR.Core.Services.Workload.Interfaces +{ + /// + /// Сервис формирования отчетности о загруженности + /// + public interface IWorkloadService + { + } +} diff --git a/PARR.Domain/DTOs/Workload/WorkloadReport.cs b/PARR.Domain/DTOs/Workload/WorkloadReport.cs new file mode 100644 index 00000000..8e8c4578 --- /dev/null +++ b/PARR.Domain/DTOs/Workload/WorkloadReport.cs @@ -0,0 +1,93 @@ +using PARR.Domain.Enums; + +namespace PARR.Domain.DTOs.Workload +{ + /// + /// Отчет о загруженности + /// + public record WorkloadReport + { + /// + /// Тип отчета (все ЗО, все РГ, одна ЗО, одна РГ) + /// + public WorkloadReportType Type { get; init; } + + /// + /// Название отчета (По всем ЗО, по одной ЗО) + /// + public required string DisplayName { get; init; } + + /// + /// Подзаголовок, наименование конкретной ЗО/РГ + /// + public string? SubfilterName { get; init; } + + /// + /// Дата формирования отчета + /// + public DateTimeOffset ReportCacheCreatedAt { get; init; } + + /// + /// Дата формирования кэша ЗО и РГ + /// + public DateTimeOffset TemplateCacheCreatedAt { get; init; } + + /// + /// Список дней + /// + public required List Days { get; init; } + + /// + /// Нагруженность + /// + public required List Statistics { get; init; } + } + + + public record WorkloadSummary + { + public required WorkloadSummaryItem Total { get; init; } + + public required WorkloadSummaryItem Activated { get; init; } + + public required WorkloadSummaryItem Deactivated { get; init; } + } + + public record WorkloadSummaryItem + { + public int Count { get; init; } + + public double Average { get; init; } + } + + public record WorkloadDayItem + { + public DateOnly Date { get; init; } + + public bool IsWeekend { get; init; } + } + + public record WorkloadStatisticItem + { + public required string Title { get; init; } + + public required WorkloadSummary Summary { get; init; } + + public required List DailyMetrics { get; init; } + } + + public record WorkloadDailyItem + { + public DateOnly Date { get; init; } + + public bool IsWeekend { get; init; } + + public int Total { get; init; } + + public int Activated { get; init; } + + public int Deactivated { get; init; } + } + + +} diff --git a/PARR.Domain/Enums/WorkloadReportType.cs b/PARR.Domain/Enums/WorkloadReportType.cs new file mode 100644 index 00000000..88666121 --- /dev/null +++ b/PARR.Domain/Enums/WorkloadReportType.cs @@ -0,0 +1,31 @@ +using System.Text.Json.Serialization; + +namespace PARR.Domain.Enums +{ + /// + /// Тип отчета о загруженности + /// + [JsonConverter(typeof(JsonStringEnumConverter))] + public enum WorkloadReportType + { + /// + /// Отчет по всем ЗО + /// + ResponseAreaAll = 0, + + /// + /// Отчет по одной ЗО + /// + ResponseAreaItem = 1, + + /// + /// Отчет по всем РГ + /// + WorkGroupAll = 2, + + /// + /// Отчет по одной РГ + /// + WorkGroupItem = 3 + } +}