feat(dal): ITemplateDistributorV2 - распределяет шаблоны с учетом часового пояса

This commit is contained in:
Mikhail Trubnikov
2026-02-10 15:50:30 +10:00
parent 897dd4c4e3
commit 7c8f8bbf97
7 changed files with 707 additions and 124 deletions

View File

@@ -2,6 +2,7 @@
using PARR.DAL.Contracts; using PARR.DAL.Contracts;
using PARR.DAL.DomainModels; using PARR.DAL.DomainModels;
using PARR.DAL.Services.Interfaces; using PARR.DAL.Services.Interfaces;
using System.Text.Json;
namespace PARR.DAL.NextRunServices.Subservices namespace PARR.DAL.NextRunServices.Subservices
{ {
@@ -67,6 +68,8 @@ namespace PARR.DAL.NextRunServices.Subservices
{ {
//offset - желательно передавать всегда, хотябы МСК, чтобы рассчитывались правильно даты, так как в ЕСПП все в МСК //offset - желательно передавать всегда, хотябы МСК, чтобы рассчитывались правильно даты, так как в ЕСПП все в МСК
logger.LogDebug("Начинаю рассчет nextRun по расписанию ЕСПП. Входные данные: date: {referenceDate}, offset: {offset}, esppSchedule: {esppSchedule}", referenceDate, offset, JsonSerializer.Serialize(esppSchedule));
var nextRun = referenceDate; var nextRun = referenceDate;
//var finalOffset = new TimeSpan(3, 0, 0) + (offset ?? TimeSpan.Zero); //var finalOffset = new TimeSpan(3, 0, 0) + (offset ?? TimeSpan.Zero);
@@ -74,6 +77,8 @@ namespace PARR.DAL.NextRunServices.Subservices
var offsetReferenceDate = referenceDate.ToOffset(finalOffset); var offsetReferenceDate = referenceDate.ToOffset(finalOffset);
logger.LogDebug("Дата после добавления offset - {offsetReferenceDate}", offsetReferenceDate);
////--------- ////---------
//// Для рассчета по МСК времени, потому что в ЮТС может быть еще ВСК, а по МСК это уже ПНД //// Для рассчета по МСК времени, потому что в ЮТС может быть еще ВСК, а по МСК это уже ПНД
//var offsetReferenceDate = referenceDate.ToOffset(new TimeSpan(3, 0, 0)); //var offsetReferenceDate = referenceDate.ToOffset(new TimeSpan(3, 0, 0));
@@ -107,9 +112,13 @@ namespace PARR.DAL.NextRunServices.Subservices
break; break;
} }
logger.LogDebug("Рассчитанная дата, с учетом offset: {nextRun}", nextRun);
// Возвращаем обратно в ЮТС // Возвращаем обратно в ЮТС
nextRun = nextRun.ToOffset(TimeSpan.Zero); nextRun = nextRun.ToOffset(TimeSpan.Zero);
logger.LogDebug("Рассчитанная дата с нулевым offset: {nextRun}", nextRun);
return nextRun; return nextRun;
} }

View File

