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