diff --git a/PARR.DAL/NextRunServices/Subservices/ITemplateDistributorV2.cs b/PARR.DAL/NextRunServices/Subservices/ITemplateDistributorV2.cs index 53aeb825..b98dbdc9 100644 --- a/PARR.DAL/NextRunServices/Subservices/ITemplateDistributorV2.cs +++ b/PARR.DAL/NextRunServices/Subservices/ITemplateDistributorV2.cs @@ -19,6 +19,7 @@ namespace PARR.DAL.NextRunServices.Subservices /// /// Метод проверяет, актуален ли текущий NextRun шаблона, если не актуален, возвращает актуальное значение + /// При проверке загружаются шаблоны из БД! Если хотим рассчитывать nextRun для НЕСКОЛЬКИХ шаблонов, то следует рассчитать nextRun для одного шаблона, сохранить в БД, затем рассчитать для следующего /// /// /// @@ -38,14 +39,5 @@ namespace PARR.DAL.NextRunServices.Subservices /// /// Task> GetWeekendsAsync(DateOnly startDate, DateOnly endDate); - - /// - /// Получить список рабочих дней - /// - /// - /// - /// - /// - Task> GetWorkDaysAsync(DateOnly start, DateOnly end, bool excludeWeekends); } } diff --git a/PARR.DAL/NextRunServices/Subservices/TemplateDistributorV2.cs b/PARR.DAL/NextRunServices/Subservices/TemplateDistributorV2.cs index 88efad7f..f22b34fe 100644 --- a/PARR.DAL/NextRunServices/Subservices/TemplateDistributorV2.cs +++ b/PARR.DAL/NextRunServices/Subservices/TemplateDistributorV2.cs @@ -20,19 +20,27 @@ namespace PARR.DAL.NextRunServices.Subservices /// private const int offsetYearsIfErrorCalcNextRun = 5; - public TemplateDistributorV2( - ILogger logger, - IWeekendDayService weekendDayService - ) + /// + /// Максимальное количество попыток расширения периода при отсутствии рабочих дней + /// + 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. Определяем целевой часовой пояс @@ -48,13 +56,15 @@ namespace PARR.DAL.NextRunServices.Subservices logger.LogInformation("Период распределения: {_startDate} - {_endDate} (в поясе {offset})", _startDate, _endDate, targetOffset); - // 3. Получаем список рабочих дней в периоде - var workDays = await GetWorkDaysAsync(_startDate, _endDate, excludeWeekends); + // 3. Получаем список рабочих дней с расширением периода при необходимости + var (expandedEndDate, workDays) = await GetWorkDaysWithExpansionAsync(_startDate, duration, excludeWeekends); + + // Обновляем _endDate на расширенный период для дальнейших расчётов + _endDate = expandedEndDate; if (workDays.Count == 0) { - logger.LogWarning("Нет рабочих дней в периоде {_startDate} - {_endDate} (пояс {offset}). Возвращаем ошибку со смещением.", _startDate, _endDate, targetOffset); - + logger.LogError("Не удалось найти рабочие дни даже после {maxAttempts} попыток расширения периода [{start}, {end}]. Возвращаем ошибку со смещением.", maxPeriodExpansions, _startDate, _endDate); return FillTemplateResultWithOffsetError(targetTemplate, _startDate, referenceDateInTargetTz, targetOffset); } @@ -280,6 +290,14 @@ namespace PARR.DAL.NextRunServices.Subservices /// 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; @@ -292,26 +310,26 @@ namespace PARR.DAL.NextRunServices.Subservices 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); + logger.LogInformation("Начинаем распределение {Count} шаблонов на {duration} дней, с {_startDate} по {_endDate}. ReferenceDateInTargetTz: {referenceDateInTargetTz} в целевом поясе: {targetOffset} ReferenceDate UTC: {referenceDate}. Исключить выходные и праздники: {excludeWeekends}.", allTemplates.Count, duration, _startDate, _endDate, referenceDateInTargetTz, targetOffset, referenceDate, excludeWeekends); - // 4. Получаем рабочие дни (календарные даты, не зависят от пояса) - var workDays = await GetWorkDaysAsync(_startDate, _endDate, excludeWeekends); + // 4. Получаем рабочие дни с расширением периода при необходимости + var (expandedEndDate, workDays) = await GetWorkDaysWithExpansionAsync(_startDate, duration, excludeWeekends); + + // Обновляем _endDate на расширенный период для дальнейших расчётов + _endDate = expandedEndDate; if (workDays.Count == 0) { - logger.LogWarning("Нет доступных рабочих дней в диапазоне {_startDate} - {_endDate} (пояс {offset}). Возвращаем шаблоны со смещением {years} лет.", _startDate, _endDate, targetOffset, offsetYearsIfErrorCalcNextRun); + logger.LogError("Не удалось найти рабочие дни даже после {maxAttempts} попыток расширения периода [{start}, {end}]. Возвращаем шаблоны со смещением {years} лет.", maxPeriodExpansions, _startDate, _endDate, 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)); + FillTemplateResultWithOffsetError(t, _startDate, referenceDateInTargetTz, targetOffset) + ).OrderBy(t => t.NextRun).ToList(); + logger.LogInformation("Распределение завершено с ошибкой. Возвращаем {Count} записей.", errorResult.Count); return errorResult; } - // 5. Строим план распределения // строим план, дни на которые должны быть назанчены шаблоны var plan = GetDistributionPlan(allTemplates.Count, workDays); @@ -355,8 +373,8 @@ namespace PARR.DAL.NextRunServices.Subservices #endregion - #region 8. Определяем, какие шаблоны нужно оставить, а какие — переназначить + #region 8. Определяем, какие шаблоны нужно оставить, а какие — переназначить var templatesToKeep = new List(); var templatesToReassign = new List(); @@ -381,33 +399,117 @@ namespace PARR.DAL.NextRunServices.Subservices if (currentCount <= expectedCount) { - // Все шаблоны в этом дне можно оставить + // Все шаблоны в этом дне можно оставить, но нужно проверить/скорректировать время foreach (var template in templates) { - // КОНВЕРТИРУЕМ В UTC ПЕРЕД СОХРАНЕНИЕМ! - templatesToKeep.Add(new TemplateNextRunResultDto - ( + // Конвертируем текущий 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: template.NextRun.Value.ToOffset(TimeSpan.Zero), // ← В UTC! + NextRun: newNextRun, 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 - ( + + // Конвертируем текущий 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: template.NextRun.Value.ToOffset(TimeSpan.Zero), // ← В UTC! + NextRun: newNextRun, NextRunOld: template.NextRun )); - logger.LogDebug("Шаблон {TemplateId} оставлен на дату {NextRun} (UTC), т.к. нагрузка в день не превышена.", template.Id, template.NextRun.Value.ToOffset(TimeSpan.Zero)); } for (int i = expectedCount; i < currentCount; i++) @@ -418,9 +520,9 @@ namespace PARR.DAL.NextRunServices.Subservices } } } - #endregion + #region 9. Добавляем шаблоны, у которых не было даты — тоже нужно распределить foreach (var template in allTemplates) @@ -777,16 +879,7 @@ namespace PARR.DAL.NextRunServices.Subservices /// Если момент времени уже прошёл, ищет следующий рабочий день в будущем с учётом нагрузки. /// В случае ошибки (нет будущих рабочих дней) возвращает ошибку через out-параметр. /// - private DateTimeOffset? AdjustNextRunToFuture( - DateOnly foundDay, - List workDays, - Dictionary currentDailyLoad, - Dictionary expectedDailyLoad, - DateTimeOffset referenceDateInTargetTz, - TimeSpan targetOffset, - DateOnly startDateForError, - TemplateNextRunDto targetTemplate, - out TemplateNextRunResultDto? errorResult) + private DateTimeOffset? AdjustNextRunToFuture(DateOnly foundDay, List workDays, Dictionary currentDailyLoad, Dictionary expectedDailyLoad, DateTimeOffset referenceDateInTargetTz, TimeSpan targetOffset, DateOnly startDateForError, TemplateNextRunDto targetTemplate, out TemplateNextRunResultDto? errorResult) { errorResult = null; @@ -1103,7 +1196,7 @@ namespace PARR.DAL.NextRunServices.Subservices /// /// Исключить выходные и праздники /// - public async Task> GetWorkDaysAsync(DateOnly start, DateOnly end, bool excludeWeekends) + private async Task> GetWorkDaysAsync(DateOnly start, DateOnly end, bool excludeWeekends) { // Получаем список выходных/праздничных дней var weekends = new HashSet(); @@ -1130,5 +1223,44 @@ namespace PARR.DAL.NextRunServices.Subservices 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()); // Пустой список для обработки ошибки вызывающим кодом + } } } diff --git a/PARR.Test/NextRun/NextRunTest.cs b/PARR.Test/NextRun/NextRunTest.cs index 344166d8..2444d03a 100644 --- a/PARR.Test/NextRun/NextRunTest.cs +++ b/PARR.Test/NextRun/NextRunTest.cs @@ -1,11 +1,8 @@ -using Microsoft.EntityFrameworkCore; -using PARR.DAL.DomainServices.Shortcodes; +using PARR.DAL.DomainServices.Shortcodes; 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 { @@ -36,22 +33,22 @@ namespace PARR.Test.NextRun public async Task Test() { - var duration = 10; - var offset = TimeSpan.FromHours(10); + var duration = 2; + var offset = TimeSpan.FromHours(3); var startDate = new DateOnly(2026, 2, 10); - var referenceDate = new DateTimeOffset(2026, 2, 12, 23, 0, 0, TimeSpan.Zero); + var referenceDate = new DateTimeOffset(2026, 2, 13, 23, 0, 0, TimeSpan.Zero); var templates = GetTemplates(); var templatesEmpty = new List(); - var templatesToHandeler = templates; // templatesEmpty + var templatesToHandeler = templatesEmpty; // templatesEmpty // templates // распределить шаблоны - //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, 23, 30, 0, TimeSpan.Zero)); - var validNextRun = await templateDistributorV2.GetNextRunForTemplateAsync(startDate, duration, referenceDate, offset, targetTemplate, templatesToHandeler, true, isNew: false); + var targetTemplate = new TemplateNextRunDto(Guid.Parse("DA151719-2742-4260-BFFE-012B61591053"), new DateTimeOffset(2026, 2, 15, 23, 30, 0, TimeSpan.Zero)); + //var validNextRun = await templateDistributorV2.GetNextRunForTemplateAsync(startDate, duration, referenceDate, offset, targetTemplate, templatesToHandeler, 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-ГВЦ"); @@ -169,7 +166,7 @@ namespace PARR.Test.NextRun templates.Add(new TemplateNextRunDto(Id: Guid.Parse("B397F1FE-DF2B-4B50-B8D6-2DABECD3D5A1"), NextRun: new DateTimeOffset(2025, 12, 18, 3, 0, 0, TimeSpan.Zero))); templates.Add(new TemplateNextRunDto(Id: Guid.Parse("619D9621-2B45-4108-AF54-CF96862A8E9B"), NextRun: new DateTimeOffset(2025, 12, 18, 3, 0, 0, TimeSpan.Zero))); templates.Add(new TemplateNextRunDto(Id: Guid.Parse("D74D831C-D435-46BF-9B5F-A24884AAA7DC"), NextRun: new DateTimeOffset(2025, 12, 25, 3, 0, 0, TimeSpan.Zero))); - templates.Add(new TemplateNextRunDto(Id: Guid.Parse("DE7241F1-5C62-4164-A3DC-99B946350E10"), NextRun: new DateTimeOffset(2026, 2, 13, 4, 0, 0, TimeSpan.Zero))); + templates.Add(new TemplateNextRunDto(Id: Guid.Parse("DE7241F1-5C62-4164-A3DC-99B946350E10"), NextRun: new DateTimeOffset(2026, 2, 16, 23, 30, 0, TimeSpan.Zero))); templates.Add(new TemplateNextRunDto(Id: Guid.Parse("94A7375E-D1BD-49B7-9186-D26E85F1CF74"), NextRun: new DateTimeOffset(2025, 12, 18, 3, 0, 0, TimeSpan.Zero))); templates.Add(new TemplateNextRunDto(Id: Guid.Parse("B3681F5F-10A5-425C-BBEA-EA2B85FE0967"), NextRun: new DateTimeOffset(2025, 12, 18, 3, 0, 0, TimeSpan.Zero))); templates.Add(new TemplateNextRunDto(Id: Guid.Parse("3259B408-48A3-4995-AF9A-097477ECAB81"), NextRun: new DateTimeOffset(2025, 12, 18, 3, 0, 0, TimeSpan.Zero)));