From 19cb6b50bf1c618f75efc01e05a1f344b5b8a3d0 Mon Sep 17 00:00:00 2001 From: Mikhail Trubnikov Date: Thu, 11 Jun 2026 14:44:39 +1000 Subject: [PATCH 1/2] =?UTF-8?q?feat(api,core,domain):=20RobotSnapshot=20-?= =?UTF-8?q?=20=D0=BF=D0=BE=D1=87=D0=B0=D1=81=D0=BE=D0=B2=D0=BE=D0=B9=20?= =?UTF-8?q?=D0=BE=D1=82=D1=87=D0=B5=D1=82=20=D0=BE=20=D0=B7=D0=B0=D0=B3?= =?UTF-8?q?=D1=80=D1=83=D0=B7=D0=BA=D0=B5=20=D1=80=D0=BE=D0=B1=D0=BE=D1=82?= =?UTF-8?q?=D0=BE=D0=B2?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- PARR.API/Contracts/V1/ApiRoutes.cs | 2 + PARR.API/Controllers/V1/TestController.cs | 23 ++- .../IRobotSnapshotService.cs | 17 +++ .../RobotSnapshotService.cs | 140 +++++++++++++++++- .../RobotSnapshotDTO/RobotAnalyticsQuery.cs | 16 ++ .../ServerHourlyAnalyticsDto.cs | 47 ++++++ 6 files changed, 243 insertions(+), 2 deletions(-) create mode 100644 PARR.Domain/DTOs/RobotSnapshotDTO/RobotAnalyticsQuery.cs create mode 100644 PARR.Domain/DTOs/RobotSnapshotDTO/ServerHourlyAnalyticsDto.cs diff --git a/PARR.API/Contracts/V1/ApiRoutes.cs b/PARR.API/Contracts/V1/ApiRoutes.cs index fa684666..0f2e903a 100644 --- a/PARR.API/Contracts/V1/ApiRoutes.cs +++ b/PARR.API/Contracts/V1/ApiRoutes.cs @@ -50,6 +50,8 @@ public const string GetNextRun = Base + "/tests/next-run"; public const string CreateCache = Base + "/tests/cache/"; + + public const string TestHandler = Base + "/tests/test/"; } public static class Template diff --git a/PARR.API/Controllers/V1/TestController.cs b/PARR.API/Controllers/V1/TestController.cs index e818bd37..e60bd570 100644 --- a/PARR.API/Controllers/V1/TestController.cs +++ b/PARR.API/Controllers/V1/TestController.cs @@ -10,9 +10,11 @@ using PARR.Core.Common.Interfaces; using PARR.Core.Repositories.Interfaces; using PARR.Core.Repositories.Interfaces.Unit; using PARR.Core.Services.NextRunServices; +using PARR.Core.Services.RobotSnapshotServices; using PARR.Core.Services.UnitService.Interfaces; using PARR.Core.Services.Workload.Implementations; using PARR.Domain.Cache; +using PARR.Domain.DTOs.RobotSnapshotDTO; namespace PARR.API.Controllers.V1 { @@ -27,6 +29,7 @@ namespace PARR.API.Controllers.V1 private readonly ILogger logger; private readonly IUnitService unitService; private readonly IUnitRepository unitRepository; + private readonly IRobotSnapshotService _robotSnapshotService; public TestController( IClientService clientService, @@ -37,7 +40,8 @@ namespace PARR.API.Controllers.V1 WorkloadCacheService workloadCacheService, ILogger logger, IUnitService unitService, - IUnitRepository unitRepository + IUnitRepository unitRepository, + IRobotSnapshotService robotSnapshotService ) { this.clientService = clientService; @@ -48,6 +52,7 @@ namespace PARR.API.Controllers.V1 this.logger = logger; this.unitService = unitService; this.unitRepository = unitRepository; + _robotSnapshotService = robotSnapshotService; } @@ -161,6 +166,22 @@ namespace PARR.API.Controllers.V1 } + /// + /// Тестовый метод + /// + /// + [HttpGet(ApiRoutes.Test.TestHandler)] + public async Task Test() + { + var data = await _robotSnapshotService.GetHourlyAnalyticsAsync(new RobotAnalyticsQuery + { + DateStart = DateTimeOffset.UtcNow.AddHours(-1), + DateEnd = DateTimeOffset.UtcNow, + Offset = TimeSpan.FromMinutes(600) + }); + + return Ok(data); + } diff --git a/PARR.Core/Services/RobotSnapshotServices/IRobotSnapshotService.cs b/PARR.Core/Services/RobotSnapshotServices/IRobotSnapshotService.cs index 6ac083f1..894e9dc2 100644 --- a/PARR.Core/Services/RobotSnapshotServices/IRobotSnapshotService.cs +++ b/PARR.Core/Services/RobotSnapshotServices/IRobotSnapshotService.cs @@ -4,8 +4,25 @@ namespace PARR.Core.Services.RobotSnapshotServices { public interface IRobotSnapshotService { + /// + /// Получить статистику + /// + /// + /// Task> GetAsync(RobotSnapshotQuery queryDto); + /// + /// Записать статистику + /// + /// + /// Task CreateAsync(CreateRobotSnapshot robotSnapshot); + + /// + /// Получить аналитику за период + /// + /// + /// + Task> GetHourlyAnalyticsAsync(RobotAnalyticsQuery queryDto); } } diff --git a/PARR.Core/Services/RobotSnapshotServices/RobotSnapshotService.cs b/PARR.Core/Services/RobotSnapshotServices/RobotSnapshotService.cs index 67738bc7..d208cf8d 100644 --- a/PARR.Core/Services/RobotSnapshotServices/RobotSnapshotService.cs +++ b/PARR.Core/Services/RobotSnapshotServices/RobotSnapshotService.cs @@ -64,7 +64,145 @@ namespace PARR.Core.Services.RobotSnapshotServices result.Add(new RobotSnapshotItemDto { Robot = userDto, Snapshots = snapshots }); } - return result.OrderBy(t => t.Robot.Ip).ThenBy(t => t.Robot.Name).ToList(); + return result.OrderBy(t => t.Robot.Name).ThenBy(t => t.Robot.Ip).ToList(); + } + + + public async Task> GetHourlyAnalyticsAsync(RobotAnalyticsQuery queryDto) + { + var snapshotsQuery = robotSnapshotRepository.Get() + .AsNoTracking() + .Where(t => queryDto.DateStart <= t.DateCreated && t.DateCreated <= queryDto.DateEnd); + + // Фильтруем по конкретному IP + if (!string.IsNullOrEmpty(queryDto.Ip)) + snapshotsQuery = snapshotsQuery.Where(t => t.Ip == queryDto.Ip); + + // Превращает TimeSpan в строку вида "+03:00" или "-05:00" + string formattedOffset = (queryDto.Offset >= TimeSpan.Zero ? "+" : "-") + + queryDto.Offset.ToString(@"hh\:mm"); + + // Группируем на стороне PostgreSQL с учетом часового пояса пользователя. + // Метод .Add(offset) сдвигает UTC-дату в Postgres на интервал времени пользователя, + // благодаря чему минуты объединяются в правильные локальные "часовые корзины". + var rawGroupedData = await snapshotsQuery + .GroupBy(t => new + { + t.Ip, + // Явно сдвигаем дату и достаем только нужные компоненты + Year = (t.DateCreated + queryDto.Offset).DateTime.Year, + Month = (t.DateCreated + queryDto.Offset).DateTime.Month, + Day = (t.DateCreated + queryDto.Offset).DateTime.Day, + Hour = (t.DateCreated + queryDto.Offset).DateTime.Hour + }) + .Select(g => new + { + g.Key.Ip, + // Вытаскиваем компоненты даты из уже смещенного локального времени + LocalYear = g.Key.Year, + LocalMonth = g.Key.Month, + LocalDay = g.Key.Day, + LocalHour = g.Key.Hour, + + MaxAllowed = g.Max(t => t.MaxRobots), + MaxTemplates = g.Max(t => t.TemplateRobotsCount), + MaxSchedules = g.Max(t => t.ScheduleRobotsCount), + SumTemplates = g.Sum(t => t.TemplateRobotsCount), + SumSchedules = g.Sum(t => t.ScheduleRobotsCount) + }) + .ToListAsync(); + + if (!rawGroupedData.Any()) + return new List(); + + // Получаем информацию о серверах + var serverIps = rawGroupedData.Select(g => g.Ip).ToHashSet(); + var serverDictionary = await userRepository.Get() + .AsNoTracking() + .Where(t => serverIps.Contains(t.Ip)) + .ToDictionaryAsync(t => t.Ip); + + // Группируем полученные агрегаты по серверам уже в памяти веб-сервера + var dataByServers = rawGroupedData.GroupBy(g => g.Ip); + var result = new List(); + + // Принудительно переводим UTC-границы запроса в локальное время пользователя для корректной генерации "дыр" + var localStart = queryDto.DateStart.ToOffset(queryDto.Offset).DateTime; + var localEnd = queryDto.DateEnd.ToOffset(queryDto.Offset).DateTime; + + // Округляем начальный час до ровного значения (00 минут, 00 секунд) + var startHour = new DateTime(localStart.Year, localStart.Month, localStart.Day, localStart.Hour, 0, 0); + + foreach (var serverGroup in dataByServers) + { + serverDictionary.TryGetValue(serverGroup.Key, out var robotInfo); + + var userDto = new UserBaseDto + { + Ip = serverGroup.Key, + Description = robotInfo?.Description ?? string.Empty, + Name = robotInfo?.Name ?? string.Empty + }; + + // Формируем точки, которые удалось вытащить из базы данных + var hourlyPoints = serverGroup.Select(g => new RobotHourlyPointDto + { + // Возвращаем дату со смещением пользователя, чтобы Angular сразу отображал правильный час + Hour = new DateTimeOffset(g.LocalYear, g.LocalMonth, g.LocalDay, g.LocalHour, 0, 0, TimeSpan.Zero), + MaxAllowedRobots = g.MaxAllowed, + MaxTemplateRobots = g.MaxTemplates, + MaxScheduleRobots = g.MaxSchedules, + // Считаем среднее количество роботов, деля поминутную сумму строго на 60 минут часа + AvgTemplateRobots = Math.Round(g.SumTemplates / 60.0, 1), + AvgScheduleRobots = Math.Round(g.SumSchedules / 60.0, 1) + }) + .ToList(); + + // Алгоритм заполнения пропусков во времени (если сервер был выключен) + var filledPoints = new List(); + var currentHour = startHour; + int lastKnownMax = hourlyPoints.FirstOrDefault()?.MaxAllowedRobots ?? 0; + + while (currentHour <= localEnd) + { + // Создаем временную точку со смещением для точного сопоставления + var targetOffsetDateTime = new DateTimeOffset(currentHour, TimeSpan.Zero); + var point = hourlyPoints.FirstOrDefault(p => p.Hour == targetOffsetDateTime); + + if (point != null) + { + filledPoints.Add(point); + lastKnownMax = point.MaxAllowedRobots; + } + else + { + // Если за этот час записей в БД нет — значит, сервер был полностью оффлайн. + // Выводим нули, чтобы график в Angular не прерывался, а плавно падал. + filledPoints.Add(new RobotHourlyPointDto + { + Hour = targetOffsetDateTime, + MaxAllowedRobots = lastKnownMax, + MaxTemplateRobots = 0, + AvgTemplateRobots = 0, + MaxScheduleRobots = 0, + AvgScheduleRobots = 0 + }); + } + // Шагаем строго на +1 час вперед по локальному времени клиента + currentHour = currentHour.AddHours(1); + } + + result.Add(new ServerHourlyAnalyticsDto + { + Robot = userDto, + Snapshots = filledPoints.OrderBy(p => p.Hour).ToList() + }); + } + + return result + .OrderBy(t => t.Robot.Name) + .ThenBy(t => t.Robot.Ip) + .ToList(); } diff --git a/PARR.Domain/DTOs/RobotSnapshotDTO/RobotAnalyticsQuery.cs b/PARR.Domain/DTOs/RobotSnapshotDTO/RobotAnalyticsQuery.cs new file mode 100644 index 00000000..f9f54a81 --- /dev/null +++ b/PARR.Domain/DTOs/RobotSnapshotDTO/RobotAnalyticsQuery.cs @@ -0,0 +1,16 @@ +namespace PARR.Domain.DTOs.RobotSnapshotDTO +{ + public record RobotAnalyticsQuery + { + public DateTimeOffset DateStart { get; init; } + + public DateTimeOffset DateEnd { get; init; } + + /// + /// Смещение часового пояса + /// + public TimeSpan Offset { get; init; } + + public string? Ip { get; init; } + } +} diff --git a/PARR.Domain/DTOs/RobotSnapshotDTO/ServerHourlyAnalyticsDto.cs b/PARR.Domain/DTOs/RobotSnapshotDTO/ServerHourlyAnalyticsDto.cs new file mode 100644 index 00000000..bca8ba7d --- /dev/null +++ b/PARR.Domain/DTOs/RobotSnapshotDTO/ServerHourlyAnalyticsDto.cs @@ -0,0 +1,47 @@ +using PARR.Domain.DTOs.User; + +namespace PARR.Domain.DTOs.RobotSnapshotDTO +{ + /// + /// Аналитика по снапшотам роботов + /// + public record ServerHourlyAnalyticsDto + { + public UserBaseDto Robot { get; init; } = null!; + + /// + /// Почасовая статистика для гарфиков + /// + public List Snapshots { get; set; } = new(); + } + + /// + /// Почасовая статистика по снапшотам роботов + /// + public record RobotHourlyPointDto + { + public DateTimeOffset Hour { get; set; } + + public int MaxAllowedRobots { get; set; } + + /// + /// Максимальное кол-во роботов работающих одновременно по шаблонам + /// + public int MaxTemplateRobots { get; set; } + + /// + /// Сколько роботов в среднем, работало в течении часа + /// + public double AvgTemplateRobots { get; set; } + + /// + /// Максимальное кол-во роботов работающих одновременно по расписаниям + /// + public int MaxScheduleRobots { get; set; } + + /// + /// Сколько роботов в среднем, работало в течении часа + /// + public double AvgScheduleRobots { get; set; } + } +} From 21e00ce42e108cfbe31135567b85b44cc5d7a810 Mon Sep 17 00:00:00 2001 From: Mikhail Trubnikov Date: Mon, 15 Jun 2026 11:00:53 +1000 Subject: [PATCH 2/2] =?UTF-8?q?feat(api,core,domain):=20=D0=A1=D1=82=D0=B0?= =?UTF-8?q?=D1=82=D0=B8=D1=81=D1=82=D0=B8=D0=BA=D0=B0=20=D0=BF=D0=BE=20?= =?UTF-8?q?=D1=80=D0=BE=D0=B1=D0=BE=D1=82=D0=B0=D0=BC,=20=D0=BA=D0=BE?= =?UTF-8?q?=D0=BB-=D0=B2=D0=BE=20=D1=80=D0=B0=D0=B1=D0=BE=D1=82=D0=B0?= =?UTF-8?q?=D1=8E=D1=89=D0=B8=D1=85=20=D1=80=D0=BE=D0=B1=D0=BE=D1=82=D0=BE?= =?UTF-8?q?=D0=B2=20=D0=B2=20=D1=87=D0=B0=D1=81?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../Statistics/StatRobotTaskPeriodResponse.cs | 5 + .../V1/Statistics/StatRobotTaskController.cs | 29 +++- PARR.API/Controllers/V1/TestController.cs | 24 +++- .../IRobotSnapshotService.cs | 11 +- .../RobotSnapshotService.cs | 132 +++++++++++++++--- .../HourlyRobotTypeAnalyticsDto.cs | 17 +++ .../RobotSnapshotDTO/RobotAnalyticsQuery.cs | 22 ++- .../RobotSnapshotRobotTypeDbDto.cs | 20 +++ .../ServerHourlyAnalyticsDto.cs | 2 +- 9 files changed, 223 insertions(+), 39 deletions(-) create mode 100644 PARR.Domain/DTOs/RobotSnapshotDTO/HourlyRobotTypeAnalyticsDto.cs create mode 100644 PARR.Domain/DTOs/RobotSnapshotDTO/RobotSnapshotRobotTypeDbDto.cs diff --git a/PARR.API/Contracts/V1/Responses/Statistics/StatRobotTaskPeriodResponse.cs b/PARR.API/Contracts/V1/Responses/Statistics/StatRobotTaskPeriodResponse.cs index 3abdd1c6..38040dcf 100644 --- a/PARR.API/Contracts/V1/Responses/Statistics/StatRobotTaskPeriodResponse.cs +++ b/PARR.API/Contracts/V1/Responses/Statistics/StatRobotTaskPeriodResponse.cs @@ -23,5 +23,10 @@ /// Ошибок /// public int Errors { get; set; } + + /// + /// Среднее кол-во роботов работающих в течении часа + /// + public double AvgRobots { get; set; } } } diff --git a/PARR.API/Controllers/V1/Statistics/StatRobotTaskController.cs b/PARR.API/Controllers/V1/Statistics/StatRobotTaskController.cs index ba9862fb..b9543a58 100644 --- a/PARR.API/Controllers/V1/Statistics/StatRobotTaskController.cs +++ b/PARR.API/Controllers/V1/Statistics/StatRobotTaskController.cs @@ -8,7 +8,9 @@ using PARR.API.Contracts.V1.Responses.Base; using PARR.API.Contracts.V1.Responses.Statistics; using PARR.API.Controllers.V1.Base; using PARR.Core.Repositories.Interfaces; +using PARR.Core.Services.RobotSnapshotServices; using PARR.Domain.Common.Roles; +using PARR.Domain.DTOs.RobotSnapshotDTO; using PARR.Domain.Enums; namespace PARR.API.Controllers.V1.Statistics @@ -24,13 +26,15 @@ namespace PARR.API.Controllers.V1.Statistics private readonly ITaskStatusRepository taskStatusService; private readonly IRobotHistoryRepository robotHistoryService; private readonly IMapper mapper; + private readonly IRobotSnapshotService _robotSnapshotService; public StatRobotTaskController( IRobotRepository robotService, IRobotConfigurationRepository robotConfigurationService, ITaskStatusRepository taskStatusService, IRobotHistoryRepository robotHistoryService, - IMapper mapper + IMapper mapper, + IRobotSnapshotService robotSnapshotService ) { this.robotService = robotService; @@ -38,6 +42,7 @@ namespace PARR.API.Controllers.V1.Statistics this.taskStatusService = taskStatusService; this.robotHistoryService = robotHistoryService; this.mapper = mapper; + _robotSnapshotService = robotSnapshotService; } /// @@ -87,11 +92,11 @@ namespace PARR.API.Controllers.V1.Statistics [HttpGet(ApiRoutes.StatRobotTask.GetPeriodStatistics)] public async Task GetPeriodStatistics([FromRoute] RobotsEnum robot) { + var offset = TimeSpan.FromHours(0); + int minusHour = 24; var queryDate = DateTimeOffset.UtcNow.AddHours(-minusHour); - queryDate = new DateTimeOffset(queryDate.Year, queryDate.Month, queryDate.Day, queryDate.Hour, 0, 0, new TimeSpan(0)); - //queryDate = queryDate.Date + new TimeSpan(queryDate.Hour, 0, 0); - //queryDate=queryDate.ToOffset(TimeSpan.Zero); + queryDate = new DateTimeOffset(queryDate.Year, queryDate.Month, queryDate.Day, queryDate.Hour, 0, 0, offset); var query = robotHistoryService.Get() @@ -110,16 +115,24 @@ namespace PARR.API.Controllers.V1.Statistics var statistics = await query.ToListAsync(); - //if (!statistics.Any()) - // return NoContent(); - var periodList = GetPeriodList(queryDate, DateTimeOffset.UtcNow); if (!periodList.Any()) return NoContent(); + // Статистика по роботам (кол-во роботов) + var robotStats = await _robotSnapshotService.GetHourlyAnalyticsByRobotTypeAsync(new RobotAnalyticsRobotTypeQuery + { + DateStart = queryDate, + DateEnd = DateTimeOffset.UtcNow, + Offset = offset, + RobotType = robot + }); + periodList.ForEach(item => { + var robotCount = robotStats.FirstOrDefault(t => t.Hour.Date == item.Date.Date && t.Hour.Hour == item.Date.Hour); + var statItem = statistics.FirstOrDefault(t => t.Date.Date == item.Date.Date && t.Date.Hour == item.Date.Hour); if (statItem != null) { @@ -127,6 +140,7 @@ namespace PARR.API.Controllers.V1.Statistics item.Creating = statItem.Creating; item.Updating = statItem.Updating; item.Ok = statItem.Ok; + item.AvgRobots = robotCount?.AvgRobots ?? 0; } }); @@ -150,6 +164,7 @@ namespace PARR.API.Controllers.V1.Statistics Errors = 0, Ok = 0, Updating = 0, + AvgRobots = 0, Date = new DateTimeOffset(periodDate.Date.Year, periodDate.Date.Month, periodDate.Date.Day, periodDate.Hour, 0, 0, new TimeSpan(0)), }); diff --git a/PARR.API/Controllers/V1/TestController.cs b/PARR.API/Controllers/V1/TestController.cs index e60bd570..c8462e56 100644 --- a/PARR.API/Controllers/V1/TestController.cs +++ b/PARR.API/Controllers/V1/TestController.cs @@ -173,14 +173,24 @@ namespace PARR.API.Controllers.V1 [HttpGet(ApiRoutes.Test.TestHandler)] public async Task Test() { - var data = await _robotSnapshotService.GetHourlyAnalyticsAsync(new RobotAnalyticsQuery - { - DateStart = DateTimeOffset.UtcNow.AddHours(-1), - DateEnd = DateTimeOffset.UtcNow, - Offset = TimeSpan.FromMinutes(600) - }); + //var data = await _robotSnapshotService.GetHourlyAnalyticsAsync(new RobotAnalyticsQuery + //{ + // DateStart = DateTimeOffset.UtcNow.AddHours(-1), + // DateEnd = DateTimeOffset.UtcNow, + // Offset = TimeSpan.FromMinutes(600) + //}); - return Ok(data); + //var data = await _robotSnapshotService.GetHourlyAnalyticsByRobotTypeAsync(new RobotAnalyticsRobotTypeQuery + //{ + // DateStart = DateTimeOffset.UtcNow.AddHours(-12), + // DateEnd = DateTimeOffset.UtcNow, + // Offset = TimeSpan.FromMinutes(600), + // RobotType = PARR.Domain.Enums.RobotsEnum.TemplateOrder + //}); + + //return Ok(data); + + return Ok(); } diff --git a/PARR.Core/Services/RobotSnapshotServices/IRobotSnapshotService.cs b/PARR.Core/Services/RobotSnapshotServices/IRobotSnapshotService.cs index 894e9dc2..3e6ab211 100644 --- a/PARR.Core/Services/RobotSnapshotServices/IRobotSnapshotService.cs +++ b/PARR.Core/Services/RobotSnapshotServices/IRobotSnapshotService.cs @@ -19,10 +19,19 @@ namespace PARR.Core.Services.RobotSnapshotServices Task CreateAsync(CreateRobotSnapshot robotSnapshot); /// - /// Получить аналитику за период + /// Получить аналитику за период с группировкой по ip серверов. Разбивка по часам. + /// Максимальная одновременная работа роботов. Среднее кол-во роботов в час. Максимально возможное кол-во роботов. /// /// /// Task> GetHourlyAnalyticsAsync(RobotAnalyticsQuery queryDto); + + /// + /// Получить аналитику за период по типу робота. Разбивка по часам. + /// Среднее кол-во роботов в час. + /// + /// + /// + Task> GetHourlyAnalyticsByRobotTypeAsync(RobotAnalyticsRobotTypeQuery queryDto); } } diff --git a/PARR.Core/Services/RobotSnapshotServices/RobotSnapshotService.cs b/PARR.Core/Services/RobotSnapshotServices/RobotSnapshotService.cs index d208cf8d..32156387 100644 --- a/PARR.Core/Services/RobotSnapshotServices/RobotSnapshotService.cs +++ b/PARR.Core/Services/RobotSnapshotServices/RobotSnapshotService.cs @@ -11,9 +11,9 @@ namespace PARR.Core.Services.RobotSnapshotServices { internal class RobotSnapshotService : IRobotSnapshotService { - private readonly IRobotSnapshotRepository robotSnapshotRepository; - private readonly IUserRepository userRepository; - private readonly IMapper mapper; + private readonly IRobotSnapshotRepository _robotSnapshotRepository; + private readonly IUserRepository _userRepository; + private readonly IMapper _mapper; public RobotSnapshotService( IRobotSnapshotRepository robotSnapshotRepository, @@ -21,9 +21,9 @@ namespace PARR.Core.Services.RobotSnapshotServices IMapper mapper ) { - this.robotSnapshotRepository = robotSnapshotRepository; - this.userRepository = userRepository; - this.mapper = mapper; + _robotSnapshotRepository = robotSnapshotRepository; + _userRepository = userRepository; + _mapper = mapper; } @@ -37,7 +37,7 @@ namespace PARR.Core.Services.RobotSnapshotServices var dateEnd = DateTimeOffset.UtcNow; var dateStart = dateEnd.AddMinutes(-minutes); - var query = robotSnapshotRepository.Get().AsNoTracking() + var query = _robotSnapshotRepository.Get().AsNoTracking() .Where(t => dateStart <= t.DateCreated && t.DateCreated <= dateEnd); if (!string.IsNullOrEmpty(queryDto.Ip)) @@ -47,7 +47,7 @@ namespace PARR.Core.Services.RobotSnapshotServices var robotIps = groupingSnapshots.Select(t => t.Key).ToHashSet(); - var robotList = await userRepository.Get() + var robotList = await _userRepository.Get() .AsNoTracking() .Where(t => robotIps.Contains(t.Ip)) .ToDictionaryAsync(t => t.Ip); @@ -59,7 +59,7 @@ namespace PARR.Core.Services.RobotSnapshotServices robotList.TryGetValue(item.Key, out var robot); var userDto = new UserBaseDto { Ip = item.Key, Description = robot?.Description ?? string.Empty, Name = robot?.Name ?? string.Empty }; - var snapshots = mapper.Map>(item).OrderBy(t => t.DateCreated).ToList(); + var snapshots = _mapper.Map>(item).OrderBy(t => t.DateCreated).ToList(); result.Add(new RobotSnapshotItemDto { Robot = userDto, Snapshots = snapshots }); } @@ -70,7 +70,7 @@ namespace PARR.Core.Services.RobotSnapshotServices public async Task> GetHourlyAnalyticsAsync(RobotAnalyticsQuery queryDto) { - var snapshotsQuery = robotSnapshotRepository.Get() + var snapshotsQuery = _robotSnapshotRepository.Get() .AsNoTracking() .Where(t => queryDto.DateStart <= t.DateCreated && t.DateCreated <= queryDto.DateEnd); @@ -78,9 +78,6 @@ namespace PARR.Core.Services.RobotSnapshotServices if (!string.IsNullOrEmpty(queryDto.Ip)) snapshotsQuery = snapshotsQuery.Where(t => t.Ip == queryDto.Ip); - // Превращает TimeSpan в строку вида "+03:00" или "-05:00" - string formattedOffset = (queryDto.Offset >= TimeSpan.Zero ? "+" : "-") + - queryDto.Offset.ToString(@"hh\:mm"); // Группируем на стороне PostgreSQL с учетом часового пояса пользователя. // Метод .Add(offset) сдвигает UTC-дату в Postgres на интервал времени пользователя, @@ -117,12 +114,12 @@ namespace PARR.Core.Services.RobotSnapshotServices // Получаем информацию о серверах var serverIps = rawGroupedData.Select(g => g.Ip).ToHashSet(); - var serverDictionary = await userRepository.Get() + var serverDictionary = await _userRepository.Get() .AsNoTracking() .Where(t => serverIps.Contains(t.Ip)) .ToDictionaryAsync(t => t.Ip); - // Группируем полученные агрегаты по серверам уже в памяти веб-сервера + // Группируем полученные агрегаты по серверам уже в памяти var dataByServers = rawGroupedData.GroupBy(g => g.Ip); var result = new List(); @@ -206,6 +203,103 @@ namespace PARR.Core.Services.RobotSnapshotServices } + public async Task> GetHourlyAnalyticsByRobotTypeAsync(RobotAnalyticsRobotTypeQuery queryDto) + { + var snapshotsQuery = _robotSnapshotRepository.Get() + .AsNoTracking() + .Where(t => queryDto.DateStart <= t.DateCreated && t.DateCreated <= queryDto.DateEnd) + .GroupBy(t => new + { + // Явно сдвигаем дату + Year = (t.DateCreated + queryDto.Offset).DateTime.Year, + Month = (t.DateCreated + queryDto.Offset).DateTime.Month, + Day = (t.DateCreated + queryDto.Offset).DateTime.Day, + Hour = (t.DateCreated + queryDto.Offset).DateTime.Hour + }); + + IQueryable groupedQuery; + + switch (queryDto.RobotType) + { + case Domain.Enums.RobotsEnum.TemplateOrder: + groupedQuery = snapshotsQuery.Select(g => new RobotSnapshotRobotTypeDbDto + { + // Вытаскиваем компоненты даты из уже смещенного локального времени + LocalYear = g.Key.Year, + LocalMonth = g.Key.Month, + LocalDay = g.Key.Day, + LocalHour = g.Key.Hour, + + // Макс не правильно. + //MaxAllowed = g.Max(t => t.MaxRobots), + Sum = g.Sum(t => t.TemplateRobotsCount) + }); + break; + case Domain.Enums.RobotsEnum.ScheduleOrder: + groupedQuery = snapshotsQuery.Select(g => new RobotSnapshotRobotTypeDbDto + { + // Вытаскиваем компоненты даты из уже смещенного локального времени + LocalYear = g.Key.Year, + LocalMonth = g.Key.Month, + LocalDay = g.Key.Day, + LocalHour = g.Key.Hour, + + // Макс не правильно. + //MaxAllowed = g.Max(t => t.MaxRobots), + Sum = g.Sum(t => t.ScheduleRobotsCount) + }); + break; + default: + throw new ArgumentException("Не обработано значение параметра", nameof(queryDto.RobotType)); + } + + var data = await groupedQuery.ToListAsync(); + + if (!data.Any()) + return new List(); + + var dbDataDict = data.ToDictionary( + k => new DateTime(k.LocalYear, k.LocalMonth, k.LocalDay, k.LocalHour, 0, 0), + v => new + { + //MaxAllowedRobots = t.MaxAllowed, + AvgRobots = Math.Round(v.Sum / 60.0, 1) + }); + + // Заполняем модель + var statistics = new List(); + + #region Алгоритм заполнения пропусков во времени (если сервер был выключен) + // Превращаем границы в локальное время для генерации сетки часов + var currentHour = queryDto.DateStart.ToOffset(queryDto.Offset).DateTime; + var localEnd = queryDto.DateEnd.ToOffset(queryDto.Offset).DateTime; + + // Срезаем минуты/секунды для старта + currentHour = new DateTime(currentHour.Year, currentHour.Month, currentHour.Day, currentHour.Hour, 0, 0); + + while (currentHour <= localEnd) + { + // Ищем значение в словаре. Если нет — ставим 0 + dbDataDict.TryGetValue(currentHour, out var statInfo); + + // 1. Создаем DateTimeOffset для локального времени с правильным смещением + var localDateTimeOffset = new DateTimeOffset(currentHour, queryDto.Offset); + + statistics.Add(new HourlyRobotTypeAnalyticsDto + { + // Возвращаем DateTimeOffset с нулевым смещением (как в вашем коде) или с queryDto.Offset + Hour = localDateTimeOffset.ToUniversalTime(), + AvgRobots = statInfo?.AvgRobots ?? 0 + }); + + currentHour = currentHour.AddHours(1); + } + #endregion + + return statistics; + } + + public async Task CreateAsync(CreateRobotSnapshot robotSnapshot) { var snapshot = new RobotSnapshot @@ -217,19 +311,19 @@ namespace PARR.Core.Services.RobotSnapshotServices Ip = robotSnapshot.Ip }; - var createdResult = await robotSnapshotRepository.CreateAsync(snapshot); - var commitResult = await robotSnapshotRepository.CommitAsync(); + var createdResult = await _robotSnapshotRepository.CreateAsync(snapshot); + var commitResult = await _robotSnapshotRepository.CommitAsync(); if (!createdResult || !commitResult) throw new DbErrorException("Ошибка сохранения в БД"); - var user = await userRepository.Get().AsNoTracking().FirstOrDefaultAsync(t => t.Ip == robotSnapshot.Ip); + var user = await _userRepository.Get().AsNoTracking().FirstOrDefaultAsync(t => t.Ip == robotSnapshot.Ip); var userDto = new UserBaseDto { Ip = robotSnapshot.Ip, Description = user?.Description ?? string.Empty, Name = user?.Name ?? string.Empty }; var result = new RobotSnapshotItemDto { Robot = userDto, - Snapshots = mapper.Map>(new List { snapshot }) + Snapshots = _mapper.Map>(new List { snapshot }) }; return result; diff --git a/PARR.Domain/DTOs/RobotSnapshotDTO/HourlyRobotTypeAnalyticsDto.cs b/PARR.Domain/DTOs/RobotSnapshotDTO/HourlyRobotTypeAnalyticsDto.cs new file mode 100644 index 00000000..9c0dc017 --- /dev/null +++ b/PARR.Domain/DTOs/RobotSnapshotDTO/HourlyRobotTypeAnalyticsDto.cs @@ -0,0 +1,17 @@ +namespace PARR.Domain.DTOs.RobotSnapshotDTO +{ + /// + /// Почасовая статистика по снапшотам роботов + /// + public record HourlyRobotTypeAnalyticsDto + { + public DateTimeOffset Hour { get; init; } + + //public int MaxAllowedRobots { get; init; } + + /// + /// Среднее кол-во роботов работающих в течении часа + /// + public double AvgRobots { get; init; } + } +} diff --git a/PARR.Domain/DTOs/RobotSnapshotDTO/RobotAnalyticsQuery.cs b/PARR.Domain/DTOs/RobotSnapshotDTO/RobotAnalyticsQuery.cs index f9f54a81..6fbaef29 100644 --- a/PARR.Domain/DTOs/RobotSnapshotDTO/RobotAnalyticsQuery.cs +++ b/PARR.Domain/DTOs/RobotSnapshotDTO/RobotAnalyticsQuery.cs @@ -1,16 +1,30 @@ -namespace PARR.Domain.DTOs.RobotSnapshotDTO +using PARR.Domain.Enums; + +namespace PARR.Domain.DTOs.RobotSnapshotDTO { - public record RobotAnalyticsQuery + public record RobotAnalyticsBaseQuery { public DateTimeOffset DateStart { get; init; } - + public DateTimeOffset DateEnd { get; init; } - + /// /// Смещение часового пояса /// public TimeSpan Offset { get; init; } + } + public record RobotAnalyticsRobotTypeQuery : RobotAnalyticsBaseQuery + { + /// + /// Тип робота + /// + public RobotsEnum RobotType { get; init; } + } + + + public record RobotAnalyticsQuery : RobotAnalyticsBaseQuery + { public string? Ip { get; init; } } } diff --git a/PARR.Domain/DTOs/RobotSnapshotDTO/RobotSnapshotRobotTypeDbDto.cs b/PARR.Domain/DTOs/RobotSnapshotDTO/RobotSnapshotRobotTypeDbDto.cs new file mode 100644 index 00000000..ccfc4f3e --- /dev/null +++ b/PARR.Domain/DTOs/RobotSnapshotDTO/RobotSnapshotRobotTypeDbDto.cs @@ -0,0 +1,20 @@ +namespace PARR.Domain.DTOs.RobotSnapshotDTO +{ + /// + /// Селект из БД по снапшотам по типам роботов + /// + public record RobotSnapshotRobotTypeDbDto + { + public int LocalYear { get; init; } + + public int LocalMonth { get; init; } + + public int LocalDay { get; init; } + + public int LocalHour { get; init; } + + //public int MaxAllowed { get; init; } + + public int Sum { get; init; } + } +} diff --git a/PARR.Domain/DTOs/RobotSnapshotDTO/ServerHourlyAnalyticsDto.cs b/PARR.Domain/DTOs/RobotSnapshotDTO/ServerHourlyAnalyticsDto.cs index bca8ba7d..f0b948fe 100644 --- a/PARR.Domain/DTOs/RobotSnapshotDTO/ServerHourlyAnalyticsDto.cs +++ b/PARR.Domain/DTOs/RobotSnapshotDTO/ServerHourlyAnalyticsDto.cs @@ -3,7 +3,7 @@ namespace PARR.Domain.DTOs.RobotSnapshotDTO { /// - /// Аналитика по снапшотам роботов + /// Аналитика по снапшотам роботов с группировкой по IP робота /// public record ServerHourlyAnalyticsDto {