< Summary

Information
Class: Services.SupplierStatusServices.Providers.FavoritPartsSupplierStatusProvider
Assembly: Services
File(s): /home/runner/work/Studio2Prod/Studio2Prod/AppRepository/SupplierStatusServices/Providers/FavoritPartsSupplierStatusProvider.cs
Line coverage
97%
Covered lines: 180
Uncovered lines: 5
Coverable lines: 185
Total lines: 325
Line coverage: 97.2%
Branch coverage
84%
Covered branches: 86
Total branches: 102
Branch coverage: 84.3%
Method coverage

Feature is only available for sponsors

Upgrade to PRO version

Metrics

MethodBranch coverage Crap Score Cyclomatic complexity Line coverage
.ctor(...)100%11100%
get_ProviderKey()100%11100%
FetchCartAsync()100%1010100%
CartStatusName(...)100%1212100%
FetchOrderAsync()85.71%2828100%
<FetchOrderAsync()100%11100%
get_StudioOrderId()100%11100%
SplitQuantities(...)92.85%1414100%
.cctor()100%11100%
PersistOrderStateAsync()70.83%252489.47%
MatchesPosition(...)70%111080%
ReadDateTime(...)75%44100%

File(s)

/home/runner/work/Studio2Prod/Studio2Prod/AppRepository/SupplierStatusServices/Providers/FavoritPartsSupplierStatusProvider.cs