@@ -5,56 +5,13 @@
/// </summary> /// </summary>
internal interface IEsppScheduleTransformService internal interface IEsppScheduleTransformService
{ {
///// <summary>
///// Получить следующую дату согласно расписания
///// </summary>
///// <param name="esppSchedule"></param>
///// <param name="lastRun"></param>
///// <returns></returns>
//DateTimeOffset GetNextDate(EsppScheduleDto esppSchedule, DateTimeOffset lastRun);
///// <summary>
///// Получить расписание
///// </summary>
///// <param name="esppSchedule"></param>
///// <param name="lastRun"></param>
///// <returns></returns>
//List<DateTimeOffset> GetNextSchedule(EsppScheduleDto esppSchedule, DateTimeOffset lastRun);
/// <summary> /// <summary>
/// Получить следующую дату по jobGroupId /// Получить следующую дату по jobGroupId
/// </summary> /// </summary>
/// <param name="jobGroupId"></param> /// <param name="jobGroupId"></param>
/// <param name="lastRun"></param> /// <param name="referenceDate">referenceDate или nextRun</param>
/// <param name="offset">offset - желательно передавать всегда, хотябы МСК, чтобы рассчитывались правильно даты, так как в ЕСПП все в МСК</param> /// <param name="offset">offset - желательно передавать всегда, хотябы МСК, чтобы рассчитывались правильно даты, так как в ЕСПП все в МСК</param>
/// <returns></returns> /// <returns></returns>
Task<DateTimeOffset> GetNextDateAsync(Guid jobGroupId, DateTimeOffset referenceDate, TimeSpan? offset = null); Task<DateTimeOffset> GetNextDateAsync(Guid jobGroupId, DateTimeOffset referenceDate, TimeSpan? offset = null);
///// <summary>
///// Получить расписание по jobGroupId
///// </summary>
///// <param name="jobGroupId"></param>
///// <param name="lastRun"></param>
///// <returns></returns>
//Task<List<DateTimeOffset>> GetNextScheduleAsync(Guid jobGroupId, DateTimeOffset lastRun);
///// <summary>
///// Получить следующую дату срабатывания в указанном периоде
///// </summary>
///// <param name="lastRun"></param>
///// <param name="periodType"></param>
///// <param name="distributionPeriod"></param>
///// <returns></returns>
//DateTimeOffset GetNextDateForDistributionRun(DateTimeOffset lastRun, DistributionPeriodTypeEnum periodType, string distributionPeriod);
///// <summary>
///// Получить дату начала периода распределения относительно опорной даты (Reference Date)
///// </summary>
///// <param name="jobGroupId"></param>
///// <param name="date"></param>
///// <returns></returns>
//Task<DateOnly> GetStartPeriodForDateAsync(Guid jobGroupId, DateTimeOffset date, DateTimeOffset refrenceDate, DistributionPeriodTypeEnum periodType, string distributionPeriod);
} }
} }

View File

@@ -0,0 +1,37 @@
using PARR.DAL.NextRunServices.Models;
namespace PARR.DAL.NextRunServices.Subservices
{
//todo: -> internal
public interface ITemplateDistributorV2
{
/// <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>
Task<List<TemplateNextRunResultDto>> DistributeTemplatesAsync(DateOnly startDate, int duration, DateTimeOffset referenceDate, TimeSpan? offset, List<TemplateNextRunDto> allTemplates, bool excludeWeekends);
/// <summary>
/// Получить список выходных дней
/// </summary>
/// <param name="startDate"></param>
/// <param name="endDate"></param>
/// <returns></returns>
Task<HashSet<DateOnly>> GetWeekendsAsync(DateOnly startDate, DateOnly endDate);
/// <summary>
/// Получить список рабочих дней
/// </summary>
/// <param name="start"></param>
/// <param name="end"></param>
/// <param name="excludeWeekends"></param>
/// <returns></returns>
Task<List<DateOnly>> GetWorkDaysAsync(DateOnly start, DateOnly end, bool excludeWeekends);
}
}

View File

