feat(core): WorkloadService - реализация логики формирования отчета

This commit is contained in:
Mikhail Trubnikov
2026-05-06 17:00:08 +10:00
parent 7d42a80556
commit eb6036f2c9
6 changed files with 443 additions and 12 deletions

View File

@@ -82,6 +82,7 @@ namespace PARR.Core
#region Workload #region Workload
services.AddScoped<WorkloadCacheService>(); services.AddScoped<WorkloadCacheService>();
services.AddScoped<IWorkloadService, WorkloadService>();
#endregion #endregion

View File

@@ -1,11 +1,14 @@
using Microsoft.EntityFrameworkCore; using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Logging; using Microsoft.Extensions.Logging;
using NodaTime;
using PARR.Core.Common.Interfaces; using PARR.Core.Common.Interfaces;
using PARR.Core.Repositories.Interfaces; using PARR.Core.Repositories.Interfaces;
using PARR.Core.Services.Shortcodes; using PARR.Core.Services.Shortcodes;
using PARR.Core.Services.Workload.Models; using PARR.Core.Services.Workload.Models;
using PARR.Domain.Cache.Models; using PARR.Domain.Cache.Models;
using PARR.Domain.DTOs.Workload;
using PARR.Domain.Entities; using PARR.Domain.Entities;
using PARR.Domain.Enums;
namespace PARR.Core.Services.Workload.Implementations namespace PARR.Core.Services.Workload.Implementations
{ {
@@ -33,7 +36,7 @@ namespace PARR.Core.Services.Workload.Implementations
} }
//----------- Настройки, вынести ----------- //todo:----------- Настройки, вынести -----------
/// <summary> /// <summary>
/// Ключ КЭШ шаблонов /// Ключ КЭШ шаблонов
/// </summary> /// </summary>
@@ -56,7 +59,6 @@ namespace PARR.Core.Services.Workload.Implementations
/// <summary> /// <summary>
/// Ключ КЭШ, отчет о загруженности, одна ЗО /// Ключ КЭШ, отчет о загруженности, одна ЗО
/// Формировать ключ, основа + Hash ЗО
/// </summary> /// </summary>
private readonly string[] cacheWorkloadResponseAreaItemKey = { "workload", "response area", "item" }; private readonly string[] cacheWorkloadResponseAreaItemKey = { "workload", "response area", "item" };
@@ -67,7 +69,6 @@ namespace PARR.Core.Services.Workload.Implementations
/// <summary> /// <summary>
/// Ключ КЭШ, отчет о загруженности, одна РГ /// Ключ КЭШ, отчет о загруженности, одна РГ
/// Формировать ключ, основа + Hash РГ
/// </summary> /// </summary>
private readonly string[] cacheWorkloadWorkGroupItemKey = { "workload", "work group", "item" }; private readonly string[] cacheWorkloadWorkGroupItemKey = { "workload", "work group", "item" };
@@ -161,19 +162,22 @@ namespace PARR.Core.Services.Workload.Implementations
/// <summary> /// <summary>
/// Получить кэш РГ и ЗО всех шаблонов, получить недостающие поля из БД. /// Получить кэш РГ и ЗО всех шаблонов, получить недостающие поля из БД.
/// Сравнит кол-во записей в БД и в КЭШ, если не хватает до 1000 шаблонов, доформирует кэш, /// Сравнит кол-во записей в БД и в КЭШ, если не хватает до 1000 шаблонов, доформирует кэш,
/// если больше 1000, вернет пустой список, так как считается кэш неактуальным. /// если больше 1000, вернет null, так как считается кэш неактуальным.
/// </summary> /// </summary>
/// <returns></returns> /// <param name="dateStart">Дата начала</param>
public async Task<Dictionary<Guid, TemplateReportData>> GetTemplateReportDataAsync() /// <param name="durationDays">Продолжительность дней</param>
/// <param name="offset">Смещение часового пояса</param>
/// <returns>null - если нет кэша или он не актуальный. Иначе, список шаблонов.</returns>
public async Task<List<TemplateReportData>?> 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<TemplateCache>(redisCacheService.GetKey(cacheTemplateKey)); var cacheData = await redisCacheService.GetAllHashFieldsAsync<TemplateCache>(redisCacheService.GetKey(cacheTemplateKey));
if (cacheData.Count == 0) if (cacheData.Count == 0)
{ {
logger.LogDebug("Записей в кэш 0. Конец."); logger.LogDebug("Записей в кэш 0. Конец.");
return new Dictionary<Guid, TemplateReportData>(); return null;
} }
// Сравним кол-во записей в БД и в КЭШ, вдруг там разница больше допустимой // Сравним кол-во записей в БД и в КЭШ, вдруг там разница больше допустимой
@@ -182,16 +186,24 @@ namespace PARR.Core.Services.Workload.Implementations
{ {
logger.LogInformation("Разница в количестве шаблонов в БД ({TemplateCount}) и в КЭШ ({CacheCount}) больше допустимой {MaxCacheDiff}. Считаем КЭШ неактуальным. Требуется повторное формирование КЭШа.", logger.LogInformation("Разница в количестве шаблонов в БД ({TemplateCount}) и в КЭШ ({CacheCount}) больше допустимой {MaxCacheDiff}. Считаем КЭШ неактуальным. Требуется повторное формирование КЭШа.",
templatesCount, cacheData.Count, maxCacheDiff); templatesCount, cacheData.Count, maxCacheDiff);
return new Dictionary<Guid, TemplateReportData>(); 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() var templates = await templateRepository.Get()
.AsNoTracking() .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 }) .Select(t => new { Id = t.Id, IsActiveTemplate = t.IsActiveTemplate, IsActiveSchedule = t.IsActiveSchedule, Name = t.Name, NextRun = t.NextRun })
.ToListAsync(); .ToListAsync();
var reportData = new Dictionary<Guid, TemplateReportData>(); logger.LogDebug("Загружено шаблонов из БД {Count}", templates.Count);
var reportData = new List<TemplateReportData>();
foreach (var template in templates) foreach (var template in templates)
{ {
@@ -220,7 +232,7 @@ namespace PARR.Core.Services.Workload.Implementations
template.IsActiveTemplate, template.IsActiveTemplate,
template.IsActiveSchedule, template.IsActiveSchedule,
DateTimeOffset.UtcNow); DateTimeOffset.UtcNow);
reportData.Add(template.Id, data); reportData.Add(data);
} }
logger.LogDebug("Сформировано итоговых данных для отчета, строк: {Count}", reportData.Count); logger.LogDebug("Сформировано итоговых данных для отчета, строк: {Count}", reportData.Count);
@@ -279,5 +291,93 @@ namespace PARR.Core.Services.Workload.Implementations
} }
/// <summary>
/// Получить отчет Workload из кэша
/// </summary>
/// <param name="reportType"></param>
/// <param name="dateStart"></param>
/// <param name="durationDays"></param>
/// <param name="offset"></param>
/// <param name="filterParam">Наименование ЗО или РГ или null</param>
/// <returns></returns>
public async Task<WorkloadReport?> GetWorkloadReportAsync(WorkloadReportType reportType, DateOnly dateStart, int durationDays, TimeSpan offset, string? filterParam)
{
var key = GetReportCacheKey(reportType, dateStart, durationDays, offset, filterParam);
return await redisCacheService.GetCachedDataAsync<WorkloadReport>(key);
}
/// <summary>
/// Записать отчет Workload в кэш
/// </summary>
/// <param name="report"></param>
/// <param name="reportType"></param>
/// <param name="dateStart"></param>
/// <param name="durationDays"></param>
/// <param name="offset"></param>
/// <param name="filterParam">Наименование ЗО или РГ или null</param>
/// <returns></returns>
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);
}
/// <summary>
/// Формирует ключ для отчета Workload
/// </summary>
/// <param name="reportType"></param>
/// <param name="dateStart"></param>
/// <param name="durationDays"></param>
/// <param name="offset"></param>
/// <param name="filterParam"></param>
/// <returns></returns>
private string GetReportCacheKey(WorkloadReportType reportType, DateOnly dateStart, int durationDays, TimeSpan offset, string? filterParam = null)
{
// формируе ключ, вида: основа + дата начала + продолжительность + смещение
// Если есть параметр (filterParam), добавляется параметр в виде Hash
var keyList = new List<string>();
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;
}
} }
} }

