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; /// /// Максимальное количество попыток расширения периода при отсутствии рабочих дней /// private const int maxPeriodExpansions = 2; public TemplateDistributorV2(ILogger logger, IWeekendDayService weekendDayService) { this.logger = logger; this.weekendDayService = weekendDayService; } public async Task GetNextRunForTemplateAsync(DateOnly startDate, int duration, DateTimeOffset referenceDate, TimeSpan? offset, TemplateNextRunDto targetTemplate, List allTemplates, bool excludeWeekends, bool isNew) { // Валидация входных параметров if (duration <= 0) throw new ArgumentException($"Duration must be positive, got {duration}", nameof(duration)); if (allTemplates == null) throw new ArgumentNullException(nameof(allTemplates)); logger.LogInformation("Начинаем проверку/расчёт NextRun для шаблона {templateId}, nextRun={nextRun}. isNew={isNew}, offset={offset}, referenceDate={referenceDate}", targetTemplate.Id, targetTemplate.NextRun, isNew, offset, referenceDate); // 1. Определяем целевой часовой пояс var targetOffset = offset ?? TimeSpan.Zero; var referenceDateInTargetTz = referenceDate.ToOffset(targetOffset); logger.LogDebug("Целевой часовой пояс: {offset}. ReferenceDate в целевом поясе: {refDate}", targetOffset, referenceDateInTargetTz); // 2. Рассчитываем период распределения в целевом поясе var _startDate = GetStartPeriodDate(startDate, referenceDateInTargetTz, targetOffset); var _endDate = GetEndPeriodDate(_startDate, duration); logger.LogInformation("Период распределения: {_startDate} - {_endDate} (в поясе {offset})", _startDate, _endDate, targetOffset); // 3. Получаем список рабочих дней с расширением периода при необходимости var (expandedEndDate, workDays) = await GetWorkDaysWithExpansionAsync(_startDate, duration, excludeWeekends); // Обновляем _endDate на расширенный период для дальнейших расчётов _endDate = expandedEndDate; if (workDays.Count == 0) { logger.LogError("Не удалось найти рабочие дни даже после {maxAttempts} попыток расширения периода [{start}, {end}]. Возвращаем ошибку со смещением.", maxPeriodExpansions, _startDate, _endDate); return FillTemplateResultWithOffsetError(targetTemplate, _startDate, referenceDateInTargetTz, targetOffset); } #region 4. Строим план нагрузки и рассчитываем текущую нагрузку logger.LogDebug("Строим план распределения для шаблона {templateId}. Всего шаблонов: {totalTemplates}", targetTemplate.Id, allTemplates.Count); // Исключаем текущий шаблон из расчёта var templatesForPlan = allTemplates.Where(t => t.Id != targetTemplate.Id).ToList(); logger.LogDebug("Исключили текущий шаблон из расчёта. Осталось {count} шаблонов для плана", templatesForPlan.Count); // Определяем тип плана (обычный или зеркальный) bool useMirrorPlan = false; // Определяем тип плана на основе контекста if (!targetTemplate.NextRun.HasValue) { // Нет текущего NextRun — тип плана зависит от isNew useMirrorPlan = !isNew; logger.LogDebug("Нет текущего NextRun. Тип плана: {planType} (isNew={isNew})", useMirrorPlan ? "зеркальный" : "обычный", isNew); } else { // Есть текущий NextRun — проверяем, в периоде ли он var _nextRunInTargetTz = targetTemplate.NextRun.Value.ToOffset(targetOffset); var _nextRunDate = DateOnly.FromDateTime(_nextRunInTargetTz.Date); bool _isInPeriod = _nextRunDate >= _startDate && _nextRunDate <= _endDate; bool _isWorkDay = workDays.Contains(_nextRunDate); if (_isInPeriod && !_isWorkDay) { // В периоде, но нерабочий день — обычный план useMirrorPlan = false; logger.LogDebug("NextRun в периоде, но на нерабочем дне. Используем обычный план."); } else if (!_isInPeriod) { // Не в периоде — тип плана зависит от isNew useMirrorPlan = !isNew; logger.LogDebug("NextRun вне периода. Тип плана: {planType} (isNew={isNew})", useMirrorPlan ? "зеркальный" : "обычный", isNew); } else { // В периоде и рабочий день — будем проверять актуальность useMirrorPlan = false; logger.LogDebug("NextRun в периоде и на рабочем дне. Используем обычный план для проверки."); } } // Строим план распределения List plan; if (useMirrorPlan) { // Зеркальный план plan = GetDistributionPlan(templatesForPlan.Count + 1, workDays, mirror: true); logger.LogDebug("Построен зеркальный план для {count} шаблонов", templatesForPlan.Count + 1); } else { // Обычный план plan = GetDistributionPlan(templatesForPlan.Count + 1, workDays); logger.LogDebug("Построен обычный план для {count} шаблонов", templatesForPlan.Count + 1); } // Считаем ожидаемую нагрузку из плана var expectedDailyLoad = plan.GroupBy(d => d).ToDictionary(g => g.Key, g => g.Count()); logger.LogDebug("Ожидаемая нагрузка по плану: {loadCount} дней", expectedDailyLoad.Count); foreach (var kvp in expectedDailyLoad.OrderBy(x => x.Key)) logger.LogDebug(" {date}: ожидаемо {count} шаблонов", kvp.Key, kvp.Value); // Считаем текущую нагрузку (только шаблоны внутри периода) var currentDailyLoad = BuildCurrentLoad(templatesForPlan, workDays, _startDate, _endDate, targetOffset); logger.LogDebug("Текущая нагрузка (без учёта текущего шаблона): {loadCount} дней", currentDailyLoad.Count); foreach (var kvp in currentDailyLoad.OrderBy(x => x.Key)) logger.LogDebug(" {date}: текущая {current}, ожидаемая {expected}", kvp.Key, kvp.Value, expectedDailyLoad.GetValueOrDefault(kvp.Key, 0)); #endregion // 5. Случай: нет текущего NextRun if (!targetTemplate.NextRun.HasValue) { logger.LogInformation("Шаблон {templateId} не имеет текущего NextRun. Ищем новое место...", targetTemplate.Id); return HandleNoNextRun( targetTemplate, workDays, currentDailyLoad, expectedDailyLoad, referenceDateInTargetTz, targetOffset, isNew); } #region 6. Случай: есть текущий NextRun — проверяем актуальность logger.LogDebug("Шаблон {templateId} имеет текущий NextRun: {nextRun}. Проверяем актуальность...", targetTemplate.Id, targetTemplate.NextRun.Value); var nextRunInTargetTz = targetTemplate.NextRun.Value.ToOffset(targetOffset); var nextRunDate = DateOnly.FromDateTime(nextRunInTargetTz.Date); logger.LogDebug("NextRun в целевом поясе {offset}: {nextRunTarget} (дата: {date})", targetOffset, nextRunInTargetTz, nextRunDate); // Проверка 1: В периоде? var isInPeriod = nextRunDate >= _startDate && nextRunDate <= _endDate; logger.LogDebug("Проверка 1 (в периоде): {result} ({date} ∈ [{start}, {end}])", isInPeriod, nextRunDate, _startDate, _endDate); // Проверка 2: Рабочий день? var isWorkDay = workDays.Contains(nextRunDate); logger.LogDebug("Проверка 2 (рабочий день): {result}", isWorkDay ? "да" : "нет"); // Проверка 3: В будущем? var nextRunMoment = new DateTimeOffset( nextRunDate.Year, nextRunDate.Month, nextRunDate.Day, nextRunInTargetTz.Hour, nextRunInTargetTz.Minute, nextRunInTargetTz.Second, targetOffset); var isFuture = nextRunMoment >= DateTimeOffset.UtcNow; logger.LogDebug("Проверка 3 (в будущем): {result} ({moment} >= {now})", isFuture, nextRunMoment, DateTimeOffset.UtcNow); // Проверка 4: Время совпадает с referenceDate? var hasMatchingTime = nextRunInTargetTz.Hour == referenceDateInTargetTz.Hour && nextRunInTargetTz.Minute == referenceDateInTargetTz.Minute && nextRunInTargetTz.Second == referenceDateInTargetTz.Second; logger.LogDebug("Проверка 4 (время совпадает): {result} (NextRun: {nextRunHour}:{nextRunMin}:{nextRunSec} vs Reference: {refHour}:{refMin}:{refSec})", hasMatchingTime, nextRunInTargetTz.Hour, nextRunInTargetTz.Minute, nextRunInTargetTz.Second, referenceDateInTargetTz.Hour, referenceDateInTargetTz.Minute, referenceDateInTargetTz.Second); // СЛУЧАЙ А: Дата актуальна, но время не совпадает → корректируем ТОЛЬКО время if (isInPeriod && isWorkDay && isFuture && !hasMatchingTime) { logger.LogDebug("Дата NextRun шаблона {templateId} актуальна, но время не совпадает. Корректируем время с {oldTime} на {newTime} (пояс {offset}).", targetTemplate.Id, $"{nextRunInTargetTz.Hour:D2}:{nextRunInTargetTz.Minute:D2}:{nextRunInTargetTz.Second:D2}", $"{referenceDateInTargetTz.Hour:D2}:{referenceDateInTargetTz.Minute:D2}:{referenceDateInTargetTz.Second:D2}", targetOffset); // Сохраняем ту же дату, но меняем время на время из referenceDate var correctedNextRunInTargetTz = new DateTimeOffset( nextRunDate.Year, nextRunDate.Month, nextRunDate.Day, referenceDateInTargetTz.Hour, referenceDateInTargetTz.Minute, referenceDateInTargetTz.Second, targetOffset); //скорректированный момент ещё в будущем? if (correctedNextRunInTargetTz >= DateTimeOffset.UtcNow) { // Время актуально — возвращаем скорректированный NextRun var correctedNextRunUtc = correctedNextRunInTargetTz.ToOffset(TimeSpan.Zero); logger.LogInformation("Скорректированный NextRun для шаблона {templateId}: {nextRunTarget} (пояс {offset}) → {nextRunUtc} (UTC). NextRunOld: {nextRunOld}", targetTemplate.Id, correctedNextRunInTargetTz, targetOffset, correctedNextRunUtc, targetTemplate.NextRun); return new TemplateNextRunResultDto( Id: targetTemplate.Id, NextRun: correctedNextRunUtc, NextRunOld: targetTemplate.NextRun); } else { // Скорректированное время уже прошло — считаем дату НЕАКТУАЛЬНОЙ logger.LogDebug("Скорректированное время {correctedTime} уже прошло (сейчас {now}). Считаем дату неактуальной и передаём в основную логику поиска.", correctedNextRunInTargetTz, DateTimeOffset.UtcNow); // Помечаем дату как неактуальную по времени isFuture = false; // Дальнейшая логика сама вызовет HandleOutdatedNextRun с правильными параметрами } } // СЛУЧАЙ Б: Все проверки пройдены (включая время) → возвращаем без изменений if (isInPeriod && isWorkDay && isFuture && hasMatchingTime) { var nextRunUtc = targetTemplate.NextRun.Value.ToOffset(TimeSpan.Zero); logger.LogInformation("NextRun шаблона {templateId} полностью актуален. Возвращаем без изменений: {nextRunUtc} (UTC)", targetTemplate.Id, nextRunUtc); return new TemplateNextRunResultDto( Id: targetTemplate.Id, NextRun: nextRunUtc, NextRunOld: targetTemplate.NextRun); } // СЛУЧАЙ В: Дата неактуальна (не в периоде / нерабочий день / уже прошло) → ищем новое место logger.LogDebug("NextRun шаблона {templateId} неактуален по дате (в периоде={inPeriod}, рабочий={workDay}, будущее={future}). Ищем новое место...", targetTemplate.Id, isInPeriod, isWorkDay, isFuture); #endregion // 7. NextRun неактуален — ищем новое место logger.LogDebug("NextRun шаблона {templateId} неактуален (в периоде={inPeriod}, рабочий={workDay}, будущее={future}). Ищем новое место...", targetTemplate.Id, isInPeriod, isWorkDay, isFuture); return HandleOutdatedNextRun( targetTemplate, nextRunDate, isInPeriod, isWorkDay, isNew, workDays, currentDailyLoad, expectedDailyLoad, referenceDateInTargetTz, targetOffset, _startDate, _endDate); } /// /// Распределить шаблоны /// /// /// /// /// /// /// /// public async Task> DistributeTemplatesAsync(DateOnly startDate, int duration, DateTimeOffset referenceDate, TimeSpan? offset, List allTemplates, bool excludeWeekends) { // Валидация входных параметров if (duration <= 0) throw new ArgumentException($"Duration must be positive, got {duration}", nameof(duration)); if (allTemplates == null) throw new ArgumentNullException(nameof(allTemplates)); // 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}. ReferenceDateInTargetTz: {referenceDateInTargetTz} в целевом поясе: {targetOffset} ReferenceDate UTC: {referenceDate}. Исключить выходные и праздники: {excludeWeekends}.", allTemplates.Count, duration, _startDate, _endDate, referenceDateInTargetTz, targetOffset, referenceDate, excludeWeekends); // 4. Получаем рабочие дни с расширением периода при необходимости var (expandedEndDate, workDays) = await GetWorkDaysWithExpansionAsync(_startDate, duration, excludeWeekends); // Обновляем _endDate на расширенный период для дальнейших расчётов _endDate = expandedEndDate; if (workDays.Count == 0) { logger.LogError("Не удалось найти рабочие дни даже после {maxAttempts} попыток расширения периода [{start}, {end}]. Возвращаем шаблоны со смещением {years} лет.", maxPeriodExpansions, _startDate, _endDate, offsetYearsIfErrorCalcNextRun); var errorResult = allTemplates.Select(t => FillTemplateResultWithOffsetError(t, _startDate, referenceDateInTargetTz, targetOffset) ).OrderBy(t => t.NextRun).ToList(); logger.LogInformation("Распределение завершено с ошибкой. Возвращаем {Count} записей.", errorResult.Count); return errorResult; } // 5. Строим план распределения // строим план, дни на которые должны быть назанчены шаблоны var plan = GetDistributionPlan(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) { // Конвертируем текущий NextRun в целевой пояс для проверки времени var nextRunInTargetTz = template.NextRun.Value.ToOffset(targetOffset); var nextRunDate = DateOnly.FromDateTime(nextRunInTargetTz.Date); // Проверяем, совпадает ли время с referenceDate var hasMatchingTime = nextRunInTargetTz.Hour == referenceDateInTargetTz.Hour && nextRunInTargetTz.Minute == referenceDateInTargetTz.Minute && nextRunInTargetTz.Second == referenceDateInTargetTz.Second; DateTimeOffset newNextRun; if (hasMatchingTime) { // Время совпадает — оставляем как есть (только конвертируем в чистый UTC) newNextRun = template.NextRun.Value.ToOffset(TimeSpan.Zero); logger.LogDebug("Шаблон {TemplateId} оставлен без изменений (время совпадает): {NextRun} (UTC)", template.Id, newNextRun); } else { // Время НЕ совпадает — корректируем ТОЛЬКО время, сохраняя ту же дату var correctedNextRunInTargetTz = new DateTimeOffset( nextRunDate.Year, nextRunDate.Month, nextRunDate.Day, referenceDateInTargetTz.Hour, referenceDateInTargetTz.Minute, referenceDateInTargetTz.Second, targetOffset); // Проверяем, что скорректированный момент ещё в будущем if (correctedNextRunInTargetTz >= DateTimeOffset.UtcNow) { newNextRun = correctedNextRunInTargetTz.ToOffset(TimeSpan.Zero); logger.LogDebug("Шаблон {TemplateId} оставлен на дате {date}, время скорректировано с {oldTime} на {newTime} (UTC): {NextRun}", template.Id, nextRunDate, $"{nextRunInTargetTz.Hour:D2}:{nextRunInTargetTz.Minute:D2}:{nextRunInTargetTz.Second:D2}", $"{referenceDateInTargetTz.Hour:D2}:{referenceDateInTargetTz.Minute:D2}:{referenceDateInTargetTz.Second:D2}", newNextRun); } else { // Скорректированное время уже прошло — переназначаем шаблон templatesToReassign.Add(template); logger.LogDebug("Шаблон {TemplateId} переназначается, т.к. скорректированное время {correctedTime} уже прошло", template.Id, correctedNextRunInTargetTz); continue; } } templatesToKeep.Add(new TemplateNextRunResultDto( Id: template.Id, NextRun: newNextRun, NextRunOld: template.NextRun )); } } else { // Оставляем только expectedCount штук, остальные — переназначить // (для оставляемых тоже нужно проверить/скорректировать время) for (int i = 0; i < expectedCount; i++) { var template = templates[i]; // Конвертируем текущий NextRun в целевой пояс для проверки времени var nextRunInTargetTz = template.NextRun.Value.ToOffset(targetOffset); var nextRunDate = DateOnly.FromDateTime(nextRunInTargetTz.Date); var hasMatchingTime = nextRunInTargetTz.Hour == referenceDateInTargetTz.Hour && nextRunInTargetTz.Minute == referenceDateInTargetTz.Minute && nextRunInTargetTz.Second == referenceDateInTargetTz.Second; DateTimeOffset newNextRun; if (hasMatchingTime) { newNextRun = template.NextRun.Value.ToOffset(TimeSpan.Zero); logger.LogDebug("Шаблон {TemplateId} (часть перегрузки) оставлен без изменений (время совпадает): {NextRun} (UTC)", template.Id, newNextRun); } else { var correctedNextRunInTargetTz = new DateTimeOffset( nextRunDate.Year, nextRunDate.Month, nextRunDate.Day, referenceDateInTargetTz.Hour, referenceDateInTargetTz.Minute, referenceDateInTargetTz.Second, targetOffset); if (correctedNextRunInTargetTz >= DateTimeOffset.UtcNow) { newNextRun = correctedNextRunInTargetTz.ToOffset(TimeSpan.Zero); logger.LogDebug("Шаблон {TemplateId} (часть перегрузки) оставлен на дате {date}, время скорректировано: {NextRun}", template.Id, nextRunDate, newNextRun); } else { templatesToReassign.Add(template); logger.LogDebug("Шаблон {TemplateId} (часть перегрузки) переназначается, т.к. скорректированное время уже прошло", template.Id); continue; } } templatesToKeep.Add(new TemplateNextRunResultDto( Id: template.Id, NextRun: newNextRun, NextRunOld: template.NextRun )); } 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; } /// /// Обрабатывает случай, когда у шаблона нет текущего NextRun. /// private TemplateNextRunResultDto HandleNoNextRun(TemplateNextRunDto targetTemplate, List workDays, Dictionary currentDailyLoad, Dictionary expectedDailyLoad, DateTimeOffset referenceDateInTargetTz, TimeSpan targetOffset, bool isNew) { logger.LogDebug("Обработка случая 'нет текущего NextRun' для шаблона {templateId} (isNew={isNew})", targetTemplate.Id, isNew); DateOnly? foundDay = null; if (isNew) { // Ищем от начала периода к концу logger.LogDebug("isNew=true: ищем от начала периода к концу"); foreach (var day in workDays) { if (currentDailyLoad[day] < expectedDailyLoad.GetValueOrDefault(day, 0)) { foundDay = day; logger.LogDebug("Нашли первый не перегруженный день: {day}", day); break; } } if (!foundDay.HasValue) { // Не нашли свободного места — берём первый день периода foundDay = workDays.First(); logger.LogWarning("Все дни перегружены. Берём первый день периода: {day}", foundDay.Value); } } else { // Ищем от конца периода к началу logger.LogDebug("isNew=false: ищем от конца периода к началу"); foreach (var day in workDays.AsEnumerable().Reverse()) { if (currentDailyLoad[day] < expectedDailyLoad.GetValueOrDefault(day, 0)) { foundDay = day; logger.LogDebug("Нашли первый не перегруженный день: {day}", day); break; } } if (!foundDay.HasValue) { // Не нашли свободного места — берём последний день периода foundDay = workDays.Last(); logger.LogWarning("Все дни перегружены. Берём последний день периода: {day}", foundDay.Value); } } // Формируем результат var nextRunInTargetTz = new DateTimeOffset( foundDay.Value.Year, foundDay.Value.Month, foundDay.Value.Day, referenceDateInTargetTz.Hour, referenceDateInTargetTz.Minute, referenceDateInTargetTz.Second, targetOffset); // Корректируем найденный день, чтобы момент времени был в будущем DateTimeOffset? adjustedNextRun; TemplateNextRunResultDto? errorResult = null; adjustedNextRun = AdjustNextRunToFuture( foundDay.Value, workDays, currentDailyLoad, expectedDailyLoad, referenceDateInTargetTz, targetOffset, workDays.First(), // startDateForError для случая ошибки targetTemplate, out errorResult); if (errorResult != null) return errorResult; //var nextRunUtc = nextRunInTargetTz.ToOffset(TimeSpan.Zero); var nextRunUtc = adjustedNextRun.Value.ToOffset(TimeSpan.Zero); logger.LogInformation("Найдено новое место для шаблона {templateId}: {nextRunTarget} (пояс {offset}) → {nextRunUtc} (UTC)", targetTemplate.Id, nextRunInTargetTz, targetOffset, nextRunUtc); return new TemplateNextRunResultDto( Id: targetTemplate.Id, NextRun: nextRunUtc, NextRunOld: targetTemplate.NextRun); } /// /// Обрабатывает случай, когда текущий NextRun неактуален. /// private TemplateNextRunResultDto HandleOutdatedNextRun(TemplateNextRunDto targetTemplate, DateOnly nextRunDate, bool isInPeriod, bool isWorkDay, bool isNew, List workDays, Dictionary currentDailyLoad, Dictionary expectedDailyLoad, DateTimeOffset referenceDateInTargetTz, TimeSpan targetOffset, DateOnly periodStart, DateOnly periodEnd) { logger.LogDebug("Обработка случая 'неактуальный NextRun' для шаблона {templateId}", targetTemplate.Id); DateOnly? foundDay = null; if (isInPeriod && !isWorkDay) { // СЦЕНАРИЙ А: В периоде, но нерабочий день logger.LogDebug("Сценарий А: в периоде ({date}), но нерабочий день. Ищем слева...", nextRunDate); // Вычисляем максимальную нагрузку по всему периоду var maxLoadInPeriod = expectedDailyLoad.Values.Max(); logger.LogDebug("Максимальная нагрузка по периоду: {maxLoad}", maxLoadInPeriod); // Берём все рабочие дни СТРОГО ЛЕВЕЕ исходной даты var leftDays = workDays .Where(d => d < nextRunDate) .OrderByDescending(d => d) // Ближайший к исходной дате — первый .ToList(); if (leftDays.Any()) { logger.LogDebug("Слева найдено {count} рабочих дней. Ищем первый не перегруженный...", leftDays.Count); // Ищем первый день где текущая_нагрузка < максимальная_нагрузка_периода foreach (var day in leftDays) { if (currentDailyLoad[day] < maxLoadInPeriod) // ← КЛЮЧЕВОЕ ИЗМЕНЕНИЕ { foundDay = day; logger.LogDebug("Нашли слева день с допустимой нагрузкой (< {maxLoad}): {day}", maxLoadInPeriod, day); break; } } //// Ищем первый не перегруженный //foreach (var day in leftDays) //{ // if (currentDailyLoad[day] < expectedDailyLoad.GetValueOrDefault(day, 0)) // { // foundDay = day; // logger.LogDebug("Нашли слева не перегруженный день: {day}", day); // break; // } //} // Если не нашли — берём ближайший слева if (!foundDay.HasValue) { foundDay = leftDays.First(); logger.LogWarning("Все дни слева перегружены. Берём ближайший: {day}", foundDay.Value); } } else { // Слева нет рабочих дней — берём ближайший справа var rightDays = workDays .Where(d => d > nextRunDate) .OrderBy(d => d) // Ближайший к исходной дате — первый .ToList(); if (rightDays.Any()) { foundDay = rightDays.First(); logger.LogDebug("Слева нет рабочих дней. Берём ближайший справа: {day}", foundDay.Value); } else { // Совсем нет рабочих дней — ошибка logger.LogError("Нет рабочих дней ни слева, ни справа от {date}. Возвращаем ошибку.", nextRunDate); return FillTemplateResultWithOffsetError(targetTemplate, periodStart, referenceDateInTargetTz, targetOffset); } } } else if (!isInPeriod) { // СЦЕНАРИЙ Б: Не в периоде logger.LogDebug("Сценарий Б: не в периоде ({date}). Ищем в зависимости от isNew...", nextRunDate); if (isNew) { // Ищем от начала периода к концу (обычный план) logger.LogDebug("isNew=true: ищем от начала периода к концу"); foreach (var day in workDays) { if (currentDailyLoad[day] < expectedDailyLoad.GetValueOrDefault(day, 0)) { foundDay = day; logger.LogDebug("Нашли первый не перегруженный день: {day}", day); break; } } if (!foundDay.HasValue) { foundDay = workDays.First(); logger.LogWarning("Все дни перегружены. Берём первый день периода: {day}", foundDay.Value); } } else { // Ищем от конца периода к началу (зеркальный план) logger.LogDebug("isNew=false: ищем от конца периода к началу"); foreach (var day in workDays.AsEnumerable().Reverse()) { if (currentDailyLoad[day] < expectedDailyLoad.GetValueOrDefault(day, 0)) { foundDay = day; logger.LogDebug("Нашли первый не перегруженный день: {day}", day); break; } } if (!foundDay.HasValue) { foundDay = workDays.Last(); logger.LogWarning("Все дни перегружены. Берём последний день периода: {day}", foundDay.Value); } } } else { // Другие случаи (например, время уже прошло) logger.LogDebug("NextRun неактуален по другим причинам. Используем логику в зависимости от isNew..."); if (isNew) { foreach (var day in workDays) { if (currentDailyLoad[day] < expectedDailyLoad.GetValueOrDefault(day, 0)) { foundDay = day; break; } } foundDay ??= workDays.First(); } else { foreach (var day in workDays.AsEnumerable().Reverse()) { if (currentDailyLoad[day] < expectedDailyLoad.GetValueOrDefault(day, 0)) { foundDay = day; break; } } foundDay ??= workDays.Last(); } } // Формируем результат var nextRunInTargetTz = new DateTimeOffset( foundDay.Value.Year, foundDay.Value.Month, foundDay.Value.Day, referenceDateInTargetTz.Hour, referenceDateInTargetTz.Minute, referenceDateInTargetTz.Second, targetOffset); // Корректируем найденный день, чтобы момент времени был в будущем DateTimeOffset? adjustedNextRun; TemplateNextRunResultDto? errorResult = null; adjustedNextRun = AdjustNextRunToFuture( foundDay.Value, workDays, currentDailyLoad, expectedDailyLoad, referenceDateInTargetTz, targetOffset, periodStart, // startDateForError для случая ошибки targetTemplate, out errorResult); if (errorResult != null) return errorResult; //var nextRunUtc = nextRunInTargetTz.ToOffset(TimeSpan.Zero); var nextRunUtc = adjustedNextRun.Value.ToOffset(TimeSpan.Zero); logger.LogInformation("Найдено новое место для шаблона {templateId}: {nextRunTarget} (пояс {offset}) → {nextRunUtc} (UTC). NextRunOld: {nextRunOld}", targetTemplate.Id, nextRunInTargetTz, targetOffset, nextRunUtc, targetTemplate.NextRun); return new TemplateNextRunResultDto( Id: targetTemplate.Id, NextRun: nextRunUtc, NextRunOld: targetTemplate.NextRun); } /// /// Корректирует найденный день, чтобы момент времени был в будущем. /// Если момент времени уже прошёл, ищет следующий рабочий день в будущем с учётом нагрузки. /// В случае ошибки (нет будущих рабочих дней) возвращает ошибку через out-параметр. /// private DateTimeOffset? AdjustNextRunToFuture(DateOnly foundDay, List workDays, Dictionary currentDailyLoad, Dictionary expectedDailyLoad, DateTimeOffset referenceDateInTargetTz, TimeSpan targetOffset, DateOnly startDateForError, TemplateNextRunDto targetTemplate, out TemplateNextRunResultDto? errorResult) { errorResult = null; // Создаём момент времени для найденного дня var nextRunInTargetTz = new DateTimeOffset( foundDay.Year, foundDay.Month, foundDay.Day, referenceDateInTargetTz.Hour, referenceDateInTargetTz.Minute, referenceDateInTargetTz.Second, targetOffset); // ФИНАЛЬНАЯ ПРОВЕРКА: момент времени должен быть в будущем if (nextRunInTargetTz < DateTimeOffset.UtcNow) { logger.LogWarning("Найденный момент времени {nextRun} уже прошёл (сейчас {now}). Ищем следующий рабочий день в будущем.", nextRunInTargetTz, DateTimeOffset.UtcNow); // Берём все рабочие дни СТРОГО ПОСЛЕ найденного дня var futureWorkDays = workDays.Where(d => d > foundDay).ToList(); if (futureWorkDays.Any()) { // Ищем первый день с допустимой нагрузкой (текущая < ожидаемая) var notOverloadedDays = futureWorkDays .Where(d => currentDailyLoad[d] < expectedDailyLoad.GetValueOrDefault(d, 0)) .ToList(); var futureDay = notOverloadedDays.Any() ? notOverloadedDays.First() : futureWorkDays.First(); logger.LogDebug("Выбран следующий рабочий день в будущем: {day}", futureDay); // Пересоздаём момент времени с новой датой nextRunInTargetTz = new DateTimeOffset( futureDay.Year, futureDay.Month, futureDay.Day, referenceDateInTargetTz.Hour, referenceDateInTargetTz.Minute, referenceDateInTargetTz.Second, targetOffset); } else { // Нет будущих рабочих дней — ошибка logger.LogError("Нет будущих рабочих дней после {date} в периоде. Возвращаем ошибку со смещением.", foundDay); errorResult = FillTemplateResultWithOffsetError(targetTemplate, startDateForError, referenceDateInTargetTz, targetOffset); return null; } } else { logger.LogDebug("Момент времени {nextRun} актуален (в будущем). Коррекция не требуется.", nextRunInTargetTz); } return nextRunInTargetTz; } /// /// Строит текущую нагрузку на дни, учитывая ТОЛЬКО шаблоны внутри периода. /// Шаблоны вне периода игнорируются. /// private Dictionary BuildCurrentLoad(List templates, List workDays, DateOnly periodStart, DateOnly periodEnd, TimeSpan targetOffset) { var load = workDays.ToDictionary(day => day, _ => 0); foreach (var template in templates) { if (!template.NextRun.HasValue) continue; // Конвертируем в целевой пояс для определения даты var templateDate = DateOnly.FromDateTime(template.NextRun.Value.ToOffset(targetOffset).Date); // Учитываем ТОЛЬКО шаблоны внутри периода if (templateDate >= periodStart && templateDate <= periodEnd) { if (load.ContainsKey(templateDate)) load[templateDate]++; } } return load; } /// /// Заполнить 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); } /// /// Получить план распределения шаблонов по рабочим дням. /// /// Общее количество шаблонов для распределения /// Список рабочих дней в периоде /// Если true — строит зеркальный план (справа налево от конца периода), иначе обычный (слева направо от начала периода) /// Список дат, на которые должны быть назначены шаблоны private List GetDistributionPlan(int totalWorks, List workDays, bool mirror = false) { var totalDays = workDays.Count; var interval = CalculateDistributionInterval(totalDays, totalWorks); var plan = new List(); for (int i = 0; i < totalWorks; i++) { // Базовый индекс для обычного плана (слева → направо) var baseIndex = (int)Math.Floor(i * interval); // Применяем зеркалирование, если нужно var index = mirror ? totalDays - 1 - baseIndex // Зеркальный индекс: от конца периода : baseIndex; // Обычный индекс: от начала периода // Защита от выхода за границы массива index = Math.Max(0, Math.Min(index, totalDays - 1)); plan.Add(workDays[index]); } // Логгирование var planType = mirror ? "Зеркальный" : "Обычный"; logger.LogDebug("--- {planType} план распределения ({totalWorks} шаблонов, {totalDays} раб. дней) ---", planType, totalWorks, totalDays); foreach (var item in plan.GroupBy(t => t).OrderBy(g => g.Key)) logger.LogDebug("Дата: {date}, кол-во работ: {workCount}", item.Key, item.Count()); return plan; } /// /// Рассчитать интервал между шаблонами /// /// Кол-во рабочих дней /// Кол-во шаблонов /// private double CalculateDistributionInterval(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(); } /// /// Получить список рабочих дней в диапазоне, исключая выходные и праздники. /// /// /// /// Исключить выходные и праздники /// 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; } /// /// Получает рабочие дни с автоматическим расширением периода при их отсутствии. /// Расширяет период до maxPeriodExpansions раз: _startDate + duration * N (N = 1, 2, 3) /// private async Task<(DateOnly endDate, List workDays)> GetWorkDaysWithExpansionAsync(DateOnly startDate, int duration, bool excludeWeekends) { // Попытка 0: исходный период [_startDate, _startDate + duration - 1] var currentEndDate = startDate.AddDays(duration - 1); var workDays = await GetWorkDaysAsync(startDate, currentEndDate, excludeWeekends); if (workDays.Count > 0) { logger.LogDebug("Найдено {count} рабочих дней в исходном периоде [{start}, {end}]", workDays.Count, startDate, currentEndDate); return (currentEndDate, workDays); } // Попытки расширения 1..maxPeriodExpansions for (int attempt = 1; attempt <= maxPeriodExpansions; attempt++) { //расширяем период относительно ПРЕДЫДУЩЕГО конца currentEndDate = currentEndDate.AddDays(duration); logger.LogWarning("Попытка {attempt}/{maxAttempts} расширения периода: нет рабочих дней в [{start}, {end}]. Расширяем до {newEnd}.", attempt, maxPeriodExpansions, startDate, startDate.AddDays(duration - 1), currentEndDate); workDays = await GetWorkDaysAsync(startDate, currentEndDate, excludeWeekends); if (workDays.Count > 0) { logger.LogInformation("Рабочие дни найдены после {attempt} попытки расширения. Период: [{start}, {end}], кол-во рабочих дней: {count}", attempt, startDate, currentEndDate, workDays.Count); return (currentEndDate, workDays); } } // Все попытки исчерпаны — рабочих дней нет logger.LogError("После {maxAttempts} попыток расширения рабочие дни не найдены в периоде [{start}, {end}]. Возвращаем ошибку.", maxPeriodExpansions, startDate, currentEndDate); return (currentEndDate, new List()); // Пустой список для обработки ошибки вызывающим кодом } } }