< Summary

Information
Class: OzonRepositories.Data.OrderReturnUpsertResult
Assembly: OzonRepositories
File(s): /home/runner/work/Studio2Prod/Studio2Prod/OzonRepositories/Data/OrderReturnRepository.cs
Line coverage
66%
Covered lines: 2
Uncovered lines: 1
Coverable lines: 3
Total lines: 496
Line coverage: 66.6%
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%11100%
.cctor()100%210%
get_Unchanged()100%11100%

File(s)

/home/runner/work/Studio2Prod/Studio2Prod/OzonRepositories/Data/OrderReturnRepository.cs

#LineLine coverage
 1using Microsoft.EntityFrameworkCore;
 2using OzonDomains.Models;
 3using OzonRepositories.Context;
 4
 5namespace OzonRepositories.Data;
 6
 7public class OrderReturnRepository : MainRepository, IRepository<OrderReturn>
 8{
 9    public OrderReturnRepository(ApplicationDbContext context) : base(context)
 10    {
 11    }
 12
 13    public async Task<int> Add(OrderReturn value)
 14    {
 15        value.UpdatedAt = DateTime.UtcNow;
 16        await _context.OrderReturns.AddAsync(value);
 17        return await _context.SaveChangesAsync();
 18    }
 19
 20    public async Task<int> Delete(OrderReturn value)
 21    {
 22        _context.OrderReturns.Remove(value);
 23        return await _context.SaveChangesAsync();
 24    }
 25
 26    public async Task<List<OrderReturn>> Get()
 27    {
 28        return await _context.OrderReturns
 29            .Include(x => x.Order)
 30            .Include(x => x.OzonClient)
 31            .OrderByDescending(x => x.UpdatedAt)
 32            .ToListAsync();
 33    }
 34
 35    public async Task<OrderReturn?> GetAsync(int id)
 36    {
 37        var entity = await GetByIdAsync(id);
 38        return entity ?? throw new InvalidOperationException($"OrderReturn с Id={id} не найден.");
 39    }
 40
 41    public async Task<OrderReturn?> GetAsync(OrderReturn value)
 42    {
 43        var entity = await _context.OrderReturns
 44            .Include(x => x.Order)
 45            .Include(x => x.OzonClient)
 46            .FirstOrDefaultAsync(x => x.OzonReturnId == value.OzonReturnId);
 47        return entity ?? throw new InvalidOperationException($"OrderReturn с OzonReturnId={value.OzonReturnId} не найден
 48    }
 49
 50    public async Task<int> Update(OrderReturn value)
 51    {
 52        value.UpdatedAt = DateTime.UtcNow;
 53        _context.OrderReturns.Update(value);
 54        return await _context.SaveChangesAsync();
 55    }
 56
 57    /// <summary>Получить возврат по внутреннему Id.</summary>
 58    public async Task<OrderReturn?> GetByIdAsync(long id)
 59    {
 60        return await _context.OrderReturns
 61            .Include(x => x.Order)
 62                .ThenInclude(o => o!.ProductInfo)
 63            .Include(x => x.OzonClient)
 64            .FirstOrDefaultAsync(x => x.Id == id);
 65    }
 66
 67    /// <summary>Получить возврат по OzonReturnId (без OzonClient — используйте OzonClientId при необходимости).</summar
 68    public async Task<OrderReturn?> GetByOzonReturnIdAsync(long ozonReturnId)
 69    {
 70        return await _context.OrderReturns
 71            .Include(x => x.Order)
 72            .FirstOrDefaultAsync(x => x.OzonReturnId == ozonReturnId);
 73    }
 74
 75    /// <summary>Получить возврат по OzonReturnId для Upsert — без Include (облегчённый запрос).</summary>
 76    public async Task<OrderReturn?> GetByOzonReturnIdForUpsertAsync(long ozonReturnId)
 77    {
 78        return await _context.OrderReturns
 79            .FirstOrDefaultAsync(x => x.OzonReturnId == ozonReturnId);
 80    }
 81
 82    /// <summary>Получить все возвраты для заказа.</summary>
 83    public async Task<List<OrderReturn>> GetByOrderIdAsync(int orderId)
 84    {
 85        return await _context.OrderReturns
 86            .Include(x => x.Order)
 87            .Include(x => x.OzonClient)
 88            .Where(x => x.OrderId == orderId)
 89            .OrderByDescending(x => x.UpdatedAt)
 90            .ToListAsync();
 91    }
 92
 93    /// <summary>Получить возвраты по списку заказов.</summary>
 94    public async Task<List<OrderReturn>> GetByOrderIdsAsync(IEnumerable<int> orderIds)
 95    {
 96        if (orderIds == null || !orderIds.Any())
 97            return new List<OrderReturn>();
 98
 99        return await _context.OrderReturns
 100            .AsNoTracking()
 101            .Include(x => x.Order)
 102            .Include(x => x.OzonClient)
 103            .Where(x => x.OrderId.HasValue && orderIds.Contains(x.OrderId.Value))
 104            .OrderByDescending(x => x.UpdatedAt)
 105            .ToListAsync();
 106    }
 107
 108    /// <summary>Получить возвраты по номеру отправления.</summary>
 109    public async Task<List<OrderReturn>> GetByPostingNumberAsync(string postingNumber)
 110    {
 111        return await _context.OrderReturns
 112            .Include(x => x.Order)
 113            .Include(x => x.OzonClient)
 114            .Where(x => x.PostingNumber == postingNumber)
 115            .OrderByDescending(x => x.UpdatedAt)
 116            .ToListAsync();
 117    }
 118
 119    /// <summary>Найти возврат по PostingNumber и OrderNumber для обновления (уникальность).</summary>
 120    public async Task<OrderReturn?> GetByPostingAndOrderNumberAsync(string? postingNumber, string? orderNumber, int ozon
 121    {
 122        if (string.IsNullOrEmpty(postingNumber) && string.IsNullOrEmpty(orderNumber))
 123            return null;
 124
 125        var query = _context.OrderReturns
 126            .Include(x => x.Order)
 127            .Include(x => x.OzonClient)
 128            .Where(x => x.OzonClientId == ozonClientId);
 129
 130        if (!string.IsNullOrEmpty(postingNumber) && !string.IsNullOrEmpty(orderNumber))
 131            query = query.Where(x => x.PostingNumber == postingNumber && x.OrderNumber == orderNumber);
 132        else if (!string.IsNullOrEmpty(postingNumber))
 133            query = query.Where(x => x.PostingNumber == postingNumber);
 134        else
 135            query = query.Where(x => x.OrderNumber == orderNumber);
 136
 137        return await query.OrderByDescending(x => x.UpdatedAt).FirstOrDefaultAsync();
 138    }
 139
 140    /// <summary>
 141    /// Найти возврат для Upsert — без Include (облегчённый запрос).
 142    /// Приоритет: PostingNumber (если задан), иначе OrderNumber.
 143    /// </summary>
 144    public async Task<OrderReturn?> GetByPostingAndOrderNumberForUpsertAsync(string? postingNumber, string? orderNumber,
 145    {
 146        if (string.IsNullOrEmpty(postingNumber) && string.IsNullOrEmpty(orderNumber))
 147            return null;
 148
 149        var query = _context.OrderReturns
 150            .Where(x => x.OzonClientId == ozonClientId && x.OzonReturnId == 0);
 151
 152        if (!string.IsNullOrEmpty(postingNumber))
 153            query = query.Where(x => x.PostingNumber == postingNumber);
 154        else
 155            query = query.Where(x => x.OrderNumber == orderNumber);
 156
 157        return await query.OrderByDescending(x => x.UpdatedAt).FirstOrDefaultAsync();
 158    }
 159
 160    /// <summary>Получить возвраты по клиенту Ozon с пагинацией.</summary>
 161    /// <param name="ozonClientId">ID клиента или 0 для всех Ozon-клиентов.</param>
 162    public async Task<(List<OrderReturn> Items, int Total)> GetByOzonClientIdAsync(
 163        int ozonClientId,
 164        int skip = 0,
 165        int take = 50,
 166        string? statusSysName = null,
 167        string? postingNumber = null,
 168        string? orderNumber = null,
 169        string? returnReasonName = null,
 170        int? orderId = null,
 171        DateTime? fromDate = null,
 172        DateTime? toDate = null,
 173        IEnumerable<int>? ozonClientIdsFilter = null)
 174    {
 175        var query = _context.OrderReturns
 176            .AsNoTracking()
 177            .Include(x => x.Order)
 178            .Include(x => x.OzonClient)
 179            .AsQueryable();
 180
 181        if (orderId.HasValue)
 182            query = query.Where(x => x.OrderId == orderId.Value);
 183        else if (ozonClientIdsFilter != null && ozonClientIdsFilter.Any())
 184            query = query.Where(x => ozonClientIdsFilter.Contains(x.OzonClientId));
 185        else if (ozonClientId > 0)
 186            query = query.Where(x => x.OzonClientId == ozonClientId);
 187
 188        if (!string.IsNullOrEmpty(statusSysName))
 189            query = query.Where(x => x.VisualStatusSysName == statusSysName);
 190
 191        if (!string.IsNullOrEmpty(postingNumber))
 192            query = query.Where(x => x.PostingNumber != null && x.PostingNumber.Contains(postingNumber));
 193
 194        if (!string.IsNullOrEmpty(orderNumber))
 195            query = query.Where(x => x.OrderNumber != null && x.OrderNumber.Contains(orderNumber));
 196
 197        if (!string.IsNullOrEmpty(returnReasonName))
 198            query = query.Where(x => x.ReturnReasonName != null && x.ReturnReasonName.Contains(returnReasonName));
 199
 200        if (fromDate.HasValue)
 201            query = query.Where(x => x.CreatedAt >= fromDate.Value);
 202
 203        if (toDate.HasValue)
 204            query = query.Where(x => x.CreatedAt <= toDate.Value);
 205
 206        var total = await query.CountAsync();
 207        var items = await query
 208            .OrderByDescending(x => x.UpdatedAt)
 209            .Skip(skip)
 210            .Take(take)
 211            .ToListAsync();
 212
 213        return (items, total);
 214    }
 215
 216    /// <summary>Создать или обновить возврат по данным из API. Уникальность по OzonReturnId, затем по PostingNumber+Ord
 217    public async Task<OrderReturn> UpsertFromOzonReturnAsync(OrderReturn entity)
 218    {
 219        var existing = await GetByOzonReturnIdForUpsertAsync(entity.OzonReturnId);
 220        if (existing != null)
 221        {
 222            UpdateFromEntity(existing, entity);
 223            await Update(existing);
 224            return existing;
 225        }
 226
 227        existing = await GetByPostingAndOrderNumberForUpsertAsync(entity.PostingNumber, entity.OrderNumber, entity.OzonC
 228        if (existing != null)
 229        {
 230            UpdateFromEntity(existing, entity);
 231            await Update(existing);
 232            return existing;
 233        }
 234
 235        await Add(entity);
 236        return entity;
 237    }
 238
 239    /// <summary>
 240    /// Сбросить ChangeTracker после обработанного батча. Джоб загрузки живёт в одном DI-скоупе,
 241    /// поэтому за прогон в трекере копятся десятки тысяч сущностей и каждый следующий SaveChanges
 242    /// становится медленнее — DetectChanges линеен по числу отслеживаемых записей.
 243    /// </summary>
 244    public void ClearChangeTracker() => _context.ChangeTracker.Clear();
 245
 246    /// <summary>
 247    /// Максимальный VisualStatusChangeMoment среди уже сохранённых возвратов клиента — watermark
 248    /// для инкрементальной загрузки. Считается по фактически записанным данным, поэтому оборванный
 249    /// прогон не сдвигает границу вперёд и недогруженный период будет перечитан на следующем запуске.
 250    /// </summary>
 251    public async Task<DateTime?> GetMaxVisualStatusChangeMomentAsync(int ozonClientId)
 252    {
 253        if (ozonClientId <= 0)
 254            return null;
 255
 256        return await _context.OrderReturns
 257            .AsNoTracking()
 258            .Where(x => x.OzonClientId == ozonClientId && x.VisualStatusChangeMoment != null)
 259            .MaxAsync(x => x.VisualStatusChangeMoment);
 260    }
 261
 262    /// <summary>
 263    /// Батч-создание/обновление возвратов из API. Один запрос на проверку существования + один SaveChanges.
 264    /// Возвращает общее количество обработанных записей и те из них, у которых реально изменился
 265    /// статус возврата или компенсации — только для них имеет смысл переспрашивать статус заказа в Ozon.
 266    /// </summary>
 267    public async Task<OrderReturnUpsertResult> UpsertBatchFromOzonReturnsAsync(IEnumerable<OrderReturn> entities)
 268    {
 269        var entityList = entities.ToList();
 270        if (entityList.Count == 0)
 271            return OrderReturnUpsertResult.Empty;
 272
 273        // Отфильтровываем записи с некорректным ID и дедуплицируем батч по OzonReturnId,
 274        // оставляя последнее состояние на случай повторов в ответе API Ozon.
 275        var dedupedList = entityList
 276            .Where(e => e.OzonReturnId > 0)
 277            .GroupBy(e => e.OzonReturnId)
 278            .Select(g => g.Last())
 279            .ToList();
 280
 281        if (dedupedList.Count == 0)
 282            return OrderReturnUpsertResult.Empty;
 283
 284        var changed = new List<OrderReturn>();
 285
 286        var ozonReturnIds = dedupedList.Select(e => e.OzonReturnId).Distinct().ToList();
 287        var existingByOzonId = await _context.OrderReturns
 288            .Where(x => ozonReturnIds.Contains(x.OzonReturnId))
 289            .ToDictionaryAsync(x => x.OzonReturnId);
 290
 291        var notFoundByOzonId = dedupedList.Where(e => !existingByOzonId.ContainsKey(e.OzonReturnId)).ToList();
 292
 293        var postingKeys = notFoundByOzonId
 294            .Where(e => e.OzonClientId > 0 && !string.IsNullOrEmpty(e.PostingNumber))
 295            .Select(e => new { e.OzonClientId, PostingNumber = e.PostingNumber! })
 296            .Distinct()
 297            .ToList();
 298
 299        var orderKeysWithoutPosting = notFoundByOzonId
 300            .Where(e => e.OzonClientId > 0 && string.IsNullOrEmpty(e.PostingNumber) && !string.IsNullOrEmpty(e.OrderNumb
 301            .Select(e => new { e.OzonClientId, OrderNumber = e.OrderNumber! })
 302            .Distinct()
 303            .ToList();
 304
 305        Dictionary<string, OrderReturn>? existingByPosting = null;
 306        if (postingKeys.Count > 0)
 307        {
 308            var postingClientIds = postingKeys.Select(k => k.OzonClientId).Distinct().ToList();
 309            var postingNumbers = postingKeys.Select(k => k.PostingNumber).Distinct().ToList();
 310            var existingPostingList = await _context.OrderReturns
 311                .Where(x =>
 312                    postingClientIds.Contains(x.OzonClientId) &&
 313                    x.OzonReturnId == 0 &&
 314                    x.PostingNumber != null &&
 315                    postingNumbers.Contains(x.PostingNumber))
 316                .ToListAsync();
 317
 318            var postingSet = postingKeys
 319                .Select(k => $"{k.OzonClientId}|{k.PostingNumber}")
 320                .ToHashSet();
 321
 322            existingByPosting = existingPostingList
 323                .Where(x => x.PostingNumber != null && postingSet.Contains($"{x.OzonClientId}|{x.PostingNumber}"))
 324                .GroupBy(x => $"{x.OzonClientId}|{x.PostingNumber}")
 325                .ToDictionary(g => g.Key, g => g.OrderByDescending(x => x.UpdatedAt).First());
 326        }
 327
 328        Dictionary<string, OrderReturn>? existingByOrderWithoutPosting = null;
 329        if (orderKeysWithoutPosting.Count > 0)
 330        {
 331            var orderClientIds = orderKeysWithoutPosting.Select(k => k.OzonClientId).Distinct().ToList();
 332            var orderNumbers = orderKeysWithoutPosting.Select(k => k.OrderNumber).Distinct().ToList();
 333            var existingOrderList = await _context.OrderReturns
 334                .Where(x =>
 335                    orderClientIds.Contains(x.OzonClientId) &&
 336                    x.OzonReturnId == 0 &&
 337                    x.OrderNumber != null &&
 338                    orderNumbers.Contains(x.OrderNumber))
 339                .ToListAsync();
 340
 341            var orderSet = orderKeysWithoutPosting
 342                .Select(k => $"{k.OzonClientId}|{k.OrderNumber}")
 343                .ToHashSet();
 344
 345            existingByOrderWithoutPosting = existingOrderList
 346                .Where(x => x.OrderNumber != null && orderSet.Contains($"{x.OzonClientId}|{x.OrderNumber}"))
 347                .GroupBy(x => $"{x.OzonClientId}|{x.OrderNumber}")
 348                .ToDictionary(g => g.Key, g => g.OrderByDescending(x => x.UpdatedAt).First());
 349        }
 350
 351        var count = 0;
 352        var inserted = 0;
 353        foreach (var entity in dedupedList)
 354        {
 355            entity.UpdatedAt = DateTime.UtcNow;
 356
 357            if (existingByOzonId.TryGetValue(entity.OzonReturnId, out var existing))
 358            {
 359                var statusChanged = HasStatusChanged(existing, entity);
 360                UpdateFromEntity(existing, entity);
 361                if (statusChanged)
 362                    changed.Add(existing);
 363                count++;
 364                continue;
 365            }
 366
 367            if (!string.IsNullOrEmpty(entity.PostingNumber))
 368            {
 369                var postingKey = $"{entity.OzonClientId}|{entity.PostingNumber}";
 370                if (existingByPosting != null && existingByPosting.TryGetValue(postingKey, out var existingByPostingNumb
 371                {
 372                    var statusChanged = HasStatusChanged(existingByPostingNumber, entity);
 373                    UpdateFromEntity(existingByPostingNumber, entity);
 374                    existingByOzonId[entity.OzonReturnId] = existingByPostingNumber;
 375                    existingByPosting.Remove(postingKey);
 376                    if (statusChanged)
 377                        changed.Add(existingByPostingNumber);
 378                    count++;
 379                    continue;
 380                }
 381            }
 382            else if (!string.IsNullOrEmpty(entity.OrderNumber))
 383            {
 384                var orderKey = $"{entity.OzonClientId}|{entity.OrderNumber}";
 385                if (existingByOrderWithoutPosting != null && existingByOrderWithoutPosting.TryGetValue(orderKey, out var
 386                {
 387                    var statusChanged = HasStatusChanged(existingByOrderNumber, entity);
 388                    UpdateFromEntity(existingByOrderNumber, entity);
 389                    existingByOzonId[entity.OzonReturnId] = existingByOrderNumber;
 390                    existingByOrderWithoutPosting.Remove(orderKey);
 391                    if (statusChanged)
 392                        changed.Add(existingByOrderNumber);
 393                    count++;
 394                    continue;
 395                }
 396            }
 397
 398            await _context.OrderReturns.AddAsync(entity);
 399            existingByOzonId[entity.OzonReturnId] = entity;
 400
 401            // Новый возврат — статус для нас всегда новый.
 402            changed.Add(entity);
 403            inserted++;
 404            count++;
 405        }
 406
 407        try
 408        {
 409            await _context.SaveChangesAsync();
 410            return new OrderReturnUpsertResult(count, inserted, changed);
 411        }
 412        catch
 413        {
 414            _context.ChangeTracker.Clear();
 415            throw;
 416        }
 417    }
 418
 419    /// <summary>
 420    /// Изменился ли статус возврата или компенсации. Остальные поля (стоимость хранения, дни,
 421    /// прогноз утилизации) меняются у Ozon постоянно и поводом перезапрашивать статус заказа не являются.
 422    /// </summary>
 423    private static bool HasStatusChanged(OrderReturn existing, OrderReturn incoming) =>
 424        !string.Equals(existing.VisualStatusSysName, incoming.VisualStatusSysName, StringComparison.Ordinal) ||
 425        existing.VisualStatusChangeMoment != incoming.VisualStatusChangeMoment ||
 426        !string.Equals(existing.CompensationStatusSysName, incoming.CompensationStatusSysName, StringComparison.Ordinal)
 427        existing.CompensationStatusChangeMoment != incoming.CompensationStatusChangeMoment;
 428
 429    private static void UpdateFromEntity(OrderReturn target, OrderReturn source)
 430    {
 431        target.OzonReturnId = source.OzonReturnId;
 432        // Связь с заказом не затираем: поиск заказа идёт только по неархивным
 433        // (OrderRepository.GetOrderIdsByShipmentNumbersAsync фильтрует OrderArchive == null),
 434        // поэтому после архивации заказа source.OrderId придёт null и уже найденная связь
 435        // была бы потеряна вместе с возвратом на странице заказа и в фильтрах.
 436        target.OrderId = source.OrderId ?? target.OrderId;
 437        target.PostingNumber = source.PostingNumber;
 438        target.OrderNumber = source.OrderNumber;
 439        target.ReturnReasonName = source.ReturnReasonName;
 440        target.ReturnType = source.ReturnType;
 441        target.Schema = source.Schema;
 442        target.PlaceId = source.PlaceId;
 443        target.PlaceName = source.PlaceName;
 444        target.PlaceAddress = source.PlaceAddress;
 445        target.TargetPlaceId = source.TargetPlaceId;
 446        target.TargetPlaceName = source.TargetPlaceName;
 447        target.TargetPlaceAddress = source.TargetPlaceAddress;
 448        target.StorageSumPrice = source.StorageSumPrice;
 449        target.StorageSumCurrency = source.StorageSumCurrency;
 450        target.StorageDays = source.StorageDays;
 451        target.StorageTarificationFirstDate = source.StorageTarificationFirstDate;
 452        target.StorageTarificationStartDate = source.StorageTarificationStartDate;
 453        target.StorageArrivedMoment = source.StorageArrivedMoment;
 454        target.UtilizationSumPrice = source.UtilizationSumPrice;
 455        target.UtilizationSumCurrency = source.UtilizationSumCurrency;
 456        target.UtilizationForecastDate = source.UtilizationForecastDate;
 457        target.ProductSku = source.ProductSku;
 458        target.ProductOfferId = source.ProductOfferId;
 459        target.ProductName = source.ProductName;
 460        target.ProductPrice = source.ProductPrice;
 461        target.ProductPriceCurrency = source.ProductPriceCurrency;
 462        target.ProductCommissionPercent = source.ProductCommissionPercent;
 463        target.ProductCommission = source.ProductCommission;
 464        target.ProductQuantity = source.ProductQuantity;
 465        target.TechnicalReturnMoment = source.TechnicalReturnMoment;
 466        target.FinalMoment = source.FinalMoment;
 467        target.CancelledWithCompensationMoment = source.CancelledWithCompensationMoment;
 468        target.ReturnDate = source.ReturnDate;
 469        target.LogisticBarcode = source.LogisticBarcode;
 470        target.VisualStatusId = source.VisualStatusId;
 471        target.VisualStatusDisplayName = source.VisualStatusDisplayName;
 472        target.VisualStatusSysName = source.VisualStatusSysName;
 473        target.VisualStatusChangeMoment = source.VisualStatusChangeMoment;
 474        target.CompensationStatusId = source.CompensationStatusId;
 475        target.CompensationStatusDisplayName = source.CompensationStatusDisplayName;
 476        target.CompensationStatusSysName = source.CompensationStatusSysName;
 477        target.CompensationStatusChangeMoment = source.CompensationStatusChangeMoment;
 478        target.IsOpened = source.IsOpened;
 479        target.IsSuperEconom = source.IsSuperEconom;
 480        target.RawDataJson = source.RawDataJson;
 481    }
 482}
 483
 484/// <summary>
 485/// Результат батч-upsert возвратов.
 486/// </summary>
 487/// <param name="Processed">Сколько записей пришло из API и было обработано.</param>
 488/// <param name="Inserted">Из них создано впервые.</param>
 489/// <param name="Changed">Записи, у которых изменился статус возврата или компенсации, включая новые.</param>
 33490public sealed record OrderReturnUpsertResult(int Processed, int Inserted, IReadOnlyList<OrderReturn> Changed)
 491{
 0492    public static readonly OrderReturnUpsertResult Empty = new(0, 0, Array.Empty<OrderReturn>());
 493
 494    /// <summary>Записи, которые пришли из API без единого изменения — их большинство на устоявшемся прогоне.</summary>
 1495    public int Unchanged => Processed - Changed.Count;
 496}