< Summary

Information
Class: OzonOrdersWeb.TransactionServices.ShateMCheckoutOptions
Assembly: OzonOrdersWeb
File(s): /home/runner/work/Studio2Prod/Studio2Prod/OzonOrdersWeb/Services/Transactions/ShateMCartService.cs
Line coverage
100%
Covered lines: 2
Uncovered lines: 0
Coverable lines: 2
Total lines: 274
Line coverage: 100%
Branch coverage
N/A
Covered branches: 0
Total branches: 0
Branch coverage: N/A
Method coverage

Feature is only available for sponsors

Upgrade to PRO version

Metrics

MethodBranch coverage Crap Score Cyclomatic complexity Line coverage
get_AgreementCode()100%11100%
get_DeliveryAt()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"/> хранит смещение слота Шате‑М: днём доставки считается календарная дата в этом
 14417public sealed record ShateMCheckoutOptions(string AgreementCode, string DeliveryAddressCode, string DeliveryType,
 12718    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
 33    public ShateMCartService(ApplicationDbContext db, ShateMDataManager api, IOrderSupplierStateService states) => (_db,
 34
 35    public Task<int> CountActiveAsync(CancellationToken ct = default) => _db.ShateMCartItems.CountAsync(x => ShateMCartI
 36    public Task<List<ShateMCartItem>> GetActiveAsync(CancellationToken ct = default) => _db.ShateMCartItems.Include(x =>
 37    public Task<List<ShateMOrder>> GetOrdersAsync(CancellationToken ct = default) => _db.ShateMOrders.Include(x => x.Ite
 38
 39    public async Task<JObject> GetCheckoutDataAsync(CancellationToken ct)
 40    {
 41        var agreements = await _api.GetAsync("/api/v1/customer/agreements", null, ct);
 42        var addresses = await _api.GetAsync("/api/v1/delivery/addresses", null, ct);
 43        return new JObject { ["agreements"] = agreements, ["addresses"] = addresses };
 44    }
 45
 46    public async Task<JToken> GetAvailableDatesAsync(string locationCode, string addressCode, CancellationToken ct) =>
 47        await _api.GetAsync("/api/v1/delivery/availableDateTimes", new Dictionary<string, string?>
 48        {
 49            ["locationCode"] = locationCode, ["deliveryAddressCode"] = addressCode
 50        }, ct);
 51
 52    public async Task<ShateMCartResult> QueueAsync(IReadOnlyCollection<ShateMCartQueueItem> queue, ShateMCheckoutOptions
 53    {
 54        if (!ValidateOptions(options, out var error)) return new(0, queue.Count, [error]);
 55        var added = 0; var failed = 0; var messages = new List<string>();
 56        foreach (var source in queue)
 57        {
 58            try
 59            {
 60                if (await _db.ShateMCartItems.AnyAsync(x => x.ActiveStudioOrderId == source.OrderId, ct)) { messages.Add
 61                var quote = await RequoteAsync(source, options, ct);
 62                var externalId = await FindExternalBasketItemIdAsync(source.OrderId, ct);
 63                if (string.IsNullOrWhiteSpace(externalId))
 64                {
 65                    var response = await _api.SearchAsync("/api/v1/cartitems", new JArray(new JObject
 66                    {
 67                        ["priceId"] = quote.PriceId,
 68                        ["quantity"] = ToApiQuantity(quote.Quantity),
 69                        ["comment"] = $"Studio2 заказ {source.OrderId}"
 70                    }), ct);
 71                    externalId = response is JArray createdItems
 72                        ? createdItems.FirstOrDefault()?["id"]?.ToString()
 73                        : response["id"]?.ToString();
 74                }
 75                var row = ToCartItem(source, quote, options, user, externalId);
 76                _db.ShateMCartItems.Add(row);
 77                await _db.SaveChangesAsync(ct);
 78                await _states.MarkInCartAsync(source.OrderId, ApiSupplierSettings.ShateMProviderKey, ShateMCartItemStatu
 79                added++; messages.Add($"{source.Article}: добавлен в корзину Шате‑М.");
 80            }
 81            catch (Exception ex) when (ex is ShateMApiException or InvalidOperationException)
 82            {
 83                failed++; messages.Add($"{source.Article}: {ex.Message}");
 84            }
 85        }
 86        return new(added, failed, messages);
 87    }
 88
 89    public async Task<ShateMCartResult> CheckoutDirectAsync(IReadOnlyCollection<ShateMCartQueueItem> queue, ShateMChecko
 90        await CreateOrdersAsync(queue, null, options, user, ct);
 91
 92    public async Task<ShateMCartResult> CheckoutAsync(IReadOnlyCollection<long>? ids, ShateMCheckoutOptions options, str
 93    {
 94        var query = _db.ShateMCartItems.Where(x => ShateMCartItemStatus.Active.Contains(x.Status));
 95        if (ids is { Count: > 0 }) query = query.Where(x => ids.Contains(x.Id));
 96        var rows = await query.ToListAsync(ct);
 97        var queue = rows.Select(x => new ShateMCartQueueItem(x.StudioOrderId, x.Article, x.Brand, x.ProductName, x.Price
 98        return await CreateOrdersAsync(queue, rows, options, user, ct);
 99    }
 100
 101    public async Task<int> RemoveAsync(IReadOnlyCollection<long>? ids, CancellationToken ct)
 102    {
 103        var query = _db.ShateMCartItems.Where(x => ShateMCartItemStatus.Active.Contains(x.Status));
 104        if (ids is { Count: > 0 }) query = query.Where(x => ids.Contains(x.Id));
 105        var rows = await query.ToListAsync(ct);
 106        foreach (var row in rows)
 107        {
 108            if (!string.IsNullOrWhiteSpace(row.ExternalBasketItemId)) await _api.DeleteAsync($"/api/v1/cartitems/{Uri.Es
 109            row.Status = ShateMCartItemStatus.Removed; row.ActiveStudioOrderId = null; row.UpdatedAtUtc = DateTime.UtcNo
 110            await _states.RemoveFromCartAsync(row.StudioOrderId, ApiSupplierSettings.ShateMProviderKey, null, ct);
 111        }
 112        await _db.SaveChangesAsync(ct); return rows.Count;
 113    }
 114
 115    /// <summary>Удаляет фактические строки внешней корзины, включая строки без локальной связи Studio2.</summary>
 116    public async Task<int> RemoveExternalAsync(IReadOnlyCollection<string>? externalIds, bool removeAll, CancellationTok
 117    {
 118        var ids = externalIds?.Where(x => !string.IsNullOrWhiteSpace(x)).Distinct(StringComparer.Ordinal).ToList() ?? []
 119        if (removeAll)
 120        {
 121            var response = await _api.GetAsync("/api/v1/cartitems", null, ct);
 122            ids = GetArray(response).OfType<JObject>().Select(x => x["id"]?.ToString()).Where(x => !string.IsNullOrWhite
 123        }
 124
 125        if (ids.Count == 0) return 0;
 126        var localRows = await _db.ShateMCartItems.Where(x => x.ExternalBasketItemId != null && ids.Contains(x.ExternalBa
 127        foreach (var id in ids)
 128            await _api.DeleteAsync($"/api/v1/cartitems/{Uri.EscapeDataString(id)}", ct);
 129        foreach (var row in localRows)
 130        {
 131            row.Status = ShateMCartItemStatus.Removed;
 132            row.ActiveStudioOrderId = null;
 133            row.UpdatedAtUtc = DateTime.UtcNow;
 134            await _states.RemoveFromCartAsync(row.StudioOrderId, ApiSupplierSettings.ShateMProviderKey, null, ct);
 135        }
 136        await _db.SaveChangesAsync(ct);
 137        return ids.Count;
 138    }
 139
 140    /// <summary>Оформляет фактические строки API-корзины через byCartItems, разбивая по складам.</summary>
 141    public async Task<ShateMCartResult> CheckoutExternalAsync(IReadOnlyCollection<string>? externalIds, bool checkoutAll
 142    {
 143        if (!ValidateOptions(options, out var error)) return new(0, externalIds?.Count ?? 0, [error]);
 144        var requested = externalIds?.Where(x => !string.IsNullOrWhiteSpace(x)).ToHashSet(StringComparer.Ordinal) ?? [];
 145        var response = await _api.GetAsync("/api/v1/cartitems", null, ct);
 146        var cartItems = GetArray(response).OfType<JObject>()
 147            .Where(item => checkoutAll || requested.Contains(item["id"]?.ToString() ?? string.Empty)).ToList();
 148        if (cartItems.Count == 0) return new(0, 0, ["Шате‑М: в API-корзине нет выбранных позиций."]);
 149
 150        var localRows = await _db.ShateMCartItems.Where(x => x.ExternalBasketItemId != null && cartItems.Select(i => i["
 151        var added = 0;
 152        var failed = 0;
 153        var messages = new List<string>();
 154        foreach (var group in cartItems.GroupBy(x => x.Value<string>("locationCode") ?? string.Empty, StringComparer.Ord
 155        {
 156            var request = new JObject
 157            {
 158                ["agreementCode"] = options.AgreementCode,
 159                ["deliveryInfo"] = new JObject { ["deliveryType"] = options.DeliveryType, ["deliveryAddressCode"] = opti
 160                ["agreeWithTermsOfDelivery"] = options.AgreeWithTermsOfDelivery,
 161                ["agreeWithPersonalDataProcessingPolicyAndUserAgreement"] = options.AgreeWithPersonalDataProcessingPolic
 162                ["cartItems"] = new JArray(group.Select(x => new JObject { ["id"] = x["id"]?.Value<long?>() }))
 163            };
 164            try
 165            {
 166                var orderResponse = await _api.SearchAsync("/api/v1/orders/byCartItems", request, ct);
 167                var externalOrderId = orderResponse["id"]?.ToString();
 168                var order = new ShateMOrder { ExternalOrderId = externalOrderId, AgreementCode = options.AgreementCode, 
 169                _db.ShateMOrders.Add(order);
 170                foreach (var item in group)
 171                {
 172                    var externalId = item["id"]?.ToString();
 173                    var local = localRows.FirstOrDefault(x => x.ExternalBasketItemId == externalId);
 174                    if (local is not null)
 175                    {
 176                        local.Status = ShateMCartItemStatus.Ordered;
 177                        local.ActiveStudioOrderId = null;
 178                        local.UpdatedAtUtc = DateTime.UtcNow;
 179                        order.Items.Add(new ShateMOrderItem { ShateMCartItemId = local.Id, StudioOrderId = local.StudioO
 180                        await _states.MarkOrderedAsync(local.StudioOrderId, ApiSupplierSettings.ShateMProviderKey, exter
 181                    }
 182                    added++;
 183                }
 184                await _db.SaveChangesAsync(ct);
 185                messages.Add($"Шате‑М: создан заказ {externalOrderId ?? "без номера"} ({group.Key}).");
 186            }
 187            catch (ShateMApiException ex)
 188            {
 189                failed += group.Count();
 190                messages.Add(ExplainCheckoutError(group.Key, ex.Message));
 191            }
 192        }
 193        return new(added, failed, messages);
 194    }
 195
 196    private async Task<ShateMCartResult> CreateOrdersAsync(IReadOnlyCollection<ShateMCartQueueItem> queue, IReadOnlyList
 197    {
 198        if (!ValidateOptions(options, out var validationError)) return new(0, queue.Count, [validationError]);
 199        var quoted = new List<(ShateMCartQueueItem Source, Quote Quote)>();
 200        try { foreach (var item in queue) quoted.Add((item, await RequoteAsync(item, options, ct))); }
 201        catch (Exception ex) when (ex is ShateMApiException or InvalidOperationException) { return new(0, queue.Count, [
 202        var messages = new List<string>(); var added = 0; var failed = 0;
 203        foreach (var group in quoted.GroupBy(x => x.Quote.LocationCode, StringComparer.OrdinalIgnoreCase))
 204        {
 205            var request = BuildOrderRequest(group.Select(x => x.Quote), options);
 206            var local = new ShateMOrder { AgreementCode = options.AgreementCode, DeliveryAddressCode = options.DeliveryA
 207            _db.ShateMOrders.Add(local); await _db.SaveChangesAsync(ct);
 208            try
 209            {
 210                var response = await _api.SearchAsync("/api/v1/orders/byPriceItems", request, ct);
 211                local.ExternalOrderId = response.Value<string>("id"); local.Status = "Создан"; local.ResponseJson = resp
 212                foreach (var (source, quote) in group)
 213                {
 214                    var cart = persisted?.FirstOrDefault(x => x.StudioOrderId == source.OrderId);
 215                    if (cart is not null) { cart.Status = ShateMCartItemStatus.Ordered; cart.ActiveStudioOrderId = null;
 216                    local.Items.Add(new ShateMOrderItem { ShateMCartItemId = cart?.Id, StudioOrderId = source.OrderId, P
 217                    await _states.MarkOrderedAsync(source.OrderId, ApiSupplierSettings.ShateMProviderKey, local.External
 218                    added++;
 219                }
 220                messages.Add($"Шате‑М: создан заказ {local.ExternalOrderId ?? local.Id.ToString(CultureInfo.InvariantCul
 221            }
 222            catch (ShateMApiException ex) { local.Status = "Failed"; local.ResponseJson = ex.Message; failed += group.Co
 223            await _db.SaveChangesAsync(ct);
 224        }
 225        return new(added, failed, messages);
 226    }
 227
 228    private static string ExplainCheckoutError(string locationCode, string error) =>
 229        error.Contains("Требуется полная предоплата", StringComparison.OrdinalIgnoreCase)
 230            ? $"Шате‑М ({locationCode}): заказ не создан. Для этой позиции требуется полная предоплата. Проверьте задолж
 231            : $"Шате‑М ({locationCode}): заказ не создан. {error}";
 232
 233    private async Task<Quote> RequoteAsync(ShateMCartQueueItem source, ShateMCheckoutOptions options, CancellationToken 
 234    {
 235        if (source.OrderId <= 0 || string.IsNullOrWhiteSpace(source.Article) || string.IsNullOrWhiteSpace(source.Brand) 
 236        var articles = await _api.SearchAsync("/api/v1/articles/search", new JObject { ["keys"] = new JArray(new JObject
 237        var article = (articles as JArray)?.Select(x => x["article"] as JObject ?? x as JObject).OfType<JObject>().First
 238        var articleId = article?.Value<int?>("id") ?? throw new InvalidOperationException($"Шате‑М: артикул {source.Arti
 239        var prices = await _api.SearchAsync("/api/v1/prices/search", new JArray(new JObject { ["articleId"] = articleId,
 240        var offers = (prices as JArray ?? prices["prices"] as JArray ?? new JArray()).SelectMany(x => x["prices"] is JAr
 241        var offer = offers.FirstOrDefault(x => string.Equals(x.Value<string>("locationCode"), source.LocationCode, Strin
 242        var minimum = offer.SelectToken("quantity.minimum")?.Value<decimal>() ?? 1m; var multiplicity = offer.SelectToke
 243        if (source.Quantity < minimum || source.Quantity % multiplicity != 0 || source.Quantity > available) throw new I
 244        return new Quote(offer.Value<string>("id") ?? throw new InvalidOperationException("Шате‑М не вернул priceId."), 
 245    }
 246
 247    private async Task<string?> FindExternalBasketItemIdAsync(int orderId, CancellationToken ct)
 248    {
 249        var response = await _api.GetAsync("/api/v1/cartitems", null, ct);
 250        var items = GetArray(response);
 251        return items.OfType<JObject>()
 252            .FirstOrDefault(item => string.Equals(item.Value<string>("comment"), $"Studio2 заказ {orderId}", StringCompa
 253    }
 254
 255    private static JArray GetArray(JToken response) => response as JArray ?? response["items"] as JArray ?? [];
 256
 257    private static JObject BuildOrderRequest(IEnumerable<Quote> quotes, ShateMCheckoutOptions options) => new()
 258    {
 259        ["agreementCode"] = options.AgreementCode, ["deliveryInfo"] = new JObject { ["deliveryType"] = options.DeliveryT
 260        ["agreeWithTermsOfDelivery"] = options.AgreeWithTermsOfDelivery, ["agreeWithPersonalDataProcessingPolicyAndUserA
 261        ["priceItems"] = new JArray(quotes.Select(x => new JObject { ["priceId"] = x.PriceId, ["quantity"] = ToApiQuanti
 262    };
 263
 264    private static int ToApiQuantity(decimal quantity)
 265    {
 266        if (quantity <= 0 || quantity != decimal.Truncate(quantity) || quantity > int.MaxValue)
 267            throw new InvalidOperationException("Шате‑М принимает только целое положительное количество.");
 268        return decimal.ToInt32(quantity);
 269    }
 270
 271    private static bool ValidateOptions(ShateMCheckoutOptions options, out string error) { error = string.Empty; if (str
 272    private static ShateMCartItem ToCartItem(ShateMCartQueueItem source, Quote quote, ShateMCheckoutOptions options, str
 273    private sealed record Quote(string PriceId, string LocationCode, decimal Quantity, decimal? Price, string CurrencyCo
 274}