< Summary

Information
Class: Services.OzonReturnsServices.OzonReturnsSyncService
Assembly: Services
File(s): /home/runner/work/Studio2Prod/Studio2Prod/AppRepository/OzonReturnsServices/OzonReturnsSyncService.cs
Line coverage
7%
Covered lines: 13
Uncovered lines: 157
Coverable lines: 170
Total lines: 364
Line coverage: 7.6%
Branch coverage
8%
Covered branches: 6
Total branches: 72
Branch coverage: 8.3%
Method coverage

Feature is only available for sponsors

Upgrade to PRO version

Metrics

MethodBranch coverage Crap Score Cyclomatic complexity Line coverage
.ctor(...)100%210%
LoadAsync()0%3192560%
ResolveClientsAsync()0%620%
BuildRequest(...)100%66100%
NotifyRunAsync()0%2040%
NotifyAlertAsync()100%210%

File(s)

/home/runner/work/Studio2Prod/Studio2Prod/AppRepository/OzonReturnsServices/OzonReturnsSyncService.cs

#LineLine coverage
 1using Hangfire;
 2using Hangfire.Storage;
 3using Microsoft.AspNetCore.SignalR;
 4using Microsoft.EntityFrameworkCore;
 5using Microsoft.Extensions.Hosting;
 6using OzonDomains;
 7using OzonDomains.Models;
 8using Services.ApiServices.OzonApi;
 9using Services.ApiServices.OzonApi.Filters;
 10using Services.DataServices;
 11using Services.SignalRServices;
 12using SlqStudio.Application.Services.EmailService;
 13
 14namespace Services.OzonReturnsServices;
 15
 16/// <summary>
 17/// Загрузка возвратов Ozon (/v1/returns/list) в БД: пагинация по клиентам, upsert, связь с заказами,
 18/// уведомления. Единственная реализация — её используют и recurring job, и ручной запуск со страницы
 19/// /Studio2/OrderReturns.
 20/// </summary>
 21public interface IOzonReturnsSyncService
 22{
 23    /// <param name="ozonClientId">Идентификатор клиента Ozon или 0 — все клиенты с типом OZON.</param>
 24    /// <param name="from">Начало периода (нижняя граница страховочного окна).</param>
 25    /// <param name="to">Конец периода.</param>
 26    /// <param name="postingNumbers">Опциональный фильтр по номерам отправлений (не более 50).</param>
 27    /// <param name="incremental">
 28    /// true — нижняя граница периода для каждого клиента поднимается до последнего загруженного
 29    /// возврата минус перехлёст, не опускаясь ниже <paramref name="from"/>. Режим частого
 30    /// recurring-запуска. false — читать весь период целиком.
 31    /// </param>
 32    /// <param name="progress">Канал прогресса; для запусков по расписанию — <see cref="NullReturnsLoadProgress"/>.</par
 33    /// <param name="triggerType">Что запустило прогон — попадает в историю (<see cref="OrderReturnSyncTrigger"/>).</par
 34    Task<ReturnsLoadOutcome> LoadAsync(
 35        int ozonClientId,
 36        DateTime from,
 37        DateTime to,
 38        string[]? postingNumbers = null,
 39        bool incremental = false,
 40        IReturnsLoadProgress? progress = null,
 41        string? triggerType = null);
 42}
 43
 44public sealed class OzonReturnsSyncService : IOzonReturnsSyncService
 45{
 46    /// <summary>Общий на всё приложение лок: две загрузки возвратов одновременно не нужны.</summary>
 47    public const string LockKey = "OrderReturns:LoadReturns";
 48
 49    /// <summary>
 50    /// Предохранитель пагинации: страница до 500 записей, то есть лимит — четверть миллиона
 51    /// возвратов на клиента за прогон. Реальные объёмы на порядки меньше.
 52    /// </summary>
 53    private const int MaxBatchesPerClient = 500;
 54
 55    private const int PageLimit = 500;
 56
 57    /// <summary>Источник и оформление тоста возвратов в общей системе алертов.</summary>
 58    private const string AlertSource = "Возвраты Ozon";
 59    private const string AlertIcon = "fa-rotate-left";
 60    private const string AlertIconError = "fa-triangle-exclamation";
 61    private const string AlertColorOk = "#0EA5E9";
 62    private const string AlertColorWarn = "#D97706";
 63    private const string AlertColorError = "#EF4444";
 64
 65    private readonly OrderReturnDataServices _orderReturnDataServices;
 66    private readonly OzonClientServices _ozonClientServices;
 67    private readonly IHttpClientFactory _httpClientFactory;
 68    private readonly IEmailService? _emailService;
 69    private readonly IHostApplicationLifetime _appLifetime;
 70    private readonly IOrderReturnSyncRunStore _runStore;
 71
 072    public OzonReturnsSyncService(
 073        OrderReturnDataServices orderReturnDataServices,
 074        OzonClientServices ozonClientServices,
 075        IHttpClientFactory httpClientFactory,
 076        IEmailService? emailService,
 077        IHostApplicationLifetime appLifetime,
 078        IOrderReturnSyncRunStore runStore)
 79    {
 080        _orderReturnDataServices = orderReturnDataServices;
 081        _ozonClientServices = ozonClientServices;
 082        _httpClientFactory = httpClientFactory;
 083        _emailService = emailService;
 084        _appLifetime = appLifetime;
 085        _runStore = runStore;
 086    }
 87
 88    public async Task<ReturnsLoadOutcome> LoadAsync(
 89        int ozonClientId,
 90        DateTime from,
 91        DateTime to,
 92        string[]? postingNumbers = null,
 93        bool incremental = false,
 94        IReturnsLoadProgress? progress = null,
 95        string? triggerType = null)
 96    {
 097        var report = progress ?? NullReturnsLoadProgress.Instance;
 098        var trigger = triggerType ?? (incremental ? OrderReturnSyncTrigger.Incremental : OrderReturnSyncTrigger.FullWind
 99
 0100        IStorageConnection? lockConnection = null;
 0101        IDisposable? distLock = null;
 0102        long? runId = null;
 103
 104        try
 105        {
 106            try
 107            {
 0108                lockConnection = JobStorage.Current.GetConnection();
 0109                distLock = lockConnection.AcquireDistributedLock(LockKey, TimeSpan.FromMilliseconds(1));
 0110            }
 111            catch (DistributedLockTimeoutException)
 112            {
 113                // Штатный пропуск тика, а не сбой: предыдущий прогон ещё идёт.
 0114                var msg = "Загрузка возвратов уже выполняется. Повторный запуск отменён.";
 0115                await report.LogAsync(msg);
 0116                await report.CompleteAsync(false, msg);
 0117                return ReturnsLoadOutcome.Success(msg);
 118            }
 119
 0120            runId = await _runStore.StartAsync(trigger, ozonClientId, from.Date, to.Date, incremental);
 121
 0122            var clientsToLoad = await ResolveClientsAsync(ozonClientId);
 0123            if (clientsToLoad.Count == 0)
 124            {
 125                // Не сбой прогона, а незаполненная настройка: ронять джоб каждые N минут смысла нет.
 0126                var msg = "Нет клиентов Ozon с настроенными учётными данными.";
 0127                await report.LogAsync(msg);
 0128                await report.CompleteAsync(false, msg);
 0129                await _runStore.FinishAsync(runId, OrderReturnSyncRunStatus.Skipped, null, msg);
 0130                return ReturnsLoadOutcome.Success(msg);
 131            }
 132
 133            // Локальные значения — для логов, отчётов и истории: пользователь задавал именно их.
 0134            var fromLocal = from.Date;
 0135            var toLocal = to.Date.AddDays(1).AddTicks(-1);
 136            // UTC — для фильтра Ozon и для сравнения с watermark, который тоже в UTC.
 0137            var fromUtc = ReturnsIncrementalWindow.LocalToUtc(fromLocal);
 0138            var toUtc = ReturnsIncrementalWindow.LocalToUtc(toLocal);
 139
 0140            var totals = new ClientLoadTotals();
 0141            var clientTotals = new Dictionary<string, ClientLoadTotals>();
 0142            var errors = new List<string>();
 143
 0144            foreach (var client in clientsToLoad)
 145            {
 0146                var batchNumber = 0;
 147
 148                try
 149                {
 0150                    var clientFromUtc = incremental
 0151                        ? ReturnsIncrementalWindow.Resolve(
 0152                            await _orderReturnDataServices.GetLastSyncedStatusMomentAsync(client.Id), fromUtc)
 0153                        : fromUtc;
 154
 0155                    await report.LogAsync(incremental
 0156                        ? $"Период: {clientFromUtc.ToLocalTime():dd.MM.yyyy HH:mm} — {toLocal:dd.MM.yyyy HH:mm} (инкреме
 0157                        : $"Период: {fromLocal:dd.MM.yyyy} — {toLocal:dd.MM.yyyy}. Клиент: {client.Name}");
 0158                    await report.ProgressAsync(totals.Processed, totals.Processed, $"Загрузка для {client.Name}...");
 159
 0160                    var apiManager = new OzonApiDataManager(_httpClientFactory, _emailService,
 0161                        client.DecryptClientId!, client.DecryptApiKey!);
 162
 0163                    long? lastId = null;
 164
 0165                    while (true)
 166                    {
 167                        // Прогон за полный период занимает минуты; при остановке приложения нет
 168                        // смысла дочитывать выдачу — задача всё равно будет снята при старте.
 0169                        if (_appLifetime.ApplicationStopping.IsCancellationRequested)
 170                        {
 0171                            await report.LogAsync($"Клиент {client.Name}: остановка приложения, загрузка прервана.");
 0172                            break;
 173                        }
 174
 0175                        if (batchNumber >= MaxBatchesPerClient)
 176                        {
 0177                            errors.Add($"Клиент {client.Name}: достигнут лимит {MaxBatchesPerClient} батчей, загрузка пр
 0178                            break;
 179                        }
 180
 0181                        batchNumber++;
 182
 0183                        var response = await apiManager.GetReturnsListAsync(
 0184                            BuildRequest(clientFromUtc, toUtc, postingNumbers, lastId));
 185
 0186                        if (response.Returns == null || response.Returns.Count == 0)
 187                        {
 0188                            if (batchNumber == 1)
 0189                                await report.LogAsync($"Клиент {client.Name}: возвратов не найдено.");
 0190                            break;
 191                        }
 192
 0193                        var sync = await _orderReturnDataServices.SyncFromApiResponseAsync(response, client.Id);
 194
 0195                        totals = totals.Add(sync.Processed, sync.Inserted, sync.StatusChanged, sync.LinkedOrders);
 0196                        clientTotals.TryGetValue(client.Name, out var prev);
 0197                        clientTotals[client.Name] =
 0198                            prev.Add(sync.Processed, sync.Inserted, sync.StatusChanged, sync.LinkedOrders);
 199
 0200                        await report.LogAsync(
 0201                            $"Клиент {client.Name}, батч {batchNumber}: получено {response.Returns.Count}, " +
 0202                            $"новых {sync.Inserted}, со сменой статуса {sync.StatusChanged}");
 0203                        await report.ProgressAsync(totals.Processed, totals.Processed,
 0204                            $"Обработано {totals.Processed} возвратов");
 205
 0206                        if (!response.HasNext)
 207                            break;
 208
 0209                        var nextLastId = response.Returns[^1].Id;
 0210                        if (nextLastId == lastId)
 211                        {
 212                            // Ozon отдал ту же страницу с has_next=true: без этой проверки цикл
 213                            // крутится бесконечно, удерживая распределённый лок до InvisibilityTimeout.
 0214                            errors.Add($"Клиент {client.Name}: пагинация не продвигается (last_id={nextLastId}), загрузк
 0215                            break;
 216                        }
 217
 0218                        lastId = nextLastId;
 0219                    }
 0220                }
 0221                catch (Exception ex)
 222                {
 223                    // Сбой одного клиента не отменяет загрузку остальных: очищаем ChangeTracker,
 224                    // чтобы неотправленные сущности не ломали последующие клиенты и батчи.
 0225                    _orderReturnDataServices.ClearChangeTracker();
 226
 0227                    var detailedMsg = ex is DbUpdateException dbEx && dbEx.InnerException != null
 0228                        ? $"{dbEx.Message} ({dbEx.InnerException.Message})"
 0229                        : ex.Message;
 0230                    var errMsg = $"Клиент {client.Name}, батч {batchNumber}: {detailedMsg}";
 0231                    errors.Add(errMsg);
 0232                    await report.LogAsync($"Ошибка. {errMsg}");
 233                }
 0234            }
 235
 236            // Только на полной сверке по всем клиентам: приводим IsReturned в соответствие с
 237            // наличием записей в OrderReturns. На инкрементальном тике это лишний проход по заказам.
 0238            if (!incremental && ozonClientId == 0)
 239            {
 240                try
 241                {
 0242                    var (cleared, set) = await _orderReturnDataServices.ReconcileIsReturnedFlagAsync();
 0243                    if (cleared > 0 || set > 0)
 0244                        await report.LogAsync($"Признак возврата: снят с {cleared} заказов, выставлен для {set}.");
 0245                }
 0246                catch (Exception ex)
 247                {
 0248                    errors.Add($"Сверка признака возврата: {ex.Message}");
 0249                }
 250            }
 251
 0252            var summary = new ReturnsRunSummary
 0253            {
 0254                Processed = totals.Processed,
 0255                Inserted = totals.Inserted,
 0256                StatusChanged = totals.StatusChanged,
 0257                Linked = totals.Linked,
 0258                Errors = errors,
 0259                ClientTotals = clientTotals,
 0260                FromDate = fromLocal,
 0261                ToDate = toLocal,
 0262                Incremental = incremental
 0263            };
 264
 265            // Инкрементальный джоб тикает раз в несколько минут и почти всегда не находит изменений.
 266            // Сообщать не о чем: шлём отчёт только когда что-то реально изменилось, когда были
 267            // ошибки или когда загрузку запустил человек и ждёт ответа.
 0268            if (summary.HasErrors || summary.HasChanges || progress != null)
 0269                await NotifyRunAsync(summary);
 270
 0271            var reportText = summary.BuildReport();
 0272            await _runStore.FinishAsync(
 0273                runId,
 0274                summary.HasErrors ? OrderReturnSyncRunStatus.PartialSuccess : OrderReturnSyncRunStatus.Success,
 0275                summary);
 276
 0277            await report.ProgressAsync(totals.Processed, totals.Processed, "Готово");
 0278            await report.CompleteAsync(!summary.HasErrors, reportText);
 279
 0280            return summary.HasErrors
 0281                ? ReturnsLoadOutcome.Failure(reportText, errors)
 0282                : ReturnsLoadOutcome.Success(reportText);
 283        }
 284        catch (Exception ex)
 285        {
 0286            var errMsg = $"Ошибка: {ex.Message}";
 0287            await report.ErrorAsync(errMsg);
 0288            await report.CompleteAsync(false, errMsg);
 0289            await _runStore.FinishAsync(runId, OrderReturnSyncRunStatus.Failed, null, ex.ToString());
 0290            await NotifyAlertAsync($"сбой: {ex.Message}", AlertColorError, AlertIconError);
 0291            return ReturnsLoadOutcome.Failure(errMsg, [errMsg]);
 292        }
 293        finally
 294        {
 0295            try { distLock?.Dispose(); } catch { /* ignore */ }
 0296            try { lockConnection?.Dispose(); } catch { /* ignore */ }
 297        }
 0298    }
 299
 300    private async Task<List<OzonClient>> ResolveClientsAsync(int ozonClientId)
 301    {
 0302        var clients = ozonClientId > 0
 0303            ? new List<OzonClient?> { await _ozonClientServices.GetOzonClientAsync(ozonClientId) }
 0304            : (await _ozonClientServices.GetOzonClients())
 0305                .Where(c => c.ClientType == ClientType.OZON)
 0306                .Cast<OzonClient?>()
 0307                .ToList();
 308
 0309        return clients
 0310            .Where(c => c != null && !string.IsNullOrEmpty(c.DecryptClientId) && !string.IsNullOrEmpty(c.DecryptApiKey))
 0311            .Select(c => c!)
 0312            .ToList();
 0313    }
 314
 315    /// <summary>
 316    /// Ищут конкретные отправления — период не накладываем: список ограничен полусотней номеров,
 317    /// а искомый возврат почти всегда меняет статус вне выбранного окна, и запрос молча возвращал
 318    /// пустоту. В остальных случаях фильтруем по visual_status_change_moment, как и раньше.
 319    /// </summary>
 320    private static ReturnListRequest BuildRequest(DateTime fromUtc, DateTime toUtc, string[]? postingNumbers, long? last
 321    {
 4322        var byPostingNumbers = postingNumbers is { Length: > 0 };
 323
 4324        var filter = new ReturnListFilter
 4325        {
 4326            VisualStatusChangeMoment = byPostingNumbers
 4327                ? null
 4328                : new ReturnDateFilter
 4329                {
 4330                    TimeFrom = fromUtc.ToString("yyyy-MM-ddTHH:mm:ss.fffZ"),
 4331                    TimeTo = toUtc.ToString("yyyy-MM-ddTHH:mm:ss.fffZ")
 4332                },
 4333            PostingNumbers = byPostingNumbers ? postingNumbers : null
 4334        };
 335
 4336        return new ReturnListRequest { Filter = filter, Limit = PageLimit, LastId = lastId };
 337    }
 338
 339    /// <summary>
 340    /// Итог прогона: короткий тост в общей системе алертов.
 341    /// Тост держим в одну строку — как у остальных фоновых задач, чтобы он читался с ходу
 342    /// и не выталкивал соседние уведомления.
 343    /// </summary>
 344    private async Task NotifyRunAsync(ReturnsRunSummary summary)
 345    {
 346        // Частичный сбой — жёлтый: часть данных всё же загрузилась. Красный оставлен на срыв прогона.
 0347        await NotifyAlertAsync(
 0348            summary.BuildAlertText(),
 0349            summary.HasErrors ? AlertColorWarn : AlertColorOk,
 0350            summary.HasErrors ? AlertIconError : AlertIcon);
 0351    }
 352
 353    private static async Task NotifyAlertAsync(string text, string color, string icon)
 354    {
 355        try
 356        {
 0357            await NotificationService.NotifyAsync(AlertSource, color, text, icon);
 0358        }
 0359        catch
 360        {
 361            // Уведомление не должно ронять загрузку.
 0362        }
 0363    }
 364}