< Summary

Information
Class: OzonOrdersWeb.Controllers.JobController
Assembly: OzonOrdersWeb
File(s): /home/runner/work/Studio2Prod/Studio2Prod/OzonOrdersWeb/Areas/Studio2/Controllers/JobController.cs
Line coverage
0%
Covered lines: 0
Uncovered lines: 727
Coverable lines: 727
Total lines: 1335
Line coverage: 0%
Branch coverage
0%
Covered branches: 0
Total branches: 356
Branch coverage: 0%
Method coverage

Feature is only available for sponsors

Upgrade to PRO version

Metrics

File(s)

/home/runner/work/Studio2Prod/Studio2Prod/OzonOrdersWeb/Areas/Studio2/Controllers/JobController.cs

#LineLine coverage
 1using System.Text;
 2using Hangfire;
 3using Hangfire.Storage;
 4using Hangfire.Storage.Monitoring;
 5using Microsoft.AspNetCore.Authorization;
 6using Microsoft.AspNetCore.Mvc;
 7using Microsoft.EntityFrameworkCore;
 8using Microsoft.IdentityModel.Tokens;
 9using Newtonsoft.Json;
 10using OzonDomains;
 11using OzonDomains.Models;
 12using OzonDomains.Models.OrderCarts;
 13using OzonOrdersWeb.ViewModels.JobViewModels;
 14using OzonRepositories.Context;
 15using OzonRepositories.Data;
 16using Services.ApiServices.DropBoxApi;
 17using Services.ApiServices.OzonApi;
 18using Services.ApiServices.YandexApi;
 19using Services.YandexServices;
 20using Services.CacheServices.Cache.OzonOrdersCache;
 21using Services.DataServices;
 22using Services.ParserServices;
 23using Services.ParserServices.FielParsers;
 24using Services.ParserServices.HelpDictEnum;
 25using Services.ReleasServices.ReleaseManager;
 26using Microsoft.AspNetCore.SignalR;
 27using Services.SignalRServices;
 28using OzonOrdersWeb.Areas.Studio2.ViewModels.HangfireQueue;
 29using Services.HangfireService;
 30using AppRepository.ApiServices.OzonApi;
 31using AppRepository.ApiServices.OzonApi.Filters;
 32using OzonOrdersWeb.Areas.Studio2.Controllers;
 33using Services.OzonWebhookServices;
 34using Services.OzonReturnsServices;
 35using Services.BackgroundJobsServices;
 36
 37namespace OzonOrdersWeb.Controllers
 38{
 39    [Authorize(Roles = "Admin")]
 40    [Area("Studio2")]
 41    public class JobController : Controller
 42    {
 43        private readonly OrdersDataServices _orderRepository;
 44        private readonly OrderCaster _orderCaster;
 45        private readonly OzonJsonDataBuilder _jsonDataBuilder;
 46        private readonly ReleaseManager _releaseManager;
 47        private readonly DuplicateOrdersServices _duplicateOrdersServices;
 48        private readonly OzonClientServices _ozonClientServices;
 49        private readonly YandexDataManager _yandexDataManager;
 50        private readonly IYandexOrderStatusSyncService _yandexOrderStatusSyncService;
 51        private readonly ApplicationDbContext _context;
 52        private readonly OrdersDataServices _orderDataServices;
 53        private readonly OrderItemRepository _orderItemRepository;
 54        private readonly ClientStatusDataServices _clientStatusServices;
 55        private readonly OrderCache _cache;
 56        private readonly IHubContext<JobProgressHub> _jobProgressHub;
 57        private readonly IHangfireService _hangfireService;
 58        private readonly IHttpClientFactory _httpClientFactory;
 59        private readonly IServiceScopeFactory _serviceScopeFactory;
 60        private readonly IOzonPostingCancellationService _postingCancellationService;
 61
 062        public JobController(OrdersDataServices ordersDataServices,
 063            OrderCaster orderCaster,
 064            OzonJsonDataBuilder jsonDataBuilder,
 065            ReleaseManager releaseManager,
 066            DuplicateOrdersServices duplicateOrdersServices,
 067            OzonClientServices ozonClientServices,
 068            YandexDataManager yandexDataManager,
 069            IYandexOrderStatusSyncService yandexOrderStatusSyncService,
 070            ApplicationDbContext context,
 071            OrdersDataServices orderDataServices,
 072            OrderItemRepository orderItemRepository,
 073            ClientStatusDataServices clientStatusServices,
 074            OrderCache cache,
 075            IHubContext<JobProgressHub> jobProgressHub,
 076            IHangfireService hangfireService,
 077            IHttpClientFactory httpClientFactory,
 078            IServiceScopeFactory serviceScopeFactory,
 079            IOzonPostingCancellationService postingCancellationService)
 80        {
 081            _orderRepository = ordersDataServices;
 082            _orderCaster = orderCaster;
 083            _jsonDataBuilder = jsonDataBuilder;
 084            _releaseManager = releaseManager;
 085            _duplicateOrdersServices = duplicateOrdersServices;
 086            _ozonClientServices = ozonClientServices;
 087            _yandexDataManager = yandexDataManager;
 088            _yandexOrderStatusSyncService = yandexOrderStatusSyncService;
 089            _context = context;
 090            _orderDataServices = orderDataServices;
 091            _orderItemRepository = orderItemRepository;
 092            _clientStatusServices = clientStatusServices;
 093            _cache = cache;
 094            _jobProgressHub = jobProgressHub;
 095            _hangfireService = hangfireService;
 096            _httpClientFactory = httpClientFactory;
 097            _serviceScopeFactory = serviceScopeFactory;
 098            _postingCancellationService = postingCancellationService;
 099        }
 100
 101        public IActionResult Index(string pageTab = "schedules", string queueName = "main",
 102            string queueTab = "enqueued")
 103        {
 0104            var api = JobStorage.Current.GetMonitoringApi();
 0105            JobList<SucceededJobDto> succeededJobs = api.SucceededJobs(0, 50);
 106
 0107            var jobResult = new JobResultViewModel
 0108            {
 0109                Schedules = BuildScheduleStatuses(),
 0110                ActivePageTab = string.IsNullOrWhiteSpace(pageTab) ? "schedules" : pageTab
 0111            };
 112
 113            // Состояние разового обновления статусов по периоду (чтобы показать "выполняется")
 0114            TryGetPeriodStatusUpdateState(api, out var state, out var jobId, out var startedAtUtc);
 0115            jobResult.PeriodStatusUpdateState = state ?? string.Empty;
 0116            jobResult.PeriodStatusUpdateJobId = jobId ?? string.Empty;
 0117            jobResult.PeriodStatusUpdateStartedAtUtc = startedAtUtc;
 118
 119            // Данные для вкладки очереди Hangfire (как /HangfireQueue)
 120            try
 121            {
 0122                var queues = _hangfireService.GetAvailableQueues()?.ToList() ?? new List<string>();
 0123                if (queues.Count == 0)
 124                {
 0125                    queues.Add("main");
 126                }
 127
 0128                if (string.IsNullOrWhiteSpace(queueName) || !queues.Contains(queueName))
 0129                    queueName = queues.First();
 130
 0131                var hModel = new HangfireJobsViewModel
 0132                {
 0133                    QueueName = queueName,
 0134                    ActiveTab = string.IsNullOrWhiteSpace(queueTab) ? "enqueued" : queueTab,
 0135                    AvailableQueues = queues,
 0136                    EnqueuedJobs = _hangfireService.GetEnqueuedJobs(queueName),
 0137                    ProcessingJobs = _hangfireService.GetProcessingJobs(queueName),
 0138                    SucceededJobs = _hangfireService.GetSucceededJobs(),
 0139                    DeletedJobs = _hangfireService.GetDeletedJobs()
 0140                };
 141
 0142                jobResult.HangfireQueue = hModel;
 0143            }
 0144            catch
 145            {
 0146                jobResult.HangfireQueue = new HangfireJobsViewModel();
 0147            }
 148
 149            // Проверка на null или пустоту
 0150            if (succeededJobs == null || succeededJobs.Count == 0)
 151            {
 0152                return View(jobResult);
 153            }
 154
 0155            var results = new List<dynamic>();
 156
 0157            foreach (var job in succeededJobs)
 158            {
 0159                if (job.Value.Result != null)
 160                {
 161                    try
 162                    {
 0163                        var result = JsonConvert.DeserializeObject<dynamic>((string)job.Value.Result);
 0164                        if (result != null)
 0165                            results.Add(result);
 0166                    }
 0167                    catch (JsonException ex)
 168                    {
 0169                        results.Add($"Error deserializing result for job {job.Key}: {ex.Message}");
 0170                    }
 171                }
 172            }
 173
 174            // Обработка результатов
 0175            foreach (var result in results)
 176            {
 0177                if (result is string)
 178                {
 0179                    jobResult.Results.Add((string)result);
 180                }
 181            }
 182
 0183            return View(jobResult);
 184        }
 185
 186        private const string PeriodStatusUpdateLockKey = "locks:statusUpdateByPeriod";
 187
 188        private void TryGetPeriodStatusUpdateState(
 189            IMonitoringApi api,
 190            out string? state,
 191            out string? jobId,
 192            out DateTime? startedAtUtc)
 193        {
 0194            state = null;
 0195            jobId = null;
 0196            startedAtUtc = null;
 197
 198            try
 199            {
 200                // Processing
 0201                var processing = api.ProcessingJobs(0, 2000);
 0202                foreach (var p in processing)
 203                {
 0204                    var j = p.Value?.Job;
 0205                    if (j?.Method?.Name == nameof(UpdateStatusesByProcessingDateBackground))
 206                    {
 0207                        state = "processing";
 0208                        jobId = p.Key;
 0209                        startedAtUtc = p.Value?.StartedAt;
 0210                        return;
 211                    }
 212                }
 213
 214                // Enqueued (во всех очередях)
 0215                foreach (var q in api.Queues())
 216                {
 0217                    var enq = api.EnqueuedJobs(q.Name, 0, 2000);
 0218                    foreach (var e in enq)
 219                    {
 0220                        var j = e.Value?.Job;
 0221                        if (j?.Method?.Name == nameof(UpdateStatusesByProcessingDateBackground))
 222                        {
 0223                            state = "enqueued";
 0224                            jobId = e.Key;
 0225                            startedAtUtc = e.Value?.EnqueuedAt;
 0226                            return;
 227                        }
 228                    }
 229                }
 0230            }
 0231            catch
 232            {
 233                // ignore
 0234            }
 0235        }
 236
 237        private List<JobScheduleViewModel> BuildScheduleStatuses()
 238        {
 239            try
 240            {
 0241                using var connection = JobStorage.Current.GetConnection();
 0242                var recurring = connection.GetRecurringJobs() ?? new List<RecurringJobDto>();
 243
 244                JobScheduleViewModel Map(
 245                    string key,
 246                    string title,
 247                    string? matchRecurringId = null,
 248                    string? matchMethodName = null)
 249                {
 0250                    var r = !string.IsNullOrEmpty(matchRecurringId)
 0251                        ? recurring.FirstOrDefault(x => x.Id == matchRecurringId)
 0252                        : null;
 253
 0254                    r ??= !string.IsNullOrEmpty(matchMethodName)
 0255                        ? recurring.FirstOrDefault(x => x.Job?.Method?.Name == matchMethodName)
 0256                        : null;
 257
 0258                    if (r == null)
 259                    {
 0260                        return new JobScheduleViewModel
 0261                        {
 0262                            Key = key,
 0263                            Title = title,
 0264                            IsConfigured = false
 0265                        };
 266                    }
 267
 0268                    return new JobScheduleViewModel
 0269                    {
 0270                        Key = key,
 0271                        Title = title,
 0272                        IsConfigured = true,
 0273                        RecurringJobId = r.Id,
 0274                        Cron = r.Cron,
 0275                        LastExecutionUtc = r.LastExecution,
 0276                        NextExecutionUtc = r.NextExecution,
 0277                        LastJobId = r.LastJobId,
 0278                        LastJobState = r.LastJobState,
 0279                        Args = r.Job?.Args?.ToArray() ?? Array.Empty<object>()
 0280                    };
 281                }
 282
 0283                return new List<JobScheduleViewModel>
 0284                {
 0285                    Map("UpdateStatusesRecurring", "Обновление статусов (последние заказы)",
 0286                        matchRecurringId: "StatusUpdateJob"),
 0287                    Map("EverydayUpdate", "Ежедневное обновление заказов (OZON)",
 0288                        matchMethodName: nameof(EverydayUpdate)),
 0289                    Map("UploadingOzon", "Периодическое обновление заказов (OZON)", matchMethodName: "UploadingOzon"),
 0290                    Map("UploadingYandex", "Периодическое обновление заказов (YANDEX)",
 0291                        matchMethodName: "UploadingYandex"),
 0292                    Map("OzonChatSync", "Синхронизация чатов Ozon", matchRecurringId: "OzonChatSync"),
 0293                    Map(LoadOzonReturnsJob.IncrementalJobId, "Загрузка возвратов Ozon (инкрементально)",
 0294                        matchRecurringId: LoadOzonReturnsJob.IncrementalJobId),
 0295                    Map(LoadOzonReturnsJob.FullWindowJobId, "Загрузка возвратов Ozon (полная сверка)",
 0296                        matchRecurringId: LoadOzonReturnsJob.FullWindowJobId)
 0297                };
 298            }
 0299            catch
 300            {
 301                // Hangfire storage может быть недоступен (например, при старте) — не ломаем страницу.
 0302                return new List<JobScheduleViewModel>();
 303            }
 0304        }
 305
 306        [HttpPost]
 307        public IActionResult SetOzonRecurring(int delay)
 308        {
 0309            string cronExp = $"*/{delay} * * * *";
 0310            RecurringJob.AddOrUpdate("UploadingOzon", "main", () => UploadingOzon(1), cronExp);
 311
 0312            return Redirect("/Hangfire");
 313        }
 314
 315
 316        [HttpPost]
 317        public IActionResult SetYandexRecurring(int delay)
 318        {
 0319            string cronExp = $"*/{delay} * * * *";
 0320            RecurringJob.AddOrUpdate("UploadingYandex", "main", () => UploadingYandex(1), cronExp);
 321
 0322            return Redirect("/Hangfire");
 323        }
 324
 325        [HttpPost]
 326        public async Task<IActionResult> SetEverydayUpdate(int monthsCount, int updateHour, int updateMinute)
 327        {
 0328            await Task.CompletedTask;
 0329            string cronExp = $"{updateMinute} {updateHour} * * *";
 0330            RecurringJob.AddOrUpdate("EverydayUpdate", "main", () => EverydayUpdate(monthsCount), cronExp);
 331
 0332            return Redirect("/Hangfire");
 0333        }
 334
 335        [HttpPost]
 336        public IActionResult SetLoadReturnsRecurring(int monthsCount, int delay)
 337        {
 338            // Держим параметры в безопасных границах UI.
 0339            if (monthsCount < 1) monthsCount = 1;
 0340            if (monthsCount > 12) monthsCount = 12;
 0341            if (delay < 1) delay = 1;
 0342            if (delay > 60) delay = 60;
 343
 0344            string cronExp = $"*/{delay} * * * *";
 345
 346            // Запускаем тот же pipeline, что и на /OrderReturns:
 347            // LoadOzonReturnsJob.RunIncrementalAsync -> OzonReturnsSyncService.LoadAsync ->
 348            // OzonApiDataManager.GetReturnsListAsync(/v1/returns/list) + SyncFromApiResponseAsync(upsert).
 349            // Частый прогон — инкрементальный: читает период от последнего загруженного возврата,
 350            // а не весь monthsCount заново на каждом тике.
 351            //
 352            // Очередь параметром AddOrUpdate не задаём: эта перегрузка требует от хранилища фичу
 353            // Job.Queue, которой в Hangfire.MySqlStorage 2.0.3 нет
 354            // (docs/hangfire-recurring-job-registration.md). Маршрутизацию обеспечивает атрибут
 355            // [Queue] на самих методах. Здесь регистрируем ровно так же, как это делает
 356            // HangfireStartupHostedService, иначе рестарт и форма переписывали бы запись
 357            // по-разному.
 0358            RecurringJob.AddOrUpdate<LoadOzonReturnsJob>(LoadOzonReturnsJob.IncrementalJobId,
 0359                j => j.RunIncrementalAsync(monthsCount), cronExp);
 360
 361            // Полное окно раз в сутки ночью — страховка от правок Ozon задним числом
 362            // и от периодов, выпавших из-за недоступности API.
 0363            RecurringJob.AddOrUpdate<LoadOzonReturnsJob>(LoadOzonReturnsJob.FullWindowJobId,
 0364                j => j.RunFullWindowAsync(monthsCount), LoadOzonReturnsJob.DefaultFullWindowCron,
 0365                new RecurringJobOptions { TimeZone = RussianTimeZoneHelper.Zone });
 366
 0367            TempData["Message"] =
 0368                $"Загрузка возвратов (как в /OrderReturns) установлена: инкрементально каждые {delay} мин., " +
 0369                $"полная сверка за {monthsCount} мес. — ежедневно в 04:00.";
 0370            return Redirect("/Hangfire");
 371        }
 372
 373
 374        [HttpPost]
 375        public IActionResult SetStatusUpdateRecurring(int delay, int count)
 376        {
 0377            string cronExp = $"*/{delay} * * * *";
 0378            RecurringJob.AddOrUpdate(
 0379                "StatusUpdateJob",
 0380                "main",
 0381                () => UpdateCartItemsStatusAsync(count),
 0382                cronExp);
 383
 0384            TempData["Message"] = $"Фоновое обновление статусов установлено с периодом {delay} минут";
 0385            return Redirect("/Hangfire");
 386        }
 387
 388        [HttpPost]
 389        public async Task<Dictionary<string, int[]>> EverydayUpdate(int monthsCount)
 390        {
 0391            var periods = GetDateRanges(monthsCount);
 0392            List<OzonClient> ozonClients = (await _ozonClientServices.GetOzonClients())
 0393                .Where(c => c.ClientType == ClientType.OZON).ToList();
 0394            Dictionary<string, int[]> result = new Dictionary<string, int[]>();
 395
 0396            foreach (var period in periods)
 397            {
 0398                int timeZone = _releaseManager.GetTimeZone();
 399
 400
 0401                foreach (var client in ozonClients)
 402                {
 403                    try
 404                    {
 0405                        if (string.IsNullOrEmpty(client.DecryptClientId) || string.IsNullOrEmpty(client.DecryptApiKey))
 0406                            continue;
 407
 0408                        _jsonDataBuilder.SetClient(client.DecryptClientId, client.DecryptApiKey);
 0409                        var jsonData = await _jsonDataBuilder.BuildData(period.Item1, period.Item2);
 0410                        var orders = await _orderCaster.JsonToOrders(jsonData);
 0411                        orders = orders.Select(order =>
 0412                        {
 0413                            order.OzonClient = client;
 0414                            return order;
 0415                        }).ToList();
 0416                        var uploadResult = await _orderRepository.AddOrders(orders);
 0417                        result.Add(client.Name, uploadResult);
 0418                    }
 0419                    catch (Exception)
 420                    {
 0421                        continue;
 422                    }
 0423                }
 424            }
 425
 0426            return result;
 0427        }
 428
 429        public async Task<string> UploadingOzon(int period)
 430        {
 0431            List<OzonClient> ozonClients = (await _ozonClientServices.GetOzonClients())
 0432                .Where(c => c.ClientType == ClientType.OZON)
 0433                .ToList();
 434
 0435            Dictionary<string, int[]> result = new Dictionary<string, int[]>();
 0436            int totalUploaded = 0;
 0437            int totalUpdated = 0;
 438
 0439            int timeZone = _releaseManager.GetTimeZone();
 0440            var start = DateTime.Now.AddHours(-period * 12 + timeZone);
 0441            var end = DateTime.Now.AddHours(timeZone);
 0442            string clientStatus = "Результат загрузки по Ozon клиентам<br>";
 443
 0444            foreach (var client in ozonClients)
 445            {
 446                try
 447                {
 0448                    if (string.IsNullOrEmpty(client.DecryptClientId) || string.IsNullOrEmpty(client.DecryptApiKey))
 449                    {
 0450                        clientStatus += $"&nbsp;&nbsp;&nbsp;&nbsp;{client.Name}: отсутствуют API ключи<br>";
 0451                        continue;
 452                    }
 453
 0454                    _jsonDataBuilder.SetClient(client.DecryptClientId, client.DecryptApiKey);
 0455                    var jsonData = await _jsonDataBuilder.BuildData(start, end);
 0456                    var orders = await _orderCaster.JsonToOrders(jsonData);
 0457                    orders = orders.Select(order =>
 0458                    {
 0459                        order.OzonClient = client;
 0460                        return order;
 0461                    }).ToList();
 462
 0463                    var updatingStatusCount = await ProcessOrdersForStatusUpdate(orders);
 0464                    var uploadResult = await _orderRepository.AddOrders(orders);
 0465                    result.Add(client.Name, uploadResult);
 466
 0467                    totalUploaded += uploadResult[0];
 0468                    totalUpdated += uploadResult[1];
 469
 0470                    clientStatus +=
 0471                        $"&nbsp;&nbsp;&nbsp;&nbsp;{client.Name}: отчёт успешно создан (обновлено {updatingStatusCount} с
 0472                }
 0473                catch (Exception ex)
 474                {
 0475                    clientStatus += $"&nbsp;&nbsp;&nbsp;&nbsp;{client.Name}: {ex.Message}<br>";
 0476                }
 0477            }
 478
 479            int? duplicateCount;
 480            int? deletedRowsCount;
 0481            (duplicateCount, deletedRowsCount) = _duplicateOrdersServices.DeleteDuplicateOrders();
 482
 0483            await NotificationService.NotifyAsync("Ozon", "#D97706",
 0484                $"Синхронизация завершена: добавлено {totalUploaded}, обновлено {totalUpdated}.");
 485
 0486            return GetJobResultString(result, start, end, clientStatus, duplicateCount, deletedRowsCount);
 0487        }
 488
 489
 490        private async Task<int> ProcessOrdersForStatusUpdate(List<Order> orders)
 491        {
 0492            int updatingCount = 0;
 493
 494            async Task ProcessOrder(Order order)
 495            {
 0496                order.OzonClient ??= await _ozonClientServices.GetOzonClientAsync(order.OzonClient?.Id ?? 0);
 0497                if (order.OzonClient != null && order.OzonClient.ClientType == ClientType.OZON)
 498                {
 0499                    if (string.IsNullOrEmpty(order.OzonClient.DecryptClientId) ||
 0500                        string.IsNullOrEmpty(order.OzonClient.DecryptApiKey) ||
 0501                        string.IsNullOrEmpty(order.ShipmentNumber))
 0502                        return;
 503
 0504                    _jsonDataBuilder.SetClient(order.OzonClient.DecryptClientId, order.OzonClient.DecryptApiKey);
 505
 0506                    string newStatus = await _jsonDataBuilder.GetProductSatatus(order.ShipmentNumber);
 0507                    if (!string.IsNullOrEmpty(newStatus) &&
 0508                        !string.Equals(order.Status, OzonStatus.OrderStatuses[newStatus], StringComparison.OrdinalIgnore
 509                    {
 0510                        order.Status = OzonStatus.OrderStatuses[newStatus];
 0511                        updatingCount++;
 512                    }
 513                }
 0514            }
 515
 0516            var tasks = orders.Select(order => ProcessOrder(order));
 0517            await Task.WhenAll(tasks);
 0518            return updatingCount;
 0519        }
 520
 521
 522        public async Task<string> UploadingYandex(int period)
 523        {
 0524            List<OzonClient> ozonClients = (await _ozonClientServices.GetOzonClients())
 0525                .Where(c => c.ClientType == ClientType.YANDEX)
 0526                .ToList();
 527
 0528            Dictionary<string, int[]> result = new Dictionary<string, int[]>();
 0529            int totalUploaded = 0;
 0530            int totalUpdated = 0;
 0531            int totalStatusUpdated = 0;
 0532            int totalCancellations = 0;
 533
 0534            int timeZone = _releaseManager.GetTimeZone();
 535            // Окно теперь по дате ОБНОВЛЕНИЯ заказа (см. YandexDataManager.GetOrders),
 536            // поэтому в выгрузку попадают и отмены заказов, созданных раньше.
 0537            var start = DateTime.Now.AddHours(-period * 12 + timeZone);
 0538            var end = DateTime.Now.AddHours(timeZone);
 0539            string clientStatus = "Результат загрузки по Yandex клиентам<br>";
 540
 0541            foreach (var client in ozonClients)
 542            {
 543                try
 544                {
 0545                    if (string.IsNullOrEmpty(client.DecryptClientId) || string.IsNullOrEmpty(client.DecryptApiKey))
 546                    {
 0547                        clientStatus += $"&nbsp;&nbsp;&nbsp;&nbsp;{client.Name}: отсутствуют API ключи<br>";
 0548                        continue;
 549                    }
 550
 0551                    _yandexDataManager.SetClient(client.DecryptClientId, client.DecryptApiKey);
 0552                    var jsonData = await _yandexDataManager.GetOrders(start, end);
 553
 0554                    if (jsonData.Count == 0 && _yandexDataManager.LastError != null)
 555                    {
 0556                        clientStatus += $"&nbsp;&nbsp;&nbsp;&nbsp;{client.Name}: " +
 0557                                        $"{_yandexDataManager.LastError}<br>";
 0558                        continue;
 559                    }
 560
 0561                    var orders = await _orderCaster.YandexToOrders(jsonData);
 0562                    orders = orders.Select(order =>
 0563                    {
 0564                        order.OzonClient = client;
 0565                        return order;
 0566                    }).ToList();
 567
 0568                    var uploadResult = await _orderRepository.AddOrders(orders);
 0569                    result.Add(client.Name, uploadResult);
 570
 0571                    totalUploaded += uploadResult[0];
 0572                    totalUpdated += uploadResult[1];
 573
 574                    // Статусы и причины отмены — отдельным шагом: рефлексия в AddOrders навигационный
 575                    // ClientStatus существующего заказа не обновляет.
 0576                    var statusSync = await _yandexOrderStatusSyncService.SyncAsync(client, jsonData);
 0577                    totalStatusUpdated += statusSync.ClientStatusUpdated;
 0578                    totalCancellations += statusSync.CancellationsRecorded;
 579
 0580                    clientStatus += $"&nbsp;&nbsp;&nbsp;&nbsp;{client.Name}: отчёт успешно создан" +
 0581                                    $" (статусов обновлено: {statusSync.ClientStatusUpdated}," +
 0582                                    $" отмен зафиксировано: {statusSync.CancellationsRecorded})<br>";
 0583                }
 0584                catch (Exception ex)
 585                {
 0586                    clientStatus += $"&nbsp;&nbsp;&nbsp;&nbsp;{client.Name}: {ex.Message}<br>";
 0587                    continue;
 588                }
 0589            }
 590
 591            int? duplicateCount;
 592            int? deletedRowsCount;
 0593            (duplicateCount, deletedRowsCount) = _duplicateOrdersServices.DeleteDuplicateOrders();
 594
 0595            await NotificationService.NotifyAsync("Yandex", "#0EA5E9",
 0596                $"Синхронизация завершена: добавлено {totalUploaded}, обновлено {totalUpdated}, " +
 0597                $"статусов изменено {totalStatusUpdated}, отмен {totalCancellations}.");
 598
 0599            return GetJobResultString(result, start, end, clientStatus, duplicateCount, deletedRowsCount);
 0600        }
 601
 602
 603
 604        private string GetJobResultString(Dictionary<string, int[]> result, DateTime start, DateTime end,
 605            string clientStatus, int? duplicateCount, int? deletedRowsCount)
 606        {
 0607            string jobResult = "";
 608
 0609            jobResult +=
 0610                $"Период: {start.ToString("dd.MM.yyyy HH:mm:ss")} - {end.ToString("dd.MM.yyyy HH:mm:ss")}<br/>";
 0611            foreach (var kvp in result)
 612            {
 0613                jobResult +=
 0614                    $"&nbsp;&nbsp;&nbsp;&nbsp;Клиент: {kvp.Key} - загружено {kvp.Value[0]}, обновлено {kvp.Value[1]}<br/
 615            }
 616
 0617            jobResult += clientStatus;
 618
 0619            jobResult += $"Удалено дублей {deletedRowsCount} из {duplicateCount}";
 620
 621
 0622            return jobResult;
 623        }
 624
 625        public List<(DateTime, DateTime)> GetDateRanges(int months)
 626        {
 0627            List<(DateTime, DateTime)> dateRanges = new List<(DateTime, DateTime)>();
 628
 0629            DateTime endDate = DateTime.Today;
 630
 0631            DateTime startDate = endDate.AddMonths(-months);
 632
 0633            while (startDate < endDate)
 634            {
 0635                DateTime weekStart = startDate;
 0636                DateTime weekEnd = startDate.AddDays(6);
 0637                if (weekEnd > endDate)
 638                {
 0639                    weekEnd = endDate;
 640                }
 641
 0642                dateRanges.Add((weekStart, weekEnd));
 0643                startDate = startDate.AddDays(7);
 644            }
 645
 0646            return dateRanges;
 647        }
 648
 649        /// <summary>
 650        /// Настройка периодической синхронизации чатов Ozon с заказами
 651        /// </summary>
 652        [HttpPost]
 653        public IActionResult SetChatSyncRecurring(int delay)
 654        {
 0655            string cronExp = $"*/{delay} * * * *";
 0656            RecurringJob.AddOrUpdate("OzonChatSync", "main", () => SyncOzonChatOrders(), cronExp);
 657
 0658            TempData["Message"] = $"Синхронизация чатов Ozon установлена с периодом {delay} минут";
 0659            return Redirect("/Hangfire");
 660        }
 661
 662        /// <summary>
 663        /// Синхронизация чатов Ozon с заказами (фоновая задача).
 664        /// Получает все чаты для всех OZON клиентов и связывает их с заказами в базе данных.
 665        /// </summary>
 666        public async Task<string> SyncOzonChatOrders()
 667        {
 0668            var result = new StringBuilder();
 0669            int totalChatsProcessed = 0;
 0670            int totalLinksCreated = 0;
 0671            int totalLinksUpdated = 0;
 672
 673            try
 674            {
 0675                result.AppendLine("=== Синхронизация чатов Ozon с заказами ===<br>");
 676
 677                // Получаем всех OZON клиентов
 0678                var ozonClients = await _ozonClientServices.GetOzonClients();
 0679                var validClients = ozonClients
 0680                    .Where(c => !string.IsNullOrEmpty(c.ClientId) && !string.IsNullOrEmpty(c.ApiKey) &&
 0681                                c.ClientType == ClientType.OZON).ToList();
 682
 0683                if (!validClients.Any())
 684                {
 0685                    result.AppendLine("Нет настроенных клиентов OZON<br>");
 0686                    return result.ToString();
 687                }
 688
 0689                result.AppendLine($"Найдено клиентов: {validClients.Count}<br><br>");
 690
 0691                foreach (var client in validClients)
 692                {
 693                    try
 694                    {
 0695                        result.AppendLine($"<b>Клиент: {client.Name}</b><br>");
 696
 0697                        if (string.IsNullOrEmpty(client.DecryptClientId) || string.IsNullOrEmpty(client.DecryptApiKey))
 698                        {
 0699                            result.AppendLine("&nbsp;&nbsp;Ошибка: отсутствуют API ключи<br>");
 0700                            continue;
 701                        }
 702
 0703                        var chatManager = new OzonChatManager(_httpClientFactory, client.DecryptClientId,
 0704                            client.DecryptApiKey);
 705
 706                        // Получаем все чаты с пагинацией (по 100 за проход)
 0707                        var allChats = new List<ChatItem>();
 0708                        string? cursor = null;
 0709                        int pageNumber = 0;
 710                        const int pageSize = 100;
 711                        const int maxPages = 5; // Защита от бесконечного цикла (макс 5000 чатов)
 712
 713                        do
 714                        {
 0715                            pageNumber++;
 0716                            var request = new ChatListRequest
 0717                            {
 0718                                Filter = new ChatFilter
 0719                                {
 0720                                    ChatStatus = "Opened",
 0721                                    UnreadOnly = false
 0722                                },
 0723                                Limit = pageSize,
 0724                                Cursor = cursor
 0725                            };
 726
 0727                            var response = await chatManager.GetChatListAsync(request);
 0728                            var chatsPage = response?.Chats ?? new List<ChatItem>();
 0729                            allChats.AddRange(chatsPage);
 730
 731                            // Обновляем cursor для следующей страницы
 0732                            cursor = response?.Cursor;
 0733                            var hasNext = response?.HasNext ?? false;
 734
 0735                            if (!hasNext || string.IsNullOrEmpty(cursor) || pageNumber >= maxPages)
 736                                break;
 737
 738                            // Небольшая задержка между запросами для снижения нагрузки на API
 0739                            await Task.Delay(100);
 0740                        } while (true);
 741
 0742                        result.AppendLine($"&nbsp;&nbsp;Найдено чатов: {allChats.Count} (страниц: {pageNumber})<br>");
 743
 0744                        if (!allChats.Any())
 0745                            continue;
 746
 747                        // Обрабатываем каждый чат
 0748                        foreach (var chatItem in allChats)
 749                        {
 750                            try
 751                            {
 0752                                totalChatsProcessed++;
 753
 754                                // Получить сервисы в этом scope
 0755                                using var scope = _serviceScopeFactory.CreateScope();
 0756                                var chatOrderService =
 0757                                    scope.ServiceProvider.GetRequiredService<OzonChatOrderDataServices>();
 0758                                var orderRepository = scope.ServiceProvider.GetRequiredService<OrderRepository>();
 759
 760                                // Проверяем, есть ли уже связь для этого чата
 0761                                var existingLinks = await chatOrderService.GetByChatIdAsync(chatItem.Chat.ChatId ?? stri
 762
 0763                                if (existingLinks.Any())
 764                                {
 765                                    // Обновляем существующие связи
 0766                                    foreach (var link in existingLinks)
 767                                    {
 0768                                        link.ChatStatus = chatItem.Chat.ChatStatus;
 0769                                        link.ChatType = chatItem.Chat.ChatType;
 0770                                        link.UnreadMessagesCount = chatItem.UnreadCount;
 0771                                        await chatOrderService.Update(link);
 0772                                        totalLinksUpdated++;
 773                                    }
 774
 0775                                    continue;
 776                                }
 777
 778                                // Если нет связей - пытаемся создать новые
 779                                // Получаем историю чата для извлечения order_number
 0780                                if ((chatItem.Chat.ChatType == "BUYER_SELLER" || chatItem.UnreadCount > 0) &&
 0781                                    !string.IsNullOrEmpty(chatItem.Chat.ChatId))
 782                                {
 0783                                    var history = await chatManager.GetChatHistoryAsync(
 0784                                        chatItem.Chat.ChatId,
 0785                                        limit: 50,
 0786                                        fromMessageId: null
 0787                                    );
 788
 0789                                    if (history?.Messages != null && history.Messages.Any())
 790                                    {
 791                                        // Ищем сообщение с order_number или SKU
 0792                                        var messageWithOrder = history.Messages
 0793                                            .FirstOrDefault(m => !string.IsNullOrEmpty(m.Context?.OrderNumber) ||
 0794                                                                 !string.IsNullOrEmpty(m.Context?.Sku));
 795
 0796                                        if (messageWithOrder != null)
 797                                        {
 0798                                            var orderNumber = messageWithOrder.Context!.OrderNumber;
 0799                                            var sku = messageWithOrder.Context!.Sku;
 800
 0801                                            var ordersToLink = new List<Order>();
 802
 803                                            // Сначала пытаемся найти заказ по номеру отправления
 0804                                            if (!string.IsNullOrEmpty(orderNumber))
 805                                            {
 0806                                                var order = await orderRepository.GetOrderByShipmentNumberAsync(
 0807                                                    orderNumber!, client.Id);
 0808                                                if (order != null)
 0809                                                    ordersToLink.Add(order);
 810                                            }
 811
 812                                            // Если не нашли по номеру, пытаемся найти по SKU
 0813                                            if (!ordersToLink.Any() && !string.IsNullOrEmpty(sku))
 814                                            {
 0815                                                var ordersBySku =
 0816                                                    await orderRepository.GetOrdersBySkuAsync(sku!, client.Id);
 0817                                                ordersToLink.AddRange(ordersBySku);
 818                                            }
 819
 0820                                            if (ordersToLink.Any())
 821                                            {
 0822                                                foreach (var order in ordersToLink)
 823                                                {
 0824                                                    var postingNumber = !string.IsNullOrEmpty(orderNumber)
 0825                                                        ? orderNumber
 0826                                                        : order.ShipmentNumber;
 827
 0828                                                    await chatOrderService.CreateOrUpdateAsync(
 0829                                                        chatId: chatItem.Chat.ChatId,
 0830                                                        orderId: order.Id,
 0831                                                        clientId: client.Id,
 0832                                                        postingNumber: postingNumber,
 0833                                                        unreadCount: chatItem.UnreadCount,
 0834                                                        chatStatus: chatItem.Chat.ChatStatus,
 0835                                                        chatType: chatItem.Chat.ChatType
 0836                                                    );
 837
 838                                                    // Обновляем информацию о последнем сообщении
 0839                                                    var lastMessage = history.Messages.First();
 0840                                                    var messageText = lastMessage.Data != null && lastMessage.Data.Any()
 0841                                                        ? string.Join(" ", lastMessage.Data)
 0842                                                        : string.Empty;
 843
 0844                                                    var lastMessageFromCustomer = lastMessage.User?.Type?
 0845                                                        .Equals("Customer", StringComparison.OrdinalIgnoreCase) == true;
 846
 0847                                                    await chatOrderService.UpdateLastMessageAsync(
 0848                                                        chatId: chatItem.Chat.ChatId,
 0849                                                        orderId: order.Id,
 0850                                                        messageDate: lastMessage.CreatedAt.ToUniversalTime(),
 0851                                                        messageText: messageText,
 0852                                                        fromCustomer: lastMessageFromCustomer
 0853                                                    );
 854
 0855                                                    totalLinksCreated++;
 0856                                                    result.AppendLine(
 0857                                                        $"&nbsp;&nbsp;&nbsp;&nbsp;+ Чат {chatItem.Chat.ChatId} → заказ {
 0858                                                }
 859                                            }
 0860                                        }
 861                                    }
 0862                                }
 0863                            }
 0864                            catch (Exception ex)
 865                            {
 0866                                result.AppendLine(
 0867                                    $"&nbsp;&nbsp;&nbsp;&nbsp;Ошибка чата {chatItem.Chat.ChatId}: {ex.Message}<br>");
 0868                            }
 0869                        }
 870
 0871                        result.AppendLine("<br>");
 0872                    }
 0873                    catch (Exception ex)
 874                    {
 0875                        result.AppendLine($"&nbsp;&nbsp;Ошибка клиента {client.Name}: {ex.Message}<br><br>");
 0876                    }
 0877                }
 878
 0879                result.AppendLine($"<b>Итого:</b><br>");
 0880                result.AppendLine($"&nbsp;&nbsp;Обработано чатов: {totalChatsProcessed}<br>");
 0881                result.AppendLine($"&nbsp;&nbsp;Создано связей: {totalLinksCreated}<br>");
 0882                result.AppendLine($"&nbsp;&nbsp;Обновлено связей: {totalLinksUpdated}<br>");
 883
 0884                await NotificationService.NotifyAllAsync(
 0885                    $"Синхронизация чатов: обработано {totalChatsProcessed}, создано {totalLinksCreated}, обновлено {tot
 886
 0887                return result.ToString();
 888            }
 0889            catch (Exception ex)
 890            {
 0891                return $"Ошибка синхронизации чатов: {ex.Message}";
 892            }
 0893        }
 894
 895        /// <summary>
 896        /// Обновляет статусы заказов (ClientStatus) по логике ProcessOrdersStatusUpdate в OrdersController:
 897        /// берёт последние count заказов и получает статус из API Ozon.
 898        /// </summary>
 899        public async Task<string> UpdateCartItemsStatusAsync(int count)
 900        {
 901            try
 902            {
 0903                var orderIds = await _context.Orders
 0904                    .AsNoTracking()
 0905                    .Where(o => o.OrderArchive == null)
 0906                    .OrderByDescending(o => o.ProcessingDate)
 0907                    .Take(count)
 0908                    .Select(o => o.Id)
 0909                    .ToListAsync();
 910
 0911                if (orderIds == null || !orderIds.Any())
 0912                    return "<p>Ошибка: Нет доступных заказов для обновления</p>";
 913
 914                // Загрузка с трэкингом для последующего обновления и SaveChanges
 0915                var ordersToUpdate =
 0916                    await _orderDataServices.GetOrdersByIdsTracked(orderIds, includeArchived: false);
 917
 0918                return await UpdateClientStatusesForOrdersAsync(ordersToUpdate, "за последние заказы");
 919            }
 0920            catch (Exception ex)
 921            {
 0922                return $"<p>Ошибка при обновлении статусов заказов: {ex.Message}</p>";
 923            }
 0924        }
 925
 926        /// <summary>
 927        /// POST из UI: ставит в очередь фоновую задачу обновления статусов по ProcessingDate.
 928        /// </summary>
 929        [HttpPost]
 930        public IActionResult UpdateStatusesByProcessingDate(DateTime from, DateTime to)
 931        {
 0932            var api = JobStorage.Current.GetMonitoringApi();
 0933            TryGetPeriodStatusUpdateState(api, out var state, out var existingJobId, out _);
 0934            if (state == "processing")
 935            {
 0936                TempData["Message"] = $"Обновление статусов по периоду уже выполняется (JobId: {existingJobId}).";
 0937                return RedirectToAction(nameof(Index), new { pageTab = "schedules" });
 938            }
 939
 940            // Если в очереди уже есть задача — не блокируем запуск.
 941            // Чтобы избежать дубликатов, пробуем удалить предыдущую enqueued-задачу.
 0942            if (state == "enqueued" && !string.IsNullOrWhiteSpace(existingJobId))
 943            {
 944                try
 945                {
 0946                    BackgroundJob.Delete(existingJobId);
 0947                }
 0948                catch
 949                {
 950                    /* ignore */
 0951                }
 952            }
 953
 0954            HangfireJobClientExtensions.EnqueueMain(() => UpdateStatusesByProcessingDateBackground(from, to, null));
 0955            TempData["Message"] =
 0956                $"Задача обновления статусов по периоду {from:dd.MM.yyyy} - {to:dd.MM.yyyy} поставлена в очередь.";
 0957            return RedirectToAction(nameof(Index));
 958        }
 959
 960        /// <summary>
 961        /// Запуск обновления статусов по периоду с передачей прогресса в SignalR (jobId).
 962        /// Возвращает JSON для вызова из модального окна.
 963        /// </summary>
 964        [HttpPost]
 965        public IActionResult StartStatusUpdateByPeriod(DateTime from, DateTime to, [FromForm] string jobId)
 966        {
 0967            if (string.IsNullOrWhiteSpace(jobId))
 0968                return BadRequest(new { error = "jobId обязателен" });
 969
 0970            var api = JobStorage.Current.GetMonitoringApi();
 0971            TryGetPeriodStatusUpdateState(api, out var state, out var existingJobId, out var startedAtUtc);
 0972            if (state == "processing")
 973            {
 0974                var msg =
 0975                    $"Уже выполняется обновление статусов по периоду (JobId: {existingJobId}, старт: {startedAtUtc?.ToLo
 0976                return StatusCode(409, new { success = false, state, jobId = existingJobId, message = msg });
 977            }
 978
 979            // Разрешаем повторный запуск, даже если задача уже enqueued.
 980            // Чтобы не плодить дубликаты, пробуем удалить предыдущую enqueued-задачу.
 0981            if (state == "enqueued" && !string.IsNullOrWhiteSpace(existingJobId))
 982            {
 983                try
 984                {
 0985                    BackgroundJob.Delete(existingJobId);
 0986                }
 0987                catch
 988                {
 989                    /* ignore */
 0990                }
 991            }
 992
 0993            HangfireJobClientExtensions.EnqueueMain(() => UpdateStatusesByProcessingDateBackground(from, to, jobId.Trim(
 0994            return Json(new { success = true, jobId = jobId.Trim() });
 995        }
 996
 997        /// <summary>
 998        /// Фоновая задача: обновляет статусы заказов (ClientStatus) для всех заказов
 999        /// в указанном периоде по ProcessingDate (включительно).
 1000        /// Если передан jobId — прогресс и логи отправляются в SignalR-группу jobId.
 1001        /// </summary>
 1002        [Queue(HangfireQueueNames.Main)]
 1003        public async Task<string> UpdateStatusesByProcessingDateBackground(DateTime from, DateTime to,
 1004            string? jobId = null)
 1005        {
 01006            var fromDate = from.Date;
 01007            var toDate = to.Date.AddDays(1).AddTicks(-1);
 01008            var hubJobId = jobId?.Trim();
 01009            var hasHub = !string.IsNullOrEmpty(hubJobId);
 01010            IStorageConnection? lockConnection = null;
 01011            IDisposable? distLock = null;
 1012
 1013            async Task SendLog(string message)
 1014            {
 01015                if (hasHub)
 01016                    await _jobProgressHub.Clients.Group(hubJobId!).SendAsync("Log", message);
 01017            }
 1018
 1019            async Task SendProgress(int current, int total, string? statusText = null)
 1020            {
 01021                if (hasHub)
 01022                    await _jobProgressHub.Clients.Group(hubJobId!).SendAsync("Progress", current, total, statusText ?? "
 01023            }
 1024
 1025            async Task SendComplete(bool success, string report)
 1026            {
 01027                if (hasHub)
 01028                    await _jobProgressHub.Clients.Group(hubJobId!).SendAsync("Complete", success, report ?? "");
 01029            }
 1030
 1031            async Task SendError(string message)
 1032            {
 01033                if (hasHub)
 01034                    await _jobProgressHub.Clients.Group(hubJobId!).SendAsync("Error", message);
 01035            }
 1036
 1037            try
 1038            {
 1039                // Глобальная защита от параллельного выполнения (на уровне Hangfire storage)
 1040                try
 1041                {
 01042                    lockConnection = JobStorage.Current.GetConnection();
 01043                    distLock = lockConnection.AcquireDistributedLock(PeriodStatusUpdateLockKey,
 01044                        TimeSpan.FromMilliseconds(1));
 01045                }
 01046                catch (DistributedLockTimeoutException)
 1047                {
 01048                    var msg = "Обновление статусов по периоду уже выполняется. Повторный запуск отменён.";
 01049                    await SendLog(msg);
 01050                    await SendComplete(false, msg);
 01051                    return msg;
 1052                }
 1053
 01054                await SendLog($"Период: {fromDate:dd.MM.yyyy} — {toDate:dd.MM.yyyy}. Поиск заказов...");
 1055
 01056                var orderIds = await _context.Orders
 01057                    .AsNoTracking()
 01058                    .Where(o => o.OrderArchive == null &&
 01059                                o.ProcessingDate >= fromDate &&
 01060                                o.ProcessingDate <= toDate)
 01061                    .OrderBy(o => o.ProcessingDate)
 01062                    .Select(o => o.Id)
 01063                    .ToListAsync();
 1064
 01065                if (orderIds == null || !orderIds.Any())
 1066                {
 01067                    var msg = "Нет заказов для обновления статусов в указанном периоде.";
 01068                    await SendLog(msg);
 01069                    await SendComplete(false, msg);
 01070                    return msg;
 1071                }
 1072
 01073                await SendLog($"Найдено заказов: {orderIds.Count}. Загрузка данных...");
 01074                await SendProgress(0, orderIds.Count, "Загрузка заказов...");
 1075
 01076                var ordersToUpdate =
 01077                    await _orderDataServices.GetOrdersByIdsTracked(orderIds, includeArchived: false);
 1078
 01079                await SendLog($"Загружено {ordersToUpdate.Count} заказов. Обновление статусов через API...");
 1080
 01081                var report = await UpdateClientStatusesForOrdersAsync(
 01082                    ordersToUpdate,
 01083                    $"за период {fromDate:dd.MM.yyyy} - {toDate:dd.MM.yyyy}",
 01084                    sendLog: hasHub ? (async (msg) => await SendLog(msg)) : null,
 01085                    sendProgress: hasHub ? (async (cur, tot, text) => await SendProgress(cur, tot, text)) : null);
 1086
 01087                await SendProgress(orderIds.Count, orderIds.Count, "Готово");
 01088                await SendComplete(true, report);
 01089                return report;
 1090            }
 01091            catch (Exception ex)
 1092            {
 01093                var errMsg = $"Ошибка: {ex.Message}";
 01094                await SendError(errMsg);
 01095                return errMsg;
 1096            }
 1097            finally
 1098            {
 01099                HangfireDistributedLockHelper.SafeDispose(ref distLock, ref lockConnection);
 1100            }
 01101        }
 1102
 1103        /// <summary>
 1104        /// Общая реализация обновления ClientStatus для набора заказов Ozon с батчевой обработкой.
 1105        /// </summary>
 1106        private async Task<string> UpdateClientStatusesForOrdersAsync(
 1107            List<Order> ordersToUpdate,
 1108            string contextInfo,
 1109            Func<string, Task>? sendLog = null,
 1110            Func<int, int, string, Task>? sendProgress = null)
 1111        {
 1112            static async Task SafeSendLog(Func<string, Task>? logFn, string message)
 1113            {
 01114                if (logFn == null) return;
 1115                try
 1116                {
 01117                    var t = logFn(message);
 01118                    if (t != null) await t;
 01119                }
 01120                catch
 1121                {
 1122                    // ignore logging errors
 01123                }
 01124            }
 1125
 1126            static async Task SafeSendProgress(Func<int, int, string, Task>? progressFn, int current, int total,
 1127                string text)
 1128            {
 01129                if (progressFn == null) return;
 1130                try
 1131                {
 01132                    var t = progressFn(current, total, text);
 01133                    if (t != null) await t;
 01134                }
 01135                catch
 1136                {
 1137                    // ignore progress errors
 01138                }
 01139            }
 1140
 01141            if (ordersToUpdate == null || ordersToUpdate.Count == 0)
 01142                return "<p>Ошибка: Нет доступных заказов для обновления</p>";
 1143
 1144            const int batchSize = 10; // Размер батча
 01145            int totalUpdated = 0;
 01146            var updateInfoBuilder = new StringBuilder();
 01147            var total = ordersToUpdate.Count;
 01148            var processedCount = 0;
 1149
 01150            await SafeSendLog(sendLog, $"Начало обработки {total} заказов, размер батча: {batchSize}");
 1151
 1152            // Чтобы в логах не было "тишины" при отсутствии изменений
 01153            var heartbeat = System.Diagnostics.Stopwatch.StartNew();
 01154            var lastProgress = System.Diagnostics.Stopwatch.StartNew();
 1155
 1156            // Обрабатываем заказы батчами
 01157            for (int batchStart = 0; batchStart < total; batchStart += batchSize)
 1158            {
 01159                var batch = ordersToUpdate
 01160                    .Skip(batchStart)
 01161                    .Take(batchSize)
 01162                    .ToList();
 1163
 01164                var batchUpdates = new List<(Order Order, ClientStatus NewStatus)>();
 01165                var batchLogs = new StringBuilder();
 01166                var batchNumber = batchStart / batchSize + 1;
 1167
 01168                await SafeSendLog(sendLog,
 01169                    $"▶️ Батч {batchNumber}: заказы {batchStart + 1}-{Math.Min(batchStart + batchSize, total)} из {total
 1170
 1171                // Шаг 1: Получаем статусы для всех заказов в батче
 01172                foreach (var order in batch)
 1173                {
 01174                    processedCount++;
 1175
 1176                    // Throttle прогресса, чтобы не забивать SignalR и не стопорить обработку
 01177                    if (sendProgress != null && (processedCount == 1 || processedCount % 10 == 0 ||
 01178                                                 lastProgress.ElapsedMilliseconds >= 1000))
 1179                    {
 01180                        lastProgress.Restart();
 01181                        _ = SafeSendProgress(sendProgress, processedCount, total,
 01182                            $"Обработка {order.ShipmentNumber}...");
 1183                    }
 1184
 1185                    // Heartbeat в лог (если долго нет сообщений об изменениях)
 01186                    if (heartbeat.ElapsedMilliseconds >= 5000)
 1187                    {
 01188                        heartbeat.Restart();
 01189                        await SafeSendLog(sendLog, $"… обработано {processedCount}/{total}");
 1190                    }
 1191
 1192                    try
 1193                    {
 01194                        order.OzonClient = await _ozonClientServices.GetOzonClientAsync(order.OzonClient?.Id ?? 0);
 1195
 01196                        if (order.OzonClient == null)
 1197                        {
 01198                            await SafeSendLog(sendLog, $"Пропуск {order.ShipmentNumber}: клиент не найден");
 01199                            continue;
 1200                        }
 1201
 01202                        ClientStatus? newClientStatus = null;
 01203                        string? newStatusName = null;
 1204
 01205                        if (order.OzonClient.ClientType == ClientType.OZON)
 1206                        {
 01207                            if (string.IsNullOrEmpty(order.OzonClient.DecryptClientId) ||
 01208                                string.IsNullOrEmpty(order.OzonClient.DecryptApiKey) ||
 01209                                string.IsNullOrEmpty(order.ShipmentNumber))
 01210                                continue;
 1211
 01212                            _jsonDataBuilder.SetClient(order.OzonClient.DecryptClientId,
 01213                                order.OzonClient.DecryptApiKey);
 1214
 01215                            string newStatus = await _jsonDataBuilder.GetProductSatatus(order.ShipmentNumber);
 01216                            if (string.IsNullOrEmpty(newStatus))
 01217                                continue;
 1218
 01219                            newStatusName = OzonStatus.OrderStatuses.TryGetValue(newStatus, out var name)
 01220                                ? name
 01221                                : newStatus;
 1222                        }
 01223                        if (string.IsNullOrEmpty(newStatusName))
 01224                            continue;
 1225
 1226                        // Проверяем, изменился ли статус
 01227                        if (order.ClientStatus?.Name?.Equals(newStatusName, StringComparison.OrdinalIgnoreCase) == true)
 01228                            continue;
 1229
 1230                        // Получаем или создаем статус
 01231                        newClientStatus = await _clientStatusServices.GetClientStatusByNameAsync(newStatusName);
 01232                        if (newClientStatus == null)
 1233                        {
 01234                            newClientStatus = new ClientStatus { Name = newStatusName };
 01235                            await _clientStatusServices.AddClientStatus(newClientStatus);
 1236                            // Сохраняем новый статус сразу, чтобы получить ID
 01237                            await _orderDataServices.SaveChangesAsync();
 1238                        }
 1239
 01240                        batchUpdates.Add((order, newClientStatus));
 1241
 01242                        var logLine = $"• {order.ShipmentNumber}: {order.ClientStatus?.Name ?? ""} → {newStatusName}";
 01243                        batchLogs.AppendLine(
 01244                            $"<b>{order.ShipmentNumber}</b>: {order.ClientStatus?.Name ?? ""} → {newStatusName}");
 01245                        await SafeSendLog(sendLog, logLine);
 01246                    }
 01247                    catch (Exception ex)
 1248                    {
 01249                        await SafeSendLog(sendLog, $"Ошибка при обработке {order.ShipmentNumber}: {ex.Message}");
 1250                        // Продолжаем обработку следующих заказов
 1251                    }
 01252                }
 1253
 1254                // Шаг 2: Применяем изменения в батче
 01255                if (batchUpdates.Any())
 1256                {
 1257                    // MySqlRetryingExecutionStrategy не поддерживает "ручные" транзакции,
 1258                    // если они не выполняются внутри execution strategy.
 01259                    var strategy = _context.Database.CreateExecutionStrategy();
 01260                    await strategy.ExecuteAsync(async () =>
 01261                    {
 01262                        await using var transaction = await _context.Database.BeginTransactionAsync();
 01263                        try
 01264                        {
 01265                            // Обновляем статусы
 01266                            foreach (var (order, newStatus) in batchUpdates)
 01267                            {
 01268                                order.ClientStatus = newStatus;
 01269                                order.ClientStatusId = newStatus.Id;
 01270                                // Навигация ClientStatus — это reference, не scalar property.
 01271                                // Для сохранения достаточно обновить FK.
 01272                                _context.Entry(order).Property(o => o.ClientStatusId).IsModified = true;
 01273                            }
 01274
 01275                            // Сохраняем батч
 01276                            var saved = await _context.SaveChangesAsync();
 01277                            totalUpdated += saved;
 01278
 01279                            await transaction.CommitAsync();
 01280
 01281                            updateInfoBuilder.Append(batchLogs.ToString());
 01282                            await SafeSendLog(sendLog,
 01283                                $"✅ Батч {batchNumber}: изменений {batchUpdates.Count}, сохранено {saved}");
 01284                        }
 01285                        catch (Exception ex)
 01286                        {
 01287                            try
 01288                            {
 01289                                if (_context.Database.GetDbConnection().State == System.Data.ConnectionState.Open)
 01290                                {
 01291                                    await transaction.RollbackAsync();
 01292                                }
 01293                            }
 01294                            catch
 01295                            {
 01296                                // Игнорируем ошибку отката при разорванном соединении
 01297                            }
 01298
 01299                            await SafeSendLog(sendLog, $"❌ Ошибка при сохранении батча {batchNumber}: {ex.Message}");
 01300                            throw; // Пробрасываем исключение для повторной попытки
 01301                        }
 01302                    });
 1303                }
 1304                else
 1305                {
 01306                    await SafeSendLog(sendLog, $"✅ Батч {batchNumber}: изменений нет");
 1307                }
 1308
 1309                // Небольшая задержка между батчами для снижения нагрузки
 01310                if (batchStart + batchSize < total)
 1311                {
 01312                    await Task.Delay(100);
 1313                }
 01314            }
 1315
 1316            // Обновляем кэш после всех батчей
 01317            await _cache.Update();
 01318            await _postingCancellationService.LinkOrdersAsync(ordersToUpdate);
 1319
 1320            // Формируем отчет
 01321            var report = new StringBuilder();
 01322            report.Append("<h4>=== Отчет об обновлении статусов заказов ===</h4>");
 01323            report.Append(totalUpdated > 0
 01324                ? $"<p>Обновлено <b>{totalUpdated}</b> статусов ({contextInfo}).</p><p>{updateInfoBuilder}</p>"
 01325                : "<p>Нет изменений в статусах заказов.</p>");
 01326            report.Append($"<p>Время обработки: {DateTime.Now:yyyy-MM-dd HH:mm:ss}</p>");
 1327
 01328            await NotificationService.NotifyAllAsync(totalUpdated > 0
 01329                ? $"Обновлено {totalUpdated} статусов заказов ({contextInfo})"
 01330                : "Нет изменений в статусах заказов");
 1331
 01332            return report.ToString();
 01333        }
 1334    }
 1335}

Methods/Properties

.ctor(Services.DataServices.OrdersDataServices,Services.ParserServices.OrderCaster,Services.ApiServices.OzonApi.OzonJsonDataBuilder,Services.ReleasServices.ReleaseManager.ReleaseManager,Services.DataServices.DuplicateOrdersServices,Services.DataServices.OzonClientServices,Services.ApiServices.YandexApi.YandexDataManager,Services.YandexServices.IYandexOrderStatusSyncService,OzonRepositories.Context.ApplicationDbContext,Services.DataServices.OrdersDataServices,OzonRepositories.Data.OrderItemRepository,Services.DataServices.ClientStatusDataServices,Services.CacheServices.Cache.OzonOrdersCache.OrderCache,Microsoft.AspNetCore.SignalR.IHubContext`1<Services.SignalRServices.JobProgressHub>,Services.HangfireService.IHangfireService,System.Net.Http.IHttpClientFactory,Microsoft.Extensions.DependencyInjection.IServiceScopeFactory,Services.OzonWebhookServices.IOzonPostingCancellationService)
Index(System.String,System.String,System.String)
TryGetPeriodStatusUpdateState(Hangfire.Storage.IMonitoringApi,System.String&,System.String&,System.Nullable`1<System.DateTime>&)
BuildScheduleStatuses()
Map()
SetOzonRecurring(System.Int32)
SetYandexRecurring(System.Int32)
SetEverydayUpdate()
SetLoadReturnsRecurring(System.Int32,System.Int32)
SetStatusUpdateRecurring(System.Int32,System.Int32)
EverydayUpdate()
UploadingOzon()
ProcessOrdersForStatusUpdate()
ProcessOrder()
UploadingYandex()
GetJobResultString(System.Collections.Generic.Dictionary`2<System.String,System.Int32[]>,System.DateTime,System.DateTime,System.String,System.Nullable`1<System.Int32>,System.Nullable`1<System.Int32>)
GetDateRanges(System.Int32)
SetChatSyncRecurring(System.Int32)
SyncOzonChatOrders()
UpdateCartItemsStatusAsync()
UpdateStatusesByProcessingDate(System.DateTime,System.DateTime)
StartStatusUpdateByPeriod(System.DateTime,System.DateTime,System.String)
UpdateStatusesByProcessingDateBackground()
SendLog()
SendProgress()
SendComplete()
SendError()
<UpdateStatusesByProcessingDateBackground()
<UpdateStatusesByProcessingDateBackground()
SafeSendLog()
SafeSendProgress()
UpdateClientStatusesForOrdersAsync()
<UpdateClientStatusesForOrdersAsync()