This commit is contained in:
Mikhail Kuznetsov
2026-06-09 11:42:30 +10:00
18 changed files with 514 additions and 199 deletions

View File

@@ -311,6 +311,11 @@
public const string ReportInfo = BaseStat + "/workload/report-info";
}
public static class StatWorkloadTemplate
{
public const string GetWorkloadTemplateReport = BaseStat + "/workload/templates/{reportType}/{filter}/{state}/{date}";
}
#endregion
#region Наряды

View File

@@ -0,0 +1,15 @@
namespace PARR.API.Contracts.V1.Requests.Queries
{
public record StatWorkloadTemplateReqportQuery
{
/// <summary>
/// Дополнительный фильтр для работ - ЗО
/// </summary>
public string? ResponseArea { get; init; }
/// <summary>
/// Дополнительный фильтр для работ - РГ
/// </summary>
public string? WorkGroup { get; init; }
}
}

View File

@@ -1,4 +1,4 @@
using PARR.Domain.Enums;
using PARR.Domain.Enums.Workload;
namespace PARR.API.Contracts.V1.Responses.Statistics
{
@@ -72,6 +72,8 @@ namespace PARR.API.Contracts.V1.Responses.Statistics
{
public required string Title { get; init; }
public Guid? ObjId { get; init; }
public required WorkloadSummaryResponse Summary { get; init; }
public required List<WorkloadDailyItemResponse> DailyMetrics { get; init; }

View File

@@ -0,0 +1,4 @@
namespace PARR.API.Contracts.V1.Responses.Statistics
{
public record StatWorkloadTemplateReportResponse(Guid TemplateId, string Name, DateTimeOffset NextRun, bool IsActiveTemplate, bool IsActiveSchedule);
}

View File

@@ -1,142 +0,0 @@
using AutoMapper;
using Microsoft.AspNetCore.Mvc;
using Microsoft.EntityFrameworkCore;
using PARR.API.Contracts.V1;
using PARR.API.Contracts.V1.Requests;
using PARR.API.Contracts.V1.Requests.Queries;
using PARR.API.Contracts.V1.Responses;
using PARR.API.Contracts.V1.Responses.Base;
using PARR.API.Contracts.V1.Responses.Statistics;
using PARR.API.Controllers.V1.Base;
using PARR.API.Services.Interfaces;
using PARR.Core.Repositories.Interfaces;
namespace PARR.API.Controllers.V1.Statistics
{
/// <summary>
/// Статистика загрузки регламентными работами по рабочим группам
/// </summary>
public class StatWorkGroupWorkLoadController : BaseApiController
{
private readonly ITemplateRepository templateService;
//private readonly IWorkLoadService workLoadService;
//private readonly IWorkGroupService workGroupService;
private readonly IMapper mapper;
private readonly ILogger<StatWorkGroupWorkLoadController> logger;
public StatWorkGroupWorkLoadController(
ITemplateRepository templateService,
//IWorkLoadService workLoadService,
//IWorkGroupService workGroupService,
IMapper mapper,
ILogger<StatWorkGroupWorkLoadController> logger
)
{
this.templateService = templateService;
//this.workLoadService = workLoadService;
//this.workGroupService = workGroupService;
this.mapper = mapper;
this.logger = logger;
}
/*
/// <summary>
/// Статистика загрузки регламентными работами рабочих групп по ЗО
/// </summary>
/// <param name="responseAreaCode"></param>
/// <param name="requestQuery"></param>
/// <returns></returns>
[HttpGet(ApiRoutes.StatWorkGroupWorkLoad.Get)]
public async Task<IActionResult> Get([FromRoute] int responseAreaCode, [FromQuery] WorkloadBaseQuery requestQuery)
{
var response = await GetStatisticsAsync(responseAreaCode, requestQuery);
return Ok(new Response<List<StatWorkGroupWorkLoadResponse>>(response, true));
}
/// <summary>
/// Статистика загрузки РГ по списку РГ
/// </summary>
/// <returns></returns>
[HttpPost(ApiRoutes.StatWorkGroupWorkLoad.GetByWorkGroups)]
public async Task<IActionResult> GetByWorkGroups([FromBody] StatWorkGroupsWorkLoadRequest request)
{
var response = await GetStatisticsAsync(null, request.Filter, request.WorkGroups);
return Ok(new Response<List<StatWorkGroupWorkLoadResponse>>(response, true));
}
private async Task<List<StatWorkGroupWorkLoadResponse>> GetStatisticsAsync(int? responseAreaCode, WorkloadBaseQuery requestQuery, List<Guid>? workGroups = null)
{
//защита от дурака, чтоб не выгрузить всю БД
if (responseAreaCode == null && (workGroups == null || workGroups.Count() == 0))
{
logger.LogWarning($"Пытались выполнить запрос получения статситики с нулевыми параметрами {nameof(responseAreaCode)}, {nameof(workGroups)}. Controller {nameof(StatWorkGroupWorkLoadController)}");
return new List<StatWorkGroupWorkLoadResponse>();
}
var daysList = workLoadService.GetDatesForPeriod(requestQuery);
IQueryable<DAL.Models.Template> query = templateService.Get()
.Include(t => t.Host);
query = workLoadService.FilterTemplatesQuery(query, requestQuery);
if (responseAreaCode.HasValue)
query = query.Where(t => t.Host!.WorkGroup!.ResponseAreaCode == responseAreaCode.Value);
if (workGroups != null && workGroups.Count() > 0)
query = query.Where(t => workGroups.Any(x => x == t.Host!.WorkGroupId.Value));
// var groupedQuery = query.GroupBy(t => new { t.NextRun.DateTime.Date, t.Host!.WorkGroupId })
var groupedQuery = query.GroupBy(t => new { t.NextRun.AddHours(requestQuery.TimeZoneOffsetHours).DateTime.Date, t.Host!.WorkGroupId })
.Select(t => new
{
WorkGroupId = t.Key.WorkGroupId,
Date = t.Key.Date,
TemplateCount = t.Count()
});
var queryResult = await groupedQuery.ToListAsync();
IQueryable<DAL.Models.WorkGroup> workGroupsQuery = workGroupService.Get()
.Include(t => t.ResponseArea);
if (responseAreaCode.HasValue)
workGroupsQuery = workGroupsQuery.Where(t => t.ResponseAreaCode == responseAreaCode);
if (workGroups != null && workGroups.Count() > 0)
workGroupsQuery = workGroupsQuery.Where(t => workGroups.Any(x => x == t.Id));
var allWorkGroups = await workGroupsQuery.ToListAsync();
var response = new List<StatWorkGroupWorkLoadResponse>();
allWorkGroups.ForEach(wg =>
{
var workLoads = daysList.Select(t => new StatWorkLoadDataResponse
{
Date = t,
TemplateCount = queryResult.FirstOrDefault(x => x.WorkGroupId == wg.Id && DateOnly.FromDateTime(x.Date) == t)?.TemplateCount ?? 0
}).OrderBy(t => t.Date).ToList();
response.Add(new StatWorkGroupWorkLoadResponse
{
WorkGroup = mapper.Map<WorkGroupResponse>(wg),
WorkLoad = workLoads
});
});
response = response.OrderBy(t => t.WorkGroup.Name).ToList();
return response;
}
*/
}
}

View File

@@ -14,8 +14,8 @@ using PARR.Core.Services.Workload.Interfaces;
using PARR.Domain.Common.Roles;
using PARR.Domain.Entities.Base.History;
using PARR.Domain.Enums;
using PARR.Domain.Enums.Workload;
using PARR.Domain.Exceptions;
using static PARR.API.Contracts.V1.ApiRoutes;
namespace PARR.API.Controllers.V1.Statistics
{
@@ -57,7 +57,7 @@ namespace PARR.API.Controllers.V1.Statistics
[HttpGet(ApiRoutes.Workload.WorkGroup)]
public async Task<IActionResult> GetWorkGroupReport([FromQuery] TimeZoneOffsetClient offset, [FromQuery] WorkloadBaseQuery request)
{
return await GetReportAsync(WorkloadReportType.WorkGroupAll, request.DurationDays, offset.TimeZoneOffset, null);
return await GetReportAsync(WorkloadReportType.WorkGroupAll, request.DurationDays, offset.TimeZoneOffset, null, null);
}
@@ -69,12 +69,12 @@ namespace PARR.API.Controllers.V1.Statistics
/// <param name="request"></param>
/// <returns></returns>
[HttpGet(ApiRoutes.Workload.WorkGroupJob)]
public async Task<IActionResult> GetWorkGroupJobReport([FromRoute] string workGroup, [FromQuery] TimeZoneOffsetClient offset, [FromQuery] WorkloadBaseQuery request)
public async Task<IActionResult> GetWorkGroupJobReport([FromRoute] string workGroup, [FromQuery] string? responseArea, [FromQuery] TimeZoneOffsetClient offset, [FromQuery] WorkloadBaseQuery request)
{
if (string.IsNullOrWhiteSpace(workGroup))
throw new AppValidationException("Не указано значение рабочей группы");
return await GetReportAsync(WorkloadReportType.WorkGroupJob, request.DurationDays, offset.TimeZoneOffset, workGroup);
return await GetReportAsync(WorkloadReportType.WorkGroupJob, request.DurationDays, offset.TimeZoneOffset, responseArea, workGroup);
}
@@ -85,7 +85,7 @@ namespace PARR.API.Controllers.V1.Statistics
[HttpGet(ApiRoutes.Workload.ResponseArea)]
public async Task<IActionResult> GetResponseAreaReport([FromQuery] TimeZoneOffsetClient offset, [FromQuery] WorkloadBaseQuery request)
{
return await GetReportAsync(WorkloadReportType.ResponseAreaAll, request.DurationDays, offset.TimeZoneOffset, null);
return await GetReportAsync(WorkloadReportType.ResponseAreaAll, request.DurationDays, offset.TimeZoneOffset, null, null);
}
@@ -102,7 +102,7 @@ namespace PARR.API.Controllers.V1.Statistics
if (string.IsNullOrWhiteSpace(responseArea))
throw new AppValidationException("Не указано значение зоны ответственности");
return await GetReportAsync(WorkloadReportType.ResponseAreaWorkGroups, request.DurationDays, offset.TimeZoneOffset, responseArea);
return await GetReportAsync(WorkloadReportType.ResponseAreaWorkGroups, request.DurationDays, offset.TimeZoneOffset, responseArea, null);
}
@@ -147,13 +147,14 @@ namespace PARR.API.Controllers.V1.Statistics
/// Универсальный метод получения отчетности
/// </summary>
/// <returns></returns>
private async Task<IActionResult> GetReportAsync(WorkloadReportType reportType, int durationDays, TimeSpan offset, string? filter)
//private async Task<IActionResult> GetReportAsync(WorkloadReportType reportType, int durationDays, TimeSpan offset, string? filter)
private async Task<IActionResult> GetReportAsync(WorkloadReportType reportType, int durationDays, TimeSpan offset, string? responseArea, string? workGroup)
{
var utcNow = DateTimeOffset.UtcNow;
var localTime = utcNow.ToOffset(offset);
var dateStart = DateOnly.FromDateTime(localTime.Date);
var report = await workloadService.GetWorkloadReport(reportType, dateStart, durationDays, offset, filter?.Trim());
var report = await workloadService.GetWorkloadReportAsync(reportType, dateStart, durationDays, offset, responseArea?.Trim(), workGroup?.Trim());
if (report == null)
return BadRequest(new Response<string?>(null, false, new List<ErrorModel> { new ErrorModel { Message = "Необходимо сформировать отчетность." } }, "empty"));

View File

@@ -0,0 +1,73 @@
using AutoMapper;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
using PARR.API.Contracts.V1;
using PARR.API.Contracts.V1.Requests.BaseRequests;
using PARR.API.Contracts.V1.Requests.Queries;
using PARR.API.Contracts.V1.Responses.Base;
using PARR.API.Contracts.V1.Responses.Statistics;
using PARR.API.Controllers.V1.Base;
using PARR.Core.Services.Workload.Interfaces;
using PARR.Domain.Common.Roles;
using PARR.Domain.Enums.Workload;
namespace PARR.API.Controllers.V1.Statistics
{
/// <summary>
/// Статситика загруженности - шаблоны
/// </summary>
[Authorize(Roles = ParrRoles.EsppRobot.RoleOrAdmin)]
public class StatWorkloadTemplateController : BaseApiController
{
private readonly IMapper mapper;
private readonly IWorkloadService workloadService;
private readonly ILogger<StatWorkloadTemplateController> logger;
public StatWorkloadTemplateController(
IMapper mapper,
IWorkloadService workloadService,
ILogger<StatWorkloadTemplateController> logger
)
{
this.mapper = mapper;
this.workloadService = workloadService;
this.logger = logger;
}
/// <summary>
/// Отчет о загруженности. Шаблоны
/// </summary>
/// <param name="reportType">Тип отчета (ЗО, РГ, Работа)</param>
/// <param name="filter">Фильтр, значиение в зависимости от типа отчета</param>
/// <param name="state">Все, активированные, деактивированные</param>
/// <param name="date">Дата</param>
/// <param name="offset">Часовой пояс</param>
/// <returns></returns>
/// <exception cref="ArgumentException"></exception>
[HttpGet(ApiRoutes.StatWorkloadTemplate.GetWorkloadTemplateReport)]
public async Task<IActionResult> GetWorkloadTemplateReport(
[FromRoute] WorkloadTemplateReportType reportType,
[FromRoute] string filter,
[FromRoute] WorkloadTemplateReportState state,
[FromRoute] DateOnly date,
[FromQuery] TimeZoneOffsetClient offset,
[FromQuery] StatWorkloadTemplateReqportQuery query
)
{
if (string.IsNullOrWhiteSpace(filter))
{
logger.LogWarning("Не передано значение фильтра");
throw new ArgumentException(nameof(filter), "Не передано значение фильтра.");
}
var report = await workloadService.GetTemplateReportAsync(reportType, filter.Trim(), state, date, offset.TimeZoneOffset, query.ResponseArea?.Trim(), query.WorkGroup?.Trim());
var response = mapper.Map<List<StatWorkloadTemplateReportResponse>>(report);
return Ok(new Response<List<StatWorkloadTemplateReportResponse>>(response, true));
}
}
}

View File

@@ -460,6 +460,12 @@ namespace PARR.API.MappingProfiles
#endregion
#region StatWorkloadTemplate
CreateMap<WorkloadTemplateReport, StatWorkloadTemplateReportResponse>();
#endregion
#region Task

View File

@@ -6,9 +6,10 @@ using PARR.Core.Services.Shortcodes;
using PARR.Core.Services.Workload.Models;
using PARR.Domain.Cache;
using PARR.Domain.Cache.Models;
using PARR.Domain.DTOs.UnitDto;
using PARR.Domain.DTOs.Workload;
using PARR.Domain.Entities;
using PARR.Domain.Enums;
using PARR.Domain.Enums.Workload;
namespace PARR.Core.Services.Workload.Implementations
{
@@ -175,6 +176,47 @@ namespace PARR.Core.Services.Workload.Implementations
await redisCacheService.DeleteKeysByPatternAsync(redisCacheService.GetKey(CacheKeys.Workload.WorkloadWorkGroupItem()) + "*");
}
/// <summary>
/// Получить список шаблонов по ИД из кэш/бд.
/// Если каких то данных не хватает в кэше, дополнить кэш.
/// </summary>
/// <param name="templateIds"></param>
/// <returns></returns>
public async Task<List<TemplateCache>?> GetTemplateReportDataByIdsAsync(IReadOnlySet<Guid> templateIds)
{
if (templateIds == null || templateIds.Count == 0)
return null;
// Формируем ключи
var keys = templateIds.Select(t =>
{
var (hashKey, fieldKey) = (redisCacheService.GetKey(CacheKeys.Workload.TemplateHash()), redisCacheService.GetKey(CacheKeys.Workload.Template(t)));
return (HashKey: hashKey, FieldKey: fieldKey);
}).ToList();
// Получаем из кэш
var cacheData = await redisCacheService.GetHashFieldsAsync<TemplateCache>(keys);
var resultDictionary = cacheData.ToDictionary(t => t.Data.TemplateId, t => t);
if (resultDictionary.Count < templateIds.Count)
{
// Догружаем недостающие данные
// Находим id которых нет в кэше
var missingIds = templateIds.Where(id => !resultDictionary.ContainsKey(id)).ToHashSet();
foreach (var templateId in missingIds)
{
var newCacheItem = await GetTemplatePropsAsync(templateId);
resultDictionary[templateId] = newCacheItem;
}
}
return resultDictionary.Select(t => t.Value).ToList();
}
/// <summary>
/// Получить кэш РГ и ЗО всех шаблонов, получить недостающие поля из БД.
/// Сравнит кол-во записей в БД и в КЭШ, если не хватает до 1000 шаблонов, доформирует кэш,
@@ -196,14 +238,9 @@ namespace PARR.Core.Services.Workload.Implementations
return null;
}
// Сравним кол-во записей в БД и в КЭШ, вдруг там разница больше допустимой
var templatesCount = await templateRepository.Get().AsNoTracking().CountAsync();
if (templatesCount - cacheData.Count > maxCacheDiff)
{
logger.LogInformation("Разница в количестве шаблонов в БД ({TemplateCount}) и в КЭШ ({CacheCount}) больше допустимой {MaxCacheDiff}. Считаем КЭШ неактуальным. Требуется повторное формирование КЭШа.",
templatesCount, cacheData.Count, maxCacheDiff);
// Кэш актуальный?
if (!await IsTemplateReportCacheActualAsync(cacheData.Count))
return null;
}
// Получаем из БД только нужные данные для отчета, попадающие в интервал
var dtStart = new DateTimeOffset(dateStart.Year, dateStart.Month, dateStart.Day, 0, 0, 0, offset);
@@ -251,7 +288,11 @@ namespace PARR.Core.Services.Workload.Implementations
}
else
{
(workGroup, responseArea, jobId) = await GetTemplatePropsAsync(template.Id);
//(workGroup, responseArea, jobId) = await GetTemplatePropsAsync(template.Id);
var newCacheItem = await GetTemplatePropsAsync(template.Id);
workGroup = newCacheItem.Data.WorkGroup;
responseArea = newCacheItem.Data.ResponseArea;
jobId = newCacheItem.Data.JobId;
}
var data = new TemplateReportData(
@@ -272,12 +313,35 @@ namespace PARR.Core.Services.Workload.Implementations
return reportData;
}
/// <summary>
/// Проверка кэша на акутальность.
/// </summary>
/// <returns></returns>
public async Task<bool> IsTemplateReportCacheActualAsync(long? cacheCount = null)
{
long finalCacheCount = cacheCount ?? await redisCacheService.GetHashLengthAsync(redisCacheService.GetKey(CacheKeys.Workload.TemplateHash()));
// Сравним кол-во записей в БД и в КЭШ, вдруг там разница больше допустимой
var templatesCount = await templateRepository.Get().AsNoTracking().CountAsync();
if (Math.Abs(templatesCount - finalCacheCount) > maxCacheDiff)
{
logger.LogInformation("Разница в количестве шаблонов в БД ({TemplateCount}) и в КЭШ ({CacheCount}) больше допустимой {MaxCacheDiff}. Считаем КЭШ неактуальным. Требуется повторное формирование КЭШа.",
templatesCount, cacheCount, maxCacheDiff);
return false;
}
return true;
}
/// <summary>
/// Получить РГ и ЗО из шорткода. Сохранить в КЭШ.
/// </summary>
/// <param name="templateId"></param>
/// <returns></returns>
private async Task<(string WorkGroup, string ResponseArea, Guid jobId)> GetTemplatePropsAsync(Guid templateId)
//private async Task<(string WorkGroup, string ResponseArea, Guid jobId)> GetTemplatePropsAsync(Guid templateId)
private async Task<TemplateCache> GetTemplatePropsAsync(Guid templateId)
{
logger.LogDebug("Дозагружаю недостающие данные из БД для шаблона {TemplateId}", templateId);
@@ -290,7 +354,20 @@ namespace PARR.Core.Services.Workload.Implementations
if (template == null)
{
logger.LogWarning("Шаблон {TemplateId} не найден в БД", templateId);
return ($"РГ {templateId}", $"ЗО {templateId}", templateId);
//return ($"РГ {templateId}", $"ЗО {templateId}", templateId);
return new TemplateCache
{
Timestamp = DateTimeOffset.UtcNow,
Source = "",
Data = new TemplateCacheDto
{
JobGroupId = Guid.Empty,
JobId = Guid.Empty,
TemplateId = templateId,
ResponseArea = $"ЗО {templateId}",
WorkGroup = $"РГ {templateId}"
}
};
}
var (workGroup, responseArea) = await ApplyShortcodesAsync(template);
@@ -311,7 +388,8 @@ namespace PARR.Core.Services.Workload.Implementations
);
logger.LogDebug("Добавлена недостающая запись в кэш. {TemplateId}, '{WorkGroup}', '{ResponseArea}'", templateId, workGroup, responseArea);
return (workGroup, responseArea, template.JobId);
//return (workGroup, responseArea, template.JobId);
return cacheItem;
}
/// <summary>
@@ -337,9 +415,9 @@ namespace PARR.Core.Services.Workload.Implementations
/// <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)
public async Task<WorkloadReport?> GetWorkloadReportAsync(WorkloadReportType reportType, DateOnly dateStart, int durationDays, TimeSpan offset, string? responseArea, string? workGroup)
{
var key = GetReportCacheKey(reportType, dateStart, durationDays, offset, filterParam);
var key = GetReportCacheKey(reportType, dateStart, durationDays, offset, responseArea, workGroup);
return await redisCacheService.GetCachedDataAsync<WorkloadReport>(key, true);
}
@@ -355,9 +433,9 @@ namespace PARR.Core.Services.Workload.Implementations
/// <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)
public async Task SetWorkloadReport(WorkloadReport report, WorkloadReportType reportType, DateOnly dateStart, int durationDays, TimeSpan offset, string? responseArea, string? workGroup)
{
var key = GetReportCacheKey(reportType, dateStart, durationDays, offset, filterParam);
var key = GetReportCacheKey(reportType, dateStart, durationDays, offset, responseArea, workGroup);
await redisCacheService.SetCachedDataAsync(key, report, cacheWorkloadTtl, true);
}
@@ -384,10 +462,10 @@ namespace PARR.Core.Services.Workload.Implementations
/// <param name="offset"></param>
/// <param name="filterParam"></param>
/// <returns></returns>
private string GetReportCacheKey(WorkloadReportType reportType, DateOnly dateStart, int durationDays, TimeSpan offset, string? filterParam = null)
private string GetReportCacheKey(WorkloadReportType reportType, DateOnly dateStart, int durationDays, TimeSpan offset, string? responseArea, string? workGroup)
{
// формируе ключ, вида: основа + дата начала + продолжительность + смещение
// Если есть параметр (filterParam), добавляется параметр в виде Hash
// Если есть параметр (responseArea, workGroup), добавляется параметр в виде Hash
var keyList = new List<string>();
@@ -415,10 +493,10 @@ namespace PARR.Core.Services.Workload.Implementations
var keyStr = string.Empty;
if (filterParam == null)
if (responseArea == null && workGroup == null)
keyStr = redisCacheService.GetKey(keyList.ToArray());
else
keyStr = redisCacheService.GetKey(keyList.ToArray(), new string[] { filterParam });
keyStr = redisCacheService.GetKey(keyList.ToArray(), new string[] { responseArea ?? string.Empty, workGroup ?? string.Empty });
logger.LogDebug("Сформирован ключ для кэш '{Key}'", keyStr);

View File

@@ -1,14 +1,18 @@
using AutoMapper;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Logging;
using PARR.Core.Repositories.Interfaces;
using PARR.Core.Repositories.Interfaces.Job;
using PARR.Core.Repositories.Interfaces.TaskRepositories;
using PARR.Core.Services.NextRunServices;
using PARR.Core.Services.TaskServices.Helpers;
using PARR.Core.Services.Workload.Interfaces;
using PARR.Core.Services.Workload.Models;
using PARR.Domain.Cache.Models;
using PARR.Domain.DTOs.Workload;
using PARR.Domain.Entities;
using PARR.Domain.Enums;
using PARR.Domain.Enums.Workload;
namespace PARR.Core.Services.Workload.Implementations
{
@@ -20,8 +24,7 @@ namespace PARR.Core.Services.Workload.Implementations
private readonly ITaskRepository taskRepository;
private readonly IMapper mapper;
private readonly IJobRepository jobRepository;
//private readonly ITaskManagementService taskManagementService;
private readonly ITemplateRepository templateRepository;
public WorkloadService(
ILogger<WorkloadService> logger,
@@ -29,8 +32,8 @@ namespace PARR.Core.Services.Workload.Implementations
INextRunService nextRunService,
ITaskRepository taskRepository,
IMapper mapper,
IJobRepository jobRepository
//ITaskManagementService taskManagementService
IJobRepository jobRepository,
ITemplateRepository templateRepository
)
{
this.logger = logger;
@@ -39,24 +42,29 @@ namespace PARR.Core.Services.Workload.Implementations
this.taskRepository = taskRepository;
this.mapper = mapper;
this.jobRepository = jobRepository;
//this.taskManagementService = taskManagementService;
this.templateRepository = templateRepository;
}
public async Task<WorkloadReport?> GetWorkloadReport(WorkloadReportType reportType, DateOnly dateStart, int durationDays, TimeSpan offset, string? filterParam)
public async Task<WorkloadReport?> GetWorkloadReportAsync(WorkloadReportType reportType, DateOnly dateStart, int durationDays, TimeSpan offset, string? responseArea, string? workGroup)
{
logger.LogDebug("Начинаю формировать отчет о загруженности по параметрам: reportType '{ReportType}', dateStart {DateStart}, durationDays {DurationDays}, offset {Offset}",
reportType.ToString(), dateStart, durationDays, offset);
if ((reportType == WorkloadReportType.WorkGroupJob || reportType == WorkloadReportType.ResponseAreaWorkGroups) && string.IsNullOrEmpty(filterParam))
if (reportType == WorkloadReportType.WorkGroupJob && string.IsNullOrEmpty(workGroup))
{
logger.LogError("Для типа отчета {ReportType} не передан параметр.", reportType.ToString());
throw new ArgumentNullException(nameof(filterParam), $"Для отчета типа {reportType.ToString()} параметр является обязательным.");
throw new ArgumentNullException(nameof(workGroup), $"Для отчета типа {reportType.ToString()} параметр является обязательным.");
}
if (reportType == WorkloadReportType.ResponseAreaWorkGroups && string.IsNullOrEmpty(responseArea))
{
logger.LogError("Для типа отчета {ReportType} не передан параметр.", reportType.ToString());
throw new ArgumentNullException(nameof(responseArea), $"Для отчета типа {reportType.ToString()} параметр является обязательным.");
}
// Если есть отчет в КЭШ, вернуть
var cacheReport = await workloadCacheService.GetWorkloadReportAsync(reportType, dateStart, durationDays, offset, filterParam);
var cacheReport = await workloadCacheService.GetWorkloadReportAsync(reportType, dateStart, durationDays, offset, responseArea, workGroup);
if (cacheReport != null)
{
logger.LogDebug("Вернул отчет из кэша.");
@@ -86,26 +94,30 @@ namespace PARR.Core.Services.Workload.Implementations
case WorkloadReportType.ResponseAreaAll:
displayName = "По зонам ответственности";
foreach (var group in templates.GroupBy(t => t.ResponseArea, StringComparer.OrdinalIgnoreCase))
statistics.Add(BuildStatisticItem(group.Key, group, reportDays, offset));
statistics.Add(BuildStatisticItem(group.Key, null, group, reportDays, offset));
break;
case WorkloadReportType.ResponseAreaWorkGroups:
displayName = "По зоне ответственности";
// Фильтруем по ЗО
var filteredRA = templates.Where(t => string.Equals(t.ResponseArea, filterParam, StringComparison.OrdinalIgnoreCase)).ToList();
var filteredRA = templates.Where(t => string.Equals(t.ResponseArea, responseArea, StringComparison.OrdinalIgnoreCase)).ToList();
// Группируем работы по РГ
foreach (var group in filteredRA.GroupBy(t => t.WorkGroup, StringComparer.OrdinalIgnoreCase))
statistics.Add(BuildStatisticItem(group.Key, group, reportDays, offset));
//statistics.Add(BuildStatisticItem(filterParam!, filteredRA, reportDays, offset));
statistics.Add(BuildStatisticItem(group.Key, null, group, reportDays, offset));
break;
case WorkloadReportType.WorkGroupAll:
displayName = "По рабочим группам";
foreach (var group in templates.GroupBy(t => t.WorkGroup))
statistics.Add(BuildStatisticItem(group.Key, group, reportDays, offset));
statistics.Add(BuildStatisticItem(group.Key, null, group, reportDays, offset));
break;
case WorkloadReportType.WorkGroupJob:
displayName = "По рабочей группе";
// Фильтруем по РГ
var filteredWG = templates.Where(t => string.Equals(t.WorkGroup, filterParam, StringComparison.OrdinalIgnoreCase)).ToList();
var filteredWG = templates.Where(t => string.Equals(t.WorkGroup, workGroup, StringComparison.OrdinalIgnoreCase)).ToList();
if (!string.IsNullOrWhiteSpace(responseArea))
{
// Дополнительно фильтруем по ЗО
filteredWG = filteredWG.Where(t => string.Equals(t.ResponseArea, responseArea, StringComparison.OrdinalIgnoreCase)).ToList();
}
// Получим все JobName из бд
var jobIds = filteredWG.Select(t => t.JobId).ToHashSet();
@@ -117,16 +129,17 @@ namespace PARR.Core.Services.Workload.Implementations
foreach (var group in filteredWG.GroupBy(t => t.JobId))
{
var jobName = jobs.GetValueOrDefault(group.Key) ?? "Работа";
statistics.Add(BuildStatisticItem(jobName, group, reportDays, offset));
statistics.Add(BuildStatisticItem(jobName, group.Key, group, reportDays, offset));
}
break;
}
var report = new WorkloadReport
{
Type = reportType,
DisplayName = displayName,
SubfilterName = filterParam,
SubfilterName = string.Join(", ", new[] { responseArea, workGroup }),
ReportCacheCreatedAt = DateTimeOffset.UtcNow,
TemplateCacheCreatedAt = templates.FirstOrDefault()?.CacheDate ?? DateTimeOffset.MinValue,
Days = reportDays,
@@ -134,7 +147,7 @@ namespace PARR.Core.Services.Workload.Implementations
};
// Сохраним в кэш
await workloadCacheService.SetWorkloadReport(report, reportType, dateStart, durationDays, offset, filterParam);
await workloadCacheService.SetWorkloadReport(report, reportType, dateStart, durationDays, offset, responseArea, workGroup);
logger.LogDebug("Отчет построен и сохранен в кэш");
return report;
@@ -174,6 +187,153 @@ namespace PARR.Core.Services.Workload.Implementations
}
public async Task<List<WorkloadTemplateReport>> GetTemplateReportAsync(
WorkloadTemplateReportType reportType,
string filter,
WorkloadTemplateReportState state,
DateOnly date,
TimeSpan offset,
string? responseArea,
string? workGroup
)
{
if (string.IsNullOrWhiteSpace(filter))
{
logger.LogWarning("Не передано значение фильтра");
throw new ArgumentException(nameof(filter), "Не передано значение фильтра.");
}
// Template cache вообще есть, он актуальный?
if (!await workloadCacheService.IsTemplateReportCacheActualAsync())
{
logger.LogDebug("Не могу рассчитать отчет Workload, так как нет основного кэш или он не актуальный.");
return new List<WorkloadTemplateReport>();
}
var trimmedFilter = filter.Trim();
// Формируем дату и время начала и конца
var dtStart = new DateTimeOffset(date.Year, date.Month, date.Day, 0, 0, 0, offset);
var dtEnd = dtStart.AddDays(1);
// Конвертируем в UTC для совместимости с PostgreSQL timestamptz
var dtStartUtc = dtStart.ToUniversalTime();
var dtEndUtc = dtEnd.ToUniversalTime();
// Запрос к БД
var query = templateRepository.Get().AsNoTracking()
.Where(t => dtStartUtc <= t.NextRun && t.NextRun < dtEndUtc);
// Фильтруем по состоянию
switch (state)
{
case WorkloadTemplateReportState.All:
break;
case WorkloadTemplateReportState.Activated:
query = query.Where(t => t.IsActiveTemplate && t.IsActiveSchedule);
break;
case WorkloadTemplateReportState.Deactivated:
query = query.Where(t => !t.IsActiveTemplate || !t.IsActiveSchedule);
break;
}
// Если тип - работы, получим из бд сразу нужные работы
if (reportType == WorkloadTemplateReportType.Job)
{
if (!Guid.TryParse(trimmedFilter, out Guid jobId))
throw new ArgumentException("Значение фильтра не является Guid");
query = query.Where(t => t.JobId == jobId);
}
// Загружаем шаблоны
var dbTemplates = await query.OrderBy(t => t.Name)
.Select(t => new WorkloadTemplateReport(t.Id, t.Name, t.NextRun, t.IsActiveTemplate, t.IsActiveSchedule))
.ToListAsync();
if (dbTemplates.Count == 0)
return new List<WorkloadTemplateReport>();
// Если тип - работы, и нет дополнительных фильтров по ЗО и РГ, не полезем в кэш, вернем сразу
if (reportType == WorkloadTemplateReportType.Job && string.IsNullOrWhiteSpace(responseArea) && string.IsNullOrWhiteSpace(workGroup))
{
// Тут кэш не нужен, вернем сразу из бд (нет фильтров по РГ и ЗО, нужен список всех работ на дату)
return FilterWorkloadJobTemplateReport(dbTemplates, null);
}
// Получаем из кэша шаблоны (если их в кэше нет, догружаем в кэш)
var cacheData = await workloadCacheService.GetTemplateReportDataByIdsAsync(dbTemplates.Select(t => t.TemplateId).ToHashSet());
if (cacheData == null)
throw new Exception("Из кэша не получили значения.");
if (dbTemplates.Count != cacheData.Count)
logger.LogWarning("Количество шаблонов в БД {DbCount} не совпадает с количеством шаблонов полученных из кэша {CacheCount}", dbTemplates.Count, cacheData.Count);
// Дополнительные фильтры
var trimmedResponseArea = responseArea?.Trim();
var trimmedWorkGroup = workGroup?.Trim();
// Фильтр по типу
switch (reportType)
{
case WorkloadTemplateReportType.Job:
// Смотрим есть ли дополнительные фильтры
if (!string.IsNullOrEmpty(trimmedResponseArea) || !string.IsNullOrEmpty(trimmedWorkGroup))
{
// Фильтруем из кэша
IEnumerable<TemplateCache> cacheTemplatesJobs = cacheData;
// Фильтруем по ЗО если есть
if (!string.IsNullOrEmpty(trimmedResponseArea))
cacheTemplatesJobs = cacheTemplatesJobs.Where(t => string.Equals(t.Data.ResponseArea, trimmedResponseArea, StringComparison.OrdinalIgnoreCase));
// Дополнительный фильтр по РГ, если есть
if (!string.IsNullOrEmpty(trimmedWorkGroup))
cacheTemplatesJobs = cacheTemplatesJobs.Where(t => string.Equals(t.Data.WorkGroup, trimmedWorkGroup, StringComparison.OrdinalIgnoreCase));
var filteredTemplateJobIds = cacheTemplatesJobs.Select(t => t.Data.TemplateId).ToHashSet();
return FilterWorkloadJobTemplateReport(dbTemplates, filteredTemplateJobIds);
}
return FilterWorkloadJobTemplateReport(dbTemplates, null);
case WorkloadTemplateReportType.WorkGroup:
var cacheTemplatesWg = cacheData.Where(t => string.Equals(t.Data.WorkGroup, trimmedFilter, StringComparison.OrdinalIgnoreCase));
// Дополнительный фильтр по ЗО, если есть
if (!string.IsNullOrEmpty(trimmedResponseArea))
cacheTemplatesWg = cacheTemplatesWg.Where(t => string.Equals(t.Data.ResponseArea, trimmedResponseArea, StringComparison.OrdinalIgnoreCase));
var filteredTemplateWgIds = cacheTemplatesWg.Select(t => t.Data.TemplateId).ToHashSet();
return dbTemplates.Where(t => filteredTemplateWgIds.Contains(t.TemplateId)).ToList();
case WorkloadTemplateReportType.ResponseArea:
var cacheTemplatesRa = cacheData.Where(t => string.Equals(t.Data.ResponseArea, trimmedFilter, StringComparison.OrdinalIgnoreCase));
// Дополнительный фильтр по РГ если есть
if (!string.IsNullOrEmpty(trimmedWorkGroup))
cacheTemplatesRa = cacheTemplatesRa.Where(t => string.Equals(t.Data.WorkGroup, trimmedWorkGroup, StringComparison.OrdinalIgnoreCase));
var filteredTemplateRaIds = cacheTemplatesRa.Select(t => t.Data.TemplateId).ToHashSet();
return dbTemplates.Where(t => filteredTemplateRaIds.Contains(t.TemplateId)).ToList();
}
logger.LogWarning("Не обработанный тип отчета {ReportType}", reportType);
return new List<WorkloadTemplateReport>();
}
private List<WorkloadTemplateReport> FilterWorkloadJobTemplateReport(List<WorkloadTemplateReport> dbTemplates, HashSet<Guid>? filteredCacheTemplates)
{
if (filteredCacheTemplates == null)
return dbTemplates;
// Отсекаем шаблоны которых нет в кэше
return dbTemplates.Where(t => filteredCacheTemplates.Contains(t.TemplateId)).ToList();
}
/// <summary>
/// Получить список дней для отчета, с отметкой выходной/рабочий
/// </summary>
@@ -255,13 +415,12 @@ namespace PARR.Core.Services.Workload.Implementations
/// <param name="reportDays"></param>
/// <param name="offset"></param>
/// <returns></returns>
private WorkloadStatisticItem BuildStatisticItem(string title, IEnumerable<TemplateReportData> groupTemplates, List<WorkloadDayItem> reportDays, TimeSpan offset)
private WorkloadStatisticItem BuildStatisticItem(string title, Guid? objId, 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 };
return new WorkloadStatisticItem { DailyMetrics = dailyMetrics, Summary = summary, Title = title, ObjId = objId };
}
}
}

