< Summary

Information
Class: Services.DataServices.ReturnsSyncResult
Assembly: Services
File(s): /home/runner/work/Studio2Prod/Studio2Prod/AppRepository/DataServices/OrderReturnDataServices.cs
Line coverage
0%
Covered lines: 0
Uncovered lines: 2
Coverable lines: 2
Total lines: 385
Line coverage: 0%
Branch coverage
N/A
Covered branches: 0
Total branches: 0
Branch coverage: N/A
Method coverage

Feature is only available for sponsors

Upgrade to PRO version

Metrics

MethodBranch coverage Crap Score Cyclomatic complexity Line coverage
get_Processed()100%210%
.cctor()100%210%

File(s)

/home/runner/work/Studio2Prod/Studio2Prod/AppRepository/DataServices/OrderReturnDataServices.cs

#LineLine coverage
 1using Newtonsoft.Json;
 2using OzonDomains;
 3using OzonDomains.Models;
 4using OzonRepositories.Data;
 5using Services.ApiServices.OzonApi;
 6using Services.ApiServices.OzonApi.Filters;
 7using Services.LabelServices;
 8using Services.ParserServices.HelpDictEnum;
 9
 10namespace Services.DataServices;
 11
 12/// <summary>
 13/// Сервис для работы с возвратами Ozon. Синхронизация из API, связь с заказами.
 14/// </summary>
 15public class OrderReturnDataServices
 16{
 17    private readonly OrderReturnRepository _repository;
 18    private readonly OrderRepository _orderRepository;
 19    private readonly OrdersDataServices _ordersDataServices;
 20    private readonly OzonJsonDataBuilder _jsonDataBuilder;
 21    private readonly OzonClientServices _ozonClientServices;
 22    private readonly ClientStatusDataServices _clientStatusServices;
 23
 24    /// <summary>
 25    /// Номера, которых нет среди наших заказов. В выдаче Ozon много чужих отмен, и на каждом батче
 26    /// каждого прогона они заново прогонялись через поиск по префиксу с заведомо нулевым результатом.
 27    ///
 28    /// Кэш живёт ровно столько, сколько экземпляр сервиса, то есть один прогон загрузки: заказ,
 29    /// импортированный в середине прогона, свяжется на следующем — при перехлёсте в два часа
 30    /// возврат всё равно будет перечитан.
 31    /// </summary>
 32    private readonly HashSet<string> _numbersWithoutOrders = new(StringComparer.OrdinalIgnoreCase);
 33
 34    /// <summary>Потолок на случай гигантской полной сверки — кэш не должен расти без предела.</summary>
 35    private const int MaxCachedMissingNumbers = 100_000;
 36
 37    public OrderReturnDataServices(
 38        OrderReturnRepository repository,
 39        OrderRepository orderRepository,
 40        OrdersDataServices ordersDataServices,
 41        OzonJsonDataBuilder jsonDataBuilder,
 42        OzonClientServices ozonClientServices,
 43        ClientStatusDataServices clientStatusServices)
 44    {
 45        _repository = repository;
 46        _orderRepository = orderRepository;
 47        _ordersDataServices = ordersDataServices;
 48        _jsonDataBuilder = jsonDataBuilder;
 49        _ozonClientServices = ozonClientServices;
 50        _clientStatusServices = clientStatusServices;
 51    }
 52
 53    /// <summary>
 54    /// Синхронизировать возвраты из ответа API в БД. Сопоставляет с заказами по PostingNumber.
 55    /// Использует батч-операции для устранения N+1 запросов.
 56    /// </summary>
 57    /// <param name="response">Ответ API /v1/returns/list.</param>
 58    /// <param name="ozonClientId">Идентификатор клиента Ozon в нашей системе.</param>
 59    public async Task<ReturnsSyncResult> SyncFromApiResponseAsync(ReturnListResponse response, int ozonClientId)
 60    {
 61        if (response?.Returns == null || response.Returns.Count == 0)
 62            return ReturnsSyncResult.Empty;
 63
 64        var orderIdsToMarkReturned = new HashSet<int>();
 65
 66        var numbersToLookup = response.Returns
 67            .SelectMany(r => new[] { r.OrderNumber, r.PostingNumber })
 68            .Where(n => !string.IsNullOrEmpty(n))
 69            .Select(n => n!)
 70            .Distinct()
 71            .Where(n => !_numbersWithoutOrders.Contains(n))
 72            .ToList();
 73
 74        var orderIdsByNumber = await _orderRepository.GetOrderIdsByShipmentNumbersAsync(numbersToLookup, ozonClientId);
 75
 76        if (_numbersWithoutOrders.Count < MaxCachedMissingNumbers)
 77        {
 78            foreach (var number in numbersToLookup.Where(n => !orderIdsByNumber.ContainsKey(n)))
 79                _numbersWithoutOrders.Add(number);
 80        }
 81
 82        var entities = new List<OrderReturn>();
 83        foreach (var ozonReturn in response.Returns)
 84        {
 85            var entity = MapToOrderReturn(ozonReturn, ozonClientId);
 86
 87            if (!string.IsNullOrEmpty(entity.OrderNumber) && orderIdsByNumber.TryGetValue(entity.OrderNumber, out var or
 88            {
 89                entity.OrderId = orderIdByOrder;
 90                orderIdsToMarkReturned.Add(orderIdByOrder);
 91            }
 92            else if (!string.IsNullOrEmpty(entity.PostingNumber) && orderIdsByNumber.TryGetValue(entity.PostingNumber, o
 93            {
 94                entity.OrderId = orderIdByPosting;
 95                orderIdsToMarkReturned.Add(orderIdByPosting);
 96            }
 97
 98            entity.RawDataJson = JsonConvert.SerializeObject(ozonReturn);
 99            entities.Add(entity);
 100        }
 101
 102        try
 103        {
 104            var upsert = await _repository.UpsertBatchFromOzonReturnsAsync(entities);
 105
 106            if (orderIdsToMarkReturned.Count > 0)
 107                await _orderRepository.SetIsReturnedForOrderIdsAsync(orderIdsToMarkReturned, true);
 108
 109            // Статус заказа в Ozon перезапрашиваем только там, где возврат реально изменился, и одним
 110            // батчем. Раньше это был последовательный /v3/posting/fbs/get на каждый связанный заказ
 111            // в каждом батче каждого прогона — при полном окне это тысячи запросов на ровном месте.
 112            var changedOrderIds = upsert.Changed
 113                .Where(r => r.OrderId.HasValue)
 114                .Select(r => r.OrderId!.Value)
 115                .Distinct()
 116                .ToList();
 117
 118            if (changedOrderIds.Count > 0)
 119            {
 120                var ordersForStatusUpdate = await _orderRepository.GetOrdersForStatusUpdateAsync(changedOrderIds);
 121                await UpdateOrderStatusesFromOzonAsync(ordersForStatusUpdate, ozonClientId);
 122            }
 123
 124            return new ReturnsSyncResult(
 125                upsert.Processed,
 126                upsert.Inserted,
 127                upsert.Changed.Count,
 128                orderIdsToMarkReturned.Count);
 129        }
 130        finally
 131        {
 132            // Батч обработан — трекер больше не нужен. При ошибке или успешном сохранении
 133            // сбрасываем ChangeTracker, чтобы не накапливать сущности и не заражать последующие батчи.
 134            _repository.ClearChangeTracker();
 135        }
 136    }
 137
 138    /// <summary>
 139    /// Сбросить ChangeTracker репозитория возвратов.
 140    /// </summary>
 141    public void ClearChangeTracker() => _repository.ClearChangeTracker();
 142
 143    /// <summary>
 144    /// Привести IsReturned в соответствие с наличием записей в OrderReturns.
 145    /// Вызывается на полной сверке — гонять по всей таблице заказов на каждом инкрементальном тике незачем.
 146    /// </summary>
 147    public async Task<(int Cleared, int Set)> ReconcileIsReturnedFlagAsync() =>
 148        await _orderRepository.ReconcileIsReturnedFlagAsync();
 149
 150    /// <summary>
 151    /// Максимальный VisualStatusChangeMoment среди сохранённых возвратов клиента — граница
 152    /// инкрементальной загрузки. null, если возвратов у клиента ещё нет.
 153    /// </summary>
 154    public async Task<DateTime?> GetLastSyncedStatusMomentAsync(int ozonClientId) =>
 155        await _repository.GetMaxVisualStatusChangeMomentAsync(ozonClientId);
 156
 157    /// <summary>
 158    /// Обновляет статус заказов (ClientStatus) из Ozon API батч-запросом. Вызывается только для заказов,
 159    /// у которых при импорте реально изменился статус возврата или компенсации.
 160    /// </summary>
 161    private async Task UpdateOrderStatusesFromOzonAsync(List<Order> orders, int ozonClientId)
 162    {
 163        try
 164        {
 165            var targets = orders
 166                .Where(o => !string.IsNullOrEmpty(o.ShipmentNumber))
 167                .ToList();
 168            if (targets.Count == 0)
 169                return;
 170
 171            var client = targets[0].OzonClient ?? await _ozonClientServices.GetOzonClientAsync(ozonClientId);
 172            if (client == null || client.ClientType != ClientType.OZON ||
 173                string.IsNullOrEmpty(client.DecryptClientId) || string.IsNullOrEmpty(client.DecryptApiKey))
 174                return;
 175
 176            _jsonDataBuilder.SetClient(client.DecryptClientId ?? "", client.DecryptApiKey ?? "");
 177
 178            var shipmentNumbers = targets
 179                .Select(o => o.ShipmentNumber!)
 180                .Where(s => !string.IsNullOrWhiteSpace(s))
 181                .Distinct()
 182                .ToList();
 183
 184            // Success=false означает «запрос упал», а не «статуса нет» — такие заказы пропускаем,
 185            // их подхватит следующий прогон, когда возврат снова попадёт в изменённые.
 186            var statuses = await _jsonDataBuilder.GetProductStatusBatch(shipmentNumbers);
 187
 188            var statusCache = new Dictionary<string, ClientStatus>(StringComparer.OrdinalIgnoreCase);
 189            var changedOrders = new List<Order>();
 190
 191            foreach (var order in targets)
 192            {
 193                var apiPosting = OzonPostingNumberHelper.GetOzonApiPostingNumber(order.ShipmentNumber);
 194                var hasStatus = statuses.TryGetValue(order.ShipmentNumber!, out var apiStatus) ||
 195                               (!string.IsNullOrEmpty(apiPosting) && statuses.TryGetValue(apiPosting, out apiStatus));
 196
 197                if (!hasStatus || !apiStatus.Success || string.IsNullOrEmpty(apiStatus.Status))
 198                    continue;
 199
 200                var newStatusName = OzonStatus.OrderStatuses.TryGetValue(apiStatus.Status, out var mappedStatus)
 201                    ? mappedStatus
 202                    : apiStatus.Status;
 203
 204                if (order.ClientStatus?.Name?.Equals(newStatusName, StringComparison.OrdinalIgnoreCase) == true)
 205                    continue;
 206
 207                if (!statusCache.TryGetValue(newStatusName, out var clientStatus))
 208                {
 209                    clientStatus = await _clientStatusServices.GetClientStatusByNameAsync(newStatusName);
 210                    if (clientStatus == null)
 211                    {
 212                        clientStatus = new ClientStatus { Name = newStatusName };
 213                        await _clientStatusServices.AddClientStatus(clientStatus);
 214                    }
 215
 216                    statusCache[newStatusName] = clientStatus;
 217                }
 218
 219                order.ClientStatus = clientStatus;
 220                order.ClientStatusId = clientStatus.Id;
 221                changedOrders.Add(order);
 222            }
 223
 224            if (changedOrders.Count > 0)
 225                await _ordersDataServices.UpdateOrders(changedOrders);
 226        }
 227        catch
 228        {
 229            // Игнорируем ошибки обновления статуса — не блокируем синхронизацию возвратов
 230        }
 231    }
 232
 233    /// <summary>Преобразовать ответ API в сущность OrderReturn.</summary>
 234    public static OrderReturn MapToOrderReturn(OzonReturn ozonReturn, int ozonClientId)
 235    {
 236        var entity = new OrderReturn
 237        {
 238            OzonReturnId = ozonReturn.Id,
 239            OzonClientId = ozonClientId,
 240            PostingNumber = ozonReturn.PostingNumber,
 241            OrderNumber = ozonReturn.OrderNumber,
 242            ReturnReasonName = ozonReturn.ReturnReasonName,
 243            ReturnType = ozonReturn.Type,
 244            Schema = ozonReturn.Schema,
 245            OzonOrderId = ozonReturn.OrderId,
 246            SourceId = ozonReturn.SourceId,
 247            ClearingId = ozonReturn.ClearingId,
 248            ReturnClearingId = ozonReturn.ReturnClearingId
 249        };
 250
 251        if (ozonReturn.Place != null)
 252        {
 253            entity.PlaceId = ozonReturn.Place.Id;
 254            entity.PlaceName = ozonReturn.Place.Name;
 255            entity.PlaceAddress = ozonReturn.Place.Address;
 256        }
 257
 258        if (ozonReturn.TargetPlace != null)
 259        {
 260            entity.TargetPlaceId = ozonReturn.TargetPlace.Id;
 261            entity.TargetPlaceName = ozonReturn.TargetPlace.Name;
 262            entity.TargetPlaceAddress = ozonReturn.TargetPlace.Address;
 263        }
 264
 265        if (ozonReturn.Storage != null)
 266        {
 267            entity.StorageSumPrice = (decimal?)(ozonReturn.Storage.Sum?.Price ?? 0);
 268            entity.StorageSumCurrency = ozonReturn.Storage.Sum?.CurrencyCode;
 269            entity.StorageDays = ozonReturn.Storage.Days;
 270            entity.StorageTarificationFirstDate = ozonReturn.Storage.TarificationFirstDate;
 271            entity.StorageTarificationStartDate = ozonReturn.Storage.TarificationStartDate;
 272            entity.StorageArrivedMoment = ozonReturn.Storage.ArrivedMoment;
 273            entity.UtilizationSumPrice = (decimal?)(ozonReturn.Storage.UtilizationSum?.Price ?? 0);
 274            entity.UtilizationSumCurrency = ozonReturn.Storage.UtilizationSum?.CurrencyCode;
 275            entity.UtilizationForecastDate = ozonReturn.Storage.UtilizationForecastDate;
 276        }
 277
 278        if (ozonReturn.Product != null)
 279        {
 280            entity.ProductSku = ozonReturn.Product.Sku;
 281            entity.ProductOfferId = ozonReturn.Product.OfferId;
 282            entity.ProductName = ozonReturn.Product.Name;
 283            entity.ProductPrice = (decimal?)(ozonReturn.Product.Price?.Price ?? 0);
 284            entity.ProductPriceCurrency = ozonReturn.Product.Price?.CurrencyCode;
 285            entity.ProductCommissionPercent = ozonReturn.Product.CommissionPercent;
 286            entity.ProductCommission = (decimal?)(ozonReturn.Product.Commission?.Price ?? 0);
 287            entity.ProductQuantity = ozonReturn.Product.Quantity;
 288        }
 289
 290        if (ozonReturn.Logistic != null)
 291        {
 292            entity.TechnicalReturnMoment = ozonReturn.Logistic.TechnicalReturnMoment;
 293            entity.FinalMoment = ozonReturn.Logistic.FinalMoment;
 294            entity.CancelledWithCompensationMoment = ozonReturn.Logistic.CancelledWithCompensationMoment;
 295            entity.ReturnDate = ozonReturn.Logistic.ReturnDate;
 296            entity.LogisticBarcode = ozonReturn.Logistic.Barcode;
 297        }
 298
 299        if (ozonReturn.Visual?.Status != null)
 300        {
 301            entity.VisualStatusId = ozonReturn.Visual.Status.Id;
 302            entity.VisualStatusDisplayName = ozonReturn.Visual.Status.DisplayName;
 303            entity.VisualStatusSysName = ozonReturn.Visual.Status.SysName;
 304            entity.VisualStatusChangeMoment = ozonReturn.Visual.ChangeMoment;
 305        }
 306
 307        if (ozonReturn.CompensationStatus?.Status != null)
 308        {
 309            entity.CompensationStatusId = ozonReturn.CompensationStatus.Status.Id;
 310            entity.CompensationStatusDisplayName = ozonReturn.CompensationStatus.Status.DisplayName;
 311            entity.CompensationStatusSysName = ozonReturn.CompensationStatus.Status.SysName;
 312            entity.CompensationStatusChangeMoment = ozonReturn.CompensationStatus.ChangeMoment;
 313        }
 314
 315        if (ozonReturn.AdditionalInfo != null)
 316        {
 317            entity.IsOpened = ozonReturn.AdditionalInfo.IsOpened;
 318            entity.IsSuperEconom = ozonReturn.AdditionalInfo.IsSuperEconom;
 319        }
 320
 321        return entity;
 322    }
 323
 324    public async Task<OrderReturn?> GetByIdAsync(long id) =>
 325        await _repository.GetByIdAsync(id);
 326
 327    public async Task<OrderReturn?> GetByOzonReturnIdAsync(long ozonReturnId) =>
 328        await _repository.GetByOzonReturnIdAsync(ozonReturnId);
 329
 330    public async Task<List<OrderReturn>> GetByOrderIdAsync(int orderId) =>
 331        await _repository.GetByOrderIdAsync(orderId);
 332
 333    public async Task<List<OrderReturn>> GetByOrderIdsAsync(IEnumerable<int> orderIds) =>
 334        await _repository.GetByOrderIdsAsync(orderIds);
 335
 336    public async Task<List<OrderReturn>> GetByPostingNumberAsync(string postingNumber) =>
 337        await _repository.GetByPostingNumberAsync(postingNumber);
 338
 339    public async Task<(List<OrderReturn> Items, int Total)> GetByOzonClientIdAsync(
 340        int ozonClientId,
 341        int skip = 0,
 342        int take = 50,
 343        string? statusSysName = null,
 344        string? postingNumber = null,
 345        string? orderNumber = null,
 346        string? returnReasonName = null,
 347        int? orderId = null,
 348        DateTime? fromDate = null,
 349        DateTime? toDate = null)
 350    {
 351        IEnumerable<int>? clientIdsFilter = null;
 352        if (!orderId.HasValue && ozonClientId <= 0)
 353        {
 354            var clients = await _ozonClientServices.GetOzonClients();
 355            var ozonClientIds = clients.Where(c => c.ClientType == ClientType.OZON).Select(c => c.Id).ToList();
 356            if (ozonClientIds.Count > 0)
 357                clientIdsFilter = ozonClientIds;
 358        }
 359
 360        return await _repository.GetByOzonClientIdAsync(
 361            ozonClientId, skip, take, statusSysName, postingNumber, orderNumber, returnReasonName, orderId, fromDate, to
 362    }
 363
 364    public async Task<List<OrderReturn>> Get() =>
 365        await _repository.Get();
 366
 367    public async Task<int> Add(OrderReturn value) =>
 368        await _repository.Add(value);
 369
 370    public async Task<int> Update(OrderReturn value) =>
 371        await _repository.Update(value);
 372
 373    public async Task<int> Delete(OrderReturn value) =>
 374        await _repository.Delete(value);
 375}
 376
 377/// <summary>Итог синхронизации одного батча возвратов.</summary>
 378/// <param name="Processed">Сколько записей пришло из API.</param>
 379/// <param name="Inserted">Из них создано впервые.</param>
 380/// <param name="StatusChanged">У скольких изменился статус возврата или компенсации (включая новые).</param>
 381/// <param name="LinkedOrders">Сколько заказов сопоставлено с возвратами в этом батче.</param>
 0382public sealed record ReturnsSyncResult(int Processed, int Inserted, int StatusChanged, int LinkedOrders)
 383{
 0384    public static readonly ReturnsSyncResult Empty = new(0, 0, 0, 0);
 385}

Methods/Properties

get_Processed()
.cctor()