View File

@@ -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<WorkloadService> logger;
private readonly WorkloadCacheService workloadCacheService;
private readonly INextRunService nextRunService;
public WorkloadService(
ILogger<WorkloadService> logger,
WorkloadCacheService workloadCacheService,
INextRunService nextRunService
)
{
this.logger = logger;
this.workloadCacheService = workloadCacheService;
this.nextRunService = nextRunService;
}
public async Task<WorkloadReport?> 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<WorkloadStatisticItem>();
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;
}
/// <summary>
/// Получить список дней для отчета, с отметкой выходной/рабочий
/// </summary>
/// <param name="dateStart"></param>
/// <param name="durationDays"></param>
/// <returns></returns>
private async Task<List<WorkloadDayItem>> 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();
}
/// <summary>
/// Составить статистику по одному объекту за период
/// </summary>
/// <param name="templates"></param>
/// <param name="reportDays"></param>
/// <param name="offset"></param>
/// <returns></returns>
private List<WorkloadDailyItem> GetDailyMetrics(List<TemplateReportData> templates, List<WorkloadDayItem> reportDays, TimeSpan offset)
{
var result = new List<WorkloadDailyItem>();
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>
/// Заполнить Summary
/// </summary>
/// <param name="dailyMetrics"></param>
/// <returns></returns>
private WorkloadSummary GetSummary(List<WorkloadDailyItem> dailyMetrics, int allDays)
{
// Локальная функция для расчета одной метрики
static WorkloadSummaryItem Calc(List<WorkloadDailyItem> dailyMetrics, int allDays, Func<WorkloadDailyItem, int> 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)
};
}
/// <summary>
/// Формирует статистику по одной группе
/// </summary>
/// <param name="title"></param>
/// <param name="groupTemplates"></param>
/// <param name="reportDays"></param>
/// <param name="offset"></param>
/// <returns></returns>
private WorkloadStatisticItem BuildStatisticItem(string title, IEnumerable<TemplateReportData> groupTemplates, List<WorkloadDayItem> 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 };
}
}
}

