From 7c8f8bbf9712b0112dfa9bb063c885cfc3f789a9 Mon Sep 17 00:00:00 2001 From: Mikhail Trubnikov Date: Tue, 10 Feb 2026 15:50:30 +1000 Subject: [PATCH] =?UTF-8?q?feat(dal):=20ITemplateDistributorV2=20-=20?= =?UTF-8?q?=D1=80=D0=B0=D1=81=D0=BF=D1=80=D0=B5=D0=B4=D0=B5=D0=BB=D1=8F?= =?UTF-8?q?=D0=B5=D1=82=20=D1=88=D0=B0=D0=B1=D0=BB=D0=BE=D0=BD=D1=8B=20?= =?UTF-8?q?=D1=81=20=D1=83=D1=87=D0=B5=D1=82=D0=BE=D0=BC=20=D1=87=D0=B0?= =?UTF-8?q?=D1=81=D0=BE=D0=B2=D0=BE=D0=B3=D0=BE=20=D0=BF=D0=BE=D1=8F=D1=81?= =?UTF-8?q?=D0=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../EsppScheduleTransformService.cs | 9 + .../IEsppScheduleTransformService.cs | 45 +- .../Subservices/ITemplateDistributorV2.cs | 37 ++ .../Subservices/TemplateDistributor.cs | 206 +++++--- .../Subservices/TemplateDistributorV2.cs | 495 ++++++++++++++++++ PARR.DAL/ParrDalInstaller.cs | 1 + PARR.Test/NextRun/NextRunTest.cs | 38 +- 7 files changed, 707 insertions(+), 124 deletions(-) create mode 100644 PARR.DAL/NextRunServices/Subservices/ITemplateDistributorV2.cs create mode 100644 PARR.DAL/NextRunServices/Subservices/TemplateDistributorV2.cs diff --git a/PARR.DAL/NextRunServices/Subservices/EsppScheduleTransformService.cs b/PARR.DAL/NextRunServices/Subservices/EsppScheduleTransformService.cs index b4a949ea..f94f9291 100644 --- a/PARR.DAL/NextRunServices/Subservices/EsppScheduleTransformService.cs +++ b/PARR.DAL/NextRunServices/Subservices/EsppScheduleTransformService.cs @@ -2,6 +2,7 @@ using PARR.DAL.Contracts; using PARR.DAL.DomainModels; using PARR.DAL.Services.Interfaces; +using System.Text.Json; namespace PARR.DAL.NextRunServices.Subservices { @@ -67,6 +68,8 @@ namespace PARR.DAL.NextRunServices.Subservices { //offset - желательно передавать всегда, хотябы МСК, чтобы рассчитывались правильно даты, так как в ЕСПП все в МСК + logger.LogDebug("Начинаю рассчет nextRun по расписанию ЕСПП. Входные данные: date: {referenceDate}, offset: {offset}, esppSchedule: {esppSchedule}", referenceDate, offset, JsonSerializer.Serialize(esppSchedule)); + var nextRun = referenceDate; //var finalOffset = new TimeSpan(3, 0, 0) + (offset ?? TimeSpan.Zero); @@ -74,6 +77,8 @@ namespace PARR.DAL.NextRunServices.Subservices var offsetReferenceDate = referenceDate.ToOffset(finalOffset); + logger.LogDebug("Дата после добавления offset - {offsetReferenceDate}", offsetReferenceDate); + ////--------- //// Для рассчета по МСК времени, потому что в ЮТС может быть еще ВСК, а по МСК это уже ПНД //var offsetReferenceDate = referenceDate.ToOffset(new TimeSpan(3, 0, 0)); @@ -107,9 +112,13 @@ namespace PARR.DAL.NextRunServices.Subservices break; } + logger.LogDebug("Рассчитанная дата, с учетом offset: {nextRun}", nextRun); + // Возвращаем обратно в ЮТС nextRun = nextRun.ToOffset(TimeSpan.Zero); + logger.LogDebug("Рассчитанная дата с нулевым offset: {nextRun}", nextRun); + return nextRun; } diff --git a/PARR.DAL/NextRunServices/Subservices/IEsppScheduleTransformService.cs b/PARR.DAL/NextRunServices/Subservices/IEsppScheduleTransformService.cs index 82b4a70e..fc4e7d75 100644 --- a/PARR.DAL/NextRunServices/Subservices/IEsppScheduleTransformService.cs +++ b/PARR.DAL/NextRunServices/Subservices/IEsppScheduleTransformService.cs @@ -5,56 +5,13 @@ /// internal interface IEsppScheduleTransformService { - ///// - ///// Получить следующую дату согласно расписания - ///// - ///// - ///// - ///// - //DateTimeOffset GetNextDate(EsppScheduleDto esppSchedule, DateTimeOffset lastRun); - - ///// - ///// Получить расписание - ///// - ///// - ///// - ///// - //List GetNextSchedule(EsppScheduleDto esppSchedule, DateTimeOffset lastRun); - /// /// Получить следующую дату по jobGroupId /// /// - /// + /// referenceDate или nextRun /// offset - желательно передавать всегда, хотябы МСК, чтобы рассчитывались правильно даты, так как в ЕСПП все в МСК /// Task GetNextDateAsync(Guid jobGroupId, DateTimeOffset referenceDate, TimeSpan? offset = null); - - ///// - ///// Получить расписание по jobGroupId - ///// - ///// - ///// - ///// - //Task> GetNextScheduleAsync(Guid jobGroupId, DateTimeOffset lastRun); - - - ///// - ///// Получить следующую дату срабатывания в указанном периоде - ///// - ///// - ///// - ///// - ///// - //DateTimeOffset GetNextDateForDistributionRun(DateTimeOffset lastRun, DistributionPeriodTypeEnum periodType, string distributionPeriod); - - - ///// - ///// Получить дату начала периода распределения относительно опорной даты (Reference Date) - ///// - ///// - ///// - ///// - //Task GetStartPeriodForDateAsync(Guid jobGroupId, DateTimeOffset date, DateTimeOffset refrenceDate, DistributionPeriodTypeEnum periodType, string distributionPeriod); } } diff --git a/PARR.DAL/NextRunServices/Subservices/ITemplateDistributorV2.cs b/PARR.DAL/NextRunServices/Subservices/ITemplateDistributorV2.cs new file mode 100644 index 00000000..98137dd8 --- /dev/null +++ b/PARR.DAL/NextRunServices/Subservices/ITemplateDistributorV2.cs @@ -0,0 +1,37 @@ +using PARR.DAL.NextRunServices.Models; + +namespace PARR.DAL.NextRunServices.Subservices +{ + //todo: -> internal + public interface ITemplateDistributorV2 + { + /// + /// Распределить шаблоны + /// + /// + /// + /// + /// + /// + /// + /// + Task> DistributeTemplatesAsync(DateOnly startDate, int duration, DateTimeOffset referenceDate, TimeSpan? offset, List allTemplates, bool excludeWeekends); + + /// + /// Получить список выходных дней + /// + /// + /// + /// + Task> GetWeekendsAsync(DateOnly startDate, DateOnly endDate); + + /// + /// Получить список рабочих дней + /// + /// + /// + /// + /// + Task> GetWorkDaysAsync(DateOnly start, DateOnly end, bool excludeWeekends); + } +} diff --git a/PARR.DAL/NextRunServices/Subservices/TemplateDistributor.cs b/PARR.DAL/NextRunServices/Subservices/TemplateDistributor.cs index e93c5dd9..1430c248 100644 --- a/PARR.DAL/NextRunServices/Subservices/TemplateDistributor.cs +++ b/PARR.DAL/NextRunServices/Subservices/TemplateDistributor.cs @@ -27,6 +27,9 @@ namespace PARR.DAL.NextRunServices.Subservices public async Task GetValidNextRunForTemplateAsync(DateOnly startDate, int duration, DateTimeOffset referenceDate, TemplateNextRunDto targetTemplate, List allTemplates, bool excludeWeekends, bool isNew) { + //if (offset.HasValue) + // referenceDate = referenceDate.ToOffset(offset.Value); + //todo: allTemplates - пока не используется. Будет использоваться когда каждый раз будем строить план // Пока работает так: @@ -104,6 +107,10 @@ namespace PARR.DAL.NextRunServices.Subservices public async Task> DistributeTemplatesAsync(DateOnly startDate, int duration, DateTimeOffset referenceDate, List allTemplates, bool excludeWeekends) { + //if (offset.HasValue) + //referenceDate = referenceDate.ToOffset(offset.Value); + + var _startDate = GetStartPeriodDate(startDate, referenceDate); var _endDate = GetEndPeriodDate(_startDate, duration); @@ -417,7 +424,7 @@ namespace PARR.DAL.NextRunServices.Subservices return CreateDateTimeOffset(minDay, referenceDate); } - + //--------------------------------------------------------------------------------------------------------------------------------------------------------------------- /// /// Получить план: дни, на которые должны быть назначены шаблоны @@ -492,7 +499,7 @@ namespace PARR.DAL.NextRunServices.Subservices /// private DateTimeOffset CreateDateTimeOffset(DateOnly date, DateTimeOffset refDate) { - return new DateTimeOffset(date.Year, date.Month, date.Day, refDate.Hour, refDate.Minute, refDate.Second, TimeSpan.Zero); + return new DateTimeOffset(date.Year, date.Month, date.Day, refDate.Hour, refDate.Minute, refDate.Second, refDate.Offset); } @@ -507,20 +514,61 @@ namespace PARR.DAL.NextRunServices.Subservices // дата начала не может быть раньше referenceDate // и не может быть раньше чем сегодня - var dateStart = startDate; + #region old + //var dateStart = startDate; - // если dateStart < чем refDate, то берем refDate - if (dateStart < DateOnly.FromDateTime(referenceDate.Date)) - dateStart = DateOnly.FromDateTime(referenceDate.Date); + //// если dateStart < чем refDate, то берем refDate + //if (dateStart < DateOnly.FromDateTime(referenceDate.Date)) + // dateStart = DateOnly.FromDateTime(referenceDate.Date); - // если dateStart < чем текущая дата, то берем текущую дату - if (dateStart < DateOnly.FromDateTime(DateTime.UtcNow.Date)) - dateStart = DateOnly.FromDateTime(DateTime.UtcNow.Date); + //// если dateStart < чем текущая дата, то берем текущую дату + //if (dateStart < DateOnly.FromDateTime(DateTime.UtcNow.Date)) + // dateStart = DateOnly.FromDateTime(DateTime.UtcNow.Date); - // если текущее время > referenceDate, то прибавляем 1 день + //// если текущее время > referenceDate, то прибавляем 1 день + //var dateTimeStart = CreateDateTimeOffset(dateStart, referenceDate); + //if (dateTimeStart < DateTimeOffset.UtcNow) + // dateStart = dateStart.AddDays(1); + + //return dateStart; + #endregion + + // 1. Конвертируем startDate (UTC) в целевой часовой пояс + var startDateUtc = new DateTimeOffset( + startDate.Year, startDate.Month, startDate.Day, + 0, 0, 0, + TimeSpan.Zero); + + var startDateInTargetTz = startDateUtc.ToOffset(referenceDate.Offset); + var dateStart = DateOnly.FromDateTime(startDateInTargetTz.DateTime); + + logger.LogDebug("startDate UTC ({startDate} 00:00 UTC) конвертирована в целевой часовой пояс {offset}: {dateStart}", startDate, referenceDate.Offset, dateStart); + + // 2. Дата начала не может быть раньше даты из referenceDate (в его часовом поясе) + var refDateInTargetTz = DateOnly.FromDateTime(referenceDate.Date); + if (dateStart < refDateInTargetTz) + { + logger.LogDebug("Дата начала {dateStart} раньше даты из referenceDate {refDate}. Используем дату из referenceDate.", dateStart, refDateInTargetTz); + dateStart = refDateInTargetTz; + } + + // 3. Дата начала не может быть раньше "сегодня" в целевом часовом поясе + var todayInTargetTz = DateOnly.FromDateTime(DateTimeOffset.UtcNow.ToOffset(referenceDate.Offset).Date); + if (dateStart < todayInTargetTz) + { + logger.LogDebug("Дата начала {dateStart} раньше 'сегодня' в целевом поясе {today}. Используем 'сегодня'.", dateStart, todayInTargetTz); + dateStart = todayInTargetTz; + } + + // 4. Если момент времени "начало дня в целевом поясе" уже прошёл — добавляем 1 день var dateTimeStart = CreateDateTimeOffset(dateStart, referenceDate); if (dateTimeStart < DateTimeOffset.UtcNow) + { + logger.LogDebug("Момент времени {dateTimeStart} уже прошёл (сейчас {now}). Добавляем 1 день.", dateTimeStart, DateTimeOffset.UtcNow); dateStart = dateStart.AddDays(1); + } + + logger.LogDebug("Итоговая дата начала распределения: {dateStart} (в часовом поясе {offset})", dateStart, referenceDate.Offset); return dateStart; } @@ -565,41 +613,44 @@ namespace PARR.DAL.NextRunServices.Subservices /// /// /// - public async Task> GetWeekendsV2Async(DateOnly startDate, DateOnly endDate, DateTimeOffset referenceDate) + public async Task> GetWeekendsV2Async(DateOnly startDate, DateOnly endDate, DateTimeOffset referenceDate, TimeSpan? offset = null) { + if (offset.HasValue) + referenceDate = referenceDate.ToOffset(offset.Value); + // проверяем это текущие или прошлые сутки относительно refDate, если надо, корректируем dateStart, dateEnd - bool isPreviousDays = IsPreviousDays(referenceDate); + //bool isPreviousDays = IsPreviousDays(referenceDate); - if (isPreviousDays) - { - // Это предыдущие сутки, корректируем - startDate = startDate.AddDays(-1); - // так как мы потом будем сдвигать выходные дни влево, то надо взять на один день дальше, чтоб знать какой он - // вроде бы это не костыль - endDate = endDate.AddDays(1); - //endDate = endDate.AddDays(-1); + //if (isPreviousDays) + //{ + // // Это предыдущие сутки, корректируем + // startDate = startDate.AddDays(-1); + // // так как мы потом будем сдвигать выходные дни влево, то надо взять на один день дальше, чтоб знать какой он + // // вроде бы это не костыль + // endDate = endDate.AddDays(1); + // //endDate = endDate.AddDays(-1); - logger.LogDebug("Это предыдущие сутки относительно referenceDate: {referenceDate}, корректируем даты для получения выходных дней, новые значения dateStart: {dateStart}, dateEnd: {dateEnd}", referenceDate, startDate, endDate); - } + // logger.LogDebug("Это предыдущие сутки относительно referenceDate: {referenceDate}, корректируем даты для получения выходных дней, новые значения dateStart: {dateStart}, dateEnd: {dateEnd}", referenceDate, startDate, endDate); + //} var weekends = await weekendDayService.GetWeekends(startDate, endDate).ToListAsync(); var weekendsResult = new List(); - if (isPreviousDays) - { - // так как должны быть предыдущие дни, отнимаем 1 день - foreach (var weekend in weekends) - { - var previous = weekend.AddDays(-1); - logger.LogDebug("Выходной день, -1, так как день должен быть предыдущий, старое значение {weekend}, новое {previous}", weekend, previous); - weekendsResult.Add(previous); - } - } - else - { - weekendsResult = weekends; - } + //if (isPreviousDays) + //{ + // // так как должны быть предыдущие дни, отнимаем 1 день + // foreach (var weekend in weekends) + // { + // var previous = weekend.AddDays(-1); + // logger.LogDebug("Выходной день, -1, так как день должен быть предыдущий, старое значение {weekend}, новое {previous}", weekend, previous); + // weekendsResult.Add(previous); + // } + //} + //else + //{ + // weekendsResult = weekends; + //} logger.LogDebug("Найдено не рабочих дней: {Count}", weekendsResult.Count); foreach (var weekend in weekendsResult) @@ -644,19 +695,22 @@ namespace PARR.DAL.NextRunServices.Subservices //} - public async Task> GetWorkDaysV2Async(DateOnly start, DateOnly end, bool excludeWeekends, DateTimeOffset referenceDate) + public async Task> GetWorkDaysV2Async(DateOnly start, DateOnly end, bool excludeWeekends, DateTimeOffset referenceDate, TimeSpan? offset = null) { + if (offset.HasValue) + referenceDate = referenceDate.ToOffset(offset.Value); + logger.LogDebug("Формирование списка рабочих дней, start: {start}, end: {end}, excludeWeekends: {excludeWeekends}, referenceDate: {referenceDate}", start, end, excludeWeekends, referenceDate); - // проверяем это текущие или прошлые сутки относительно refDate, если надо, корректируем dateStart, dateEnd - if (IsPreviousDays(referenceDate)) - { - // Это предыдущие сутки, корректируем - start = start.AddDays(-1); - end = end.AddDays(-1); + //// проверяем это текущие или прошлые сутки относительно refDate, если надо, корректируем dateStart, dateEnd + //if (IsPreviousDays(referenceDate)) + //{ + // // Это предыдущие сутки, корректируем + // start = start.AddDays(-1); + // end = end.AddDays(-1); - logger.LogDebug("Это предыдущие сутки относительно referenceDate: {referenceDate}, корректируем даты, новые значения dateStart: {dateStart}, dateEnd: {dateEnd}", referenceDate, start, end); - } + // logger.LogDebug("Это предыдущие сутки относительно referenceDate: {referenceDate}, корректируем даты, новые значения dateStart: {dateStart}, dateEnd: {dateEnd}", referenceDate, start, end); + //} // Получаем список выходных/праздничных дней var weekends = new HashSet(); @@ -683,33 +737,45 @@ namespace PARR.DAL.NextRunServices.Subservices return result; } - /// - /// Это предыдущие сутки? Относительно referenceDate и МСК времени - /// - /// - /// - private bool IsPreviousDays(DateTimeOffset referenceDate) + + + public Task> GetWeekendsV2Async(DateOnly startDate, DateOnly endDate, DateTimeOffset referenceDate) { - // смещение часов относительно UTC - MSK - int offsetMskHour = 3; - - // если время больше 21 часа, то это текущие сутки, если меньше, то прошлые - int maxHour = 24 - offsetMskHour; - - if (referenceDate.Hour >= maxHour && referenceDate.Hour <= 23) - { - // это прошлые сутки - logger.LogDebug("ReferenceDate {refDate} - прошлые стуки", referenceDate); - return true; - } - else - { - // это текущие сутки - logger.LogDebug("ReferenceDate {refDate} - текущие стуки", referenceDate); - return false; - } - + throw new NotImplementedException(); } + public Task> GetWorkDaysV2Async(DateOnly start, DateOnly end, bool excludeWeekends, DateTimeOffset referenceDate) + { + throw new NotImplementedException(); + } + + ///// + ///// Это предыдущие сутки? Относительно referenceDate и МСК времени + ///// + ///// + ///// + //private bool IsPreviousDays(DateTimeOffset referenceDate) + //{ + // // смещение часов относительно UTC - MSK + // int offsetMskHour = 3; + + // // если время больше 21 часа, то это текущие сутки, если меньше, то прошлые + // int maxHour = 24 - offsetMskHour; + + // if (referenceDate.Hour >= maxHour && referenceDate.Hour <= 23) + // { + // // это прошлые сутки + // logger.LogDebug("ReferenceDate {refDate} - прошлые стуки", referenceDate); + // return true; + // } + // else + // { + // // это текущие сутки + // logger.LogDebug("ReferenceDate {refDate} - текущие стуки", referenceDate); + // return false; + // } + + //} + } } diff --git a/PARR.DAL/NextRunServices/Subservices/TemplateDistributorV2.cs b/PARR.DAL/NextRunServices/Subservices/TemplateDistributorV2.cs new file mode 100644 index 00000000..d9f76269 --- /dev/null +++ b/PARR.DAL/NextRunServices/Subservices/TemplateDistributorV2.cs @@ -0,0 +1,495 @@ +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; + } + + + } +} diff --git a/PARR.DAL/ParrDalInstaller.cs b/PARR.DAL/ParrDalInstaller.cs index 21a686e1..0d12389e 100644 --- a/PARR.DAL/ParrDalInstaller.cs +++ b/PARR.DAL/ParrDalInstaller.cs @@ -141,6 +141,7 @@ namespace PARR.DAL services.AddTransient(); services.AddTransient(); services.AddTransient(); + services.AddTransient(); #endregion diff --git a/PARR.Test/NextRun/NextRunTest.cs b/PARR.Test/NextRun/NextRunTest.cs index afaa3c1a..377468d1 100644 --- a/PARR.Test/NextRun/NextRunTest.cs +++ b/PARR.Test/NextRun/NextRunTest.cs @@ -14,20 +14,38 @@ namespace PARR.Test.NextRun private readonly INextRunService nextRunService; private readonly ITemplateService templateService; private readonly IShortcodesService shortcodesService; + private readonly ITemplateDistributorV2 templateDistributorV2; - public NextRunTest(/*ITemplateDistributor templateDistributor, */INextRunService nextRunService, ITemplateService templateService, IShortcodesService shortcodesService) + public NextRunTest( + /*ITemplateDistributor templateDistributor, */ + INextRunService nextRunService, + ITemplateService templateService, + IShortcodesService shortcodesService, + ITemplateDistributorV2 templateDistributorV2 + ) { //this.templateDistributor = templateDistributor; this.nextRunService = nextRunService; this.templateService = templateService; this.shortcodesService = shortcodesService; + this.templateDistributorV2 = templateDistributorV2; } - int periodDays = 10; - DateTimeOffset referenceDate = new DateTimeOffset(2025, 1, 20, 3, 0, 0, TimeSpan.Zero); + public async Task Test() { + var duration = 10; + 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 templates = GetTemplates(); + + var distributedTemplates = await templateDistributorV2.DistributeTemplatesAsync(startDate, duration, referenceDate, offset, templates, true); + + //var template = await templateService.Get().Include(t => t.Job).ThenInclude(t => t.Group).FirstAsync(t => t.Name == "ЭИТИ-ПАРР_ЦКИТ-ГВЦ_СХД ТО-1_СХД-AERODISK-432-2-4-U26-EN4SAG022-ГВЦ"); //var responseAreae = await shortcodesService.ApplyShortcodesAsync(template.Job.ResponseAreaMask, template); //var nextRunWithTimezone = nextRunService.GetNextRunWithTimezoneEsppAndResponseArea(template.NextRun, template.Job?.Group?.IsResponseAreaTimezone, responseAreae); @@ -117,14 +135,14 @@ namespace PARR.Test.NextRun } - private DateOnly GetDateStart() - { - var today = DateOnly.FromDateTime(DateTime.UtcNow.Date); + //private DateOnly GetDateStart() + //{ + // var today = DateOnly.FromDateTime(DateTime.UtcNow.Date); - var refDate = DateOnly.FromDateTime(referenceDate.Date); + // var refDate = DateOnly.FromDateTime(referenceDate.Date); - return today < refDate ? refDate : today; - } + // return today < refDate ? refDate : today; + //} //private DateOnly GetDateEnd() //{ @@ -143,7 +161,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(2025, 12, 18, 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("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)));