< Summary

Information
Class: OzonRepositories.Data.OrderReturnRepository
Assembly: OzonRepositories
File(s): /home/runner/work/Studio2Prod/Studio2Prod/OzonRepositories/Data/OrderReturnRepository.cs
Line coverage
41%
Covered lines: 125
Uncovered lines: 174
Coverable lines: 299
Total lines: 496
Line coverage: 41.8%
Branch coverage
33%
Covered branches: 35
Total branches: 106
Branch coverage: 33%
Method coverage

Feature is only available for sponsors

Upgrade to PRO version

Metrics

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{
 159    public OrderReturnRepository(ApplicationDbContext context) : base(context)
 10    {
 1511    }
 12
 13    public async Task<int> Add(OrderReturn value)
 14    {
 015        value.UpdatedAt = DateTime.UtcNow;
 016        await _context.OrderReturns.AddAsync(value);
 017        return await _context.SaveChangesAsync();
 018    }
 19
 20    public async Task<int> Delete(OrderReturn value)
 21    {
 022        _context.OrderReturns.Remove(value);
 023        return await _context.SaveChangesAsync();
 024    }
 25
 26    public async Task<List<OrderReturn>> Get()
 27    {
 028        return await _context.OrderReturns
 029            .Include(x => x.Order)
 030            .Include(x => x.OzonClient)
 031            .OrderByDescending(x => x.UpdatedAt)
 032            .ToListAsync();
 033    }
 34
 35    public async Task<OrderReturn?> GetAsync(int id)
 36    {
 037        var entity = await GetByIdAsync(id);
 038        return entity ?? throw new InvalidOperationException($"OrderReturn с Id={id} не найден.");
 039    }
 40
 41    public async Task<OrderReturn?> GetAsync(OrderReturn value)
 42    {
 043        var entity = await _context.OrderReturns
 044            .Include(x => x.Order)
 045            .Include(x => x.OzonClient)
 046            .FirstOrDefaultAsync(x => x.OzonReturnId == value.OzonReturnId);
 047        return entity ?? throw new InvalidOperationException($"OrderReturn с OzonReturnId={value.OzonReturnId} не найден
 048    }
 49
 50    public async Task<int> Update(OrderReturn value)
 51    {
 052        value.UpdatedAt = DateTime.UtcNow;
 053        _context.OrderReturns.Update(value);
 054        return await _context.SaveChangesAsync();
 055    }
 56
 57    /// <summary>Получить возврат по внутреннему Id.</summary>
 58    public async Task<OrderReturn?> GetByIdAsync(long id)
 59    {
 060        return await _context.OrderReturns
 061            .Include(x => x.Order)
 062                .ThenInclude(o => o!.ProductInfo)
 063            .Include(x => x.OzonClient)
 064            .FirstOrDefaultAsync(x => x.Id == id);
 065    }
 66
 67    /// <summary>Получить возврат по OzonReturnId (без OzonClient — используйте OzonClientId при необходимости).</summar
 68    public async Task<OrderReturn?> GetByOzonReturnIdAsync(long ozonReturnId)
 69    {
 070        return await _context.OrderReturns
 071            .Include(x => x.Order)
 072            .FirstOrDefaultAsync(x => x.OzonReturnId == ozonReturnId);
 073    }
 74
 75    /// <summary>Получить возврат по OzonReturnId для Upsert — без Include (облегчённый запрос).</summary>
 76    public async Task<OrderReturn?> GetByOzonReturnIdForUpsertAsync(long ozonReturnId)
 77    {
 078        return await _context.OrderReturns
 079            .FirstOrDefaultAsync(x => x.OzonReturnId == ozonReturnId);
 080    }
 81
 82    /// <summary>Получить все возвраты для заказа.</summary>
 83    public async Task<List<OrderReturn>> GetByOrderIdAsync(int orderId)
 84    {
 085        return await _context.OrderReturns
 086            .Include(x => x.Order)
 087            .Include(x => x.OzonClient)
 088            .Where(x => x.OrderId == orderId)
 089            .OrderByDescending(x => x.UpdatedAt)
 090            .ToListAsync();
 091    }
 92
 93    /// <summary>Получить возвраты по списку заказов.</summary>
 94    public async Task<List<OrderReturn>> GetByOrderIdsAsync(IEnumerable<int> orderIds)
 95    {
 096        if (orderIds == null || !orderIds.Any())
 097            return new List<OrderReturn>();
 98
 099        return await _context.OrderReturns
 0100            .AsNoTracking()
 0101            .Include(x => x.Order)
 0102            .Include(x => x.OzonClient)
 0103            .Where(x => x.OrderId.HasValue && orderIds.Contains(x.OrderId.Value))
 0104            .OrderByDescending(x => x.UpdatedAt)
 0105            .ToListAsync();
 0106    }
 107
 108    /// <summary>Получить возвраты по номеру отправления.</summary>
 109    public async Task<List<OrderReturn>> GetByPostingNumberAsync(string postingNumber)
 110    {
 0111        return await _context.OrderReturns
 0112            .Include(x => x.Order)
 0113            .Include(x => x.OzonClient)
 0114            .Where(x => x.PostingNumber == postingNumber)
 0115            .OrderByDescending(x => x.UpdatedAt)
 0116            .ToListAsync();
 0117    }
 118
 119    /// <summary>Найти возврат по PostingNumber и OrderNumber для обновления (уникальность).</summary>
 120    public async Task<OrderReturn?> GetByPostingAndOrderNumberAsync(string? postingNumber, string? orderNumber, int ozon
 121    {
 0122        if (string.IsNullOrEmpty(postingNumber) && string.IsNullOrEmpty(orderNumber))
 0123            return null;
 124
 0125        var query = _context.OrderReturns
 0126            .Include(x => x.Order)
 0127            .Include(x => x.OzonClient)
 0128            .Where(x => x.OzonClientId == ozonClientId);
 129
 0130        if (!string.IsNullOrEmpty(postingNumber) && !string.IsNullOrEmpty(orderNumber))
 0131            query = query.Where(x => x.PostingNumber == postingNumber && x.OrderNumber == orderNumber);
 0132        else if (!string.IsNullOrEmpty(postingNumber))
 0133            query = query.Where(x => x.PostingNumber == postingNumber);
 134        else
 0135            query = query.Where(x => x.OrderNumber == orderNumber);
 136
 0137        return await query.OrderByDescending(x => x.UpdatedAt).FirstOrDefaultAsync();
 0138    }
 139
 140    /// <summary>
 141    /// Найти возврат для Upsert — без Include (облегчённый запрос).
 142    /// Приоритет: PostingNumber (если задан), иначе OrderNumber.
 143    /// </summary>
 144    public async Task<OrderReturn?> GetByPostingAndOrderNumberForUpsertAsync(string? postingNumber, string? orderNumber,
 145    {
 0146        if (string.IsNullOrEmpty(postingNumber) && string.IsNullOrEmpty(orderNumber))
 0147            return null;
 148
 0149        var query = _context.OrderReturns
 0150            .Where(x => x.OzonClientId == ozonClientId && x.OzonReturnId == 0);
 151
 0152        if (!string.IsNullOrEmpty(postingNumber))
 0153            query = query.Where(x => x.PostingNumber == postingNumber);
 154        else
 0155            query = query.Where(x => x.OrderNumber == orderNumber);
 156
 0157        return await query.OrderByDescending(x => x.UpdatedAt).FirstOrDefaultAsync();
 0158    }
 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    {
 0175        var query = _context.OrderReturns
 0176            .AsNoTracking()
 0177            .Include(x => x.Order)
 0178            .Include(x => x.OzonClient)
 0179            .AsQueryable();
 180
 0181        if (orderId.HasValue)
 0182            query = query.Where(x => x.OrderId == orderId.Value);
 0183        else if (ozonClientIdsFilter != null && ozonClientIdsFilter.Any())
 0184            query = query.Where(x => ozonClientIdsFilter.Contains(x.OzonClientId));
 0185        else if (ozonClientId > 0)
 0186            query = query.Where(x => x.OzonClientId == ozonClientId);
 187
 0188        if (!string.IsNullOrEmpty(statusSysName))
 0189            query = query.Where(x => x.VisualStatusSysName == statusSysName);
 190
 0191        if (!string.IsNullOrEmpty(postingNumber))
 0192            query = query.Where(x => x.PostingNumber != null && x.PostingNumber.Contains(postingNumber));
 193
 0194        if (!string.IsNullOrEmpty(orderNumber))
 0195            query = query.Where(x => x.OrderNumber != null && x.OrderNumber.Contains(orderNumber));
 196
 0197        if (!string.IsNullOrEmpty(returnReasonName))
 0198            query = query.Where(x => x.ReturnReasonName != null && x.ReturnReasonName.Contains(returnReasonName));
 199
 0200        if (fromDate.HasValue)
 0201            query = query.Where(x => x.CreatedAt >= fromDate.Value);
 202
 0203        if (toDate.HasValue)
 0204            query = query.Where(x => x.CreatedAt <= toDate.Value);
 205
 0206        var total = await query.CountAsync();
 0207        var items = await query
 0208            .OrderByDescending(x => x.UpdatedAt)
 0209            .Skip(skip)
 0210            .Take(take)
 0211            .ToListAsync();
 212
 0213        return (items, total);
 0214    }
 215
 216    /// <summary>Создать или обновить возврат по данным из API. Уникальность по OzonReturnId, затем по PostingNumber+Ord
 217    public async Task<OrderReturn> UpsertFromOzonReturnAsync(OrderReturn entity)
 218    {
 0219        var existing = await GetByOzonReturnIdForUpsertAsync(entity.OzonReturnId);
 0220        if (existing != null)
 221        {
 0222            UpdateFromEntity(existing, entity);
 0223            await Update(existing);
 0224            return existing;
 225        }
 226
 0227        existing = await GetByPostingAndOrderNumberForUpsertAsync(entity.PostingNumber, entity.OrderNumber, entity.OzonC
 0228        if (existing != null)
 229        {
 0230            UpdateFromEntity(existing, entity);
 0231            await Update(existing);
 0232            return existing;
 233        }
 234
 0235        await Add(entity);
 0236        return entity;
 0237    }
 238
 239    /// <summary>
 240    /// Сбросить ChangeTracker после обработанного батча. Джоб загрузки живёт в одном DI-скоупе,
 241    /// поэтому за прогон в трекере копятся десятки тысяч сущностей и каждый следующий SaveChanges
 242    /// становится медленнее — DetectChanges линеен по числу отслеживаемых записей.
 243    /// </summary>
 1244    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    {
 2253        if (ozonClientId <= 0)
 0254            return null;
 255
 2256        return await _context.OrderReturns
 2257            .AsNoTracking()
 2258            .Where(x => x.OzonClientId == ozonClientId && x.VisualStatusChangeMoment != null)
 2259            .MaxAsync(x => x.VisualStatusChangeMoment);
 2260    }
 261
 262    /// <summary>
 263    /// Батч-создание/обновление возвратов из API. Один запрос на проверку существования + один SaveChanges.
 264    /// Возвращает общее количество обработанных записей и те из них, у которых реально изменился
 265    /// статус возврата или компенсации — только для них имеет смысл переспрашивать статус заказа в Ozon.
 266    /// </summary>
 267    public async Task<OrderReturnUpsertResult> UpsertBatchFromOzonReturnsAsync(IEnumerable<OrderReturn> entities)
 268    {
 13269        var entityList = entities.ToList();
 13270        if (entityList.Count == 0)
 0271            return OrderReturnUpsertResult.Empty;
 272
 273        // Отфильтровываем записи с некорректным ID и дедуплицируем батч по OzonReturnId,
 274        // оставляя последнее состояние на случай повторов в ответе API Ozon.
 13275        var dedupedList = entityList
 19276            .Where(e => e.OzonReturnId > 0)
 18277            .GroupBy(e => e.OzonReturnId)
 17278            .Select(g => g.Last())
 13279            .ToList();
 280
 13281        if (dedupedList.Count == 0)
 0282            return OrderReturnUpsertResult.Empty;
 283
 13284        var changed = new List<OrderReturn>();
 285
 30286        var ozonReturnIds = dedupedList.Select(e => e.OzonReturnId).Distinct().ToList();
 13287        var existingByOzonId = await _context.OrderReturns
 13288            .Where(x => ozonReturnIds.Contains(x.OzonReturnId))
 22289            .ToDictionaryAsync(x => x.OzonReturnId);
 290
 30291        var notFoundByOzonId = dedupedList.Where(e => !existingByOzonId.ContainsKey(e.OzonReturnId)).ToList();
 292
 13293        var postingKeys = notFoundByOzonId
 8294            .Where(e => e.OzonClientId > 0 && !string.IsNullOrEmpty(e.PostingNumber))
 8295            .Select(e => new { e.OzonClientId, PostingNumber = e.PostingNumber! })
 13296            .Distinct()
 13297            .ToList();
 298
 13299        var orderKeysWithoutPosting = notFoundByOzonId
 8300            .Where(e => e.OzonClientId > 0 && string.IsNullOrEmpty(e.PostingNumber) && !string.IsNullOrEmpty(e.OrderNumb
 0301            .Select(e => new { e.OzonClientId, OrderNumber = e.OrderNumber! })
 13302            .Distinct()
 13303            .ToList();
 304
 13305        Dictionary<string, OrderReturn>? existingByPosting = null;
 13306        if (postingKeys.Count > 0)
 307        {
 13308            var postingClientIds = postingKeys.Select(k => k.OzonClientId).Distinct().ToList();
 13309            var postingNumbers = postingKeys.Select(k => k.PostingNumber).Distinct().ToList();
 6310            var existingPostingList = await _context.OrderReturns
 6311                .Where(x =>
 6312                    postingClientIds.Contains(x.OzonClientId) &&
 6313                    x.OzonReturnId == 0 &&
 6314                    x.PostingNumber != null &&
 6315                    postingNumbers.Contains(x.PostingNumber))
 6316                .ToListAsync();
 317
 6318            var postingSet = postingKeys
 7319                .Select(k => $"{k.OzonClientId}|{k.PostingNumber}")
 6320                .ToHashSet();
 321
 6322            existingByPosting = existingPostingList
 0323                .Where(x => x.PostingNumber != null && postingSet.Contains($"{x.OzonClientId}|{x.PostingNumber}"))
 0324                .GroupBy(x => $"{x.OzonClientId}|{x.PostingNumber}")
 6325                .ToDictionary(g => g.Key, g => g.OrderByDescending(x => x.UpdatedAt).First());
 6326        }
 327
 13328        Dictionary<string, OrderReturn>? existingByOrderWithoutPosting = null;
 13329        if (orderKeysWithoutPosting.Count > 0)
 330        {
 0331            var orderClientIds = orderKeysWithoutPosting.Select(k => k.OzonClientId).Distinct().ToList();
 0332            var orderNumbers = orderKeysWithoutPosting.Select(k => k.OrderNumber).Distinct().ToList();
 0333            var existingOrderList = await _context.OrderReturns
 0334                .Where(x =>
 0335                    orderClientIds.Contains(x.OzonClientId) &&
 0336                    x.OzonReturnId == 0 &&
 0337                    x.OrderNumber != null &&
 0338                    orderNumbers.Contains(x.OrderNumber))
 0339                .ToListAsync();
 340
 0341            var orderSet = orderKeysWithoutPosting
 0342                .Select(k => $"{k.OzonClientId}|{k.OrderNumber}")
 0343                .ToHashSet();
 344
 0345            existingByOrderWithoutPosting = existingOrderList
 0346                .Where(x => x.OrderNumber != null && orderSet.Contains($"{x.OzonClientId}|{x.OrderNumber}"))
 0347                .GroupBy(x => $"{x.OzonClientId}|{x.OrderNumber}")
 0348                .ToDictionary(g => g.Key, g => g.OrderByDescending(x => x.UpdatedAt).First());
 0349        }
 350
 13351        var count = 0;
 13352        var inserted = 0;
 60353        foreach (var entity in dedupedList)
 354        {
 17355            entity.UpdatedAt = DateTime.UtcNow;
 356
 17357            if (existingByOzonId.TryGetValue(entity.OzonReturnId, out var existing))
 358            {
 9359                var statusChanged = HasStatusChanged(existing, entity);
 9360                UpdateFromEntity(existing, entity);
 9361                if (statusChanged)
 4362                    changed.Add(existing);
 9363                count++;
 9364                continue;
 365            }
 366
 8367            if (!string.IsNullOrEmpty(entity.PostingNumber))
 368            {
 8369                var postingKey = $"{entity.OzonClientId}|{entity.PostingNumber}";
 8370                if (existingByPosting != null && existingByPosting.TryGetValue(postingKey, out var existingByPostingNumb
 371                {
 0372                    var statusChanged = HasStatusChanged(existingByPostingNumber, entity);
 0373                    UpdateFromEntity(existingByPostingNumber, entity);
 0374                    existingByOzonId[entity.OzonReturnId] = existingByPostingNumber;
 0375                    existingByPosting.Remove(postingKey);
 0376                    if (statusChanged)
 0377                        changed.Add(existingByPostingNumber);
 0378                    count++;
 0379                    continue;
 380                }
 381            }
 0382            else if (!string.IsNullOrEmpty(entity.OrderNumber))
 383            {
 0384                var orderKey = $"{entity.OzonClientId}|{entity.OrderNumber}";
 0385                if (existingByOrderWithoutPosting != null && existingByOrderWithoutPosting.TryGetValue(orderKey, out var
 386                {
 0387                    var statusChanged = HasStatusChanged(existingByOrderNumber, entity);
 0388                    UpdateFromEntity(existingByOrderNumber, entity);
 0389                    existingByOzonId[entity.OzonReturnId] = existingByOrderNumber;
 0390                    existingByOrderWithoutPosting.Remove(orderKey);
 0391                    if (statusChanged)
 0392                        changed.Add(existingByOrderNumber);
 0393                    count++;
 0394                    continue;
 395                }
 396            }
 397
 8398            await _context.OrderReturns.AddAsync(entity);
 8399            existingByOzonId[entity.OzonReturnId] = entity;
 400
 401            // Новый возврат — статус для нас всегда новый.
 8402            changed.Add(entity);
 8403            inserted++;
 8404            count++;
 8405        }
 406
 407        try
 408        {
 13409            await _context.SaveChangesAsync();
 13410            return new OrderReturnUpsertResult(count, inserted, changed);
 411        }
 0412        catch
 413        {
 0414            _context.ChangeTracker.Clear();
 0415            throw;
 416        }
 13417    }
 418
 419    /// <summary>
 420    /// Изменился ли статус возврата или компенсации. Остальные поля (стоимость хранения, дни,
 421    /// прогноз утилизации) меняются у Ozon постоянно и поводом перезапрашивать статус заказа не являются.
 422    /// </summary>
 423    private static bool HasStatusChanged(OrderReturn existing, OrderReturn incoming) =>
 9424        !string.Equals(existing.VisualStatusSysName, incoming.VisualStatusSysName, StringComparison.Ordinal) ||
 9425        existing.VisualStatusChangeMoment != incoming.VisualStatusChangeMoment ||
 9426        !string.Equals(existing.CompensationStatusSysName, incoming.CompensationStatusSysName, StringComparison.Ordinal)
 9427        existing.CompensationStatusChangeMoment != incoming.CompensationStatusChangeMoment;
 428
 429    private static void UpdateFromEntity(OrderReturn target, OrderReturn source)
 430    {
 9431        target.OzonReturnId = source.OzonReturnId;
 432        // Связь с заказом не затираем: поиск заказа идёт только по неархивным
 433        // (OrderRepository.GetOrderIdsByShipmentNumbersAsync фильтрует OrderArchive == null),
 434        // поэтому после архивации заказа source.OrderId придёт null и уже найденная связь
 435        // была бы потеряна вместе с возвратом на странице заказа и в фильтрах.
 9436        target.OrderId = source.OrderId ?? target.OrderId;
 9437        target.PostingNumber = source.PostingNumber;
 9438        target.OrderNumber = source.OrderNumber;
 9439        target.ReturnReasonName = source.ReturnReasonName;
 9440        target.ReturnType = source.ReturnType;
 9441        target.Schema = source.Schema;
 9442        target.PlaceId = source.PlaceId;
 9443        target.PlaceName = source.PlaceName;
 9444        target.PlaceAddress = source.PlaceAddress;
 9445        target.TargetPlaceId = source.TargetPlaceId;
 9446        target.TargetPlaceName = source.TargetPlaceName;
 9447        target.TargetPlaceAddress = source.TargetPlaceAddress;
 9448        target.StorageSumPrice = source.StorageSumPrice;
 9449        target.StorageSumCurrency = source.StorageSumCurrency;
 9450        target.StorageDays = source.StorageDays;
 9451        target.StorageTarificationFirstDate = source.StorageTarificationFirstDate;
 9452        target.StorageTarificationStartDate = source.StorageTarificationStartDate;
 9453        target.StorageArrivedMoment = source.StorageArrivedMoment;
 9454        target.UtilizationSumPrice = source.UtilizationSumPrice;
 9455        target.UtilizationSumCurrency = source.UtilizationSumCurrency;
 9456        target.UtilizationForecastDate = source.UtilizationForecastDate;
 9457        target.ProductSku = source.ProductSku;
 9458        target.ProductOfferId = source.ProductOfferId;
 9459        target.ProductName = source.ProductName;
 9460        target.ProductPrice = source.ProductPrice;
 9461        target.ProductPriceCurrency = source.ProductPriceCurrency;
 9462        target.ProductCommissionPercent = source.ProductCommissionPercent;
 9463        target.ProductCommission = source.ProductCommission;
 9464        target.ProductQuantity = source.ProductQuantity;
 9465        target.TechnicalReturnMoment = source.TechnicalReturnMoment;
 9466        target.FinalMoment = source.FinalMoment;
 9467        target.CancelledWithCompensationMoment = source.CancelledWithCompensationMoment;
 9468        target.ReturnDate = source.ReturnDate;
 9469        target.LogisticBarcode = source.LogisticBarcode;
 9470        target.VisualStatusId = source.VisualStatusId;
 9471        target.VisualStatusDisplayName = source.VisualStatusDisplayName;
 9472        target.VisualStatusSysName = source.VisualStatusSysName;
 9473        target.VisualStatusChangeMoment = source.VisualStatusChangeMoment;
 9474        target.CompensationStatusId = source.CompensationStatusId;
 9475        target.CompensationStatusDisplayName = source.CompensationStatusDisplayName;
 9476        target.CompensationStatusSysName = source.CompensationStatusSysName;
 9477        target.CompensationStatusChangeMoment = source.CompensationStatusChangeMoment;
 9478        target.IsOpened = source.IsOpened;
 9479        target.IsSuperEconom = source.IsSuperEconom;
 9480        target.RawDataJson = source.RawDataJson;
 9481    }
 482}
 483
 484/// <summary>
 485/// Результат батч-upsert возвратов.
 486/// </summary>
 487/// <param name="Processed">Сколько записей пришло из API и было обработано.</param>
 488/// <param name="Inserted">Из них создано впервые.</param>
 489/// <param name="Changed">Записи, у которых изменился статус возврата или компенсации, включая новые.</param>
 490public sealed record OrderReturnUpsertResult(int Processed, int Inserted, IReadOnlyList<OrderReturn> Changed)
 491{
 492    public static readonly OrderReturnUpsertResult Empty = new(0, 0, Array.Empty<OrderReturn>());
 493
 494    /// <summary>Записи, которые пришли из API без единого изменения — их большинство на устоявшемся прогоне.</summary>
 495    public int Unchanged => Processed - Changed.Count;
 496}