using Microsoft.EntityFrameworkCore; using Microsoft.Extensions.Logging; using PARR.DAL.NextRunServices.Models; using PARR.DAL.Services.Interfaces; namespace PARR.DAL.NextRunServices.Subservices { /// /// Распределитель шаблонов с поддержкой часовых поясов. /// Все входные даты интерпретируются в целевом часовом поясе (параметр offset). /// Все результаты возвращаются строго в UTC (TimeSpan.Zero). /// internal class TemplateDistributorV2 : ITemplateDistributorV2 { private readonly ILogger logger; private readonly IWeekendDayService weekendDayService; /// /// Смещение nextRun на несколько лет вперед, если не смог его рассчитать /// private const int offsetYearsIfErrorCalcNextRun = 5; public TemplateDistributorV2( ILogger logger, IWeekendDayService weekendDayService ) { this.logger = logger; this.weekendDayService = weekendDayService; } public async Task> DistributeTemplatesAsync(DateOnly startDate, int duration, DateTimeOffset referenceDate, TimeSpan? offset, List allTemplates, bool excludeWeekends) { // 1. Определяем целевой часовой пояс var targetOffset = offset ?? TimeSpan.Zero; // 2. Применяем оффсет к referenceDate для расчётов в целевом поясе var referenceDateInTargetTz = referenceDate.ToOffset(targetOffset); logger.LogDebug("Распределение шаблонов в часовом поясе {offset}. ReferenceDate в целевом поясе: {refDate}", targetOffset, referenceDateInTargetTz); // 3. Рассчитываем период распределения С УЧЁТОМ ЧАСОВОГО ПОЯСА var _startDate = GetStartPeriodDate(startDate, referenceDateInTargetTz, targetOffset); var _endDate = GetEndPeriodDate(_startDate, duration); logger.LogInformation("Начинаем распределение {Count} шаблонов на {duration} дней, с {_startDate} по {_endDate}. ReferenceDate: {referenceDateInTargetTz} в целевом поясе: {targetOffset}. Исключить выходные и праздники: {excludeWeekends}.", allTemplates.Count, duration, _startDate, _endDate, referenceDateInTargetTz, targetOffset, excludeWeekends); // 4. Получаем рабочие дни (календарные даты, не зависят от пояса) var workDays = await GetWorkDaysAsync(_startDate, _endDate, excludeWeekends); if (workDays.Count == 0) { logger.LogWarning("Нет доступных рабочих дней в диапазоне {_startDate} - {_endDate} (пояс {offset}). Возвращаем шаблоны со смещением {years} лет.", _startDate, _endDate, targetOffset, offsetYearsIfErrorCalcNextRun); // смещаем все nextRun вперед var errorResult = allTemplates.Select(t => FillTemplateResultWithOffsetError(t, _startDate, referenceDateInTargetTz, targetOffset) ).OrderBy(t => t.NextRun).ToList(); logger.LogInformation("Распределение завершено. Возвращаем {Count} записей. NextRun не изменился у {oldCount} шт. Обновлен NextRun у {changedCount} шт.", errorResult.Count, errorResult.Count(t => t.NextRun == t.NextRunOld), errorResult.Count(t => t.NextRun != t.NextRunOld)); return errorResult; } // 5. Строим план распределения // строим план, дни на которые должны быть назанчены шаблоны var plan = GetPlan(allTemplates.Count, workDays); // Подсчитываем, сколько работ должно быть на каждый день var expectedDailyLoad = plan.GroupBy(d => d) .ToDictionary(g => g.Key, g => g.Count()); #region Группируем шаблоны по дням В ЦЕЛЕВОМ ПОЯСЕ, на которые они уже назначены var templatesByDay = new Dictionary>(); foreach (var template in allTemplates) { if (template.NextRun.HasValue) { // КОНВЕРТИРУЕМ В ЦЕЛЕВОЙ ПОЯС ДЛЯ ОПРЕДЕЛЕНИЯ ДАТЫ! var dayInTargetTz = DateOnly.FromDateTime(template.NextRun.Value.ToOffset(targetOffset).Date); //var day = DateOnly.FromDateTime(template.NextRun.Value.Date); if (!templatesByDay.ContainsKey(dayInTargetTz)) { templatesByDay[dayInTargetTz] = new List(); } templatesByDay[dayInTargetTz].Add(template); logger.LogDebug("Шаблон {Id} с NextRun={nextRun} отнесён к дате {day} в поясе {offset}", template.Id, template.NextRun.Value, dayInTargetTz, targetOffset); } } #endregion #region 7. Подсчитываем текущую нагрузку на каждый день var currentDailyLoad = workDays.ToDictionary(day => day, _ => 0); foreach (var kvp in templatesByDay) { if (currentDailyLoad.ContainsKey(kvp.Key)) currentDailyLoad[kvp.Key] = kvp.Value.Count; } #endregion #region 8. Определяем, какие шаблоны нужно оставить, а какие — переназначить 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) { // КОНВЕРТИРУЕМ В UTC ПЕРЕД СОХРАНЕНИЕМ! templatesToKeep.Add(new TemplateNextRunResultDto ( Id: template.Id, NextRun: template.NextRun.Value.ToOffset(TimeSpan.Zero), // ← В UTC! NextRunOld: template.NextRun )); logger.LogDebug("Шаблон {TemplateId} оставлен на дату {NextRun} (UTC), т.к. нагрузка в день не превышена.", template.Id, template.NextRun.Value.ToOffset(TimeSpan.Zero)); } } else { // Оставляем только expectedCount штук, остальные — переназначить for (int i = 0; i < expectedCount; i++) { var template = templates[i]; // КОНВЕРТИРУЕМ В UTC ПЕРЕД СОХРАНЕНИЕМ! templatesToKeep.Add(new TemplateNextRunResultDto ( Id: template.Id, NextRun: template.NextRun.Value.ToOffset(TimeSpan.Zero), // ← В UTC! NextRunOld: template.NextRun )); logger.LogDebug("Шаблон {TemplateId} оставлен на дату {NextRun} (UTC), т.к. нагрузка в день не превышена.", template.Id, template.NextRun.Value.ToOffset(TimeSpan.Zero)); } for (int i = expectedCount; i < currentCount; i++) { var template = templates[i]; templatesToReassign.Add(template); logger.LogDebug("Шаблон {TemplateId} переназначается, т.к. нагрузка в день превышена.", template.Id); } } } #endregion #region 9. Добавляем шаблоны, у которых не было даты — тоже нужно распределить foreach (var template in allTemplates) { if (!template.NextRun.HasValue) { templatesToReassign.Add(template); logger.LogDebug("Шаблон {TemplateId} не имел даты — будет распределён.", template.Id); } } #endregion #region 10. Распределяем шаблоны, которые нужно переназначить, учитывая нагрузку var updatedTemplates = new List(templatesToKeep); foreach (var template in templatesToReassign) { var nextRunInTargetTz = FindNextAvailableDateWithLoad(currentDailyLoad, workDays, referenceDateInTargetTz, expectedDailyLoad, targetOffset); if (nextRunInTargetTz.HasValue) { var day = DateOnly.FromDateTime(nextRunInTargetTz.Value.Date); currentDailyLoad[day]++; // КОНВЕРТИРУЕМ В UTC ПЕРЕД ВОЗВРАТОМ! updatedTemplates.Add(new TemplateNextRunResultDto ( Id: template.Id, NextRun: nextRunInTargetTz.Value.ToOffset(TimeSpan.Zero), // ← В UTC! NextRunOld: template.NextRun )); logger.LogDebug("Шаблону {Id} назначен NextRun: {nextRunTarget} (пояс {offset}) → {nextRunUtc} (UTC). NextRunOld: {NextRunOld}", template.Id, nextRunInTargetTz.Value, targetOffset, nextRunInTargetTz.Value.ToOffset(TimeSpan.Zero), template.NextRun); } else { logger.LogWarning("Не удалось назначить дату для шаблона {TemplateId}", template.Id); // Возвращаем со смещением на несколько лет, так как ошибка // конвертируем в UTC updatedTemplates.Add(FillTemplateResultWithOffsetError(template, _startDate, referenceDateInTargetTz, targetOffset)); } } #endregion // 11. Сортируем и возвращаем результат в UTC var result = updatedTemplates.OrderBy(t => t.NextRun).ToList(); logger.LogDebug("--- Результат распределения ---"); foreach (var item in result) logger.LogDebug("Шаблон {templateId}, NextRun UTC: {NextRun}, NextRunOld UTC: {NextRunOld}", item.Id, item.NextRun, item.NextRunOld); logger.LogInformation("Распределение завершено. Возвращаем {Count} записей в UTC. Изменено: {changedCount}. Остались прежние значения: {oldCount}", result.Count, result.Count(t => t.NextRun != t.NextRunOld), result.Count(t => t.NextRun == t.NextRunOld)); return result; } /// /// Заполнить TemplateNextRunResultDto, если нет рабочих дней со смещением несколько лет /// /// /// private TemplateNextRunResultDto FillTemplateResultWithOffsetError(TemplateNextRunDto template, DateOnly startDate, DateTimeOffset referenceDateInTargetTz, TimeSpan targetOffset) { // Создаём дату в целевом поясе var errorDateInTargetTz = new DateTimeOffset( startDate.AddYears(offsetYearsIfErrorCalcNextRun).Year, startDate.AddYears(offsetYearsIfErrorCalcNextRun).Month, startDate.AddYears(offsetYearsIfErrorCalcNextRun).Day, referenceDateInTargetTz.Hour, referenceDateInTargetTz.Minute, referenceDateInTargetTz.Second, targetOffset); // КОНВЕРТИРУЕМ В UTC ДЛЯ ВОЗВРАТА var errorDateUtc = errorDateInTargetTz.ToOffset(TimeSpan.Zero); logger.LogWarning("Шаблон {Id}: нет рабочих дней в периоде, сдвиг на {years} лет → {nextRun} (UTC)", template.Id, offsetYearsIfErrorCalcNextRun, errorDateUtc); return new TemplateNextRunResultDto( Id: template.Id, NextRun: errorDateUtc, NextRunOld: template.NextRun ); } /// /// Находит следующий день с доступной нагрузкой, учитывая expectedDailyLoad /// /// /// /// /// /// private DateTimeOffset? FindNextAvailableDateWithLoad(Dictionary currentDailyLoad, List workDays, DateTimeOffset referenceDateInTargetTz, Dictionary expectedDailyLoad, TimeSpan targetOffset) { foreach (var day in workDays) { var expected = expectedDailyLoad.GetValueOrDefault(day, 0); var current = currentDailyLoad.GetValueOrDefault(day, 0); if (current < expected) { // Создаём момент времени В ЦЕЛЕВОМ ПОЯСЕ return new DateTimeOffset( day.Year, day.Month, day.Day, referenceDateInTargetTz.Hour, referenceDateInTargetTz.Minute, referenceDateInTargetTz.Second, targetOffset); } } // Если все дни загружены — берём день с минимальной нагрузкой var minDay = currentDailyLoad.MinBy(x => x.Value).Key; return new DateTimeOffset( minDay.Year, minDay.Month, minDay.Day, referenceDateInTargetTz.Hour, referenceDateInTargetTz.Minute, referenceDateInTargetTz.Second, targetOffset); } /// /// Получить план: дни, на которые должны быть назначены шаблоны /// /// /// /// 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; logger.LogDebug("Интевал между шаблонами: {interval}", interval); return interval; } /// /// Получить дату начала распределения с учётом целевого часового пояса. /// Метод возвращает календарную дату (DateOnly), рассчитанную в контексте указанного оффсета. /// /// Стартовая дата (интерпретируется в целевом часовом поясе) /// Момент времени в целевом часовом поясе (после применения оффсета) /// Целевой часовой пояс (оффсет) /// Дата начала распределения private DateOnly GetStartPeriodDate(DateOnly startDate, DateTimeOffset referenceDate, TimeSpan offset) { var dateStart = startDate; // 1. Дата начала не может быть раньше даты из referenceDate (в целевом поясе) var refDateInTargetTz = DateOnly.FromDateTime(referenceDate.Date); if (dateStart < refDateInTargetTz) { logger.LogDebug("Дата начала {startDate} раньше даты из referenceDate в поясе {offset} ({refDate}). Используем {refDate}.", dateStart, offset, refDateInTargetTz, refDateInTargetTz); dateStart = refDateInTargetTz; } // 2. Дата начала не может быть раньше "сегодня" в целевом часовом поясе var todayInTargetTz = DateOnly.FromDateTime(DateTimeOffset.UtcNow.ToOffset(offset).Date); if (dateStart < todayInTargetTz) { logger.LogDebug("Дата начала {startDate} раньше 'сегодня' в поясе {offset} ({today}). Используем {today}.", dateStart, offset, todayInTargetTz, todayInTargetTz); dateStart = todayInTargetTz; } // 3. Если момент времени "начало дня в целевом поясе + время из referenceDate" уже прошёл — добавляем 1 день // ВАЖНО: время берём из самого referenceDate (который уже в целевом поясе!) var dateTimeStart = new DateTimeOffset( dateStart.Year, dateStart.Month, dateStart.Day, referenceDate.Hour, // ← Время из целевого пояса! referenceDate.Minute, referenceDate.Second, offset); // ← Оффсет целевого пояса // Сравниваем абсолютные моменты времени (работает корректно даже с разными оффсетами) if (dateTimeStart < DateTimeOffset.UtcNow) { logger.LogDebug("Момент времени {dateTimeStart} (в поясе {offset}) уже прошёл (сейчас {now}). Добавляем 1 день.", dateTimeStart, offset, DateTimeOffset.UtcNow); dateStart = dateStart.AddDays(1); } logger.LogDebug("Итоговая дата начала распределения в поясе {offset}: {dateStart}", offset, dateStart); return dateStart; } /// /// Получить дату окончания периода распределения. /// Продолжительность включает стартовый день (например, duration=7 означает 7 дней: с 1 по 7 июня). /// /// /// /// private DateOnly GetEndPeriodDate(DateOnly startDate, int duration) { if (duration <= 0) { logger.LogWarning("Некорректная продолжительность периода: {duration}. Используем минимальное значение 1.", duration); duration = 1; } // duration — количество дней, включая startDate // поэтому вычитаем 1, чтобы получить сдвиг var endDate = startDate.AddDays(duration - 1); logger.LogDebug("Расчёт даты окончания периода: startDate={startDate}, duration={duration}, endDate={endDate}", startDate, duration, endDate); return endDate; } /// /// Получить список выходных дней /// /// /// /// public 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(); } /// /// Получить список рабочих дней в диапазоне, исключая выходные и праздники. /// /// /// /// Исключить выходные и праздники /// public 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; } } }