< Summary

Information
Class: OzonOrdersWeb.TransactionServices.ShateMCartService
Assembly: OzonOrdersWeb
File(s): /home/runner/work/Studio2Prod/Studio2Prod/OzonOrdersWeb/Services/Transactions/ShateMCartService.cs
Line coverage
99%
Covered lines: 171
Uncovered lines: 1
Coverable lines: 172
Total lines: 274
Line coverage: 99.4%
Branch coverage
75%
Covered branches: 112
Total branches: 148
Branch coverage: 75.6%
Method coverage

Feature is only available for sponsors

Upgrade to PRO version

Metrics

MethodBranch coverage Crap Score Cyclomatic complexity Line coverage
.ctor(...)100%11100%
CountActiveAsync(...)100%11100%
GetActiveAsync(...)100%11100%
GetOrdersAsync(...)100%11100%
GetCheckoutDataAsync()100%11100%
GetAvailableDatesAsync()100%11100%
QueueAsync()62.5%1616100%
CheckoutDirectAsync()100%11100%
CheckoutAsync()100%44100%
RemoveAsync()100%88100%
RemoveExternalAsync()91.66%1212100%
CheckoutExternalAsync()65.38%2626100%
CreateOrdersAsync()85%202095.23%
ExplainCheckoutError(...)100%22100%
RequoteAsync()73.91%4646100%
FindExternalBasketItemIdAsync()75%44100%
GetArray(...)50%44100%
BuildOrderRequest(...)100%11100%
ToApiQuantity(...)100%66100%
ValidateOptions(...)100%1010100%
ToCartItem(...)100%11100%
get_PriceId()100%11100%

File(s)

/home/runner/work/Studio2Prod/Studio2Prod/OzonOrdersWeb/Services/Transactions/ShateMCartService.cs

