Files
parr_api/PARR.DAL/NextRunServices/Subservices/TemplateDistributorV2.cs

1267 lines
71 KiB
C#
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Logging;
using PARR.DAL.NextRunServices.Models;
using PARR.DAL.Services.Interfaces;
namespace PARR.DAL.NextRunServices.Subservices
{
/// <summary>
/// Распределитель шаблонов с поддержкой часовых поясов.
/// Все входные даты интерпретируются в целевом часовом поясе (параметр offset).
/// Все результаты возвращаются строго в UTC (TimeSpan.Zero).
/// </summary>
internal class TemplateDistributorV2 : ITemplateDistributorV2
{
private readonly ILogger<TemplateDistributorV2> logger;
private readonly IWeekendDayService weekendDayService;
/// <summary>
/// Смещение nextRun на несколько лет вперед, если не смог его рассчитать
/// </summary>
private const int offsetYearsIfErrorCalcNextRun = 5;
/// <summary>
/// Максимальное количество попыток расширения периода при отсутствии рабочих дней
/// </summary>
private const int maxPeriodExpansions = 2;
public TemplateDistributorV2(ILogger<TemplateDistributorV2> logger, IWeekendDayService weekendDayService)
{
this.logger = logger;
this.weekendDayService = weekendDayService;
}
public async Task<TemplateNextRunResultDto> GetNextRunForTemplateAsync(DateOnly startDate, int duration, DateTimeOffset referenceDate, TimeSpan? offset, TemplateNextRunDto targetTemplate, List<TemplateNextRunDto> 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<DateOnly> 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);
}
/// <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)
{
// Валидация входных параметров
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<DateOnly, List<TemplateNextRunDto>>();
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<TemplateNextRunDto>();
}
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<TemplateNextRunResultDto>();
var templatesToReassign = new List<TemplateNextRunDto>();
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<TemplateNextRunResultDto>(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;
}
/// <summary>
/// Обрабатывает случай, когда у шаблона нет текущего NextRun.
/// </summary>
private TemplateNextRunResultDto HandleNoNextRun(TemplateNextRunDto targetTemplate, List<DateOnly> workDays, Dictionary<DateOnly, int> currentDailyLoad, Dictionary<DateOnly, int> 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);
}
/// <summary>
/// Обрабатывает случай, когда текущий NextRun неактуален.
/// </summary>
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)
{
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);
}
/// <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
{
logger.LogDebug("Момент времени {nextRun} актуален (в будущем). Коррекция не требуется.", nextRunInTargetTz);
}
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);
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;
}
/// <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
);
}
/// <summary>
/// Находит следующий день с доступной нагрузкой, учитывая expectedDailyLoad
/// </summary>
/// <param name="currentDailyLoad"></param>
/// <param name="workDays"></param>
/// <param name="referenceDate"></param>
/// <param name="expectedDailyLoad"></param>
/// <returns></returns>
private DateTimeOffset? FindNextAvailableDateWithLoad(Dictionary<DateOnly, int> currentDailyLoad, List<DateOnly> workDays, DateTimeOffset referenceDateInTargetTz, Dictionary<DateOnly, int> 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);
}
/// <summary>
/// Получить план распределения шаблонов по рабочим дням.
/// </summary>
/// <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)
{
var totalDays = workDays.Count;
var interval = CalculateDistributionInterval(totalDays, totalWorks);
var plan = new List<DateOnly>();
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;
}
/// <summary>
/// Рассчитать интервал между шаблонами
/// </summary>
/// <param name="totalDays">Кол-во рабочих дней</param>
/// <param name="totalWorks">Кол-во шаблонов</param>
/// <returns></returns>
private double CalculateDistributionInterval(int totalDays, int totalWorks)
{
var interval = (double)totalDays / totalWorks;
logger.LogDebug("Интервал распределения: {interval} (дней на шаблон)", interval);
return interval;
}
/// <summary>
/// Получить дату начала распределения с учётом целевого часового пояса.
/// Метод возвращает календарную дату (DateOnly), рассчитанную в контексте указанного оффсета.
/// </summary>
/// <param name="startDate">Стартовая дата (интерпретируется в целевом часовом поясе)</param>
/// <param name="referenceDate">Момент времени в целевом часовом поясе (после применения оффсета)</param>
/// <param name="offset">Целевой часовой пояс (оффсет)</param>
/// <returns>Дата начала распределения</returns>
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;
}
/// <summary>
/// Получить дату окончания периода распределения.
/// Продолжительность включает стартовый день (например, duration=7 означает 7 дней: с 1 по 7 июня).
/// </summary>
/// <param name="startDate"></param>
/// <param name="duration"></param>
/// <returns></returns>
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;
}
/// <summary>
/// Получить список выходных дней
/// </summary>
/// <param name="startDate"></param>
/// <param name="endDate"></param>
/// <returns></returns>
public async Task<HashSet<DateOnly>> 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();
}
/// <summary>
/// Получить список рабочих дней в диапазоне, исключая выходные и праздники.
/// </summary>
/// <param name="start"></param>
/// <param name="end"></param>
/// <param name="excludeWeekends">Исключить выходные и праздники</param>
/// <returns></returns>
private async Task<List<DateOnly>> GetWorkDaysAsync(DateOnly start, DateOnly end, bool excludeWeekends)
{
// Получаем список выходных/праздничных дней
var weekends = new HashSet<DateOnly>();
if (excludeWeekends)
weekends = await GetWeekendsAsync(start, end);
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;
}
/// <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)
{
// Попытка 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<DateOnly>()); // Пустой список для обработки ошибки вызывающим кодом
}
}
}