< Summary

Information
Class: OzonRepositories.Data.OzonHandoverRepository
Assembly: OzonRepositories
File(s): /home/runner/work/Studio2Prod/Studio2Prod/OzonRepositories/Data/OzonHandoverRepository.cs
Line coverage
85%
Covered lines: 122
Uncovered lines: 20
Coverable lines: 142
Total lines: 236
Line coverage: 85.9%
Branch coverage
63%
Covered branches: 24
Total branches: 38
Branch coverage: 63.1%
Method coverage

Feature is only available for sponsors

Upgrade to PRO version

Metrics

MethodBranch coverage Crap Score Cyclomatic complexity Line coverage
.ctor(...)100%11100%
GetByClosedIdAsync()100%11100%
UpsertBatchAsync()88.88%181894.62%
IsSameAct(...)50%66100%
IsSameItem(...)33.33%11650%
RecomputeActSummary(...)100%11100%
IsStatus(...)100%11100%
ComputeActStatus(...)50%9657.14%
GetItemsByOrderIdsAsync()0%620%
GetByIdWithDetailsAsync()100%11100%

File(s)

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

#LineLine coverage
 1using Microsoft.EntityFrameworkCore;
 2using OzonDomains.Models.OzonHandover;
 3using OzonRepositories.Context;
 4
 5namespace OzonRepositories.Data;
 6
 7/// <summary>
 8/// Репозиторий для импорта актов выдачи/приёма-передачи Ozon (формат "Telegram Lite").
 9/// </summary>
 10public class OzonHandoverRepository : MainRepository
 11{
 612    public OzonHandoverRepository(ApplicationDbContext context) : base(context)
 13    {
 614    }
 15
 16    /// <summary>Найти существующий батч по ключу идемпотентности closed.id.</summary>
 17    public async Task<OzonHandoverImportBatch?> GetByClosedIdAsync(string closedId, CancellationToken ct = default)
 18    {
 719        return await _context.OzonHandoverImportBatches
 720            .FirstOrDefaultAsync(b => b.ClosedId == closedId, ct);
 721    }
 22
 23    /// <summary>
 24    /// Сохраняет батч. Если батч с таким ClosedId уже существует, слияние идёт поштучно:
 25    /// акт сопоставляется по (ActNo, ActDate, Pvz, ActType), позиция внутри акта — по ReturnId
 26    /// (а если его нет — по ItemNo+RowNo). Совпавшая позиция обновляется на месте (EF не шлёт
 27    /// UPDATE, если данные не изменились), несовпавшая — добавляется как новая. Повторный импорт
 28    /// того же документа не плодит дублей и не пересоздаёт то, что не изменилось.
 29    /// </summary>
 30    public async Task<OzonHandoverImportBatch> UpsertBatchAsync(OzonHandoverImportBatch batch, CancellationToken ct = de
 31    {
 832        OzonHandoverImportBatch? existing = null;
 833        if (!string.IsNullOrEmpty(batch.ClosedId))
 34        {
 835            existing = await _context.OzonHandoverImportBatches
 836                .Include(b => b.Acts)
 837                .ThenInclude(a => a.Items)
 838                .ThenInclude(i => i.Photos)
 839                .AsSplitQuery()
 840                .FirstOrDefaultAsync(b => b.ClosedId == batch.ClosedId, ct);
 41        }
 42
 843        if (existing == null)
 44        {
 2045            foreach (var act in batch.Acts)
 546                RecomputeActSummary(act);
 47
 548            batch.ImportedAt = DateTime.UtcNow;
 549            batch.UpdatedAt = DateTime.UtcNow;
 550            await _context.OzonHandoverImportBatches.AddAsync(batch, ct);
 551            await _context.SaveChangesAsync(ct);
 552            return batch;
 53        }
 54
 355        existing.DocumentType = batch.DocumentType;
 356        existing.GeneratedAt = batch.GeneratedAt;
 357        existing.ReturnType = batch.ReturnType;
 358        existing.ClosedKind = batch.ClosedKind;
 359        existing.SummaryActsCount = batch.SummaryActsCount;
 360        existing.SummaryItemsCount = batch.SummaryItemsCount;
 361        existing.SummaryTotalValue = batch.SummaryTotalValue;
 362        existing.SummaryAcceptedItemsCount = batch.SummaryAcceptedItemsCount;
 363        existing.SummaryAcceptedValue = batch.SummaryAcceptedValue;
 364        existing.SummaryRejectedItemsCount = batch.SummaryRejectedItemsCount;
 365        existing.SummaryRejectedValue = batch.SummaryRejectedValue;
 366        existing.SummaryPendingItemsCount = batch.SummaryPendingItemsCount;
 367        existing.SummaryPendingValue = batch.SummaryPendingValue;
 368        existing.SummaryDefectCount = batch.SummaryDefectCount;
 369        existing.SummaryMissortCount = batch.SummaryMissortCount;
 370        existing.MatchedItemsCount = batch.MatchedItemsCount;
 371        existing.UnmatchedItemsCount = batch.UnmatchedItemsCount;
 372        existing.RawJson = batch.RawJson;
 373        existing.UpdatedAt = DateTime.UtcNow;
 74
 1075        foreach (var incomingAct in batch.Acts)
 76        {
 477            var existingAct = existing.Acts.FirstOrDefault(a => IsSameAct(a, incomingAct));
 278            if (existingAct == null)
 79            {
 080                RecomputeActSummary(incomingAct);
 081                existing.Acts.Add(incomingAct);
 082                continue;
 83            }
 84
 285            existingAct.GroupKey = incomingAct.GroupKey;
 286            existingAct.GroupName = incomingAct.GroupName;
 287            existingAct.Source = incomingAct.Source;
 288            existingAct.AccountLabel = incomingAct.AccountLabel;
 289            existingAct.ReturnType = incomingAct.ReturnType;
 290            existingAct.BoxBarcode = incomingAct.BoxBarcode;
 291            existingAct.ReturnId = incomingAct.ReturnId;
 92
 893            foreach (var incomingItem in incomingAct.Items)
 94            {
 495                var existingItem = existingAct.Items.FirstOrDefault(i => IsSameItem(i, incomingItem));
 296                if (existingItem == null)
 97                {
 098                    existingAct.Items.Add(incomingItem);
 099                    continue;
 100                }
 101
 2102                existingItem.OrderId = incomingItem.OrderId;
 2103                existingItem.RowNo = incomingItem.RowNo;
 2104                existingItem.ItemNo = incomingItem.ItemNo;
 2105                existingItem.ProductName = incomingItem.ProductName;
 2106                existingItem.Barcode = incomingItem.Barcode;
 2107                existingItem.Value = incomingItem.Value;
 2108                existingItem.ValueApi = incomingItem.ValueApi;
 2109                existingItem.WeightG = incomingItem.WeightG;
 2110                existingItem.Status = incomingItem.Status;
 2111                existingItem.Defect = incomingItem.Defect;
 2112                existingItem.Missort = incomingItem.Missort;
 2113                existingItem.Comment = incomingItem.Comment;
 2114                existingItem.MarkedAt = incomingItem.MarkedAt;
 2115                existingItem.Sku = incomingItem.Sku;
 2116                existingItem.OfferId = incomingItem.OfferId;
 2117                existingItem.AccountLabel = incomingItem.AccountLabel;
 2118                existingItem.ReturnId = incomingItem.ReturnId;
 2119                existingItem.OzonStatus = incomingItem.OzonStatus;
 2120                existingItem.OzonStatusName = incomingItem.OzonStatusName;
 2121                existingItem.ReturnType = incomingItem.ReturnType;
 2122                existingItem.BoxBarcode = incomingItem.BoxBarcode;
 2123                existingItem.Reason = incomingItem.Reason;
 2124                existingItem.ReasonIsDefect = incomingItem.ReasonIsDefect;
 2125                existingItem.ClientComment = incomingItem.ClientComment;
 2126                existingItem.ClientPhotosExpiresAt = incomingItem.ClientPhotosExpiresAt;
 2127                existingItem.PhotosCount = incomingItem.PhotosCount;
 2128                existingItem.PhotosError = incomingItem.PhotosError;
 2129                existingItem.RejectionComment = incomingItem.RejectionComment;
 2130                existingItem.RejectionReason = incomingItem.RejectionReason;
 2131                existingItem.RuPostTracking = incomingItem.RuPostTracking;
 2132                existingItem.ReturnNumber = incomingItem.ReturnNumber;
 2133                existingItem.OrderNumber = incomingItem.OrderNumber;
 2134                existingItem.ClientReturnMethod = incomingItem.ClientReturnMethod;
 2135                existingItem.WarehouseId = incomingItem.WarehouseId;
 136                // При повторном импорте с уже истёкшими временными URL не теряем ранее
 137                // скачанные фотографии. Пустой список без ошибки означает, что источник
 138                // действительно больше не передал фото и должен очистить галерею.
 2139                if (incomingItem.Photos.Count > 0 || string.IsNullOrWhiteSpace(incomingItem.PhotosError))
 140                {
 1141                    _context.OzonHandoverActItemPhotos.RemoveRange(existingItem.Photos);
 1142                    existingItem.Photos.Clear();
 1143                    existingItem.Photos.AddRange(incomingItem.Photos);
 144                }
 145            }
 146
 2147            RecomputeActSummary(existingAct);
 148        }
 149
 3150        await _context.SaveChangesAsync(ct);
 3151        return existing;
 8152    }
 153
 154    /// <summary>Один и тот же акт при повторном импорте: совпадают номер, дата, ПВЗ и тип акта.</summary>
 155    private static bool IsSameAct(OzonHandoverAct existing, OzonHandoverAct incoming) =>
 2156        string.Equals(existing.ActNo, incoming.ActNo, StringComparison.OrdinalIgnoreCase) &&
 2157        existing.ActDate == incoming.ActDate &&
 2158        string.Equals(existing.Pvz, incoming.Pvz, StringComparison.OrdinalIgnoreCase) &&
 2159        string.Equals(existing.ActType, incoming.ActType, StringComparison.OrdinalIgnoreCase);
 160
 161    /// <summary>
 162    /// Одна и та же позиция при повторном импорте: приоритет — ReturnId (идентификатор возврата
 163    /// в Ozon, стабилен между экспортами). Если его нет ни у той, ни у другой стороны — запасной
 164    /// ключ ItemNo+RowNo.
 165    /// </summary>
 166    private static bool IsSameItem(OzonHandoverActItem existing, OzonHandoverActItem incoming)
 167    {
 2168        if (existing.ReturnId.HasValue && incoming.ReturnId.HasValue)
 2169            return existing.ReturnId == incoming.ReturnId;
 170
 0171        return string.Equals(existing.ItemNo, incoming.ItemNo, StringComparison.OrdinalIgnoreCase) &&
 0172               existing.RowNo == incoming.RowNo;
 173    }
 174
 175    /// <summary>
 176    /// Статус и денормализованную сводку акта пересчитываем из его текущего набора позиций —
 177    /// единообразно и для новых актов, и для слитых с уже существующими.
 178    /// </summary>
 179    private static void RecomputeActSummary(OzonHandoverAct act)
 180    {
 7181        act.ItemsCount = act.Items.Count;
 14182        act.TotalValue = act.Items.Sum(i => i.Value);
 14183        act.AcceptedCount = act.Items.Count(i => IsStatus(i, "accepted"));
 14184        act.RejectedCount = act.Items.Count(i => IsStatus(i, "rejected"));
 14185        act.PendingCount = act.Items.Count(i => IsStatus(i, "pending"));
 14186        act.DefectCount = act.Items.Count(i => i.Defect);
 14187        act.MissortCount = act.Items.Count(i => i.Missort);
 7188        act.Status = ComputeActStatus(act.Items);
 7189    }
 190
 191    private static bool IsStatus(OzonHandoverActItem item, string status) =>
 35192        string.Equals(item.Status, status, StringComparison.OrdinalIgnoreCase);
 193
 194    /// <summary>
 195    /// Статус акта — только accepted / rejected / partially_accepted (как в источнике,
 196    /// см. _act_status()); "pending" на уровне акта не существует. Все позиции accepted →
 197    /// accepted; все rejected → rejected; любая иная смесь (включая pending) — partially_accepted.
 198    /// </summary>
 199    private static string ComputeActStatus(IReadOnlyCollection<OzonHandoverActItem> items)
 200    {
 7201        if (items.Count == 0)
 0202            return "partially_accepted";
 203
 14204        if (items.All(i => IsStatus(i, "accepted")))
 0205            return "accepted";
 206
 14207        if (items.All(i => IsStatus(i, "rejected")))
 0208            return "rejected";
 209
 7210        return "partially_accepted";
 211    }
 212
 213    public async Task<List<OzonHandoverActItem>> GetItemsByOrderIdsAsync(IEnumerable<int> orderIds, CancellationToken ct
 214    {
 0215        var ids = orderIds.Distinct().ToList();
 0216        if (ids.Count == 0)
 0217            return new List<OzonHandoverActItem>();
 218
 0219        return await _context.OzonHandoverActItems
 0220            .AsNoTracking()
 0221            .Include(i => i.Act)
 0222            .Include(i => i.Photos)
 0223            .Where(i => i.OrderId != null && ids.Contains(i.OrderId.Value))
 0224            .ToListAsync(ct);
 0225    }
 226
 227    public async Task<OzonHandoverImportBatch?> GetByIdWithDetailsAsync(int id, CancellationToken ct = default)
 228    {
 1229        return await _context.OzonHandoverImportBatches
 1230            .AsNoTracking()
 1231            .Include(b => b.Acts)
 1232            .ThenInclude(a => a.Items)
 1233            .AsSplitQuery()
 1234            .FirstOrDefaultAsync(b => b.Id == id, ct);
 1235    }
 236}