From b2b5516326da234b9028a04057aada297b2057c2 Mon Sep 17 00:00:00 2001 From: Mikhail Trubnikov Date: Thu, 5 Mar 2026 10:19:54 +1000 Subject: [PATCH] =?UTF-8?q?feat(api):=20=D1=81=D1=82=D0=B0=D1=82=D0=B8?= =?UTF-8?q?=D1=81=D1=82=D0=B8=D0=BA=D0=B0=20=D0=BE=D1=82=D0=BE=D0=B1=D1=80?= =?UTF-8?q?=D0=B0=D0=B6=D0=B0=D0=B5=D1=82=D1=81=D1=8F=20=D0=B2=20=D1=87?= =?UTF-8?q?=D0=B0=D1=81=D0=BE=D0=B2=D0=BE=D0=BC=20=D0=BF=D0=BE=D1=8F=D1=81?= =?UTF-8?q?=D0=B5=20=D0=BA=D0=BB=D0=B8=D0=B5=D0=BD=D1=82=D0=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../Requests/Queries/StatRobotHistoryQuery.cs | 2 +- .../Statistics/StatRobotHistoryResponse.cs | 2 +- .../Controllers/V1/RobotTaskController.cs | 4 +- .../V1/Statistics/StatOrderController.cs | 86 +- .../Statistics/StatRobotHistoryController.cs | 20 +- .../Statistics/StatRobotStatusController.cs | 5 +- .../V1/Statistics/StatRobotTaskController.cs | 8 +- .../StatTemplateAutoControlController.cs | 78 +- .../V1/Statistics/StatTemplateController.cs | 59 +- .../StatTemplateDistributionController.cs | 4 +- .../V1/Statistics/StatUnitController.cs | 71 +- .../StatUnitFieldValuesController.cs | 103 +- PARR.API/Helpers/TimeZoneDateHelper.cs | 96 ++ PARR.API/appsettings.json | 2 +- .../Implementations/CalendarService.cs | 7 + ...NextRunServiceV2.cs => INextRunService.cs} | 2 +- ...{NextRunServiceV2.cs => NextRunService.cs} | 12 +- .../Subservices/ITemplateDistributor.cs | 62 +- .../Subservices/ITemplateDistributorV2.cs | 51 - .../Subservices/TemplateDistributor.cs | 1372 +++++++++++------ .../Subservices/TemplateDistributorV2.cs | 1259 --------------- PARR.DAL/ParrDalInstaller.cs | 3 +- PARR.EsppScheduleSync/ScheduleSyncher.cs | 4 +- PARR.NextRun/NextRunIntervalService.cs | 8 +- PARR.NextRun/NextRunRabbitService.cs | 2 +- .../TemplateDistributor.cs | 4 +- .../TemplateGenerator.cs | 4 +- .../Services/TemplateUpdaterService.cs | 4 +- PARR.Test/NextRun/NextRunTest.cs | 4 +- PARR.Test/Worker.cs | 6 +- 30 files changed, 1333 insertions(+), 2011 deletions(-) create mode 100644 PARR.API/Helpers/TimeZoneDateHelper.cs rename PARR.DAL/NextRunServices/{INextRunServiceV2.cs => INextRunService.cs} (98%) rename PARR.DAL/NextRunServices/{NextRunServiceV2.cs => NextRunService.cs} (99%) delete mode 100644 PARR.DAL/NextRunServices/Subservices/ITemplateDistributorV2.cs delete mode 100644 PARR.DAL/NextRunServices/Subservices/TemplateDistributorV2.cs diff --git a/PARR.API/Contracts/V1/Requests/Queries/StatRobotHistoryQuery.cs b/PARR.API/Contracts/V1/Requests/Queries/StatRobotHistoryQuery.cs index 06ec616a..88b53c5e 100644 --- a/PARR.API/Contracts/V1/Requests/Queries/StatRobotHistoryQuery.cs +++ b/PARR.API/Contracts/V1/Requests/Queries/StatRobotHistoryQuery.cs @@ -12,6 +12,6 @@ namespace PARR.API.Contracts.V1.Requests.Queries /// /// Фильтр за дату /// - public DateTime? Date { get; set; } + public DateOnly? Date { get; set; } } } diff --git a/PARR.API/Contracts/V1/Responses/Statistics/StatRobotHistoryResponse.cs b/PARR.API/Contracts/V1/Responses/Statistics/StatRobotHistoryResponse.cs index b7bdc914..cd02bb77 100644 --- a/PARR.API/Contracts/V1/Responses/Statistics/StatRobotHistoryResponse.cs +++ b/PARR.API/Contracts/V1/Responses/Statistics/StatRobotHistoryResponse.cs @@ -8,6 +8,6 @@ public int DateCount { get; set; } - public DateTime Date { get; set; } + public DateOnly Date { get; set; } } } diff --git a/PARR.API/Controllers/V1/RobotTaskController.cs b/PARR.API/Controllers/V1/RobotTaskController.cs index a2c54b19..2fd2f87d 100644 --- a/PARR.API/Controllers/V1/RobotTaskController.cs +++ b/PARR.API/Controllers/V1/RobotTaskController.cs @@ -31,7 +31,7 @@ namespace PARR.API.Controllers.V1 private readonly IClientService clientService; private readonly IRobotHistoryService robotHistoryService; private readonly IShortcodesService shortcodesService; - private readonly INextRunServiceV2 nextRunService; + private readonly INextRunService nextRunService; private readonly IScheduleResponseAreaTimeOffsetService scheduleResponseAreaTimeOffsetService; public RobotTaskController( @@ -43,7 +43,7 @@ namespace PARR.API.Controllers.V1 IClientService clientService, IRobotHistoryService robotHistoryService, IShortcodesService shortcodesService, - INextRunServiceV2 nextRunService, + INextRunService nextRunService, IScheduleResponseAreaTimeOffsetService scheduleResponseAreaTimeOffsetService ) { diff --git a/PARR.API/Controllers/V1/Statistics/StatOrderController.cs b/PARR.API/Controllers/V1/Statistics/StatOrderController.cs index fc156d12..f7dc5695 100644 --- a/PARR.API/Controllers/V1/Statistics/StatOrderController.cs +++ b/PARR.API/Controllers/V1/Statistics/StatOrderController.cs @@ -8,8 +8,9 @@ using PARR.API.Contracts.V1.Responses; using PARR.API.Contracts.V1.Responses.Base; using PARR.API.Contracts.V1.Responses.Statistics; using PARR.API.Controllers.V1.Base; -using PARR.BLL.Services.Interfaces; +using PARR.API.Helpers; using PARR.Constants; +using PARR.DAL.NextRunServices; using PARR.DAL.Services.Interfaces; namespace PARR.API.Controllers.V1.Statistics @@ -23,34 +24,37 @@ namespace PARR.API.Controllers.V1.Statistics private readonly IOrderService orderService; private readonly IOrderStatusService orderStatusService; private readonly IMapper mapper; - private readonly ICalendarService calendarService; + private readonly INextRunService nextRunService; public StatOrderController( IOrderService orderService, IOrderStatusService orderStatusService, IMapper mapper, - ICalendarService calendarService + INextRunService nextRunService ) { this.orderService = orderService; this.orderStatusService = orderStatusService; this.mapper = mapper; - this.calendarService = calendarService; + this.nextRunService = nextRunService; } /// - /// Статистика по нарядам - всего в ПАРР / загружено за последние сутки + /// Статистика по нарядам - всего в ПАРР / загружено за сегодня /// /// [HttpGet(ApiRoutes.StatOrder.Get)] - public async Task Get() + public async Task Get([FromQuery] TimeZoneOffsetClient timeZoneQuery) { - var queryDate = DateTimeOffset.UtcNow.AddDays(-1); + var todayStartUtc = TimeZoneDateHelper.GetUtcSartOfDay( + DateOnly.FromDateTime(DateTimeOffset.UtcNow.ToOffset(timeZoneQuery.TimeZoneOffset).DateTime), + timeZoneQuery.TimeZoneOffset + ); var response = new StatOrderResponse { - AllCount = await orderService.Get().CountAsync(), - DownloadedLastDayCount = await orderService.Get().CountAsync(t => t.DateCreated >= queryDate) + AllCount = await orderService.Get().AsNoTracking().CountAsync(), + DownloadedLastDayCount = await orderService.Get().AsNoTracking().CountAsync(t => t.DateCreated.UtcDateTime >= todayStartUtc) }; return Ok(new Response(response, true)); @@ -66,9 +70,9 @@ namespace PARR.API.Controllers.V1.Statistics { var response = new StatTaskToEsppResponse { - AllCount = await orderService.Get().CountAsync(), + AllCount = await orderService.Get().AsNoTracking().CountAsync(), // такая же логика в PARR.Master - TaskCount = await orderService.Get().CountAsync(t => t.NextStatusCode.HasValue && t.NextStatusCode.Value != t.StatusCode) + TaskCount = await orderService.Get().AsNoTracking().CountAsync(t => t.NextStatusCode.HasValue && t.NextStatusCode.Value != t.StatusCode) }; return Ok(new Response(response, true)); @@ -82,8 +86,7 @@ namespace PARR.API.Controllers.V1.Statistics [HttpGet(ApiRoutes.StatOrder.Statuses)] public async Task Statuses() { - var statuses = await orderStatusService.Get().OrderBy(t => t.Code).ToListAsync(); - + var statuses = await orderStatusService.Get().AsNoTracking().OrderBy(t => t.Code).ToListAsync(); var response = new List(); foreach (var stat in statuses) @@ -91,7 +94,7 @@ namespace PARR.API.Controllers.V1.Statistics response.Add(new StatOrderStatusesResponse { Status = mapper.Map(stat), - Count = await orderService.Get().CountAsync(t => t.StatusCode == stat.Code) + Count = await orderService.Get().AsNoTracking().CountAsync(t => t.StatusCode == stat.Code) }); } @@ -100,23 +103,26 @@ namespace PARR.API.Controllers.V1.Statistics /// - /// Статистика по нарядам - по статусу за последние сутки + /// Статистика по нарядам - по статусу за сегодня /// /// 1,2,3,4,5 /// [HttpGet(ApiRoutes.StatOrder.GetStatusesLastDay)] - public async Task GetStatusesLastDay([FromRoute] int statusCode) + public async Task GetStatusesLastDay([FromRoute] int statusCode, [FromQuery] TimeZoneOffsetClient timeZoneQuery) { - var status = await orderStatusService.Get().FirstOrDefaultAsync(t => t.Code == statusCode); + var status = await orderStatusService.Get().AsNoTracking().FirstOrDefaultAsync(t => t.Code == statusCode); if (status == null) return BadRequest(new Response(false, new List { new ErrorModel { Message = $"Не найден статус с кодом: {statusCode}" } })); - var queryDate = DateTimeOffset.UtcNow.AddDays(-1); + var todayStartUtc = TimeZoneDateHelper.GetUtcSartOfDay( + DateOnly.FromDateTime(DateTimeOffset.UtcNow.ToOffset(timeZoneQuery.TimeZoneOffset).DateTime), + timeZoneQuery.TimeZoneOffset + ); var response = new StatGetStatusesLastDayResponse { - Count = await orderService.Get().CountAsync(t => t.DateModified >= queryDate && t.StatusCode == statusCode), + Count = await orderService.Get().CountAsync(t => (t.DateModified.HasValue && t.DateModified.Value >= todayStartUtc) && t.StatusCode == statusCode), Status = mapper.Map(status) }; @@ -133,36 +139,30 @@ namespace PARR.API.Controllers.V1.Statistics [HttpGet(ApiRoutes.StatOrder.GetForPeriod)] public async Task GetForPeriod([FromQuery] TimeZoneOffsetClient timeZoneQuery, [FromQuery] int startPeriodDays = 3) { - var endPeriod = calendarService.GetDateWithTimeZoneOffset(DateTimeOffset.UtcNow, timeZoneQuery.TimeZoneOffsetHours); - var startPeriod = endPeriod.AddDays(-startPeriodDays); + // Формируем период + var userEnd = DateOnly.FromDateTime(DateTimeOffset.UtcNow.ToOffset(timeZoneQuery.TimeZoneOffset).DateTime); + var userStart = userEnd.AddDays(-startPeriodDays); - var query = orderService.Get() - .Where(t => - t.DateCreated.DateTime.AddHours(timeZoneQuery.TimeZoneOffsetHours).Date >= startPeriod.Date - && t.DateCreated.DateTime.AddHours(timeZoneQuery.TimeZoneOffsetHours).Date <= endPeriod.Date - ).GroupBy(t => t.DateCreated.AddHours(timeZoneQuery.TimeZoneOffsetHours).Date) - .Select(t => new - { - Date = t.Key, - OrderCount = t.Count() - }); + var (utcStart, utcEnd) = TimeZoneDateHelper.GetUtcDateRange( + userStart, + userEnd.AddDays(1), + timeZoneQuery.TimeZoneOffset); - var result = await query.ToListAsync(); + var allRecords = await orderService.Get() + .AsNoTracking() + .FilterByDateRangeUtc(t => t.DateCreated, utcStart, utcEnd) + .Select(t => new { t.Id, t.DateCreated }) + .ToListAsync(); ; - var daysList = calendarService.GetDatesForPeriod(startPeriod, endPeriod); + var resultDict = allRecords.GroupByUserDate(t => t.DateCreated, timeZoneQuery.TimeZoneOffset); - var response = new List(); + var daysList = await nextRunService.GetWorkDaysAsync(userStart, userEnd, false); - daysList.ForEach(date => + var response = daysList.Select(date => new StatOrderGetForPeriodResponse { - response.Add(new StatOrderGetForPeriodResponse - { - Date = date, - OrderCount = result.FirstOrDefault(t => DateOnly.FromDateTime(t.Date) == date)?.OrderCount ?? 0 - }); - }); - - response = response.OrderBy(t => t.Date).ToList(); + Date = date, + OrderCount = resultDict.TryGetValue(date, out var cnt) ? cnt : 0 + }).OrderBy(t => t.Date).ToList(); return Ok(new Response>(response, true)); } diff --git a/PARR.API/Controllers/V1/Statistics/StatRobotHistoryController.cs b/PARR.API/Controllers/V1/Statistics/StatRobotHistoryController.cs index e216fc50..0ec3552e 100644 --- a/PARR.API/Controllers/V1/Statistics/StatRobotHistoryController.cs +++ b/PARR.API/Controllers/V1/Statistics/StatRobotHistoryController.cs @@ -3,11 +3,13 @@ using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Mvc; using Microsoft.EntityFrameworkCore; using PARR.API.Contracts.V1; +using PARR.API.Contracts.V1.Requests.BaseRequests; using PARR.API.Contracts.V1.Requests.Queries; using PARR.API.Contracts.V1.Responses; using PARR.API.Contracts.V1.Responses.Base; using PARR.API.Contracts.V1.Responses.Statistics; using PARR.API.Controllers.V1.Base; +using PARR.API.Helpers; using PARR.Constants; using PARR.DAL.Models; using PARR.DAL.Services.Interfaces; @@ -40,10 +42,10 @@ namespace PARR.API.Controllers.V1.Statistics /// /// [HttpGet(ApiRoutes.StatRobotHistory.Get)] - public async Task Get([FromQuery] StatRobotHistoryQuery request) + public async Task Get([FromQuery] StatRobotHistoryQuery request, [FromQuery] TimeZoneOffsetClient timeZoneQuery) { if (!request.Date.HasValue) - request.Date = DateTime.Now.Date; + request.Date = DateOnly.FromDateTime(DateTimeOffset.UtcNow.Date); // всего var allStat = await robotHistoryService.Get() @@ -51,9 +53,19 @@ namespace PARR.API.Controllers.V1.Statistics .Select(t => new { HistoryLevel = t.Key, Count = t.Count() }) .ToListAsync(); + // Формируем диапазон в пользовательском времени + var userDateStart = new DateTimeOffset(request.Date.Value.Year, request.Date.Value.Month, request.Date.Value.Day, 0, 0, 0, timeZoneQuery.TimeZoneOffset); + var userDateEnd = userDateStart.AddDays(1); + + // Конвертируем в UTC для сравнения с БД + var utcStart = userDateStart.UtcDateTime; + var utcEnd = userDateEnd.UtcDateTime; + // за дату var dateStat = await robotHistoryService.Get() - .Where(t => t.DateCreated.DateTime.Date == request.Date.Value.Date) + //.Where(t => t.DateCreated.DateTime.Date == request.Date.Value.Date) + //.Where(t => t.DateCreated.UtcDateTime >= utcStart && t.DateCreated.UtcDateTime < utcEnd) + .FilterByDateRangeUtc(t => t.DateCreated, utcStart, utcEnd) .GroupBy(t => t.HistoryLevel) .Select(t => new { HistoryLevel = t.Key, Count = t.Count() }) .ToListAsync(); @@ -73,7 +85,7 @@ namespace PARR.API.Controllers.V1.Statistics { AllCount = allStat.FirstOrDefault(t => t.HistoryLevel == level.Level)?.Count ?? 0, DateCount = dateStat.FirstOrDefault(t => t.HistoryLevel == level.Level)?.Count ?? 0, - Date = request.Date.Value.Date, + Date = request.Date.Value, Level = mapper.Map(level) }); } diff --git a/PARR.API/Controllers/V1/Statistics/StatRobotStatusController.cs b/PARR.API/Controllers/V1/Statistics/StatRobotStatusController.cs index 7bd1e90e..59b198e5 100644 --- a/PARR.API/Controllers/V1/Statistics/StatRobotStatusController.cs +++ b/PARR.API/Controllers/V1/Statistics/StatRobotStatusController.cs @@ -44,12 +44,13 @@ namespace PARR.API.Controllers.V1.Statistics public async Task Get() { var confStat = await robotConfigurationService.Get() + .AsNoTracking() .GroupBy(t => new { t.RobotCode, t.RobotStatusCode }) .Select(t => new { RobotCode = t.Key.RobotCode, RobotStatusCode = t.Key.RobotStatusCode, Count = t.Count() }) .ToListAsync(); - var robots = await robotService.Get().ToListAsync(); - var robotStatuses = await robotStatusService.Get().ToListAsync(); + var robots = await robotService.Get().AsNoTracking().ToListAsync(); + var robotStatuses = await robotStatusService.Get().AsNoTracking().ToListAsync(); var response = new List(); diff --git a/PARR.API/Controllers/V1/Statistics/StatRobotTaskController.cs b/PARR.API/Controllers/V1/Statistics/StatRobotTaskController.cs index c3939d07..adc5874d 100644 --- a/PARR.API/Controllers/V1/Statistics/StatRobotTaskController.cs +++ b/PARR.API/Controllers/V1/Statistics/StatRobotTaskController.cs @@ -49,12 +49,13 @@ namespace PARR.API.Controllers.V1.Statistics public async Task Get() { var confStat = await robotConfigurationService.Get() + .AsNoTracking() .GroupBy(t => new { t.RobotCode, t.TaskStatusCode }) .Select(t => new { RobotCode = t.Key.RobotCode, TaskStatusCode = t.Key.TaskStatusCode, Count = t.Count() }) .ToListAsync(); - var robots = await robotService.Get().ToListAsync(); - var statuses = await taskStatusService.Get().ToListAsync(); + var robots = await robotService.Get().AsNoTracking().ToListAsync(); + var statuses = await taskStatusService.Get().AsNoTracking().ToListAsync(); var response = new List(); @@ -95,7 +96,8 @@ namespace PARR.API.Controllers.V1.Statistics var query = robotHistoryService.Get() - .Include(t => t.RobotConfiguration) + //.Include(t => t.RobotConfiguration) + .AsNoTracking() .Where(t => t.RobotConfiguration!.RobotCode == (int)robot && t.DateCreated >= queryDate) .GroupBy(t => new { Date = t.DateCreated.Date, Hour = t.DateCreated.Hour }) .Select(t => new diff --git a/PARR.API/Controllers/V1/Statistics/StatTemplateAutoControlController.cs b/PARR.API/Controllers/V1/Statistics/StatTemplateAutoControlController.cs index bf7a028d..a760b350 100644 --- a/PARR.API/Controllers/V1/Statistics/StatTemplateAutoControlController.cs +++ b/PARR.API/Controllers/V1/Statistics/StatTemplateAutoControlController.cs @@ -5,8 +5,9 @@ using PARR.API.Contracts.V1.Requests.BaseRequests; using PARR.API.Contracts.V1.Responses.Base; using PARR.API.Contracts.V1.Responses.Statistics; using PARR.API.Controllers.V1.Base; -using PARR.BLL.Services.Interfaces; +using PARR.API.Helpers; using PARR.Constants; +using PARR.DAL.NextRunServices; using PARR.DAL.Services.Interfaces; namespace PARR.API.Controllers.V1.Statistics @@ -15,17 +16,17 @@ namespace PARR.API.Controllers.V1.Statistics { private readonly ITemplateService templateService; private readonly ITemplateHistoryService templateHistoryService; - private readonly ICalendarService calendarService; + private readonly INextRunService nextRunService; public StatTemplateAutoControlController( ITemplateService templateService, ITemplateHistoryService templateHistoryService, - ICalendarService calendarService + INextRunService nextRunService ) { this.templateService = templateService; this.templateHistoryService = templateHistoryService; - this.calendarService = calendarService; + this.nextRunService = nextRunService; } @@ -38,8 +39,14 @@ namespace PARR.API.Controllers.V1.Statistics [HttpGet(ApiRoutes.StatTemplateAutoControl.GetForPeriod)] public async Task GetForPeriod([FromQuery] TimeZoneOffsetClient timeZoneQuery, [FromQuery] int startPeriodDays = 3) { - var endPeriod = calendarService.GetDateWithTimeZoneOffset(DateTimeOffset.Now, timeZoneQuery.TimeZoneOffsetHours); - var startPeriod = endPeriod.AddDays(-startPeriodDays); + //var endPeriod = calendarService.GetDateWithTimeZoneOffset(DateTimeOffset.Now, timeZoneQuery.TimeZoneOffsetHours); + //var startPeriod = endPeriod.AddDays(-startPeriodDays); + + var userEndPeriod = DateTimeOffset.UtcNow.Add(timeZoneQuery.TimeZoneOffset); + var userStartPeriod = userEndPeriod.AddDays(-startPeriodDays); + + var utcEndPeriod = userEndPeriod.UtcDateTime; + var utcStartPeriod = userStartPeriod.UtcDateTime; var templates = await templateService.Get() .Where(t => @@ -48,12 +55,16 @@ namespace PARR.API.Controllers.V1.Statistics (( // измененные t.DateModified.HasValue - && t.DateModified.Value.DateTime.AddHours(timeZoneQuery.TimeZoneOffsetHours).Date >= startPeriod.Date - && t.DateModified.Value.DateTime.AddHours(timeZoneQuery.TimeZoneOffsetHours).Date <= endPeriod.Date + //&& t.DateModified.Value.DateTime.AddHours(timeZoneQuery.TimeZoneOffsetHours).Date >= startPeriod.Date + //&& t.DateModified.Value.DateTime.AddHours(timeZoneQuery.TimeZoneOffsetHours).Date <= endPeriod.Date + && t.DateModified.Value.UtcDateTime >= utcStartPeriod + && t.DateModified.Value.UtcDateTime <= utcEndPeriod ) || ( // созданные - t.DateCreated.DateTime.AddHours(timeZoneQuery.TimeZoneOffsetHours).Date >= startPeriod.Date - && t.DateCreated.DateTime.AddHours(timeZoneQuery.TimeZoneOffsetHours).Date <= endPeriod.Date + //t.DateCreated.DateTime.AddHours(timeZoneQuery.TimeZoneOffsetHours).Date >= startPeriod.Date + //&& t.DateCreated.DateTime.AddHours(timeZoneQuery.TimeZoneOffsetHours).Date <= endPeriod.Date + t.DateCreated.UtcDateTime >= utcStartPeriod + && t.DateCreated.UtcDateTime <= utcEndPeriod && t.InitiatorComment != null && !t.InitiatorComment.Contains(TemplateActivatorActionsEnum.ActivateAllForAutoControl.ToString()) && !t.InitiatorComment.Contains(TemplateActivatorActionsEnum.DeactivateAllForAutoControl.ToString()) @@ -72,8 +83,10 @@ namespace PARR.API.Controllers.V1.Statistics #region ищем созданные, но уже перемещенные в историю var createdTemplatesAndMoveToHistory = await templateService.Get().Include(t => t.TemplateHistories) .Where(t => - t.DateCreated.DateTime.AddHours(timeZoneQuery.TimeZoneOffsetHours).Date >= startPeriod.Date - && t.DateCreated.DateTime.AddHours(timeZoneQuery.TimeZoneOffsetHours).Date <= endPeriod.Date + //t.DateCreated.DateTime.AddHours(timeZoneQuery.TimeZoneOffsetHours).Date >= startPeriod.Date + //&& t.DateCreated.DateTime.AddHours(timeZoneQuery.TimeZoneOffsetHours).Date <= endPeriod.Date + t.DateCreated.UtcDateTime >= utcStartPeriod + && t.DateCreated.UtcDateTime <= utcEndPeriod && t.TemplateHistories.Any(x => x.InitiatorParrComponentId == Constants.ParrComponentsEnum.JobAutoControl) ) .ToListAsync(); @@ -93,8 +106,10 @@ namespace PARR.API.Controllers.V1.Statistics .Where(t => t.InitiatorParrComponentId == Constants.ParrComponentsEnum.JobAutoControl && t.DateModified.HasValue - && t.DateModified.Value.DateTime.AddHours(timeZoneQuery.TimeZoneOffsetHours).Date >= startPeriod.Date - && t.DateModified.Value.DateTime.AddHours(timeZoneQuery.TimeZoneOffsetHours).Date <= endPeriod.Date + //&& t.DateModified.Value.DateTime.AddHours(timeZoneQuery.TimeZoneOffsetHours).Date >= startPeriod.Date + //&& t.DateModified.Value.DateTime.AddHours(timeZoneQuery.TimeZoneOffsetHours).Date <= endPeriod.Date + && t.DateModified.Value.UtcDateTime >= utcStartPeriod + && t.DateModified.Value.UtcDateTime <= utcEndPeriod ) .Select(t => new QueryResult { @@ -112,31 +127,36 @@ namespace PARR.API.Controllers.V1.Statistics objs.AddRange(createdTemplatesAndMoveToHistory.Select(t => new QueryResult { DateCreated = t.DateCreated })); // создано - var creations = objs.Where(t => t.DateCreated.HasValue).GroupBy(t => t.DateCreated!.Value.AddHours(timeZoneQuery.TimeZoneOffsetHours).Date) - .Select(t => new { Date = t.Key, Created = t.Count() }); + var creations = objs.Where(t => t.DateCreated.HasValue) + //.GroupBy(t => t.DateCreated!.Value.AddHours(timeZoneQuery.TimeZoneOffsetHours).Date) + //.GroupBy(t => DateOnly.FromDateTime(t.DateCreated!.Value.ToOffset(timeZoneQuery.TimeZoneOffset).DateTime)) + //.ToDictionary(t => t.Key, t => t.Count()); + .GroupByUserDate(t => t.DateCreated!.Value, timeZoneQuery.TimeZoneOffset); // активаций (сколько раз, а не сколько объектов) var activations = objs.Where(t => t.DateModified.HasValue && t.InitiatorComment != null && t.InitiatorComment.Contains(TemplateActivatorActionsEnum.ActivateAllForAutoControl.ToString())) - .GroupBy(t => t.DateModified!.Value.AddHours(timeZoneQuery.TimeZoneOffsetHours).Date) - .Select(t => new { Date = t.Key, Actiovations = t.Count() }); + //.GroupBy(t => t.DateModified!.Value.AddHours(timeZoneQuery.TimeZoneOffsetHours).Date) + //.GroupBy(t => DateOnly.FromDateTime(t.DateModified!.Value.ToOffset(timeZoneQuery.TimeZoneOffset).DateTime)) + //.ToDictionary(t => t.Key, t => t.Count()); + .GroupByUserDate(t => t.DateModified!.Value, timeZoneQuery.TimeZoneOffset); // деактиваций (сколько раз, а не сколько объектов) var deactivations = objs.Where(t => t.DateModified.HasValue && t.InitiatorComment != null && t.InitiatorComment.Contains(TemplateActivatorActionsEnum.DeactivateAllForAutoControl.ToString())) - .GroupBy(t => t.DateModified!.Value.AddHours(timeZoneQuery.TimeZoneOffsetHours).Date) - .Select(t => new { Date = t.Key, Deactivations = t.Count() }); + //.GroupBy(t => t.DateModified!.Value.AddHours(timeZoneQuery.TimeZoneOffsetHours).Date) + //.GroupBy(t => DateOnly.FromDateTime(t.DateModified!.Value.ToOffset(timeZoneQuery.TimeZoneOffset).DateTime)) + //.ToDictionary(t => t.Key, t => t.Count()); + .GroupByUserDate(t => t.DateModified!.Value, timeZoneQuery.TimeZoneOffset); - var daysList = calendarService.GetDatesForPeriod(startPeriod, endPeriod); - var response = new List(); + //var daysList = calendarService.GetDatesForPeriod(startPeriod, endPeriod); + var daysList = await nextRunService.GetWorkDaysAsync(DateOnly.FromDateTime(userStartPeriod.DateTime), DateOnly.FromDateTime(userEndPeriod.DateTime), false); - daysList.ForEach(date => response.Add(new StatTemplateAutoControlResponse + var response = daysList.Select(date => new StatTemplateAutoControlResponse { Date = date, - Activations = activations.FirstOrDefault(t => DateOnly.FromDateTime(t.Date) == date)?.Actiovations ?? 0, - Creations = creations.FirstOrDefault(t => DateOnly.FromDateTime(t.Date) == date)?.Created ?? 0, - Deactivations = deactivations.FirstOrDefault(t => DateOnly.FromDateTime(t.Date) == date)?.Deactivations ?? 0, - })); - - response = response.OrderBy(t => t.Date).ToList(); + Activations = activations.TryGetValue(date, out var countAct) ? countAct : 0, + Creations = creations.TryGetValue(date, out var countCrt) ? countCrt : 0, + Deactivations = deactivations.TryGetValue(date, out var countDeact) ? countDeact : 0 + }).OrderBy(t => t.Date).ToList(); return Ok(new Response>(response, true)); } diff --git a/PARR.API/Controllers/V1/Statistics/StatTemplateController.cs b/PARR.API/Controllers/V1/Statistics/StatTemplateController.cs index 7613d6ff..28863f45 100644 --- a/PARR.API/Controllers/V1/Statistics/StatTemplateController.cs +++ b/PARR.API/Controllers/V1/Statistics/StatTemplateController.cs @@ -6,9 +6,10 @@ using PARR.API.Contracts.V1.Requests.BaseRequests; using PARR.API.Contracts.V1.Responses.Base; using PARR.API.Contracts.V1.Responses.Statistics; using PARR.API.Controllers.V1.Base; -using PARR.BLL.Services.Interfaces; +using PARR.API.Helpers; using PARR.Constants; using PARR.DAL.Contracts; +using PARR.DAL.NextRunServices; using PARR.DAL.Services.Interfaces; namespace PARR.API.Controllers.V1.Statistics @@ -20,15 +21,15 @@ namespace PARR.API.Controllers.V1.Statistics public class StatTemplateController : BaseApiController { private readonly ITemplateService templateService; - private readonly ICalendarService calendarService; + private readonly INextRunService nextRunService; public StatTemplateController( ITemplateService templateService, - ICalendarService calendarService + INextRunService nextRunService ) { this.templateService = templateService; - this.calendarService = calendarService; + this.nextRunService = nextRunService; } /// @@ -40,12 +41,12 @@ namespace PARR.API.Controllers.V1.Statistics { var response = new StatTemplateResponse { - ActivateScheduleCount = await templateService.Get().CountAsync(t => t.IsActiveSchedule), - ActivateTemplateCount = await templateService.Get().CountAsync(t => t.IsActiveTemplate), - TemplateAgentCount = await templateService.Get().Include(t => t.Job).ThenInclude(t=>t.Group).CountAsync(t => t.Job!.Group!.IsAgent), - TemplateCount = await templateService.Get().CountAsync(), - SyncEsppScheduleCount = await templateService.Get().Include(t => t.RobotConfigurations).CountAsync(t => t.RobotConfigurations.Any(c => c.RobotCode == (int)RobotsEnum.ScheduleOrder && c.TaskStatusCode == (int)TaskStatusEnum.Ok)), - SyncEsppTemplatesCount = await templateService.Get().Include(t => t.RobotConfigurations).CountAsync(t => t.RobotConfigurations.Any(c => c.RobotCode == (int)RobotsEnum.TemplateOrder && c.TaskStatusCode == (int)TaskStatusEnum.Ok)) + ActivateScheduleCount = await templateService.Get().AsNoTracking().CountAsync(t => t.IsActiveSchedule), + ActivateTemplateCount = await templateService.Get().AsNoTracking().CountAsync(t => t.IsActiveTemplate), + TemplateAgentCount = await templateService.Get().AsNoTracking().CountAsync(t => t.Job!.Group!.IsAgent), + TemplateCount = await templateService.Get().AsNoTracking().CountAsync(), + SyncEsppScheduleCount = await templateService.Get().AsNoTracking().CountAsync(t => t.RobotConfigurations.Any(c => c.RobotCode == (int)RobotsEnum.ScheduleOrder && c.TaskStatusCode == (int)TaskStatusEnum.Ok)), + SyncEsppTemplatesCount = await templateService.Get().AsNoTracking().CountAsync(t => t.RobotConfigurations.Any(c => c.RobotCode == (int)RobotsEnum.TemplateOrder && c.TaskStatusCode == (int)TaskStatusEnum.Ok)) }; return Ok(new Response(response, true)); @@ -61,33 +62,31 @@ namespace PARR.API.Controllers.V1.Statistics [HttpGet(ApiRoutes.StatTemplate.GetForPeriod)] public async Task GetForPeriod([FromQuery] TimeZoneOffsetClient timeZoneQuery, [FromQuery] int startPeriodDays = 3) { - var endPeriod = calendarService.GetDateWithTimeZoneOffset(DateTimeOffset.UtcNow, timeZoneQuery.TimeZoneOffsetHours); - var startPeriod = endPeriod.AddDays(-startPeriodDays); + // Формируем период + var userEnd = DateOnly.FromDateTime(DateTimeOffset.UtcNow.ToOffset(timeZoneQuery.TimeZoneOffset).DateTime); + var userStart = userEnd.AddDays(-startPeriodDays); - var query = templateService.Get() - .Where(t => - t.DateCreated.DateTime.AddHours(timeZoneQuery.TimeZoneOffsetHours).Date >= startPeriod.Date - && t.DateCreated.DateTime.AddHours(timeZoneQuery.TimeZoneOffsetHours).Date <= endPeriod.Date - ).GroupBy(t => t.DateCreated.AddHours(timeZoneQuery.TimeZoneOffsetHours).Date) - .Select(t => new - { - Date = t.Key, - CreatedObjs = t.Count() - }); + var (utcStart, utcEnd) = TimeZoneDateHelper.GetUtcDateRange( + userStart, + userEnd.AddDays(1), + timeZoneQuery.TimeZoneOffset); - var result = await query.ToListAsync(); + var allRecords = await templateService.Get() + .AsNoTracking() + .FilterByDateRangeUtc(t => t.DateCreated, utcStart, utcEnd) + .Select(t => new { t.Id, t.DateCreated }) + .ToListAsync(); - var daysList = calendarService.GetDatesForPeriod(startPeriod, endPeriod); + var resultDict = allRecords.GroupByUserDate(t => t.DateCreated, timeZoneQuery.TimeZoneOffset); - var response = new List(); - daysList.ForEach(date => response.Add(new StatTemplatePeriodResponse + var daysList = await nextRunService.GetWorkDaysAsync(userStart, userEnd, false); + + var response = daysList.Select(date => new StatTemplatePeriodResponse { Date = date, - CreatedTemplatesCount = result.FirstOrDefault(t => DateOnly.FromDateTime(t.Date) == date)?.CreatedObjs ?? 0 - })); - - response = response.OrderBy(t => t.Date).ToList(); + CreatedTemplatesCount = resultDict.TryGetValue(date, out var cnt) ? cnt : 0 + }).OrderBy(t => t.Date).ToList(); return Ok(new Response>(response, true)); } diff --git a/PARR.API/Controllers/V1/Statistics/StatTemplateDistributionController.cs b/PARR.API/Controllers/V1/Statistics/StatTemplateDistributionController.cs index 378843f8..eea541ff 100644 --- a/PARR.API/Controllers/V1/Statistics/StatTemplateDistributionController.cs +++ b/PARR.API/Controllers/V1/Statistics/StatTemplateDistributionController.cs @@ -29,7 +29,7 @@ namespace PARR.API.Controllers.V1.Statistics private readonly IJobGroupService jobGroupService; private readonly IMapper mapper; private readonly IShortcodesService shortcodesService; - private readonly INextRunServiceV2 nextRunService; + private readonly INextRunService nextRunService; private readonly ILogger logger; private readonly IScheduleResponseAreaTimeOffsetService scheduleResponseAreaTimeOffsetService; @@ -38,7 +38,7 @@ namespace PARR.API.Controllers.V1.Statistics IJobGroupService jobGroupService, IMapper mapper, IShortcodesService shortcodesService, - INextRunServiceV2 nextRunService, + INextRunService nextRunService, ILogger logger, IScheduleResponseAreaTimeOffsetService scheduleResponseAreaTimeOffsetService ) diff --git a/PARR.API/Controllers/V1/Statistics/StatUnitController.cs b/PARR.API/Controllers/V1/Statistics/StatUnitController.cs index c17e99a2..e1427571 100644 --- a/PARR.API/Controllers/V1/Statistics/StatUnitController.cs +++ b/PARR.API/Controllers/V1/Statistics/StatUnitController.cs @@ -6,8 +6,9 @@ using PARR.API.Contracts.V1.Requests.BaseRequests; using PARR.API.Contracts.V1.Responses.Base; using PARR.API.Contracts.V1.Responses.Statistics; using PARR.API.Controllers.V1.Base; -using PARR.BLL.Services.Interfaces; +using PARR.API.Helpers; using PARR.Constants; +using PARR.DAL.NextRunServices; using PARR.DAL.Services.Interfaces.Unit; namespace PARR.API.Controllers.V1.Statistics @@ -19,34 +20,36 @@ namespace PARR.API.Controllers.V1.Statistics public class StatUnitController : BaseApiController { private readonly IUnitService unitService; - private readonly ICalendarService calendarService; + private readonly INextRunService nextRunService; public StatUnitController( IUnitService unitService, - ICalendarService calendarService + INextRunService nextRunService ) { this.unitService = unitService; - this.calendarService = calendarService; + this.nextRunService = nextRunService; } /// - /// Статистика синхронизации юнитов (ЭК) с АИХИТ за последние сутки + /// Статистика синхронизации юнитов (ЭК) с АИХИТ за сегодня /// /// [HttpGet(ApiRoutes.StatUnit.Get)] - public async Task Get() + public async Task Get([FromQuery] TimeZoneOffsetClient timeZoneQuery) { - var queryDate = DateTimeOffset.UtcNow.AddDays(-1); + var todayStartUtc = TimeZoneDateHelper.GetUtcSartOfDay( + DateOnly.FromDateTime(DateTimeOffset.UtcNow.ToOffset(timeZoneQuery.TimeZoneOffset).DateTime), + timeZoneQuery.TimeZoneOffset + ); var response = new StatUnitResponse { - AllHosts = await unitService.Get().CountAsync(), - CreatedLastDay = await unitService.Get().CountAsync(t => t.DateCreated >= queryDate), - //UpdatedLastDay = await hostService.Get().CountAsync(t => t.DateModified.HasValue && t.DateModified.Value >= queryDate) + AllHosts = await unitService.Get().AsNoTracking().CountAsync(), + CreatedLastDay = await unitService.Get().AsNoTracking().CountAsync(t => t.DateCreated.UtcDateTime >= todayStartUtc), UpdatedLastDay = await unitService.Get() - .Include(t => t.UnitValues) - .Where(t => t.UnitValues.Any(v => v.DateModified.HasValue && v.DateModified.Value >= queryDate)) + .Where(t => t.UnitValues.Any(v => v.DateModified.HasValue && v.DateModified.Value.UtcDateTime >= todayStartUtc)) + .AsNoTracking() .CountAsync() }; @@ -63,37 +66,31 @@ namespace PARR.API.Controllers.V1.Statistics [HttpGet(ApiRoutes.StatUnit.GetForPeriod)] public async Task GetForPeriod([FromQuery] TimeZoneOffsetClient timeZoneQuery, [FromQuery] int startPeriodDays = 3) { - var endPeriod = calendarService.GetDateWithTimeZoneOffset(DateTimeOffset.UtcNow, timeZoneQuery.TimeZoneOffsetHours); - var startPeriod = endPeriod.AddDays(-startPeriodDays); + // Формируем период + var userEnd = DateOnly.FromDateTime(DateTimeOffset.UtcNow.ToOffset(timeZoneQuery.TimeZoneOffset).DateTime); + var userStart = userEnd.AddDays(-startPeriodDays); - var query = unitService.Get() - .Where(t => - t.DateCreated.DateTime.AddHours(timeZoneQuery.TimeZoneOffsetHours).Date >= startPeriod.Date - && t.DateCreated.DateTime.AddHours(timeZoneQuery.TimeZoneOffsetHours).Date <= endPeriod.Date - ) - .GroupBy(t => t.DateCreated.AddHours(timeZoneQuery.TimeZoneOffsetHours).Date) - .Select(t => new - { - Date = t.Key, - UnitCount = t.Count() - }); + var (utcStart, utcEnd) = TimeZoneDateHelper.GetUtcDateRange( + userStart, + userEnd.AddDays(1), + timeZoneQuery.TimeZoneOffset); - var result = await query.ToListAsync(); + var allRecords = await unitService.Get() + .AsNoTracking() + .FilterByDateRangeUtc(t => t.DateCreated, utcStart, utcEnd) + .Select(t => new { t.Id, t.DateCreated }) + .ToListAsync(); - var daysList = calendarService.GetDatesForPeriod(startPeriod, endPeriod); - var response = new List(); + var resultDict = allRecords.GroupByUserDate(t => t.DateCreated, timeZoneQuery.TimeZoneOffset); - daysList.ForEach(date => + var daysList = await nextRunService.GetWorkDaysAsync(userStart, userEnd, false); + + var response = daysList.Select(date => new StatUnitPeriodResponse { - response.Add(new StatUnitPeriodResponse - { - Date = date, - CreatedUnitsCount = result.FirstOrDefault(t => DateOnly.FromDateTime(t.Date) == date)?.UnitCount ?? 0 - }); - }); - - response = response.OrderBy(t => t.Date).ToList(); + Date = date, + CreatedUnitsCount = resultDict.TryGetValue(date, out var cnt) ? cnt : 0 + }).OrderBy(t => t.Date).ToList(); return Ok(new Response>(response, true)); } diff --git a/PARR.API/Controllers/V1/Statistics/StatUnitFieldValuesController.cs b/PARR.API/Controllers/V1/Statistics/StatUnitFieldValuesController.cs index 1dbf39e5..b04cfa74 100644 --- a/PARR.API/Controllers/V1/Statistics/StatUnitFieldValuesController.cs +++ b/PARR.API/Controllers/V1/Statistics/StatUnitFieldValuesController.cs @@ -6,8 +6,8 @@ using PARR.API.Contracts.V1.Requests.BaseRequests; using PARR.API.Contracts.V1.Responses.Base; using PARR.API.Contracts.V1.Responses.Statistics; using PARR.API.Controllers.V1.Base; -using PARR.BLL.Services.Interfaces; using PARR.Constants; +using PARR.DAL.NextRunServices; using PARR.DAL.Services.Interfaces; using PARR.DAL.Services.Interfaces.Unit; @@ -21,32 +21,44 @@ namespace PARR.API.Controllers.V1.Statistics { private readonly IApplicationService applicationService; private readonly IUnitFieldValueService unitFieldValueService; - private readonly ICalendarService calendarService; + private readonly INextRunService nextRunService; + + //private readonly ICalendarService calendarService; public StatUnitFieldValuesController( IApplicationService applicationService, IUnitFieldValueService unitFieldValueService, - ICalendarService calendarService + //ICalendarService calendarService + INextRunService nextRunService ) { this.applicationService = applicationService; this.unitFieldValueService = unitFieldValueService; - this.calendarService = calendarService; + this.nextRunService = nextRunService; + //this.calendarService = calendarService; } /// - /// Статистика синхронизации полей юнитов (ЭК) с АИХИТ за последние сутки + /// Статистика синхронизации полей юнитов (ЭК) с АИХИТ за сегодня /// /// [HttpGet(ApiRoutes.StatUnitFieldValues.Get)] - public async Task Get() + public async Task Get([FromQuery] TimeZoneOffsetClient timeZoneQuery) { - var queryDate = DateTimeOffset.UtcNow.AddDays(-1); + // Формируем начало "сегодня" в часовом поясе пользователя + var userNow = DateTimeOffset.UtcNow.ToOffset(timeZoneQuery.TimeZoneOffset); + var userTodayStart = new DateTimeOffset( + userNow.Year, userNow.Month, userNow.Day, + 0, 0, 0, + timeZoneQuery.TimeZoneOffset); + + // Конвертируем в UTC для запроса к БД + var todayStartUtc = userTodayStart.UtcDateTime; var response = new StatUnitFieldValuesResponse { - AllValues = await unitFieldValueService.Get().CountAsync(), - CreatedLastDay = await unitFieldValueService.Get().CountAsync(t => t.DateCreated >= queryDate), + AllValues = await unitFieldValueService.Get().AsNoTracking().CountAsync(), + CreatedLastDay = await unitFieldValueService.Get().AsNoTracking().CountAsync(t => t.DateCreated.UtcDateTime >= todayStartUtc), //UpdatedLastDay = await applicationService.Get().CountAsync(t => t.DateModified.HasValue && t.DateModified.Value >= queryDate), }; @@ -63,37 +75,62 @@ namespace PARR.API.Controllers.V1.Statistics [HttpGet(ApiRoutes.StatUnitFieldValues.GetForPeriod)] public async Task GetForPeriod([FromQuery] TimeZoneOffsetClient timeZoneQuery, [FromQuery] int startPeriodDays = 3) { - var endPeriod = calendarService.GetDateWithTimeZoneOffset(DateTimeOffset.UtcNow, timeZoneQuery.TimeZoneOffsetHours); - var startPeriod = endPeriod.AddDays(-startPeriodDays); + var userEndPeriod = DateTimeOffset.UtcNow.Add(timeZoneQuery.TimeZoneOffset); + var userStartPeriod = userEndPeriod.AddDays(-startPeriodDays); - var query = unitFieldValueService.Get() + var utcEndPeriod = userEndPeriod.UtcDateTime; + var utcStartPeriod = userStartPeriod.UtcDateTime; + + var allRecords = await unitFieldValueService.Get() + .AsNoTracking() .Where(t => - t.DateCreated.DateTime.AddHours(timeZoneQuery.TimeZoneOffsetHours).Date >= startPeriod.Date - && t.DateCreated.DateTime.AddHours(timeZoneQuery.TimeZoneOffsetHours).Date <= endPeriod.Date - ) - .GroupBy(t => t.DateCreated.AddHours(timeZoneQuery.TimeZoneOffsetHours).Date) - .Select(t => new - { - Date = t.Key, - Count = t.Count() - }); + //t.DateCreated.DateTime.AddHours(timeZoneQuery.TimeZoneOffsetHours).Date >= startPeriod.Date + //&& t.DateCreated.DateTime.AddHours(timeZoneQuery.TimeZoneOffsetHours).Date <= endPeriod.Date + utcStartPeriod <= t.DateCreated.UtcDateTime && t.DateCreated.UtcDateTime < utcEndPeriod + ).Select(t => new { t.Id, t.DateCreated }) + .ToListAsync(); - var result = await query.ToListAsync(); + var resultDict = allRecords.GroupBy(t => DateOnly.FromDateTime(t.DateCreated.ToOffset(timeZoneQuery.TimeZoneOffset).DateTime)) + .ToDictionary(t => t.Key, t => t.Count()); - var daysList = calendarService.GetDatesForPeriod(startPeriod, endPeriod); + //var endPeriod = calendarService.GetDateWithTimeZoneOffset(DateTimeOffset.UtcNow, timeZoneQuery.TimeZoneOffsetHours); + //var startPeriod = endPeriod.AddDays(-startPeriodDays); - var response = new List(); + //var query = unitFieldValueService.Get() + // .Where(t => + // t.DateCreated.DateTime.AddHours(timeZoneQuery.TimeZoneOffsetHours).Date >= startPeriod.Date + // && t.DateCreated.DateTime.AddHours(timeZoneQuery.TimeZoneOffsetHours).Date <= endPeriod.Date + // ) + // .GroupBy(t => t.DateCreated.AddHours(timeZoneQuery.TimeZoneOffsetHours).Date) + // .Select(t => new + // { + // Date = t.Key, + // Count = t.Count() + // }); - daysList.ForEach(date => + //var result = await query.ToListAsync(); + + //var daysList = calendarService.GetDatesForPeriod(startPeriod, endPeriod); + var daysList = await nextRunService.GetWorkDaysAsync(DateOnly.FromDateTime(userStartPeriod.DateTime), DateOnly.FromDateTime(userEndPeriod.DateTime), false); + + //var response = new List(); + + //daysList.ForEach(date => + //{ + // response.Add(new StatApplicationPeriodResponse + // { + // Date = date, + // CreatedApplicationsCount = result.FirstOrDefault(t => DateOnly.FromDateTime(t.Date) == date)?.Count ?? 0 + // }); + //}); + + //response = response.OrderBy(t => t.Date).ToList(); + + var response = daysList.Select(date => new StatApplicationPeriodResponse { - response.Add(new StatApplicationPeriodResponse - { - Date = date, - CreatedApplicationsCount = result.FirstOrDefault(t => DateOnly.FromDateTime(t.Date) == date)?.Count ?? 0 - }); - }); - - response = response.OrderBy(t => t.Date).ToList(); + Date = date, + CreatedApplicationsCount = resultDict.TryGetValue(date, out var cnt) ? cnt : 0 + }).OrderBy(t => t.Date).ToList(); return Ok(new Response>(response, true)); } diff --git a/PARR.API/Helpers/TimeZoneDateHelper.cs b/PARR.API/Helpers/TimeZoneDateHelper.cs new file mode 100644 index 00000000..b49de37b --- /dev/null +++ b/PARR.API/Helpers/TimeZoneDateHelper.cs @@ -0,0 +1,96 @@ +using System.Linq.Expressions; + +namespace PARR.API.Helpers +{ + /// + /// Хелпер для работы с датами в разных часовых поясах (UTC - пользователь). Используется для формирования СТАТИСТИКИ + /// + public static class TimeZoneDateHelper + { + /// + /// Создаёт начало дня (00:00:00) в часовом поясе пользователя и конвертирует в UTC + /// + /// + /// + /// + public static DateTime GetUtcSartOfDay(DateOnly userDate, TimeSpan userOffset) + { + var userStart = new DateTimeOffset(userDate.Year, userDate.Month, userDate.Day, + 0, 0, 0, + userOffset); + + return userStart.UtcDateTime; + } + + + /// + /// Создаёт полуоткрытый интервал [start; end) в UTC для фильтрации за период (end не включается) + /// + /// + /// + /// + /// + public static (DateTime StartUtc, DateTime EndUtc) GetUtcDateRange(DateOnly userStartDate, DateOnly userEndDateExclusive, TimeSpan userOffset) + { + var start = new DateTimeOffset( + userStartDate.Year, userStartDate.Month, userStartDate.Day, + 0, 0, 0, userOffset).UtcDateTime; + + var end = new DateTimeOffset( + userEndDateExclusive.Year, userEndDateExclusive.Month, userEndDateExclusive.Day, + 0, 0, 0, userOffset).UtcDateTime; + + return (start, end); + } + + + /// + /// Применяет фильтр по дате к IQueryable + /// + /// + /// + /// + /// + /// + /// + public static IQueryable FilterByDateRangeUtc(this IQueryable query, Expression> dateSelector, DateTime startUtc, DateTime endUtc) + { + // Строим выражение: t => t.DateCreated.UtcDateTime >= start && < end + var param = dateSelector.Parameters[0]; + var body = Expression.AndAlso( + // Строим левую часть: t.DateCreated.UtcDateTime >= startUtc + Expression.GreaterThanOrEqual( + Expression.Property(dateSelector.Body, nameof(DateTimeOffset.UtcDateTime)), + Expression.Constant(startUtc, typeof(DateTime)) + ), + // Строим правую часть: t.DateCreated.UtcDateTime < endUtc + Expression.LessThan( + Expression.Property(dateSelector.Body, nameof(DateTimeOffset.UtcDateTime)), + Expression.Constant(endUtc, typeof(DateTime)) + ) + ); + + // Собираем финальную лямбду: t => (t.DateCreated.UtcDateTime >= start && < end) + var lambda = Expression.Lambda>(body, param); + + // Применяем к запросу + return query.Where(lambda); + } + + + /// + /// Группирует записи по дате в часовом поясе пользователя (для загруженных записей) + /// + /// + /// + /// + /// + /// + public static Dictionary GroupByUserDate(this IEnumerable source, Func dateSelector, TimeSpan userOffset) + { + return source.GroupBy(t => DateOnly.FromDateTime(dateSelector(t).ToOffset(userOffset).DateTime)) + .ToDictionary(g => g.Key, g => g.Count()); + + } + } +} diff --git a/PARR.API/appsettings.json b/PARR.API/appsettings.json index d976bb4a..4b82eb4e 100644 --- a/PARR.API/appsettings.json +++ b/PARR.API/appsettings.json @@ -110,7 +110,7 @@ } ], "RabbitMq": { - "ThresholdConnections": 18 + "ThresholdConnections": 28 } } } diff --git a/PARR.BLL/Services/Implementations/CalendarService.cs b/PARR.BLL/Services/Implementations/CalendarService.cs index 9c4b4613..f6f3f9ea 100644 --- a/PARR.BLL/Services/Implementations/CalendarService.cs +++ b/PARR.BLL/Services/Implementations/CalendarService.cs @@ -7,6 +7,13 @@ namespace PARR.BLL.Services.Implementations { internal class CalendarService : ICalendarService { + + + + // !!! Вообще не использовать этот класс!!! Удалить его!!! Есть же INextRunServiceV2, вот его и использовать!!! + + + private readonly ILogger logger; public CalendarService(ILogger logger) diff --git a/PARR.DAL/NextRunServices/INextRunServiceV2.cs b/PARR.DAL/NextRunServices/INextRunService.cs similarity index 98% rename from PARR.DAL/NextRunServices/INextRunServiceV2.cs rename to PARR.DAL/NextRunServices/INextRunService.cs index 37d41a23..8d5fff32 100644 --- a/PARR.DAL/NextRunServices/INextRunServiceV2.cs +++ b/PARR.DAL/NextRunServices/INextRunService.cs @@ -4,7 +4,7 @@ using PARR.DAL.NextRunServices.Models; namespace PARR.DAL.NextRunServices { - public interface INextRunServiceV2 + public interface INextRunService { /// /// Распределить шаблоны в группе работ (получить список распределенных шаблонов с актуальными nextRun) diff --git a/PARR.DAL/NextRunServices/NextRunServiceV2.cs b/PARR.DAL/NextRunServices/NextRunService.cs similarity index 99% rename from PARR.DAL/NextRunServices/NextRunServiceV2.cs rename to PARR.DAL/NextRunServices/NextRunService.cs index aa1dff2b..9e54bb29 100644 --- a/PARR.DAL/NextRunServices/NextRunServiceV2.cs +++ b/PARR.DAL/NextRunServices/NextRunService.cs @@ -13,23 +13,23 @@ using PARR.DAL.Services.Interfaces.Schedule; namespace PARR.DAL.NextRunServices { - internal class NextRunServiceV2 : INextRunServiceV2 + internal class NextRunService : INextRunService { - private readonly ILogger logger; + private readonly ILogger logger; private readonly ITemplateService templateService; private readonly IJobGroupService jobGroupService; private readonly IEsppScheduleTransformService esppScheduleTransformService; - private readonly ITemplateDistributorV2 templateDistributor; + private readonly ITemplateDistributor templateDistributor; private readonly IShortcodesService shortcodesService; private readonly IScheduleResponseAreaTimeOffsetService scheduleResponseAreaTimeOffsetService; private readonly SettingsFromDb settingsFromDb; - public NextRunServiceV2( - ILogger logger, + public NextRunService( + ILogger logger, ITemplateService templateService, IJobGroupService jobGroupService, IEsppScheduleTransformService esppScheduleTransformService, - ITemplateDistributorV2 templateDistributor, + ITemplateDistributor templateDistributor, IShortcodesService shortcodesService, IScheduleResponseAreaTimeOffsetService scheduleResponseAreaTimeOffsetService, SettingsFromDb settingsFromDb diff --git a/PARR.DAL/NextRunServices/Subservices/ITemplateDistributor.cs b/PARR.DAL/NextRunServices/Subservices/ITemplateDistributor.cs index 6126e040..043fc44a 100644 --- a/PARR.DAL/NextRunServices/Subservices/ITemplateDistributor.cs +++ b/PARR.DAL/NextRunServices/Subservices/ITemplateDistributor.cs @@ -5,63 +5,47 @@ namespace PARR.DAL.NextRunServices.Subservices internal interface ITemplateDistributor { /// - /// Распределяет все шаблоны на указанный период, учитывая план нагрузки. - /// Возвращает список всех шаблонов с обновлёнными датами выполнения. + /// Распределить шаблоны /// - /// Дата начала распределения - /// Длительность распределения, дней - /// ReferenceDate - /// Спиоск шаблонов для распределения - /// Исключать выходные из распределения - /// - Task> DistributeTemplatesAsync(DateOnly startDate, int duration, DateTimeOffset referenceDate, List allTemplates, bool excludeWeekends); - - - - /// - /// Возвращает актуальную дату выполнения для одного шаблона, учитывая план нагрузки. - /// Возвращает обновлённый шаблон с новой датой выполнения. - /// - /// + /// Дата начала, в любом часовом поясе /// /// + /// + /// + /// + /// + Task> DistributeTemplatesAsync(DateOnly startDate, int duration, DateTimeOffset referenceDate, TimeSpan? offset, List allTemplates, bool excludeWeekends); + + /// + /// Метод проверяет, актуален ли текущий NextRun шаблона, если не актуален, возвращает актуальное значение + /// При проверке загружаются шаблоны из БД! Если хотим рассчитывать nextRun для НЕСКОЛЬКИХ шаблонов, то следует рассчитать nextRun для одного шаблона, сохранить в БД, затем рассчитать для следующего + /// + /// Дата начала, в любом часовом поясе + /// + /// + /// /// /// /// - /// Новый шаблон + /// /// - Task GetValidNextRunForTemplateAsync(DateOnly startDate, int duration, DateTimeOffset referenceDate, TemplateNextRunDto targetTemplate, List allTemplates, bool excludeWeekends, bool isNew); - - + Task GetNextRunForTemplateAsync(DateOnly startDate, int duration, DateTimeOffset referenceDate, TimeSpan? offset, TemplateNextRunDto targetTemplate, List allTemplates, bool excludeWeekends, bool isNew); + /// /// Получить список выходных дней /// /// /// - /// /// - Task> GetWeekendsV2Async(DateOnly startDate, DateOnly endDate, DateTimeOffset referenceDate); - - - ///// - ///// Получить список рабочих дней - ///// - ///// - ///// - ///// - ///// - //Task> GetWorkDaysAsync(DateOnly start, DateOnly end, bool excludeWeekends); - + Task> GetWeekendsAsync(DateOnly startDate, DateOnly endDate); /// - /// Получить список рабочих дней + /// Получить список рабочих дней в диапазоне, исключая выходные и праздники. /// /// /// - /// - /// + /// Исключить выходные и праздники /// - Task> GetWorkDaysV2Async(DateOnly start, DateOnly end, bool excludeWeekends, DateTimeOffset referenceDate); + Task> GetWorkDaysAsync(DateOnly start, DateOnly end, bool excludeWeekends); } } - diff --git a/PARR.DAL/NextRunServices/Subservices/ITemplateDistributorV2.cs b/PARR.DAL/NextRunServices/Subservices/ITemplateDistributorV2.cs deleted file mode 100644 index 57c10510..00000000 --- a/PARR.DAL/NextRunServices/Subservices/ITemplateDistributorV2.cs +++ /dev/null @@ -1,51 +0,0 @@ -using PARR.DAL.NextRunServices.Models; - -namespace PARR.DAL.NextRunServices.Subservices -{ - internal interface ITemplateDistributorV2 - { - /// - /// Распределить шаблоны - /// - /// Дата начала, в любом часовом поясе - /// - /// - /// - /// - /// - /// - Task> DistributeTemplatesAsync(DateOnly startDate, int duration, DateTimeOffset referenceDate, TimeSpan? offset, List allTemplates, bool excludeWeekends); - - /// - /// Метод проверяет, актуален ли текущий NextRun шаблона, если не актуален, возвращает актуальное значение - /// При проверке загружаются шаблоны из БД! Если хотим рассчитывать nextRun для НЕСКОЛЬКИХ шаблонов, то следует рассчитать nextRun для одного шаблона, сохранить в БД, затем рассчитать для следующего - /// - /// Дата начала, в любом часовом поясе - /// - /// - /// - /// - /// - /// - /// - /// - Task GetNextRunForTemplateAsync(DateOnly startDate, int duration, DateTimeOffset referenceDate, TimeSpan? offset, TemplateNextRunDto targetTemplate, List allTemplates, bool excludeWeekends, bool isNew); - - /// - /// Получить список выходных дней - /// - /// - /// - /// - Task> GetWeekendsAsync(DateOnly startDate, DateOnly endDate); - - /// - /// Получить список рабочих дней в диапазоне, исключая выходные и праздники. - /// - /// - /// - /// Исключить выходные и праздники - /// - Task> GetWorkDaysAsync(DateOnly start, DateOnly end, bool excludeWeekends); - } -} diff --git a/PARR.DAL/NextRunServices/Subservices/TemplateDistributor.cs b/PARR.DAL/NextRunServices/Subservices/TemplateDistributor.cs index 1430c248..e0eab027 100644 --- a/PARR.DAL/NextRunServices/Subservices/TemplateDistributor.cs +++ b/PARR.DAL/NextRunServices/Subservices/TemplateDistributor.cs @@ -5,6 +5,11 @@ using PARR.DAL.Services.Interfaces; namespace PARR.DAL.NextRunServices.Subservices { + /// + /// Распределитель шаблонов с поддержкой часовых поясов. + /// Все входные даты интерпретируются в целевом часовом поясе (параметр offset). + /// Все результаты возвращаются строго в UTC (TimeSpan.Zero). + /// internal class TemplateDistributor : ITemplateDistributor { private readonly ILogger logger; @@ -15,141 +20,324 @@ namespace PARR.DAL.NextRunServices.Subservices /// private const int offsetYearsIfErrorCalcNextRun = 5; - public TemplateDistributor( - ILogger logger, - IWeekendDayService weekendDayService - ) + /// + /// Максимальное количество попыток расширения периода при отсутствии рабочих дней + /// + private const int maxPeriodExpansions = 2; + + public TemplateDistributor(ILogger logger, IWeekendDayService weekendDayService) { this.logger = logger; this.weekendDayService = weekendDayService; } - public async Task GetValidNextRunForTemplateAsync(DateOnly startDate, int duration, DateTimeOffset referenceDate, TemplateNextRunDto targetTemplate, List allTemplates, bool excludeWeekends, bool isNew) + public async Task GetNextRunForTemplateAsync(DateOnly startDate, int duration, DateTimeOffset referenceDate, TimeSpan? offset, TemplateNextRunDto targetTemplate, List allTemplates, bool excludeWeekends, bool isNew) { - //if (offset.HasValue) - // referenceDate = referenceDate.ToOffset(offset.Value); + // Валидация входных параметров + if (duration <= 0) + throw new ArgumentException($"Duration must be positive, got {duration}", nameof(duration)); - //todo: allTemplates - пока не используется. Будет использоваться когда каждый раз будем строить план + if (allTemplates == null) + throw new ArgumentNullException(nameof(allTemplates)); - // Пока работает так: - // Получаем список рабочих дней, если работа попадает в эти дни, то считаем дату валидной - // Если нет, то перемещаем на последний день периода распределения или первый в зависимости от параметра isNew + logger.LogInformation("Начинаем проверку/расчёт NextRun для шаблона {templateId}, nextRun={nextRun}. isNew={isNew}, offset={offset}, referenceDate={referenceDate}", targetTemplate.Id, targetTemplate.NextRun, isNew, offset, referenceDate); - var _startDate = GetStartPeriodDate(startDate, 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.LogDebug("Получаю актуальный nextRun для шаблона {templateId}. Текущий nextRun: {nextRun}. Период {duration} дней, с {_startDate} по {_endDate}. Исключить выходные и праздники: {excludeWeekends}. Для нового шаблона: {isNew}", targetTemplate.Id, targetTemplate.NextRun, duration, _startDate, _endDate, excludeWeekends, isNew); + logger.LogInformation("Период распределения: {_startDate} - {_endDate} (в поясе {offset})", _startDate, _endDate, targetOffset); - // Получаем список рабочих дней (исключая выходные и праздники) - //var workDays = await GetWorkDaysAsync(_startDate, _endDate, excludeWeekends); - var workDays = await GetWorkDaysV2Async(_startDate, _endDate, excludeWeekends, referenceDate); + // 3. Получаем список рабочих дней с расширением периода при необходимости + var (expandedEndDate, workDays) = await GetWorkDaysWithExpansionAsync(_startDate, duration, excludeWeekends); + + // Обновляем _endDate на расширенный период для дальнейших расчётов + _endDate = expandedEndDate; if (workDays.Count == 0) { - logger.LogWarning("Нет доступных рабочих дней в диапазоне {_startDate} - {_endDate}. Возвращаем шаблон со смещением: {offsetYearsIfErrorCalcNextRun} лет.", _startDate, _endDate, offsetYearsIfErrorCalcNextRun); - - // смещаем nextRun вперед - var result = FillTemplateResultWithOffsetError(targetTemplate, _startDate, referenceDate); - - logger.LogInformation("Получение актуального NextRun завершено, templateId: {templateId}. NextRun {nextRun}. OldNextRun у {oldNextRun} шт.", result.Id, result.NextRun, result.NextRunOld); - - return result; + logger.LogError("Не удалось найти рабочие дни даже после {maxAttempts} попыток расширения периода [{start}, {end}]. Возвращаем ошибку со смещением.", maxPeriodExpansions, _startDate, _endDate); + return FillTemplateResultWithOffsetError(targetTemplate, _startDate, referenceDateInTargetTz, targetOffset); } - // если isNew, то берем первый день периода - if (isNew) + #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) { - var result = new TemplateNextRunResultDto - ( - Id: targetTemplate.Id, - NextRun: FindNextAvailableDateIgnoreLoad(workDays, referenceDate, true), //CreateDateTimeOffset(workDays.First(), referenceDate), - NextRunOld: targetTemplate.NextRun - ); - - logger.LogInformation("Получение актуального NextRun завершено, templateId: {templateId}. Это было создание нового, поместили его в начало периода. NextRun {nextRun}. OldNextRun у {oldNextRun} шт.", result.Id, result.NextRun, result.NextRunOld); - - return result; + // Нет текущего NextRun — тип плана зависит от isNew + useMirrorPlan = !isNew; + logger.LogDebug("Нет текущего NextRun. Тип плана: {planType} (isNew={isNew})", useMirrorPlan ? "зеркальный" : "обычный", isNew); } - - // есть ли NextRun и попадает ли он на рабочий день - // проверяем чтоб было не меньше чем UtcNow - //TODO: тут еще сделать проверку нагрузки на день - if (targetTemplate.NextRun.HasValue && workDays.Contains(DateOnly.FromDateTime(targetTemplate.NextRun.Value.Date)) && targetTemplate.NextRun.Value > DateTimeOffset.UtcNow) + else { - var result = new TemplateNextRunResultDto - ( - Id: targetTemplate.Id, - NextRun: CreateDateTimeOffset(targetTemplate.NextRun.Value, referenceDate), - NextRunOld: targetTemplate.NextRun - ); + // Есть текущий NextRun — проверяем, в периоде ли он + var _nextRunInTargetTz = targetTemplate.NextRun.Value.ToOffset(targetOffset); + var _nextRunDate = DateOnly.FromDateTime(_nextRunInTargetTz.Date); - logger.LogInformation("Получение актуального NextRun завершено, templateId: {templateId}. NextRun актуальный, возвращаем его. NextRun {nextRun}. OldNextRun у {oldNextRun} шт.", result.Id, result.NextRun, result.NextRunOld); + bool _isInPeriod = _nextRunDate >= _startDate && _nextRunDate <= _endDate; + bool _isWorkDay = workDays.Contains(_nextRunDate); - return result; + if (_isInPeriod && !_isWorkDay) + { + // В периоде, но нерабочий день — обычный план + useMirrorPlan = false; + logger.LogDebug("NextRun в периоде, но на нерабочем дне. Используем обычный план."); + } + else if (!_isInPeriod) + { + // Не в периоде — тип плана зависит от isNew + useMirrorPlan = !isNew; + logger.LogDebug("NextRun вне периода. Тип плана: {planType} (isNew={isNew})", useMirrorPlan ? "зеркальный" : "обычный", isNew); + } + else + { + // В периоде и рабочий день — будем проверять актуальность + useMirrorPlan = false; + logger.LogDebug("NextRun в периоде и на рабочем дне. Используем обычный план для проверки."); + } } + // Строим план распределения + List plan; + if (useMirrorPlan) + { + // Зеркальный план + plan = GetDistributionPlan(templatesForPlan.Count + 1, workDays, mirror: true); + logger.LogDebug("Построен зеркальный план для {count} шаблонов", templatesForPlan.Count + 1); + } + else + { + // Обычный план + plan = GetDistributionPlan(templatesForPlan.Count + 1, workDays); + logger.LogDebug("Построен обычный план для {count} шаблонов", templatesForPlan.Count + 1); + } - //TODO: Временный костыль, перемещаем на конец периода + // Считаем ожидаемую нагрузку из плана + var expectedDailyLoad = plan.GroupBy(d => d).ToDictionary(g => g.Key, g => g.Count()); - var endPeriodResult = new TemplateNextRunResultDto - ( - Id: targetTemplate.Id, - NextRun: FindNextAvailableDateIgnoreLoad(workDays, referenceDate, false), - NextRunOld: targetTemplate.NextRun - ); + logger.LogDebug("Ожидаемая нагрузка по плану: {loadCount} дней", expectedDailyLoad.Count); + foreach (var kvp in expectedDailyLoad.OrderBy(x => x.Key)) + logger.LogDebug(" {date}: ожидаемо {count} шаблонов", kvp.Key, kvp.Value); - logger.LogInformation("Получение актуального NextRun завершено, templateId: {templateId}. NextRun перемещен на конец периода. NextRun {nextRun}. OldNextRun у {oldNextRun} шт.", endPeriodResult.Id, endPeriodResult.NextRun, endPeriodResult.NextRunOld); + // Считаем текущую нагрузку (только шаблоны внутри периода) + var currentDailyLoad = BuildCurrentLoad(templatesForPlan, workDays, _startDate, _endDate, targetOffset); - return endPeriodResult; + logger.LogDebug("Текущая нагрузка (без учёта текущего шаблона): {loadCount} дней", currentDailyLoad.Count); + foreach (var kvp in currentDailyLoad.OrderBy(x => x.Key)) + logger.LogDebug(" {date}: текущая {current}, ожидаемая {expected}", kvp.Key, kvp.Value, expectedDailyLoad.GetValueOrDefault(kvp.Key, 0)); + + #endregion + + // 5. Случай: нет текущего NextRun + if (!targetTemplate.NextRun.HasValue) + { + logger.LogInformation("Шаблон {templateId} не имеет текущего NextRun. Ищем новое место...", targetTemplate.Id); + + return HandleNoNextRun( + targetTemplate, + workDays, + currentDailyLoad, + expectedDailyLoad, + referenceDateInTargetTz, + targetOffset, + isNew); + } + + #region 6. Случай: есть текущий NextRun — проверяем актуальность + + logger.LogDebug("Шаблон {templateId} имеет текущий NextRun: {nextRun}. Проверяем актуальность...", targetTemplate.Id, targetTemplate.NextRun.Value); + + var nextRunInTargetTz = targetTemplate.NextRun.Value.ToOffset(targetOffset); + var nextRunDate = DateOnly.FromDateTime(nextRunInTargetTz.Date); + + logger.LogDebug("NextRun в целевом поясе {offset}: {nextRunTarget} (дата: {date})", targetOffset, nextRunInTargetTz, nextRunDate); + + // Проверка 1: В периоде? + var isInPeriod = nextRunDate >= _startDate && nextRunDate <= _endDate; + logger.LogDebug("Проверка 1 (в периоде): {result} ({date} ∈ [{start}, {end}])", isInPeriod, nextRunDate, _startDate, _endDate); + + // Проверка 2: Рабочий день? + var isWorkDay = workDays.Contains(nextRunDate); + logger.LogDebug("Проверка 2 (рабочий день): {result}", isWorkDay ? "да" : "нет"); + + // Проверка 3: В будущем? + var nextRunMoment = new DateTimeOffset( + nextRunDate.Year, nextRunDate.Month, nextRunDate.Day, + nextRunInTargetTz.Hour, nextRunInTargetTz.Minute, nextRunInTargetTz.Second, + targetOffset); + + var isFuture = nextRunMoment >= DateTimeOffset.UtcNow; + logger.LogDebug("Проверка 3 (в будущем): {result} ({moment} >= {now})", isFuture, nextRunMoment, DateTimeOffset.UtcNow); + + // Проверка 4: Время совпадает с referenceDate? + var hasMatchingTime = nextRunInTargetTz.Hour == referenceDateInTargetTz.Hour && + nextRunInTargetTz.Minute == referenceDateInTargetTz.Minute && + nextRunInTargetTz.Second == referenceDateInTargetTz.Second; + logger.LogDebug("Проверка 4 (время совпадает): {result} (NextRun: {nextRunHour}:{nextRunMin}:{nextRunSec} vs Reference: {refHour}:{refMin}:{refSec})", + hasMatchingTime, + nextRunInTargetTz.Hour, nextRunInTargetTz.Minute, nextRunInTargetTz.Second, + referenceDateInTargetTz.Hour, referenceDateInTargetTz.Minute, referenceDateInTargetTz.Second); + + // СЛУЧАЙ А: Дата актуальна, но время не совпадает → корректируем ТОЛЬКО время + if (isInPeriod && isWorkDay && isFuture && !hasMatchingTime) + { + logger.LogDebug("Дата NextRun шаблона {templateId} актуальна, но время не совпадает. Корректируем время с {oldTime} на {newTime} (пояс {offset}).", + targetTemplate.Id, + $"{nextRunInTargetTz.Hour:D2}:{nextRunInTargetTz.Minute:D2}:{nextRunInTargetTz.Second:D2}", + $"{referenceDateInTargetTz.Hour:D2}:{referenceDateInTargetTz.Minute:D2}:{referenceDateInTargetTz.Second:D2}", + targetOffset); + + // Сохраняем ту же дату, но меняем время на время из referenceDate + var correctedNextRunInTargetTz = new DateTimeOffset( + nextRunDate.Year, nextRunDate.Month, nextRunDate.Day, + referenceDateInTargetTz.Hour, + referenceDateInTargetTz.Minute, + referenceDateInTargetTz.Second, + targetOffset); + + //скорректированный момент ещё в будущем? + if (correctedNextRunInTargetTz >= DateTimeOffset.UtcNow) + { + // Время актуально — возвращаем скорректированный NextRun + var correctedNextRunUtc = correctedNextRunInTargetTz.ToOffset(TimeSpan.Zero); + + logger.LogInformation("Скорректированный NextRun для шаблона {templateId}: {nextRunTarget} (пояс {offset}) → {nextRunUtc} (UTC). NextRunOld: {nextRunOld}", + targetTemplate.Id, correctedNextRunInTargetTz, targetOffset, correctedNextRunUtc, targetTemplate.NextRun); + + return new TemplateNextRunResultDto( + Id: targetTemplate.Id, + NextRun: correctedNextRunUtc, + NextRunOld: targetTemplate.NextRun); + } + else + { + // Скорректированное время уже прошло — считаем дату НЕАКТУАЛЬНОЙ + logger.LogDebug("Скорректированное время {correctedTime} уже прошло (сейчас {now}). Считаем дату неактуальной и передаём в основную логику поиска.", + correctedNextRunInTargetTz, DateTimeOffset.UtcNow); + + // Помечаем дату как неактуальную по времени + isFuture = false; + // Дальнейшая логика сама вызовет HandleOutdatedNextRun с правильными параметрами + } + } + + // СЛУЧАЙ Б: Все проверки пройдены (включая время) → возвращаем без изменений + if (isInPeriod && isWorkDay && isFuture && hasMatchingTime) + { + var nextRunUtc = targetTemplate.NextRun.Value.ToOffset(TimeSpan.Zero); + logger.LogInformation("NextRun шаблона {templateId} полностью актуален. Возвращаем без изменений: {nextRunUtc} (UTC)", + targetTemplate.Id, nextRunUtc); + + return new TemplateNextRunResultDto( + Id: targetTemplate.Id, + NextRun: nextRunUtc, + NextRunOld: targetTemplate.NextRun); + } + + // СЛУЧАЙ В: Дата неактуальна (не в периоде / нерабочий день / уже прошло) → ищем новое место + logger.LogDebug("NextRun шаблона {templateId} неактуален по дате (в периоде={inPeriod}, рабочий={workDay}, будущее={future}). Ищем новое место...", + targetTemplate.Id, isInPeriod, isWorkDay, isFuture); + + #endregion + + // 7. NextRun неактуален — ищем новое место + logger.LogDebug("NextRun шаблона {templateId} неактуален (в периоде={inPeriod}, рабочий={workDay}, будущее={future}). Ищем новое место...", targetTemplate.Id, isInPeriod, isWorkDay, isFuture); + + return HandleOutdatedNextRun( + targetTemplate, + nextRunDate, + isInPeriod, + isWorkDay, + isNew, + workDays, + currentDailyLoad, + expectedDailyLoad, + referenceDateInTargetTz, + targetOffset, + _startDate, + _endDate); } - public async Task> DistributeTemplatesAsync(DateOnly startDate, int duration, DateTimeOffset referenceDate, List allTemplates, bool excludeWeekends) + /// + /// Распределить шаблоны + /// + /// + /// + /// + /// + /// + /// + /// + public async Task> DistributeTemplatesAsync(DateOnly startDate, int duration, DateTimeOffset referenceDate, TimeSpan? offset, List allTemplates, bool excludeWeekends) { - //if (offset.HasValue) - //referenceDate = referenceDate.ToOffset(offset.Value); + // Валидация входных параметров + if (duration <= 0) + throw new ArgumentException($"Duration must be positive, got {duration}", nameof(duration)); + + if (allTemplates == null) + throw new ArgumentNullException(nameof(allTemplates)); - var _startDate = GetStartPeriodDate(startDate, referenceDate); + // 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: {referenceDate}. Исключить выходные и праздники: {excludeWeekends}.", allTemplates.Count, duration, _startDate, _endDate, referenceDate, excludeWeekends); + logger.LogInformation("Начинаем распределение {Count} шаблонов на {duration} дней, с {_startDate} по {_endDate}. ReferenceDateInTargetTz: {referenceDateInTargetTz} в целевом поясе: {targetOffset} ReferenceDate UTC: {referenceDate}. Исключить выходные и праздники: {excludeWeekends}.", allTemplates.Count, duration, _startDate, _endDate, referenceDateInTargetTz, targetOffset, referenceDate, excludeWeekends); - // Получаем список рабочих дней (исключая выходные и праздники) - //var workDays = await GetWorkDaysAsync(_startDate, _endDate, excludeWeekends); - var workDays = await GetWorkDaysV2Async(_startDate, _endDate, excludeWeekends, referenceDate); + // 4. Получаем рабочие дни с расширением периода при необходимости + var (expandedEndDate, workDays) = await GetWorkDaysWithExpansionAsync(_startDate, duration, excludeWeekends); + + // Обновляем _endDate на расширенный период для дальнейших расчётов + _endDate = expandedEndDate; if (workDays.Count == 0) { - logger.LogWarning("Нет доступных рабочих дней в диапазоне {_startDate} - {_endDate}. Возвращаем список шаблонов со смещением: {offsetYearsIfErrorCalcNextRun} лет.", _startDate, _endDate, offsetYearsIfErrorCalcNextRun); + logger.LogError("Не удалось найти рабочие дни даже после {maxAttempts} попыток расширения периода [{start}, {end}]. Возвращаем шаблоны со смещением {years} лет.", maxPeriodExpansions, _startDate, _endDate, offsetYearsIfErrorCalcNextRun); - // смещаем все nextRun вперед - var result = allTemplates.Select(t => - FillTemplateResultWithOffsetError(t, _startDate, referenceDate) - ).OrderBy(t => t.NextRun).ToList(); + var errorResult = allTemplates.Select(t => + FillTemplateResultWithOffsetError(t, _startDate, referenceDateInTargetTz, targetOffset) + ).OrderBy(t => t.NextRun).ToList(); - logger.LogInformation("Распределение завершено. Возвращаем {Count} записей. NextRun не изменился у {oldCount} шт. Обновлен NextRun у {changedCount} шт.", result.Count, result.Count(t => t.NextRun == t.NextRunOld), result.Count(t => t.NextRun != t.NextRunOld)); - - return result; + logger.LogInformation("Распределение завершено с ошибкой. Возвращаем {Count} записей.", errorResult.Count); + return errorResult; } - var totalWorks = allTemplates.Count; - - // интервал между шаблонами - var interval = GetInterval(workDays.Count, totalWorks); - + // 5. Строим план распределения // строим план, дни на которые должны быть назанчены шаблоны - var plan = GetPlan(totalWorks, workDays); - - + var plan = GetDistributionPlan(allTemplates.Count, workDays); // Подсчитываем, сколько работ должно быть на каждый день - var expectedDailyLoad = new Dictionary(); + var expectedDailyLoad = plan.GroupBy(d => d) + .ToDictionary(g => g.Key, g => g.Count()); - foreach (var day in plan) - expectedDailyLoad[day] = expectedDailyLoad.GetValueOrDefault(day, 0) + 1; - - #region Группируем шаблоны по дням, на которые они уже назначены + #region Группируем шаблоны по дням В ЦЕЛЕВОМ ПОЯСЕ, на которые они уже назначены var templatesByDay = new Dictionary>(); @@ -157,37 +345,36 @@ namespace PARR.DAL.NextRunServices.Subservices { if (template.NextRun.HasValue) { - var day = DateOnly.FromDateTime(template.NextRun.Value.Date); - if (!templatesByDay.ContainsKey(day)) + // КОНВЕРТИРУЕМ В ЦЕЛЕВОЙ ПОЯС ДЛЯ ОПРЕДЕЛЕНИЯ ДАТЫ! + var dayInTargetTz = DateOnly.FromDateTime(template.NextRun.Value.ToOffset(targetOffset).Date); + //var day = DateOnly.FromDateTime(template.NextRun.Value.Date); + + if (!templatesByDay.ContainsKey(dayInTargetTz)) { - templatesByDay[day] = new List(); + templatesByDay[dayInTargetTz] = new List(); } - templatesByDay[day].Add(template); + templatesByDay[dayInTargetTz].Add(template); + + logger.LogDebug("Шаблон {Id} с NextRun={nextRun} отнесён к дате {day} в поясе {offset}", template.Id, template.NextRun.Value, dayInTargetTz, targetOffset); } } #endregion - #region Подсчитываем текущую нагрузку на каждый день + #region 7. Подсчитываем текущую нагрузку на каждый день - var currentDailyLoad = new Dictionary(); - - foreach (var day in workDays) - { - currentDailyLoad[day] = 0; - } + var currentDailyLoad = workDays.ToDictionary(day => day, _ => 0); foreach (var kvp in templatesByDay) { - var day = kvp.Key; - var count = kvp.Value.Count; - currentDailyLoad[day] = count; + if (currentDailyLoad.ContainsKey(kvp.Key)) + currentDailyLoad[kvp.Key] = kvp.Value.Count; } #endregion - #region Определяем, какие шаблоны нужно оставить, а какие — переназначить + #region 8. Определяем, какие шаблоны нужно оставить, а какие — переназначить var templatesToKeep = new List(); var templatesToReassign = new List(); @@ -212,31 +399,117 @@ namespace PARR.DAL.NextRunServices.Subservices if (currentCount <= expectedCount) { - // Все шаблоны в этом дне можно оставить + // Все шаблоны в этом дне можно оставить, но нужно проверить/скорректировать время foreach (var template in templates) { - templatesToKeep.Add(new TemplateNextRunResultDto - ( + // Конвертируем текущий 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: template.NextRun.Value, + NextRun: newNextRun, NextRunOld: template.NextRun )); - logger.LogDebug("Шаблон {TemplateId} оставлен на дату {NextRun}, т.к. нагрузка в день не превышена.", template.Id, template.NextRun); } } else { // Оставляем только expectedCount штук, остальные — переназначить + // (для оставляемых тоже нужно проверить/скорректировать время) for (int i = 0; i < expectedCount; i++) { var template = templates[i]; - templatesToKeep.Add(new TemplateNextRunResultDto - ( + + // Конвертируем текущий 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: template.NextRun.Value, + NextRun: newNextRun, NextRunOld: template.NextRun )); - logger.LogDebug("Шаблон {TemplateId} оставлен на дату {NextRun}, т.к. нагрузка в день не превышена.", template.Id, template.NextRun); } for (int i = expectedCount; i < currentCount; i++) @@ -247,10 +520,10 @@ namespace PARR.DAL.NextRunServices.Subservices } } } - #endregion - #region Добавляем шаблоны, у которых не было даты — тоже нужно распределить + + #region 9. Добавляем шаблоны, у которых не было даты — тоже нужно распределить foreach (var template in allTemplates) { @@ -263,125 +536,466 @@ namespace PARR.DAL.NextRunServices.Subservices #endregion - #region Распределяем шаблоны, которые нужно переназначить, учитывая нагрузку + #region 10. Распределяем шаблоны, которые нужно переназначить, учитывая нагрузку var updatedTemplates = new List(templatesToKeep); - foreach (var template in templatesToReassign) + foreach (var template in templatesToReassign.OrderBy(t => t.NextRun)) { - var nextRun = FindNextAvailableDateWithLoad(currentDailyLoad, workDays, referenceDate, expectedDailyLoad); - if (nextRun.HasValue) + var nextRunInTargetTz = FindNextAvailableDateWithLoad(currentDailyLoad, workDays, referenceDateInTargetTz, expectedDailyLoad, targetOffset); + if (nextRunInTargetTz.HasValue) { - var day = DateOnly.FromDateTime(nextRun.Value.Date); + var day = DateOnly.FromDateTime(nextRunInTargetTz.Value.Date); currentDailyLoad[day]++; + // КОНВЕРТИРУЕМ В UTC ПЕРЕД ВОЗВРАТОМ! updatedTemplates.Add(new TemplateNextRunResultDto ( Id: template.Id, - NextRun: nextRun.Value, + NextRun: nextRunInTargetTz.Value.ToOffset(TimeSpan.Zero), // ← В UTC! NextRunOld: template.NextRun )); - logger.LogDebug("Шаблону {TemplateId} назначен NextRun: {NextRun}, NextRunOld: {NextRunOld}", template.Id, nextRun, 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); - // Возвращаем с ошибкой - updatedTemplates.Add(new TemplateNextRunResultDto - ( - Id: template.Id, - NextRun: CreateDateTimeOffset(DateTimeOffset.UtcNow.AddYears(offsetYearsIfErrorCalcNextRun), referenceDate), - NextRunOld: template.NextRun - )); + // Возвращаем со смещением на несколько лет, так как ошибка + // конвертируем в UTC + updatedTemplates.Add(FillTemplateResultWithOffsetError(template, _startDate, referenceDateInTargetTz, targetOffset)); } } #endregion + // 11. Сортируем и возвращаем результат в UTC - updatedTemplates = updatedTemplates.OrderBy(t => t.NextRun).ToList(); + var result = updatedTemplates.OrderBy(t => t.NextRun).ToList(); logger.LogDebug("--- Результат распределения ---"); - foreach (var item in updatedTemplates) - logger.LogDebug("Шаблон {templateId}, NextRun: {NextRun}, NextRunOld: {NextRunOld}", item.Id, item.NextRun, item.NextRunOld); + foreach (var item in result) + logger.LogDebug("Шаблон {templateId}, NextRun UTC: {NextRun}, NextRunOld UTC: {NextRunOld}", item.Id, item.NextRun, item.NextRunOld); - logger.LogInformation("Распределение завершено. Возвращаем {Count} записей. NextRun не изменился у {oldCount} шт. Обновлен NextRun у {changedCount} шт.", updatedTemplates.Count, updatedTemplates.Count(t => t.NextRun == t.NextRunOld), updatedTemplates.Count(t => t.NextRun != t.NextRunOld)); - - return updatedTemplates; - } - - - /// - /// Заполнить TemplateNextRunResultDto, если нет рабочих дней со смещением - /// - /// - /// - private TemplateNextRunResultDto FillTemplateResultWithOffsetError(TemplateNextRunDto template, DateOnly startDate, DateTimeOffset referenceDate) - { - var result = new TemplateNextRunResultDto - ( - Id: template.Id, - //NextRun = template.NextRun.HasValue - // // если есть nextRun, то прибавляем к нему offsetYearsIfErrorCalcNextRun - // ? CreateNextRun(template.NextRun.Value.AddYears(offsetYearsIfErrorCalcNextRun), referenceDate) - // // если нет nextRun, берем сегодня и прибавляем offsetYearsIfErrorCalcNextRun - // : CreateNextRun(DateTimeOffset.UtcNow.AddYears(offsetYearsIfErrorCalcNextRun), referenceDate), - NextRun: CreateDateTimeOffset(startDate.AddYears(offsetYearsIfErrorCalcNextRun), referenceDate), - NextRunOld: template.NextRun - ); - - logger.LogDebug("Нет рабочих дней, сдвинул nextRun у шаблона {Id} на {offsetYearsIfErrorCalcNextRun} лет вперед, nextRun: {NextRun}, nextRunOld: {NextRunOld}", result.Id, offsetYearsIfErrorCalcNextRun, result.NextRun, result.NextRunOld); + logger.LogInformation("Распределение завершено. Возвращаем {Count} записей в UTC. Изменено: {changedCount}. Остались прежние значения: {oldCount}", result.Count, result.Count(t => t.NextRun != t.NextRunOld), result.Count(t => t.NextRun == t.NextRunOld)); return result; } - /// - /// Получает NextRun не учитывая нагрузку на день + /// Обрабатывает случай, когда у шаблона нет текущего NextRun. /// - /// - /// - /// Искать с начала = true, false - искать с конца - /// - private DateTimeOffset FindNextAvailableDateIgnoreLoad(List workDays, DateTimeOffset referenseDate, bool isSearchFirst) + private TemplateNextRunResultDto HandleNoNextRun(TemplateNextRunDto targetTemplate, List workDays, Dictionary currentDailyLoad, Dictionary expectedDailyLoad, DateTimeOffset referenceDateInTargetTz, TimeSpan targetOffset, bool isNew) { - // возможно это временная реализация метода, используется для GetValidNextRunForTemplateAsync + logger.LogDebug("Обработка случая 'нет текущего NextRun' для шаблона {templateId} (isNew={isNew})", targetTemplate.Id, isNew); - if (!isSearchFirst) + DateOnly? foundDay = null; + + if (isNew) { - // берем первый рабочий день с конца и возвращаем его - var lastDate = CreateDateTimeOffset(workDays.Last(), referenseDate); - logger.LogDebug("Берем первый рабочий день с конца. Сгенерированный NextRun: {lastDate}", lastDate); - return lastDate; + // Ищем от начала периода к концу + 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 firstDate = CreateDateTimeOffset(workDays.First(), referenseDate); - if (firstDate > DateTimeOffset.UtcNow) - { - logger.LogDebug("Берем первый день с начала. Сгенерированный NextRun: {firstDate}", firstDate); - return firstDate; - } + // Формируем результат + var nextRunInTargetTz = new DateTimeOffset( + foundDay.Value.Year, foundDay.Value.Month, foundDay.Value.Day, + referenceDateInTargetTz.Hour, + referenceDateInTargetTz.Minute, + referenceDateInTargetTz.Second, + targetOffset); - // тогда берем второй день - if (workDays.Count > 1) - { - var secondDate = CreateDateTimeOffset(workDays.ElementAt(1), referenseDate); - logger.LogDebug("Берем второй день сначала, так как у первого дня уже время прошло. Сгенерированный NextRun: {secondDate}", secondDate); - return secondDate; - } - logger.LogWarning("Вернули nextRun: {firstDate} из первого дня распределения, хотя время уже просрочено. Всего рабочих дней: {workDays}", firstDate, workDays.Count); - // если вдруг список рабочих дней был не >1, то тогда берем первый и вренем его. Так никогда не должно быть - return firstDate; + // Корректируем найденный день, чтобы момент времени был в будущем + DateTimeOffset? adjustedNextRun; + TemplateNextRunResultDto? errorResult = null; + + adjustedNextRun = AdjustNextRunToFuture( + foundDay.Value, + workDays, + currentDailyLoad, + expectedDailyLoad, + referenceDateInTargetTz, + targetOffset, + workDays.First(), // startDateForError для случая ошибки + targetTemplate, + out errorResult); + + if (errorResult != null) + return errorResult; + + //var nextRunUtc = nextRunInTargetTz.ToOffset(TimeSpan.Zero); + var nextRunUtc = adjustedNextRun.Value.ToOffset(TimeSpan.Zero); + + logger.LogInformation("Найдено новое место для шаблона {templateId}: {nextRunTarget} (пояс {offset}) → {nextRunUtc} (UTC)", targetTemplate.Id, nextRunInTargetTz, targetOffset, nextRunUtc); + + return new TemplateNextRunResultDto( + Id: targetTemplate.Id, + NextRun: nextRunUtc, + NextRunOld: targetTemplate.NextRun); } + /// + /// Обрабатывает случай, когда текущий NextRun неактуален. + /// + private TemplateNextRunResultDto HandleOutdatedNextRun(TemplateNextRunDto targetTemplate, DateOnly nextRunDate, bool isInPeriod, bool isWorkDay, bool isNew, List workDays, Dictionary currentDailyLoad, Dictionary expectedDailyLoad, DateTimeOffset referenceDateInTargetTz, TimeSpan targetOffset, DateOnly periodStart, DateOnly periodEnd) + { + logger.LogDebug("Обработка случая 'неактуальный NextRun' для шаблона {templateId}", targetTemplate.Id); + + DateOnly? foundDay = null; + + if (isInPeriod && !isWorkDay) + { + // СЦЕНАРИЙ А: В периоде, но нерабочий день + logger.LogDebug("Сценарий А: в периоде ({date}), но нерабочий день. Ищем слева...", nextRunDate); + + // Вычисляем максимальную нагрузку по всему периоду + var maxLoadInPeriod = expectedDailyLoad.Values.Max(); + logger.LogDebug("Максимальная нагрузка по периоду: {maxLoad}", maxLoadInPeriod); + + // Берём все рабочие дни СТРОГО ЛЕВЕЕ исходной даты + var leftDays = workDays + .Where(d => d < nextRunDate) + .OrderByDescending(d => d) // Ближайший к исходной дате — первый + .ToList(); + + if (leftDays.Any()) + { + logger.LogDebug("Слева найдено {count} рабочих дней. Ищем первый не перегруженный...", leftDays.Count); + + // Ищем первый день где текущая_нагрузка < максимальная_нагрузка_периода + foreach (var day in leftDays) + { + if (currentDailyLoad[day] < maxLoadInPeriod) // ← КЛЮЧЕВОЕ ИЗМЕНЕНИЕ + { + foundDay = day; + logger.LogDebug("Нашли слева день с допустимой нагрузкой (< {maxLoad}): {day}", maxLoadInPeriod, day); + break; + } + } + + //// Ищем первый не перегруженный + //foreach (var day in leftDays) + //{ + // if (currentDailyLoad[day] < expectedDailyLoad.GetValueOrDefault(day, 0)) + // { + // foundDay = day; + // logger.LogDebug("Нашли слева не перегруженный день: {day}", day); + // break; + // } + //} + + // Если не нашли — берём ближайший слева + if (!foundDay.HasValue) + { + foundDay = leftDays.First(); + logger.LogWarning("Все дни слева перегружены. Берём ближайший: {day}", foundDay.Value); + } + } + else + { + // Слева нет рабочих дней — берём ближайший справа + var rightDays = workDays + .Where(d => d > nextRunDate) + .OrderBy(d => d) // Ближайший к исходной дате — первый + .ToList(); + + if (rightDays.Any()) + { + foundDay = rightDays.First(); + logger.LogDebug("Слева нет рабочих дней. Берём ближайший справа: {day}", foundDay.Value); + } + else + { + // Совсем нет рабочих дней — ошибка + logger.LogError("Нет рабочих дней ни слева, ни справа от {date}. Возвращаем ошибку.", nextRunDate); + return FillTemplateResultWithOffsetError(targetTemplate, periodStart, referenceDateInTargetTz, targetOffset); + } + } + } + else if (!isInPeriod) + { + // СЦЕНАРИЙ Б: Не в периоде + logger.LogDebug("Сценарий Б: не в периоде ({date}). Ищем в зависимости от isNew...", nextRunDate); + + if (isNew) + { + // Ищем от начала периода к концу (обычный план) + logger.LogDebug("isNew=true: ищем от начала периода к концу"); + + foreach (var day in workDays) + { + if (currentDailyLoad[day] < expectedDailyLoad.GetValueOrDefault(day, 0)) + { + foundDay = day; + logger.LogDebug("Нашли первый не перегруженный день: {day}", day); + break; + } + } + + if (!foundDay.HasValue) + { + foundDay = workDays.First(); + logger.LogWarning("Все дни перегружены. Берём первый день периода: {day}", foundDay.Value); + } + } + else + { + // Ищем от конца периода к началу (зеркальный план) + logger.LogDebug("isNew=false: ищем от конца периода к началу"); + + foreach (var day in workDays.AsEnumerable().Reverse()) + { + if (currentDailyLoad[day] < expectedDailyLoad.GetValueOrDefault(day, 0)) + { + foundDay = day; + logger.LogDebug("Нашли первый не перегруженный день: {day}", day); + break; + } + } + + if (!foundDay.HasValue) + { + foundDay = workDays.Last(); + logger.LogWarning("Все дни перегружены. Берём последний день периода: {day}", foundDay.Value); + } + } + } + else + { + // Другие случаи (например, время уже прошло) + logger.LogDebug("NextRun неактуален по другим причинам. Используем логику в зависимости от isNew..."); + + if (isNew) + { + foreach (var day in workDays) + { + if (currentDailyLoad[day] < expectedDailyLoad.GetValueOrDefault(day, 0)) + { + foundDay = day; + break; + } + } + foundDay ??= workDays.First(); + } + else + { + foreach (var day in workDays.AsEnumerable().Reverse()) + { + if (currentDailyLoad[day] < expectedDailyLoad.GetValueOrDefault(day, 0)) + { + foundDay = day; + break; + } + } + foundDay ??= workDays.Last(); + } + } + + // Формируем результат + var nextRunInTargetTz = new DateTimeOffset( + foundDay.Value.Year, foundDay.Value.Month, foundDay.Value.Day, + referenceDateInTargetTz.Hour, + referenceDateInTargetTz.Minute, + referenceDateInTargetTz.Second, + targetOffset); + + // Корректируем найденный день, чтобы момент времени был в будущем + DateTimeOffset? adjustedNextRun; + TemplateNextRunResultDto? errorResult = null; + + adjustedNextRun = AdjustNextRunToFuture( + foundDay.Value, + workDays, + currentDailyLoad, + expectedDailyLoad, + referenceDateInTargetTz, + targetOffset, + periodStart, // startDateForError для случая ошибки + targetTemplate, + out errorResult); + + if (errorResult != null) + return errorResult; + + + //var nextRunUtc = nextRunInTargetTz.ToOffset(TimeSpan.Zero); + var nextRunUtc = adjustedNextRun.Value.ToOffset(TimeSpan.Zero); + + logger.LogInformation("Найдено новое место для шаблона {templateId}: {nextRunTarget} (пояс {offset}) → {nextRunUtc} (UTC). NextRunOld: {nextRunOld}", targetTemplate.Id, nextRunInTargetTz, targetOffset, nextRunUtc, targetTemplate.NextRun); + + return new TemplateNextRunResultDto( + Id: targetTemplate.Id, + NextRun: nextRunUtc, + NextRunOld: targetTemplate.NextRun); + } + + + /// + /// Корректирует найденный день, чтобы момент времени был в будущем. + /// Если момент времени уже прошёл, ищет следующий рабочий день в будущем с учётом нагрузки. + /// В случае ошибки (нет будущих рабочих дней) возвращает ошибку через out-параметр. + /// + private DateTimeOffset? AdjustNextRunToFuture(DateOnly foundDay, List workDays, Dictionary currentDailyLoad, Dictionary expectedDailyLoad, DateTimeOffset referenceDateInTargetTz, TimeSpan targetOffset, DateOnly startDateForError, TemplateNextRunDto targetTemplate, out TemplateNextRunResultDto? errorResult) + { + errorResult = null; + + // Создаём момент времени для найденного дня + var nextRunInTargetTz = new DateTimeOffset( + foundDay.Year, foundDay.Month, foundDay.Day, + referenceDateInTargetTz.Hour, + referenceDateInTargetTz.Minute, + referenceDateInTargetTz.Second, + targetOffset); + + // ФИНАЛЬНАЯ ПРОВЕРКА: момент времени должен быть в будущем + if (nextRunInTargetTz < DateTimeOffset.UtcNow) + { + logger.LogWarning("Найденный момент времени {nextRun} уже прошёл (сейчас {now}). Ищем следующий рабочий день в будущем.", + nextRunInTargetTz, DateTimeOffset.UtcNow); + + // Берём все рабочие дни СТРОГО ПОСЛЕ найденного дня + var futureWorkDays = workDays.Where(d => d > foundDay).ToList(); + + if (futureWorkDays.Any()) + { + // Ищем первый день с допустимой нагрузкой (текущая < ожидаемая) + var notOverloadedDays = futureWorkDays + .Where(d => currentDailyLoad[d] < expectedDailyLoad.GetValueOrDefault(d, 0)) + .ToList(); + + var futureDay = notOverloadedDays.Any() + ? notOverloadedDays.First() + : futureWorkDays.First(); + + logger.LogDebug("Выбран следующий рабочий день в будущем: {day}", futureDay); + + // Пересоздаём момент времени с новой датой + nextRunInTargetTz = new DateTimeOffset( + futureDay.Year, futureDay.Month, futureDay.Day, + referenceDateInTargetTz.Hour, + referenceDateInTargetTz.Minute, + referenceDateInTargetTz.Second, + targetOffset); + } + else + { + // Нет будущих рабочих дней — ошибка + logger.LogError("Нет будущих рабочих дней после {date} в периоде. Возвращаем ошибку со смещением.", + foundDay); + + errorResult = FillTemplateResultWithOffsetError(targetTemplate, startDateForError, referenceDateInTargetTz, targetOffset); + return null; + } + } + else + { + logger.LogDebug("Момент времени {nextRun} актуален (в будущем). Коррекция не требуется.", nextRunInTargetTz); + } + + return nextRunInTargetTz; + } + + + /// + /// Строит текущую нагрузку на дни, учитывая ТОЛЬКО шаблоны внутри периода. + /// Шаблоны вне периода игнорируются. + /// + private Dictionary BuildCurrentLoad(List templates, List workDays, DateOnly periodStart, DateOnly periodEnd, TimeSpan targetOffset) + { + var load = workDays.ToDictionary(day => day, _ => 0); + + foreach (var template in templates) + { + if (!template.NextRun.HasValue) + continue; + + // Конвертируем в целевой пояс для определения даты + var templateDate = DateOnly.FromDateTime(template.NextRun.Value.ToOffset(targetOffset).Date); + + // Учитываем ТОЛЬКО шаблоны внутри периода + if (templateDate >= periodStart && templateDate <= periodEnd) + { + if (load.ContainsKey(templateDate)) + load[templateDate]++; + } + } + + return load; + } + + + /// + /// Заполнить TemplateNextRunResultDto, если нет рабочих дней со смещением несколько лет + /// + /// + /// + private TemplateNextRunResultDto FillTemplateResultWithOffsetError(TemplateNextRunDto template, DateOnly startDate, DateTimeOffset referenceDateInTargetTz, TimeSpan targetOffset) + { + // Создаём дату в целевом поясе + var errorDateInTargetTz = new DateTimeOffset( + startDate.AddYears(offsetYearsIfErrorCalcNextRun).Year, + startDate.AddYears(offsetYearsIfErrorCalcNextRun).Month, + startDate.AddYears(offsetYearsIfErrorCalcNextRun).Day, + referenceDateInTargetTz.Hour, + referenceDateInTargetTz.Minute, + referenceDateInTargetTz.Second, + targetOffset); + + // КОНВЕРТИРУЕМ В UTC ДЛЯ ВОЗВРАТА + var errorDateUtc = errorDateInTargetTz.ToOffset(TimeSpan.Zero); + + logger.LogWarning("Шаблон {Id}: нет рабочих дней в периоде, сдвиг на {years} лет → {nextRun} (UTC)", template.Id, offsetYearsIfErrorCalcNextRun, errorDateUtc); + + return new TemplateNextRunResultDto( + Id: template.Id, + NextRun: errorDateUtc, + NextRunOld: template.NextRun + ); + } /// @@ -392,7 +1006,7 @@ namespace PARR.DAL.NextRunServices.Subservices /// /// /// - private DateTimeOffset? FindNextAvailableDateWithLoad(Dictionary currentDailyLoad, List workDays, DateTimeOffset referenceDate, Dictionary expectedDailyLoad) + private DateTimeOffset? FindNextAvailableDateWithLoad(Dictionary currentDailyLoad, List workDays, DateTimeOffset referenceDateInTargetTz, Dictionary expectedDailyLoad, TimeSpan targetOffset) { foreach (var day in workDays) { @@ -401,63 +1015,62 @@ namespace PARR.DAL.NextRunServices.Subservices if (current < expected) { - return CreateDateTimeOffset(day, referenceDate); + // Создаём момент времени В ЦЕЛЕВОМ ПОЯСЕ + return new DateTimeOffset( + day.Year, day.Month, day.Day, + referenceDateInTargetTz.Hour, + referenceDateInTargetTz.Minute, + referenceDateInTargetTz.Second, + targetOffset); } } - // Если нет дня с доступной нагрузкой — ищем день с минимальной текущей нагрузкой, но не превышающий план - foreach (var day in workDays) - { - var expected = expectedDailyLoad.GetValueOrDefault(day, 0); - var current = currentDailyLoad.GetValueOrDefault(day, 0); - - if (current < expected) - { - return CreateDateTimeOffset(day, referenceDate); - } - } - - // Если все дни уже загружены по плану — ищем день с минимальной нагрузкой - var minLoad = currentDailyLoad.Values.Min(); - var minDay = currentDailyLoad.First(x => x.Value == minLoad).Key; - - return CreateDateTimeOffset(minDay, referenceDate); + // Если все дни загружены — берём день с минимальной нагрузкой + var minDay = currentDailyLoad.MinBy(x => x.Value).Key; + return new DateTimeOffset( + minDay.Year, minDay.Month, minDay.Day, + referenceDateInTargetTz.Hour, + referenceDateInTargetTz.Minute, + referenceDateInTargetTz.Second, + targetOffset); } - //--------------------------------------------------------------------------------------------------------------------------------------------------------------------- /// - /// Получить план: дни, на которые должны быть назначены шаблоны + /// Получить план распределения шаблонов по рабочим дням. /// - /// - /// - /// - private List GetPlan(int totalWorks, List workDays) + /// Общее количество шаблонов для распределения + /// Список рабочих дней в периоде + /// Если true — строит зеркальный план (справа налево от конца периода), иначе обычный (слева направо от начала периода) + /// Список дат, на которые должны быть назначены шаблоны + private List GetDistributionPlan(int totalWorks, List workDays, bool mirror = false) { - var totalDays = workDays.Count(); - - var interval = GetInterval(totalDays, totalWorks); + var totalDays = workDays.Count; + var interval = CalculateDistributionInterval(totalDays, totalWorks); var plan = new List(); for (int i = 0; i < totalWorks; i++) { - #region Вынести в метод - // Вычисляем индекс дня, на который нужно назначить i-ю работу - //var dayIndex = (int)Math.Round(i * interval); - var dayIndex = (int)Math.Floor(i * interval); + // Базовый индекс для обычного плана (слева → направо) + var baseIndex = (int)Math.Floor(i * interval); - // Защита от возможного выхода за границы массива из-за округления - if (dayIndex >= totalDays) - dayIndex = totalDays - 1; - #endregion + // Применяем зеркалирование, если нужно + var index = mirror + ? totalDays - 1 - baseIndex // Зеркальный индекс: от конца периода + : baseIndex; // Обычный индекс: от начала периода - var date = workDays[dayIndex]; - plan.Add(date); + // Защита от выхода за границы массива + index = Math.Max(0, Math.Min(index, totalDays - 1)); + + plan.Add(workDays[index]); } - logger.LogDebug("--- План распределения ---"); - foreach (var item in plan.GroupBy(t => t)) + // Логгирование + 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; @@ -470,252 +1083,118 @@ namespace PARR.DAL.NextRunServices.Subservices /// Кол-во рабочих дней /// Кол-во шаблонов /// - private double GetInterval(int totalDays, int totalWorks) + private double CalculateDistributionInterval(int totalDays, int totalWorks) { var interval = (double)totalDays / totalWorks; - logger.LogDebug("Интевал между шаблонами: {interval}", interval); + logger.LogDebug("Интервал распределения: {interval} (дней на шаблон)", interval); return interval; } /// - /// Создать DateTimeOffset (из DateTimeOffset + refDate) + /// Получить дату начала распределения с учётом целевого часового пояса. + /// Метод возвращает календарную дату (DateOnly), рассчитанную в контексте указанного оффсета. /// - /// Откуда взять дату - /// Откуда взять время - /// - private DateTimeOffset CreateDateTimeOffset(DateTimeOffset date, DateTimeOffset refDate) + /// Стартовая дата (интерпретируется в целевом часовом поясе) + /// Момент времени в целевом часовом поясе (после применения оффсета) + /// Целевой часовой пояс (оффсет) + /// Дата начала распределения + private DateOnly GetStartPeriodDate(DateOnly startDate, DateTimeOffset referenceDate, TimeSpan offset) { - return CreateDateTimeOffset(DateOnly.FromDateTime(date.Date), refDate); - } + var dateStart = startDate; - /// - /// Создать DateTimeOffset (из DateOnly + refDate) - /// - /// - /// - /// - private DateTimeOffset CreateDateTimeOffset(DateOnly date, DateTimeOffset refDate) - { - return new DateTimeOffset(date.Year, date.Month, date.Day, refDate.Hour, refDate.Minute, refDate.Second, refDate.Offset); - } - - - /// - /// Получить дату начала распределения относительно StartDate, ReferenceDate и текущей даты - /// - /// - /// - /// - private DateOnly GetStartPeriodDate(DateOnly startDate, DateTimeOffset referenceDate) - { - // дата начала не может быть раньше referenceDate - // и не может быть раньше чем сегодня - - #region old - //var dateStart = startDate; - - //// если dateStart < чем refDate, то берем refDate - //if (dateStart < DateOnly.FromDateTime(referenceDate.Date)) - // dateStart = DateOnly.FromDateTime(referenceDate.Date); - - //// если dateStart < чем текущая дата, то берем текущую дату - //if (dateStart < DateOnly.FromDateTime(DateTime.UtcNow.Date)) - // dateStart = DateOnly.FromDateTime(DateTime.UtcNow.Date); - - //// если текущее время > 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 (в его часовом поясе) + // 1. Дата начала не может быть раньше даты из referenceDate (в целевом поясе) var refDateInTargetTz = DateOnly.FromDateTime(referenceDate.Date); if (dateStart < refDateInTargetTz) { - logger.LogDebug("Дата начала {dateStart} раньше даты из referenceDate {refDate}. Используем дату из referenceDate.", dateStart, refDateInTargetTz); + logger.LogDebug("Дата начала {startDate} раньше даты из referenceDate в поясе {offset} ({refDate}). Используем {refDate}.", + dateStart, offset, refDateInTargetTz, refDateInTargetTz); dateStart = refDateInTargetTz; } - // 3. Дата начала не может быть раньше "сегодня" в целевом часовом поясе - var todayInTargetTz = DateOnly.FromDateTime(DateTimeOffset.UtcNow.ToOffset(referenceDate.Offset).Date); + // 2. Дата начала не может быть раньше "сегодня" в целевом часовом поясе + var todayInTargetTz = DateOnly.FromDateTime(DateTimeOffset.UtcNow.ToOffset(offset).Date); if (dateStart < todayInTargetTz) { - logger.LogDebug("Дата начала {dateStart} раньше 'сегодня' в целевом поясе {today}. Используем 'сегодня'.", dateStart, todayInTargetTz); + logger.LogDebug("Дата начала {startDate} раньше 'сегодня' в поясе {offset} ({today}). Используем {today}.", + dateStart, offset, todayInTargetTz, todayInTargetTz); dateStart = todayInTargetTz; } - // 4. Если момент времени "начало дня в целевом поясе" уже прошёл — добавляем 1 день - var dateTimeStart = CreateDateTimeOffset(dateStart, referenceDate); + // 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} уже прошёл (сейчас {now}). Добавляем 1 день.", dateTimeStart, DateTimeOffset.UtcNow); + logger.LogDebug("Момент времени {dateTimeStart} (в поясе {offset}) уже прошёл (сейчас {now}). Добавляем 1 день.", + dateTimeStart, offset, DateTimeOffset.UtcNow); dateStart = dateStart.AddDays(1); } - logger.LogDebug("Итоговая дата начала распределения: {dateStart} (в часовом поясе {offset})", dateStart, referenceDate.Offset); - + logger.LogDebug("Итоговая дата начала распределения в поясе {offset}: {dateStart}", offset, dateStart); return dateStart; } /// - /// Получить дату конца периода распределения + /// Получить дату окончания периода распределения. + /// Продолжительность включает стартовый день (например, duration=7 означает 7 дней: с 1 по 7 июня). /// /// /// /// private DateOnly GetEndPeriodDate(DateOnly startDate, int duration) { + if (duration <= 0) + { + logger.LogWarning("Некорректная продолжительность периода: {duration}. Используем минимальное значение 1.", duration); + duration = 1; + } + // duration — количество дней, включая startDate // поэтому вычитаем 1, чтобы получить сдвиг - return startDate.AddDays(duration - 1); + var endDate = startDate.AddDays(duration - 1); + logger.LogDebug("Расчёт даты окончания периода: startDate={startDate}, duration={duration}, endDate={endDate}", startDate, duration, endDate); + + return endDate; } - ///// - ///// Получить список выходных дней - ///// - ///// - ///// - ///// - //private async Task> GetWeekendsAsync(DateOnly startDate, DateOnly endDate) - //{ - // var weekends = await weekendDayService.GetWeekends(startDate, endDate).ToListAsync(); - - // logger.LogDebug("Найдено не рабочих дней: {Count}", weekends.Count); - // foreach (var weekend in weekends) - // logger.LogDebug("Не рабочий день: {weekend}", weekend); - - // return weekends.ToHashSet(); - //} - - /// /// Получить список выходных дней /// /// /// /// - public async Task> GetWeekendsV2Async(DateOnly startDate, DateOnly endDate, DateTimeOffset referenceDate, TimeSpan? offset = null) + public async Task> GetWeekendsAsync(DateOnly startDate, DateOnly endDate) { - if (offset.HasValue) - referenceDate = referenceDate.ToOffset(offset.Value); - - // проверяем это текущие или прошлые сутки относительно refDate, если надо, корректируем dateStart, dateEnd - //bool isPreviousDays = IsPreviousDays(referenceDate); - - //if (isPreviousDays) - //{ - // // Это предыдущие сутки, корректируем - // startDate = startDate.AddDays(-1); - // // так как мы потом будем сдвигать выходные дни влево, то надо взять на один день дальше, чтоб знать какой он - // // вроде бы это не костыль - // endDate = endDate.AddDays(1); - // //endDate = endDate.AddDays(-1); - - // logger.LogDebug("Это предыдущие сутки относительно referenceDate: {referenceDate}, корректируем даты для получения выходных дней, новые значения dateStart: {dateStart}, dateEnd: {dateEnd}", referenceDate, startDate, endDate); - //} - + //Возвращает все выходные дни включая сб и вск, и праздники var weekends = await weekendDayService.GetWeekends(startDate, endDate).ToListAsync(); - var weekendsResult = new List(); - - //if (isPreviousDays) - //{ - // // так как должны быть предыдущие дни, отнимаем 1 день - // foreach (var weekend in weekends) - // { - // var previous = weekend.AddDays(-1); - // logger.LogDebug("Выходной день, -1, так как день должен быть предыдущий, старое значение {weekend}, новое {previous}", weekend, previous); - // weekendsResult.Add(previous); - // } - //} - //else - //{ - // weekendsResult = weekends; - //} - - logger.LogDebug("Найдено не рабочих дней: {Count}", weekendsResult.Count); - foreach (var weekend in weekendsResult) + logger.LogDebug("Найдено не рабочих дней: {Count}", weekends.Count); + foreach (var weekend in weekends) logger.LogDebug("Не рабочий день: {weekend}", weekend); - return weekendsResult.ToHashSet(); + return weekends.ToHashSet(); } - ///// - ///// Возвращает список рабочих дней в диапазоне, исключая выходные и праздники. - ///// - ///// - ///// - ///// Исключить выходные и праздники - ///// - //public async Task> GetWorkDaysAsync(DateOnly start, DateOnly end, bool excludeWeekends) - //{ - // // Получаем список выходных/праздничных дней - // var weekends = new HashSet(); - // if (excludeWeekends) - // weekends = await GetWeekendsAsync(start, end); - - // var result = new List(); - // var date = start; - - // while (date <= end) - // { - // var dayOfWeek = date.DayOfWeek; - // if (!weekends.Contains(date)) - // { - // result.Add(date); - // } - // date = date.AddDays(1); - // } - - // logger.LogDebug("Найдено рабочих дней: {Count}", result.Count); - // foreach (var workDay in result) - // logger.LogDebug("Рабочий день: {workDay}", workDay); - - // return result; - //} - - - public async Task> GetWorkDaysV2Async(DateOnly start, DateOnly end, bool excludeWeekends, DateTimeOffset referenceDate, TimeSpan? offset = null) + public async Task> GetWorkDaysAsync(DateOnly start, DateOnly end, bool excludeWeekends) { - if (offset.HasValue) - referenceDate = referenceDate.ToOffset(offset.Value); - - logger.LogDebug("Формирование списка рабочих дней, start: {start}, end: {end}, excludeWeekends: {excludeWeekends}, referenceDate: {referenceDate}", start, end, excludeWeekends, referenceDate); - - //// проверяем это текущие или прошлые сутки относительно refDate, если надо, корректируем dateStart, dateEnd - //if (IsPreviousDays(referenceDate)) - //{ - // // Это предыдущие сутки, корректируем - // start = start.AddDays(-1); - // end = end.AddDays(-1); - - // logger.LogDebug("Это предыдущие сутки относительно referenceDate: {referenceDate}, корректируем даты, новые значения dateStart: {dateStart}, dateEnd: {dateEnd}", referenceDate, start, end); - //} - // Получаем список выходных/праздничных дней var weekends = new HashSet(); if (excludeWeekends) - weekends = await GetWeekendsV2Async(start, end, referenceDate); + weekends = await GetWeekendsAsync(start, end); var result = new List(); var date = start; @@ -737,45 +1216,44 @@ namespace PARR.DAL.NextRunServices.Subservices return result; } - - public Task> GetWeekendsV2Async(DateOnly startDate, DateOnly endDate, DateTimeOffset referenceDate) + /// + /// Получает рабочие дни с автоматическим расширением периода при их отсутствии. + /// Расширяет период до maxPeriodExpansions раз: _startDate + duration * N (N = 1, 2, 3) + /// + private async Task<(DateOnly endDate, List workDays)> GetWorkDaysWithExpansionAsync(DateOnly startDate, int duration, bool excludeWeekends) { - throw new NotImplementedException(); + // Попытка 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()); // Пустой список для обработки ошибки вызывающим кодом } - - public Task> GetWorkDaysV2Async(DateOnly start, DateOnly end, bool excludeWeekends, DateTimeOffset referenceDate) - { - throw new NotImplementedException(); - } - - ///// - ///// Это предыдущие сутки? Относительно referenceDate и МСК времени - ///// - ///// - ///// - //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; - // } - - //} - } } diff --git a/PARR.DAL/NextRunServices/Subservices/TemplateDistributorV2.cs b/PARR.DAL/NextRunServices/Subservices/TemplateDistributorV2.cs deleted file mode 100644 index 511b799b..00000000 --- a/PARR.DAL/NextRunServices/Subservices/TemplateDistributorV2.cs +++ /dev/null @@ -1,1259 +0,0 @@ -using Microsoft.EntityFrameworkCore; -using Microsoft.Extensions.Logging; -using PARR.DAL.NextRunServices.Models; -using PARR.DAL.Services.Interfaces; - -namespace PARR.DAL.NextRunServices.Subservices -{ - /// - /// Распределитель шаблонов с поддержкой часовых поясов. - /// Все входные даты интерпретируются в целевом часовом поясе (параметр offset). - /// Все результаты возвращаются строго в UTC (TimeSpan.Zero). - /// - internal class TemplateDistributorV2 : ITemplateDistributorV2 - { - private readonly ILogger logger; - private readonly IWeekendDayService weekendDayService; - - /// - /// Смещение nextRun на несколько лет вперед, если не смог его рассчитать - /// - private const int offsetYearsIfErrorCalcNextRun = 5; - - /// - /// Максимальное количество попыток расширения периода при отсутствии рабочих дней - /// - private const int maxPeriodExpansions = 2; - - public TemplateDistributorV2(ILogger logger, IWeekendDayService weekendDayService) - { - this.logger = logger; - this.weekendDayService = weekendDayService; - } - - - public async Task GetNextRunForTemplateAsync(DateOnly startDate, int duration, DateTimeOffset referenceDate, TimeSpan? offset, TemplateNextRunDto targetTemplate, List allTemplates, bool excludeWeekends, bool isNew) - { - // Валидация входных параметров - if (duration <= 0) - throw new ArgumentException($"Duration must be positive, got {duration}", nameof(duration)); - - if (allTemplates == null) - throw new ArgumentNullException(nameof(allTemplates)); - - logger.LogInformation("Начинаем проверку/расчёт NextRun для шаблона {templateId}, nextRun={nextRun}. isNew={isNew}, offset={offset}, referenceDate={referenceDate}", targetTemplate.Id, targetTemplate.NextRun, isNew, offset, referenceDate); - - // 1. Определяем целевой часовой пояс - var targetOffset = offset ?? TimeSpan.Zero; - var referenceDateInTargetTz = referenceDate.ToOffset(targetOffset); - - logger.LogDebug("Целевой часовой пояс: {offset}. ReferenceDate в целевом поясе: {refDate}", targetOffset, referenceDateInTargetTz); - - - // 2. Рассчитываем период распределения в целевом поясе - var _startDate = GetStartPeriodDate(startDate, referenceDateInTargetTz, targetOffset); - var _endDate = GetEndPeriodDate(_startDate, duration); - - logger.LogInformation("Период распределения: {_startDate} - {_endDate} (в поясе {offset})", _startDate, _endDate, targetOffset); - - // 3. Получаем список рабочих дней с расширением периода при необходимости - var (expandedEndDate, workDays) = await GetWorkDaysWithExpansionAsync(_startDate, duration, excludeWeekends); - - // Обновляем _endDate на расширенный период для дальнейших расчётов - _endDate = expandedEndDate; - - if (workDays.Count == 0) - { - logger.LogError("Не удалось найти рабочие дни даже после {maxAttempts} попыток расширения периода [{start}, {end}]. Возвращаем ошибку со смещением.", maxPeriodExpansions, _startDate, _endDate); - return FillTemplateResultWithOffsetError(targetTemplate, _startDate, referenceDateInTargetTz, targetOffset); - } - - #region 4. Строим план нагрузки и рассчитываем текущую нагрузку - - logger.LogDebug("Строим план распределения для шаблона {templateId}. Всего шаблонов: {totalTemplates}", targetTemplate.Id, allTemplates.Count); - - // Исключаем текущий шаблон из расчёта - var templatesForPlan = allTemplates.Where(t => t.Id != targetTemplate.Id).ToList(); - - logger.LogDebug("Исключили текущий шаблон из расчёта. Осталось {count} шаблонов для плана", templatesForPlan.Count); - - // Определяем тип плана (обычный или зеркальный) - bool useMirrorPlan = false; - - // Определяем тип плана на основе контекста - if (!targetTemplate.NextRun.HasValue) - { - // Нет текущего NextRun — тип плана зависит от isNew - useMirrorPlan = !isNew; - logger.LogDebug("Нет текущего NextRun. Тип плана: {planType} (isNew={isNew})", useMirrorPlan ? "зеркальный" : "обычный", isNew); - } - else - { - // Есть текущий NextRun — проверяем, в периоде ли он - var _nextRunInTargetTz = targetTemplate.NextRun.Value.ToOffset(targetOffset); - var _nextRunDate = DateOnly.FromDateTime(_nextRunInTargetTz.Date); - - bool _isInPeriod = _nextRunDate >= _startDate && _nextRunDate <= _endDate; - bool _isWorkDay = workDays.Contains(_nextRunDate); - - if (_isInPeriod && !_isWorkDay) - { - // В периоде, но нерабочий день — обычный план - useMirrorPlan = false; - logger.LogDebug("NextRun в периоде, но на нерабочем дне. Используем обычный план."); - } - else if (!_isInPeriod) - { - // Не в периоде — тип плана зависит от isNew - useMirrorPlan = !isNew; - logger.LogDebug("NextRun вне периода. Тип плана: {planType} (isNew={isNew})", useMirrorPlan ? "зеркальный" : "обычный", isNew); - } - else - { - // В периоде и рабочий день — будем проверять актуальность - useMirrorPlan = false; - logger.LogDebug("NextRun в периоде и на рабочем дне. Используем обычный план для проверки."); - } - } - - // Строим план распределения - List plan; - if (useMirrorPlan) - { - // Зеркальный план - plan = GetDistributionPlan(templatesForPlan.Count + 1, workDays, mirror: true); - logger.LogDebug("Построен зеркальный план для {count} шаблонов", templatesForPlan.Count + 1); - } - else - { - // Обычный план - plan = GetDistributionPlan(templatesForPlan.Count + 1, workDays); - logger.LogDebug("Построен обычный план для {count} шаблонов", templatesForPlan.Count + 1); - } - - // Считаем ожидаемую нагрузку из плана - var expectedDailyLoad = plan.GroupBy(d => d).ToDictionary(g => g.Key, g => g.Count()); - - logger.LogDebug("Ожидаемая нагрузка по плану: {loadCount} дней", expectedDailyLoad.Count); - foreach (var kvp in expectedDailyLoad.OrderBy(x => x.Key)) - logger.LogDebug(" {date}: ожидаемо {count} шаблонов", kvp.Key, kvp.Value); - - // Считаем текущую нагрузку (только шаблоны внутри периода) - var currentDailyLoad = BuildCurrentLoad(templatesForPlan, workDays, _startDate, _endDate, targetOffset); - - logger.LogDebug("Текущая нагрузка (без учёта текущего шаблона): {loadCount} дней", currentDailyLoad.Count); - foreach (var kvp in currentDailyLoad.OrderBy(x => x.Key)) - logger.LogDebug(" {date}: текущая {current}, ожидаемая {expected}", kvp.Key, kvp.Value, expectedDailyLoad.GetValueOrDefault(kvp.Key, 0)); - - #endregion - - // 5. Случай: нет текущего NextRun - if (!targetTemplate.NextRun.HasValue) - { - logger.LogInformation("Шаблон {templateId} не имеет текущего NextRun. Ищем новое место...", targetTemplate.Id); - - return HandleNoNextRun( - targetTemplate, - workDays, - currentDailyLoad, - expectedDailyLoad, - referenceDateInTargetTz, - targetOffset, - isNew); - } - - #region 6. Случай: есть текущий NextRun — проверяем актуальность - - logger.LogDebug("Шаблон {templateId} имеет текущий NextRun: {nextRun}. Проверяем актуальность...", targetTemplate.Id, targetTemplate.NextRun.Value); - - var nextRunInTargetTz = targetTemplate.NextRun.Value.ToOffset(targetOffset); - var nextRunDate = DateOnly.FromDateTime(nextRunInTargetTz.Date); - - logger.LogDebug("NextRun в целевом поясе {offset}: {nextRunTarget} (дата: {date})", targetOffset, nextRunInTargetTz, nextRunDate); - - // Проверка 1: В периоде? - var isInPeriod = nextRunDate >= _startDate && nextRunDate <= _endDate; - logger.LogDebug("Проверка 1 (в периоде): {result} ({date} ∈ [{start}, {end}])", isInPeriod, nextRunDate, _startDate, _endDate); - - // Проверка 2: Рабочий день? - var isWorkDay = workDays.Contains(nextRunDate); - logger.LogDebug("Проверка 2 (рабочий день): {result}", isWorkDay ? "да" : "нет"); - - // Проверка 3: В будущем? - var nextRunMoment = new DateTimeOffset( - nextRunDate.Year, nextRunDate.Month, nextRunDate.Day, - nextRunInTargetTz.Hour, nextRunInTargetTz.Minute, nextRunInTargetTz.Second, - targetOffset); - - var isFuture = nextRunMoment >= DateTimeOffset.UtcNow; - logger.LogDebug("Проверка 3 (в будущем): {result} ({moment} >= {now})", isFuture, nextRunMoment, DateTimeOffset.UtcNow); - - // Проверка 4: Время совпадает с referenceDate? - var hasMatchingTime = nextRunInTargetTz.Hour == referenceDateInTargetTz.Hour && - nextRunInTargetTz.Minute == referenceDateInTargetTz.Minute && - nextRunInTargetTz.Second == referenceDateInTargetTz.Second; - logger.LogDebug("Проверка 4 (время совпадает): {result} (NextRun: {nextRunHour}:{nextRunMin}:{nextRunSec} vs Reference: {refHour}:{refMin}:{refSec})", - hasMatchingTime, - nextRunInTargetTz.Hour, nextRunInTargetTz.Minute, nextRunInTargetTz.Second, - referenceDateInTargetTz.Hour, referenceDateInTargetTz.Minute, referenceDateInTargetTz.Second); - - // СЛУЧАЙ А: Дата актуальна, но время не совпадает → корректируем ТОЛЬКО время - if (isInPeriod && isWorkDay && isFuture && !hasMatchingTime) - { - logger.LogDebug("Дата NextRun шаблона {templateId} актуальна, но время не совпадает. Корректируем время с {oldTime} на {newTime} (пояс {offset}).", - targetTemplate.Id, - $"{nextRunInTargetTz.Hour:D2}:{nextRunInTargetTz.Minute:D2}:{nextRunInTargetTz.Second:D2}", - $"{referenceDateInTargetTz.Hour:D2}:{referenceDateInTargetTz.Minute:D2}:{referenceDateInTargetTz.Second:D2}", - targetOffset); - - // Сохраняем ту же дату, но меняем время на время из referenceDate - var correctedNextRunInTargetTz = new DateTimeOffset( - nextRunDate.Year, nextRunDate.Month, nextRunDate.Day, - referenceDateInTargetTz.Hour, - referenceDateInTargetTz.Minute, - referenceDateInTargetTz.Second, - targetOffset); - - //скорректированный момент ещё в будущем? - if (correctedNextRunInTargetTz >= DateTimeOffset.UtcNow) - { - // Время актуально — возвращаем скорректированный NextRun - var correctedNextRunUtc = correctedNextRunInTargetTz.ToOffset(TimeSpan.Zero); - - logger.LogInformation("Скорректированный NextRun для шаблона {templateId}: {nextRunTarget} (пояс {offset}) → {nextRunUtc} (UTC). NextRunOld: {nextRunOld}", - targetTemplate.Id, correctedNextRunInTargetTz, targetOffset, correctedNextRunUtc, targetTemplate.NextRun); - - return new TemplateNextRunResultDto( - Id: targetTemplate.Id, - NextRun: correctedNextRunUtc, - NextRunOld: targetTemplate.NextRun); - } - else - { - // Скорректированное время уже прошло — считаем дату НЕАКТУАЛЬНОЙ - logger.LogDebug("Скорректированное время {correctedTime} уже прошло (сейчас {now}). Считаем дату неактуальной и передаём в основную логику поиска.", - correctedNextRunInTargetTz, DateTimeOffset.UtcNow); - - // Помечаем дату как неактуальную по времени - isFuture = false; - // Дальнейшая логика сама вызовет HandleOutdatedNextRun с правильными параметрами - } - } - - // СЛУЧАЙ Б: Все проверки пройдены (включая время) → возвращаем без изменений - if (isInPeriod && isWorkDay && isFuture && hasMatchingTime) - { - var nextRunUtc = targetTemplate.NextRun.Value.ToOffset(TimeSpan.Zero); - logger.LogInformation("NextRun шаблона {templateId} полностью актуален. Возвращаем без изменений: {nextRunUtc} (UTC)", - targetTemplate.Id, nextRunUtc); - - return new TemplateNextRunResultDto( - Id: targetTemplate.Id, - NextRun: nextRunUtc, - NextRunOld: targetTemplate.NextRun); - } - - // СЛУЧАЙ В: Дата неактуальна (не в периоде / нерабочий день / уже прошло) → ищем новое место - logger.LogDebug("NextRun шаблона {templateId} неактуален по дате (в периоде={inPeriod}, рабочий={workDay}, будущее={future}). Ищем новое место...", - targetTemplate.Id, isInPeriod, isWorkDay, isFuture); - - #endregion - - // 7. NextRun неактуален — ищем новое место - logger.LogDebug("NextRun шаблона {templateId} неактуален (в периоде={inPeriod}, рабочий={workDay}, будущее={future}). Ищем новое место...", targetTemplate.Id, isInPeriod, isWorkDay, isFuture); - - return HandleOutdatedNextRun( - targetTemplate, - nextRunDate, - isInPeriod, - isWorkDay, - isNew, - workDays, - currentDailyLoad, - expectedDailyLoad, - referenceDateInTargetTz, - targetOffset, - _startDate, - _endDate); - } - - - /// - /// Распределить шаблоны - /// - /// - /// - /// - /// - /// - /// - /// - public async Task> DistributeTemplatesAsync(DateOnly startDate, int duration, DateTimeOffset referenceDate, TimeSpan? offset, List allTemplates, bool excludeWeekends) - { - // Валидация входных параметров - if (duration <= 0) - throw new ArgumentException($"Duration must be positive, got {duration}", nameof(duration)); - - if (allTemplates == null) - throw new ArgumentNullException(nameof(allTemplates)); - - - // 1. Определяем целевой часовой пояс - var targetOffset = offset ?? TimeSpan.Zero; - - // 2. Применяем оффсет к referenceDate для расчётов в целевом поясе - var referenceDateInTargetTz = referenceDate.ToOffset(targetOffset); - - logger.LogDebug("Распределение шаблонов в часовом поясе {offset}. ReferenceDate в целевом поясе: {refDate}", targetOffset, referenceDateInTargetTz); - - // 3. Рассчитываем период распределения С УЧЁТОМ ЧАСОВОГО ПОЯСА - var _startDate = GetStartPeriodDate(startDate, referenceDateInTargetTz, targetOffset); - var _endDate = GetEndPeriodDate(_startDate, duration); - - logger.LogInformation("Начинаем распределение {Count} шаблонов на {duration} дней, с {_startDate} по {_endDate}. ReferenceDateInTargetTz: {referenceDateInTargetTz} в целевом поясе: {targetOffset} ReferenceDate UTC: {referenceDate}. Исключить выходные и праздники: {excludeWeekends}.", allTemplates.Count, duration, _startDate, _endDate, referenceDateInTargetTz, targetOffset, referenceDate, excludeWeekends); - - // 4. Получаем рабочие дни с расширением периода при необходимости - var (expandedEndDate, workDays) = await GetWorkDaysWithExpansionAsync(_startDate, duration, excludeWeekends); - - // Обновляем _endDate на расширенный период для дальнейших расчётов - _endDate = expandedEndDate; - - if (workDays.Count == 0) - { - logger.LogError("Не удалось найти рабочие дни даже после {maxAttempts} попыток расширения периода [{start}, {end}]. Возвращаем шаблоны со смещением {years} лет.", maxPeriodExpansions, _startDate, _endDate, offsetYearsIfErrorCalcNextRun); - - var errorResult = allTemplates.Select(t => - FillTemplateResultWithOffsetError(t, _startDate, referenceDateInTargetTz, targetOffset) - ).OrderBy(t => t.NextRun).ToList(); - - logger.LogInformation("Распределение завершено с ошибкой. Возвращаем {Count} записей.", errorResult.Count); - return errorResult; - } - - // 5. Строим план распределения - // строим план, дни на которые должны быть назанчены шаблоны - var plan = GetDistributionPlan(allTemplates.Count, workDays); - // Подсчитываем, сколько работ должно быть на каждый день - var expectedDailyLoad = plan.GroupBy(d => d) - .ToDictionary(g => g.Key, g => g.Count()); - - #region Группируем шаблоны по дням В ЦЕЛЕВОМ ПОЯСЕ, на которые они уже назначены - - var templatesByDay = new Dictionary>(); - - foreach (var template in allTemplates) - { - if (template.NextRun.HasValue) - { - // КОНВЕРТИРУЕМ В ЦЕЛЕВОЙ ПОЯС ДЛЯ ОПРЕДЕЛЕНИЯ ДАТЫ! - var dayInTargetTz = DateOnly.FromDateTime(template.NextRun.Value.ToOffset(targetOffset).Date); - //var day = DateOnly.FromDateTime(template.NextRun.Value.Date); - - if (!templatesByDay.ContainsKey(dayInTargetTz)) - { - templatesByDay[dayInTargetTz] = new List(); - } - templatesByDay[dayInTargetTz].Add(template); - - logger.LogDebug("Шаблон {Id} с NextRun={nextRun} отнесён к дате {day} в поясе {offset}", template.Id, template.NextRun.Value, dayInTargetTz, targetOffset); - } - } - - #endregion - - #region 7. Подсчитываем текущую нагрузку на каждый день - - var currentDailyLoad = workDays.ToDictionary(day => day, _ => 0); - - foreach (var kvp in templatesByDay) - { - if (currentDailyLoad.ContainsKey(kvp.Key)) - currentDailyLoad[kvp.Key] = kvp.Value.Count; - } - - #endregion - - - #region 8. Определяем, какие шаблоны нужно оставить, а какие — переназначить - var templatesToKeep = new List(); - var templatesToReassign = new List(); - - foreach (var kvp in templatesByDay) - { - var day = kvp.Key; - var templates = kvp.Value; - - // Если день — нерабочий, то все шаблоны нужно переназначить - if (!workDays.Contains(day)) - { - foreach (var template in templates) - { - templatesToReassign.Add(template); - logger.LogDebug("Шаблон {TemplateId} назначен на нерабочий день {NextRun} — будет переназначен.", template.Id, template.NextRun); - } - continue; - } - - var expectedCount = expectedDailyLoad.GetValueOrDefault(day, 0); - var currentCount = templates.Count; - - if (currentCount <= expectedCount) - { - // Все шаблоны в этом дне можно оставить, но нужно проверить/скорректировать время - foreach (var template in templates) - { - // Конвертируем текущий NextRun в целевой пояс для проверки времени - var nextRunInTargetTz = template.NextRun.Value.ToOffset(targetOffset); - var nextRunDate = DateOnly.FromDateTime(nextRunInTargetTz.Date); - - // Проверяем, совпадает ли время с referenceDate - var hasMatchingTime = nextRunInTargetTz.Hour == referenceDateInTargetTz.Hour && - nextRunInTargetTz.Minute == referenceDateInTargetTz.Minute && - nextRunInTargetTz.Second == referenceDateInTargetTz.Second; - - DateTimeOffset newNextRun; - - if (hasMatchingTime) - { - // Время совпадает — оставляем как есть (только конвертируем в чистый UTC) - newNextRun = template.NextRun.Value.ToOffset(TimeSpan.Zero); - logger.LogDebug("Шаблон {TemplateId} оставлен без изменений (время совпадает): {NextRun} (UTC)", - template.Id, newNextRun); - } - else - { - // Время НЕ совпадает — корректируем ТОЛЬКО время, сохраняя ту же дату - var correctedNextRunInTargetTz = new DateTimeOffset( - nextRunDate.Year, nextRunDate.Month, nextRunDate.Day, - referenceDateInTargetTz.Hour, - referenceDateInTargetTz.Minute, - referenceDateInTargetTz.Second, - targetOffset); - - // Проверяем, что скорректированный момент ещё в будущем - if (correctedNextRunInTargetTz >= DateTimeOffset.UtcNow) - { - newNextRun = correctedNextRunInTargetTz.ToOffset(TimeSpan.Zero); - logger.LogDebug("Шаблон {TemplateId} оставлен на дате {date}, время скорректировано с {oldTime} на {newTime} (UTC): {NextRun}", - template.Id, nextRunDate, - $"{nextRunInTargetTz.Hour:D2}:{nextRunInTargetTz.Minute:D2}:{nextRunInTargetTz.Second:D2}", - $"{referenceDateInTargetTz.Hour:D2}:{referenceDateInTargetTz.Minute:D2}:{referenceDateInTargetTz.Second:D2}", - newNextRun); - } - else - { - // Скорректированное время уже прошло — переназначаем шаблон - templatesToReassign.Add(template); - logger.LogDebug("Шаблон {TemplateId} переназначается, т.к. скорректированное время {correctedTime} уже прошло", - template.Id, correctedNextRunInTargetTz); - continue; - } - } - - templatesToKeep.Add(new TemplateNextRunResultDto( - Id: template.Id, - NextRun: newNextRun, - NextRunOld: template.NextRun - )); - } - } - else - { - // Оставляем только expectedCount штук, остальные — переназначить - // (для оставляемых тоже нужно проверить/скорректировать время) - for (int i = 0; i < expectedCount; i++) - { - var template = templates[i]; - - // Конвертируем текущий NextRun в целевой пояс для проверки времени - var nextRunInTargetTz = template.NextRun.Value.ToOffset(targetOffset); - var nextRunDate = DateOnly.FromDateTime(nextRunInTargetTz.Date); - - var hasMatchingTime = nextRunInTargetTz.Hour == referenceDateInTargetTz.Hour && - nextRunInTargetTz.Minute == referenceDateInTargetTz.Minute && - nextRunInTargetTz.Second == referenceDateInTargetTz.Second; - - DateTimeOffset newNextRun; - - if (hasMatchingTime) - { - newNextRun = template.NextRun.Value.ToOffset(TimeSpan.Zero); - logger.LogDebug("Шаблон {TemplateId} (часть перегрузки) оставлен без изменений (время совпадает): {NextRun} (UTC)", - template.Id, newNextRun); - } - else - { - var correctedNextRunInTargetTz = new DateTimeOffset( - nextRunDate.Year, nextRunDate.Month, nextRunDate.Day, - referenceDateInTargetTz.Hour, - referenceDateInTargetTz.Minute, - referenceDateInTargetTz.Second, - targetOffset); - - if (correctedNextRunInTargetTz >= DateTimeOffset.UtcNow) - { - newNextRun = correctedNextRunInTargetTz.ToOffset(TimeSpan.Zero); - logger.LogDebug("Шаблон {TemplateId} (часть перегрузки) оставлен на дате {date}, время скорректировано: {NextRun}", - template.Id, nextRunDate, newNextRun); - } - else - { - templatesToReassign.Add(template); - logger.LogDebug("Шаблон {TemplateId} (часть перегрузки) переназначается, т.к. скорректированное время уже прошло", - template.Id); - continue; - } - } - - templatesToKeep.Add(new TemplateNextRunResultDto( - Id: template.Id, - NextRun: newNextRun, - NextRunOld: template.NextRun - )); - } - - for (int i = expectedCount; i < currentCount; i++) - { - var template = templates[i]; - templatesToReassign.Add(template); - logger.LogDebug("Шаблон {TemplateId} переназначается, т.к. нагрузка в день превышена.", template.Id); - } - } - } - #endregion - - - #region 9. Добавляем шаблоны, у которых не было даты — тоже нужно распределить - - foreach (var template in allTemplates) - { - if (!template.NextRun.HasValue) - { - templatesToReassign.Add(template); - logger.LogDebug("Шаблон {TemplateId} не имел даты — будет распределён.", template.Id); - } - } - - #endregion - - #region 10. Распределяем шаблоны, которые нужно переназначить, учитывая нагрузку - - var updatedTemplates = new List(templatesToKeep); - - foreach (var template in templatesToReassign.OrderBy(t => t.NextRun)) - { - var nextRunInTargetTz = FindNextAvailableDateWithLoad(currentDailyLoad, workDays, referenceDateInTargetTz, expectedDailyLoad, targetOffset); - if (nextRunInTargetTz.HasValue) - { - var day = DateOnly.FromDateTime(nextRunInTargetTz.Value.Date); - currentDailyLoad[day]++; - - // КОНВЕРТИРУЕМ В UTC ПЕРЕД ВОЗВРАТОМ! - updatedTemplates.Add(new TemplateNextRunResultDto - ( - Id: template.Id, - NextRun: nextRunInTargetTz.Value.ToOffset(TimeSpan.Zero), // ← В UTC! - NextRunOld: template.NextRun - )); - - logger.LogDebug("Шаблону {Id} назначен NextRun: {nextRunTarget} (пояс {offset}) → {nextRunUtc} (UTC). NextRunOld: {NextRunOld}", template.Id, nextRunInTargetTz.Value, targetOffset, nextRunInTargetTz.Value.ToOffset(TimeSpan.Zero), template.NextRun); - } - else - { - logger.LogWarning("Не удалось назначить дату для шаблона {TemplateId}", template.Id); - - // Возвращаем со смещением на несколько лет, так как ошибка - // конвертируем в UTC - updatedTemplates.Add(FillTemplateResultWithOffsetError(template, _startDate, referenceDateInTargetTz, targetOffset)); - } - } - - #endregion - - // 11. Сортируем и возвращаем результат в UTC - - var result = updatedTemplates.OrderBy(t => t.NextRun).ToList(); - - logger.LogDebug("--- Результат распределения ---"); - foreach (var item in result) - logger.LogDebug("Шаблон {templateId}, NextRun UTC: {NextRun}, NextRunOld UTC: {NextRunOld}", item.Id, item.NextRun, item.NextRunOld); - - logger.LogInformation("Распределение завершено. Возвращаем {Count} записей в UTC. Изменено: {changedCount}. Остались прежние значения: {oldCount}", result.Count, result.Count(t => t.NextRun != t.NextRunOld), result.Count(t => t.NextRun == t.NextRunOld)); - - return result; - } - - - /// - /// Обрабатывает случай, когда у шаблона нет текущего NextRun. - /// - private TemplateNextRunResultDto HandleNoNextRun(TemplateNextRunDto targetTemplate, List workDays, Dictionary currentDailyLoad, Dictionary expectedDailyLoad, DateTimeOffset referenceDateInTargetTz, TimeSpan targetOffset, bool isNew) - { - logger.LogDebug("Обработка случая 'нет текущего NextRun' для шаблона {templateId} (isNew={isNew})", targetTemplate.Id, isNew); - - DateOnly? foundDay = null; - - if (isNew) - { - // Ищем от начала периода к концу - logger.LogDebug("isNew=true: ищем от начала периода к концу"); - - foreach (var day in workDays) - { - if (currentDailyLoad[day] < expectedDailyLoad.GetValueOrDefault(day, 0)) - { - foundDay = day; - logger.LogDebug("Нашли первый не перегруженный день: {day}", day); - break; - } - } - - if (!foundDay.HasValue) - { - // Не нашли свободного места — берём первый день периода - foundDay = workDays.First(); - logger.LogWarning("Все дни перегружены. Берём первый день периода: {day}", foundDay.Value); - } - } - else - { - // Ищем от конца периода к началу - logger.LogDebug("isNew=false: ищем от конца периода к началу"); - - foreach (var day in workDays.AsEnumerable().Reverse()) - { - if (currentDailyLoad[day] < expectedDailyLoad.GetValueOrDefault(day, 0)) - { - foundDay = day; - logger.LogDebug("Нашли первый не перегруженный день: {day}", day); - break; - } - } - - if (!foundDay.HasValue) - { - // Не нашли свободного места — берём последний день периода - foundDay = workDays.Last(); - logger.LogWarning("Все дни перегружены. Берём последний день периода: {day}", foundDay.Value); - } - } - - // Формируем результат - var nextRunInTargetTz = new DateTimeOffset( - foundDay.Value.Year, foundDay.Value.Month, foundDay.Value.Day, - referenceDateInTargetTz.Hour, - referenceDateInTargetTz.Minute, - referenceDateInTargetTz.Second, - targetOffset); - - - - // Корректируем найденный день, чтобы момент времени был в будущем - DateTimeOffset? adjustedNextRun; - TemplateNextRunResultDto? errorResult = null; - - adjustedNextRun = AdjustNextRunToFuture( - foundDay.Value, - workDays, - currentDailyLoad, - expectedDailyLoad, - referenceDateInTargetTz, - targetOffset, - workDays.First(), // startDateForError для случая ошибки - targetTemplate, - out errorResult); - - if (errorResult != null) - return errorResult; - - //var nextRunUtc = nextRunInTargetTz.ToOffset(TimeSpan.Zero); - var nextRunUtc = adjustedNextRun.Value.ToOffset(TimeSpan.Zero); - - logger.LogInformation("Найдено новое место для шаблона {templateId}: {nextRunTarget} (пояс {offset}) → {nextRunUtc} (UTC)", targetTemplate.Id, nextRunInTargetTz, targetOffset, nextRunUtc); - - return new TemplateNextRunResultDto( - Id: targetTemplate.Id, - NextRun: nextRunUtc, - NextRunOld: targetTemplate.NextRun); - } - - - /// - /// Обрабатывает случай, когда текущий NextRun неактуален. - /// - private TemplateNextRunResultDto HandleOutdatedNextRun(TemplateNextRunDto targetTemplate, DateOnly nextRunDate, bool isInPeriod, bool isWorkDay, bool isNew, List workDays, Dictionary currentDailyLoad, Dictionary expectedDailyLoad, DateTimeOffset referenceDateInTargetTz, TimeSpan targetOffset, DateOnly periodStart, DateOnly periodEnd) - { - logger.LogDebug("Обработка случая 'неактуальный NextRun' для шаблона {templateId}", targetTemplate.Id); - - DateOnly? foundDay = null; - - if (isInPeriod && !isWorkDay) - { - // СЦЕНАРИЙ А: В периоде, но нерабочий день - logger.LogDebug("Сценарий А: в периоде ({date}), но нерабочий день. Ищем слева...", nextRunDate); - - // Вычисляем максимальную нагрузку по всему периоду - var maxLoadInPeriod = expectedDailyLoad.Values.Max(); - logger.LogDebug("Максимальная нагрузка по периоду: {maxLoad}", maxLoadInPeriod); - - // Берём все рабочие дни СТРОГО ЛЕВЕЕ исходной даты - var leftDays = workDays - .Where(d => d < nextRunDate) - .OrderByDescending(d => d) // Ближайший к исходной дате — первый - .ToList(); - - if (leftDays.Any()) - { - logger.LogDebug("Слева найдено {count} рабочих дней. Ищем первый не перегруженный...", leftDays.Count); - - // Ищем первый день где текущая_нагрузка < максимальная_нагрузка_периода - foreach (var day in leftDays) - { - if (currentDailyLoad[day] < maxLoadInPeriod) // ← КЛЮЧЕВОЕ ИЗМЕНЕНИЕ - { - foundDay = day; - logger.LogDebug("Нашли слева день с допустимой нагрузкой (< {maxLoad}): {day}", maxLoadInPeriod, day); - break; - } - } - - //// Ищем первый не перегруженный - //foreach (var day in leftDays) - //{ - // if (currentDailyLoad[day] < expectedDailyLoad.GetValueOrDefault(day, 0)) - // { - // foundDay = day; - // logger.LogDebug("Нашли слева не перегруженный день: {day}", day); - // break; - // } - //} - - // Если не нашли — берём ближайший слева - if (!foundDay.HasValue) - { - foundDay = leftDays.First(); - logger.LogWarning("Все дни слева перегружены. Берём ближайший: {day}", foundDay.Value); - } - } - else - { - // Слева нет рабочих дней — берём ближайший справа - var rightDays = workDays - .Where(d => d > nextRunDate) - .OrderBy(d => d) // Ближайший к исходной дате — первый - .ToList(); - - if (rightDays.Any()) - { - foundDay = rightDays.First(); - logger.LogDebug("Слева нет рабочих дней. Берём ближайший справа: {day}", foundDay.Value); - } - else - { - // Совсем нет рабочих дней — ошибка - logger.LogError("Нет рабочих дней ни слева, ни справа от {date}. Возвращаем ошибку.", nextRunDate); - return FillTemplateResultWithOffsetError(targetTemplate, periodStart, referenceDateInTargetTz, targetOffset); - } - } - } - else if (!isInPeriod) - { - // СЦЕНАРИЙ Б: Не в периоде - logger.LogDebug("Сценарий Б: не в периоде ({date}). Ищем в зависимости от isNew...", nextRunDate); - - if (isNew) - { - // Ищем от начала периода к концу (обычный план) - logger.LogDebug("isNew=true: ищем от начала периода к концу"); - - foreach (var day in workDays) - { - if (currentDailyLoad[day] < expectedDailyLoad.GetValueOrDefault(day, 0)) - { - foundDay = day; - logger.LogDebug("Нашли первый не перегруженный день: {day}", day); - break; - } - } - - if (!foundDay.HasValue) - { - foundDay = workDays.First(); - logger.LogWarning("Все дни перегружены. Берём первый день периода: {day}", foundDay.Value); - } - } - else - { - // Ищем от конца периода к началу (зеркальный план) - logger.LogDebug("isNew=false: ищем от конца периода к началу"); - - foreach (var day in workDays.AsEnumerable().Reverse()) - { - if (currentDailyLoad[day] < expectedDailyLoad.GetValueOrDefault(day, 0)) - { - foundDay = day; - logger.LogDebug("Нашли первый не перегруженный день: {day}", day); - break; - } - } - - if (!foundDay.HasValue) - { - foundDay = workDays.Last(); - logger.LogWarning("Все дни перегружены. Берём последний день периода: {day}", foundDay.Value); - } - } - } - else - { - // Другие случаи (например, время уже прошло) - logger.LogDebug("NextRun неактуален по другим причинам. Используем логику в зависимости от isNew..."); - - if (isNew) - { - foreach (var day in workDays) - { - if (currentDailyLoad[day] < expectedDailyLoad.GetValueOrDefault(day, 0)) - { - foundDay = day; - break; - } - } - foundDay ??= workDays.First(); - } - else - { - foreach (var day in workDays.AsEnumerable().Reverse()) - { - if (currentDailyLoad[day] < expectedDailyLoad.GetValueOrDefault(day, 0)) - { - foundDay = day; - break; - } - } - foundDay ??= workDays.Last(); - } - } - - // Формируем результат - var nextRunInTargetTz = new DateTimeOffset( - foundDay.Value.Year, foundDay.Value.Month, foundDay.Value.Day, - referenceDateInTargetTz.Hour, - referenceDateInTargetTz.Minute, - referenceDateInTargetTz.Second, - targetOffset); - - // Корректируем найденный день, чтобы момент времени был в будущем - DateTimeOffset? adjustedNextRun; - TemplateNextRunResultDto? errorResult = null; - - adjustedNextRun = AdjustNextRunToFuture( - foundDay.Value, - workDays, - currentDailyLoad, - expectedDailyLoad, - referenceDateInTargetTz, - targetOffset, - periodStart, // startDateForError для случая ошибки - targetTemplate, - out errorResult); - - if (errorResult != null) - return errorResult; - - - //var nextRunUtc = nextRunInTargetTz.ToOffset(TimeSpan.Zero); - var nextRunUtc = adjustedNextRun.Value.ToOffset(TimeSpan.Zero); - - logger.LogInformation("Найдено новое место для шаблона {templateId}: {nextRunTarget} (пояс {offset}) → {nextRunUtc} (UTC). NextRunOld: {nextRunOld}", targetTemplate.Id, nextRunInTargetTz, targetOffset, nextRunUtc, targetTemplate.NextRun); - - return new TemplateNextRunResultDto( - Id: targetTemplate.Id, - NextRun: nextRunUtc, - NextRunOld: targetTemplate.NextRun); - } - - - /// - /// Корректирует найденный день, чтобы момент времени был в будущем. - /// Если момент времени уже прошёл, ищет следующий рабочий день в будущем с учётом нагрузки. - /// В случае ошибки (нет будущих рабочих дней) возвращает ошибку через out-параметр. - /// - private DateTimeOffset? AdjustNextRunToFuture(DateOnly foundDay, List workDays, Dictionary currentDailyLoad, Dictionary expectedDailyLoad, DateTimeOffset referenceDateInTargetTz, TimeSpan targetOffset, DateOnly startDateForError, TemplateNextRunDto targetTemplate, out TemplateNextRunResultDto? errorResult) - { - errorResult = null; - - // Создаём момент времени для найденного дня - var nextRunInTargetTz = new DateTimeOffset( - foundDay.Year, foundDay.Month, foundDay.Day, - referenceDateInTargetTz.Hour, - referenceDateInTargetTz.Minute, - referenceDateInTargetTz.Second, - targetOffset); - - // ФИНАЛЬНАЯ ПРОВЕРКА: момент времени должен быть в будущем - if (nextRunInTargetTz < DateTimeOffset.UtcNow) - { - logger.LogWarning("Найденный момент времени {nextRun} уже прошёл (сейчас {now}). Ищем следующий рабочий день в будущем.", - nextRunInTargetTz, DateTimeOffset.UtcNow); - - // Берём все рабочие дни СТРОГО ПОСЛЕ найденного дня - var futureWorkDays = workDays.Where(d => d > foundDay).ToList(); - - if (futureWorkDays.Any()) - { - // Ищем первый день с допустимой нагрузкой (текущая < ожидаемая) - var notOverloadedDays = futureWorkDays - .Where(d => currentDailyLoad[d] < expectedDailyLoad.GetValueOrDefault(d, 0)) - .ToList(); - - var futureDay = notOverloadedDays.Any() - ? notOverloadedDays.First() - : futureWorkDays.First(); - - logger.LogDebug("Выбран следующий рабочий день в будущем: {day}", futureDay); - - // Пересоздаём момент времени с новой датой - nextRunInTargetTz = new DateTimeOffset( - futureDay.Year, futureDay.Month, futureDay.Day, - referenceDateInTargetTz.Hour, - referenceDateInTargetTz.Minute, - referenceDateInTargetTz.Second, - targetOffset); - } - else - { - // Нет будущих рабочих дней — ошибка - logger.LogError("Нет будущих рабочих дней после {date} в периоде. Возвращаем ошибку со смещением.", - foundDay); - - errorResult = FillTemplateResultWithOffsetError(targetTemplate, startDateForError, referenceDateInTargetTz, targetOffset); - return null; - } - } - else - { - logger.LogDebug("Момент времени {nextRun} актуален (в будущем). Коррекция не требуется.", nextRunInTargetTz); - } - - return nextRunInTargetTz; - } - - - /// - /// Строит текущую нагрузку на дни, учитывая ТОЛЬКО шаблоны внутри периода. - /// Шаблоны вне периода игнорируются. - /// - private Dictionary BuildCurrentLoad(List templates, List workDays, DateOnly periodStart, DateOnly periodEnd, TimeSpan targetOffset) - { - var load = workDays.ToDictionary(day => day, _ => 0); - - foreach (var template in templates) - { - if (!template.NextRun.HasValue) - continue; - - // Конвертируем в целевой пояс для определения даты - var templateDate = DateOnly.FromDateTime(template.NextRun.Value.ToOffset(targetOffset).Date); - - // Учитываем ТОЛЬКО шаблоны внутри периода - if (templateDate >= periodStart && templateDate <= periodEnd) - { - if (load.ContainsKey(templateDate)) - load[templateDate]++; - } - } - - return load; - } - - - /// - /// Заполнить TemplateNextRunResultDto, если нет рабочих дней со смещением несколько лет - /// - /// - /// - private TemplateNextRunResultDto FillTemplateResultWithOffsetError(TemplateNextRunDto template, DateOnly startDate, DateTimeOffset referenceDateInTargetTz, TimeSpan targetOffset) - { - // Создаём дату в целевом поясе - var errorDateInTargetTz = new DateTimeOffset( - startDate.AddYears(offsetYearsIfErrorCalcNextRun).Year, - startDate.AddYears(offsetYearsIfErrorCalcNextRun).Month, - startDate.AddYears(offsetYearsIfErrorCalcNextRun).Day, - referenceDateInTargetTz.Hour, - referenceDateInTargetTz.Minute, - referenceDateInTargetTz.Second, - targetOffset); - - // КОНВЕРТИРУЕМ В UTC ДЛЯ ВОЗВРАТА - var errorDateUtc = errorDateInTargetTz.ToOffset(TimeSpan.Zero); - - logger.LogWarning("Шаблон {Id}: нет рабочих дней в периоде, сдвиг на {years} лет → {nextRun} (UTC)", template.Id, offsetYearsIfErrorCalcNextRun, errorDateUtc); - - return new TemplateNextRunResultDto( - Id: template.Id, - NextRun: errorDateUtc, - NextRunOld: template.NextRun - ); - } - - - /// - /// Находит следующий день с доступной нагрузкой, учитывая expectedDailyLoad - /// - /// - /// - /// - /// - /// - private DateTimeOffset? FindNextAvailableDateWithLoad(Dictionary currentDailyLoad, List workDays, DateTimeOffset referenceDateInTargetTz, Dictionary expectedDailyLoad, TimeSpan targetOffset) - { - foreach (var day in workDays) - { - var expected = expectedDailyLoad.GetValueOrDefault(day, 0); - var current = currentDailyLoad.GetValueOrDefault(day, 0); - - if (current < expected) - { - // Создаём момент времени В ЦЕЛЕВОМ ПОЯСЕ - return new DateTimeOffset( - day.Year, day.Month, day.Day, - referenceDateInTargetTz.Hour, - referenceDateInTargetTz.Minute, - referenceDateInTargetTz.Second, - targetOffset); - } - } - - // Если все дни загружены — берём день с минимальной нагрузкой - var minDay = currentDailyLoad.MinBy(x => x.Value).Key; - return new DateTimeOffset( - minDay.Year, minDay.Month, minDay.Day, - referenceDateInTargetTz.Hour, - referenceDateInTargetTz.Minute, - referenceDateInTargetTz.Second, - targetOffset); - } - - - /// - /// Получить план распределения шаблонов по рабочим дням. - /// - /// Общее количество шаблонов для распределения - /// Список рабочих дней в периоде - /// Если true — строит зеркальный план (справа налево от конца периода), иначе обычный (слева направо от начала периода) - /// Список дат, на которые должны быть назначены шаблоны - private List GetDistributionPlan(int totalWorks, List workDays, bool mirror = false) - { - var totalDays = workDays.Count; - var interval = CalculateDistributionInterval(totalDays, totalWorks); - - var plan = new List(); - - for (int i = 0; i < totalWorks; i++) - { - // Базовый индекс для обычного плана (слева → направо) - var baseIndex = (int)Math.Floor(i * interval); - - // Применяем зеркалирование, если нужно - var index = mirror - ? totalDays - 1 - baseIndex // Зеркальный индекс: от конца периода - : baseIndex; // Обычный индекс: от начала периода - - // Защита от выхода за границы массива - index = Math.Max(0, Math.Min(index, totalDays - 1)); - - plan.Add(workDays[index]); - } - - // Логгирование - var planType = mirror ? "Зеркальный" : "Обычный"; - logger.LogDebug("--- {planType} план распределения ({totalWorks} шаблонов, {totalDays} раб. дней) ---", planType, totalWorks, totalDays); - - foreach (var item in plan.GroupBy(t => t).OrderBy(g => g.Key)) - logger.LogDebug("Дата: {date}, кол-во работ: {workCount}", item.Key, item.Count()); - - return plan; - } - - - /// - /// Рассчитать интервал между шаблонами - /// - /// Кол-во рабочих дней - /// Кол-во шаблонов - /// - private double CalculateDistributionInterval(int totalDays, int totalWorks) - { - var interval = (double)totalDays / totalWorks; - - logger.LogDebug("Интервал распределения: {interval} (дней на шаблон)", interval); - - return interval; - } - - - /// - /// Получить дату начала распределения с учётом целевого часового пояса. - /// Метод возвращает календарную дату (DateOnly), рассчитанную в контексте указанного оффсета. - /// - /// Стартовая дата (интерпретируется в целевом часовом поясе) - /// Момент времени в целевом часовом поясе (после применения оффсета) - /// Целевой часовой пояс (оффсет) - /// Дата начала распределения - private DateOnly GetStartPeriodDate(DateOnly startDate, DateTimeOffset referenceDate, TimeSpan offset) - { - var dateStart = startDate; - - // 1. Дата начала не может быть раньше даты из referenceDate (в целевом поясе) - var refDateInTargetTz = DateOnly.FromDateTime(referenceDate.Date); - if (dateStart < refDateInTargetTz) - { - logger.LogDebug("Дата начала {startDate} раньше даты из referenceDate в поясе {offset} ({refDate}). Используем {refDate}.", - dateStart, offset, refDateInTargetTz, refDateInTargetTz); - dateStart = refDateInTargetTz; - } - - // 2. Дата начала не может быть раньше "сегодня" в целевом часовом поясе - var todayInTargetTz = DateOnly.FromDateTime(DateTimeOffset.UtcNow.ToOffset(offset).Date); - if (dateStart < todayInTargetTz) - { - logger.LogDebug("Дата начала {startDate} раньше 'сегодня' в поясе {offset} ({today}). Используем {today}.", - dateStart, offset, todayInTargetTz, todayInTargetTz); - dateStart = todayInTargetTz; - } - - // 3. Если момент времени "начало дня в целевом поясе + время из referenceDate" уже прошёл — добавляем 1 день - // ВАЖНО: время берём из самого referenceDate (который уже в целевом поясе!) - var dateTimeStart = new DateTimeOffset( - dateStart.Year, dateStart.Month, dateStart.Day, - referenceDate.Hour, // ← Время из целевого пояса! - referenceDate.Minute, - referenceDate.Second, - offset); // ← Оффсет целевого пояса - - // Сравниваем абсолютные моменты времени (работает корректно даже с разными оффсетами) - if (dateTimeStart < DateTimeOffset.UtcNow) - { - logger.LogDebug("Момент времени {dateTimeStart} (в поясе {offset}) уже прошёл (сейчас {now}). Добавляем 1 день.", - dateTimeStart, offset, DateTimeOffset.UtcNow); - dateStart = dateStart.AddDays(1); - } - - logger.LogDebug("Итоговая дата начала распределения в поясе {offset}: {dateStart}", offset, dateStart); - return dateStart; - } - - - /// - /// Получить дату окончания периода распределения. - /// Продолжительность включает стартовый день (например, duration=7 означает 7 дней: с 1 по 7 июня). - /// - /// - /// - /// - private DateOnly GetEndPeriodDate(DateOnly startDate, int duration) - { - if (duration <= 0) - { - logger.LogWarning("Некорректная продолжительность периода: {duration}. Используем минимальное значение 1.", duration); - duration = 1; - } - - // duration — количество дней, включая startDate - // поэтому вычитаем 1, чтобы получить сдвиг - - var endDate = startDate.AddDays(duration - 1); - logger.LogDebug("Расчёт даты окончания периода: startDate={startDate}, duration={duration}, endDate={endDate}", startDate, duration, endDate); - - return endDate; - } - - - /// - /// Получить список выходных дней - /// - /// - /// - /// - public async Task> GetWeekendsAsync(DateOnly startDate, DateOnly endDate) - { - //Возвращает все выходные дни включая сб и вск, и праздники - var weekends = await weekendDayService.GetWeekends(startDate, endDate).ToListAsync(); - - logger.LogDebug("Найдено не рабочих дней: {Count}", weekends.Count); - foreach (var weekend in weekends) - logger.LogDebug("Не рабочий день: {weekend}", weekend); - - return weekends.ToHashSet(); - } - - - public async Task> GetWorkDaysAsync(DateOnly start, DateOnly end, bool excludeWeekends) - { - // Получаем список выходных/праздничных дней - var weekends = new HashSet(); - if (excludeWeekends) - weekends = await GetWeekendsAsync(start, end); - - var result = new List(); - var date = start; - - while (date <= end) - { - var dayOfWeek = date.DayOfWeek; - if (!weekends.Contains(date)) - { - result.Add(date); - } - date = date.AddDays(1); - } - - logger.LogDebug("Найдено рабочих дней: {Count}", result.Count); - foreach (var workDay in result) - logger.LogDebug("Рабочий день: {workDay}", workDay); - - return result; - } - - - /// - /// Получает рабочие дни с автоматическим расширением периода при их отсутствии. - /// Расширяет период до maxPeriodExpansions раз: _startDate + duration * N (N = 1, 2, 3) - /// - private async Task<(DateOnly endDate, List workDays)> GetWorkDaysWithExpansionAsync(DateOnly startDate, int duration, bool excludeWeekends) - { - // Попытка 0: исходный период [_startDate, _startDate + duration - 1] - var currentEndDate = startDate.AddDays(duration - 1); - var workDays = await GetWorkDaysAsync(startDate, currentEndDate, excludeWeekends); - - if (workDays.Count > 0) - { - logger.LogDebug("Найдено {count} рабочих дней в исходном периоде [{start}, {end}]", workDays.Count, startDate, currentEndDate); - return (currentEndDate, workDays); - } - - // Попытки расширения 1..maxPeriodExpansions - for (int attempt = 1; attempt <= maxPeriodExpansions; attempt++) - { - //расширяем период относительно ПРЕДЫДУЩЕГО конца - currentEndDate = currentEndDate.AddDays(duration); - - logger.LogWarning("Попытка {attempt}/{maxAttempts} расширения периода: нет рабочих дней в [{start}, {end}]. Расширяем до {newEnd}.", attempt, maxPeriodExpansions, startDate, startDate.AddDays(duration - 1), currentEndDate); - - workDays = await GetWorkDaysAsync(startDate, currentEndDate, excludeWeekends); - - if (workDays.Count > 0) - { - logger.LogInformation("Рабочие дни найдены после {attempt} попытки расширения. Период: [{start}, {end}], кол-во рабочих дней: {count}", attempt, startDate, currentEndDate, workDays.Count); - return (currentEndDate, workDays); - } - } - - // Все попытки исчерпаны — рабочих дней нет - logger.LogError("После {maxAttempts} попыток расширения рабочие дни не найдены в периоде [{start}, {end}]. Возвращаем ошибку.", maxPeriodExpansions, startDate, currentEndDate); - - return (currentEndDate, new List()); // Пустой список для обработки ошибки вызывающим кодом - } - } -} diff --git a/PARR.DAL/ParrDalInstaller.cs b/PARR.DAL/ParrDalInstaller.cs index aa952cab..eb5c539e 100644 --- a/PARR.DAL/ParrDalInstaller.cs +++ b/PARR.DAL/ParrDalInstaller.cs @@ -141,8 +141,7 @@ namespace PARR.DAL services.AddTransient(); services.AddTransient(); - services.AddTransient(); - services.AddTransient(); + services.AddTransient(); #endregion diff --git a/PARR.EsppScheduleSync/ScheduleSyncher.cs b/PARR.EsppScheduleSync/ScheduleSyncher.cs index d3410738..9477ef58 100644 --- a/PARR.EsppScheduleSync/ScheduleSyncher.cs +++ b/PARR.EsppScheduleSync/ScheduleSyncher.cs @@ -226,7 +226,7 @@ namespace PARR.EsppScheduleSync //nextRunWithTimeZone = nextRunModifierService.GetNextRunByAccountRobotTimeZone(template.NextRun); - var nextRunService = scope.ServiceProvider.GetRequiredService(); + var nextRunService = scope.ServiceProvider.GetRequiredService(); //var shortcodeService = scope.ServiceProvider.GetRequiredService(); //responseArea = shortcodeService.ApplyShortcodesAsync(template.Job!.ResponseAreaMask, template).GetAwaiter().GetResult(); @@ -327,7 +327,7 @@ namespace PARR.EsppScheduleSync private async Task CalcNextRunWithRobotTzAsync(Guid templateId) { using var scope = serviceProvider.CreateScope(); - var nextRunService = scope.ServiceProvider.GetRequiredService(); + var nextRunService = scope.ServiceProvider.GetRequiredService(); var nextRun = await nextRunService.GetNextRunForTemplateAsync(templateId, isNew: false); diff --git a/PARR.NextRun/NextRunIntervalService.cs b/PARR.NextRun/NextRunIntervalService.cs index 4cccea50..beaa4b9e 100644 --- a/PARR.NextRun/NextRunIntervalService.cs +++ b/PARR.NextRun/NextRunIntervalService.cs @@ -46,10 +46,10 @@ namespace PARR.NextRun //var esppSchService = scope.ServiceProvider.GetService(); var templateService = scope.ServiceProvider.GetService(); var jobGroupService = scope.ServiceProvider.GetService(); - var nextRunService = scope.ServiceProvider.GetService(); + var nextRunService = scope.ServiceProvider.GetService(); if (templateService == null || nextRunService == null || jobGroupService == null) - throw new Exception($"Не смог получить серивс {nameof(ITemplateService)} или {nameof(INextRunServiceV2)} или {nameof(IJobGroupService)}"); + throw new Exception($"Не смог получить серивс {nameof(ITemplateService)} или {nameof(INextRunService)} или {nameof(IJobGroupService)}"); await HandlerAsync(templateService, nextRunService, jobGroupService); } @@ -61,7 +61,7 @@ namespace PARR.NextRun /// Рассчет следующей даты срабатываения /// /// - private async Task HandlerAsync(ITemplateService templateService, INextRunServiceV2 nextRunService, IJobGroupService jobGroupService) + private async Task HandlerAsync(ITemplateService templateService, INextRunService nextRunService, IJobGroupService jobGroupService) { logger.LogInformation("Начинаю обновлять NextRun по расписанию"); @@ -77,7 +77,7 @@ namespace PARR.NextRun } - private async Task UpdateNextRunAsync(ITemplateService templateService, INextRunServiceV2 nextRunService, IJobGroupService jobGroupService) + private async Task UpdateNextRunAsync(ITemplateService templateService, INextRunService nextRunService, IJobGroupService jobGroupService) { // выбираем все шаблоны с просроченным nextRun в статусе Used diff --git a/PARR.NextRun/NextRunRabbitService.cs b/PARR.NextRun/NextRunRabbitService.cs index 6a465ee3..df58beb9 100644 --- a/PARR.NextRun/NextRunRabbitService.cs +++ b/PARR.NextRun/NextRunRabbitService.cs @@ -100,7 +100,7 @@ namespace PARR.NextRun using var scope = serviceProvider.CreateScope(); var templateService = scope.ServiceProvider.GetRequiredService(); - var nextRunService = scope.ServiceProvider.GetRequiredService(); + var nextRunService = scope.ServiceProvider.GetRequiredService(); // Берем шаблоны только в статусе Used var templates = await templateService.Get() diff --git a/PARR.TemplateDistributor/TemplateDistributor.cs b/PARR.TemplateDistributor/TemplateDistributor.cs index a2d1dc37..6a497c29 100644 --- a/PARR.TemplateDistributor/TemplateDistributor.cs +++ b/PARR.TemplateDistributor/TemplateDistributor.cs @@ -12,14 +12,14 @@ namespace PARR.TemplateDistributor { private readonly ILogger logger; //private readonly INextRunService nextRunService; - private readonly INextRunServiceV2 nextRunService; + private readonly INextRunService nextRunService; private readonly ITemplateService templateService; private readonly IRobotConfigurationService robotConfigurationService; public TemplateDistributor( ILogger logger, //INextRunService nextRunService, - INextRunServiceV2 nextRunService, + INextRunService nextRunService, ITemplateService templateService, IRobotConfigurationService robotConfigurationService ) diff --git a/PARR.TemplateGeneratorWorker/TemplateGenerator.cs b/PARR.TemplateGeneratorWorker/TemplateGenerator.cs index 2d7fa3d0..fee27c1c 100644 --- a/PARR.TemplateGeneratorWorker/TemplateGenerator.cs +++ b/PARR.TemplateGeneratorWorker/TemplateGenerator.cs @@ -20,7 +20,7 @@ namespace PARR.TemplateGeneratorWorker private readonly IJobService jobService; private readonly IShortcodesService shortcodesService; private readonly ITemplateService templateService; - private readonly INextRunServiceV2 nextRunService; + private readonly INextRunService nextRunService; //private readonly INextRunService nextRunService; @@ -33,7 +33,7 @@ namespace PARR.TemplateGeneratorWorker IJobService jobService, IShortcodesService shortcodesService, ITemplateService templateService, - INextRunServiceV2 nextRunService + INextRunService nextRunService //INextRunService nextRunService //IEsppScheduleTransformService esppScheduleTransformService ) diff --git a/PARR.TemplateUpdater/Services/TemplateUpdaterService.cs b/PARR.TemplateUpdater/Services/TemplateUpdaterService.cs index 4f0009b3..bf78fabe 100644 --- a/PARR.TemplateUpdater/Services/TemplateUpdaterService.cs +++ b/PARR.TemplateUpdater/Services/TemplateUpdaterService.cs @@ -19,7 +19,7 @@ namespace PARR.TemplateUpdater.Services private readonly IJobService jobService; private readonly IUnitService unitService; private readonly IRobotConfigurationService robotConfigurationService; - private readonly INextRunServiceV2 nextRunService; + private readonly INextRunService nextRunService; public TemplateUpdaterService( ILogger logger, @@ -27,7 +27,7 @@ namespace PARR.TemplateUpdater.Services IJobService jobService, IUnitService unitService, IRobotConfigurationService robotConfigurationService, - INextRunServiceV2 nextRunService + INextRunService nextRunService ) { this.logger = logger; diff --git a/PARR.Test/NextRun/NextRunTest.cs b/PARR.Test/NextRun/NextRunTest.cs index e01a2850..29f25322 100644 --- a/PARR.Test/NextRun/NextRunTest.cs +++ b/PARR.Test/NextRun/NextRunTest.cs @@ -14,7 +14,7 @@ namespace PARR.Test.NextRun private readonly ITemplateService templateService; private readonly IShortcodesService shortcodesService; //private readonly ITemplateDistributorV2 templateDistributorV2; - private readonly INextRunServiceV2 nextRunServiceV2; + private readonly INextRunService nextRunServiceV2; public NextRunTest( /*ITemplateDistributor templateDistributor, */ @@ -22,7 +22,7 @@ namespace PARR.Test.NextRun ITemplateService templateService, IShortcodesService shortcodesService, //ITemplateDistributorV2 templateDistributorV2, - INextRunServiceV2 nextRunServiceV2 + INextRunService nextRunServiceV2 ) { //this.templateDistributor = templateDistributor; diff --git a/PARR.Test/Worker.cs b/PARR.Test/Worker.cs index 7ed9581a..b10ef5c2 100644 --- a/PARR.Test/Worker.cs +++ b/PARR.Test/Worker.cs @@ -15,21 +15,21 @@ namespace PARR.Test private readonly ILogger _logger; private readonly IEsppApiService esppApiService; private readonly IIntervalService intervalService; - private readonly ICalendarService calendarService; + //private readonly ICalendarService calendarService; private readonly IServiceProvider serviceProvider; public Worker( ILogger logger, IEsppApiService esppApiService, IIntervalService intervalService, - ICalendarService calendarService, + //ICalendarService calendarService, IServiceProvider serviceProvider ) { _logger = logger; this.esppApiService = esppApiService; this.intervalService = intervalService; - this.calendarService = calendarService; + //this.calendarService = calendarService; this.serviceProvider = serviceProvider; }