< Summary

Information
Class: Services.ApiServices.ProfitLigaApi.ProfitLigaCartOperation
Assembly: Services
File(s): /home/runner/work/Studio2Prod/Studio2Prod/AppRepository/ApiServices/ProfitLigaApi/ProfitLigaDataManager.cs
Line coverage
88%
Covered lines: 15
Uncovered lines: 2
Coverable lines: 17
Total lines: 242
Line coverage: 88.2%
Branch coverage
78%
Covered branches: 11
Total branches: 14
Branch coverage: 78.5%
Method coverage

Feature is only available for sponsors

Upgrade to PRO version

Metrics

MethodBranch coverage Crap Score Cyclomatic complexity Line coverage
get_IsSuccess()100%11100%
From(...)50%4487.5%
Describe()90%101087.5%

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";
 11    private static readonly TimeSpan ParamsCacheDuration = TimeSpan.FromMinutes(30);
 12
 13    private readonly ProfitLigaHttpClient _client;
 14    private readonly IMemoryCache _cache;
 15
 16    public ProfitLigaDataManager(ProfitLigaHttpClient client, IMemoryCache cache)
 17    {
 18        _client = client;
 19        _cache = cache;
 20    }
 21
 22    public Task<JToken> GetWarehousesRawAsync(CancellationToken ct = default) =>
 23        _client.GetAsync("search/warehouses", new Dictionary<string, string?> { ["action"] = "list" }, ct: ct);
 24
 25    public async Task<IReadOnlyList<ProfitLigaWarehouse>> GetWarehousesAsync(CancellationToken ct = default) =>
 26        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)
 35            .ToList();
 36
 37    /// <summary>Каталожные карточки по артикулу, без наличия и цен.</summary>
 38    public Task<JToken> SearchProductsAsync(string article, CancellationToken ct = default) =>
 39        _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) =>
 43        _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) =>
 49        _client.GetAsync("search/crosses", new Dictionary<string, string?>
 50        {
 51            ["article"] = article.Trim(),
 52            ["brand"] = brand.Trim(),
 53            ["replaces"] = replaces ? "1" : "0"
 54        }, ct: ct);
 55
 56    public Task<JToken> GetOrderParamsRawAsync(CancellationToken ct = default) =>
 57        _client.GetAsync("cart/params", ct: ct);
 58
 59    /// <summary>Справочники оформления. Меняются редко — держим 30 минут, как справочник складов Шате-М.</summary>
 60    public async Task<ProfitLigaOrderParams> GetOrderParamsAsync(CancellationToken ct = default)
 61    {
 62        if (_cache.TryGetValue(ParamsCacheKey, out ProfitLigaOrderParams? cached) && cached is not null)
 63            return cached;
 64
 65        var parsed = ParseOrderParams(await GetOrderParamsRawAsync(ct));
 66        _cache.Set(ParamsCacheKey, parsed, ParamsCacheDuration);
 67        return parsed;
 68    }
 69
 70    public void InvalidateOrderParams() => _cache.Remove(ParamsCacheKey);
 71
 72    internal static ProfitLigaOrderParams ParseOrderParams(JToken? token)
 73    {
 74        if (token is not JObject payload) return ProfitLigaOrderParams.Empty;
 75
 76        var methods = ProfitLigaJson.Objects(payload["methods"])
 77            .Select(x => new ProfitLigaDeliveryMethod(
 78                ProfitLigaJson.String(x, "id") ?? string.Empty,
 79                ProfitLigaJson.String(x, "name") ?? string.Empty))
 80            .Where(x => x.Id.Length > 0)
 81            .ToList();
 82
 83        var payments = ProfitLigaJson.Objects(payload["payment"])
 84            .Select(x => new ProfitLigaPaymentMethod(
 85                ProfitLigaJson.String(x, "id") ?? string.Empty,
 86                ProfitLigaJson.String(x, "name") ?? string.Empty))
 87            .Where(x => x.Id.Length > 0)
 88            .ToList();
 89
 90        var statuses = ProfitLigaJson.Objects(payload["statuses"])
 91            .Select(x => new ProfitLigaOrderStatus(
 92                ProfitLigaJson.String(x, "id") ?? string.Empty,
 93                ProfitLigaJson.String(x, "name") ?? string.Empty,
 94                ProfitLigaJson.String(x, "description")))
 95            .Where(x => x.Id.Length > 0)
 96            .ToList();
 97
 98        return new ProfitLigaOrderParams(methods, ParsePoints(payload["points"], false),
 99            ParsePoints(payload["pickup_points"], true), payments, statuses);
 100    }
 101
 102    /// <summary>У торговых точек имя лежит в point, у точек самовывоза — в name.</summary>
 103    private static IReadOnlyList<ProfitLigaPoint> ParsePoints(JToken? token, bool isPickup) =>
 104        ProfitLigaJson.Objects(token)
 105            .Select(x => new ProfitLigaPoint(
 106                ProfitLigaJson.String(x, "code") ?? string.Empty,
 107                ProfitLigaJson.String(x, "point") ?? ProfitLigaJson.String(x, "name") ?? string.Empty,
 108                ProfitLigaJson.String(x, "address"),
 109                isPickup))
 110            .Where(x => x.Code.Length > 0)
 111            .ToList();
 112
 113    public Task<JToken> GetCartRawAsync(CancellationToken ct = default) =>
 114        _client.GetAsync("cart/list", ct: ct);
 115
 116    public async Task<IReadOnlyList<ProfitLigaCartLine>> GetCartAsync(CancellationToken ct = default) =>
 117        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)
 131            .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    {
 138        var response = await _client.PostAsync("cart/add", new Dictionary<string, string?>
 139        {
 140            ["id"] = articleId,
 141            ["warehouse"] = warehouseId,
 142            ["quantity"] = quantity.ToString("0.##", System.Globalization.CultureInfo.InvariantCulture),
 143            ["code"] = productCode,
 144            ["comment"] = Truncate(comment, CommentMaxLength)
 145        }, throwOnErrorStatus: false, ct);
 146        return ProfitLigaCartOperation.From(response, "success");
 147    }
 148
 149    public async Task<ProfitLigaCartOperation> RemoveFromCartAsync(string articleId, string warehouseId,
 150        CancellationToken ct = default)
 151    {
 152        var response = await _client.PostAsync("cart/remove", new Dictionary<string, string?>
 153        {
 154            ["id"] = articleId,
 155            ["warehouse"] = warehouseId
 156        }, throwOnErrorStatus: false, ct);
 157        // Фактический API отвечает status=success, хотя в старой документации указан cart-success.
 158        return ProfitLigaCartOperation.From(response, "success");
 159    }
 160
 161    /// <summary>Торговая точка по умолчанию — состояние всего аккаунта, а не отдельного запроса.
 162    /// Вызывать только непосредственно перед оформлением заказа.</summary>
 163    public Task<JToken> SetPointAsync(string pointCode, CancellationToken ct = default) =>
 164        _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    {
 171        var response = await _client.PostAsync("cart/order", new Dictionary<string, string?>
 172        {
 173            ["method"] = methodId,
 174            ["payment"] = paymentId,
 175            ["point"] = pointCode,
 176            ["address"] = address,
 177            ["pickup_point"] = pickupPointCode
 178        }, throwOnErrorStatus: false, ct);
 179
 180        var payload = response as JObject;
 181        var status = payload is null ? null : ProfitLigaJson.String(payload, "status");
 182        var numbers = (payload?["orders"] as JArray)?
 183            .Select(x => x.ToString().Trim())
 184            .Where(x => x.Length > 0)
 185            .ToList() ?? [];
 186        var error = payload is null ? null : ProfitLigaJson.String(payload, "err");
 187        return new ProfitLigaCreateOrderResult(
 188            string.Equals(status, "success", StringComparison.OrdinalIgnoreCase) && numbers.Count > 0,
 189            numbers, error, response.ToString(Newtonsoft.Json.Formatting.None));
 190    }
 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) =>
 196        _client.GetAsync("orders/list", new Dictionary<string, string?>
 197        {
 198            ["page"] = page?.ToString(System.Globalization.CultureInfo.InvariantCulture),
 199            ["order_id"] = orderIds,
 200            ["status_id"] = statusId?.ToString(System.Globalization.CultureInfo.InvariantCulture)
 201        }, 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) =>
 209        string.IsNullOrWhiteSpace(value) ? null
 210        : value.Length <= maxLength ? value : value[..maxLength];
 211}
 212
 213/// <summary>Результат операции над корзиной: статус плюс актуальные итоги корзины.</summary>
 81214public sealed record ProfitLigaCartOperation(bool IsSuccess, string Status, decimal? Total, int? Count)
 215{
 216    public static ProfitLigaCartOperation From(JToken response, string successStatus)
 217    {
 33218        if (response is not JObject payload)
 0219            return new ProfitLigaCartOperation(false, "error", null, null);
 220
 33221        var status = ProfitLigaJson.String(payload, "status") ?? "error";
 33222        return new ProfitLigaCartOperation(
 33223            string.Equals(status, successStatus, StringComparison.OrdinalIgnoreCase),
 33224            status,
 33225            ProfitLigaJson.Decimal(payload, "total"),
 33226            ProfitLigaJson.Int(payload, "count"));
 227    }
 228
 229    /// <summary>Коды статусов /cart/add и /cart/remove человеческим языком.</summary>
 8230    public string Describe() => Status.ToLowerInvariant() switch
 8231    {
 2232        "success" or "cart-success" => "успешно",
 2233        "no-quantity" => "запрошенное количество превышает наличие на складе",
 0234        "less" => "количество должно быть больше нуля",
 3235        "cart-error" => "поставщик не смог удалить позицию из корзины",
 1236        _ => "поставщик отклонил операцию (товар не определён, не совпала кратность или ошибка записи)"
 8237    };
 238}
 239
 240/// <summary>Результат POST /cart/order: корзина разбивается по складам, номеров может быть несколько.</summary>
 241public sealed record ProfitLigaCreateOrderResult(
 242    bool IsSuccess, IReadOnlyList<string> OrderNumbers, string? Error, string ResponseJson);