using AutoMapper; using Microsoft.EntityFrameworkCore; using PARR.Core.Repositories.Interfaces; using PARR.Core.Repositories.Interfaces.RobotRepositories; using PARR.Domain.DTOs.RobotSnapshotDTO; using PARR.Domain.DTOs.User; using PARR.Domain.Entities.RobotEntities; using PARR.Domain.Exceptions; namespace PARR.Core.Services.RobotSnapshotServices { internal class RobotSnapshotService : IRobotSnapshotService { private readonly IRobotSnapshotRepository robotSnapshotRepository; private readonly IUserRepository userRepository; private readonly IMapper mapper; public RobotSnapshotService( IRobotSnapshotRepository robotSnapshotRepository, IUserRepository userRepository, IMapper mapper ) { this.robotSnapshotRepository = robotSnapshotRepository; this.userRepository = userRepository; this.mapper = mapper; } public async Task> GetAsync(RobotSnapshotQuery queryDto) { // Просто возвращаем данные из БД, не проверяем что есть промежутки между которыми не было данных // Т.е это просто чтоб посмотреть что есть в БД var minutes = queryDto.Minutes > 0 ? queryDto.Minutes : 60 * 12; var dateEnd = DateTimeOffset.UtcNow; var dateStart = dateEnd.AddMinutes(-minutes); var query = robotSnapshotRepository.Get().AsNoTracking() .Where(t => dateStart <= t.DateCreated && t.DateCreated <= dateEnd); if (!string.IsNullOrEmpty(queryDto.Ip)) query = query.Where(t => t.Ip == queryDto.Ip); var groupingSnapshots = (await query.ToListAsync()).GroupBy(t => t.Ip); var robotIps = groupingSnapshots.Select(t => t.Key).ToHashSet(); var robotList = await userRepository.Get() .AsNoTracking() .Where(t => robotIps.Contains(t.Ip)) .ToDictionaryAsync(t => t.Ip); var result = new List(); foreach (var item in groupingSnapshots) { 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(); result.Add(new RobotSnapshotItemDto { Robot = userDto, Snapshots = snapshots }); } 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(); } public async Task CreateAsync(CreateRobotSnapshot robotSnapshot) { var snapshot = new RobotSnapshot { Id = Guid.NewGuid(), TemplateRobotsCount = robotSnapshot.TemplateRobotsCount, ScheduleRobotsCount = robotSnapshot.ScheduleRobotsCount, MaxRobots = robotSnapshot.MaxRobots, Ip = robotSnapshot.Ip }; 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 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 }) }; return result; } } }