using Microsoft.EntityFrameworkCore; using Microsoft.Extensions.Logging; using PARR.DAL.NextRunServices.Models; using PARR.DAL.Services.Interfaces; namespace PARR.DAL.NextRunServices.Subservices { internal class TemplateDistributor : ITemplateDistributor { private readonly ILogger logger; private readonly IWeekendDayService weekendDayService; public TemplateDistributor( ILogger logger, IWeekendDayService weekendDayService ) { this.logger = logger; this.weekendDayService = weekendDayService; } /// /// Распределяет шаблоны по рабочим дням в заданном диапазоне с равномерной загрузкой. /// Возвращает список с Id и новой датой выполнения для каждого шаблона. /// public async Task> ScheduleTemplatesAsync(List templates, DateOnly startDate, DateOnly endDate, TimeSpan executionTime) { logger.LogInformation("Начинаем распределение {Count} шаблонов с {StartDate} по {EndDate}", templates.Count, startDate, endDate); // Получаем список выходных/праздничных дней в заданном диапазоне var weekends = (await weekendDayService.GetWeekends(startDate, endDate).ToListAsync()).ToHashSet(); // Получаем список рабочих дней (исключая выходные и праздники) var workDays = GetWorkDays(startDate, endDate, weekends); logger.LogDebug("Найдено {Count} рабочих дней", workDays.Count); // Проверяем, есть ли хотя бы один рабочий день if (workDays.Count == 0) { logger.LogWarning("Нет доступных рабочих дней в диапазоне {StartDate} - {EndDate}", startDate, endDate); return new List(); } var totalWorks = templates.Count; // Общее количество шаблонов для распределения var totalDays = workDays.Count; // Общее количество доступных рабочих дней // Сортируем шаблоны: сначала без даты (NextRun == null), затем с датой var sortedTemplates = templates.OrderBy(t => t.NextRun.HasValue ? 1 : 0).ToList(); // Вычисляем интервал между работами: сколько "дней" приходится на одну работу // Это позволяет равномерно распределить работы по всему периоду // т е например 2 работы распределятся на 30 дней равномерно, не будут идти подряд var interval = (double)totalDays / totalWorks; // Проходим по всем шаблонам и распределяем их равномерно по дням for (int i = 0; i < totalWorks; i++) { // Вычисляем индекс дня, на который нужно назначить i-ю работу // Используем округление, чтобы получить ближайший день var dayIndex = (int)Math.Round(i * interval); // Защита от возможного выхода за границы массива из-за округления if (dayIndex >= totalDays) dayIndex = totalDays - 1; // Получаем дату из списка рабочих дней по вычисленному индексу var date = workDays[dayIndex]; // Формируем дату выполнения: дата + заданное время (executionTime) var nextRun = new DateTimeOffset(date.Year, date.Month, date.Day, executionTime.Hours, executionTime.Minutes, executionTime.Seconds, TimeSpan.Zero); // Назначаем вычисленную дату выполнения шаблону sortedTemplates[i].NextRun = nextRun; logger.LogDebug("Шаблон {TemplateId} назначен на {NextRun}", sortedTemplates[i].Id, nextRun); } logger.LogInformation("Распределение завершено. Возвращаем {Count} записей.", templates.Count); // Возвращаем список шаблонов с обновлёнными датами выполнения return templates.Select(t => new TemplateNextRunDto { Id = t.Id, NextRun = t.NextRun!.Value }).ToList(); } /// /// Обновляет устаревшие даты выполнения шаблонов. /// Возвращает список шаблонов, чьи даты были изменены. /// public async Task> ReassignOutdatedRunsAsync(List templates, DateOnly startDate, DateOnly endDate, TimeSpan executionTime) { logger.LogInformation("Начинаем переназначение устаревших дат выполнения"); // Получаем список выходных/праздничных дней var weekends = (await weekendDayService.GetWeekends(startDate, endDate).ToListAsync()).ToHashSet(); var workDays = GetWorkDays(startDate, endDate, weekends).OrderBy(d => d).ToList(); var now = DateTimeOffset.UtcNow; var result = new List(); foreach (var template in templates) { if (template.NextRun.HasValue && template.NextRun.Value < now) { logger.LogDebug("Шаблон {TemplateId} с датой {OldDate} устарел", template.Id, template.NextRun); var newDate = FindNextAvailableDate(workDays, DateOnly.FromDateTime(now.DateTime), DateOnly.FromDateTime(template.NextRun.Value.Date)); if (newDate.HasValue) { var nextRun = new DateTimeOffset(newDate.Value.Year, newDate.Value.Month, newDate.Value.Day, executionTime.Hours, executionTime.Minutes, executionTime.Seconds, TimeSpan.Zero); template.NextRun = nextRun; logger.LogDebug("Шаблон {TemplateId} перенесен на {NewDate}", template.Id, nextRun); result.Add(new TemplateNextRunDto { Id = template.Id, NextRun = nextRun }); } else { logger.LogWarning("Не удалось найти новую дату для шаблона {TemplateId}", template.Id); } } } logger.LogInformation("Переназначение завершено. Изменено {Count} записей.", result.Count); return result; } /// /// Перераспределяет только те шаблоны, у которых дата недействительна (выходной, вне периода). /// Возвращает список всех шаблонов с новыми или старыми датами. /// public async Task> RebalanceTemplatesAsync(List templates, DateOnly startDate, DateOnly endDate, TimeSpan executionTime) { logger.LogInformation("Начинаем перераспределение шаблонов"); // Получаем список выходных/праздничных дней var weekends = (await weekendDayService.GetWeekends(startDate, endDate).ToListAsync()).ToHashSet(); var workDays = GetWorkDays(startDate, endDate, weekends); var validDates = new HashSet(workDays); // Отфильтруем шаблоны с недействительной датой var unassigned = templates.Where(t => !t.NextRun.HasValue || !validDates.Contains(DateOnly.FromDateTime(t.NextRun.Value.Date))).ToList(); logger.LogDebug("Найдено {Count} шаблонов для переназначения", unassigned.Count); // Перераспределяем только невалидные var reassigned = await ScheduleTemplatesAsync(unassigned, startDate, endDate, executionTime); // Возвращаем все шаблоны с их датами var result = templates.Select(t => new TemplateNextRunDto { Id = t.Id, NextRun = t.NextRun!.Value }).ToList(); logger.LogInformation("Перераспределение завершено. Возвращаем {Count} записей.", result.Count); return result; } /// /// Возвращает актуальную дату выполнения для одного шаблона. /// Результат — список из одного элемента (если успешно) или пустой. /// public async Task> GetValidNextRunAsync(TemplateNextRunDto template, DateOnly startDate, DateOnly endDate, TimeSpan executionTime) { logger.LogDebug("Запрос актуальной даты для шаблона {TemplateId}", template.Id); // Получаем список выходных/праздничных дней var weekends = (await weekendDayService.GetWeekends(startDate, endDate).ToListAsync()).ToHashSet(); var nextRun = await GetValidNextRunInternalAsync(template, startDate, endDate, weekends, executionTime); if (nextRun.HasValue) { logger.LogDebug("Шаблон {TemplateId} имеет актуальную дату {NextRun}", template.Id, nextRun); return new List { new TemplateNextRunDto { Id = template.Id, NextRun = nextRun.Value } }; } logger.LogWarning("Не удалось получить актуальную дату для шаблона {TemplateId}", template.Id); return new List(); } /// /// Возвращает список актуальных дат выполнения для всех переданных шаблонов. /// public async Task> GetValidNextRunsAsync(List templates, DateOnly startDate, DateOnly endDate, TimeSpan executionTime) { logger.LogDebug("Запрос актуальных дат для {Count} шаблонов", templates.Count); // Получаем список выходных/праздничных дней var weekends = (await weekendDayService.GetWeekends(startDate, endDate).ToListAsync()).ToHashSet(); var result = new List(); foreach (var template in templates) { var nextRun = await GetValidNextRunInternalAsync(template, startDate, endDate, weekends, executionTime); if (nextRun.HasValue) { result.Add(new TemplateNextRunDto { Id = template.Id, NextRun = nextRun.Value }); } } logger.LogInformation("Получено {Count} актуальных дат выполнения", result.Count); return result; } /// /// Внутренний метод для получения актуальной даты выполнения для одного шаблона. /// private async Task GetValidNextRunInternalAsync(TemplateNextRunDto template, DateOnly startDate, DateOnly endDate, HashSet weekends, TimeSpan executionTime) { var workDays = GetWorkDays(startDate, endDate, weekends); var today = DateOnly.FromDateTime(DateTime.UtcNow); // Если у шаблона нет даты, назначаем первую доступную if (!template.NextRun.HasValue) { var firstDay = workDays.FirstOrDefault(); if (firstDay != default) { var nextRun = new DateTimeOffset(firstDay.Year, firstDay.Month, firstDay.Day, executionTime.Hours, executionTime.Minutes, executionTime.Seconds, TimeSpan.Zero); logger.LogDebug("Шаблон {TemplateId} получил первую доступную дату: {NextRun}", template.Id, nextRun); return nextRun; } logger.LogWarning("Нет доступных рабочих дней для шаблона {TemplateId}", template.Id); return null; } var scheduledDate = DateOnly.FromDateTime(template.NextRun.Value.Date); // Если дата валидна и не устарела — возвращаем как есть if (workDays.Contains(scheduledDate)) { if (template.NextRun.Value < DateTimeOffset.UtcNow) { logger.LogDebug("Дата шаблона {TemplateId} ({Date}) устарела", template.Id, template.NextRun); var newDate = FindNextAvailableDate(workDays, today, scheduledDate); if (newDate.HasValue) { var nextRun = new DateTimeOffset(newDate.Value.Year, newDate.Value.Month, newDate.Value.Day, executionTime.Hours, executionTime.Minutes, executionTime.Seconds, TimeSpan.Zero); logger.LogDebug("Шаблон {TemplateId} перенесен на {NewDate}", template.Id, nextRun); return nextRun; } logger.LogWarning("Не удалось найти новую дату для устаревшего шаблона {TemplateId}", template.Id); return null; } // Дата валидна и не устарела logger.LogDebug("Шаблон {TemplateId} имеет действительную дату: {NextRun}", template.Id, template.NextRun); return template.NextRun; } // Дата недействительна (выходной или вне периода) — ищем ближайшую var closest = FindClosestValidDate(workDays, scheduledDate); if (closest.HasValue) { var nextRun = new DateTimeOffset(closest.Value.Year, closest.Value.Month, closest.Value.Day, executionTime.Hours, executionTime.Minutes, executionTime.Seconds, TimeSpan.Zero); logger.LogDebug("Шаблон {TemplateId} перемещен на ближайшую дату: {NextRun}", template.Id, nextRun); return nextRun; } logger.LogWarning("Не удалось найти подходящую дату для шаблона {TemplateId}", template.Id); return null; } /// /// Ищет следующую доступную дату после указанной, если не находит — ищет назад. /// private DateOnly? FindNextAvailableDate(List workDays, DateOnly afterDate, DateOnly originalDate) { var future = workDays.FirstOrDefault(d => d > afterDate); if (future != default) return future; var past = workDays.Where(t => t >= originalDate).OrderByDescending(t => t).FirstOrDefault(); return past != default ? (DateOnly?)past : null; } /// /// Ищет ближайшую дату (вперед или назад) к заданной, среди доступных рабочих дней. /// private DateOnly? FindClosestValidDate(List workDays, DateOnly targetDate) { var future = workDays.FirstOrDefault(t => t >= targetDate); if (future != default) return future; var past = workDays.Where(t => t <= targetDate).OrderByDescending(t => t).FirstOrDefault(); return past != default ? (DateOnly?)past : null; } /// /// Возвращает список рабочих дней в диапазоне, исключая выходные и праздники. /// private List GetWorkDays(DateOnly start, DateOnly end, HashSet weekends) { var result = new List(); var date = start; while (date <= end) { if (!weekends.Contains(date)) { result.Add(date); } date = date.AddDays(1); } return result; } } }