View File

@@ -1,5 +1,6 @@
using PARR.Domain.DTOs.Workload;
using PARR.Domain.Enums;
using Microsoft.Extensions.Primitives;
using PARR.Domain.DTOs.Workload;
using PARR.Domain.Enums.Workload;
namespace PARR.Core.Services.Workload.Interfaces
{
@@ -21,8 +22,31 @@ namespace PARR.Core.Services.Workload.Interfaces
/// <param name="dateStart"></param>
/// <param name="durationDays"></param>
/// <param name="offset"></param>
/// <param name="filterParam"></param>
/// <param name="responseArea"></param>
/// <param name="workGroup"></param>
/// <returns></returns>
Task<WorkloadReport?> GetWorkloadReport(WorkloadReportType reportType, DateOnly dateStart, int durationDays, TimeSpan offset, string? filterParam);
Task<WorkloadReport?> GetWorkloadReportAsync(WorkloadReportType reportType, DateOnly dateStart, int durationDays, TimeSpan offset, string? responseArea, string? workGroup);
/// <summary>
/// Получить отчет - список шаблонов на дату согласно фильтрам
/// </summary>
/// <param name="reportType"></param>
/// <param name="filter">Фильтр, значение в зависимости от reportType</param>
/// <param name="state"></param>
/// <param name="date"></param>
/// <param name="offset"></param>
/// <param name="responseArea">Дополнительный фильтр при отчете по шаблонам, ЗО</param>
/// <param name="workGorup">Дополнительный фильтр при отчете по шаблонам, РГ</param>
/// <returns></returns>
Task<List<WorkloadTemplateReport>> GetTemplateReportAsync(
WorkloadTemplateReportType reportType,
string filter,
WorkloadTemplateReportState state,
DateOnly date,
TimeSpan offset,
string? responseArea,
string? workGroup
);
}
}

