2025-12-22 17:03:41 +10:00
using Microsoft.EntityFrameworkCore ;
using Microsoft.Extensions.Logging ;
using PARR.DAL.NextRunServices.Models ;
using PARR.DAL.Services.Interfaces ;
namespace PARR.DAL.NextRunServices.Subservices
{
2026-03-05 10:19:54 +10:00
/// <summary>
/// Распределитель шаблонов с поддержкой часовых поясов.
/// В с е входные даты интерпретируются в целевом часовом поясе (параметр offset).
/// В с е результаты возвращаются строго в UTC (TimeSpan.Zero).
/// </summary>
2025-12-22 17:03:41 +10:00
internal class TemplateDistributor : ITemplateDistributor
{
private readonly ILogger < TemplateDistributor > logger ;
private readonly IWeekendDayService weekendDayService ;
2025-12-25 12:07:53 +10:00
/// <summary>
/// Смещение nextRun на несколько лет вперед, если не смог е г о рассчитать
/// </summary>
private const int offsetYearsIfErrorCalcNextRun = 5 ;
2026-03-05 10:19:54 +10:00
/// <summary>
/// Максимальное количество попыток расширения периода при отсутствии рабочих дней
/// </summary>
private const int maxPeriodExpansions = 2 ;
public TemplateDistributor ( ILogger < TemplateDistributor > logger , IWeekendDayService weekendDayService )
2025-12-22 17:03:41 +10:00
{
this . logger = logger ;
this . weekendDayService = weekendDayService ;
}
2025-12-25 12:07:53 +10:00
2026-03-05 10:19:54 +10:00
public async Task < TemplateNextRunResultDto > GetNextRunForTemplateAsync ( DateOnly startDate , int duration , DateTimeOffset referenceDate , TimeSpan ? offset , TemplateNextRunDto targetTemplate , List < TemplateNextRunDto > allTemplates , bool excludeWeekends , bool isNew )
2025-12-22 17:03:41 +10:00
{
2026-03-05 10:19:54 +10:00
// Валидация входных параметров
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 ) ;
2026-02-10 15:50:30 +10:00
2026-03-05 10:19:54 +10:00
// 1. Определяем целевой часовой пояс
var targetOffset = offset ? ? TimeSpan . Zero ;
var referenceDateInTargetTz = referenceDate . ToOffset ( targetOffset ) ;
2025-12-25 12:07:53 +10:00
2026-03-05 10:19:54 +10:00
logger . LogDebug ( "Целевой часовой пояс: {offset}. ReferenceDate в целевом поясе: {refDate}" , targetOffset , referenceDateInTargetTz ) ;
2025-12-25 12:07:53 +10:00
2026-03-05 10:19:54 +10:00
// 2. Рассчитываем период распределения в целевом поясе
var _startDate = GetStartPeriodDate ( startDate , referenceDateInTargetTz , targetOffset ) ;
2026-01-20 15:04:36 +10:00
var _endDate = GetEndPeriodDate ( _startDate , duration ) ;
2025-12-22 17:03:41 +10:00
2026-03-05 10:19:54 +10:00
logger . LogInformation ( "Период распределения: {_startDate} - {_endDate} (в поясе {offset})" , _startDate , _endDate , targetOffset ) ;
// 3. Получаем список рабочих дней с расширением периода при необходимости
var ( expandedEndDate , workDays ) = await GetWorkDaysWithExpansionAsync ( _startDate , duration , excludeWeekends ) ;
2025-12-25 12:07:53 +10:00
2026-03-05 10:19:54 +10:00
// Обновляем _endDate на расширенный период для дальнейших расчётов
_endDate = expandedEndDate ;
2025-12-25 12:07:53 +10:00
2026-01-20 15:04:36 +10:00
if ( workDays . Count = = 0 )
{
2026-03-05 10:19:54 +10:00
logger . LogError ( "Н е удалось найти рабочие дни даже после {maxAttempts} попыток расширения периода [{start}, {end}]. Возвращаем ошибку с о смещением." , maxPeriodExpansions , _startDate , _endDate ) ;
return FillTemplateResultWithOffsetError ( targetTemplate , _startDate , referenceDateInTargetTz , targetOffset ) ;
}
2025-12-25 12:07:53 +10:00
2026-03-05 10:19:54 +10:00
#region 4. С т р о и м п л а н н а г р у з к и и р а с с ч и т ы в а е м т е к у щ у ю н а г р у з к у
2025-12-25 12:07:53 +10:00
2026-03-05 10:19:54 +10:00
logger . LogDebug ( "Строим план распределения для шаблона {templateId}. В с е г о шаблонов: {totalTemplates}" , targetTemplate . Id , allTemplates . Count ) ;
2026-01-20 15:04:36 +10:00
2026-03-05 10:19:54 +10:00
// Исключаем текущий шаблон из расчёта
var templatesForPlan = allTemplates . Where ( t = > t . Id ! = targetTemplate . Id ) . ToList ( ) ;
2026-01-20 15:04:36 +10:00
2026-03-05 10:19:54 +10:00
logger . LogDebug ( "Исключили текущий шаблон из расчёта. Осталось {count} шаблонов для плана" , templatesForPlan . Count ) ;
// Определяем тип плана (обычный или зеркальный)
bool useMirrorPlan = false ;
// Определяем тип плана на основе контекста
if ( ! targetTemplate . NextRun . HasValue )
2026-01-19 16:33:11 +10:00
{
2026-03-05 10:19:54 +10:00
// Нет текущего 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 ) ;
2026-01-20 15:04:36 +10:00
2026-03-05 10:19:54 +10:00
bool _isInPeriod = _nextRunDate > = _startDate & & _nextRunDate < = _endDate ;
bool _isWorkDay = workDays . Contains ( _nextRunDate ) ;
2025-12-25 12:07:53 +10:00
2026-03-05 10:19:54 +10:00
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 в периоде и на рабочем дне. Используем обычный план для проверки." ) ;
}
2026-01-19 16:33:11 +10:00
}
2025-12-22 17:03:41 +10:00
2026-03-05 10:19:54 +10:00
// Строим план распределения
List < DateOnly > plan ;
if ( useMirrorPlan )
2026-01-20 15:04:36 +10:00
{
2026-03-05 10:19:54 +10:00
// Зеркальный план
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 ( ) ) ;
2025-12-25 12:07:53 +10:00
2026-03-05 10:19:54 +10:00
logger . LogDebug ( "Ожидаемая нагрузка по плану: {loadCount} дней" , expectedDailyLoad . Count ) ;
foreach ( var kvp in expectedDailyLoad . OrderBy ( x = > x . Key ) )
logger . LogDebug ( " {date}: ожидаемо {count} шаблонов" , kvp . Key , kvp . Value ) ;
2025-12-22 17:03:41 +10:00
2026-03-05 10:19:54 +10:00
// Считаем текущую нагрузку (только шаблоны внутри периода)
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 ) ;
2026-01-20 15:04:36 +10:00
}
2026-03-05 10:19:54 +10:00
#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 ) ;
2026-01-20 15:04:36 +10:00
2026-03-05 10:19:54 +10:00
// Проверка 2: Рабочий день?
var isWorkDay = workDays . Contains ( nextRunDate ) ;
logger . LogDebug ( "Проверка 2 (рабочий день): {result}" , isWorkDay ? "да" : "нет" ) ;
2026-01-20 15:04:36 +10:00
2026-03-05 10:19:54 +10:00
// Проверка 3: В будущем?
var nextRunMoment = new DateTimeOffset (
nextRunDate . Year , nextRunDate . Month , nextRunDate . Day ,
nextRunInTargetTz . Hour , nextRunInTargetTz . Minute , nextRunInTargetTz . Second ,
targetOffset ) ;
2026-01-20 15:04:36 +10:00
2026-03-05 10:19:54 +10:00
var isFuture = nextRunMoment > = DateTimeOffset . UtcNow ;
logger . LogDebug ( "Проверка 3 (в будущем): {result} ({moment} >= {now})" , isFuture , nextRunMoment , DateTimeOffset . UtcNow ) ;
2026-01-20 15:04:36 +10:00
2026-03-05 10:19:54 +10:00
// Проверка 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 ) ;
2026-01-20 15:04:36 +10:00
}
2026-03-05 10:19:54 +10:00
/// <summary>
/// Распределить шаблоны
/// </summary>
/// <param name="startDate"></param>
/// <param name="duration"></param>
/// <param name="referenceDate"></param>
/// <param name="offset"></param>
/// <param name="allTemplates"></param>
/// <param name="excludeWeekends"></param>
/// <returns></returns>
public async Task < List < TemplateNextRunResultDto > > DistributeTemplatesAsync ( DateOnly startDate , int duration , DateTimeOffset referenceDate , TimeSpan ? offset , List < TemplateNextRunDto > allTemplates , bool excludeWeekends )
2026-01-20 15:04:36 +10:00
{
2026-03-05 10:19:54 +10:00
// Валидация входных параметров
if ( duration < = 0 )
throw new ArgumentException ( $"Duration must be positive, got {duration}" , nameof ( duration ) ) ;
2026-02-10 15:50:30 +10:00
2026-03-05 10:19:54 +10:00
if ( allTemplates = = null )
throw new ArgumentNullException ( nameof ( allTemplates ) ) ;
2026-02-10 15:50:30 +10:00
2026-01-20 15:04:36 +10:00
2026-03-05 10:19:54 +10:00
// 1. Определяем целевой часовой пояс
var targetOffset = offset ? ? TimeSpan . Zero ;
2026-01-20 15:04:36 +10:00
2026-03-05 10:19:54 +10:00
// 2. Применяем оффсет к referenceDate для расчётов в целевом поясе
var referenceDateInTargetTz = referenceDate . ToOffset ( targetOffset ) ;
2025-12-22 17:03:41 +10:00
2026-03-05 10:19:54 +10:00
logger . LogDebug ( "Распределение шаблонов в часовом поясе {offset}. ReferenceDate в целевом поясе: {refDate}" , targetOffset , referenceDateInTargetTz ) ;
2025-12-25 12:07:53 +10:00
2026-03-05 10:19:54 +10:00
// 3. Рассчитываем период распределения С УЧЁТОМ ЧАСОВОГО ПОЯСА
var _startDate = GetStartPeriodDate ( startDate , referenceDateInTargetTz , targetOffset ) ;
var _endDate = GetEndPeriodDate ( _startDate , duration ) ;
2025-12-25 12:07:53 +10:00
2026-03-05 10:19:54 +10:00
logger . LogInformation ( "Начинаем распределение {Count} шаблонов на {duration} дней, с {_startDate} по {_endDate}. ReferenceDateInTargetTz: {referenceDateInTargetTz} в целевом поясе: {targetOffset} ReferenceDate UTC: {referenceDate}. Исключить выходные и праздники: {excludeWeekends}." , allTemplates . Count , duration , _startDate , _endDate , referenceDateInTargetTz , targetOffset , referenceDate , excludeWeekends ) ;
2025-12-25 12:07:53 +10:00
2026-03-05 10:19:54 +10:00
// 4. Получаем рабочие дни с расширением периода при необходимости
var ( expandedEndDate , workDays ) = await GetWorkDaysWithExpansionAsync ( _startDate , duration , excludeWeekends ) ;
2025-12-22 17:03:41 +10:00
2026-03-05 10:19:54 +10:00
// Обновляем _endDate на расширенный период для дальнейших расчётов
_endDate = expandedEndDate ;
2025-12-22 17:03:41 +10:00
2026-03-05 10:19:54 +10:00
if ( workDays . Count = = 0 )
{
logger . LogError ( "Н е удалось найти рабочие дни даже после {maxAttempts} попыток расширения периода [{start}, {end}]. Возвращаем шаблоны с о смещением {years} лет." , maxPeriodExpansions , _startDate , _endDate , offsetYearsIfErrorCalcNextRun ) ;
2025-12-22 17:03:41 +10:00
2026-03-05 10:19:54 +10:00
var errorResult = allTemplates . Select ( t = >
FillTemplateResultWithOffsetError ( t , _startDate , referenceDateInTargetTz , targetOffset )
) . OrderBy ( t = > t . NextRun ) . ToList ( ) ;
2025-12-22 17:03:41 +10:00
2026-03-05 10:19:54 +10:00
logger . LogInformation ( "Распределение завершено с ошибкой. Возвращаем {Count} записей." , errorResult . Count ) ;
return errorResult ;
}
2025-12-22 17:03:41 +10:00
2026-03-05 10:19:54 +10:00
// 5. Строим план распределения
// строим план, дни на которые должны быть назанчены шаблоны
var plan = GetDistributionPlan ( allTemplates . Count , workDays ) ;
2025-12-25 12:07:53 +10:00
// Подсчитываем, сколько работ должно быть на каждый день
2026-03-05 10:19:54 +10:00
var expectedDailyLoad = plan . GroupBy ( d = > d )
. ToDictionary ( g = > g . Key , g = > g . Count ( ) ) ;
2025-12-22 17:03:41 +10:00
2026-03-05 10:19:54 +10:00
#region Г р у п п и р у е м ш а б л о н ы п о д н я м В Ц Е Л Е В О М П О Я С Е , н а к о т о р ы е о н и у ж е н а з н а ч е н ы
2025-12-22 17:03:41 +10:00
2025-12-25 12:07:53 +10:00
var templatesByDay = new Dictionary < DateOnly , List < TemplateNextRunDto > > ( ) ;
2025-12-22 17:03:41 +10:00
2025-12-25 12:07:53 +10:00
foreach ( var template in allTemplates )
{
if ( template . NextRun . HasValue )
{
2026-03-05 10:19:54 +10:00
// КОНВЕРТИРУЕМ В ЦЕЛЕВОЙ ПОЯС ДЛЯ ОПРЕДЕЛЕНИЯ ДАТЫ!
var dayInTargetTz = DateOnly . FromDateTime ( template . NextRun . Value . ToOffset ( targetOffset ) . Date ) ;
//var day = DateOnly.FromDateTime(template.NextRun.Value.Date);
if ( ! templatesByDay . ContainsKey ( dayInTargetTz ) )
2025-12-25 12:07:53 +10:00
{
2026-03-05 10:19:54 +10:00
templatesByDay [ dayInTargetTz ] = new List < TemplateNextRunDto > ( ) ;
2025-12-25 12:07:53 +10:00
}
2026-03-05 10:19:54 +10:00
templatesByDay [ dayInTargetTz ] . Add ( template ) ;
logger . LogDebug ( "Шаблон {Id} с NextRun={nextRun} отнесён к дате {day} в поясе {offset}" , template . Id , template . NextRun . Value , dayInTargetTz , targetOffset ) ;
2025-12-25 12:07:53 +10:00
}
2025-12-22 17:03:41 +10:00
}
2025-12-25 12:07:53 +10:00
#endregion
2025-12-22 17:03:41 +10:00
2026-03-05 10:19:54 +10:00
#region 7. П о д с ч и т ы в а е м т е к у щ у ю н а г р у з к у н а к а ж д ы й д е н ь
2025-12-22 17:03:41 +10:00
2026-03-05 10:19:54 +10:00
var currentDailyLoad = workDays . ToDictionary ( day = > day , _ = > 0 ) ;
2025-12-22 17:03:41 +10:00
2025-12-25 12:07:53 +10:00
foreach ( var kvp in templatesByDay )
{
2026-03-05 10:19:54 +10:00
if ( currentDailyLoad . ContainsKey ( kvp . Key ) )
currentDailyLoad [ kvp . Key ] = kvp . Value . Count ;
2025-12-25 12:07:53 +10:00
}
2025-12-22 17:03:41 +10:00
2025-12-25 12:07:53 +10:00
#endregion
2025-12-22 17:03:41 +10:00
2025-12-25 12:07:53 +10:00
2026-03-05 10:19:54 +10:00
#region 8. О п р е д е л я е м , к а к и е ш а б л о н ы н у ж н о о с т а в и т ь , а к а к и е — п е р е н а з н а ч и т ь
2025-12-25 12:07:53 +10:00
var templatesToKeep = new List < TemplateNextRunResultDto > ( ) ;
var templatesToReassign = new List < TemplateNextRunDto > ( ) ;
foreach ( var kvp in templatesByDay )
2025-12-22 17:03:41 +10:00
{
2025-12-25 12:07:53 +10:00
var day = kvp . Key ;
var templates = kvp . Value ;
2025-12-22 17:03:41 +10:00
2025-12-25 12:07:53 +10:00
// Если день — нерабочий, то все шаблоны нужно переназначить
if ( ! workDays . Contains ( day ) )
{
foreach ( var template in templates )
2025-12-22 17:03:41 +10:00
{
2025-12-25 12:07:53 +10:00
templatesToReassign . Add ( template ) ;
logger . LogDebug ( "Шаблон {TemplateId} назначен на нерабочий день {NextRun} — будет переназначен." , template . Id , template . NextRun ) ;
}
continue ;
}
2025-12-22 17:03:41 +10:00
2025-12-25 12:07:53 +10:00
var expectedCount = expectedDailyLoad . GetValueOrDefault ( day , 0 ) ;
var currentCount = templates . Count ;
2025-12-22 17:03:41 +10:00
2025-12-25 12:07:53 +10:00
if ( currentCount < = expectedCount )
{
2026-03-05 10:19:54 +10:00
// В с е шаблоны в этом дне можно оставить, но нужно проверить/скорректировать время
2025-12-25 12:07:53 +10:00
foreach ( var template in templates )
{
2026-03-05 10:19:54 +10:00
// Конвертируем текущий 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 (
2026-01-20 15:04:36 +10:00
Id : template . Id ,
2026-03-05 10:19:54 +10:00
NextRun : newNextRun ,
2026-01-20 15:04:36 +10:00
NextRunOld : template . NextRun
) ) ;
2025-12-22 17:03:41 +10:00
}
2025-12-25 12:07:53 +10:00
}
else
{
// Оставляем только expectedCount штук, остальные — переназначить
2026-03-05 10:19:54 +10:00
// (для оставляемых тоже нужно проверить/скорректировать время)
2025-12-25 12:07:53 +10:00
for ( int i = 0 ; i < expectedCount ; i + + )
{
var template = templates [ i ] ;
2026-03-05 10:19:54 +10:00
// Конвертируем текущий 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 (
2026-01-20 15:04:36 +10:00
Id : template . Id ,
2026-03-05 10:19:54 +10:00
NextRun : newNextRun ,
2026-01-20 15:04:36 +10:00
NextRunOld : template . NextRun
) ) ;
2025-12-25 12:07:53 +10:00
}
for ( int i = expectedCount ; i < currentCount ; i + + )
2025-12-22 17:03:41 +10:00
{
2025-12-25 12:07:53 +10:00
var template = templates [ i ] ;
templatesToReassign . Add ( template ) ;
logger . LogDebug ( "Шаблон {TemplateId} переназначается, т.к. нагрузка в день превышена." , template . Id ) ;
2025-12-22 17:03:41 +10:00
}
}
}
2025-12-25 12:07:53 +10:00
#endregion
2025-12-22 17:03:41 +10:00
2026-03-05 10:19:54 +10:00
#region 9. Д о б а в л я е м ш а б л о н ы , у к о т о р ы х н е б ы л о д а т ы — т о ж е н у ж н о р а с п р е д е л и т ь
2025-12-22 17:03:41 +10:00
2025-12-25 12:07:53 +10:00
foreach ( var template in allTemplates )
{
if ( ! template . NextRun . HasValue )
{
templatesToReassign . Add ( template ) ;
logger . LogDebug ( "Шаблон {TemplateId} не имел даты — будет распределён." , template . Id ) ;
}
}
2025-12-22 17:03:41 +10:00
2025-12-25 12:07:53 +10:00
#endregion
2025-12-22 17:03:41 +10:00
2026-03-05 10:19:54 +10:00
#region 10. Р а с п р е д е л я е м ш а б л о н ы , к о т о р ы е н у ж н о п е р е н а з н а ч и т ь , у ч и т ы в а я н а г р у з к у
2025-12-22 17:03:41 +10:00
2025-12-25 12:07:53 +10:00
var updatedTemplates = new List < TemplateNextRunResultDto > ( templatesToKeep ) ;
2025-12-22 17:03:41 +10:00
2026-03-05 10:19:54 +10:00
foreach ( var template in templatesToReassign . OrderBy ( t = > t . NextRun ) )
2025-12-25 12:07:53 +10:00
{
2026-03-05 10:19:54 +10:00
var nextRunInTargetTz = FindNextAvailableDateWithLoad ( currentDailyLoad , workDays , referenceDateInTargetTz , expectedDailyLoad , targetOffset ) ;
if ( nextRunInTargetTz . HasValue )
2025-12-25 12:07:53 +10:00
{
2026-03-05 10:19:54 +10:00
var day = DateOnly . FromDateTime ( nextRunInTargetTz . Value . Date ) ;
2025-12-25 12:07:53 +10:00
currentDailyLoad [ day ] + + ;
2025-12-22 17:03:41 +10:00
2026-03-05 10:19:54 +10:00
// КОНВЕРТИРУЕМ В UTC ПЕРЕД В О З В Р А Т О М !
2025-12-25 12:07:53 +10:00
updatedTemplates . Add ( new TemplateNextRunResultDto
2026-01-20 15:04:36 +10:00
(
Id : template . Id ,
2026-03-05 10:19:54 +10:00
NextRun : nextRunInTargetTz . Value . ToOffset ( TimeSpan . Zero ) , // ← В UTC!
2026-01-20 15:04:36 +10:00
NextRunOld : template . NextRun
) ) ;
2025-12-22 17:03:41 +10:00
2026-03-05 10:19:54 +10:00
logger . LogDebug ( "Шаблону {Id} назначен NextRun: {nextRunTarget} (пояс {offset}) → {nextRunUtc} (UTC). NextRunOld: {NextRunOld}" , template . Id , nextRunInTargetTz . Value , targetOffset , nextRunInTargetTz . Value . ToOffset ( TimeSpan . Zero ) , template . NextRun ) ;
2025-12-25 12:07:53 +10:00
}
else
{
logger . LogWarning ( "Н е удалось назначить дату для шаблона {TemplateId}" , template . Id ) ;
2025-12-22 17:03:41 +10:00
2026-03-05 10:19:54 +10:00
// Возвращаем с о смещением на несколько лет, так как ошибка
// конвертируем в UTC
updatedTemplates . Add ( FillTemplateResultWithOffsetError ( template , _startDate , referenceDateInTargetTz , targetOffset ) ) ;
2025-12-25 12:07:53 +10:00
}
}
2025-12-22 17:03:41 +10:00
2025-12-25 12:07:53 +10:00
#endregion
2025-12-22 17:03:41 +10:00
2026-03-05 10:19:54 +10:00
// 11. Сортируем и возвращаем результат в UTC
2025-12-22 17:03:41 +10:00
2026-03-05 10:19:54 +10:00
var result = updatedTemplates . OrderBy ( t = > t . NextRun ) . ToList ( ) ;
2025-12-22 17:03:41 +10:00
2026-01-20 15:04:36 +10:00
logger . LogDebug ( "--- Результат распределения ---" ) ;
2026-03-05 10:19:54 +10:00
foreach ( var item in result )
logger . LogDebug ( "Шаблон {templateId}, NextRun UTC: {NextRun}, NextRunOld UTC: {NextRunOld}" , item . Id , item . NextRun , item . NextRunOld ) ;
2025-12-22 17:03:41 +10:00
2026-03-05 10:19:54 +10:00
logger . LogInformation ( "Распределение завершено. Возвращаем {Count} записей в UTC. Изменено: {changedCount}. Остались прежние значения: {oldCount}" , result . Count , result . Count ( t = > t . NextRun ! = t . NextRunOld ) , result . Count ( t = > t . NextRun = = t . NextRunOld ) ) ;
2025-12-22 17:03:41 +10:00
2026-03-05 10:19:54 +10:00
return result ;
2025-12-22 17:03:41 +10:00
}
2026-01-20 15:04:36 +10:00
/// <summary>
2026-03-05 10:19:54 +10:00
/// Обрабатывает случай, когда у шаблона нет текущего NextRun.
2026-01-20 15:04:36 +10:00
/// </summary>
2026-03-05 10:19:54 +10:00
private TemplateNextRunResultDto HandleNoNextRun ( TemplateNextRunDto targetTemplate , List < DateOnly > workDays , Dictionary < DateOnly , int > currentDailyLoad , Dictionary < DateOnly , int > expectedDailyLoad , DateTimeOffset referenceDateInTargetTz , TimeSpan targetOffset , bool isNew )
2026-01-20 15:04:36 +10:00
{
2026-03-05 10:19:54 +10:00
logger . LogDebug ( "Обработка случая 'нет текущего NextRun' для шаблона {templateId} (isNew={isNew})" , targetTemplate . Id , isNew ) ;
2026-01-20 15:04:36 +10:00
2026-03-05 10:19:54 +10:00
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 ;
2026-01-20 15:04:36 +10:00
2026-03-05 10:19:54 +10:00
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 ) ;
}
2026-01-20 15:04:36 +10:00
/// <summary>
2026-03-05 10:19:54 +10:00
/// Обрабатывает случай, когда текущий NextRun неактуален.
2026-01-20 15:04:36 +10:00
/// </summary>
2026-03-05 10:19:54 +10:00
private TemplateNextRunResultDto HandleOutdatedNextRun ( TemplateNextRunDto targetTemplate , DateOnly nextRunDate , bool isInPeriod , bool isWorkDay , bool isNew , List < DateOnly > workDays , Dictionary < DateOnly , int > currentDailyLoad , Dictionary < DateOnly , int > expectedDailyLoad , DateTimeOffset referenceDateInTargetTz , TimeSpan targetOffset , DateOnly periodStart , DateOnly periodEnd )
2026-01-20 15:04:36 +10:00
{
2026-03-05 10:19:54 +10:00
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 ;
}
}
2026-01-20 15:04:36 +10:00
2026-03-05 10:19:54 +10:00
if ( ! foundDay . HasValue )
{
foundDay = workDays . Last ( ) ;
logger . LogWarning ( "В с е дни перегружены. Берём последний день периода: {day}" , foundDay . Value ) ;
}
}
}
else
2026-01-20 15:04:36 +10:00
{
2026-03-05 10:19:54 +10:00
// Другие случаи (например, время уже прошло)
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 ( ) ;
}
2026-01-20 15:04:36 +10:00
}
2026-03-05 10:19:54 +10:00
// Формируем результат
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 ) ;
}
/// <summary>
/// Корректирует найденный день, чтобы момент времени был в будущем.
/// Если момент времени уже прошёл, ищет следующий рабочий день в будущем с учётом нагрузки.
/// В случае ошибки (нет будущих рабочих дней) возвращает ошибку через out-параметр.
/// </summary>
private DateTimeOffset ? AdjustNextRunToFuture ( DateOnly foundDay , List < DateOnly > workDays , Dictionary < DateOnly , int > currentDailyLoad , Dictionary < DateOnly , int > 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
2026-01-20 15:04:36 +10:00
{
2026-03-05 10:19:54 +10:00
logger . LogDebug ( "Момент времени {nextRun} актуален (в будущем). Коррекция не требуется." , nextRunInTargetTz ) ;
2026-01-20 15:04:36 +10:00
}
2026-03-05 10:19:54 +10:00
return nextRunInTargetTz ;
}
/// <summary>
/// Строит текущую нагрузку на дни, учитывая ТОЛЬКО шаблоны внутри периода.
/// Шаблоны вне периода игнорируются.
/// </summary>
private Dictionary < DateOnly , int > BuildCurrentLoad ( List < TemplateNextRunDto > templates , List < DateOnly > workDays , DateOnly periodStart , DateOnly periodEnd , TimeSpan targetOffset )
{
var load = workDays . ToDictionary ( day = > day , _ = > 0 ) ;
2026-01-20 15:04:36 +10:00
2026-03-05 10:19:54 +10:00
foreach ( var template in templates )
2026-01-20 15:04:36 +10:00
{
2026-03-05 10:19:54 +10:00
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 ] + + ;
}
2026-01-20 15:04:36 +10:00
}
2026-03-05 10:19:54 +10:00
return load ;
2026-01-20 15:04:36 +10:00
}
2026-03-05 10:19:54 +10:00
/// <summary>
/// Заполнить TemplateNextRunResultDto, если нет рабочих дней с о смещением несколько лет
/// </summary>
/// <param name="template"></param>
/// <returns></returns>
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
) ;
}
2026-01-20 15:04:36 +10:00
2025-12-22 17:03:41 +10:00
/// <summary>
2025-12-25 12:07:53 +10:00
/// Находит следующий день с доступной нагрузкой, учитывая expectedDailyLoad
2025-12-22 17:03:41 +10:00
/// </summary>
2025-12-25 12:07:53 +10:00
/// <param name="currentDailyLoad"></param>
/// <param name="workDays"></param>
/// <param name="referenceDate"></param>
/// <param name="expectedDailyLoad"></param>
/// <returns></returns>
2026-03-05 10:19:54 +10:00
private DateTimeOffset ? FindNextAvailableDateWithLoad ( Dictionary < DateOnly , int > currentDailyLoad , List < DateOnly > workDays , DateTimeOffset referenceDateInTargetTz , Dictionary < DateOnly , int > expectedDailyLoad , TimeSpan targetOffset )
2025-12-22 17:03:41 +10:00
{
2025-12-25 12:07:53 +10:00
foreach ( var day in workDays )
{
var expected = expectedDailyLoad . GetValueOrDefault ( day , 0 ) ;
var current = currentDailyLoad . GetValueOrDefault ( day , 0 ) ;
2025-12-22 17:03:41 +10:00
2025-12-25 12:07:53 +10:00
if ( current < expected )
{
2026-03-05 10:19:54 +10:00
// Создаём момент времени В ЦЕЛЕВОМ ПОЯСЕ
return new DateTimeOffset (
day . Year , day . Month , day . Day ,
referenceDateInTargetTz . Hour ,
referenceDateInTargetTz . Minute ,
referenceDateInTargetTz . Second ,
targetOffset ) ;
2025-12-22 17:03:41 +10:00
}
}
2026-03-05 10:19:54 +10:00
// Если все дни загружены — берём день с минимальной нагрузкой
var minDay = currentDailyLoad . MinBy ( x = > x . Value ) . Key ;
return new DateTimeOffset (
minDay . Year , minDay . Month , minDay . Day ,
referenceDateInTargetTz . Hour ,
referenceDateInTargetTz . Minute ,
referenceDateInTargetTz . Second ,
targetOffset ) ;
2025-12-22 17:03:41 +10:00
}
2025-12-25 12:07:53 +10:00
2025-12-22 17:03:41 +10:00
/// <summary>
2026-03-05 10:19:54 +10:00
/// Получить план распределения шаблонов по рабочим дням.
2025-12-22 17:03:41 +10:00
/// </summary>
2026-03-05 10:19:54 +10:00
/// <param name="totalWorks">Общее количество шаблонов для распределения</param>
/// <param name="workDays">Список рабочих дней в периоде</param>
/// <param name="mirror">Если true — строит зеркальный план (справа налево от конца периода), иначе обычный (слева направо от начала периода)</param>
/// <returns>Список дат, на которые должны быть назначены шаблоны</returns>
private List < DateOnly > GetDistributionPlan ( int totalWorks , List < DateOnly > workDays , bool mirror = false )
2025-12-22 17:03:41 +10:00
{
2026-03-05 10:19:54 +10:00
var totalDays = workDays . Count ;
var interval = CalculateDistributionInterval ( totalDays , totalWorks ) ;
2025-12-22 17:03:41 +10:00
2025-12-25 12:07:53 +10:00
var plan = new List < DateOnly > ( ) ;
for ( int i = 0 ; i < totalWorks ; i + + )
2025-12-22 17:03:41 +10:00
{
2026-03-05 10:19:54 +10:00
// Базовый индекс для обычного плана (слева → направо)
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 ] ) ;
2025-12-22 17:03:41 +10:00
}
2026-03-05 10:19:54 +10:00
// Логгирование
var planType = mirror ? "Зеркальный" : "Обычный" ;
logger . LogDebug ( "--- {planType} план распределения ({totalWorks} шаблонов, {totalDays} р а б . дней) ---" , planType , totalWorks , totalDays ) ;
foreach ( var item in plan . GroupBy ( t = > t ) . OrderBy ( g = > g . Key ) )
2026-01-20 15:04:36 +10:00
logger . LogDebug ( "Дата: {date}, кол-во работ: {workCount}" , item . Key , item . Count ( ) ) ;
2025-12-25 12:07:53 +10:00
return plan ;
}
2025-12-22 17:03:41 +10:00
2025-12-25 12:07:53 +10:00
/// <summary>
/// Рассчитать интервал между шаблонами
/// </summary>
/// <param name="totalDays">Кол-во рабочих дней</param>
/// <param name="totalWorks">Кол-во шаблонов</param>
/// <returns></returns>
2026-03-05 10:19:54 +10:00
private double CalculateDistributionInterval ( int totalDays , int totalWorks )
2025-12-25 12:07:53 +10:00
{
var interval = ( double ) totalDays / totalWorks ;
2025-12-22 17:03:41 +10:00
2026-03-05 10:19:54 +10:00
logger . LogDebug ( "Интервал распределения: {interval} (дней на шаблон)" , interval ) ;
2026-01-26 14:29:47 +10:00
2025-12-25 12:07:53 +10:00
return interval ;
2025-12-22 17:03:41 +10:00
}
/// <summary>
2026-03-05 10:19:54 +10:00
/// Получить дату начала распределения с учётом целевого часового пояса.
/// Метод возвращает календарную дату (DateOnly), рассчитанную в контексте указанного оффсета.
2025-12-25 12:07:53 +10:00
/// </summary>
2026-03-05 10:19:54 +10:00
/// <param name="startDate">Стартовая дата (интерпретируется в целевом часовом поясе)</param>
/// <param name="referenceDate">Момент времени в целевом часовом поясе (после применения оффсета)</param>
/// <param name="offset">Целевой часовой пояс (оффсет)</param>
/// <returns>Дата начала распределения</returns>
private DateOnly GetStartPeriodDate ( DateOnly startDate , DateTimeOffset referenceDate , TimeSpan offset )
2025-12-25 12:07:53 +10:00
{
2026-03-05 10:19:54 +10:00
var dateStart = startDate ;
2025-12-25 12:07:53 +10:00
2026-03-05 10:19:54 +10:00
// 1. Дата начала не может быть раньше даты из referenceDate (в целевом поясе)
2026-02-10 15:50:30 +10:00
var refDateInTargetTz = DateOnly . FromDateTime ( referenceDate . Date ) ;
if ( dateStart < refDateInTargetTz )
{
2026-03-05 10:19:54 +10:00
logger . LogDebug ( "Дата начала {startDate} раньше даты из referenceDate в поясе {offset} ({refDate}). Используем {refDate}." ,
dateStart , offset , refDateInTargetTz , refDateInTargetTz ) ;
2026-02-10 15:50:30 +10:00
dateStart = refDateInTargetTz ;
}
2026-03-05 10:19:54 +10:00
// 2. Дата начала не может быть раньше "сегодня" в целевом часовом поясе
var todayInTargetTz = DateOnly . FromDateTime ( DateTimeOffset . UtcNow . ToOffset ( offset ) . Date ) ;
2026-02-10 15:50:30 +10:00
if ( dateStart < todayInTargetTz )
{
2026-03-05 10:19:54 +10:00
logger . LogDebug ( "Дата начала {startDate} раньше 'сегодня' в поясе {offset} ({today}). Используем {today}." ,
dateStart , offset , todayInTargetTz , todayInTargetTz ) ;
2026-02-10 15:50:30 +10:00
dateStart = todayInTargetTz ;
}
2026-03-05 10:19:54 +10:00
// 3. Если момент времени "начало дня в целевом поясе + время из referenceDate" уже прошёл — добавляем 1 день
// ВАЖНО: время берём из самого referenceDate (который уже в целевом поясе!)
var dateTimeStart = new DateTimeOffset (
dateStart . Year , dateStart . Month , dateStart . Day ,
referenceDate . Hour , // ← Время из целевого пояса!
referenceDate . Minute ,
referenceDate . Second ,
offset ) ; // ← Оффсет целевого пояса
// Сравниваем абсолютные моменты времени (работает корректно даже с разными оффсетами)
2026-01-20 15:04:36 +10:00
if ( dateTimeStart < DateTimeOffset . UtcNow )
2026-02-10 15:50:30 +10:00
{
2026-03-05 10:19:54 +10:00
logger . LogDebug ( "Момент времени {dateTimeStart} (в поясе {offset}) уже прошёл (сейчас {now}). Добавляем 1 день." ,
dateTimeStart , offset , DateTimeOffset . UtcNow ) ;
2026-01-20 15:04:36 +10:00
dateStart = dateStart . AddDays ( 1 ) ;
2026-02-10 15:50:30 +10:00
}
2026-03-05 10:19:54 +10:00
logger . LogDebug ( "Итоговая дата начала распределения в поясе {offset}: {dateStart}" , offset , dateStart ) ;
2025-12-25 12:07:53 +10:00
return dateStart ;
2025-12-22 17:03:41 +10:00
}
/// <summary>
2026-03-05 10:19:54 +10:00
/// Получить дату окончания периода распределения.
/// Продолжительность включает стартовый день (например, duration=7 означает 7 дней: с 1 по 7 июня).
2025-12-22 17:03:41 +10:00
/// </summary>
2025-12-25 12:07:53 +10:00
/// <param name="startDate"></param>
/// <param name="duration"></param>
/// <returns></returns>
private DateOnly GetEndPeriodDate ( DateOnly startDate , int duration )
2025-12-22 17:03:41 +10:00
{
2026-03-05 10:19:54 +10:00
if ( duration < = 0 )
{
logger . LogWarning ( "Некорректная продолжительность периода: {duration}. Используем минимальное значение 1." , duration ) ;
duration = 1 ;
}
2025-12-25 12:07:53 +10:00
// duration — количество дней, включая startDate
// поэтому вычитаем 1, чтобы получить сдвиг
2026-03-05 10:19:54 +10:00
var endDate = startDate . AddDays ( duration - 1 ) ;
logger . LogDebug ( "Расчёт даты окончания периода: startDate={startDate}, duration={duration}, endDate={endDate}" , startDate , duration , endDate ) ;
2025-12-22 17:03:41 +10:00
2026-03-05 10:19:54 +10:00
return endDate ;
}
2025-12-22 17:03:41 +10:00
2026-01-28 14:40:40 +10:00
/// <summary>
/// Получить список выходных дней
/// </summary>
/// <param name="startDate"></param>
/// <param name="endDate"></param>
/// <returns></returns>
2026-03-05 10:19:54 +10:00
public async Task < HashSet < DateOnly > > GetWeekendsAsync ( DateOnly startDate , DateOnly endDate )
2026-01-28 14:40:40 +10:00
{
2026-03-05 10:19:54 +10:00
//Возвращает все выходные дни включая с б и вск, и праздники
2026-01-28 14:40:40 +10:00
var weekends = await weekendDayService . GetWeekends ( startDate , endDate ) . ToListAsync ( ) ;
2026-03-05 10:19:54 +10:00
logger . LogDebug ( "Найдено не рабочих дней: {Count}" , weekends . Count ) ;
foreach ( var weekend in weekends )
2026-01-28 14:40:40 +10:00
logger . LogDebug ( "Н е рабочий день: {weekend}" , weekend ) ;
2026-03-05 10:19:54 +10:00
return weekends . ToHashSet ( ) ;
2026-01-28 14:40:40 +10:00
}
2026-03-05 10:19:54 +10:00
public async Task < List < DateOnly > > GetWorkDaysAsync ( DateOnly start , DateOnly end , bool excludeWeekends )
2026-01-28 14:40:40 +10:00
{
// Получаем список выходных/праздничных дней
var weekends = new HashSet < DateOnly > ( ) ;
if ( excludeWeekends )
2026-03-05 10:19:54 +10:00
weekends = await GetWeekendsAsync ( start , end ) ;
2026-01-28 14:40:40 +10:00
var result = new List < DateOnly > ( ) ;
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 ;
}
2026-02-10 15:50:30 +10:00
2026-03-05 10:19:54 +10:00
/// <summary>
/// Получает рабочие дни с автоматическим расширением периода при их отсутствии.
/// Расширяет период до maxPeriodExpansions раз: _startDate + duration * N (N = 1, 2, 3)
/// </summary>
private async Task < ( DateOnly endDate , List < DateOnly > workDays ) > GetWorkDaysWithExpansionAsync ( DateOnly startDate , int duration , bool excludeWeekends )
2026-01-28 14:40:40 +10:00
{
2026-03-05 10:19:54 +10:00
// Попытка 0: исходный период [_startDate, _startDate + duration - 1]
var currentEndDate = startDate . AddDays ( duration - 1 ) ;
var workDays = await GetWorkDaysAsync ( startDate , currentEndDate , excludeWeekends ) ;
2026-01-28 14:40:40 +10:00
2026-03-05 10:19:54 +10:00
if ( workDays . Count > 0 )
{
logger . LogDebug ( "Найдено {count} рабочих дней в исходном периоде [{start}, {end}]" , workDays . Count , startDate , currentEndDate ) ;
return ( currentEndDate , workDays ) ;
}
2026-01-28 14:40:40 +10:00
2026-03-05 10:19:54 +10:00
// Попытки расширения 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 ) ;
2026-01-28 14:40:40 +10:00
2026-03-05 10:19:54 +10:00
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 < DateOnly > ( ) ) ; // Пустой список для обработки ошибки вызывающим кодом
}
2025-12-22 17:03:41 +10:00
}
}