@@ -27,6 +27,9 @@ namespace PARR.DAL.NextRunServices.Subservices
public async Task<TemplateNextRunResultDto> GetValidNextRunForTemplateAsync(DateOnly startDate, int duration, DateTimeOffset referenceDate, TemplateNextRunDto targetTemplate, List<TemplateNextRunDto> allTemplates, bool excludeWeekends, bool isNew) public async Task<TemplateNextRunResultDto> GetValidNextRunForTemplateAsync(DateOnly startDate, int duration, DateTimeOffset referenceDate, TemplateNextRunDto targetTemplate, List<TemplateNextRunDto> allTemplates, bool excludeWeekends, bool isNew)
{ {
//if (offset.HasValue)
// referenceDate = referenceDate.ToOffset(offset.Value);
//todo: allTemplates - пока не используется. Будет использоваться когда каждый раз будем строить план //todo: allTemplates - пока не используется. Будет использоваться когда каждый раз будем строить план
// Пока работает так: // Пока работает так:
@@ -104,6 +107,10 @@ namespace PARR.DAL.NextRunServices.Subservices
public async Task<List<TemplateNextRunResultDto>> DistributeTemplatesAsync(DateOnly startDate, int duration, DateTimeOffset referenceDate, List<TemplateNextRunDto> allTemplates, bool excludeWeekends) public async Task<List<TemplateNextRunResultDto>> DistributeTemplatesAsync(DateOnly startDate, int duration, DateTimeOffset referenceDate, List<TemplateNextRunDto> allTemplates, bool excludeWeekends)
{ {
//if (offset.HasValue)
//referenceDate = referenceDate.ToOffset(offset.Value);
var _startDate = GetStartPeriodDate(startDate, referenceDate); var _startDate = GetStartPeriodDate(startDate, referenceDate);
var _endDate = GetEndPeriodDate(_startDate, duration); var _endDate = GetEndPeriodDate(_startDate, duration);
@@ -417,7 +424,7 @@ namespace PARR.DAL.NextRunServices.Subservices
return CreateDateTimeOffset(minDay, referenceDate); return CreateDateTimeOffset(minDay, referenceDate);
} }
//---------------------------------------------------------------------------------------------------------------------------------------------------------------------
/// <summary> /// <summary>
/// Получить план: дни, на которые должны быть назначены шаблоны /// Получить план: дни, на которые должны быть назначены шаблоны
@@ -492,7 +499,7 @@ namespace PARR.DAL.NextRunServices.Subservices
/// <returns></returns> /// <returns></returns>
private DateTimeOffset CreateDateTimeOffset(DateOnly date, DateTimeOffset refDate) private DateTimeOffset CreateDateTimeOffset(DateOnly date, DateTimeOffset refDate)
{ {
return new DateTimeOffset(date.Year, date.Month, date.Day, refDate.Hour, refDate.Minute, refDate.Second, TimeSpan.Zero); return new DateTimeOffset(date.Year, date.Month, date.Day, refDate.Hour, refDate.Minute, refDate.Second, refDate.Offset);
} }
@@ -507,20 +514,61 @@ namespace PARR.DAL.NextRunServices.Subservices
// дата начала не может быть раньше referenceDate // дата начала не может быть раньше referenceDate
// и не может быть раньше чем сегодня // и не может быть раньше чем сегодня
var dateStart = startDate; #region old
//var dateStart = startDate;
// если dateStart < чем refDate, то берем refDate //// если dateStart < чем refDate, то берем refDate
if (dateStart < DateOnly.FromDateTime(referenceDate.Date)) //if (dateStart < DateOnly.FromDateTime(referenceDate.Date))
dateStart = DateOnly.FromDateTime(referenceDate.Date); // dateStart = DateOnly.FromDateTime(referenceDate.Date);
// если dateStart < чем текущая дата, то берем текущую дату //// если dateStart < чем текущая дата, то берем текущую дату
if (dateStart < DateOnly.FromDateTime(DateTime.UtcNow.Date)) //if (dateStart < DateOnly.FromDateTime(DateTime.UtcNow.Date))
dateStart = DateOnly.FromDateTime(DateTime.UtcNow.Date); // dateStart = DateOnly.FromDateTime(DateTime.UtcNow.Date);
// если текущее время > referenceDate, то прибавляем 1 день //// если текущее время > referenceDate, то прибавляем 1 день
//var dateTimeStart = CreateDateTimeOffset(dateStart, referenceDate);
//if (dateTimeStart < DateTimeOffset.UtcNow)
// dateStart = dateStart.AddDays(1);
//return dateStart;
#endregion
// 1. Конвертируем startDate (UTC) в целевой часовой пояс
var startDateUtc = new DateTimeOffset(
startDate.Year, startDate.Month, startDate.Day,
0, 0, 0,
TimeSpan.Zero);
var startDateInTargetTz = startDateUtc.ToOffset(referenceDate.Offset);
var dateStart = DateOnly.FromDateTime(startDateInTargetTz.DateTime);
logger.LogDebug("startDate UTC ({startDate} 00:00 UTC) конвертирована в целевой часовой пояс {offset}: {dateStart}", startDate, referenceDate.Offset, dateStart);
// 2. Дата начала не может быть раньше даты из referenceDate (в его часовом поясе)
var refDateInTargetTz = DateOnly.FromDateTime(referenceDate.Date);
if (dateStart < refDateInTargetTz)
{
logger.LogDebug("Дата начала {dateStart} раньше даты из referenceDate {refDate}. Используем дату из referenceDate.", dateStart, refDateInTargetTz);
dateStart = refDateInTargetTz;
}
// 3. Дата начала не может быть раньше "сегодня" в целевом часовом поясе
var todayInTargetTz = DateOnly.FromDateTime(DateTimeOffset.UtcNow.ToOffset(referenceDate.Offset).Date);
if (dateStart < todayInTargetTz)
{
logger.LogDebug("Дата начала {dateStart} раньше 'сегодня' в целевом поясе {today}. Используем 'сегодня'.", dateStart, todayInTargetTz);
dateStart = todayInTargetTz;
}
// 4. Если момент времени "начало дня в целевом поясе" уже прошёл — добавляем 1 день
var dateTimeStart = CreateDateTimeOffset(dateStart, referenceDate); var dateTimeStart = CreateDateTimeOffset(dateStart, referenceDate);
if (dateTimeStart < DateTimeOffset.UtcNow) if (dateTimeStart < DateTimeOffset.UtcNow)
{
logger.LogDebug("Момент времени {dateTimeStart} уже прошёл (сейчас {now}). Добавляем 1 день.", dateTimeStart, DateTimeOffset.UtcNow);
dateStart = dateStart.AddDays(1); dateStart = dateStart.AddDays(1);
}
logger.LogDebug("Итоговая дата начала распределения: {dateStart} (в часовом поясе {offset})", dateStart, referenceDate.Offset);
return dateStart; return dateStart;
} }
@@ -565,41 +613,44 @@ namespace PARR.DAL.NextRunServices.Subservices
/// <param name="startDate"></param> /// <param name="startDate"></param>
/// <param name="endDate"></param> /// <param name="endDate"></param>
/// <returns></returns> /// <returns></returns>
public async Task<HashSet<DateOnly>> GetWeekendsV2Async(DateOnly startDate, DateOnly endDate, DateTimeOffset referenceDate) public async Task<HashSet<DateOnly>> GetWeekendsV2Async(DateOnly startDate, DateOnly endDate, DateTimeOffset referenceDate, TimeSpan? offset = null)
{ {
if (offset.HasValue)
referenceDate = referenceDate.ToOffset(offset.Value);
// проверяем это текущие или прошлые сутки относительно refDate, если надо, корректируем dateStart, dateEnd // проверяем это текущие или прошлые сутки относительно refDate, если надо, корректируем dateStart, dateEnd
bool isPreviousDays = IsPreviousDays(referenceDate); //bool isPreviousDays = IsPreviousDays(referenceDate);
if (isPreviousDays) //if (isPreviousDays)
{ //{
// Это предыдущие сутки, корректируем // // Это предыдущие сутки, корректируем
startDate = startDate.AddDays(-1); // startDate = startDate.AddDays(-1);
// так как мы потом будем сдвигать выходные дни влево, то надо взять на один день дальше, чтоб знать какой он // // так как мы потом будем сдвигать выходные дни влево, то надо взять на один день дальше, чтоб знать какой он
// вроде бы это не костыль // // вроде бы это не костыль
endDate = endDate.AddDays(1); // endDate = endDate.AddDays(1);
//endDate = endDate.AddDays(-1); // //endDate = endDate.AddDays(-1);
logger.LogDebug("Это предыдущие сутки относительно referenceDate: {referenceDate}, корректируем даты для получения выходных дней, новые значения dateStart: {dateStart}, dateEnd: {dateEnd}", referenceDate, startDate, endDate); // logger.LogDebug("Это предыдущие сутки относительно referenceDate: {referenceDate}, корректируем даты для получения выходных дней, новые значения dateStart: {dateStart}, dateEnd: {dateEnd}", referenceDate, startDate, endDate);
} //}
var weekends = await weekendDayService.GetWeekends(startDate, endDate).ToListAsync(); var weekends = await weekendDayService.GetWeekends(startDate, endDate).ToListAsync();
var weekendsResult = new List<DateOnly>(); var weekendsResult = new List<DateOnly>();
if (isPreviousDays) //if (isPreviousDays)
{ //{
// так как должны быть предыдущие дни, отнимаем 1 день // // так как должны быть предыдущие дни, отнимаем 1 день
foreach (var weekend in weekends) // foreach (var weekend in weekends)
{ // {
var previous = weekend.AddDays(-1); // var previous = weekend.AddDays(-1);
logger.LogDebug("Выходной день, -1, так как день должен быть предыдущий, старое значение {weekend}, новое {previous}", weekend, previous); // logger.LogDebug("Выходной день, -1, так как день должен быть предыдущий, старое значение {weekend}, новое {previous}", weekend, previous);
weekendsResult.Add(previous); // weekendsResult.Add(previous);
} // }
} //}
else //else
{ //{
weekendsResult = weekends; // weekendsResult = weekends;
} //}
logger.LogDebug("Найдено не рабочих дней: {Count}", weekendsResult.Count); logger.LogDebug("Найдено не рабочих дней: {Count}", weekendsResult.Count);
foreach (var weekend in weekendsResult) foreach (var weekend in weekendsResult)
@@ -644,19 +695,22 @@ namespace PARR.DAL.NextRunServices.Subservices
//} //}
public async Task<List<DateOnly>> GetWorkDaysV2Async(DateOnly start, DateOnly end, bool excludeWeekends, DateTimeOffset referenceDate) public async Task<List<DateOnly>> GetWorkDaysV2Async(DateOnly start, DateOnly end, bool excludeWeekends, DateTimeOffset referenceDate, TimeSpan? offset = null)
{ {
if (offset.HasValue)
referenceDate = referenceDate.ToOffset(offset.Value);
logger.LogDebug("Формирование списка рабочих дней, start: {start}, end: {end}, excludeWeekends: {excludeWeekends}, referenceDate: {referenceDate}", start, end, excludeWeekends, referenceDate); logger.LogDebug("Формирование списка рабочих дней, start: {start}, end: {end}, excludeWeekends: {excludeWeekends}, referenceDate: {referenceDate}", start, end, excludeWeekends, referenceDate);
// проверяем это текущие или прошлые сутки относительно refDate, если надо, корректируем dateStart, dateEnd //// проверяем это текущие или прошлые сутки относительно refDate, если надо, корректируем dateStart, dateEnd
if (IsPreviousDays(referenceDate)) //if (IsPreviousDays(referenceDate))
{ //{
// Это предыдущие сутки, корректируем // // Это предыдущие сутки, корректируем
start = start.AddDays(-1); // start = start.AddDays(-1);
end = end.AddDays(-1); // end = end.AddDays(-1);
logger.LogDebug("Это предыдущие сутки относительно referenceDate: {referenceDate}, корректируем даты, новые значения dateStart: {dateStart}, dateEnd: {dateEnd}", referenceDate, start, end); // logger.LogDebug("Это предыдущие сутки относительно referenceDate: {referenceDate}, корректируем даты, новые значения dateStart: {dateStart}, dateEnd: {dateEnd}", referenceDate, start, end);
} //}
// Получаем список выходных/праздничных дней // Получаем список выходных/праздничных дней
var weekends = new HashSet<DateOnly>(); var weekends = new HashSet<DateOnly>();
@@ -683,33 +737,45 @@ namespace PARR.DAL.NextRunServices.Subservices
return result; return result;
} }
/// <summary>
/// Это предыдущие сутки? Относительно referenceDate и МСК времени
/// </summary> public Task<HashSet<DateOnly>> GetWeekendsV2Async(DateOnly startDate, DateOnly endDate, DateTimeOffset referenceDate)
/// <param name="referenceDate"></param>
/// <returns></returns>
private bool IsPreviousDays(DateTimeOffset referenceDate)
{ {
// смещение часов относительно UTC - MSK throw new NotImplementedException();
int offsetMskHour = 3;
// если время больше 21 часа, то это текущие сутки, если меньше, то прошлые
int maxHour = 24 - offsetMskHour;
if (referenceDate.Hour >= maxHour && referenceDate.Hour <= 23)
{
// это прошлые сутки
logger.LogDebug("ReferenceDate {refDate} - прошлые стуки", referenceDate);
return true;
}
else
{
// это текущие сутки
logger.LogDebug("ReferenceDate {refDate} - текущие стуки", referenceDate);
return false;
}
} }
public Task<List<DateOnly>> GetWorkDaysV2Async(DateOnly start, DateOnly end, bool excludeWeekends, DateTimeOffset referenceDate)
{
throw new NotImplementedException();
}
///// <summary>
///// Это предыдущие сутки? Относительно referenceDate и МСК времени
///// </summary>
///// <param name="referenceDate"></param>
///// <returns></returns>
//private bool IsPreviousDays(DateTimeOffset referenceDate)
//{
// // смещение часов относительно UTC - MSK
// int offsetMskHour = 3;
// // если время больше 21 часа, то это текущие сутки, если меньше, то прошлые
// int maxHour = 24 - offsetMskHour;
// if (referenceDate.Hour >= maxHour && referenceDate.Hour <= 23)
// {
// // это прошлые сутки
// logger.LogDebug("ReferenceDate {refDate} - прошлые стуки", referenceDate);
// return true;
// }
// else
// {
// // это текущие сутки
// logger.LogDebug("ReferenceDate {refDate} - текущие стуки", referenceDate);
// return false;
// }
//}
} }
} }