View File

@@ -1,4 +1,4 @@
using PARR.Domain.Enums;
using PARR.Domain.Enums.Workload;
namespace PARR.Domain.DTOs.Workload
{
@@ -73,6 +73,12 @@ namespace PARR.Domain.DTOs.Workload
{
public required string Title { get; init; }
/// <summary>
/// ИД строки, если есть.
/// Например JobId
/// </summary>
public Guid? ObjId { get; init; }
public required WorkloadSummary Summary { get; init; }
public required List<WorkloadDailyItem> DailyMetrics { get; init; }

View File

@@ -0,0 +1,10 @@
namespace PARR.Domain.DTOs.Workload
{
/// <summary>
/// Отчет о шаблонах, для отчета о нагруженности
/// </summary>
/// <param name="TemplateId"></param>
/// <param name="Name"></param>
/// <param name="NextRun"></param>
public record WorkloadTemplateReport(Guid TemplateId, string Name, DateTimeOffset NextRun, bool IsActiveTemplate, bool IsActiveSchedule);
}

View File

@@ -0,0 +1,22 @@
using System.Text.Json.Serialization;
namespace PARR.Domain.Enums.Workload
{
/// <summary>
/// Дополнительный фильтр для работ.
/// Отчет Workload, шаблоны
/// </summary>
[JsonConverter(typeof(JsonStringEnumConverter))]
public enum WorkloadJobSubfliterType
{
/// <summary>
/// ЗО
/// </summary>
ResponseArea = 0,
/// <summary>
/// РГ
/// </summary>
WorkGroup=1
}
}

View File

@@ -1,6 +1,6 @@
using System.Text.Json.Serialization;
namespace PARR.Domain.Enums
namespace PARR.Domain.Enums.Workload
{
/// <summary>
/// Тип отчета о загруженности

View File

@@ -0,0 +1,26 @@
using System.Text.Json.Serialization;
namespace PARR.Domain.Enums.Workload
{
/// <summary>
/// Тип отчета о загруженности, состояние
/// </summary>
[JsonConverter(typeof(JsonStringEnumConverter))]
public enum WorkloadTemplateReportState
{
/// <summary>
/// Все
/// </summary>
All = 0,
/// <summary>
/// Активированные
/// </summary>
Activated = 1,
/// <summary>
/// Деактивированные
/// </summary>
Deactivated = 2
}
}

View File

@@ -0,0 +1,26 @@
using System.Text.Json.Serialization;
namespace PARR.Domain.Enums.Workload
{
/// <summary>
/// Тип отчета о загруженности, шаблоны
/// </summary>
[JsonConverter(typeof(JsonStringEnumConverter))]
public enum WorkloadTemplateReportType
{
/// <summary>
/// Зона ответственности
/// </summary>
ResponseArea = 0,
/// <summary>
/// Рабочая группа
/// </summary>
WorkGroup = 1,
/// <summary>
/// Работа
/// </summary>
Job = 2
}
}

View File

@@ -504,7 +504,7 @@ namespace PARR.Infrastructure.Redis
if (keyPartsToHash != null && keyPartsToHash.Length > 0)
{
var partsToHashStr = string.Join(mainSeparator, keyPartsToHash)
var partsToHashStr = string.Join(mainSeparator, keyPartsToHash.Where(t => !string.IsNullOrWhiteSpace(t)))
.Replace(" ", wordSeparator)
.ToLower();