diff --git a/PARR.DAL/NextRunServices/Subservices/IEsppScheduleTransformService.cs b/PARR.DAL/NextRunServices/Subservices/IEsppScheduleTransformService.cs index fc4e7d75..b7b5f1fd 100644 --- a/PARR.DAL/NextRunServices/Subservices/IEsppScheduleTransformService.cs +++ b/PARR.DAL/NextRunServices/Subservices/IEsppScheduleTransformService.cs @@ -1,9 +1,10 @@ namespace PARR.DAL.NextRunServices.Subservices { + //todo: public->internal /// /// Сервис трансформации расписания ЕСПП в дату/расписание /// - internal interface IEsppScheduleTransformService + public interface IEsppScheduleTransformService { /// /// Получить следующую дату по jobGroupId diff --git a/PARR.DAL/NextRunServices/Subservices/ITemplateDistributorV2.cs b/PARR.DAL/NextRunServices/Subservices/ITemplateDistributorV2.cs index 98137dd8..53aeb825 100644 --- a/PARR.DAL/NextRunServices/Subservices/ITemplateDistributorV2.cs +++ b/PARR.DAL/NextRunServices/Subservices/ITemplateDistributorV2.cs @@ -16,7 +16,21 @@ namespace PARR.DAL.NextRunServices.Subservices /// /// Task> DistributeTemplatesAsync(DateOnly startDate, int duration, DateTimeOffset referenceDate, TimeSpan? offset, List allTemplates, bool excludeWeekends); - + + /// + /// Метод проверяет, актуален ли текущий NextRun шаблона, если не актуален, возвращает актуальное значение + /// + /// + /// + /// + /// + /// + /// + /// + /// + /// + Task GetNextRunForTemplateAsync(DateOnly startDate, int duration, DateTimeOffset referenceDate, TimeSpan? offset, TemplateNextRunDto targetTemplate, List allTemplates, bool excludeWeekends, bool isNew); + /// /// Получить список выходных дней /// @@ -24,7 +38,7 @@ namespace PARR.DAL.NextRunServices.Subservices /// /// Task> GetWeekendsAsync(DateOnly startDate, DateOnly endDate); - + /// /// Получить список рабочих дней /// diff --git a/PARR.DAL/NextRunServices/Subservices/TemplateDistributorV2.cs b/PARR.DAL/NextRunServices/Subservices/TemplateDistributorV2.cs index d9f76269..c640b2ad 100644 --- a/PARR.DAL/NextRunServices/Subservices/TemplateDistributorV2.cs +++ b/PARR.DAL/NextRunServices/Subservices/TemplateDistributorV2.cs @@ -29,6 +29,255 @@ namespace PARR.DAL.NextRunServices.Subservices this.weekendDayService = weekendDayService; } + + + public async Task GetNextRunForTemplateAsync(DateOnly startDate, int duration, DateTimeOffset referenceDate, TimeSpan? offset, TemplateNextRunDto targetTemplate, List allTemplates, bool excludeWeekends, bool isNew) + { + 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 workDays = await GetWorkDaysAsync(_startDate, _endDate, excludeWeekends); + + if (workDays.Count == 0) + { + logger.LogWarning("Нет рабочих дней в периоде {_startDate} - {_endDate} (пояс {offset}). Возвращаем ошибку со смещением.", _startDate, _endDate, targetOffset); + + 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) { // 1. Определяем целевой часовой пояс @@ -65,7 +314,7 @@ namespace PARR.DAL.NextRunServices.Subservices // 5. Строим план распределения // строим план, дни на которые должны быть назанчены шаблоны - var plan = GetPlan(allTemplates.Count, workDays); + var plan = GetDistributionPlan(allTemplates.Count, workDays); // Подсчитываем, сколько работ должно быть на каждый день var expectedDailyLoad = plan.GroupBy(d => d) .ToDictionary(g => g.Key, g => g.Count()); @@ -233,6 +482,383 @@ namespace PARR.DAL.NextRunServices.Subservices } + /// + /// Обрабатывает случай, когда у шаблона нет текущего 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 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] < 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, если нет рабочих дней со смещением несколько лет @@ -302,42 +928,41 @@ namespace PARR.DAL.NextRunServices.Subservices } - - - - /// - /// Получить план: дни, на которые должны быть назначены шаблоны + /// Получить план распределения шаблонов по рабочим дням. /// - /// - /// - /// - private List GetPlan(int totalWorks, List workDays) + /// Общее количество шаблонов для распределения + /// Список рабочих дней в периоде + /// Если true — строит зеркальный план (справа налево от конца периода), иначе обычный (слева направо от начала периода) + /// Список дат, на которые должны быть назначены шаблоны + private List GetDistributionPlan(int totalWorks, List workDays, bool mirror = false) { - var totalDays = workDays.Count(); - - var interval = GetInterval(totalDays, totalWorks); + var totalDays = workDays.Count; + var interval = CalculateDistributionInterval(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); + // Базовый индекс для обычного плана (слева → направо) + var baseIndex = (int)Math.Floor(i * interval); - // Защита от возможного выхода за границы массива из-за округления - if (dayIndex >= totalDays) - dayIndex = totalDays - 1; - #endregion + // Применяем зеркалирование, если нужно + var index = mirror + ? totalDays - 1 - baseIndex // Зеркальный индекс: от конца периода + : baseIndex; // Обычный индекс: от начала периода - var date = workDays[dayIndex]; - plan.Add(date); + // Защита от выхода за границы массива + index = Math.Max(0, Math.Min(index, totalDays - 1)); + + plan.Add(workDays[index]); } - logger.LogDebug("--- План распределения ---"); - foreach (var item in plan.GroupBy(t => t)) + // Логгирование + 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; @@ -350,11 +975,11 @@ namespace PARR.DAL.NextRunServices.Subservices /// Кол-во рабочих дней /// Кол-во шаблонов /// - private double GetInterval(int totalDays, int totalWorks) + private double CalculateDistributionInterval(int totalDays, int totalWorks) { var interval = (double)totalDays / totalWorks; - logger.LogDebug("Интевал между шаблонами: {interval}", interval); + logger.LogDebug("Интервал распределения: {interval} (дней на шаблон)", interval); return interval; } @@ -490,6 +1115,5 @@ namespace PARR.DAL.NextRunServices.Subservices return result; } - } } diff --git a/PARR.Test/NextRun/NextRunTest.cs b/PARR.Test/NextRun/NextRunTest.cs index 377468d1..7b852db3 100644 --- a/PARR.Test/NextRun/NextRunTest.cs +++ b/PARR.Test/NextRun/NextRunTest.cs @@ -4,6 +4,7 @@ using PARR.DAL.NextRunServices; using PARR.DAL.NextRunServices.Models; using PARR.DAL.NextRunServices.Subservices; using PARR.DAL.Services.Interfaces; +using System.ComponentModel.DataAnnotations; using System.Threading.Tasks; namespace PARR.Test.NextRun @@ -39,11 +40,17 @@ namespace PARR.Test.NextRun var offset = TimeSpan.FromHours(10); var startDate = new DateOnly(2026, 2, 10); - var referenceDate = new DateTimeOffset(2026, 2, 10, 4, 0, 0, TimeSpan.Zero); + var referenceDate = new DateTimeOffset(2026, 2, 10, 6, 0, 0, TimeSpan.Zero); var templates = GetTemplates(); + var templatesEmpty = new List(); - var distributedTemplates = await templateDistributorV2.DistributeTemplatesAsync(startDate, duration, referenceDate, offset, templates, true); + // распределить шаблоны + //var distributedTemplates = await templateDistributorV2.DistributeTemplatesAsync(startDate, duration, referenceDate, offset, templates, true); + + // получить актуальный nextRun + var targetTemplate = new TemplateNextRunDto(Guid.Parse("DA151719-2742-4260-BFFE-012B61591053"), new DateTimeOffset(2026, 2, 14, 5, 0, 0, TimeSpan.Zero)); + var validNextRun = await templateDistributorV2.GetNextRunForTemplateAsync(startDate, duration, referenceDate, offset, targetTemplate, templatesEmpty, true, isNew: false); //var template = await templateService.Get().Include(t => t.Job).ThenInclude(t => t.Group).FirstAsync(t => t.Name == "ЭИТИ-ПАРР_ЦКИТ-ГВЦ_СХД ТО-1_СХД-AERODISK-432-2-4-U26-EN4SAG022-ГВЦ");