#LineLine coverage
 1using System.Globalization;
 2using System.Text.Encodings.Web;
 3using System.Text.Json;
 4using Microsoft.EntityFrameworkCore;
 5using Microsoft.Extensions.Logging;
 6using Newtonsoft.Json.Linq;
 7using OzonDomains.Models;
 8using OzonDomains.Models.SupplierStatuses;
 9using OzonRepositories.Context;
 10using Services.ApiServices.FavoritPartsApi;
 11using Services.SignalRServices;
 12
 13namespace Services.SupplierStatusServices.Providers;
 14
 15/// <summary>
 16/// Статусы FavoritParts. Корзина локальная — читается из БД (LocalCartStatusReader); заказ опрашивается
 17/// через GET /ws/v1/order/{id}/, по одному вызову на локально известный внешний GUID заказа (ответ уже
 18/// содержит объект status {id, name} — отдельного похода в справочник statuses не нужно).
 19/// Из состава заказа берётся countCancel — количество, которое поставщик отгрузить не сможет;
 20/// version/number/postError сохраняются в FavoritPartsOrders, чтобы было видно, проведён ли заказ
 21/// в учётной системе Фаворита и менялся ли он после создания.
 22/// </summary>
 23public class FavoritPartsSupplierStatusProvider : ISupplierStatusProvider
 24{
 25    /// <summary>Один заказ — отдельный запрос; та же граница, что у Forum-Auto/ML-Auto.</summary>
 26    private const int DetailsConcurrency = 4;
 27
 28    private readonly ApplicationDbContext _db;
 29    private readonly FavoritPartsDataManager _favoritParts;
 30    private readonly ILogger<FavoritPartsSupplierStatusProvider> _logger;
 31
 3432    public FavoritPartsSupplierStatusProvider(
 3433        ApplicationDbContext db, FavoritPartsDataManager favoritParts,
 3434        ILogger<FavoritPartsSupplierStatusProvider> logger,
 3435        string providerKey = ApiSupplierSettings.FavoritPartsProviderKey)
 36    {
 3437        _db = db;
 3438        _favoritParts = favoritParts;
 3439        _logger = logger;
 3440        ProviderKey = providerKey;
 3441    }
 42
 4443    public string ProviderKey { get; }
 44
 45    /// <summary>
 46    /// Корзина FavoritParts серверная, поэтому локальной таблицы недостаточно: позицию могли убрать
 47    /// на сайте поставщика или прямым вызовом API мимо Studio2, и тогда локальная строка навсегда
 48    /// осталась бы в «В корзине». Сверяем локальные привязки с реальным составом GET ws/v1/cart/:
 49    /// чего в корзине нет — закрываем как Removed, в том числе в самой таблице привязок.
 50    /// </summary>
 51    public async Task<IReadOnlyList<SupplierStatusSnapshot>> FetchCartAsync(
 52        IReadOnlyList<OrderSupplierStateRef> states, CancellationToken ct)
 53    {
 2154        if (states.Count == 0) return [];
 55
 56        IReadOnlyCollection<(Guid Goods, Guid WarehouseGroup)> positions;
 57        try
 58        {
 1759            positions = await _favoritParts.GetCartPositionsAsync(ct);
 1560        }
 261        catch (Exception ex) when (ex is FavoritPartsApiException or HttpRequestException or TaskCanceledException)
 62        {
 63            // Недоступность API не должна выглядеть как выход из корзины — отдаём ошибку синхронизации.
 264            _logger.LogInformation("FavoritParts: не удалось прочитать корзину для сверки статусов: {Error}", ex.Message
 465            return states.Select(state => new SupplierStatusSnapshot
 466            {
 467                OrderId = state.OrderId, Kind = SupplierStatusKind.Cart, StatusName = string.Empty,
 468                Error = $"FavoritParts: {ex.Message}"
 469            }).ToList();
 70        }
 71
 3072        var orderIds = states.Select(x => x.OrderId).Distinct().ToList();
 1573        var items = await _db.FavoritPartsCartItems
 1574            .Where(x => x.ProviderKey == ProviderKey && x.ActiveStudioOrderId != null
 1575                        && orderIds.Contains(x.ActiveStudioOrderId.Value))
 1576            .ToListAsync(ct);
 1577        var byOrderId = items
 1578            .GroupBy(x => x.ActiveStudioOrderId!.Value)
 5879            .ToDictionary(g => g.Key, g => g.OrderByDescending(x => x.UpdatedAtUtc).First());
 80
 1581        var result = new List<SupplierStatusSnapshot>(states.Count);
 6082        foreach (var state in states)
 83        {
 84            string code;
 1585            if (!byOrderId.TryGetValue(state.OrderId, out var item))
 86            {
 87                // Привязки нет вовсе — позиция ушла из корзины мимо нас.
 188                code = SupplierCartStatusCode.Removed;
 89            }
 1490            else if (!positions.Contains((item.GoodsId, item.WarehouseGroupId)))
 91            {
 292                code = SupplierCartStatusCode.Removed;
 293                item.Status = FavoritPartsCartItemStatus.Removed;
 294                item.ActiveStudioOrderId = null;
 295                item.UpdatedAtUtc = DateTime.UtcNow;
 96            }
 97            else
 98            {
 1299                code = string.IsNullOrWhiteSpace(item.Status) ? SupplierCartStatusCode.Pending : item.Status;
 100            }
 101
 15102            result.Add(new SupplierStatusSnapshot
 15103            {
 15104                OrderId = state.OrderId,
 15105                Kind = SupplierStatusKind.Cart,
 15106                ExternalCode = code,
 15107                StatusName = CartStatusName(code)
 15108            });
 109        }
 110
 15111        await _db.SaveChangesAsync(ct);
 15112        return result;
 19113    }
 114
 15115    private static string CartStatusName(string code) => code switch
 15116    {
 4117        SupplierCartStatusCode.Pending => "В корзине, нужна проверка",
 3118        SupplierCartStatusCode.Ready => "Проверен, готов к заказу",
 1119        SupplierCartStatusCode.Processing => "Оформляется",
 1120        SupplierCartStatusCode.Ordered => "Заказан у поставщика",
 1121        SupplierCartStatusCode.Failed => "Ошибка проверки",
 4122        SupplierCartStatusCode.Removed => "Удалён из корзины",
 1123        _ => code
 15124    };
 125
 126    public async Task<IReadOnlyList<SupplierStatusSnapshot>> FetchOrderAsync(
 127        IReadOnlyList<OrderSupplierStateRef> states, CancellationToken ct)
 128    {
 16129        if (states.Count == 0) return [];
 130
 31131        var orderIds = states.Select(x => x.OrderId).Distinct().ToList();
 14132        var links = await _db.FavoritPartsOrderItems
 14133            .Where(x => orderIds.Contains(x.StudioOrderId)
 14134                        && x.FavoritPartsOrder != null && x.FavoritPartsOrder.ProviderKey == ProviderKey
 14135                        && x.FavoritPartsOrder.ExternalOrderId != null)
 14136            .Select(x => new OrderLink(x.StudioOrderId, x.FavoritPartsOrder!.ExternalOrderId!.Value,
 14137                x.GoodsId, x.WarehouseGroupId, x.Quantity))
 14138            .ToListAsync(ct);
 15139        if (links.Count == 0) return [];
 140
 28141        var byOrder = links.GroupBy(x => x.ExternalOrderId).ToList();
 13142        var gate = new SemaphoreSlim(DetailsConcurrency);
 13143        var tasks = byOrder.Select(async group =>
 13144        {
 14145            await gate.WaitAsync(ct);
 13146            try
 13147            {
 14148                var response = await _favoritParts.GetOrderAsync(group.Key, ct);
 13149                return (Group: group, Order: response as JObject, Error: (string?)null);
 13150            }
 1151            catch (Exception ex) when (ex is FavoritPartsApiException or HttpRequestException or TaskCanceledException)
 13152            {
 1153                _logger.LogInformation("FavoritParts: не удалось прочитать заказ {ExternalOrderId}: {Error}", group.Key,
 1154                return (Group: group, Order: (JObject?)null, Error: $"FavoritParts: {ex.Message}");
 13155            }
 13156            finally
 13157            {
 14158                gate.Release();
 13159            }
 27160        });
 161
 13162        var result = new List<SupplierStatusSnapshot>();
 54163        foreach (var (group, order, error) in await Task.WhenAll(tasks))
 164        {
 14165            if (error is not null)
 166            {
 4167                foreach (var link in group)
 168                {
 1169                    result.Add(new SupplierStatusSnapshot
 1170                    {
 1171                        OrderId = link.StudioOrderId, Kind = SupplierStatusKind.Order, StatusName = string.Empty, Error 
 1172                    });
 173                }
 174                continue;
 175            }
 176
 13177            var status = order?["status"] as JObject;
 13178            var statusName = status?.Value<string>("name");
 13179            await PersistOrderStateAsync(group.Key, order, statusName, ct);
 13180            if (status is null || string.IsNullOrWhiteSpace(statusName)) continue;
 181
 12182            var statusId = status.Value<int?>("id");
 12183            var shippingDate = ReadDateTime(order, "shippingDate");
 12184            var quantities = SplitQuantities(order, group.ToList());
 12185            var orderNumber = order?.Value<string>("number");
 186
 50187            foreach (var link in group)
 188            {
 13189                var (confirmed, rejected) = quantities.TryGetValue(link.StudioOrderId, out var q) ? q : (link.Quantity, 
 13190                result.Add(new SupplierStatusSnapshot
 13191                {
 13192                    OrderId = link.StudioOrderId,
 13193                    Kind = SupplierStatusKind.Order,
 13194                    ExternalCode = statusId?.ToString(CultureInfo.InvariantCulture),
 13195                    StatusName = statusName,
 13196                    ExternalOrderNumber = string.IsNullOrWhiteSpace(orderNumber) ? FavoritPartsGuid.Format(group.Key) : 
 13197                    ExternalOrderId = FavoritPartsGuid.Format(group.Key),
 13198                    QuantityConfirmed = confirmed,
 13199                    QuantityRejected = rejected,
 13200                    ExpectedDeliveryAt = shippingDate
 13201                });
 202            }
 12203        }
 204
 13205        await _db.SaveChangesAsync(ct);
 13206        return result;
 15207    }
 208
 142209    private sealed record OrderLink(int StudioOrderId, Guid ExternalOrderId, Guid GoodsId, Guid WarehouseGroupId, decima
 210
 211    /// <summary>
 212    /// Раскладывает countCancel строки заказа по заказам Studio2. Строка заказа поставщика может обслуживать
 213    /// несколько заказов Studio2 (одинаковый товар с одного склада хранения уходит одной строкой), а поставщик
 214    /// снимает количество со строки целиком и не знает про наше деление — подтверждённое количество раздаём
 215    /// по возрастанию Id заказа, снятое достаётся последним.
 216    /// </summary>
 217    private static Dictionary<int, (decimal Confirmed, decimal Rejected)> SplitQuantities(JObject? order, List<OrderLink
 218    {
 12219        var result = new Dictionary<int, (decimal, decimal)>();
 12220        var rows = (order?["goodsList"] as JArray ?? new JArray()).OfType<JObject>().ToList();
 221
 61222        foreach (var byPosition in links.GroupBy(x => (x.GoodsId, x.WarehouseGroupId)))
 223        {
 17224            var row = rows.FirstOrDefault(r => MatchesPosition(r, byPosition.Key.GoodsId, byPosition.Key.WarehouseGroupI
 19225            var ordered = row?.Value<decimal?>("count") ?? byPosition.Sum(x => x.Quantity);
 12226            var cancelled = row?.Value<decimal?>("countCancel") ?? 0m;
 12227            var available = Math.Max(0m, ordered - cancelled);
 228
 63229            foreach (var link in byPosition.OrderBy(x => x.StudioOrderId))
 230            {
 13231                var confirmed = Math.Min(available, link.Quantity);
 13232                available -= confirmed;
 13233                result[link.StudioOrderId] = (confirmed, link.Quantity - confirmed);
 234            }
 235        }
 12236        return result;
 237    }
 238
 1239    private static readonly JsonSerializerOptions JsonOptions = new()
 1240    {
 1241        Encoder = JavaScriptEncoder.UnsafeRelaxedJsonEscaping
 1242    };
 243
 244    /// <summary>Номер в учётной системе, статус, идентификатор версии и ошибку проведения храним локально:
 245    /// version меняется при каждом изменении заказа на стороне Фаворита, postError означает, что заказ
 246    /// создан на сайте, но в учётную систему не проведён.</summary>
 247    private async Task PersistOrderStateAsync(Guid externalOrderId, JObject? order, string? statusName, CancellationToke
 248    {
 13249        if (order is null) return;
 13250        var local = await _db.FavoritPartsOrders
 13251            .FirstOrDefaultAsync(x => x.ProviderKey == ProviderKey && x.ExternalOrderId == externalOrderId, ct);
 13252        if (local is null) return;
 253
 13254        var number = order.Value<string>("number");
 13255        var version = order.Value<string>("version");
 13256        var postError = order.Value<string>("postError");
 257
 13258        string? previousPostError = null;
 13259        if (!string.IsNullOrWhiteSpace(local.ResponseJson))
 260        {
 261            try
 262            {
 1263                using var doc = JsonDocument.Parse(local.ResponseJson);
 0264                if (doc.RootElement.TryGetProperty("postError", out var prop) && prop.ValueKind == JsonValueKind.String)
 0265                    previousPostError = prop.GetString();
 0266            }
 1267            catch
 268            {
 269                // Игнорируем ошибки парсинга прежнего ответа
 1270            }
 271        }
 272
 25273        if (!string.IsNullOrWhiteSpace(number)) local.Number = number.Length > 40 ? number[..40] : number;
 25274        if (!string.IsNullOrWhiteSpace(statusName)) local.Status = statusName.Length > 30 ? statusName[..30] : statusNam
 13275        local.ResponseJson = JsonSerializer.Serialize(new
 13276        {
 13277            version,
 13278            number,
 13279            status = statusName,
 13280            postError,
 13281            fetchedAtUtc = DateTime.UtcNow
 13282        }, JsonOptions);
 283
 13284        if (!string.IsNullOrWhiteSpace(postError))
 285        {
 2286            _logger.LogInformation("FavoritParts: заказ {ExternalOrderId} не проведён у поставщика: {PostError}", extern
 287
 2288            if (!string.Equals(previousPostError, postError, StringComparison.Ordinal))
 289            {
 2290                var orderLabel = !string.IsNullOrWhiteSpace(local.Number) ? local.Number : FavoritPartsGuid.Format(exter
 291                try
 292                {
 2293                    await NotificationService.NotifyAsync(
 2294                        "FavoritParts",
 2295                        "#D97706",
 2296                        $"Заказ {orderLabel} не проведён у поставщика: {postError}",
 2297                        "fa-triangle-exclamation");
 0298                }
 2299                catch
 300                {
 301                    // Игнорируем отсутствие активного NotificationService (фоновый runner/тесты)
 2302                }
 303            }
 304        }
 13305    }
 306
 307    private static bool MatchesPosition(JObject row, Guid goodsId, Guid warehouseGroupId)
 308    {
 5309        if (!Guid.TryParse((row["goods"] as JObject)?.Value<string>("id"), out var rowGoods) || rowGoods != goodsId)
 0310            return false;
 311        // Склад хранения в строке заказа приходит не всегда — тогда сверяем только товар.
 5312        if (row["warehouseGroup"] is not JObject warehouse || !Guid.TryParse(warehouse.Value<string>("id"), out var rowW
 1313            return true;
 4314        return rowWarehouse == warehouseGroupId;
 315    }
 316
 317    /// <summary>Дата отгрузки приходит с оффсетом +0300 — приводим её к московской дате,
 318    /// иначе на UTC-хосте отгрузка «на завтра» показывалась бы сегодняшним вечером.</summary>
 319    private static DateTime? ReadDateTime(JObject? source, string propertyName)
 320    {
 12321        if (source is null) return null;
 12322        var value = FavoritPartsPriceMapper.ToMoscowDate(FavoritPartsPriceMapper.ReadOffset(source, propertyName));
 12323        return value?.ToDateTime(TimeOnly.MinValue);
 324    }
 325}