< Summary

Information
Class: OzonOrdersWeb.TransactionServices.BergCartQueueItem
Assembly: OzonOrdersWeb
File(s): /home/runner/work/Studio2Prod/Studio2Prod/OzonOrdersWeb/Services/Transactions/BergCartService.cs
Line coverage
100%
Covered lines: 15
Uncovered lines: 0
Coverable lines: 15
Total lines: 463
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
.ctor(...)100%11100%
get_OrderId()100%11100%
get_ResourceId()100%11100%
get_WarehouseId()100%11100%
get_Quantity()100%11100%
get_Price()100%11100%
get_Article()100%11100%
get_Brand()100%11100%
get_ProductName()100%11100%
get_ShipmentNumber()100%11100%
get_ClientName()100%11100%
get_Multi()100%11100%
get_DeliveryDaysMin()100%11100%
get_DeliveryDaysMax()100%11100%
get_Direction()100%11100%

File(s)

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

#LineLine coverage
 1using System.Text.Json;
 2using Microsoft.EntityFrameworkCore;
 3using OzonDomains.Models;
 4using OzonDomains.Models.SupplierStatuses;
 5using OzonRepositories.Context;
 6using Services.ApiServices.BergApi;
 7using Services.ApiSupplierSettingsServices;
 8using Services.DataServices;
 9using Services.SupplierStatusServices;
 10
 11namespace OzonOrdersWeb.TransactionServices;
 12
 1613public sealed record BergCartQueueItem(
 8614    int OrderId,
 3215    long ResourceId,
 3216    long WarehouseId,
 3617    decimal Quantity,
 3118    decimal? Price,
 4919    string? Article,
 920    string? Brand,
 1521    string? ProductName,
 6522    string? ShipmentNumber,
 823    string? ClientName,
 1424    int Multi = 1,
 3425    int? DeliveryDaysMin = null,
 826    int? DeliveryDaysMax = null,
 3927    string? Direction = null);
 28
 29public sealed record BergCartResult(bool IsSuccess, IReadOnlyList<string> Messages);
 30
 31/// <summary>
 32/// Корзина Berg со страницы проведения. У Berg нет API корзины: <c>ordering/place_order.json</c>
 33/// размещает заказ целиком одним вызовом, поэтому отобранные позиции копятся в локальной таблице
 34/// <c>BergCartItems</c>, а прямое оформление отправляет их поставщику за один запрос.
 35/// </summary>
 36public sealed class BergCartService
 37{
 38    private const string ProviderKey = ApiSupplierSettings.BergProviderKey;
 39    private const string SupplierDisplayName = "Берг";
 40    private const string LogoUrl = "/images/berg-logo-2026.png";
 41    private const string CurrencyCode = "RUB";
 42    private const string SupplierCartUrl = "/SupplierCart?provider=berg&supplierTab=basket";
 43
 44    /// <summary>Заказы, поданные после этого часа, Berg отгружает во второй половине дня.</summary>
 45    private const int MiddayHour = 15;
 46
 47    private readonly ApplicationDbContext _context;
 48    private readonly BergDataManager _berg;
 49    private readonly BergDeliveryAddressMappingService _addresses;
 50    private readonly IOrderHistoryDataService _orderHistoryService;
 51    private readonly IOrderSupplierStateService _supplierStateService;
 52
 53    public BergCartService(ApplicationDbContext context, BergDataManager berg,
 54        BergDeliveryAddressMappingService addresses, IOrderHistoryDataService orderHistoryService,
 55        IOrderSupplierStateService supplierStateService)
 56    {
 57        _context = context;
 58        _berg = berg;
 59        _addresses = addresses;
 60        _orderHistoryService = orderHistoryService;
 61        _supplierStateService = supplierStateService;
 62    }
 63
 64    /// <summary>Позиции кладутся только в локальную корзину: заказ у Berg оформляется отдельно.</summary>
 65    public async Task<BergCartResult> PlaceQueuedItemsAsync(IReadOnlyCollection<BergCartQueueItem> items,
 66        string changedBy, CancellationToken ct = default)
 67    {
 68        if (items.Count == 0) return new(true, []);
 69        if (Invalid(items)) return new(false, ["Для корзины Berg требуется ровно одно корректное предложение на заказ."]
 70
 71        var messages = new List<string>();
 72        var added = new List<BergCartQueueItem>();
 73        foreach (var item in items)
 74        {
 75            var existing = await _context.BergCartItems
 76                .SingleOrDefaultAsync(x => x.ActiveStudioOrderId == item.OrderId, ct);
 77            if (existing is not null)
 78            {
 79                messages.Add($"{Label(item)}: уже в корзине Berg, повторно не добавляем.");
 80                continue;
 81            }
 82
 83            _context.BergCartItems.Add(BuildCartItem(item, changedBy, BergCartItemStatus.Pending));
 84            added.Add(item);
 85        }
 86
 87        if (added.Count == 0)
 88            return new(false, messages);
 89
 90        await _context.SaveChangesAsync(ct);
 91
 92        // Журнал и стадия — только по реально добавленным строкам: пропущенный дубль
 93        // не должен появляться в истории заказа.
 94        foreach (var item in added)
 95        {
 96            await TryTrackCartAsync(item, changedBy);
 97            await TryRecordHistoryAsync(item, true, null, null, changedBy, ApiSupplierBasketCartStatus.Pending);
 98            messages.Add($"{Label(item)}: добавлено в корзину Berg.");
 99        }
 100
 101        return new(added.Count == items.Count, messages);
 102    }
 103
 104    /// <summary>
 105    /// Прямое оформление: позиции сохраняются в локальной корзине и сразу уходят в Berg одним
 106    /// заказом. Berg не умеет дозаказывать в существующий заказ, поэтому дробить вызов нельзя.
 107    /// </summary>
 108    public async Task<BergCartResult> CheckoutDirectAsync(IReadOnlyCollection<BergCartQueueItem> items,
 109        string changedBy, CancellationToken ct = default, bool isTest = false)
 110    {
 111        if (items.Count == 0) return new(false, ["Не выбраны позиции Berg для оформления."]);
 112        if (Invalid(items)) return new(false, ["Для прямого оформления Berg требуется ровно одно корректное предложение 
 113
 114        // Адрес отгрузки обязателен и определяется складом заказа. Заказы с разными адресами —
 115        // это разные заказы Berg, а одним вызовом place_order их не разделить.
 116        var addresses = new Dictionary<int, BergDeliveryAddressSelection>();
 117        var missing = new List<string>();
 118        foreach (var item in items)
 119        {
 120            var address = await _addresses.ResolveAsync(item.OrderId, cancellationToken: ct);
 121            if (address is null) missing.Add(Label(item));
 122            else addresses[item.OrderId] = address;
 123        }
 124
 125        if (missing.Count > 0)
 126            return new(false, [$"Для складов этих позиций не задан адрес отгрузки Berg: {string.Join(", ", missing)}. " 
 127                               "Свяжите склад с адресом в <a href=\"/Studio2/ApiSupplierSettings/Berg\" target=\"_blank\
 128                               "(раздел Справочники → Настройки API-поставщиков → Berg)."]);
 129
 130        var distinctAddresses = addresses.Values.Select(x => x.BergShipmentAddressId).Distinct().Count();
 131        if (distinctAddresses > 1)
 132            return new(false, ["Позиции относятся к разным адресам отгрузки Berg. Проведите их отдельными документами."]
 133
 134        var selection = addresses.Values.First();
 135        var request = BuildOrderRequest(items, selection) with { IsTest = isTest };
 136
 137        var cartItems = new List<BergCartItem>();
 138        foreach (var item in items)
 139        {
 140            var existing = await _context.BergCartItems
 141                .SingleOrDefaultAsync(x => x.ActiveStudioOrderId == item.OrderId, ct);
 142            if (existing is null)
 143            {
 144                existing = BuildCartItem(item, changedBy, BergCartItemStatus.Processing);
 145                _context.BergCartItems.Add(existing);
 146            }
 147            else
 148            {
 149                existing.Status = BergCartItemStatus.Processing;
 150                existing.UpdatedAtUtc = DateTime.UtcNow;
 151            }
 152
 153            cartItems.Add(existing);
 154        }
 155
 156        await _context.SaveChangesAsync(ct);
 157
 158        BergPlaceOrderResult result;
 159        try
 160        {
 161            result = await _berg.PlaceOrderAsync(request, ct);
 162        }
 163        catch (BergApiException ex)
 164        {
 165            await MarkFailedAsync(cartItems, items, ex.Message, changedBy, ct);
 166            return new(false, [$"Berg не оформил заказ: {ex.Message}. Позиции остались в корзине Berg."]);
 167        }
 168
 169        if (!result.IsSuccess)
 170        {
 171            var error = result.Errors.Count > 0
 172                ? string.Join("; ", result.Errors.Select(x => $"{x.Text} ({x.Code})"))
 173                : "поставщик не вернул номер заказа";
 174            await MarkFailedAsync(cartItems, items, error, changedBy, ct);
 175            return new(false, [$"Berg не оформил заказ: {error}. Позиции остались в корзине Berg."]);
 176        }
 177
 178        var order = await SaveOrderAsync(result.Order!, request, items, cartItems, changedBy, ct);
 179
 180        var messages = new List<string>();
 181        foreach (var item in items)
 182        {
 183            var line = MatchLine(result.Order!, item);
 184            await TryTrackOrderedAsync(item, order.ExternalOrderId, line?.Sequence, changedBy);
 185            await TryRecordHistoryAsync(item, true, line?.Sequence.ToString(), order.ExternalOrderId.ToString(),
 186                changedBy, ApiSupplierBasketCartStatus.Ordered);
 187        }
 188
 189        messages.Add(request.IsTest
 190            ? $"Berg принял тестовый заказ №{order.ExternalOrderId} (is_test — заказ не обрабатывается)."
 191            : $"Создан заказ Berg №{order.ExternalOrderId}.");
 192        foreach (var warning in result.Warnings)
 193            messages.Add($"Berg: {warning.Text} ({warning.Code}).");
 194
 195        return new(true, messages);
 196    }
 197
 198    internal static BergOrderRequest BuildOrderRequest(IReadOnlyCollection<BergCartQueueItem> items,
 199        BergDeliveryAddressSelection address, DateTime? now = null)
 200    {
 201        var moment = now ?? DateTime.Now;
 202        // Дату отгрузки задаёт самая долгая позиция: раньше неё заказ целиком не уедет.
 203        var days = items.Max(x => x.DeliveryDaysMin ?? 0);
 204        return new BergOrderRequest
 205        {
 206            Items = items.Select(x => new BergOrderItemRequest(
 207                x.ResourceId, x.WarehouseId, x.Quantity,
 208                MaxPrice: x.Price,
 209                Comment: Comment(x))).ToList(),
 210            DispatchType = address.DispatchType,
 211            DispatchAt = moment.Date.AddDays(days),
 212            DispatchTime = moment.Hour < MiddayHour ? BergDispatchTime.BeforeMidday : BergDispatchTime.AfterMidday,
 213            ShipmentAddressId = address.BergShipmentAddressId,
 214            Comment = $"Studio2: {string.Join(", ", items.Select(Label))}",
 215            // Числовой ключ идемпотентности: Berg отклонит повторную отправку той же формы.
 216            Reference = DateTimeOffset.UtcNow.ToUnixTimeMilliseconds()
 217        };
 218    }
 219
 220    private async Task<BergOrder> SaveOrderAsync(BergOrderState state, BergOrderRequest request,
 221        IReadOnlyCollection<BergCartQueueItem> items, IReadOnlyList<BergCartItem> cartItems, string changedBy,
 222        CancellationToken ct)
 223    {
 224        var order = new BergOrder
 225        {
 226            ExternalOrderId = state.Id,
 227            Reference = request.Reference,
 228            DispatchType = request.DispatchType,
 229            DispatchAt = request.DispatchAt,
 230            DispatchTime = request.DispatchTime,
 231            ShipmentAddressId = request.ShipmentAddressId,
 232            ShipmentAddress = state.ShipmentAddress,
 233            PaymentType = state.PaymentType,
 234            IsTest = state.IsTest || request.IsTest,
 235            Status = request.IsTest ? BergOrderStatus.Test : BergOrderStatus.Placed,
 236            RequestJson = BergDataManager.BuildOrderBody(request).ToString(Newtonsoft.Json.Formatting.None),
 237            ResponseJson = JsonSerializer.Serialize(state, HistoryJsonOptions),
 238            CreatedBy = changedBy,
 239            CreatedAtUtc = DateTime.UtcNow
 240        };
 241
 242        foreach (var item in items)
 243        {
 244            var line = MatchLine(state, item);
 245            var cartItem = cartItems.FirstOrDefault(x => x.StudioOrderId == item.OrderId);
 246            order.Items.Add(new BergOrderItem
 247            {
 248                BergCartItemId = cartItem?.Id,
 249                StudioOrderId = item.OrderId,
 250                Sequence = line?.Sequence ?? 0,
 251                ResourceId = item.ResourceId,
 252                WarehouseId = item.WarehouseId,
 253                Article = Trim(item.Article, 60),
 254                Brand = Trim(item.Brand, 100),
 255                Quantity = item.Quantity,
 256                Price = line?.Price ?? item.Price,
 257                StateId = line?.StateId,
 258                StateName = Trim(line?.StateName, 200)
 259            });
 260
 261            if (cartItem is null) continue;
 262            cartItem.Status = BergCartItemStatus.Ordered;
 263            // Строка уходит из активной корзины, но остаётся в истории.
 264            cartItem.ActiveStudioOrderId = null;
 265            cartItem.LastError = null;
 266            cartItem.UpdatedAtUtc = DateTime.UtcNow;
 267        }
 268
 269        _context.BergOrders.Add(order);
 270        await _context.SaveChangesAsync(ct);
 271        return order;
 272    }
 273
 274    private async Task MarkFailedAsync(IReadOnlyList<BergCartItem> cartItems,
 275        IReadOnlyCollection<BergCartQueueItem> items, string error, string changedBy, CancellationToken ct)
 276    {
 277        foreach (var cartItem in cartItems)
 278        {
 279            cartItem.Status = BergCartItemStatus.Failed;
 280            cartItem.LastError = Trim(error, 1000);
 281            cartItem.UpdatedAtUtc = DateTime.UtcNow;
 282        }
 283
 284        await _context.SaveChangesAsync(ct);
 285
 286        foreach (var item in items)
 287        {
 288            await TryTrackCartAsync(item, changedBy, SupplierCartStatusCode.Failed);
 289            await TryRecordHistoryAsync(item, false, null, null, changedBy, ApiSupplierBasketCartStatus.Failed, error);
 290        }
 291    }
 292
 293    private BergCartItem BuildCartItem(BergCartQueueItem item, string changedBy, string status) => new()
 294    {
 295        StudioOrderId = item.OrderId,
 296        ActiveStudioOrderId = item.OrderId,
 297        ResourceId = item.ResourceId,
 298        WarehouseId = item.WarehouseId,
 299        Article = Trim(item.Article, 60),
 300        Brand = Trim(item.Brand, 100),
 301        ProductName = Trim(item.ProductName, 200),
 302        WarehouseName = Trim(item.Direction, 160),
 303        Quantity = item.Quantity,
 304        Multi = item.Multi > 0 ? item.Multi : 1,
 305        SourcePrice = item.Price,
 306        MaxPrice = item.Price,
 307        CurrencyCode = CurrencyCode,
 308        DeliveryAtUtc = item.DeliveryDaysMin.HasValue ? DateTime.UtcNow.Date.AddDays(item.DeliveryDaysMin.Value) : null,
 309        Status = status,
 310        CreatedBy = changedBy,
 311        CreatedAtUtc = DateTime.UtcNow,
 312        UpdatedAtUtc = DateTime.UtcNow
 313    };
 314
 315    /// <summary>Berg нумерует строки заказа сам, поэтому сопоставляем по паре resource + склад.</summary>
 316    private static BergOrderItemState? MatchLine(BergOrderState state, BergCartQueueItem item) =>
 317        state.Items.FirstOrDefault(x => x.ResourceId == item.ResourceId && x.WarehouseId == item.WarehouseId);
 318
 319    private static bool Invalid(IReadOnlyCollection<BergCartQueueItem> items) =>
 320        items.Any(x => x.OrderId <= 0 || x.ResourceId <= 0 || x.WarehouseId <= 0 || x.Quantity <= 0) ||
 321        items.GroupBy(x => x.OrderId).Any(x => x.Count() > 1);
 322
 323    /// <summary>Подпись позиции в сообщениях: номер отгрузки Studio2 и артикул — по ним
 324    /// пользователь находит заказ, к которому относится строка.</summary>
 325    private static string Label(BergCartQueueItem item)
 326    {
 327        var order = string.IsNullOrWhiteSpace(item.ShipmentNumber) ? $"ID {item.OrderId}" : item.ShipmentNumber!;
 328        return string.IsNullOrWhiteSpace(item.Article) ? order : $"{order} ({item.Article})";
 329    }
 330
 331    private static string Comment(BergCartQueueItem item) =>
 332        $"Studio2 {(string.IsNullOrWhiteSpace(item.ShipmentNumber) ? $"ID {item.OrderId}" : item.ShipmentNumber)} (ID {i
 333
 334    private static string Trim(string? value, int maxLength)
 335    {
 336        var trimmed = (value ?? string.Empty).Trim();
 337        return trimmed.Length <= maxLength ? trimmed : trimmed[..maxLength];
 338    }
 339
 340    private static readonly JsonSerializerOptions HistoryJsonOptions = new()
 341    {
 342        Encoder = System.Text.Encodings.Web.JavaScriptEncoder.Create(System.Text.Unicode.UnicodeRanges.All)
 343    };
 344
 345    /// <summary>Стадия корзины — вспомогательный журнал: её сбой не должен ломать уже выполненное размещение.</summary>
 346    private async Task TryTrackCartAsync(BergCartQueueItem item, string changedBy,
 347        string statusCode = SupplierCartStatusCode.Pending)
 348    {
 349        try
 350        {
 351            await _supplierStateService.MarkInCartAsync(item.OrderId, ProviderKey, statusCode,
 352                cartItemId: null, externalBasketItemId: null, user: changedBy,
 353                deliveryDaysMin: item.DeliveryDaysMin, deliveryDaysMax: item.DeliveryDaysMax, direction: item.Direction)
 354        }
 355        catch
 356        {
 357            // См. комментарий к методу.
 358        }
 359    }
 360
 361    private async Task TryTrackOrderedAsync(BergCartQueueItem item, long externalOrderId, int? sequence, string changedB
 362    {
 363        try
 364        {
 365            var orderId = externalOrderId.ToString();
 366            await _supplierStateService.MarkOrderedAsync(item.OrderId, ProviderKey, externalOrderNumber: orderId,
 367                externalOrderId: orderId, externalLineId: sequence?.ToString(), user: changedBy,
 368                deliveryDaysMin: item.DeliveryDaysMin, deliveryDaysMax: item.DeliveryDaysMax, direction: item.Direction)
 369        }
 370        catch
 371        {
 372            // См. TryTrackCartAsync.
 373        }
 374    }
 375
 376    private async Task TryRecordHistoryAsync(BergCartQueueItem item, bool success, string? apiItemId,
 377        string? supplierOrderNumber, string changedBy, string cartStatus, string? errorMessage = null)
 378    {
 379        try
 380        {
 381            var entry = new ApiSupplierBasketHistoryEntry(ProviderKey, SupplierDisplayName, LogoUrl, success,
 382                errorMessage, apiItemId, item.Article, item.ProductName, item.Price, CurrencyCode, item.Quantity,
 383                string.IsNullOrWhiteSpace(item.ShipmentNumber) ? $"ID {item.OrderId}" : item.ShipmentNumber,
 384                ClientName: item.ClientName, WarehouseName: item.Direction, CartStatus: cartStatus,
 385                SupplierOrderNumber: supplierOrderNumber, SupplierCartUrl: SupplierCartUrl);
 386
 387            await _orderHistoryService.AddOrderHistory(new OrderHistory
 388            {
 389                OrderId = item.OrderId,
 390                ColumnName = ApiSupplierBasketHistoryEntry.HistoryColumnName,
 391                ColumnDisplayName = $"Корзина {SupplierDisplayName}",
 392                OldValue = null,
 393                NewValue = JsonSerializer.Serialize(entry, ApiSupplierBasketHistoryEntry.JsonOptions),
 394                ChangedAt = DateTime.Now,
 395                ChangedBy = changedBy
 396            });
 397        }
 398        catch
 399        {
 400            // Не ломаем размещение в корзине из-за журнала истории.
 401        }
 402    }
 403
 404    /// <summary>
 405    /// Оформление отобранных строк локальной корзины со страницы «Корзина поставщика».
 406    /// <paramref name="isTest"/> отправляет заказ с order[is_test]=1: Berg сохранит его в истории,
 407    /// но обрабатывать не станет — это штатная проверка перед боевым размещением.
 408    /// </summary>
 409    public async Task<BergCartResult> CheckoutAsync(IReadOnlyCollection<long> cartItemIds, string changedBy,
 410        bool isTest = false, CancellationToken ct = default)
 411    {
 412        var rows = await _context.BergCartItems
 413            .Where(x => cartItemIds.Contains(x.Id) && BergCartItemStatus.Active.Contains(x.Status))
 414            .ToListAsync(ct);
 415        if (rows.Count == 0) return new(false, ["Не выбраны позиции корзины Berg."]);
 416
 417        var queue = rows.Select(x => new BergCartQueueItem(x.StudioOrderId, x.ResourceId, x.WarehouseId, x.Quantity,
 418            x.MaxPrice ?? x.SourcePrice, x.Article, x.Brand, x.ProductName, null, null,
 419            x.Multi, null, null, x.WarehouseName)).ToList();
 420
 421        var shipmentNumbers = await _context.Orders.AsNoTracking()
 422            .Where(x => queue.Select(q => q.OrderId).Contains(x.Id))
 423            .ToDictionaryAsync(x => x.Id, x => x.ShipmentNumber, ct);
 424        queue = queue.Select(x => shipmentNumbers.TryGetValue(x.OrderId, out var number) && !string.IsNullOrWhiteSpace(n
 425            ? x with { ShipmentNumber = number }
 426            : x).ToList();
 427
 428        return await CheckoutDirectAsync(queue, changedBy, ct, isTest);
 429    }
 430
 431    public Task<int> CountActiveAsync(CancellationToken ct = default) =>
 432        _context.BergCartItems.CountAsync(x => BergCartItemStatus.Active.Contains(x.Status), ct);
 433
 434    public Task<List<BergCartItem>> GetActiveAsync(CancellationToken ct = default) =>
 435        _context.BergCartItems.AsNoTracking()
 436            .Where(x => BergCartItemStatus.Active.Contains(x.Status))
 437            .OrderByDescending(x => x.CreatedAtUtc)
 438            .ToListAsync(ct);
 439
 440    /// <summary>Снимает строки с активной корзины, сохраняя их в истории.</summary>
 441    public async Task<int> RemoveAsync(IReadOnlyCollection<long> cartItemIds, CancellationToken ct = default)
 442    {
 443        if (cartItemIds.Count == 0) return 0;
 444
 445        var rows = await _context.BergCartItems.Where(x => cartItemIds.Contains(x.Id)).ToListAsync(ct);
 446        foreach (var row in rows)
 447        {
 448            row.Status = BergCartItemStatus.Removed;
 449            row.ActiveStudioOrderId = null;
 450            row.UpdatedAtUtc = DateTime.UtcNow;
 451        }
 452
 453        await _context.SaveChangesAsync(ct);
 454
 455        foreach (var row in rows)
 456        {
 457            try { await _supplierStateService.RemoveFromCartAsync(row.StudioOrderId, ProviderKey); }
 458            catch { /* журнал стадии не должен ломать удаление */ }
 459        }
 460
 461        return rows.Count;
 462    }
 463}