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; /// /// Смещение nextRun на несколько лет вперед, если не смог его рассчитать /// private const int offsetYearsIfErrorCalcNextRun = 5; public TemplateDistributor( ILogger logger, IWeekendDayService weekendDayService ) { this.logger = logger; this.weekendDayService = weekendDayService; } public async Task GetValidNextRunForTemplateAsync(DateOnly startDate, int duration, DateTimeOffset referenceDate, TemplateNextRunDto targetTemplate, List allTemplates, bool excludeWeekends, bool isNew) { //todo: allTemplates - пока не используется. Будет использоваться когда каждый раз будем строить план // Пока работает так: // Получаем список рабочих дней, если работа попадает в эти дни, то считаем дату валидной // Если нет, то перемещаем на последний день периода распределения или первый в зависимости от параметра isNew var _startDate = GetStartPeriodDate(startDate, referenceDate); var _endDate = GetEndPeriodDate(_startDate, duration); logger.LogDebug("Получаю актуальный nextRun для шаблона {templateId}. Текущий nextRun: {nextRun}. Период {duration} дней, с {_startDate} по {_endDate}. Исключить выходные и праздники: {excludeWeekends}. Для нового шаблона: {isNew}", targetTemplate.Id, targetTemplate.NextRun, duration, _startDate, _endDate, excludeWeekends, isNew); // Получаем список рабочих дней (исключая выходные и праздники) var workDays = await GetWorkDaysAsync(_startDate, _endDate, excludeWeekends); if (workDays.Count == 0) { logger.LogWarning("Нет доступных рабочих дней в диапазоне {_startDate} - {_endDate}. Возвращаем шаблон со смещением: {offsetYearsIfErrorCalcNextRun} лет.", _startDate, _endDate, offsetYearsIfErrorCalcNextRun); // смещаем nextRun вперед var result = FillTemplateResultWithOffsetError(targetTemplate, _startDate, referenceDate); logger.LogInformation("Получение актуального NextRun завершено, templateId: {templateId}. NextRun {nextRun}. OldNextRun у {oldNextRun} шт.", result.Id, result.NextRun, result.NextRunOld); return result; } // если isNew, то берем первый день периода if (isNew) { var result = new TemplateNextRunResultDto ( Id: targetTemplate.Id, NextRun: FindNextAvailableDateIgnoreLoad(workDays, referenceDate, true), //CreateDateTimeOffset(workDays.First(), referenceDate), NextRunOld: targetTemplate.NextRun ); logger.LogInformation("Получение актуального NextRun завершено, templateId: {templateId}. Это было создание нового, поместили его в начало периода. NextRun {nextRun}. OldNextRun у {oldNextRun} шт.", result.Id, result.NextRun, result.NextRunOld); return result; } // есть ли NextRun и попадает ли он на рабочий день // проверяем чтоб было не меньше чем UtcNow //TODO: тут еще сделать проверку нагрузки на день if (targetTemplate.NextRun.HasValue && workDays.Contains(DateOnly.FromDateTime(targetTemplate.NextRun.Value.Date)) && targetTemplate.NextRun.Value > DateTimeOffset.UtcNow) { var result = new TemplateNextRunResultDto ( Id: targetTemplate.Id, NextRun: CreateDateTimeOffset(targetTemplate.NextRun.Value, referenceDate), NextRunOld: targetTemplate.NextRun ); logger.LogInformation("Получение актуального NextRun завершено, templateId: {templateId}. NextRun актуальный, возвращаем его. NextRun {nextRun}. OldNextRun у {oldNextRun} шт.", result.Id, result.NextRun, result.NextRunOld); return result; } //TODO: Временный костыль, перемещаем на конец периода var endPeriodResult = new TemplateNextRunResultDto ( Id: targetTemplate.Id, NextRun: FindNextAvailableDateIgnoreLoad(workDays, referenceDate, false), NextRunOld: targetTemplate.NextRun ); logger.LogInformation("Получение актуального NextRun завершено, templateId: {templateId}. NextRun перемещен на конец периода. NextRun {nextRun}. OldNextRun у {oldNextRun} шт.", endPeriodResult.Id, endPeriodResult.NextRun, endPeriodResult.NextRunOld); return endPeriodResult; } public async Task> DistributeTemplatesAsync(DateOnly startDate, int duration, DateTimeOffset referenceDate, List allTemplates, bool excludeWeekends) { var _startDate = GetStartPeriodDate(startDate, referenceDate); var _endDate = GetEndPeriodDate(_startDate, duration); logger.LogInformation("Начинаем распределение {Count} шаблонов на {duration} дней, с {_startDate} по {_endDate}. ReferenceDate: {referenceDate}. Исключить выходные и праздники: {excludeWeekends}.", allTemplates.Count, duration, _startDate, _endDate, referenceDate, excludeWeekends); // Получаем список рабочих дней (исключая выходные и праздники) var workDays = await GetWorkDaysAsync(_startDate, _endDate, excludeWeekends); if (workDays.Count == 0) { logger.LogWarning("Нет доступных рабочих дней в диапазоне {_startDate} - {_endDate}. Возвращаем список шаблонов со смещением: {offsetYearsIfErrorCalcNextRun} лет.", _startDate, _endDate, offsetYearsIfErrorCalcNextRun); // смещаем все nextRun вперед var result = allTemplates.Select(t => FillTemplateResultWithOffsetError(t, _startDate, referenceDate) ).OrderBy(t => t.NextRun).ToList(); logger.LogInformation("Распределение завершено. Возвращаем {Count} записей. NextRun не изменился у {oldCount} шт. Обновлен NextRun у {changedCount} шт.", result.Count, result.Count(t => t.NextRun == t.NextRunOld), result.Count(t => t.NextRun != t.NextRunOld)); return result; } var totalWorks = allTemplates.Count; // интервал между шаблонами var interval = GetInterval(workDays.Count, totalWorks); // строим план, дни на которые должны быть назанчены шаблоны var plan = GetPlan(totalWorks, workDays); // Подсчитываем, сколько работ должно быть на каждый день var expectedDailyLoad = new Dictionary(); foreach (var day in plan) expectedDailyLoad[day] = expectedDailyLoad.GetValueOrDefault(day, 0) + 1; #region Группируем шаблоны по дням, на которые они уже назначены var templatesByDay = new Dictionary>(); foreach (var template in allTemplates) { if (template.NextRun.HasValue) { var day = DateOnly.FromDateTime(template.NextRun.Value.Date); if (!templatesByDay.ContainsKey(day)) { templatesByDay[day] = new List(); } templatesByDay[day].Add(template); } } #endregion #region Подсчитываем текущую нагрузку на каждый день var currentDailyLoad = new Dictionary(); foreach (var day in workDays) { currentDailyLoad[day] = 0; } foreach (var kvp in templatesByDay) { var day = kvp.Key; var count = kvp.Value.Count; currentDailyLoad[day] = count; } #endregion #region Определяем, какие шаблоны нужно оставить, а какие — переназначить var templatesToKeep = new List(); var templatesToReassign = new List(); foreach (var kvp in templatesByDay) { var day = kvp.Key; var templates = kvp.Value; // Если день — нерабочий, то все шаблоны нужно переназначить if (!workDays.Contains(day)) { foreach (var template in templates) { templatesToReassign.Add(template); logger.LogDebug("Шаблон {TemplateId} назначен на нерабочий день {NextRun} — будет переназначен.", template.Id, template.NextRun); } continue; } var expectedCount = expectedDailyLoad.GetValueOrDefault(day, 0); var currentCount = templates.Count; if (currentCount <= expectedCount) { // Все шаблоны в этом дне можно оставить foreach (var template in templates) { templatesToKeep.Add(new TemplateNextRunResultDto ( Id: template.Id, NextRun: template.NextRun.Value, NextRunOld: template.NextRun )); logger.LogDebug("Шаблон {TemplateId} оставлен на дату {NextRun}, т.к. нагрузка в день не превышена.", template.Id, template.NextRun); } } else { // Оставляем только expectedCount штук, остальные — переназначить for (int i = 0; i < expectedCount; i++) { var template = templates[i]; templatesToKeep.Add(new TemplateNextRunResultDto ( Id: template.Id, NextRun: template.NextRun.Value, NextRunOld: template.NextRun )); logger.LogDebug("Шаблон {TemplateId} оставлен на дату {NextRun}, т.к. нагрузка в день не превышена.", template.Id, template.NextRun); } for (int i = expectedCount; i < currentCount; i++) { var template = templates[i]; templatesToReassign.Add(template); logger.LogDebug("Шаблон {TemplateId} переназначается, т.к. нагрузка в день превышена.", template.Id); } } } #endregion #region Добавляем шаблоны, у которых не было даты — тоже нужно распределить foreach (var template in allTemplates) { if (!template.NextRun.HasValue) { templatesToReassign.Add(template); logger.LogDebug("Шаблон {TemplateId} не имел даты — будет распределён.", template.Id); } } #endregion #region Распределяем шаблоны, которые нужно переназначить, учитывая нагрузку var updatedTemplates = new List(templatesToKeep); foreach (var template in templatesToReassign) { var nextRun = FindNextAvailableDateWithLoad(currentDailyLoad, workDays, referenceDate, expectedDailyLoad); if (nextRun.HasValue) { var day = DateOnly.FromDateTime(nextRun.Value.Date); currentDailyLoad[day]++; updatedTemplates.Add(new TemplateNextRunResultDto ( Id: template.Id, NextRun: nextRun.Value, NextRunOld: template.NextRun )); logger.LogDebug("Шаблону {TemplateId} назначен NextRun: {NextRun}, NextRunOld: {NextRunOld}", template.Id, nextRun, template.NextRun); } else { logger.LogWarning("Не удалось назначить дату для шаблона {TemplateId}", template.Id); // Возвращаем с ошибкой updatedTemplates.Add(new TemplateNextRunResultDto ( Id: template.Id, NextRun: CreateDateTimeOffset(DateTimeOffset.UtcNow.AddYears(offsetYearsIfErrorCalcNextRun), referenceDate), NextRunOld: template.NextRun )); } } #endregion updatedTemplates = updatedTemplates.OrderBy(t => t.NextRun).ToList(); logger.LogDebug("--- Результат распределения ---"); foreach (var item in updatedTemplates) logger.LogDebug("Шаблон {templateId}, NextRun: {NextRun}, NextRunOld: {NextRunOld}", item.Id, item.NextRun, item.NextRunOld); logger.LogInformation("Распределение завершено. Возвращаем {Count} записей. NextRun не изменился у {oldCount} шт. Обновлен NextRun у {changedCount} шт.", updatedTemplates.Count, updatedTemplates.Count(t => t.NextRun == t.NextRunOld), updatedTemplates.Count(t => t.NextRun != t.NextRunOld)); return updatedTemplates; } /// /// Заполнить TemplateNextRunResultDto, если нет рабочих дней со смещением /// /// /// private TemplateNextRunResultDto FillTemplateResultWithOffsetError(TemplateNextRunDto template, DateOnly startDate, DateTimeOffset referenceDate) { var result = new TemplateNextRunResultDto ( Id: template.Id, //NextRun = template.NextRun.HasValue // // если есть nextRun, то прибавляем к нему offsetYearsIfErrorCalcNextRun // ? CreateNextRun(template.NextRun.Value.AddYears(offsetYearsIfErrorCalcNextRun), referenceDate) // // если нет nextRun, берем сегодня и прибавляем offsetYearsIfErrorCalcNextRun // : CreateNextRun(DateTimeOffset.UtcNow.AddYears(offsetYearsIfErrorCalcNextRun), referenceDate), NextRun: CreateDateTimeOffset(startDate.AddYears(offsetYearsIfErrorCalcNextRun), referenceDate), NextRunOld: template.NextRun ); logger.LogDebug("Нет рабочих дней, сдвинул nextRun у шаблона {Id} на {offsetYearsIfErrorCalcNextRun} лет вперед, nextRun: {NextRun}, nextRunOld: {NextRunOld}", result.Id, offsetYearsIfErrorCalcNextRun, result.NextRun, result.NextRunOld); return result; } /// /// Получает NextRun не учитывая нагрузку на день /// /// /// /// Искать с начала = true, false - искать с конца /// private DateTimeOffset FindNextAvailableDateIgnoreLoad(List workDays, DateTimeOffset referenseDate, bool isSearchFirst) { // возможно это временная реализация метода, используется для GetValidNextRunForTemplateAsync if (!isSearchFirst) { // берем первый рабочий день с конца и возвращаем его var lastDate = CreateDateTimeOffset(workDays.Last(), referenseDate); logger.LogDebug("Берем первый рабочий день с конца. Сгенерированный NextRun: {lastDate}", lastDate); return lastDate; } // будем искать сначала // берем первый день, и смотрим не прошло ли уже время var firstDate = CreateDateTimeOffset(workDays.First(), referenseDate); if (firstDate > DateTimeOffset.UtcNow) { logger.LogDebug("Берем первый день с начала. Сгенерированный NextRun: {firstDate}", firstDate); return firstDate; } // тогда берем второй день if (workDays.Count > 1) { var secondDate = CreateDateTimeOffset(workDays.ElementAt(1), referenseDate); logger.LogDebug("Берем второй день сначала, так как у первого дня уже время прошло. Сгенерированный NextRun: {secondDate}", secondDate); return secondDate; } logger.LogWarning("Вернули nextRun: {firstDate} из первого дня распределения, хотя время уже просрочено. Всего рабочих дней: {workDays}", firstDate, workDays.Count); // если вдруг список рабочих дней был не >1, то тогда берем первый и вренем его. Так никогда не должно быть return firstDate; } /// /// Находит следующий день с доступной нагрузкой, учитывая expectedDailyLoad /// /// /// /// /// /// private DateTimeOffset? FindNextAvailableDateWithLoad(Dictionary currentDailyLoad, List workDays, DateTimeOffset referenceDate, Dictionary expectedDailyLoad) { foreach (var day in workDays) { var expected = expectedDailyLoad.GetValueOrDefault(day, 0); var current = currentDailyLoad.GetValueOrDefault(day, 0); if (current < expected) { return CreateDateTimeOffset(day, referenceDate); } } // Если нет дня с доступной нагрузкой — ищем день с минимальной текущей нагрузкой, но не превышающий план foreach (var day in workDays) { var expected = expectedDailyLoad.GetValueOrDefault(day, 0); var current = currentDailyLoad.GetValueOrDefault(day, 0); if (current < expected) { return CreateDateTimeOffset(day, referenceDate); } } // Если все дни уже загружены по плану — ищем день с минимальной нагрузкой var minLoad = currentDailyLoad.Values.Min(); var minDay = currentDailyLoad.First(x => x.Value == minLoad).Key; return CreateDateTimeOffset(minDay, referenceDate); } /// /// Получить план: дни, на которые должны быть назначены шаблоны /// /// /// /// private List GetPlan(int totalWorks, List workDays) { var totalDays = workDays.Count(); var interval = GetInterval(totalDays, totalWorks); var plan = new List(); for (int i = 0; i < totalWorks; i++) { #region Вынести в метод // Вычисляем индекс дня, на который нужно назначить i-ю работу //var dayIndex = (int)Math.Round(i * interval); var dayIndex = (int)Math.Floor(i * interval); // Защита от возможного выхода за границы массива из-за округления if (dayIndex >= totalDays) dayIndex = totalDays - 1; #endregion var date = workDays[dayIndex]; plan.Add(date); } logger.LogDebug("--- План распределения ---"); foreach (var item in plan.GroupBy(t => t)) logger.LogDebug("Дата: {date}, кол-во работ: {workCount}", item.Key, item.Count()); return plan; } /// /// Рассчитать интервал между шаблонами /// /// Кол-во рабочих дней /// Кол-во шаблонов /// private double GetInterval(int totalDays, int totalWorks) { var interval = (double)totalDays / totalWorks; return interval; } /// /// Создать DateTimeOffset (из DateTimeOffset + refDate) /// /// Откуда взять дату /// Откуда взять время /// private DateTimeOffset CreateDateTimeOffset(DateTimeOffset date, DateTimeOffset refDate) { return CreateDateTimeOffset(DateOnly.FromDateTime(date.Date), refDate); } /// /// Создать DateTimeOffset (из DateOnly + refDate) /// /// /// /// private DateTimeOffset CreateDateTimeOffset(DateOnly date, DateTimeOffset refDate) { return new DateTimeOffset(date.Year, date.Month, date.Day, refDate.Hour, refDate.Minute, refDate.Second, TimeSpan.Zero); } /// /// Получить дату начала распределения относительно StartDate, ReferenceDate и текущей даты /// /// /// /// private DateOnly GetStartPeriodDate(DateOnly startDate, DateTimeOffset referenceDate) { // дата начала не может быть раньше referenceDate // и не может быть раньше чем сегодня var dateStart = startDate; // если dateStart < чем refDate, то берем refDate if (dateStart < DateOnly.FromDateTime(referenceDate.Date)) dateStart = DateOnly.FromDateTime(referenceDate.Date); // если dateStart < чем текущая дата, то берем текущую дату if (dateStart < DateOnly.FromDateTime(DateTime.UtcNow.Date)) dateStart = DateOnly.FromDateTime(DateTime.UtcNow.Date); // если текущее время > referenceDate, то прибавляем 1 день var dateTimeStart = CreateDateTimeOffset(dateStart, referenceDate); if (dateTimeStart < DateTimeOffset.UtcNow) dateStart = dateStart.AddDays(1); return dateStart; } /// /// Получить дату конца периода распределения /// /// /// /// private DateOnly GetEndPeriodDate(DateOnly startDate, int duration) { // duration — количество дней, включая startDate // поэтому вычитаем 1, чтобы получить сдвиг return startDate.AddDays(duration - 1); } /// /// Получить список выходных дней /// /// /// /// private async Task> GetWeekendsAsync(DateOnly startDate, DateOnly endDate) { var weekends = await weekendDayService.GetWeekends(startDate, endDate).ToListAsync(); logger.LogDebug("Найдено не рабочих дней: {Count}", weekends.Count); foreach (var weekend in weekends) logger.LogDebug("Не рабочий день: {weekend}", weekend); return weekends.ToHashSet(); } /// /// Возвращает список рабочих дней в диапазоне, исключая выходные и праздники. /// /// /// /// Исключить выходные и праздники /// private async Task> GetWorkDaysAsync(DateOnly start, DateOnly end, bool excludeWeekends) { // Получаем список выходных/праздничных дней var weekends = new HashSet(); if (excludeWeekends) weekends = await GetWeekendsAsync(start, end); var result = new List(); var date = start; while (date <= end) { var dayOfWeek = date.DayOfWeek; if (!weekends.Contains(date)) { result.Add(date); } date = date.AddDays(1); } logger.LogDebug("Найдено рабочих дней: {Count}", result.Count); foreach (var workDay in result) logger.LogDebug("Рабочий день: {workDay}", workDay); return result; } } }