diff --git a/PARR.API/Contracts/V1/ApiRoutes.cs b/PARR.API/Contracts/V1/ApiRoutes.cs
index f4b6d161..d75ef393 100644
--- a/PARR.API/Contracts/V1/ApiRoutes.cs
+++ b/PARR.API/Contracts/V1/ApiRoutes.cs
@@ -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 Наряды
diff --git a/PARR.API/Contracts/V1/Requests/Queries/StatWorkloadTemplateReqportQuery.cs b/PARR.API/Contracts/V1/Requests/Queries/StatWorkloadTemplateReqportQuery.cs
new file mode 100644
index 00000000..8a7c9d17
--- /dev/null
+++ b/PARR.API/Contracts/V1/Requests/Queries/StatWorkloadTemplateReqportQuery.cs
@@ -0,0 +1,15 @@
+namespace PARR.API.Contracts.V1.Requests.Queries
+{
+ public record StatWorkloadTemplateReqportQuery
+ {
+ ///
+ /// Дополнительный фильтр для работ - ЗО
+ ///
+ public string? ResponseArea { get; init; }
+
+ ///
+ /// Дополнительный фильтр для работ - РГ
+ ///
+ public string? WorkGroup { get; init; }
+ }
+}
diff --git a/PARR.API/Contracts/V1/Responses/Statistics/StatWorkloadReportResponse.cs b/PARR.API/Contracts/V1/Responses/Statistics/StatWorkloadReportResponse.cs
index 560cf3e4..c7dac959 100644
--- a/PARR.API/Contracts/V1/Responses/Statistics/StatWorkloadReportResponse.cs
+++ b/PARR.API/Contracts/V1/Responses/Statistics/StatWorkloadReportResponse.cs
@@ -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 DailyMetrics { get; init; }
diff --git a/PARR.API/Contracts/V1/Responses/Statistics/StatWorkloadTemplateReportResponse.cs b/PARR.API/Contracts/V1/Responses/Statistics/StatWorkloadTemplateReportResponse.cs
new file mode 100644
index 00000000..95acebbc
--- /dev/null
+++ b/PARR.API/Contracts/V1/Responses/Statistics/StatWorkloadTemplateReportResponse.cs
@@ -0,0 +1,4 @@
+namespace PARR.API.Contracts.V1.Responses.Statistics
+{
+ public record StatWorkloadTemplateReportResponse(Guid TemplateId, string Name, DateTimeOffset NextRun, bool IsActiveTemplate, bool IsActiveSchedule);
+}
diff --git a/PARR.API/Controllers/V1/Statistics/StatWorkGroupWorkLoadController.cs b/PARR.API/Controllers/V1/Statistics/StatWorkGroupWorkLoadController.cs
deleted file mode 100644
index 9fe2a151..00000000
--- a/PARR.API/Controllers/V1/Statistics/StatWorkGroupWorkLoadController.cs
+++ /dev/null
@@ -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
-{
- ///
- /// Статистика загрузки регламентными работами по рабочим группам
- ///
- public class StatWorkGroupWorkLoadController : BaseApiController
- {
- private readonly ITemplateRepository templateService;
- //private readonly IWorkLoadService workLoadService;
- //private readonly IWorkGroupService workGroupService;
- private readonly IMapper mapper;
- private readonly ILogger logger;
-
- public StatWorkGroupWorkLoadController(
- ITemplateRepository templateService,
- //IWorkLoadService workLoadService,
- //IWorkGroupService workGroupService,
- IMapper mapper,
- ILogger logger
- )
- {
- this.templateService = templateService;
- //this.workLoadService = workLoadService;
- //this.workGroupService = workGroupService;
- this.mapper = mapper;
- this.logger = logger;
- }
-
- /*
- ///
- /// Статистика загрузки регламентными работами рабочих групп по ЗО
- ///
- ///
- ///
- ///
- [HttpGet(ApiRoutes.StatWorkGroupWorkLoad.Get)]
- public async Task Get([FromRoute] int responseAreaCode, [FromQuery] WorkloadBaseQuery requestQuery)
- {
- var response = await GetStatisticsAsync(responseAreaCode, requestQuery);
-
- return Ok(new Response>(response, true));
- }
-
-
- ///
- /// Статистика загрузки РГ по списку РГ
- ///
- ///
- [HttpPost(ApiRoutes.StatWorkGroupWorkLoad.GetByWorkGroups)]
- public async Task GetByWorkGroups([FromBody] StatWorkGroupsWorkLoadRequest request)
- {
- var response = await GetStatisticsAsync(null, request.Filter, request.WorkGroups);
-
- return Ok(new Response>(response, true));
- }
-
-
- private async Task> GetStatisticsAsync(int? responseAreaCode, WorkloadBaseQuery requestQuery, List? workGroups = null)
- {
- //защита от дурака, чтоб не выгрузить всю БД
- if (responseAreaCode == null && (workGroups == null || workGroups.Count() == 0))
- {
- logger.LogWarning($"Пытались выполнить запрос получения статситики с нулевыми параметрами {nameof(responseAreaCode)}, {nameof(workGroups)}. Controller {nameof(StatWorkGroupWorkLoadController)}");
- return new List();
- }
-
-
- var daysList = workLoadService.GetDatesForPeriod(requestQuery);
-
- IQueryable 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 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();
-
- 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(wg),
- WorkLoad = workLoads
- });
- });
-
- response = response.OrderBy(t => t.WorkGroup.Name).ToList();
-
- return response;
- }
- */
- }
-}
diff --git a/PARR.API/Controllers/V1/Statistics/StatWorkloadController.cs b/PARR.API/Controllers/V1/Statistics/StatWorkloadController.cs
index fe83179c..0a8dade1 100644
--- a/PARR.API/Controllers/V1/Statistics/StatWorkloadController.cs
+++ b/PARR.API/Controllers/V1/Statistics/StatWorkloadController.cs
@@ -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 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
///
///
[HttpGet(ApiRoutes.Workload.WorkGroupJob)]
- public async Task GetWorkGroupJobReport([FromRoute] string workGroup, [FromQuery] TimeZoneOffsetClient offset, [FromQuery] WorkloadBaseQuery request)
+ public async Task 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 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
/// Универсальный метод получения отчетности
///
///
- private async Task GetReportAsync(WorkloadReportType reportType, int durationDays, TimeSpan offset, string? filter)
+ //private async Task GetReportAsync(WorkloadReportType reportType, int durationDays, TimeSpan offset, string? filter)
+ private async Task 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(null, false, new List { new ErrorModel { Message = "Необходимо сформировать отчетность." } }, "empty"));
diff --git a/PARR.API/Controllers/V1/Statistics/StatWorkloadTemplateController.cs b/PARR.API/Controllers/V1/Statistics/StatWorkloadTemplateController.cs
new file mode 100644
index 00000000..7ae670fa
--- /dev/null
+++ b/PARR.API/Controllers/V1/Statistics/StatWorkloadTemplateController.cs
@@ -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
+{
+ ///
+ /// Статситика загруженности - шаблоны
+ ///
+ [Authorize(Roles = ParrRoles.EsppRobot.RoleOrAdmin)]
+ public class StatWorkloadTemplateController : BaseApiController
+ {
+ private readonly IMapper mapper;
+ private readonly IWorkloadService workloadService;
+ private readonly ILogger logger;
+
+ public StatWorkloadTemplateController(
+ IMapper mapper,
+ IWorkloadService workloadService,
+ ILogger logger
+ )
+ {
+ this.mapper = mapper;
+ this.workloadService = workloadService;
+ this.logger = logger;
+ }
+
+
+ ///
+ /// Отчет о загруженности. Шаблоны
+ ///
+ /// Тип отчета (ЗО, РГ, Работа)
+ /// Фильтр, значиение в зависимости от типа отчета
+ /// Все, активированные, деактивированные
+ /// Дата
+ /// Часовой пояс
+ ///
+ ///
+ [HttpGet(ApiRoutes.StatWorkloadTemplate.GetWorkloadTemplateReport)]
+ public async Task 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>(report);
+
+ return Ok(new Response>(response, true));
+ }
+
+
+ }
+}
diff --git a/PARR.API/MappingProfiles/DomainToResponseProfile.cs b/PARR.API/MappingProfiles/DomainToResponseProfile.cs
index 5735968c..f71c1276 100644
--- a/PARR.API/MappingProfiles/DomainToResponseProfile.cs
+++ b/PARR.API/MappingProfiles/DomainToResponseProfile.cs
@@ -460,6 +460,12 @@ namespace PARR.API.MappingProfiles
#endregion
+ #region StatWorkloadTemplate
+
+ CreateMap();
+
+ #endregion
+
#region Task
diff --git a/PARR.Core/Services/Workload/Implementations/WorkloadCacheService.cs b/PARR.Core/Services/Workload/Implementations/WorkloadCacheService.cs
index 7f384ae5..a4d8cc85 100644
--- a/PARR.Core/Services/Workload/Implementations/WorkloadCacheService.cs
+++ b/PARR.Core/Services/Workload/Implementations/WorkloadCacheService.cs
@@ -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()) + "*");
}
+
+ ///
+ /// Получить список шаблонов по ИД из кэш/бд.
+ /// Если каких то данных не хватает в кэше, дополнить кэш.
+ ///
+ ///
+ ///
+ public async Task?> GetTemplateReportDataByIdsAsync(IReadOnlySet 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(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();
+ }
+
+
///
/// Получить кэш РГ и ЗО всех шаблонов, получить недостающие поля из БД.
/// Сравнит кол-во записей в БД и в КЭШ, если не хватает до 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;
}
+
+ ///
+ /// Проверка кэша на акутальность.
+ ///
+ ///
+ public async Task 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;
+ }
+
+
///
/// Получить РГ и ЗО из шорткода. Сохранить в КЭШ.
///
///
///
- 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 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;
}
///
@@ -337,9 +415,9 @@ namespace PARR.Core.Services.Workload.Implementations
///
/// Наименование ЗО или РГ или null
///
- public async Task GetWorkloadReportAsync(WorkloadReportType reportType, DateOnly dateStart, int durationDays, TimeSpan offset, string? filterParam)
+ public async Task 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(key, true);
}
@@ -355,9 +433,9 @@ namespace PARR.Core.Services.Workload.Implementations
///
/// Наименование ЗО или РГ или null
///
- 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
///
///
///
- 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();
@@ -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);
diff --git a/PARR.Core/Services/Workload/Implementations/WorkloadService.cs b/PARR.Core/Services/Workload/Implementations/WorkloadService.cs
index 2511396a..c1531fa7 100644
--- a/PARR.Core/Services/Workload/Implementations/WorkloadService.cs
+++ b/PARR.Core/Services/Workload/Implementations/WorkloadService.cs
@@ -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 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 GetWorkloadReport(WorkloadReportType reportType, DateOnly dateStart, int durationDays, TimeSpan offset, string? filterParam)
+ public async Task 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> 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();
+ }
+
+ 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();
+
+ // Если тип - работы, и нет дополнительных фильтров по ЗО и РГ, не полезем в кэш, вернем сразу
+ 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 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();
+ }
+
+
+ private List FilterWorkloadJobTemplateReport(List dbTemplates, HashSet? filteredCacheTemplates)
+ {
+ if (filteredCacheTemplates == null)
+ return dbTemplates;
+
+ // Отсекаем шаблоны которых нет в кэше
+ return dbTemplates.Where(t => filteredCacheTemplates.Contains(t.TemplateId)).ToList();
+ }
+
+
///
/// Получить список дней для отчета, с отметкой выходной/рабочий
///
@@ -255,13 +415,12 @@ namespace PARR.Core.Services.Workload.Implementations
///
///
///
- private WorkloadStatisticItem BuildStatisticItem(string title, IEnumerable groupTemplates, List reportDays, TimeSpan offset)
+ private WorkloadStatisticItem BuildStatisticItem(string title, Guid? objId, 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 };
+ return new WorkloadStatisticItem { DailyMetrics = dailyMetrics, Summary = summary, Title = title, ObjId = objId };
}
-
}
}
diff --git a/PARR.Core/Services/Workload/Interfaces/IWorkloadService.cs b/PARR.Core/Services/Workload/Interfaces/IWorkloadService.cs
index aae196c5..be742bed 100644
--- a/PARR.Core/Services/Workload/Interfaces/IWorkloadService.cs
+++ b/PARR.Core/Services/Workload/Interfaces/IWorkloadService.cs
@@ -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
///
///
///
- ///
+ ///
+ ///
///
- Task GetWorkloadReport(WorkloadReportType reportType, DateOnly dateStart, int durationDays, TimeSpan offset, string? filterParam);
+ Task GetWorkloadReportAsync(WorkloadReportType reportType, DateOnly dateStart, int durationDays, TimeSpan offset, string? responseArea, string? workGroup);
+
+ ///
+ /// Получить отчет - список шаблонов на дату согласно фильтрам
+ ///
+ ///
+ /// Фильтр, значение в зависимости от reportType
+ ///
+ ///
+ ///
+ /// Дополнительный фильтр при отчете по шаблонам, ЗО
+ /// Дополнительный фильтр при отчете по шаблонам, РГ
+ ///
+ Task> GetTemplateReportAsync(
+ WorkloadTemplateReportType reportType,
+ string filter,
+ WorkloadTemplateReportState state,
+ DateOnly date,
+ TimeSpan offset,
+ string? responseArea,
+ string? workGroup
+ );
+
}
}
diff --git a/PARR.Domain/DTOs/Workload/WorkloadReport.cs b/PARR.Domain/DTOs/Workload/WorkloadReport.cs
index ed312a08..862577ab 100644
--- a/PARR.Domain/DTOs/Workload/WorkloadReport.cs
+++ b/PARR.Domain/DTOs/Workload/WorkloadReport.cs
@@ -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; }
+ ///
+ /// ИД строки, если есть.
+ /// Например JobId
+ ///
+ public Guid? ObjId { get; init; }
+
public required WorkloadSummary Summary { get; init; }
public required List DailyMetrics { get; init; }
diff --git a/PARR.Domain/DTOs/Workload/WorkloadTemplateReport.cs b/PARR.Domain/DTOs/Workload/WorkloadTemplateReport.cs
new file mode 100644
index 00000000..6753cb0b
--- /dev/null
+++ b/PARR.Domain/DTOs/Workload/WorkloadTemplateReport.cs
@@ -0,0 +1,10 @@
+namespace PARR.Domain.DTOs.Workload
+{
+ ///
+ /// Отчет о шаблонах, для отчета о нагруженности
+ ///
+ ///
+ ///
+ ///
+ public record WorkloadTemplateReport(Guid TemplateId, string Name, DateTimeOffset NextRun, bool IsActiveTemplate, bool IsActiveSchedule);
+}
diff --git a/PARR.Domain/Enums/Workload/WorkloadJobSubfliterType.cs b/PARR.Domain/Enums/Workload/WorkloadJobSubfliterType.cs
new file mode 100644
index 00000000..0d0954f0
--- /dev/null
+++ b/PARR.Domain/Enums/Workload/WorkloadJobSubfliterType.cs
@@ -0,0 +1,22 @@
+using System.Text.Json.Serialization;
+
+namespace PARR.Domain.Enums.Workload
+{
+ ///
+ /// Дополнительный фильтр для работ.
+ /// Отчет Workload, шаблоны
+ ///
+ [JsonConverter(typeof(JsonStringEnumConverter))]
+ public enum WorkloadJobSubfliterType
+ {
+ ///
+ /// ЗО
+ ///
+ ResponseArea = 0,
+
+ ///
+ /// РГ
+ ///
+ WorkGroup=1
+ }
+}
diff --git a/PARR.Domain/Enums/WorkloadReportType.cs b/PARR.Domain/Enums/Workload/WorkloadReportType.cs
similarity index 95%
rename from PARR.Domain/Enums/WorkloadReportType.cs
rename to PARR.Domain/Enums/Workload/WorkloadReportType.cs
index 57be84a9..bbc1c4eb 100644
--- a/PARR.Domain/Enums/WorkloadReportType.cs
+++ b/PARR.Domain/Enums/Workload/WorkloadReportType.cs
@@ -1,6 +1,6 @@
using System.Text.Json.Serialization;
-namespace PARR.Domain.Enums
+namespace PARR.Domain.Enums.Workload
{
///
/// Тип отчета о загруженности
diff --git a/PARR.Domain/Enums/Workload/WorkloadTemplateReportState.cs b/PARR.Domain/Enums/Workload/WorkloadTemplateReportState.cs
new file mode 100644
index 00000000..7b2c1a3d
--- /dev/null
+++ b/PARR.Domain/Enums/Workload/WorkloadTemplateReportState.cs
@@ -0,0 +1,26 @@
+using System.Text.Json.Serialization;
+
+namespace PARR.Domain.Enums.Workload
+{
+ ///
+ /// Тип отчета о загруженности, состояние
+ ///
+ [JsonConverter(typeof(JsonStringEnumConverter))]
+ public enum WorkloadTemplateReportState
+ {
+ ///
+ /// Все
+ ///
+ All = 0,
+
+ ///
+ /// Активированные
+ ///
+ Activated = 1,
+
+ ///
+ /// Деактивированные
+ ///
+ Deactivated = 2
+ }
+}
diff --git a/PARR.Domain/Enums/Workload/WorkloadTemplateReportType.cs b/PARR.Domain/Enums/Workload/WorkloadTemplateReportType.cs
new file mode 100644
index 00000000..b8680aab
--- /dev/null
+++ b/PARR.Domain/Enums/Workload/WorkloadTemplateReportType.cs
@@ -0,0 +1,26 @@
+using System.Text.Json.Serialization;
+
+namespace PARR.Domain.Enums.Workload
+{
+ ///
+ /// Тип отчета о загруженности, шаблоны
+ ///
+ [JsonConverter(typeof(JsonStringEnumConverter))]
+ public enum WorkloadTemplateReportType
+ {
+ ///
+ /// Зона ответственности
+ ///
+ ResponseArea = 0,
+
+ ///
+ /// Рабочая группа
+ ///
+ WorkGroup = 1,
+
+ ///
+ /// Работа
+ ///
+ Job = 2
+ }
+}
diff --git a/PARR.Infrastructure/Redis/RedisCacheService.cs b/PARR.Infrastructure/Redis/RedisCacheService.cs
index 55f30ff5..3a3820d7 100644
--- a/PARR.Infrastructure/Redis/RedisCacheService.cs
+++ b/PARR.Infrastructure/Redis/RedisCacheService.cs
@@ -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();