#LineLine coverage
 1using System.Globalization;
 2using Microsoft.EntityFrameworkCore;
 3using Newtonsoft.Json;
 4using Newtonsoft.Json.Linq;
 5using OzonDomains.Models;
 6using OzonRepositories.Context;
 7using Services.ApiServices.ShateMApi;
 8using Services.SupplierStatusServices;
 9
 10namespace OzonOrdersWeb.TransactionServices;
 11
 12public sealed record ShateMCartQueueItem(int OrderId, string Article, string Brand, string? ProductName,
 13    string? PriceId, string? LocationCode, string? Direction, decimal Quantity, decimal? SourcePrice,
 14    string? CurrencyCode, int? DeliveryDaysMin, int? DeliveryDaysMax);
 15
 16/// <summary><paramref name="DeliveryAt"/> хранит смещение слота Шате‑М: днём доставки считается календарная дата в этом
 17public sealed record ShateMCheckoutOptions(string AgreementCode, string DeliveryAddressCode, string DeliveryType,
 18    DateTimeOffset DeliveryAt, bool AgreeWithTermsOfDelivery, bool AgreeWithPersonalDataProcessingPolicyAndUserAgreement
 19
 20public sealed record ShateMCartResult(int Added, int Failed, IReadOnlyList<string> Messages)
 21{
 22    public bool IsSuccess => Failed == 0;
 23    public string ToHtmlBlock() => $"<div class='alert {(Failed == 0 ? "alert-success" : "alert-warning")} mb-0'><strong
 24}
 25
 26/// <summary>Внешняя корзина и заказы Шате‑М. Перед каждой write-операцией предложение повторно проценивается.</summary>
 27public sealed class ShateMCartService
 28{
 29    private readonly ApplicationDbContext _db;
 30    private readonly ShateMDataManager _api;
 31    private readonly IOrderSupplierStateService _states;
 32
 6433    public ShateMCartService(ApplicationDbContext db, ShateMDataManager api, IOrderSupplierStateService states) => (_db,
 34
 135    public Task<int> CountActiveAsync(CancellationToken ct = default) => _db.ShateMCartItems.CountAsync(x => ShateMCartI
 136    public Task<List<ShateMCartItem>> GetActiveAsync(CancellationToken ct = default) => _db.ShateMCartItems.Include(x =>
 137    public Task<List<ShateMOrder>> GetOrdersAsync(CancellationToken ct = default) => _db.ShateMOrders.Include(x => x.Ite
 38
 39    public async Task<JObject> GetCheckoutDataAsync(CancellationToken ct)
 40    {
 141        var agreements = await _api.GetAsync("/api/v1/customer/agreements", null, ct);
 142        var addresses = await _api.GetAsync("/api/v1/delivery/addresses", null, ct);
 143        return new JObject { ["agreements"] = agreements, ["addresses"] = addresses };
 144    }
 45
 46    public async Task<JToken> GetAvailableDatesAsync(string locationCode, string addressCode, CancellationToken ct) =>
 147        await _api.GetAsync("/api/v1/delivery/availableDateTimes", new Dictionary<string, string?>
 148        {
 149            ["locationCode"] = locationCode, ["deliveryAddressCode"] = addressCode
 150        }, ct);
 51
 52    public async Task<ShateMCartResult> QueueAsync(IReadOnlyCollection<ShateMCartQueueItem> queue, ShateMCheckoutOptions
 53    {
 2354        if (!ValidateOptions(options, out var error)) return new(0, queue.Count, [error]);
 3955        var added = 0; var failed = 0; var messages = new List<string>();
 5256        foreach (var source in queue)
 57        {
 58            try
 59            {
 1560                if (await _db.ShateMCartItems.AnyAsync(x => x.ActiveStudioOrderId == source.OrderId, ct)) { messages.Add
 1261                var quote = await RequoteAsync(source, options, ct);
 362                var externalId = await FindExternalBasketItemIdAsync(source.OrderId, ct);
 363                if (string.IsNullOrWhiteSpace(externalId))
 64                {
 265                    var response = await _api.SearchAsync("/api/v1/cartitems", new JArray(new JObject
 266                    {
 267                        ["priceId"] = quote.PriceId,
 268                        ["quantity"] = ToApiQuantity(quote.Quantity),
 269                        ["comment"] = $"Studio2 заказ {source.OrderId}"
 270                    }), ct);
 171                    externalId = response is JArray createdItems
 172                        ? createdItems.FirstOrDefault()?["id"]?.ToString()
 173                        : response["id"]?.ToString();
 74                }
 275                var row = ToCartItem(source, quote, options, user, externalId);
 276                _db.ShateMCartItems.Add(row);
 277                await _db.SaveChangesAsync(ct);
 278                await _states.MarkInCartAsync(source.OrderId, ApiSupplierSettings.ShateMProviderKey, ShateMCartItemStatu
 479                added++; messages.Add($"{source.Article}: добавлен в корзину Шате‑М.");
 280            }
 1081            catch (Exception ex) when (ex is ShateMApiException or InvalidOperationException)
 82            {
 2083                failed++; messages.Add($"{source.Article}: {ex.Message}");
 1084            }
 1285        }
 1386        return new(added, failed, messages);
 1887    }
 88
 89    public async Task<ShateMCartResult> CheckoutDirectAsync(IReadOnlyCollection<ShateMCartQueueItem> queue, ShateMChecko
 390        await CreateOrdersAsync(queue, null, options, user, ct);
 91
 92    public async Task<ShateMCartResult> CheckoutAsync(IReadOnlyCollection<long>? ids, ShateMCheckoutOptions options, str
 93    {
 194        var query = _db.ShateMCartItems.Where(x => ShateMCartItemStatus.Active.Contains(x.Status));
 295        if (ids is { Count: > 0 }) query = query.Where(x => ids.Contains(x.Id));
 196        var rows = await query.ToListAsync(ct);
 297        var queue = rows.Select(x => new ShateMCartQueueItem(x.StudioOrderId, x.Article, x.Brand, x.ProductName, x.Price
 198        return await CreateOrdersAsync(queue, rows, options, user, ct);
 199    }
 100
 101    public async Task<int> RemoveAsync(IReadOnlyCollection<long>? ids, CancellationToken ct)
 102    {
 1103        var query = _db.ShateMCartItems.Where(x => ShateMCartItemStatus.Active.Contains(x.Status));
 2104        if (ids is { Count: > 0 }) query = query.Where(x => ids.Contains(x.Id));
 1105        var rows = await query.ToListAsync(ct);
 4106        foreach (var row in rows)
 107        {
 2108            if (!string.IsNullOrWhiteSpace(row.ExternalBasketItemId)) await _api.DeleteAsync($"/api/v1/cartitems/{Uri.Es
 3109            row.Status = ShateMCartItemStatus.Removed; row.ActiveStudioOrderId = null; row.UpdatedAtUtc = DateTime.UtcNo
 1110            await _states.RemoveFromCartAsync(row.StudioOrderId, ApiSupplierSettings.ShateMProviderKey, null, ct);
 1111        }
 2112        await _db.SaveChangesAsync(ct); return rows.Count;
 1113    }
 114
 115    /// <summary>Удаляет фактические строки внешней корзины, включая строки без локальной связи Studio2.</summary>
 116    public async Task<int> RemoveExternalAsync(IReadOnlyCollection<string>? externalIds, bool removeAll, CancellationTok
 117    {
 4118        var ids = externalIds?.Where(x => !string.IsNullOrWhiteSpace(x)).Distinct(StringComparer.Ordinal).ToList() ?? []
 2119        if (removeAll)
 120        {
 1121            var response = await _api.GetAsync("/api/v1/cartitems", null, ct);
 7122            ids = GetArray(response).OfType<JObject>().Select(x => x["id"]?.ToString()).Where(x => !string.IsNullOrWhite
 123        }
 124
 2125        if (ids.Count == 0) return 0;
 2126        var localRows = await _db.ShateMCartItems.Where(x => x.ExternalBasketItemId != null && ids.Contains(x.ExternalBa
 14127        foreach (var id in ids)
 5128            await _api.DeleteAsync($"/api/v1/cartitems/{Uri.EscapeDataString(id)}", ct);
 6129        foreach (var row in localRows)
 130        {
 1131            row.Status = ShateMCartItemStatus.Removed;
 1132            row.ActiveStudioOrderId = null;
 1133            row.UpdatedAtUtc = DateTime.UtcNow;
 1134            await _states.RemoveFromCartAsync(row.StudioOrderId, ApiSupplierSettings.ShateMProviderKey, null, ct);
 135        }
 2136        await _db.SaveChangesAsync(ct);
 2137        return ids.Count;
 2138    }
 139
 140    /// <summary>Оформляет фактические строки API-корзины через byCartItems, разбивая по складам.</summary>
 141    public async Task<ShateMCartResult> CheckoutExternalAsync(IReadOnlyCollection<string>? externalIds, bool checkoutAll
 142    {
 3143        if (!ValidateOptions(options, out var error)) return new(0, externalIds?.Count ?? 0, [error]);
 6144        var requested = externalIds?.Where(x => !string.IsNullOrWhiteSpace(x)).ToHashSet(StringComparer.Ordinal) ?? [];
 3145        var response = await _api.GetAsync("/api/v1/cartitems", null, ct);
 3146        var cartItems = GetArray(response).OfType<JObject>()
 5147            .Where(item => checkoutAll || requested.Contains(item["id"]?.ToString() ?? string.Empty)).ToList();
 4148        if (cartItems.Count == 0) return new(0, 0, ["Шате‑М: в API-корзине нет выбранных позиций."]);
 149
 2150        var localRows = await _db.ShateMCartItems.Where(x => x.ExternalBasketItemId != null && cartItems.Select(i => i["
 2151        var added = 0;
 2152        var failed = 0;
 2153        var messages = new List<string>();
 10154        foreach (var group in cartItems.GroupBy(x => x.Value<string>("locationCode") ?? string.Empty, StringComparer.Ord
 155        {
 2156            var request = new JObject
 2157            {
 2158                ["agreementCode"] = options.AgreementCode,
 2159                ["deliveryInfo"] = new JObject { ["deliveryType"] = options.DeliveryType, ["deliveryAddressCode"] = opti
 2160                ["agreeWithTermsOfDelivery"] = options.AgreeWithTermsOfDelivery,
 2161                ["agreeWithPersonalDataProcessingPolicyAndUserAgreement"] = options.AgreeWithPersonalDataProcessingPolic
 2162                ["cartItems"] = new JArray(group.Select(x => new JObject { ["id"] = x["id"]?.Value<long?>() }))
 2163            };
 164            try
 165            {
 2166                var orderResponse = await _api.SearchAsync("/api/v1/orders/byCartItems", request, ct);
 1167                var externalOrderId = orderResponse["id"]?.ToString();
 1168                var order = new ShateMOrder { ExternalOrderId = externalOrderId, AgreementCode = options.AgreementCode, 
 1169                _db.ShateMOrders.Add(order);
 4170                foreach (var item in group)
 171                {
 1172                    var externalId = item["id"]?.ToString();
 2173                    var local = localRows.FirstOrDefault(x => x.ExternalBasketItemId == externalId);
 1174                    if (local is not null)
 175                    {
 1176                        local.Status = ShateMCartItemStatus.Ordered;
 1177                        local.ActiveStudioOrderId = null;
 1178                        local.UpdatedAtUtc = DateTime.UtcNow;
 1179                        order.Items.Add(new ShateMOrderItem { ShateMCartItemId = local.Id, StudioOrderId = local.StudioO
 1180                        await _states.MarkOrderedAsync(local.StudioOrderId, ApiSupplierSettings.ShateMProviderKey, exter
 181                    }
 1182                    added++;
 183                }
 1184                await _db.SaveChangesAsync(ct);
 1185                messages.Add($"Шате‑М: создан заказ {externalOrderId ?? "без номера"} ({group.Key}).");
 1186            }
 1187            catch (ShateMApiException ex)
 188            {
 1189                failed += group.Count();
 1190                messages.Add(ExplainCheckoutError(group.Key, ex.Message));
 1191            }
 2192        }
 2193        return new(added, failed, messages);
 3194    }
 195
 196    private async Task<ShateMCartResult> CreateOrdersAsync(IReadOnlyCollection<ShateMCartQueueItem> queue, IReadOnlyList
 197    {
 4198        if (!ValidateOptions(options, out var validationError)) return new(0, queue.Count, [validationError]);
 4199        var quoted = new List<(ShateMCartQueueItem Source, Quote Quote)>();
 27200        try { foreach (var item in queue) quoted.Add((item, await RequoteAsync(item, options, ct))); }
 0201        catch (Exception ex) when (ex is ShateMApiException or InvalidOperationException) { return new(0, queue.Count, [
 12202        var messages = new List<string>(); var added = 0; var failed = 0;
 23203        foreach (var group in quoted.GroupBy(x => x.Quote.LocationCode, StringComparer.OrdinalIgnoreCase))
 204        {
 10205            var request = BuildOrderRequest(group.Select(x => x.Quote), options);
 5206            var local = new ShateMOrder { AgreementCode = options.AgreementCode, DeliveryAddressCode = options.DeliveryA
 10207            _db.ShateMOrders.Add(local); await _db.SaveChangesAsync(ct);
 208            try
 209            {
 5210                var response = await _api.SearchAsync("/api/v1/orders/byPriceItems", request, ct);
 9211                local.ExternalOrderId = response.Value<string>("id"); local.Status = "Создан"; local.ResponseJson = resp
 12212                foreach (var (source, quote) in group)
 213                {
 4214                    var cart = persisted?.FirstOrDefault(x => x.StudioOrderId == source.OrderId);
 6215                    if (cart is not null) { cart.Status = ShateMCartItemStatus.Ordered; cart.ActiveStudioOrderId = null;
 3216                    local.Items.Add(new ShateMOrderItem { ShateMCartItemId = cart?.Id, StudioOrderId = source.OrderId, P
 3217                    await _states.MarkOrderedAsync(source.OrderId, ApiSupplierSettings.ShateMProviderKey, local.External
 3218                    added++;
 219                }
 3220                messages.Add($"Шате‑М: создан заказ {local.ExternalOrderId ?? local.Id.ToString(CultureInfo.InvariantCul
 3221            }
 12222            catch (ShateMApiException ex) { local.Status = "Failed"; local.ResponseJson = ex.Message; failed += group.Co
 5223            await _db.SaveChangesAsync(ct);
 5224        }
 4225        return new(added, failed, messages);
 4226    }
 227
 228    private static string ExplainCheckoutError(string locationCode, string error) =>
 3229        error.Contains("Требуется полная предоплата", StringComparison.OrdinalIgnoreCase)
 3230            ? $"Шате‑М ({locationCode}): заказ не создан. Для этой позиции требуется полная предоплата. Проверьте задолж
 3231            : $"Шате‑М ({locationCode}): заказ не создан. {error}";
 232
 233    private async Task<Quote> RequoteAsync(ShateMCartQueueItem source, ShateMCheckoutOptions options, CancellationToken 
 234    {
 21235        if (source.OrderId <= 0 || string.IsNullOrWhiteSpace(source.Article) || string.IsNullOrWhiteSpace(source.Brand) 
 13236        var articles = await _api.SearchAsync("/api/v1/articles/search", new JObject { ["keys"] = new JArray(new JObject
 37237        var article = (articles as JArray)?.Select(x => x["article"] as JObject ?? x as JObject).OfType<JObject>().First
 13238        var articleId = article?.Value<int?>("id") ?? throw new InvalidOperationException($"Шате‑М: артикул {source.Arti
 12239        var prices = await _api.SearchAsync("/api/v1/prices/search", new JArray(new JObject { ["articleId"] = articleId,
 23240        var offers = (prices as JArray ?? prices["prices"] as JArray ?? new JArray()).SelectMany(x => x["prices"] is JAr
 23241        var offer = offers.FirstOrDefault(x => string.Equals(x.Value<string>("locationCode"), source.LocationCode, Strin
 33242        var minimum = offer.SelectToken("quantity.minimum")?.Value<decimal>() ?? 1m; var multiplicity = offer.SelectToke
 14243        if (source.Quantity < minimum || source.Quantity % multiplicity != 0 || source.Quantity > available) throw new I
 8244        return new Quote(offer.Value<string>("id") ?? throw new InvalidOperationException("Шате‑М не вернул priceId."), 
 8245    }
 246
 247    private async Task<string?> FindExternalBasketItemIdAsync(int orderId, CancellationToken ct)
 248    {
 3249        var response = await _api.GetAsync("/api/v1/cartitems", null, ct);
 3250        var items = GetArray(response);
 3251        return items.OfType<JObject>()
 4252            .FirstOrDefault(item => string.Equals(item.Value<string>("comment"), $"Studio2 заказ {orderId}", StringCompa
 3253    }
 254
 7255    private static JArray GetArray(JToken response) => response as JArray ?? response["items"] as JArray ?? [];
 256
 5257    private static JObject BuildOrderRequest(IEnumerable<Quote> quotes, ShateMCheckoutOptions options) => new()
 5258    {
 5259        ["agreementCode"] = options.AgreementCode, ["deliveryInfo"] = new JObject { ["deliveryType"] = options.DeliveryT
 5260        ["agreeWithTermsOfDelivery"] = options.AgreeWithTermsOfDelivery, ["agreeWithPersonalDataProcessingPolicyAndUserA
 5261        ["priceItems"] = new JArray(quotes.Select(x => new JObject { ["priceId"] = x.PriceId, ["quantity"] = ToApiQuanti
 5262    };
 263
 264    private static int ToApiQuantity(decimal quantity)
 265    {
 7266        if (quantity <= 0 || quantity != decimal.Truncate(quantity) || quantity > int.MaxValue)
 1267            throw new InvalidOperationException("Шате‑М принимает только целое положительное количество.");
 6268        return decimal.ToInt32(quantity);
 269    }
 270
 80271    private static bool ValidateOptions(ShateMCheckoutOptions options, out string error) { error = string.Empty; if (str
 2272    private static ShateMCartItem ToCartItem(ShateMCartQueueItem source, Quote quote, ShateMCheckoutOptions options, str
 49273    private sealed record Quote(string PriceId, string LocationCode, decimal Quantity, decimal? Price, string CurrencyCo
 274}