< Summary

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

Feature is only available for sponsors

Upgrade to PRO version

Metrics

MethodBranch coverage Crap Score Cyclomatic complexity Line coverage
.ctor(...)100%210%
SyncFromApiResponseAsync()0%812280%
ClearChangeTracker()100%210%
ReconcileIsReturnedFlagAsync()100%210%
GetLastSyncedStatusMomentAsync()100%210%
UpdateOrderStatusesFromOzonAsync()0%1806420%
MapToOrderReturn(...)0%1190340%
GetByIdAsync()100%210%
GetByOzonReturnIdAsync()100%210%
GetByOrderIdAsync()100%210%
GetByOrderIdsAsync()100%210%
GetByPostingNumberAsync()100%210%
GetByOzonClientIdAsync()0%4260%
Get()100%210%
Add()100%210%
Update()100%210%
Delete()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>
 032    private readonly HashSet<string> _numbersWithoutOrders = new(StringComparer.OrdinalIgnoreCase);
 33
 34    /// <summary>Потолок на случай гигантской полной сверки — кэш не должен расти без предела.</summary>
 35    private const int MaxCachedMissingNumbers = 100_000;
 36
 037    public OrderReturnDataServices(
 038        OrderReturnRepository repository,
 039        OrderRepository orderRepository,
 040        OrdersDataServices ordersDataServices,
 041        OzonJsonDataBuilder jsonDataBuilder,
 042        OzonClientServices ozonClientServices,
 043        ClientStatusDataServices clientStatusServices)
 44    {
 045        _repository = repository;
 046        _orderRepository = orderRepository;
 047        _ordersDataServices = ordersDataServices;
 048        _jsonDataBuilder = jsonDataBuilder;
 049        _ozonClientServices = ozonClientServices;
 050        _clientStatusServices = clientStatusServices;
 051    }
 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    {
 061        if (response?.Returns == null || response.Returns.Count == 0)
 062            return ReturnsSyncResult.Empty;
 63
 064        var orderIdsToMarkReturned = new HashSet<int>();
 65
 066        var numbersToLookup = response.Returns
 067            .SelectMany(r => new[] { r.OrderNumber, r.PostingNumber })
 068            .Where(n => !string.IsNullOrEmpty(n))
 069            .Select(n => n!)
 070            .Distinct()
 071            .Where(n => !_numbersWithoutOrders.Contains(n))
 072            .ToList();
 73
 074        var orderIdsByNumber = await _orderRepository.GetOrderIdsByShipmentNumbersAsync(numbersToLookup, ozonClientId);
 75
 076        if (_numbersWithoutOrders.Count < MaxCachedMissingNumbers)
 77        {
 078            foreach (var number in numbersToLookup.Where(n => !orderIdsByNumber.ContainsKey(n)))
 079                _numbersWithoutOrders.Add(number);
 80        }
 81
 082        var entities = new List<OrderReturn>();
 083        foreach (var ozonReturn in response.Returns)
 84        {
 085            var entity = MapToOrderReturn(ozonReturn, ozonClientId);
 86
 087            if (!string.IsNullOrEmpty(entity.OrderNumber) && orderIdsByNumber.TryGetValue(entity.OrderNumber, out var or
 88            {
 089                entity.OrderId = orderIdByOrder;
 090                orderIdsToMarkReturned.Add(orderIdByOrder);
 91            }
 092            else if (!string.IsNullOrEmpty(entity.PostingNumber) && orderIdsByNumber.TryGetValue(entity.PostingNumber, o
 93            {
 094                entity.OrderId = orderIdByPosting;
 095                orderIdsToMarkReturned.Add(orderIdByPosting);
 96            }
 97
 098            entity.RawDataJson = JsonConvert.SerializeObject(ozonReturn);
 099            entities.Add(entity);
 100        }
 101
 102        try
 103        {
 0104            var upsert = await _repository.UpsertBatchFromOzonReturnsAsync(entities);
 105
 0106            if (orderIdsToMarkReturned.Count > 0)
 0107                await _orderRepository.SetIsReturnedForOrderIdsAsync(orderIdsToMarkReturned, true);
 108
 109            // Статус заказа в Ozon перезапрашиваем только там, где возврат реально изменился, и одним
 110            // батчем. Раньше это был последовательный /v3/posting/fbs/get на каждый связанный заказ
 111            // в каждом батче каждого прогона — при полном окне это тысячи запросов на ровном месте.
 0112            var changedOrderIds = upsert.Changed
 0113                .Where(r => r.OrderId.HasValue)
 0114                .Select(r => r.OrderId!.Value)
 0115                .Distinct()
 0116                .ToList();
 117
 0118            if (changedOrderIds.Count > 0)
 119            {
 0120                var ordersForStatusUpdate = await _orderRepository.GetOrdersForStatusUpdateAsync(changedOrderIds);
 0121                await UpdateOrderStatusesFromOzonAsync(ordersForStatusUpdate, ozonClientId);
 122            }
 123
 0124            return new ReturnsSyncResult(
 0125                upsert.Processed,
 0126                upsert.Inserted,
 0127                upsert.Changed.Count,
 0128                orderIdsToMarkReturned.Count);
 129        }
 130        finally
 131        {
 132            // Батч обработан — трекер больше не нужен. При ошибке или успешном сохранении
 133            // сбрасываем ChangeTracker, чтобы не накапливать сущности и не заражать последующие батчи.
 0134            _repository.ClearChangeTracker();
 135        }
 0136    }
 137
 138    /// <summary>
 139    /// Сбросить ChangeTracker репозитория возвратов.
 140    /// </summary>
 0141    public void ClearChangeTracker() => _repository.ClearChangeTracker();
 142
 143    /// <summary>
 144    /// Привести IsReturned в соответствие с наличием записей в OrderReturns.
 145    /// Вызывается на полной сверке — гонять по всей таблице заказов на каждом инкрементальном тике незачем.
 146    /// </summary>
 147    public async Task<(int Cleared, int Set)> ReconcileIsReturnedFlagAsync() =>
 0148        await _orderRepository.ReconcileIsReturnedFlagAsync();
 149
 150    /// <summary>
 151    /// Максимальный VisualStatusChangeMoment среди сохранённых возвратов клиента — граница
 152    /// инкрементальной загрузки. null, если возвратов у клиента ещё нет.
 153    /// </summary>
 154    public async Task<DateTime?> GetLastSyncedStatusMomentAsync(int ozonClientId) =>
 0155        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        {
 0165            var targets = orders
 0166                .Where(o => !string.IsNullOrEmpty(o.ShipmentNumber))
 0167                .ToList();
 0168            if (targets.Count == 0)
 0169                return;
 170
 0171            var client = targets[0].OzonClient ?? await _ozonClientServices.GetOzonClientAsync(ozonClientId);
 0172            if (client == null || client.ClientType != ClientType.OZON ||
 0173                string.IsNullOrEmpty(client.DecryptClientId) || string.IsNullOrEmpty(client.DecryptApiKey))
 0174                return;
 175
 0176            _jsonDataBuilder.SetClient(client.DecryptClientId ?? "", client.DecryptApiKey ?? "");
 177
 0178            var shipmentNumbers = targets
 0179                .Select(o => o.ShipmentNumber!)
 0180                .Where(s => !string.IsNullOrWhiteSpace(s))
 0181                .Distinct()
 0182                .ToList();
 183
 184            // Success=false означает «запрос упал», а не «статуса нет» — такие заказы пропускаем,
 185            // их подхватит следующий прогон, когда возврат снова попадёт в изменённые.
 0186            var statuses = await _jsonDataBuilder.GetProductStatusBatch(shipmentNumbers);
 187
 0188            var statusCache = new Dictionary<string, ClientStatus>(StringComparer.OrdinalIgnoreCase);
 0189            var changedOrders = new List<Order>();
 190
 0191            foreach (var order in targets)
 192            {
 0193                var apiPosting = OzonPostingNumberHelper.GetOzonApiPostingNumber(order.ShipmentNumber);
 0194                var hasStatus = statuses.TryGetValue(order.ShipmentNumber!, out var apiStatus) ||
 0195                               (!string.IsNullOrEmpty(apiPosting) && statuses.TryGetValue(apiPosting, out apiStatus));
 196
 0197                if (!hasStatus || !apiStatus.Success || string.IsNullOrEmpty(apiStatus.Status))
 198                    continue;
 199
 0200                var newStatusName = OzonStatus.OrderStatuses.TryGetValue(apiStatus.Status, out var mappedStatus)
 0201                    ? mappedStatus
 0202                    : apiStatus.Status;
 203
 0204                if (order.ClientStatus?.Name?.Equals(newStatusName, StringComparison.OrdinalIgnoreCase) == true)
 205                    continue;
 206
 0207                if (!statusCache.TryGetValue(newStatusName, out var clientStatus))
 208                {
 0209                    clientStatus = await _clientStatusServices.GetClientStatusByNameAsync(newStatusName);
 0210                    if (clientStatus == null)
 211                    {
 0212                        clientStatus = new ClientStatus { Name = newStatusName };
 0213                        await _clientStatusServices.AddClientStatus(clientStatus);
 214                    }
 215
 0216                    statusCache[newStatusName] = clientStatus;
 217                }
 218
 0219                order.ClientStatus = clientStatus;
 0220                order.ClientStatusId = clientStatus.Id;
 0221                changedOrders.Add(order);
 0222            }
 223
 0224            if (changedOrders.Count > 0)
 0225                await _ordersDataServices.UpdateOrders(changedOrders);
 0226        }
 0227        catch
 228        {
 229            // Игнорируем ошибки обновления статуса — не блокируем синхронизацию возвратов
 0230        }
 0231    }
 232
 233    /// <summary>Преобразовать ответ API в сущность OrderReturn.</summary>
 234    public static OrderReturn MapToOrderReturn(OzonReturn ozonReturn, int ozonClientId)
 235    {
 0236        var entity = new OrderReturn
 0237        {
 0238            OzonReturnId = ozonReturn.Id,
 0239            OzonClientId = ozonClientId,
 0240            PostingNumber = ozonReturn.PostingNumber,
 0241            OrderNumber = ozonReturn.OrderNumber,
 0242            ReturnReasonName = ozonReturn.ReturnReasonName,
 0243            ReturnType = ozonReturn.Type,
 0244            Schema = ozonReturn.Schema,
 0245            OzonOrderId = ozonReturn.OrderId,
 0246            SourceId = ozonReturn.SourceId,
 0247            ClearingId = ozonReturn.ClearingId,
 0248            ReturnClearingId = ozonReturn.ReturnClearingId
 0249        };
 250
 0251        if (ozonReturn.Place != null)
 252        {
 0253            entity.PlaceId = ozonReturn.Place.Id;
 0254            entity.PlaceName = ozonReturn.Place.Name;
 0255            entity.PlaceAddress = ozonReturn.Place.Address;
 256        }
 257
 0258        if (ozonReturn.TargetPlace != null)
 259        {
 0260            entity.TargetPlaceId = ozonReturn.TargetPlace.Id;
 0261            entity.TargetPlaceName = ozonReturn.TargetPlace.Name;
 0262            entity.TargetPlaceAddress = ozonReturn.TargetPlace.Address;
 263        }
 264
 0265        if (ozonReturn.Storage != null)
 266        {
 0267            entity.StorageSumPrice = (decimal?)(ozonReturn.Storage.Sum?.Price ?? 0);
 0268            entity.StorageSumCurrency = ozonReturn.Storage.Sum?.CurrencyCode;
 0269            entity.StorageDays = ozonReturn.Storage.Days;
 0270            entity.StorageTarificationFirstDate = ozonReturn.Storage.TarificationFirstDate;
 0271            entity.StorageTarificationStartDate = ozonReturn.Storage.TarificationStartDate;
 0272            entity.StorageArrivedMoment = ozonReturn.Storage.ArrivedMoment;
 0273            entity.UtilizationSumPrice = (decimal?)(ozonReturn.Storage.UtilizationSum?.Price ?? 0);
 0274            entity.UtilizationSumCurrency = ozonReturn.Storage.UtilizationSum?.CurrencyCode;
 0275            entity.UtilizationForecastDate = ozonReturn.Storage.UtilizationForecastDate;
 276        }
 277
 0278        if (ozonReturn.Product != null)
 279        {
 0280            entity.ProductSku = ozonReturn.Product.Sku;
 0281            entity.ProductOfferId = ozonReturn.Product.OfferId;
 0282            entity.ProductName = ozonReturn.Product.Name;
 0283            entity.ProductPrice = (decimal?)(ozonReturn.Product.Price?.Price ?? 0);
 0284            entity.ProductPriceCurrency = ozonReturn.Product.Price?.CurrencyCode;
 0285            entity.ProductCommissionPercent = ozonReturn.Product.CommissionPercent;
 0286            entity.ProductCommission = (decimal?)(ozonReturn.Product.Commission?.Price ?? 0);
 0287            entity.ProductQuantity = ozonReturn.Product.Quantity;
 288        }
 289
 0290        if (ozonReturn.Logistic != null)
 291        {
 0292            entity.TechnicalReturnMoment = ozonReturn.Logistic.TechnicalReturnMoment;
 0293            entity.FinalMoment = ozonReturn.Logistic.FinalMoment;
 0294            entity.CancelledWithCompensationMoment = ozonReturn.Logistic.CancelledWithCompensationMoment;
 0295            entity.ReturnDate = ozonReturn.Logistic.ReturnDate;
 0296            entity.LogisticBarcode = ozonReturn.Logistic.Barcode;
 297        }
 298
 0299        if (ozonReturn.Visual?.Status != null)
 300        {
 0301            entity.VisualStatusId = ozonReturn.Visual.Status.Id;
 0302            entity.VisualStatusDisplayName = ozonReturn.Visual.Status.DisplayName;
 0303            entity.VisualStatusSysName = ozonReturn.Visual.Status.SysName;
 0304            entity.VisualStatusChangeMoment = ozonReturn.Visual.ChangeMoment;
 305        }
 306
 0307        if (ozonReturn.CompensationStatus?.Status != null)
 308        {
 0309            entity.CompensationStatusId = ozonReturn.CompensationStatus.Status.Id;
 0310            entity.CompensationStatusDisplayName = ozonReturn.CompensationStatus.Status.DisplayName;
 0311            entity.CompensationStatusSysName = ozonReturn.CompensationStatus.Status.SysName;
 0312            entity.CompensationStatusChangeMoment = ozonReturn.CompensationStatus.ChangeMoment;
 313        }
 314
 0315        if (ozonReturn.AdditionalInfo != null)
 316        {
 0317            entity.IsOpened = ozonReturn.AdditionalInfo.IsOpened;
 0318            entity.IsSuperEconom = ozonReturn.AdditionalInfo.IsSuperEconom;
 319        }
 320
 0321        return entity;
 322    }
 323
 324    public async Task<OrderReturn?> GetByIdAsync(long id) =>
 0325        await _repository.GetByIdAsync(id);
 326
 327    public async Task<OrderReturn?> GetByOzonReturnIdAsync(long ozonReturnId) =>
 0328        await _repository.GetByOzonReturnIdAsync(ozonReturnId);
 329
 330    public async Task<List<OrderReturn>> GetByOrderIdAsync(int orderId) =>
 0331        await _repository.GetByOrderIdAsync(orderId);
 332
 333    public async Task<List<OrderReturn>> GetByOrderIdsAsync(IEnumerable<int> orderIds) =>
 0334        await _repository.GetByOrderIdsAsync(orderIds);
 335
 336    public async Task<List<OrderReturn>> GetByPostingNumberAsync(string postingNumber) =>
 0337        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    {
 0351        IEnumerable<int>? clientIdsFilter = null;
 0352        if (!orderId.HasValue && ozonClientId <= 0)
 353        {
 0354            var clients = await _ozonClientServices.GetOzonClients();
 0355            var ozonClientIds = clients.Where(c => c.ClientType == ClientType.OZON).Select(c => c.Id).ToList();
 0356            if (ozonClientIds.Count > 0)
 0357                clientIdsFilter = ozonClientIds;
 358        }
 359
 0360        return await _repository.GetByOzonClientIdAsync(
 0361            ozonClientId, skip, take, statusSysName, postingNumber, orderNumber, returnReasonName, orderId, fromDate, to
 0362    }
 363
 364    public async Task<List<OrderReturn>> Get() =>
 0365        await _repository.Get();
 366
 367    public async Task<int> Add(OrderReturn value) =>
 0368        await _repository.Add(value);
 369
 370    public async Task<int> Update(OrderReturn value) =>
 0371        await _repository.Update(value);
 372
 373    public async Task<int> Delete(OrderReturn value) =>
 0374        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>
 382public sealed record ReturnsSyncResult(int Processed, int Inserted, int StatusChanged, int LinkedOrders)
 383{
 384    public static readonly ReturnsSyncResult Empty = new(0, 0, 0, 0);
 385}