View File

@@ -0,0 +1,9 @@
namespace PARR.Core.Services.Workload.Interfaces
{
/// <summary>
/// Сервис формирования отчетности о загруженности
/// </summary>
public interface IWorkloadService
{
}
}

View File

@@ -0,0 +1,93 @@
using PARR.Domain.Enums;
namespace PARR.Domain.DTOs.Workload
{
/// <summary>
/// Отчет о загруженности
/// </summary>
public record WorkloadReport
{
/// <summary>
/// Тип отчета (все ЗО, все РГ, одна ЗО, одна РГ)
/// </summary>
public WorkloadReportType Type { get; init; }
/// <summary>
/// Название отчета (По всем ЗО, по одной ЗО)
/// </summary>
public required string DisplayName { get; init; }
/// <summary>
/// Подзаголовок, наименование конкретной ЗО/РГ
/// </summary>
public string? SubfilterName { get; init; }
/// <summary>
/// Дата формирования отчета
/// </summary>
public DateTimeOffset ReportCacheCreatedAt { get; init; }
/// <summary>
/// Дата формирования кэша ЗО и РГ
/// </summary>
public DateTimeOffset TemplateCacheCreatedAt { get; init; }
/// <summary>
/// Список дней
/// </summary>
public required List<WorkloadDayItem> Days { get; init; }
/// <summary>
/// Нагруженность
/// </summary>
public required List<WorkloadStatisticItem> 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<WorkloadDailyItem> 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; }
}
}

View File

@@ -0,0 +1,31 @@
using System.Text.Json.Serialization;
namespace PARR.Domain.Enums
{
/// <summary>
/// Тип отчета о загруженности
/// </summary>
[JsonConverter(typeof(JsonStringEnumConverter))]
public enum WorkloadReportType
{
/// <summary>
/// Отчет по всем ЗО
/// </summary>
ResponseAreaAll = 0,
/// <summary>
/// Отчет по одной ЗО
/// </summary>
ResponseAreaItem = 1,
/// <summary>
/// Отчет по всем РГ
/// </summary>
WorkGroupAll = 2,
/// <summary>
/// Отчет по одной РГ
/// </summary>
WorkGroupItem = 3
}
}