| | | 1 | | using Microsoft.Extensions.Caching.Memory; |
| | | 2 | | using Newtonsoft.Json.Linq; |
| | | 3 | | |
| | | 4 | | namespace Services.ApiServices.ProfitLigaApi; |
| | | 5 | | |
| | | 6 | | /// <summary>Операции API «Профит-Лига». Read-методы поиска отдают сырой JSON — раскладывает его |
| | | 7 | | /// <see cref="ProfitLigaPriceMapper"/>; справочники и корзина разбираются здесь.</summary> |
| | | 8 | | public sealed class ProfitLigaDataManager |
| | | 9 | | { |
| | | 10 | | private const string ParamsCacheKey = "api-supplier:profit-liga:order-params"; |
| | 1 | 11 | | private static readonly TimeSpan ParamsCacheDuration = TimeSpan.FromMinutes(30); |
| | | 12 | | |
| | | 13 | | private readonly ProfitLigaHttpClient _client; |
| | | 14 | | private readonly IMemoryCache _cache; |
| | | 15 | | |
| | 86 | 16 | | public ProfitLigaDataManager(ProfitLigaHttpClient client, IMemoryCache cache) |
| | | 17 | | { |
| | 86 | 18 | | _client = client; |
| | 86 | 19 | | _cache = cache; |
| | 86 | 20 | | } |
| | | 21 | | |
| | | 22 | | public Task<JToken> GetWarehousesRawAsync(CancellationToken ct = default) => |
| | 0 | 23 | | _client.GetAsync("search/warehouses", new Dictionary<string, string?> { ["action"] = "list" }, ct: ct); |
| | | 24 | | |
| | | 25 | | public async Task<IReadOnlyList<ProfitLigaWarehouse>> GetWarehousesAsync(CancellationToken ct = default) => |
| | 0 | 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) |
| | 0 | 35 | | .ToList(); |
| | | 36 | | |
| | | 37 | | /// <summary>Каталожные карточки по артикулу, без наличия и цен.</summary> |
| | | 38 | | public Task<JToken> SearchProductsAsync(string article, CancellationToken ct = default) => |
| | 0 | 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) => |
| | 0 | 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) => |
| | 8 | 49 | | _client.GetAsync("search/crosses", new Dictionary<string, string?> |
| | 8 | 50 | | { |
| | 8 | 51 | | ["article"] = article.Trim(), |
| | 8 | 52 | | ["brand"] = brand.Trim(), |
| | 8 | 53 | | ["replaces"] = replaces ? "1" : "0" |
| | 8 | 54 | | }, ct: ct); |
| | | 55 | | |
| | | 56 | | public Task<JToken> GetOrderParamsRawAsync(CancellationToken ct = default) => |
| | 10 | 57 | | _client.GetAsync("cart/params", ct: ct); |
| | | 58 | | |
| | | 59 | | /// <summary>Справочники оформления. Меняются редко — держим 30 минут, как справочник складов Шате-М.</summary> |
| | | 60 | | public async Task<ProfitLigaOrderParams> GetOrderParamsAsync(CancellationToken ct = default) |
| | | 61 | | { |
| | 10 | 62 | | if (_cache.TryGetValue(ParamsCacheKey, out ProfitLigaOrderParams? cached) && cached is not null) |
| | 0 | 63 | | return cached; |
| | | 64 | | |
| | 10 | 65 | | var parsed = ParseOrderParams(await GetOrderParamsRawAsync(ct)); |
| | 9 | 66 | | _cache.Set(ParamsCacheKey, parsed, ParamsCacheDuration); |
| | 9 | 67 | | return parsed; |
| | 9 | 68 | | } |
| | | 69 | | |
| | 0 | 70 | | public void InvalidateOrderParams() => _cache.Remove(ParamsCacheKey); |
| | | 71 | | |
| | | 72 | | internal static ProfitLigaOrderParams ParseOrderParams(JToken? token) |
| | | 73 | | { |
| | 13 | 74 | | if (token is not JObject payload) return ProfitLigaOrderParams.Empty; |
| | | 75 | | |
| | 11 | 76 | | var methods = ProfitLigaJson.Objects(payload["methods"]) |
| | 22 | 77 | | .Select(x => new ProfitLigaDeliveryMethod( |
| | 22 | 78 | | ProfitLigaJson.String(x, "id") ?? string.Empty, |
| | 22 | 79 | | ProfitLigaJson.String(x, "name") ?? string.Empty)) |
| | 22 | 80 | | .Where(x => x.Id.Length > 0) |
| | 11 | 81 | | .ToList(); |
| | | 82 | | |
| | 11 | 83 | | var payments = ProfitLigaJson.Objects(payload["payment"]) |
| | 22 | 84 | | .Select(x => new ProfitLigaPaymentMethod( |
| | 22 | 85 | | ProfitLigaJson.String(x, "id") ?? string.Empty, |
| | 22 | 86 | | ProfitLigaJson.String(x, "name") ?? string.Empty)) |
| | 22 | 87 | | .Where(x => x.Id.Length > 0) |
| | 11 | 88 | | .ToList(); |
| | | 89 | | |
| | 11 | 90 | | var statuses = ProfitLigaJson.Objects(payload["statuses"]) |
| | 11 | 91 | | .Select(x => new ProfitLigaOrderStatus( |
| | 11 | 92 | | ProfitLigaJson.String(x, "id") ?? string.Empty, |
| | 11 | 93 | | ProfitLigaJson.String(x, "name") ?? string.Empty, |
| | 11 | 94 | | ProfitLigaJson.String(x, "description"))) |
| | 11 | 95 | | .Where(x => x.Id.Length > 0) |
| | 11 | 96 | | .ToList(); |
| | | 97 | | |
| | 11 | 98 | | return new ProfitLigaOrderParams(methods, ParsePoints(payload["points"], false), |
| | 11 | 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) => |
| | 22 | 104 | | ProfitLigaJson.Objects(token) |
| | 31 | 105 | | .Select(x => new ProfitLigaPoint( |
| | 31 | 106 | | ProfitLigaJson.String(x, "code") ?? string.Empty, |
| | 31 | 107 | | ProfitLigaJson.String(x, "point") ?? ProfitLigaJson.String(x, "name") ?? string.Empty, |
| | 31 | 108 | | ProfitLigaJson.String(x, "address"), |
| | 31 | 109 | | isPickup)) |
| | 31 | 110 | | .Where(x => x.Code.Length > 0) |
| | 22 | 111 | | .ToList(); |
| | | 112 | | |
| | | 113 | | public Task<JToken> GetCartRawAsync(CancellationToken ct = default) => |
| | 28 | 114 | | _client.GetAsync("cart/list", ct: ct); |
| | | 115 | | |
| | | 116 | | public async Task<IReadOnlyList<ProfitLigaCartLine>> GetCartAsync(CancellationToken ct = default) => |
| | 28 | 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) |
| | 28 | 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 | | { |
| | 20 | 138 | | var response = await _client.PostAsync("cart/add", new Dictionary<string, string?> |
| | 20 | 139 | | { |
| | 20 | 140 | | ["id"] = articleId, |
| | 20 | 141 | | ["warehouse"] = warehouseId, |
| | 20 | 142 | | ["quantity"] = quantity.ToString("0.##", System.Globalization.CultureInfo.InvariantCulture), |
| | 20 | 143 | | ["code"] = productCode, |
| | 20 | 144 | | ["comment"] = Truncate(comment, CommentMaxLength) |
| | 20 | 145 | | }, throwOnErrorStatus: false, ct); |
| | 19 | 146 | | return ProfitLigaCartOperation.From(response, "success"); |
| | 19 | 147 | | } |
| | | 148 | | |
| | | 149 | | public async Task<ProfitLigaCartOperation> RemoveFromCartAsync(string articleId, string warehouseId, |
| | | 150 | | CancellationToken ct = default) |
| | | 151 | | { |
| | 12 | 152 | | var response = await _client.PostAsync("cart/remove", new Dictionary<string, string?> |
| | 12 | 153 | | { |
| | 12 | 154 | | ["id"] = articleId, |
| | 12 | 155 | | ["warehouse"] = warehouseId |
| | 12 | 156 | | }, throwOnErrorStatus: false, ct); |
| | | 157 | | // Фактический API отвечает status=success, хотя в старой документации указан cart-success. |
| | 10 | 158 | | return ProfitLigaCartOperation.From(response, "success"); |
| | 10 | 159 | | } |
| | | 160 | | |
| | | 161 | | /// <summary>Торговая точка по умолчанию — состояние всего аккаунта, а не отдельного запроса. |
| | | 162 | | /// Вызывать только непосредственно перед оформлением заказа.</summary> |
| | | 163 | | public Task<JToken> SetPointAsync(string pointCode, CancellationToken ct = default) => |
| | 8 | 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 | | { |
| | 8 | 171 | | var response = await _client.PostAsync("cart/order", new Dictionary<string, string?> |
| | 8 | 172 | | { |
| | 8 | 173 | | ["method"] = methodId, |
| | 8 | 174 | | ["payment"] = paymentId, |
| | 8 | 175 | | ["point"] = pointCode, |
| | 8 | 176 | | ["address"] = address, |
| | 8 | 177 | | ["pickup_point"] = pickupPointCode |
| | 8 | 178 | | }, throwOnErrorStatus: false, ct); |
| | | 179 | | |
| | 8 | 180 | | var payload = response as JObject; |
| | 8 | 181 | | var status = payload is null ? null : ProfitLigaJson.String(payload, "status"); |
| | 8 | 182 | | var numbers = (payload?["orders"] as JArray)? |
| | | 183 | | .Select(x => x.ToString().Trim()) |
| | | 184 | | .Where(x => x.Length > 0) |
| | 8 | 185 | | .ToList() ?? []; |
| | 8 | 186 | | var error = payload is null ? null : ProfitLigaJson.String(payload, "err"); |
| | 8 | 187 | | return new ProfitLigaCreateOrderResult( |
| | 8 | 188 | | string.Equals(status, "success", StringComparison.OrdinalIgnoreCase) && numbers.Count > 0, |
| | 8 | 189 | | numbers, error, response.ToString(Newtonsoft.Json.Formatting.None)); |
| | 8 | 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) => |
| | 30 | 196 | | _client.GetAsync("orders/list", new Dictionary<string, string?> |
| | 30 | 197 | | { |
| | 30 | 198 | | ["page"] = page?.ToString(System.Globalization.CultureInfo.InvariantCulture), |
| | 30 | 199 | | ["order_id"] = orderIds, |
| | 30 | 200 | | ["status_id"] = statusId?.ToString(System.Globalization.CultureInfo.InvariantCulture) |
| | 30 | 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) => |
| | 20 | 209 | | string.IsNullOrWhiteSpace(value) ? null |
| | 20 | 210 | | : value.Length <= maxLength ? value : value[..maxLength]; |
| | | 211 | | } |
| | | 212 | | |
| | | 213 | | /// <summary>Результат операции над корзиной: статус плюс актуальные итоги корзины.</summary> |
| | | 214 | | public 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> |
| | | 241 | | public sealed record ProfitLigaCreateOrderResult( |
| | | 242 | | bool IsSuccess, IReadOnlyList<string> OrderNumbers, string? Error, string ResponseJson); |