496 lines
25 KiB
C#
496 lines
25 KiB
C#
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;
|
||
|
||
public TemplateDistributorV2(
|
||
ILogger<TemplateDistributorV2> logger,
|
||
IWeekendDayService weekendDayService
|
||
)
|
||
{
|
||
this.logger = logger;
|
||
this.weekendDayService = weekendDayService;
|
||
}
|
||
|
||
public async Task<List<TemplateNextRunResultDto>> DistributeTemplatesAsync(DateOnly startDate, int duration, DateTimeOffset referenceDate, TimeSpan? offset, List<TemplateNextRunDto> allTemplates, bool excludeWeekends)
|
||
{
|
||
// 1. Определяем целевой часовой пояс
|
||
var targetOffset = offset ?? TimeSpan.Zero;
|
||
|
||
// 2. Применяем оффсет к referenceDate для расчётов в целевом поясе
|
||
var referenceDateInTargetTz = referenceDate.ToOffset(targetOffset);
|
||
|
||
logger.LogDebug("Распределение шаблонов в часовом поясе {offset}. ReferenceDate в целевом поясе: {refDate}", targetOffset, referenceDateInTargetTz);
|
||
|
||
// 3. Рассчитываем период распределения С УЧЁТОМ ЧАСОВОГО ПОЯСА
|
||
var _startDate = GetStartPeriodDate(startDate, referenceDateInTargetTz, targetOffset);
|
||
var _endDate = GetEndPeriodDate(_startDate, duration);
|
||
|
||
logger.LogInformation("Начинаем распределение {Count} шаблонов на {duration} дней, с {_startDate} по {_endDate}. ReferenceDate: {referenceDateInTargetTz} в целевом поясе: {targetOffset}. Исключить выходные и праздники: {excludeWeekends}.", allTemplates.Count, duration, _startDate, _endDate, referenceDateInTargetTz, targetOffset, excludeWeekends);
|
||
|
||
// 4. Получаем рабочие дни (календарные даты, не зависят от пояса)
|
||
var workDays = await GetWorkDaysAsync(_startDate, _endDate, excludeWeekends);
|
||
|
||
if (workDays.Count == 0)
|
||
{
|
||
logger.LogWarning("Нет доступных рабочих дней в диапазоне {_startDate} - {_endDate} (пояс {offset}). Возвращаем шаблоны со смещением {years} лет.", _startDate, _endDate, targetOffset, offsetYearsIfErrorCalcNextRun);
|
||
|
||
// смещаем все nextRun вперед
|
||
var errorResult = allTemplates.Select(t =>
|
||
FillTemplateResultWithOffsetError(t, _startDate, referenceDateInTargetTz, targetOffset)
|
||
).OrderBy(t => t.NextRun).ToList();
|
||
|
||
logger.LogInformation("Распределение завершено. Возвращаем {Count} записей. NextRun не изменился у {oldCount} шт. Обновлен NextRun у {changedCount} шт.", errorResult.Count, errorResult.Count(t => t.NextRun == t.NextRunOld), errorResult.Count(t => t.NextRun != t.NextRunOld));
|
||
|
||
return errorResult;
|
||
}
|
||
|
||
|
||
// 5. Строим план распределения
|
||
// строим план, дни на которые должны быть назанчены шаблоны
|
||
var plan = GetPlan(allTemplates.Count, workDays);
|
||
// Подсчитываем, сколько работ должно быть на каждый день
|
||
var expectedDailyLoad = plan.GroupBy(d => d)
|
||
.ToDictionary(g => g.Key, g => g.Count());
|
||
|
||
#region Группируем шаблоны по дням В ЦЕЛЕВОМ ПОЯСЕ, на которые они уже назначены
|
||
|
||
var templatesByDay = new Dictionary<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)
|
||
{
|
||
// КОНВЕРТИРУЕМ В UTC ПЕРЕД СОХРАНЕНИЕМ!
|
||
templatesToKeep.Add(new TemplateNextRunResultDto
|
||
(
|
||
Id: template.Id,
|
||
NextRun: template.NextRun.Value.ToOffset(TimeSpan.Zero), // ← В UTC!
|
||
NextRunOld: template.NextRun
|
||
));
|
||
logger.LogDebug("Шаблон {TemplateId} оставлен на дату {NextRun} (UTC), т.к. нагрузка в день не превышена.", template.Id, template.NextRun.Value.ToOffset(TimeSpan.Zero));
|
||
}
|
||
}
|
||
else
|
||
{
|
||
// Оставляем только expectedCount штук, остальные — переназначить
|
||
for (int i = 0; i < expectedCount; i++)
|
||
{
|
||
var template = templates[i];
|
||
// КОНВЕРТИРУЕМ В UTC ПЕРЕД СОХРАНЕНИЕМ!
|
||
templatesToKeep.Add(new TemplateNextRunResultDto
|
||
(
|
||
Id: template.Id,
|
||
NextRun: template.NextRun.Value.ToOffset(TimeSpan.Zero), // ← В UTC!
|
||
NextRunOld: template.NextRun
|
||
));
|
||
logger.LogDebug("Шаблон {TemplateId} оставлен на дату {NextRun} (UTC), т.к. нагрузка в день не превышена.", template.Id, template.NextRun.Value.ToOffset(TimeSpan.Zero));
|
||
}
|
||
|
||
for (int i = expectedCount; i < currentCount; i++)
|
||
{
|
||
var template = templates[i];
|
||
templatesToReassign.Add(template);
|
||
logger.LogDebug("Шаблон {TemplateId} переназначается, т.к. нагрузка в день превышена.", template.Id);
|
||
}
|
||
}
|
||
}
|
||
|
||
#endregion
|
||
|
||
#region 9. Добавляем шаблоны, у которых не было даты — тоже нужно распределить
|
||
|
||
foreach (var template in allTemplates)
|
||
{
|
||
if (!template.NextRun.HasValue)
|
||
{
|
||
templatesToReassign.Add(template);
|
||
logger.LogDebug("Шаблон {TemplateId} не имел даты — будет распределён.", template.Id);
|
||
}
|
||
}
|
||
|
||
#endregion
|
||
|
||
#region 10. Распределяем шаблоны, которые нужно переназначить, учитывая нагрузку
|
||
|
||
var updatedTemplates = new List<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>
|
||
/// Заполнить 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>
|
||
/// <returns></returns>
|
||
private List<DateOnly> GetPlan(int totalWorks, List<DateOnly> workDays)
|
||
{
|
||
var totalDays = workDays.Count();
|
||
|
||
var interval = GetInterval(totalDays, totalWorks);
|
||
|
||
var plan = new List<DateOnly>();
|
||
|
||
for (int i = 0; i < totalWorks; i++)
|
||
{
|
||
#region Вынести в метод
|
||
// Вычисляем индекс дня, на который нужно назначить i-ю работу
|
||
//var dayIndex = (int)Math.Round(i * interval);
|
||
var dayIndex = (int)Math.Floor(i * interval);
|
||
|
||
// Защита от возможного выхода за границы массива из-за округления
|
||
if (dayIndex >= totalDays)
|
||
dayIndex = totalDays - 1;
|
||
#endregion
|
||
|
||
var date = workDays[dayIndex];
|
||
plan.Add(date);
|
||
}
|
||
|
||
logger.LogDebug("--- План распределения ---");
|
||
foreach (var item in plan.GroupBy(t => t))
|
||
logger.LogDebug("Дата: {date}, кол-во работ: {workCount}", item.Key, item.Count());
|
||
|
||
return plan;
|
||
}
|
||
|
||
|
||
/// <summary>
|
||
/// Рассчитать интервал между шаблонами
|
||
/// </summary>
|
||
/// <param name="totalDays">Кол-во рабочих дней</param>
|
||
/// <param name="totalWorks">Кол-во шаблонов</param>
|
||
/// <returns></returns>
|
||
private double GetInterval(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>
|
||
public 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;
|
||
}
|
||
|
||
|
||
}
|
||
}
|