< Summary

Information
Class: Services.ApiServices.ProfitLigaApi.ProfitLigaDataManager
Assembly: Services
File(s): /home/runner/work/Studio2Prod/Studio2Prod/AppRepository/ApiServices/ProfitLigaApi/ProfitLigaDataManager.cs
Line coverage
93%
Covered lines: 93
Uncovered lines: 7
Coverable lines: 100
Total lines: 242
Line coverage: 93%
Branch coverage
65%
Covered branches: 30
Total branches: 46
Branch coverage: 65.2%
Method coverage

Feature is only available for sponsors

Upgrade to PRO version

Metrics

MethodBranch coverage Crap Score Cyclomatic complexity Line coverage
.cctor()100%11100%
.ctor(...)100%11100%
GetWarehousesRawAsync(...)100%210%
GetWarehousesAsync()100%210%
SearchProductsAsync(...)100%210%
SearchItemsAsync(...)100%210%
SearchCrossesAsync(...)50%22100%
GetOrderParamsRawAsync(...)100%11100%
GetOrderParamsAsync()50%4483.33%
InvalidateOrderParams()100%210%
ParseOrderParams(...)57.14%1414100%
ParsePoints(...)66.66%66100%
GetCartRawAsync(...)100%11100%
GetCartAsync()100%11100%
AddToCartAsync()100%11100%
RemoveFromCartAsync()100%11100%
SetPointAsync(...)100%11100%
CreateOrderAsync()75%1212100%
GetOrdersAsync(...)75%44100%
Truncate(...)75%44100%

File(s)

/home/runner/work/Studio2Prod/Studio2Prod/AppRepository/ApiServices/ProfitLigaApi/ProfitLigaDataManager.cs

