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

496 lines
25 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;
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;
}
}
}