using AutoMapper; using Microsoft.EntityFrameworkCore; using Microsoft.Extensions.Logging; using PARR.Core.Repositories.Interfaces; using PARR.Core.Repositories.Interfaces.RobotRepositories; using PARR.Core.Services.Snapshots.Interfaces; 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, ISnapshotProvider { private readonly IRobotSnapshotRepository _robotSnapshotRepository; private readonly IUserRepository _userRepository; private readonly IMapper _mapper; private readonly ILogger _logger; private readonly ISnapshotSettings _snapshotSettings; // Тут любое значение, не используем метод создания снапшота public TimeSpan Interval => TimeSpan.FromHours(1); public TimeSpan RetentionPeriod => _snapshotSettings.RobotSnapshotRetentionPeriod; public RobotSnapshotService( IRobotSnapshotRepository robotSnapshotRepository, IUserRepository userRepository, IMapper mapper, ILogger logger, ISnapshotSettings snapshotSettings ) { _robotSnapshotRepository = robotSnapshotRepository; _userRepository = userRepository; _mapper = mapper; _logger = logger; _snapshotSettings = snapshotSettings; } 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); // Группируем на стороне 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 минут часа //todo: тут не всегда нужно делить на 60, если час последний, то там может быть 50 минут или 20 и тп 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> 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(); // Фиксируем текущее время в UTC и «округляем» его до начала часа var nowLocal = DateTimeOffset.UtcNow.ToOffset(queryDto.Offset); var currentHourLocal = new DateTime(nowLocal.Year, nowLocal.Month, nowLocal.Day, nowLocal.Hour, 0, 0); var dbDataDict = data.ToDictionary( k => new DateTime(k.LocalYear, k.LocalMonth, k.LocalDay, k.LocalHour, 0, 0), v => { double minutesInHour = 60.0; var dbLocalHour = new DateTime(v.LocalYear, v.LocalMonth, v.LocalDay, v.LocalHour, 0, 0); // Строгая проверка: совпадает ли час из базы данных ИМЕННО с текущим часом сегодня? // Если да, то нужно делить не на 60, а на прошедние минуты if (dbLocalHour == currentHourLocal) { // Если идет первая минута часа, делим на 1, чтобы избежать деления на ноль minutesInHour = nowLocal.Minute == 0 ? 1.0 : (double)nowLocal.Minute; } return new { //MaxAllowedRobots = t.MaxAllowed, AvgRobots = Math.Round(v.Sum / minutesInHour, 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 { 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; } public Task TakeSnapshotAsync(CancellationToken cancellationToken) { // Метод пустой! Нам не нужно собирать данные по таймеру, // так как они и так пишутся сюда через контроллер API. return Task.CompletedTask; } public async Task CleanUpOldSnapshotsAsync(CancellationToken cancellationToken) { var thresholdDate = DateTimeOffset.UtcNow - RetentionPeriod; _logger.LogInformation("[{ServiceName}] Запуск очистки старых снапшотов. Удаление данных старше {ThresholdDate}", GetType().Name, thresholdDate); // Удаляем старые записи напрямую в PostgreSQL var deletedCount = await _robotSnapshotRepository.Get() .Where(s => s.DateCreated < thresholdDate) .ExecuteDeleteAsync(cancellationToken); _logger.LogInformation("[{ServiceName}] Очистка завершена. Удалено устаревших строк снапшотов: {Count}", GetType().Name, deletedCount); } } }