< Summary

Information
Class: Services.LabelServices.OzonExemplarService
Assembly: Services
File(s): /home/runner/work/Studio2Prod/Studio2Prod/AppRepository/LabelServices/OzonExemplarService.cs
Line coverage
44%
Covered lines: 62
Uncovered lines: 77
Coverable lines: 139
Total lines: 361
Line coverage: 44.6%
Branch coverage
51%
Covered branches: 73
Total branches: 142
Branch coverage: 51.4%
Method coverage

Feature is only available for sponsors

Upgrade to PRO version

Metrics

MethodBranch coverage Crap Score Cyclomatic complexity Line coverage
.ctor(...)100%210%
SetClient(...)100%210%
GetRequirementsAsync()0%812280%
SaveAsync()0%156120%
BuildSetRequest(...)81.25%3232100%
BuildSetItem(...)78.33%676087.5%
GetProductNamesAsync()0%110100%

File(s)

/home/runner/work/Studio2Prod/Studio2Prod/AppRepository/LabelServices/OzonExemplarService.cs

#LineLine coverage
 1using Newtonsoft.Json.Linq;
 2using OzonDomains.Models;
 3using Services.ApiServices.OzonApi;
 4using Services.ApiServices.OzonApi.Filters;
 5
 6namespace Services.LabelServices;
 7
 8/// <summary>Требование Ozon по экземпляру, привязанное к заказу системы (для UI).</summary>
 9public sealed class OzonExemplarOrderRequirement
 10{
 11    public int OrderId { get; init; }
 12
 13    public string ShipmentNumber { get; init; } = string.Empty;
 14
 15    public string ProductName { get; init; } = string.Empty;
 16
 17    public string OfferId { get; init; } = string.Empty;
 18
 19    public OzonExemplarRequirement Requirement { get; init; } = new();
 20}
 21
 22/// <summary>Данные одного экземпляра, введённые пользователем в форме перед печатью.</summary>
 23public sealed class OzonExemplarInput
 24{
 25    public long ProductId { get; set; }
 26
 27    public long ExemplarId { get; set; }
 28
 29    public string? MandatoryMark { get; set; }
 30
 31    public string? Gtd { get; set; }
 32
 33    public bool IsGtdAbsent { get; set; }
 34
 35    public string? Rnpt { get; set; }
 36
 37    public bool IsRnptAbsent { get; set; }
 38}
 39
 40public interface IOzonExemplarService
 41{
 42    /// <summary>
 43    /// Устанавливает Client-Id/Api-Key Ozon. Вызывать обязательно перед остальными методами —
 44    /// у сервиса свой (DI, transient) <see cref="OzonApiDataManager"/>, не связанный с вызывающим кодом.
 45    /// </summary>
 46    void SetClient(string clientId, string apiKey);
 47
 48    /// <summary>
 49    /// Требования Ozon по экземплярам для переданных заказов. Отправления дедуплицируются по
 50    /// нормализованному posting: studio-split части (<c>/1</c>, <c>/2</c>) ведут на один posting Ozon.
 51    /// </summary>
 52    Task<List<OzonExemplarOrderRequirement>> GetRequirementsAsync(
 53        IEnumerable<Order> orders,
 54        CancellationToken cancellationToken = default);
 55
 56    /// <summary>
 57    /// Сохраняет данные экземпляров отправления и дожидается вердикта Ozon о готовности к сборке.
 58    /// </summary>
 59    Task<OzonExemplarSaveResult> SaveAsync(
 60        string shipmentNumber,
 61        IReadOnlyList<OzonExemplarInput> inputs,
 62        CancellationToken cancellationToken = default);
 63}
 64
 65/// <summary>
 66/// Работа с данными экземпляров Ozon (Честный ЗНАК, ГТД, РНПТ) перед сборкой FBS-отправления.
 67/// Пайплайн: create-or-get → set → status (до <c>ship_available</c>) → ship.
 68/// </summary>
 69public sealed class OzonExemplarService : IOzonExemplarService
 70{
 71    private const int StatusPollAttempts = 5;
 72    private const int StatusPollDelayMs = 2000;
 73
 74    private OzonApiDataManager _ozonApiDataManager;
 75
 076    public OzonExemplarService(OzonApiDataManager ozonApiDataManager)
 77    {
 078        _ozonApiDataManager = ozonApiDataManager;
 079    }
 80
 81    public void SetClient(string clientId, string apiKey)
 82    {
 083        _ozonApiDataManager = _ozonApiDataManager.SetClient(clientId, apiKey);
 084    }
 85
 86    public async Task<List<OzonExemplarOrderRequirement>> GetRequirementsAsync(
 87        IEnumerable<Order> orders,
 88        CancellationToken cancellationToken = default)
 89    {
 090        var result = new List<OzonExemplarOrderRequirement>();
 091        var processedPostings = new HashSet<string>(StringComparer.Ordinal);
 92
 093        foreach (var order in orders)
 94        {
 095            cancellationToken.ThrowIfCancellationRequested();
 96
 097            if (string.IsNullOrWhiteSpace(order.ShipmentNumber) ||
 098                !OzonLabelOrderEligibility.IsOzonOrder(
 099                    order.OzonClient?.ClientType, order.ShipmentNumber, order.OzonClient?.Id))
 100            {
 101                continue;
 102            }
 103
 0104            var apiPosting = OzonPostingNumberHelper.GetOzonApiPostingNumber(order.ShipmentNumber);
 0105            if (!processedPostings.Add(apiPosting))
 106            {
 107                continue;
 108            }
 109
 110            List<OzonExemplarRequirement> requirements;
 111            try
 112            {
 0113                var response = await _ozonApiDataManager
 0114                    .GetFbsExemplarsCreateOrGetAsync(apiPosting)
 0115                    .ConfigureAwait(false);
 0116                requirements = OzonExemplarHelper.ParseRequirements(response);
 0117            }
 0118            catch
 119            {
 120                // Отправление могло быть отменено/не найдено — не роняем проверку остальных заказов.
 0121                continue;
 122            }
 123
 0124            if (requirements.Count == 0)
 125            {
 126                continue;
 127            }
 128
 0129            var productNames = await GetProductNamesAsync(apiPosting).ConfigureAwait(false);
 130
 0131            foreach (var requirement in requirements)
 132            {
 0133                productNames.TryGetValue(requirement.ProductId, out var product);
 0134                result.Add(new OzonExemplarOrderRequirement
 0135                {
 0136                    OrderId = order.Id,
 0137                    ShipmentNumber = order.ShipmentNumber,
 0138                    ProductName = product.Name ?? order.ProductInfo?.Article ?? "Товар",
 0139                    OfferId = product.OfferId ?? order.ProductInfo?.Article ?? string.Empty,
 0140                    Requirement = requirement
 0141                });
 142            }
 0143        }
 144
 0145        return result;
 0146    }
 147
 148    public async Task<OzonExemplarSaveResult> SaveAsync(
 149        string shipmentNumber,
 150        IReadOnlyList<OzonExemplarInput> inputs,
 151        CancellationToken cancellationToken = default)
 152    {
 0153        var apiPosting = OzonPostingNumberHelper.GetOzonApiPostingNumber(shipmentNumber);
 154
 155        // Ozon требует передавать полный набор экземпляров отправления, а не только изменённые,
 156        // поэтому берём актуальный состав и накладываем на него введённые данные.
 0157        var current = await _ozonApiDataManager.GetFbsExemplarsCreateOrGetAsync(apiPosting)
 0158            .ConfigureAwait(false);
 159
 0160        var request = BuildSetRequest(apiPosting, current, inputs);
 0161        if (request.Products.Count == 0)
 162        {
 0163            return new OzonExemplarSaveResult
 0164            {
 0165                ShipAvailable = false,
 0166                Reasons = ["Ozon не вернул экземпляры для этого отправления."]
 0167            };
 168        }
 169
 0170        await _ozonApiDataManager.SetFbsExemplarsAsync(request).ConfigureAwait(false);
 171
 0172        IReadOnlyList<string> reasons = [];
 0173        for (var attempt = 0; attempt < StatusPollAttempts; attempt++)
 174        {
 0175            cancellationToken.ThrowIfCancellationRequested();
 0176            await Task.Delay(StatusPollDelayMs, cancellationToken).ConfigureAwait(false);
 177
 0178            var status = await _ozonApiDataManager.GetFbsExemplarStatusAsync(apiPosting)
 0179                .ConfigureAwait(false);
 180
 0181            if (OzonExemplarHelper.IsShipAvailable(status, out reasons))
 182            {
 0183                return new OzonExemplarSaveResult { ShipAvailable = true };
 184            }
 185
 0186            var statusValue = status["status"]?.Value<string>() ?? string.Empty;
 0187            if (!statusValue.Equals(OzonExemplarHelper.ValidationInProgressStatus,
 0188                    StringComparison.OrdinalIgnoreCase))
 189            {
 190                break;
 191            }
 192        }
 193
 0194        return new OzonExemplarSaveResult { ShipAvailable = false, Reasons = reasons };
 0195    }
 196
 197    /// <summary>
 198    /// Собирает запрос set: экземпляры из ответа Ozon, поверх — введённые пользователем данные.
 199    /// Сопоставление идёт по <c>exemplar_id</c>, а при его отсутствии — по порядку в рамках товара.
 200    /// </summary>
 201    internal static FbsExemplarSetRequest BuildSetRequest(
 202        string apiPosting,
 203        JObject? createOrGetResponse,
 204        IReadOnlyList<OzonExemplarInput> inputs)
 205    {
 5206        var request = new FbsExemplarSetRequest { PostingNumber = apiPosting };
 207
 5208        var multiBoxQty = createOrGetResponse?["multi_box_qty"]?.Value<int>() ?? 0;
 5209        if (multiBoxQty > 0)
 210        {
 3211            request.MultiBoxQty = multiBoxQty;
 212        }
 213
 18214        foreach (var product in (createOrGetResponse?["products"] as JArray)?.OfType<JObject>() ?? [])
 215        {
 4216            var productId = product["product_id"]?.Value<long>() ?? 0;
 4217            if (productId <= 0)
 218            {
 219                continue;
 220            }
 221
 9222            var productInputs = inputs.Where(input => input.ProductId == productId).ToList();
 4223            var exemplars = (product["exemplars"] as JArray)?.OfType<JObject>().ToList() ?? [];
 4224            var setProduct = new FbsExemplarSetProduct { ProductId = productId };
 225
 22226            for (var index = 0; index < exemplars.Count; index++)
 227            {
 7228                var exemplar = exemplars[index];
 7229                var exemplarId = exemplar["exemplar_id"]?.Value<long>() ?? 0;
 7230                if (exemplarId <= 0)
 231                {
 232                    continue;
 233                }
 234
 15235                var input = productInputs.FirstOrDefault(item => item.ExemplarId == exemplarId)
 7236                            ?? productInputs.ElementAtOrDefault(index);
 237
 7238                setProduct.Exemplars.Add(BuildSetItem(exemplarId, exemplar, input));
 239            }
 240
 4241            if (setProduct.Exemplars.Count > 0)
 242            {
 4243                request.Products.Add(setProduct);
 244            }
 245        }
 246
 5247        return request;
 248    }
 249
 250    private static FbsExemplarSetItem BuildSetItem(long exemplarId, JObject existing, OzonExemplarInput? input)
 251    {
 7252        var item = new FbsExemplarSetItem { ExemplarId = exemplarId };
 253
 7254        var existingMarks = (existing["marks"] as JArray)?.OfType<JObject>()
 1255            .Select(mark => new FbsExemplarMark
 1256            {
 1257                Mark = mark["mark"]?.Value<string>() ?? string.Empty,
 1258                MarkType = mark["mark_type"]?.Value<string>() ?? OzonExemplarMarkType.MandatoryMark
 1259            })
 1260            .Where(mark => !string.IsNullOrWhiteSpace(mark.Mark))
 7261            .ToList();
 262
 7263        if (!string.IsNullOrWhiteSpace(input?.MandatoryMark))
 264        {
 5265            item.Marks =
 5266            [
 5267                new FbsExemplarMark
 5268                {
 5269                    Mark = input.MandatoryMark,
 5270                    MarkType = OzonExemplarMarkType.MandatoryMark
 5271                }
 5272            ];
 273        }
 2274        else if (existingMarks is { Count: > 0 })
 275        {
 1276            item.Marks = existingMarks;
 277        }
 278
 7279        var gtd = input?.Gtd?.Trim();
 7280        var gtdAbsent = input?.IsGtdAbsent ?? false;
 7281        if (!string.IsNullOrWhiteSpace(gtd))
 282        {
 1283            item.Gtd = gtd;
 1284            item.IsGtdAbsent = false;
 285        }
 6286        else if (gtdAbsent)
 287        {
 4288            item.IsGtdAbsent = true;
 289        }
 290        else
 291        {
 2292            var existingGtd = existing["gtd"]?.Value<string>();
 2293            if (!string.IsNullOrWhiteSpace(existingGtd))
 294            {
 1295                item.Gtd = existingGtd;
 296            }
 1297            else if (existing["is_gtd_absent"]?.Value<bool>() == true)
 298            {
 0299                item.IsGtdAbsent = true;
 300            }
 301        }
 302
 7303        var rnpt = input?.Rnpt?.Trim();
 7304        var rnptAbsent = input?.IsRnptAbsent ?? false;
 7305        if (!string.IsNullOrWhiteSpace(rnpt))
 306        {
 0307            item.Rnpt = rnpt;
 0308            item.IsRnptAbsent = false;
 309        }
 7310        else if (rnptAbsent)
 311        {
 0312            item.IsRnptAbsent = true;
 313        }
 314        else
 315        {
 7316            var existingRnpt = existing["rnpt"]?.Value<string>();
 7317            if (!string.IsNullOrWhiteSpace(existingRnpt))
 318            {
 0319                item.Rnpt = existingRnpt;
 320            }
 7321            else if (existing["is_rnpt_absent"]?.Value<bool>() == true)
 322            {
 0323                item.IsRnptAbsent = true;
 324            }
 325        }
 326
 7327        var existingWeight = existing["weight"]?.Value<double?>();
 7328        if (existingWeight is > 0)
 329        {
 1330            item.Weight = existingWeight;
 331        }
 332
 7333        return item;
 334    }
 335
 336    private async Task<Dictionary<long, (string? Name, string? OfferId)>> GetProductNamesAsync(string apiPosting)
 337    {
 338        try
 339        {
 0340            var (_, products) = await _ozonApiDataManager.GetFbsPostingRequirementsAsync(apiPosting)
 0341                .ConfigureAwait(false);
 342
 0343            var map = new Dictionary<long, (string? Name, string? OfferId)>();
 0344            foreach (var product in products)
 345            {
 0346                var sku = product["sku"]?.Value<long>() ?? 0;
 0347                if (sku > 0)
 348                {
 0349                    map[sku] = (product["name"]?.Value<string>(), product["offer_id"]?.Value<string>());
 350                }
 351            }
 352
 0353            return map;
 354        }
 0355        catch
 356        {
 357            // Названия товаров — украшение формы, без них требования всё равно показываем.
 0358            return new Dictionary<long, (string? Name, string? OfferId)>();
 359        }
 0360    }
 361}