< Summary

Information
Class: Services.LabelServices.OzonExemplarOrderRequirement
Assembly: Services
File(s): /home/runner/work/Studio2Prod/Studio2Prod/AppRepository/LabelServices/OzonExemplarService.cs
Line coverage
0%
Covered lines: 0
Uncovered lines: 5
Coverable lines: 5
Total lines: 361
Line coverage: 0%
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_OrderId()100%210%
get_ShipmentNumber()100%210%
get_ProductName()100%210%
get_OfferId()100%210%
get_Requirement()100%210%

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{
 011    public int OrderId { get; init; }
 12
 013    public string ShipmentNumber { get; init; } = string.Empty;
 14
 015    public string ProductName { get; init; } = string.Empty;
 16
 017    public string OfferId { get; init; } = string.Empty;
 18
 019    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
 76    public OzonExemplarService(OzonApiDataManager ozonApiDataManager)
 77    {
 78        _ozonApiDataManager = ozonApiDataManager;
 79    }
 80
 81    public void SetClient(string clientId, string apiKey)
 82    {
 83        _ozonApiDataManager = _ozonApiDataManager.SetClient(clientId, apiKey);
 84    }
 85
 86    public async Task<List<OzonExemplarOrderRequirement>> GetRequirementsAsync(
 87        IEnumerable<Order> orders,
 88        CancellationToken cancellationToken = default)
 89    {
 90        var result = new List<OzonExemplarOrderRequirement>();
 91        var processedPostings = new HashSet<string>(StringComparer.Ordinal);
 92
 93        foreach (var order in orders)
 94        {
 95            cancellationToken.ThrowIfCancellationRequested();
 96
 97            if (string.IsNullOrWhiteSpace(order.ShipmentNumber) ||
 98                !OzonLabelOrderEligibility.IsOzonOrder(
 99                    order.OzonClient?.ClientType, order.ShipmentNumber, order.OzonClient?.Id))
 100            {
 101                continue;
 102            }
 103
 104            var apiPosting = OzonPostingNumberHelper.GetOzonApiPostingNumber(order.ShipmentNumber);
 105            if (!processedPostings.Add(apiPosting))
 106            {
 107                continue;
 108            }
 109
 110            List<OzonExemplarRequirement> requirements;
 111            try
 112            {
 113                var response = await _ozonApiDataManager
 114                    .GetFbsExemplarsCreateOrGetAsync(apiPosting)
 115                    .ConfigureAwait(false);
 116                requirements = OzonExemplarHelper.ParseRequirements(response);
 117            }
 118            catch
 119            {
 120                // Отправление могло быть отменено/не найдено — не роняем проверку остальных заказов.
 121                continue;
 122            }
 123
 124            if (requirements.Count == 0)
 125            {
 126                continue;
 127            }
 128
 129            var productNames = await GetProductNamesAsync(apiPosting).ConfigureAwait(false);
 130
 131            foreach (var requirement in requirements)
 132            {
 133                productNames.TryGetValue(requirement.ProductId, out var product);
 134                result.Add(new OzonExemplarOrderRequirement
 135                {
 136                    OrderId = order.Id,
 137                    ShipmentNumber = order.ShipmentNumber,
 138                    ProductName = product.Name ?? order.ProductInfo?.Article ?? "Товар",
 139                    OfferId = product.OfferId ?? order.ProductInfo?.Article ?? string.Empty,
 140                    Requirement = requirement
 141                });
 142            }
 143        }
 144
 145        return result;
 146    }
 147
 148    public async Task<OzonExemplarSaveResult> SaveAsync(
 149        string shipmentNumber,
 150        IReadOnlyList<OzonExemplarInput> inputs,
 151        CancellationToken cancellationToken = default)
 152    {
 153        var apiPosting = OzonPostingNumberHelper.GetOzonApiPostingNumber(shipmentNumber);
 154
 155        // Ozon требует передавать полный набор экземпляров отправления, а не только изменённые,
 156        // поэтому берём актуальный состав и накладываем на него введённые данные.
 157        var current = await _ozonApiDataManager.GetFbsExemplarsCreateOrGetAsync(apiPosting)
 158            .ConfigureAwait(false);
 159
 160        var request = BuildSetRequest(apiPosting, current, inputs);
 161        if (request.Products.Count == 0)
 162        {
 163            return new OzonExemplarSaveResult
 164            {
 165                ShipAvailable = false,
 166                Reasons = ["Ozon не вернул экземпляры для этого отправления."]
 167            };
 168        }
 169
 170        await _ozonApiDataManager.SetFbsExemplarsAsync(request).ConfigureAwait(false);
 171
 172        IReadOnlyList<string> reasons = [];
 173        for (var attempt = 0; attempt < StatusPollAttempts; attempt++)
 174        {
 175            cancellationToken.ThrowIfCancellationRequested();
 176            await Task.Delay(StatusPollDelayMs, cancellationToken).ConfigureAwait(false);
 177
 178            var status = await _ozonApiDataManager.GetFbsExemplarStatusAsync(apiPosting)
 179                .ConfigureAwait(false);
 180
 181            if (OzonExemplarHelper.IsShipAvailable(status, out reasons))
 182            {
 183                return new OzonExemplarSaveResult { ShipAvailable = true };
 184            }
 185
 186            var statusValue = status["status"]?.Value<string>() ?? string.Empty;
 187            if (!statusValue.Equals(OzonExemplarHelper.ValidationInProgressStatus,
 188                    StringComparison.OrdinalIgnoreCase))
 189            {
 190                break;
 191            }
 192        }
 193
 194        return new OzonExemplarSaveResult { ShipAvailable = false, Reasons = reasons };
 195    }
 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    {
 206        var request = new FbsExemplarSetRequest { PostingNumber = apiPosting };
 207
 208        var multiBoxQty = createOrGetResponse?["multi_box_qty"]?.Value<int>() ?? 0;
 209        if (multiBoxQty > 0)
 210        {
 211            request.MultiBoxQty = multiBoxQty;
 212        }
 213
 214        foreach (var product in (createOrGetResponse?["products"] as JArray)?.OfType<JObject>() ?? [])
 215        {
 216            var productId = product["product_id"]?.Value<long>() ?? 0;
 217            if (productId <= 0)
 218            {
 219                continue;
 220            }
 221
 222            var productInputs = inputs.Where(input => input.ProductId == productId).ToList();
 223            var exemplars = (product["exemplars"] as JArray)?.OfType<JObject>().ToList() ?? [];
 224            var setProduct = new FbsExemplarSetProduct { ProductId = productId };
 225
 226            for (var index = 0; index < exemplars.Count; index++)
 227            {
 228                var exemplar = exemplars[index];
 229                var exemplarId = exemplar["exemplar_id"]?.Value<long>() ?? 0;
 230                if (exemplarId <= 0)
 231                {
 232                    continue;
 233                }
 234
 235                var input = productInputs.FirstOrDefault(item => item.ExemplarId == exemplarId)
 236                            ?? productInputs.ElementAtOrDefault(index);
 237
 238                setProduct.Exemplars.Add(BuildSetItem(exemplarId, exemplar, input));
 239            }
 240
 241            if (setProduct.Exemplars.Count > 0)
 242            {
 243                request.Products.Add(setProduct);
 244            }
 245        }
 246
 247        return request;
 248    }
 249
 250    private static FbsExemplarSetItem BuildSetItem(long exemplarId, JObject existing, OzonExemplarInput? input)
 251    {
 252        var item = new FbsExemplarSetItem { ExemplarId = exemplarId };
 253
 254        var existingMarks = (existing["marks"] as JArray)?.OfType<JObject>()
 255            .Select(mark => new FbsExemplarMark
 256            {
 257                Mark = mark["mark"]?.Value<string>() ?? string.Empty,
 258                MarkType = mark["mark_type"]?.Value<string>() ?? OzonExemplarMarkType.MandatoryMark
 259            })
 260            .Where(mark => !string.IsNullOrWhiteSpace(mark.Mark))
 261            .ToList();
 262
 263        if (!string.IsNullOrWhiteSpace(input?.MandatoryMark))
 264        {
 265            item.Marks =
 266            [
 267                new FbsExemplarMark
 268                {
 269                    Mark = input.MandatoryMark,
 270                    MarkType = OzonExemplarMarkType.MandatoryMark
 271                }
 272            ];
 273        }
 274        else if (existingMarks is { Count: > 0 })
 275        {
 276            item.Marks = existingMarks;
 277        }
 278
 279        var gtd = input?.Gtd?.Trim();
 280        var gtdAbsent = input?.IsGtdAbsent ?? false;
 281        if (!string.IsNullOrWhiteSpace(gtd))
 282        {
 283            item.Gtd = gtd;
 284            item.IsGtdAbsent = false;
 285        }
 286        else if (gtdAbsent)
 287        {
 288            item.IsGtdAbsent = true;
 289        }
 290        else
 291        {
 292            var existingGtd = existing["gtd"]?.Value<string>();
 293            if (!string.IsNullOrWhiteSpace(existingGtd))
 294            {
 295                item.Gtd = existingGtd;
 296            }
 297            else if (existing["is_gtd_absent"]?.Value<bool>() == true)
 298            {
 299                item.IsGtdAbsent = true;
 300            }
 301        }
 302
 303        var rnpt = input?.Rnpt?.Trim();
 304        var rnptAbsent = input?.IsRnptAbsent ?? false;
 305        if (!string.IsNullOrWhiteSpace(rnpt))
 306        {
 307            item.Rnpt = rnpt;
 308            item.IsRnptAbsent = false;
 309        }
 310        else if (rnptAbsent)
 311        {
 312            item.IsRnptAbsent = true;
 313        }
 314        else
 315        {
 316            var existingRnpt = existing["rnpt"]?.Value<string>();
 317            if (!string.IsNullOrWhiteSpace(existingRnpt))
 318            {
 319                item.Rnpt = existingRnpt;
 320            }
 321            else if (existing["is_rnpt_absent"]?.Value<bool>() == true)
 322            {
 323                item.IsRnptAbsent = true;
 324            }
 325        }
 326
 327        var existingWeight = existing["weight"]?.Value<double?>();
 328        if (existingWeight is > 0)
 329        {
 330            item.Weight = existingWeight;
 331        }
 332
 333        return item;
 334    }
 335
 336    private async Task<Dictionary<long, (string? Name, string? OfferId)>> GetProductNamesAsync(string apiPosting)
 337    {
 338        try
 339        {
 340            var (_, products) = await _ozonApiDataManager.GetFbsPostingRequirementsAsync(apiPosting)
 341                .ConfigureAwait(false);
 342
 343            var map = new Dictionary<long, (string? Name, string? OfferId)>();
 344            foreach (var product in products)
 345            {
 346                var sku = product["sku"]?.Value<long>() ?? 0;
 347                if (sku > 0)
 348                {
 349                    map[sku] = (product["name"]?.Value<string>(), product["offer_id"]?.Value<string>());
 350                }
 351            }
 352
 353            return map;
 354        }
 355        catch
 356        {
 357            // Названия товаров — украшение формы, без них требования всё равно показываем.
 358            return new Dictionary<long, (string? Name, string? OfferId)>();
 359        }
 360    }
 361}