#LineLine coverage
 1using Microsoft.Extensions.Caching.Memory;
 2using Newtonsoft.Json.Linq;
 3
 4namespace Services.ApiServices.ProfitLigaApi;
 5
 6/// <summary>Операции API «Профит-Лига». Read-методы поиска отдают сырой JSON — раскладывает его
 7/// <see cref="ProfitLigaPriceMapper"/>; справочники и корзина разбираются здесь.</summary>
 8public sealed class ProfitLigaDataManager
 9{
 10    private const string ParamsCacheKey = "api-supplier:profit-liga:order-params";
 111    private static readonly TimeSpan ParamsCacheDuration = TimeSpan.FromMinutes(30);
 12
 13    private readonly ProfitLigaHttpClient _client;
 14    private readonly IMemoryCache _cache;
 15
 8616    public ProfitLigaDataManager(ProfitLigaHttpClient client, IMemoryCache cache)
 17    {
 8618        _client = client;
 8619        _cache = cache;
 8620    }
 21
 22    public Task<JToken> GetWarehousesRawAsync(CancellationToken ct = default) =>
 023        _client.GetAsync("search/warehouses", new Dictionary<string, string?> { ["action"] = "list" }, ct: ct);
 24
 25    public async Task<IReadOnlyList<ProfitLigaWarehouse>> GetWarehousesAsync(CancellationToken ct = default) =>
 026        ProfitLigaJson.Objects(await GetWarehousesRawAsync(ct))
 27            .Select(x => new ProfitLigaWarehouse(
 28                ProfitLigaJson.Int(x, "id") ?? 0,
 29                ProfitLigaJson.String(x, "name") ?? string.Empty,
 30                ProfitLigaJson.Int(x, "delivery_hours") ?? 0,
 31                ProfitLigaJson.Bool(x, "own"),
 32                ProfitLigaJson.Bool(x, "active"),
 33                ProfitLigaJson.String(x, "comment")))
 34            .Where(x => x.Id > 0)
 035            .ToList();
 36
 37    /// <summary>Каталожные карточки по артикулу, без наличия и цен.</summary>
 38    public Task<JToken> SearchProductsAsync(string article, CancellationToken ct = default) =>
 039        _client.GetAsync("search/products", new Dictionary<string, string?> { ["article"] = article.Trim() }, ct: ct);
 40
 41    /// <summary>Карточки с наличием по артикулу. Бренд этот метод не фильтрует.</summary>
 42    public Task<JToken> SearchItemsAsync(string article, CancellationToken ct = default) =>
 043        _client.GetAsync("search/items", new Dictionary<string, string?> { ["article"] = article.Trim() }, ct: ct);
 44
 45    /// <summary>Единственный метод поиска с фильтром по бренду на стороне API.
 46    /// <paramref name="replaces"/> = false — только оригиналы, именно этот режим идёт в проценку.</summary>
 47    public Task<JToken> SearchCrossesAsync(string article, string brand, bool replaces = false,
 48        CancellationToken ct = default) =>
 849        _client.GetAsync("search/crosses", new Dictionary<string, string?>
 850        {
 851            ["article"] = article.Trim(),
 852            ["brand"] = brand.Trim(),
 853            ["replaces"] = replaces ? "1" : "0"
 854        }, ct: ct);
 55
 56    public Task<JToken> GetOrderParamsRawAsync(CancellationToken ct = default) =>
 1057        _client.GetAsync("cart/params", ct: ct);
 58
 59    /// <summary>Справочники оформления. Меняются редко — держим 30 минут, как справочник складов Шате-М.</summary>
 60    public async Task<ProfitLigaOrderParams> GetOrderParamsAsync(CancellationToken ct = default)
 61    {
 1062        if (_cache.TryGetValue(ParamsCacheKey, out ProfitLigaOrderParams? cached) && cached is not null)
 063            return cached;
 64
 1065        var parsed = ParseOrderParams(await GetOrderParamsRawAsync(ct));
 966        _cache.Set(ParamsCacheKey, parsed, ParamsCacheDuration);
 967        return parsed;
 968    }
 69
 070    public void InvalidateOrderParams() => _cache.Remove(ParamsCacheKey);
 71
 72    internal static ProfitLigaOrderParams ParseOrderParams(JToken? token)
 73    {
 1374        if (token is not JObject payload) return ProfitLigaOrderParams.Empty;
 75
 1176        var methods = ProfitLigaJson.Objects(payload["methods"])
 2277            .Select(x => new ProfitLigaDeliveryMethod(
 2278                ProfitLigaJson.String(x, "id") ?? string.Empty,
 2279                ProfitLigaJson.String(x, "name") ?? string.Empty))
 2280            .Where(x => x.Id.Length > 0)
 1181            .ToList();
 82
 1183        var payments = ProfitLigaJson.Objects(payload["payment"])
 2284            .Select(x => new ProfitLigaPaymentMethod(
 2285                ProfitLigaJson.String(x, "id") ?? string.Empty,
 2286                ProfitLigaJson.String(x, "name") ?? string.Empty))
 2287            .Where(x => x.Id.Length > 0)
 1188            .ToList();
 89
 1190        var statuses = ProfitLigaJson.Objects(payload["statuses"])
 1191            .Select(x => new ProfitLigaOrderStatus(
 1192                ProfitLigaJson.String(x, "id") ?? string.Empty,
 1193                ProfitLigaJson.String(x, "name") ?? string.Empty,
 1194                ProfitLigaJson.String(x, "description")))
 1195            .Where(x => x.Id.Length > 0)
 1196            .ToList();
 97
 1198        return new ProfitLigaOrderParams(methods, ParsePoints(payload["points"], false),
 1199            ParsePoints(payload["pickup_points"], true), payments, statuses);
 100    }
 101
 102    /// <summary>У торговых точек имя лежит в point, у точек самовывоза — в name.</summary>
 103    private static IReadOnlyList<ProfitLigaPoint> ParsePoints(JToken? token, bool isPickup) =>
 22104        ProfitLigaJson.Objects(token)
 31105            .Select(x => new ProfitLigaPoint(
 31106                ProfitLigaJson.String(x, "code") ?? string.Empty,
 31107                ProfitLigaJson.String(x, "point") ?? ProfitLigaJson.String(x, "name") ?? string.Empty,
 31108                ProfitLigaJson.String(x, "address"),
 31109                isPickup))
 31110            .Where(x => x.Code.Length > 0)
 22111            .ToList();
 112
 113    public Task<JToken> GetCartRawAsync(CancellationToken ct = default) =>
 28114        _client.GetAsync("cart/list", ct: ct);
 115
 116    public async Task<IReadOnlyList<ProfitLigaCartLine>> GetCartAsync(CancellationToken ct = default) =>
 28117        ProfitLigaJson.Objects(await GetCartRawAsync(ct))
 118            .Select(x => new ProfitLigaCartLine(
 119                ProfitLigaJson.String(x, "article_id") ?? string.Empty,
 120                ProfitLigaJson.String(x, "warehouse_id") ?? string.Empty,
 121                ProfitLigaJson.String(x, "product_code"),
 122                ProfitLigaJson.String(x, "article"),
 123                ProfitLigaJson.String(x, "brand"),
 124                ProfitLigaJson.String(x, "description"),
 125                ProfitLigaJson.String(x, "warehouse"),
 126                ProfitLigaJson.Decimal(x, "quantity") ?? 0m,
 127                ProfitLigaJson.Decimal(x, "price"),
 128                ProfitLigaJson.String(x, "show_date"),
 129                ProfitLigaJson.String(x, "comment")))
 130            .Where(x => x.ArticleId.Length > 0)
 28131            .ToList();
 132
 133    /// <summary>Добавление в корзину. Ответ разбирает вызывающий: no-quantity и less — бизнес-результаты,
 134    /// а не сбой запроса, поэтому автоматическую проверку status здесь отключаем.</summary>
 135    public async Task<ProfitLigaCartOperation> AddToCartAsync(string articleId, string warehouseId, decimal quantity,
 136        string productCode, string? comment, CancellationToken ct = default)
 137    {
 20138        var response = await _client.PostAsync("cart/add", new Dictionary<string, string?>
 20139        {
 20140            ["id"] = articleId,
 20141            ["warehouse"] = warehouseId,
 20142            ["quantity"] = quantity.ToString("0.##", System.Globalization.CultureInfo.InvariantCulture),
 20143            ["code"] = productCode,
 20144            ["comment"] = Truncate(comment, CommentMaxLength)
 20145        }, throwOnErrorStatus: false, ct);
 19146        return ProfitLigaCartOperation.From(response, "success");
 19147    }
 148
 149    public async Task<ProfitLigaCartOperation> RemoveFromCartAsync(string articleId, string warehouseId,
 150        CancellationToken ct = default)
 151    {
 12152        var response = await _client.PostAsync("cart/remove", new Dictionary<string, string?>
 12153        {
 12154            ["id"] = articleId,
 12155            ["warehouse"] = warehouseId
 12156        }, throwOnErrorStatus: false, ct);
 157        // Фактический API отвечает status=success, хотя в старой документации указан cart-success.
 10158        return ProfitLigaCartOperation.From(response, "success");
 10159    }
 160
 161    /// <summary>Торговая точка по умолчанию — состояние всего аккаунта, а не отдельного запроса.
 162    /// Вызывать только непосредственно перед оформлением заказа.</summary>
 163    public Task<JToken> SetPointAsync(string pointCode, CancellationToken ct = default) =>
 8164        _client.PostAsync("cart/point", new Dictionary<string, string?> { ["code"] = pointCode }, ct: ct);
 165
 166    /// <summary>Оформление. Заказывается ВСЯ корзина аккаунта — API разбивает её по складам
 167    /// и возвращает список номеров.</summary>
 168    public async Task<ProfitLigaCreateOrderResult> CreateOrderAsync(string methodId, string paymentId,
 169        string? pointCode, string? address, string? pickupPointCode, CancellationToken ct = default)
 170    {
 8171        var response = await _client.PostAsync("cart/order", new Dictionary<string, string?>
 8172        {
 8173            ["method"] = methodId,
 8174            ["payment"] = paymentId,
 8175            ["point"] = pointCode,
 8176            ["address"] = address,
 8177            ["pickup_point"] = pickupPointCode
 8178        }, throwOnErrorStatus: false, ct);
 179
 8180        var payload = response as JObject;
 8181        var status = payload is null ? null : ProfitLigaJson.String(payload, "status");
 8182        var numbers = (payload?["orders"] as JArray)?
 183            .Select(x => x.ToString().Trim())
 184            .Where(x => x.Length > 0)
 8185            .ToList() ?? [];
 8186        var error = payload is null ? null : ProfitLigaJson.String(payload, "err");
 8187        return new ProfitLigaCreateOrderResult(
 8188            string.Equals(status, "success", StringComparison.OrdinalIgnoreCase) && numbers.Count > 0,
 8189            numbers, error, response.ToString(Newtonsoft.Json.Formatting.None));
 8190    }
 191
 192    /// <summary>Заказы с позициями. Нумерация страниц начинается с нуля,
 193    /// order_id принимает не более трёх номеров через запятую.</summary>
 194    public Task<JToken> GetOrdersAsync(int? page = null, string? orderIds = null, int? statusId = null,
 195        CancellationToken ct = default) =>
 30196        _client.GetAsync("orders/list", new Dictionary<string, string?>
 30197        {
 30198            ["page"] = page?.ToString(System.Globalization.CultureInfo.InvariantCulture),
 30199            ["order_id"] = orderIds,
 30200            ["status_id"] = statusId?.ToString(System.Globalization.CultureInfo.InvariantCulture)
 30201        }, ct: ct);
 202
 203    /// <summary>Ограничение поля comment в /cart/add по документации — 255 символов.</summary>
 204    public const int CommentMaxLength = 255;
 205
 206    public const int OrdersPerRequest = 3;
 207
 208    private static string? Truncate(string? value, int maxLength) =>
 20209        string.IsNullOrWhiteSpace(value) ? null
 20210        : value.Length <= maxLength ? value : value[..maxLength];
 211}
 212
 213/// <summary>Результат операции над корзиной: статус плюс актуальные итоги корзины.</summary>
 214public sealed record ProfitLigaCartOperation(bool IsSuccess, string Status, decimal? Total, int? Count)
 215{
 216    public static ProfitLigaCartOperation From(JToken response, string successStatus)
 217    {
 218        if (response is not JObject payload)
 219            return new ProfitLigaCartOperation(false, "error", null, null);
 220
 221        var status = ProfitLigaJson.String(payload, "status") ?? "error";
 222        return new ProfitLigaCartOperation(
 223            string.Equals(status, successStatus, StringComparison.OrdinalIgnoreCase),
 224            status,
 225            ProfitLigaJson.Decimal(payload, "total"),
 226            ProfitLigaJson.Int(payload, "count"));
 227    }
 228
 229    /// <summary>Коды статусов /cart/add и /cart/remove человеческим языком.</summary>
 230    public string Describe() => Status.ToLowerInvariant() switch
 231    {
 232        "success" or "cart-success" => "успешно",
 233        "no-quantity" => "запрошенное количество превышает наличие на складе",
 234        "less" => "количество должно быть больше нуля",
 235        "cart-error" => "поставщик не смог удалить позицию из корзины",
 236        _ => "поставщик отклонил операцию (товар не определён, не совпала кратность или ошибка записи)"
 237    };
 238}
 239
 240/// <summary>Результат POST /cart/order: корзина разбивается по складам, номеров может быть несколько.</summary>
 241public sealed record ProfitLigaCreateOrderResult(
 242    bool IsSuccess, IReadOnlyList<string> OrderNumbers, string? Error, string ResponseJson);