View File

@@ -0,0 +1,495 @@
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;
}
}
}

View File

@@ -141,6 +141,7 @@ namespace PARR.DAL
services.AddTransient<IEsppScheduleTransformService, EsppScheduleTransformService>(); services.AddTransient<IEsppScheduleTransformService, EsppScheduleTransformService>();
services.AddTransient<INextRunService, NextRunService>(); services.AddTransient<INextRunService, NextRunService>();
services.AddTransient<ITemplateDistributor, TemplateDistributor>(); services.AddTransient<ITemplateDistributor, TemplateDistributor>();
services.AddTransient<ITemplateDistributorV2, TemplateDistributorV2>();
#endregion #endregion

View File

@@ -14,20 +14,38 @@ namespace PARR.Test.NextRun
private readonly INextRunService nextRunService; private readonly INextRunService nextRunService;
private readonly ITemplateService templateService; private readonly ITemplateService templateService;
private readonly IShortcodesService shortcodesService; private readonly IShortcodesService shortcodesService;
private readonly ITemplateDistributorV2 templateDistributorV2;
public NextRunTest(/*ITemplateDistributor templateDistributor, */INextRunService nextRunService, ITemplateService templateService, IShortcodesService shortcodesService) public NextRunTest(
/*ITemplateDistributor templateDistributor, */
INextRunService nextRunService,
ITemplateService templateService,
IShortcodesService shortcodesService,
ITemplateDistributorV2 templateDistributorV2
)
{ {
//this.templateDistributor = templateDistributor; //this.templateDistributor = templateDistributor;
this.nextRunService = nextRunService; this.nextRunService = nextRunService;
this.templateService = templateService; this.templateService = templateService;
this.shortcodesService = shortcodesService; this.shortcodesService = shortcodesService;
this.templateDistributorV2 = templateDistributorV2;
} }
int periodDays = 10;
DateTimeOffset referenceDate = new DateTimeOffset(2025, 1, 20, 3, 0, 0, TimeSpan.Zero);
public async Task Test() public async Task Test()
{ {
var duration = 10;
var offset = TimeSpan.FromHours(10);
var startDate = new DateOnly(2026, 2, 10);
var referenceDate = new DateTimeOffset(2026, 2, 10, 4, 0, 0, TimeSpan.Zero);
var templates = GetTemplates();
var distributedTemplates = await templateDistributorV2.DistributeTemplatesAsync(startDate, duration, referenceDate, offset, templates, true);
//var template = await templateService.Get().Include(t => t.Job).ThenInclude(t => t.Group).FirstAsync(t => t.Name == "ЭИТИ-ПАРРКИТВЦ_СХД ТО-1_СХД-AERODISK-432-2-4-U26-EN4SAG022-ГВЦ"); //var template = await templateService.Get().Include(t => t.Job).ThenInclude(t => t.Group).FirstAsync(t => t.Name == "ЭИТИ-ПАРРКИТВЦ_СХД ТО-1_СХД-AERODISK-432-2-4-U26-EN4SAG022-ГВЦ");
//var responseAreae = await shortcodesService.ApplyShortcodesAsync(template.Job.ResponseAreaMask, template); //var responseAreae = await shortcodesService.ApplyShortcodesAsync(template.Job.ResponseAreaMask, template);
//var nextRunWithTimezone = nextRunService.GetNextRunWithTimezoneEsppAndResponseArea(template.NextRun, template.Job?.Group?.IsResponseAreaTimezone, responseAreae); //var nextRunWithTimezone = nextRunService.GetNextRunWithTimezoneEsppAndResponseArea(template.NextRun, template.Job?.Group?.IsResponseAreaTimezone, responseAreae);
@@ -117,14 +135,14 @@ namespace PARR.Test.NextRun
} }
private DateOnly GetDateStart() //private DateOnly GetDateStart()
{ //{
var today = DateOnly.FromDateTime(DateTime.UtcNow.Date); // var today = DateOnly.FromDateTime(DateTime.UtcNow.Date);
var refDate = DateOnly.FromDateTime(referenceDate.Date); // var refDate = DateOnly.FromDateTime(referenceDate.Date);
return today < refDate ? refDate : today; // return today < refDate ? refDate : today;
} //}
//private DateOnly GetDateEnd() //private DateOnly GetDateEnd()
//{ //{
@@ -143,7 +161,7 @@ namespace PARR.Test.NextRun
templates.Add(new TemplateNextRunDto(Id: Guid.Parse("B397F1FE-DF2B-4B50-B8D6-2DABECD3D5A1"), NextRun: new DateTimeOffset(2025, 12, 18, 3, 0, 0, TimeSpan.Zero))); templates.Add(new TemplateNextRunDto(Id: Guid.Parse("B397F1FE-DF2B-4B50-B8D6-2DABECD3D5A1"), NextRun: new DateTimeOffset(2025, 12, 18, 3, 0, 0, TimeSpan.Zero)));
templates.Add(new TemplateNextRunDto(Id: Guid.Parse("619D9621-2B45-4108-AF54-CF96862A8E9B"), NextRun: new DateTimeOffset(2025, 12, 18, 3, 0, 0, TimeSpan.Zero))); templates.Add(new TemplateNextRunDto(Id: Guid.Parse("619D9621-2B45-4108-AF54-CF96862A8E9B"), NextRun: new DateTimeOffset(2025, 12, 18, 3, 0, 0, TimeSpan.Zero)));
templates.Add(new TemplateNextRunDto(Id: Guid.Parse("D74D831C-D435-46BF-9B5F-A24884AAA7DC"), NextRun: new DateTimeOffset(2025, 12, 25, 3, 0, 0, TimeSpan.Zero))); templates.Add(new TemplateNextRunDto(Id: Guid.Parse("D74D831C-D435-46BF-9B5F-A24884AAA7DC"), NextRun: new DateTimeOffset(2025, 12, 25, 3, 0, 0, TimeSpan.Zero)));
templates.Add(new TemplateNextRunDto(Id: Guid.Parse("DE7241F1-5C62-4164-A3DC-99B946350E10"), NextRun: new DateTimeOffset(2025, 12, 18, 3, 0, 0, TimeSpan.Zero))); templates.Add(new TemplateNextRunDto(Id: Guid.Parse("DE7241F1-5C62-4164-A3DC-99B946350E10"), NextRun: new DateTimeOffset(2026, 2, 13, 4, 0, 0, TimeSpan.Zero)));
templates.Add(new TemplateNextRunDto(Id: Guid.Parse("94A7375E-D1BD-49B7-9186-D26E85F1CF74"), NextRun: new DateTimeOffset(2025, 12, 18, 3, 0, 0, TimeSpan.Zero))); templates.Add(new TemplateNextRunDto(Id: Guid.Parse("94A7375E-D1BD-49B7-9186-D26E85F1CF74"), NextRun: new DateTimeOffset(2025, 12, 18, 3, 0, 0, TimeSpan.Zero)));
templates.Add(new TemplateNextRunDto(Id: Guid.Parse("B3681F5F-10A5-425C-BBEA-EA2B85FE0967"), NextRun: new DateTimeOffset(2025, 12, 18, 3, 0, 0, TimeSpan.Zero))); templates.Add(new TemplateNextRunDto(Id: Guid.Parse("B3681F5F-10A5-425C-BBEA-EA2B85FE0967"), NextRun: new DateTimeOffset(2025, 12, 18, 3, 0, 0, TimeSpan.Zero)));
templates.Add(new TemplateNextRunDto(Id: Guid.Parse("3259B408-48A3-4995-AF9A-097477ECAB81"), NextRun: new DateTimeOffset(2025, 12, 18, 3, 0, 0, TimeSpan.Zero))); templates.Add(new TemplateNextRunDto(Id: Guid.Parse("3259B408-48A3-4995-AF9A-097477ECAB81"), NextRun: new DateTimeOffset(2025, 12, 18, 3, 0, 0, TimeSpan.Zero)));