< Summary

Information
Class: OzonOrdersWeb.TransactionServices.ZZapCartResult
Assembly: OzonOrdersWeb
File(s): /home/runner/work/Studio2Prod/Studio2Prod/OzonOrdersWeb/Services/Transactions/ZZapCartService.cs
Line coverage
100%
Covered lines: 5
Uncovered lines: 0
Coverable lines: 5
Total lines: 695
Line coverage: 100%
Branch coverage
100%
Covered branches: 2
Total branches: 2
Branch coverage: 100%
Method coverage

Feature is only available for sponsors

Upgrade to PRO version

Metrics

MethodBranch coverage Crap Score Cyclomatic complexity Line coverage
get_Added()100%11100%
get_IsSuccess()100%11100%
ToHtmlBlock()100%22100%

File(s)

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

#LineLine coverage
 1using System.Globalization;
 2using System.Net;
 3using System.Text.Json;
 4using Microsoft.EntityFrameworkCore;
 5using Microsoft.Extensions.Logging;
 6using Microsoft.Extensions.Logging.Abstractions;
 7using Newtonsoft.Json.Linq;
 8using OzonDomains.Models;
 9using OzonRepositories.Context;
 10using Services.ApiServices.ZZapApi;
 11using Services.DataServices;
 12using Services.SupplierStatusServices;
 13using OzonDomains.Models.SupplierStatuses;
 14
 15namespace OzonOrdersWeb.TransactionServices;
 16
 17/// <summary>Снимок предложения ZZap из проценки, который браузер отдаёт при проведении транзакции.</summary>
 18public sealed record ZZapCartQueueItem(int OrderId, string CodeDocB, string Article, string Brand, string? ProductName,
 19    string? SellerName, string? SellerUserKey, string? SellerLocation, int Quantity, int MinimumQuantity,
 20    decimal? SourcePrice, int DeliveryDays, int? CodeRegion, string? Direction = null,
 21    int? DeliveryDaysMin = null, int? DeliveryDaysMax = null, string? ShipmentNumber = null, string? ClientName = null,
 22    decimal? MinSumOrder = null, int? QtyMax = null);
 23
 18124public sealed record ZZapCartResult(int Added, int Skipped, int Failed, IReadOnlyList<string> Messages)
 25{
 426    public bool IsSuccess => Failed == 0;
 27
 28    public string ToHtmlBlock()
 29    {
 230        var css = Failed > 0 ? "alert-warning" : "alert-success";
 531        var list = string.Join(string.Empty, Messages.Select(x => $"<li>{WebUtility.HtmlEncode(x)}</li>"));
 232        return $"<div class='alert {css} mb-0'><strong>ZZap Москва: добавлено {Added}, уже было {Skipped}, ошибок {Faile
 33    }
 34}
 35
 36/// <summary>Живое состояние позиции в корзине/заказах ZZap для вкладки «Корзина поставщиков».</summary>
 37public sealed record ZZapApiCartLine(long CodeOrder, string? Article, string? Brand, string? ProductName,
 38    string? SellerName, string? Location, decimal? Price, int? Quantity, int? DeliveryDays, string? DeliveryText, DateTi
 39
 40public sealed record ZZapApiOrderLine(long CodeOrder, DateTime? CreatedAt, string? Article, string? Brand,
 41    string? ProductName, string? SellerName, string? SellerPhone, decimal? Price, string? QuantityOrdered,
 42    int? StatusCode, string? StatusName, string? StatusMessage, DateTime? StatusChangedAt);
 43
 44/// <summary>Корзина ZZap. Рабочая корзина живёт у ZZap: POST /cart сразу создаёт позицию и отдаёт code_order,
 45/// Studio2 держит зеркало в ZZapCartItems, чтобы связать code_order с заказом Studio2 и отслеживать статус.
 46/// Dry-run нет для добавления, но есть для оформления: POST /cart/order?test=true проверяет запрос без записи.</summary
 47public sealed class ZZapCartService
 48{
 49    public const string ProviderKey = ApiSupplierSettings.ZZapMoscowProviderKey;
 50    public const string SupplierCartUrl = "/SupplierCart?provider=zzapmoscow&supplierTab=basket";
 51    public const string DisplayName = "ZZap Москва";
 52    public const string LogoUrl = "/images/zzap-logo.svg";
 53
 54    /// <summary>Окно, в течение которого успешная проверка разрешает оформление — как у Armtek и ML-Auto.</summary>
 55    private static readonly TimeSpan ReadyWindow = TimeSpan.FromMinutes(5);
 56
 57    private readonly ApplicationDbContext _db;
 58    private readonly ZZapDataManager _api;
 59    private readonly IOrderSupplierStateService _supplierState;
 60    private readonly IOrderHistoryDataService _orderHistory;
 61    private readonly ILogger<ZZapCartService> _logger;
 62
 63    public ZZapCartService(ApplicationDbContext db, ZZapDataManager api, IOrderSupplierStateService supplierState,
 64        IOrderHistoryDataService orderHistory, ILogger<ZZapCartService>? logger = null) =>
 65        (_db, _api, _supplierState, _orderHistory, _logger) =
 66        (db, api, supplierState, orderHistory, logger ?? NullLogger<ZZapCartService>.Instance);
 67
 68    public Task<int> CountActiveAsync(CancellationToken ct = default) =>
 69        _db.ZZapCartItems.CountAsync(x => x.ProviderKey == ProviderKey && ZZapCartItemStatus.Active.Contains(x.Status), 
 70
 71    public Task<int> CountOrderedAsync(CancellationToken ct = default) =>
 72        _db.ZZapCartItems.CountAsync(x => x.ProviderKey == ProviderKey && x.Status == ZZapCartItemStatus.Ordered, ct);
 73
 74    public Task<List<ZZapCartItem>> GetActiveAsync(CancellationToken ct = default) => _db.ZZapCartItems.AsNoTracking()
 75        .Include(x => x.StudioOrder).ThenInclude(x => x!.ProductInfo)
 76        .Include(x => x.StudioOrder).ThenInclude(x => x!.Manufacturer)
 77        .Include(x => x.StudioOrder).ThenInclude(x => x!.OzonClient)
 78        .Where(x => x.ProviderKey == ProviderKey && ZZapCartItemStatus.Active.Contains(x.Status))
 79        .OrderByDescending(x => x.CreatedAtUtc).ToListAsync(ct);
 80
 81    public Task<List<ZZapCartItem>> GetOrderedAsync(int take = 200, CancellationToken ct = default) => _db.ZZapCartItems
 82        .Include(x => x.StudioOrder).ThenInclude(x => x!.OzonClient)
 83        .Where(x => x.ProviderKey == ProviderKey && x.Status == ZZapCartItemStatus.Ordered)
 84        .OrderByDescending(x => x.OrderedAtUtc).Take(take).ToListAsync(ct);
 85
 86    /// <summary>Позиции корзины ZZap как их видит API. Ключ — code_order, по нему сверяется зеркало Studio2.</summary>
 87    public async Task<IReadOnlyDictionary<long, ZZapApiCartLine>> GetApiCartAsync(CancellationToken ct = default)
 88    {
 89        var result = new Dictionary<long, ZZapApiCartLine>();
 90        for (var page = 1; page <= 10; page++)
 91        {
 92            var response = await _api.GetCartAsync(page, 100, ct);
 93            var data = response["result"]?["data"] as JArray;
 94            if (data is null || data.Count == 0) break;
 95            foreach (var line in data.OfType<JObject>())
 96            {
 97                var code = line.Value<long?>("code_order") ?? 0;
 98                if (code <= 0) continue;
 99                result[code] = new ZZapApiCartLine(code, line.Value<string>("partnumber"), line.Value<string>("class_man
 100                    line.Value<string>("class_cat"), line.Value<string>("seller_name"), line.Value<string>("location"),
 101                    line.Value<decimal?>("price"), line.Value<int?>("qty_order"), line.Value<int?>("delivery_days"),
 102                    line.Value<string>("descr_delivery"), line.Value<DateTime?>("create_date"));
 103            }
 104            var info = response["result_info"] as JObject;
 105            if ((info?.Value<int?>("count") ?? 0) < 100) break;
 106        }
 107        return result;
 108    }
 109
 110    /// <summary>Заказы покупателя из GET /orders с текущим статусом продавца.</summary>
 111    public async Task<IReadOnlyList<ZZapApiOrderLine>> GetApiOrdersAsync(int pages = 2, CancellationToken ct = default)
 112    {
 113        var result = new List<ZZapApiOrderLine>();
 114        for (var page = 1; page <= pages; page++)
 115        {
 116            var response = await _api.GetOrdersAsync(page, 100, ct);
 117            var data = response["result"]?["data"] as JArray;
 118            if (data is null || data.Count == 0) break;
 119            result.AddRange(data.OfType<JObject>().Select(ParseOrderLine).Where(x => x.CodeOrder > 0));
 120            if (data.Count < 100) break;
 121        }
 122        return result;
 123    }
 124
 125    public static ZZapApiOrderLine ParseOrderLine(JObject order) => new(
 126        order.Value<long?>("code_order") ?? 0, order.Value<DateTime?>("create_date"), order.Value<string>("partnumber"),
 127        order.Value<string>("class_man"), order.Value<string>("class_cat"), order.Value<string>("seller_name"),
 128        order.Value<string>("seller_phone"), order.Value<decimal?>("price_v2"), order.Value<string>("qty_order"),
 129        order.Value<int?>("code_track"), order.Value<string>("class_track"),
 130        order.Value<string>("message_track") ?? order.Value<string>("message"), order.Value<DateTime?>("create_track"));
 131
 132    /// <summary>Кладёт предложения в корзину ZZap и пишет зеркало. Позиция уже создана у поставщика к моменту
 133    /// сохранения строки, поэтому при сбое записи она снимается обратно — иначе осталась бы без следа в Studio2.</summa
 134    public async Task<ZZapCartResult> QueueAsync(IReadOnlyCollection<ZZapCartQueueItem> queue, string user, Cancellation
 135    {
 136        var added = 0; var skipped = 0; var failed = 0; var messages = new List<string>();
 137        foreach (var item in queue)
 138        {
 139            if (item.OrderId <= 0 || string.IsNullOrWhiteSpace(item.CodeDocB) || item.Quantity <= 0)
 140            {
 141                failed++; messages.Add($"{Describe(item)} — неполные данные предложения ZZap.");
 142                if (item.OrderId > 0)
 143                    await RecordHistoryAsync(item.OrderId, item, false, "Неполные данные предложения ZZap.", ApiSupplier
 144                continue;
 145            }
 146            var pack = Math.Max(1, item.MinimumQuantity);
 147            if (item.Quantity % pack != 0)
 148            {
 149                failed++; messages.Add($"{Describe(item)} — продавец отпускает кратно {pack} шт., в заказе {item.Quantit
 150                continue;
 151            }
 152            if (item.QtyMax is > 0 && item.Quantity > item.QtyMax)
 153            {
 154                failed++; messages.Add($"{Describe(item)} — продавец отдаёт не больше {item.QtyMax} шт., в заказе {item.
 155                continue;
 156            }
 157            // Минимальная сумма — на весь заказ у продавца: считаем очередь и уже лежащие в корзине строки того же прод
 158            if (item.MinSumOrder is > 0 && item.SourcePrice is > 0)
 159            {
 160                var sellerTotal = queue.Where(x => x.SellerUserKey == item.SellerUserKey && x.SourcePrice is > 0).Sum(x 
 161                    + await _db.ZZapCartItems.Where(x => x.ProviderKey == ProviderKey && x.ActiveStudioOrderId != null &
 162                        .SumAsync(x => (x.SourcePrice ?? 0m) * x.Quantity, ct);
 163                if (sellerTotal < item.MinSumOrder)
 164                {
 165                    failed++; messages.Add($"{Describe(item)} — минимальная сумма заказа у продавца {item.SellerName} {i
 166                    continue;
 167                }
 168            }
 169            if (await _db.ZZapCartItems.AnyAsync(x => x.ProviderKey == ProviderKey && x.ActiveStudioOrderId == item.Orde
 170            {
 171                skipped++; messages.Add($"{Describe(item)} — уже в корзине ZZap."); continue;
 172            }
 173
 174            long codeOrder;
 175            try
 176            {
 177                var response = await _api.AddToCartAsync(item.CodeDocB, item.Quantity,
 178                    $"Studio2 {item.ShipmentNumber} (ID {item.OrderId})".Trim(), item.CodeRegion, ct: ct);
 179                codeOrder = response["result"]?.Value<long?>("code_order") ?? 0;
 180                if (codeOrder <= 0)
 181                {
 182                    failed++; messages.Add($"{Describe(item)} — ZZap не вернул код позиции корзины.");
 183                    await RecordHistoryAsync(item.OrderId, item, false, "ZZap не вернул code_order.", ApiSupplierBasketC
 184                    continue;
 185                }
 186            }
 187            catch (Exception ex) when (ex is ZZapApiException or HttpRequestException)
 188            {
 189                failed++; messages.Add($"{Describe(item)} — {ex.Message}");
 190                await RecordHistoryAsync(item.OrderId, item, false, ex.Message, ApiSupplierBasketCartStatus.Failed, null
 191                continue;
 192            }
 193
 194            var row = new ZZapCartItem
 195            {
 196                ProviderKey = ProviderKey, StudioOrderId = item.OrderId, ActiveStudioOrderId = item.OrderId,
 197                CodeDocB = item.CodeDocB, CodeOrder = codeOrder, CodeRegion = item.CodeRegion ?? _api.DefaultCodeRegion,
 198                Article = item.Article, Brand = item.Brand, ProductName = item.ProductName,
 199                SellerName = item.SellerName, SellerUserKey = item.SellerUserKey, SellerLocation = item.SellerLocation,
 200                Quantity = item.Quantity, MinimumQuantity = pack, SourcePrice = item.SourcePrice,
 201                DeliveryDays = item.DeliveryDays, MinSumOrder = item.MinSumOrder, QtyMax = item.QtyMax, CreatedBy = user
 202            };
 203            _db.ZZapCartItems.Add(row);
 204            try
 205            {
 206                await _db.SaveChangesAsync(ct);
 207            }
 208            catch (Exception saveEx)
 209            {
 210                _db.Entry(row).State = EntityState.Detached;
 211                try
 212                {
 213                    await _api.RemoveFromCartAsync([codeOrder], ct: CancellationToken.None);
 214                    messages.Add($"{Describe(item)} — позиция снята из корзины ZZap (code_order {codeOrder}).");
 215                }
 216                catch (Exception rollbackEx)
 217                {
 218                    messages.Add($"{Describe(item)} — ВНИМАНИЕ: позиция осталась в корзине ZZap (code_order {codeOrder})
 219                }
 220                failed++; messages.Add($"{Describe(item)} — не сохранено в Studio2. {saveEx.Message}");
 221                continue;
 222            }
 223
 224            added++;
 225            messages.Add($"{Describe(item)} — добавлено в корзину ZZap (code_order {codeOrder}); перед оформлением выпол
 226            await RecordHistoryAsync(item.OrderId, item, true, null, ApiSupplierBasketCartStatus.Pending, null, user, ct
 227            await TrackAsync(() => _supplierState.MarkInCartAsync(item.OrderId, ProviderKey, ZZapCartItemStatus.Pending,
 228                codeOrder.ToString(CultureInfo.InvariantCulture), user,
 229                item.DeliveryDaysMin ?? item.DeliveryDays, item.DeliveryDaysMax ?? item.DeliveryDays,
 230                item.Direction ?? BuildDirection(item.SellerName, item.SellerLocation), ct));
 231        }
 232        return new ZZapCartResult(added, skipped, failed, messages);
 233    }
 234
 235    /// <summary>Проверка перед оформлением: сверка с живой корзиной ZZap и POST /cart/order?test=true.
 236    /// Успех даёт Ready на 5 минут.</summary>
 237    public async Task<ZZapCartResult> TestAsync(IReadOnlyCollection<long>? codesOrder, string? user, CancellationToken c
 238    {
 239        var rows = await GetTrackedActiveAsync(codesOrder, ct);
 240        if (rows.Count == 0) return new(0, 0, 0, [codesOrder is { Count: > 0 }
 241            ? "Выбранные позиции не привязаны к заказам Studio2: проверять и оформлять можно только строки, добавленные 
 242            : "В корзине нет позиций, привязанных к заказам Studio2."]);
 243
 244        var messages = new List<string>(); var ok = 0; var failed = 0;
 245        IReadOnlyDictionary<long, ZZapApiCartLine> apiCart;
 246        ZZapBuyerContact buyer;
 247        try
 248        {
 249            apiCart = await GetApiCartAsync(ct);
 250            buyer = await _api.GetBuyerContactAsync(ct);
 251        }
 252        catch (Exception ex) when (ex is ZZapApiException or HttpRequestException)
 253        {
 254            return new(0, 0, rows.Count, [$"ZZap: {ex.Message}"]);
 255        }
 256
 257        foreach (var group in rows.GroupBy(x => x.CodeRegion))
 258        {
 259            var present = group.Where(x => apiCart.ContainsKey(x.CodeOrder)).ToList();
 260            foreach (var row in group.Except(present))
 261            {
 262                row.Status = ZZapCartItemStatus.Failed;
 263                row.LastError = "Позиции нет в корзине ZZap: удалена на сайте или устарела. Удалите строку и проценьте з
 264                row.UpdatedAtUtc = DateTime.UtcNow;
 265                failed++; messages.Add($"{row.Article}: {row.LastError}");
 266            }
 267            if (present.Count == 0) continue;
 268
 269            // Кратность и максимум продавца — по количеству, которое реально лежит в корзине ZZap.
 270            foreach (var row in present.ToList())
 271            {
 272                var liveQty = apiCart[row.CodeOrder].Quantity ?? row.Quantity;
 273                var pack = Math.Max(1, row.MinimumQuantity);
 274                string? problem = null;
 275                if (liveQty % pack != 0) problem = $"Продавец отпускает кратно {pack} шт., в корзине {liveQty} шт.";
 276                else if (row.QtyMax is > 0 && liveQty > row.QtyMax) problem = $"Продавец отдаёт не больше {row.QtyMax} ш
 277                if (problem is null) continue;
 278                row.Status = ZZapCartItemStatus.Failed; row.LastError = problem; row.UpdatedAtUtc = DateTime.UtcNow;
 279                failed++; messages.Add($"{row.Article}: {problem}");
 280                present.Remove(row);
 281            }
 282            if (present.Count == 0) continue;
 283
 284            // Минимальная сумма продавца — по всем его позициям в живой корзине, по актуальным ценам API.
 285            foreach (var sellerRows in present.Where(x => x.MinSumOrder is > 0).GroupBy(x => x.SellerUserKey ?? x.Seller
 286            {
 287                var sellerCodes = rows.Where(x => (x.SellerUserKey ?? x.SellerName ?? string.Empty) == sellerRows.Key).S
 288                var sellerTotal = apiCart.Values.Where(x => sellerCodes.Contains(x.CodeOrder)).Sum(x => (x.Price ?? 0m) 
 289                var minSum = sellerRows.Max(x => x.MinSumOrder!.Value);
 290                if (sellerTotal >= minSum) continue;
 291                foreach (var row in sellerRows)
 292                {
 293                    row.Status = ZZapCartItemStatus.Failed;
 294                    row.LastError = $"Минимальная сумма заказа у продавца {minSum:0.##} ₽, в корзине у него {sellerTotal
 295                    row.UpdatedAtUtc = DateTime.UtcNow;
 296                    failed++; messages.Add($"{row.Article}: {row.LastError}");
 297                    present.Remove(row);
 298                }
 299            }
 300            if (present.Count == 0) continue;
 301
 302            try
 303            {
 304                await _api.CreateOrderFromCartAsync(present.Select(x => x.CodeOrder).ToList(), buyer, group.Key, null, t
 305                foreach (var row in present)
 306                {
 307                    var live = apiCart[row.CodeOrder];
 308                    if (live.Price.HasValue) row.SourcePrice = live.Price;
 309                    if (live.DeliveryDays.HasValue) row.DeliveryDays = live.DeliveryDays.Value;
 310                    row.Status = ZZapCartItemStatus.Ready; row.LastError = null;
 311                    row.TestedAtUtc = DateTime.UtcNow; row.UpdatedAtUtc = DateTime.UtcNow;
 312                    ok++; messages.Add($"{row.Article}: подтверждено ZZap (продавец {live.SellerName ?? row.SellerName},
 313                }
 314            }
 315            catch (Exception ex) when (ex is ZZapApiException or HttpRequestException)
 316            {
 317                foreach (var row in present)
 318                {
 319                    row.Status = ZZapCartItemStatus.Failed; row.LastError = ex.Message; row.UpdatedAtUtc = DateTime.UtcN
 320                    failed++; messages.Add($"{row.Article}: {ex.Message}");
 321                }
 322            }
 323        }
 324
 325        await _db.SaveChangesAsync(ct);
 326        foreach (var row in rows) await TrackCartStatusAsync(row, user, ct);
 327        return new(ok, 0, failed, messages);
 328    }
 329
 330    /// <summary>Реальное оформление. Требует свежей проверки; строки атомарно захватываются Ready → Processing,
 331    /// чтобы параллельный запрос не оформил их дважды.</summary>
 332    public async Task<ZZapCartResult> CheckoutAsync(IReadOnlyCollection<long>? codesOrder, string user, CancellationToke
 333    {
 334        var rows = await GetTrackedActiveAsync(codesOrder, ct);
 335        if (codesOrder is { Count: > 0 } && rows.Count != codesOrder.Distinct().Count())
 336            return new(0, 0, codesOrder.Count, ["Часть выбранных позиций не привязана к заказам Studio2 или состав корзи
 337        if (rows.Count == 0) return new(0, 0, 0, ["В корзине нет позиций, привязанных к заказам Studio2."]);
 338        if (rows.Any(x => x.Status != ZZapCartItemStatus.Ready || x.TestedAtUtc < DateTime.UtcNow - ReadyWindow))
 339            return new(0, 0, rows.Count, ["Перед оформлением выполните свежую проверку ZZap."]);
 340
 341        var ids = rows.Select(x => x.Id).ToArray();
 342        var locked = await _db.ZZapCartItems.Where(x => ids.Contains(x.Id) && x.Status == ZZapCartItemStatus.Ready)
 343            .ExecuteUpdateAsync(s => s.SetProperty(x => x.Status, ZZapCartItemStatus.Processing).SetProperty(x => x.Upda
 344        if (locked != rows.Count) return new(0, 0, rows.Count, ["Корзина уже оформляется в другом запросе."]);
 345        foreach (var row in rows) row.Status = ZZapCartItemStatus.Processing;
 346
 347        return await PlaceOrdersAsync(rows, user, ct);
 348    }
 349
 350    /// <summary>«Оформить с проведением»: заказ создаётся сразу через POST /orders, минуя корзину ZZap.
 351    /// Сначала все позиции проходят dry-run (test=true), реальные вызовы идут только если все проверки прошли;
 352    /// любой отказ возвращает IsSuccess=false — проведение документа при этом не начинается.</summary>
 353    public async Task<ZZapCartResult> CheckoutDirectAsync(IReadOnlyCollection<ZZapCartQueueItem> queue, string user, Can
 354    {
 355        var messages = new List<string>();
 356        var items = new List<ZZapCartQueueItem>();
 357        foreach (var item in queue)
 358        {
 359            var problem = ValidateQueueItem(item, queue);
 360            if (problem is not null) { messages.Add($"{Describe(item)} — {problem}"); continue; }
 361            if (await _db.ZZapCartItems.AnyAsync(x => x.ProviderKey == ProviderKey && x.ActiveStudioOrderId == item.Orde
 362            {
 363                messages.Add($"{Describe(item)} — уже в корзине ZZap: оформите её на странице корзины или удалите оттуда
 364                continue;
 365            }
 366            items.Add(item);
 367        }
 368        if (messages.Count > 0) return new(0, 0, messages.Count, messages);
 369        if (items.Count == 0) return new(0, 0, 0, ["Нет позиций ZZap для оформления."]);
 370
 371        ZZapBuyerContact buyer;
 372        try
 373        {
 374            buyer = await _api.GetBuyerContactAsync(ct);
 375            foreach (var item in items)
 376                await _api.CreateOrderDirectAsync(item.CodeDocB, item.Quantity, buyer, item.CodeRegion, OrderComment(ite
 377        }
 378        catch (Exception ex) when (ex is ZZapApiException or HttpRequestException)
 379        {
 380            return new(0, 0, items.Count, [$"ZZap отклонил проверку заказа: {ex.Message}"]);
 381        }
 382
 383        var success = 0; var failed = 0;
 384        foreach (var item in items)
 385        {
 386            long codeOrder;
 387            try
 388            {
 389                var response = await _api.CreateOrderDirectAsync(item.CodeDocB, item.Quantity, buyer, item.CodeRegion, O
 390                codeOrder = response["result"]?.Value<long?>("code_order") ?? 0;
 391                if (codeOrder <= 0) throw new ZZapApiException("ZZap не вернул номер созданного заказа.", requestPath: "
 392            }
 393            catch (Exception ex) when (ex is ZZapApiException or HttpRequestException)
 394            {
 395                failed++; messages.Add($"{Describe(item)} — {ex.Message}");
 396                await RecordHistoryAsync(item.OrderId, item, false, ex.Message, ApiSupplierBasketCartStatus.Failed, null
 397                continue;
 398            }
 399
 400            var row = new ZZapCartItem
 401            {
 402                ProviderKey = ProviderKey, StudioOrderId = item.OrderId, ActiveStudioOrderId = null,
 403                CodeDocB = item.CodeDocB, CodeOrder = codeOrder, CodeRegion = item.CodeRegion ?? _api.DefaultCodeRegion,
 404                Article = item.Article, Brand = item.Brand, ProductName = item.ProductName,
 405                SellerName = item.SellerName, SellerUserKey = item.SellerUserKey, SellerLocation = item.SellerLocation,
 406                Quantity = item.Quantity, MinimumQuantity = Math.Max(1, item.MinimumQuantity), SourcePrice = item.Source
 407                DeliveryDays = item.DeliveryDays, MinSumOrder = item.MinSumOrder, QtyMax = item.QtyMax,
 408                Status = ZZapCartItemStatus.Ordered, OrderedAtUtc = DateTime.UtcNow, TestedAtUtc = DateTime.UtcNow, Crea
 409            };
 410            _db.ZZapCartItems.Add(row);
 411            try { await _db.SaveChangesAsync(ct); }
 412            catch (Exception saveEx)
 413            {
 414                // Заказ у продавца уже создан — его нельзя «снять» как позицию корзины; фиксируем номер в сообщении и и
 415                _db.Entry(row).State = EntityState.Detached;
 416                failed++; messages.Add($"{Describe(item)} — ВНИМАНИЕ: заказ ZZap {codeOrder} создан, но не сохранён в St
 417                await RecordHistoryAsync(item.OrderId, item, false, saveEx.Message, ApiSupplierBasketCartStatus.Ordered,
 418                continue;
 419            }
 420
 421            success++;
 422            messages.Add($"{Describe(item)} — заказ ZZap {codeOrder} оформлен у продавца {item.SellerName} ({item.Quanti
 423            var code = codeOrder.ToString(CultureInfo.InvariantCulture);
 424            await RecordHistoryAsync(item.OrderId, item, true, null, ApiSupplierBasketCartStatus.Ordered, code, user, ct
 425            await TrackAsync(() => _supplierState.MarkOrderedAsync(item.OrderId, ProviderKey, code, code, null, user,
 426                item.DeliveryDaysMin ?? item.DeliveryDays, item.DeliveryDaysMax ?? item.DeliveryDays,
 427                item.Direction ?? BuildDirection(item.SellerName, item.SellerLocation), ct));
 428        }
 429        return new(success, 0, failed, messages);
 430    }
 431
 432    private static string OrderComment(ZZapCartQueueItem item) => $"Studio2 {item.ShipmentNumber} (ID {item.OrderId})".T
 433
 434    /// <summary>Ограничения продавца по очереди: кратность, максимум и минимальная сумма по всем позициям продавца в оч
 435    private static string? ValidateQueueItem(ZZapCartQueueItem item, IReadOnlyCollection<ZZapCartQueueItem> queue)
 436    {
 437        if (item.OrderId <= 0 || string.IsNullOrWhiteSpace(item.CodeDocB) || item.Quantity <= 0) return "неполные данные
 438        var pack = Math.Max(1, item.MinimumQuantity);
 439        if (item.Quantity % pack != 0) return $"продавец отпускает кратно {pack} шт., в заказе {item.Quantity} шт.";
 440        if (item.QtyMax is > 0 && item.Quantity > item.QtyMax) return $"продавец отдаёт не больше {item.QtyMax} шт., в з
 441        if (item.MinSumOrder is > 0 && item.SourcePrice is > 0)
 442        {
 443            var sellerTotal = queue.Where(x => x.SellerUserKey == item.SellerUserKey && x.SourcePrice is > 0).Sum(x => x
 444            if (sellerTotal < item.MinSumOrder) return $"минимальная сумма заказа у продавца {item.SellerName} {item.Min
 445        }
 446        return null;
 447    }
 448
 449    private async Task<ZZapCartResult> PlaceOrdersAsync(List<ZZapCartItem> rows, string user, CancellationToken ct)
 450    {
 451        var success = 0; var failed = 0; var messages = new List<string>();
 452        ZZapBuyerContact buyer;
 453        try { buyer = await _api.GetBuyerContactAsync(ct); }
 454        catch (Exception ex) when (ex is ZZapApiException or HttpRequestException)
 455        {
 456            await MarkFailedAsync(rows, ex.Message, ct);
 457            return new(0, 0, rows.Count, [$"ZZap: {ex.Message}"]);
 458        }
 459
 460        // Регион — параметр оформления, поэтому корзина делится по нему; внутри региона один вызов на все позиции.
 461        foreach (var group in rows.GroupBy(x => x.CodeRegion))
 462        {
 463            var groupRows = group.ToList();
 464            try
 465            {
 466                var response = await _api.CreateOrderFromCartAsync(groupRows.Select(x => x.CodeOrder).ToList(), buyer, g
 467                    "Заказ Studio2", test: false, ct);
 468                var confirmed = ParseCodes(response["result"]?.Value<string>("codes_order"));
 469                foreach (var row in groupRows)
 470                {
 471                    // ZZap возвращает коды оформленных заказов; пустой список считаем подтверждением всей группы —
 472                    // API отвечает 400 на любой отказ, а не частичным результатом.
 473                    if (confirmed.Count > 0 && !confirmed.Contains(row.CodeOrder))
 474                    {
 475                        row.Status = ZZapCartItemStatus.Failed; row.LastError = "ZZap не подтвердил оформление этой пози
 476                        row.UpdatedAtUtc = DateTime.UtcNow; failed++; messages.Add($"{row.Article}: {row.LastError}");
 477                        continue;
 478                    }
 479                    row.Status = ZZapCartItemStatus.Ordered; row.ActiveStudioOrderId = null; row.LastError = null;
 480                    row.OrderedAtUtc = DateTime.UtcNow; row.UpdatedAtUtc = DateTime.UtcNow;
 481                    success++; messages.Add($"{row.Article}: заказ ZZap {row.CodeOrder} оформлен у продавца {row.SellerN
 482                }
 483            }
 484            catch (Exception ex) when (ex is ZZapApiException or HttpRequestException)
 485            {
 486                foreach (var row in groupRows)
 487                {
 488                    row.Status = ZZapCartItemStatus.Failed; row.LastError = ex.Message; row.UpdatedAtUtc = DateTime.UtcN
 489                    failed++; messages.Add($"{row.Article}: {ex.Message}");
 490                }
 491            }
 492        }
 493
 494        await _db.SaveChangesAsync(ct);
 495        foreach (var row in rows)
 496        {
 497            if (row.Status == ZZapCartItemStatus.Ordered)
 498            {
 499                var code = row.CodeOrder.ToString(CultureInfo.InvariantCulture);
 500                await RecordHistoryAsync(row.StudioOrderId, ToQueueItem(row), true, null, ApiSupplierBasketCartStatus.Or
 501                await TrackAsync(() => _supplierState.MarkOrderedAsync(row.StudioOrderId, ProviderKey, code, code, null,
 502            }
 503            else
 504            {
 505                await TrackCartStatusAsync(row, user, ct);
 506            }
 507        }
 508        return new(success, 0, failed, messages);
 509    }
 510
 511    /// <summary>Удаляет позиции из корзины ZZap по code_order — в том числе добавленные на сайте, без зеркала.
 512    /// all — вся живая корзина аккаунта плюс зеркальные строки, которых в ней уже нет.</summary>
 513    /// <summary>Отмена оформленного заказа покупателем: dry-run, затем реальный POST /orders/{code}/status.
 514    /// Отмена — просьба продавцу, а не откат: статус «отменён покупателем» конечный и закрывает отслеживание,
 515    /// ответ продавца (если будет) придёт как отдельный заказ-ответ на сайте ZZap.</summary>
 516    public async Task<ZZapCartResult> CancelOrderAsync(long codeOrder, string? reason, string user, CancellationToken ct
 517    {
 518        var row = await _db.ZZapCartItems.Include(x => x.StudioOrder)
 519            .FirstOrDefaultAsync(x => x.ProviderKey == ProviderKey && x.CodeOrder == codeOrder && x.Status == ZZapCartIt
 520        if (row is null) return new(0, 0, 1, [$"Заказ ZZap {codeOrder} не оформлялся через Studio2 — отмените его на сай
 521        if (row.ExternalStatusCode is int current && ZZapSupplierStatusProviderFinalCodes.Contains(current))
 522            return new(0, 0, 1, [$"Заказ ZZap {codeOrder} уже в конечном статусе «{row.ExternalStatusName}»."]);
 523
 524        var comment = string.IsNullOrWhiteSpace(reason) ? $"Отмена заказа Studio2 {row.StudioOrder?.ShipmentNumber}".Tri
 525        try
 526        {
 527            await _api.SetOrderStatusAsync(codeOrder, ZZapDataManager.CancelledByBuyerTrackCode, comment, test: true, ct
 528            await _api.SetOrderStatusAsync(codeOrder, ZZapDataManager.CancelledByBuyerTrackCode, comment, test: false, c
 529        }
 530        catch (Exception ex) when (ex is ZZapApiException or HttpRequestException)
 531        {
 532            return new(0, 0, 1, [$"ZZap: {ex.Message}"]);
 533        }
 534
 535        row.ExternalStatusCode = ZZapDataManager.CancelledByBuyerTrackCode;
 536        row.ExternalStatusName = "Заказ отменен покупателем";
 537        row.UpdatedAtUtc = DateTime.UtcNow;
 538        await _db.SaveChangesAsync(ct);
 539
 540        await RecordHistoryAsync(row.StudioOrderId, ToQueueItem(row), true, null, "Cancelled", codeOrder.ToString(Cultur
 541        await TrackAsync(() => _supplierState.ApplySnapshotsAsync(ProviderKey, [new SupplierStatusSnapshot
 542        {
 543            OrderId = row.StudioOrderId,
 544            Kind = SupplierStatusKind.Order,
 545            ExternalCode = ZZapDataManager.CancelledByBuyerTrackCode.ToString(CultureInfo.InvariantCulture),
 546            StatusName = row.ExternalStatusName,
 547            ExternalOrderNumber = codeOrder.ToString(CultureInfo.InvariantCulture),
 548            ExternalOrderId = codeOrder.ToString(CultureInfo.InvariantCulture),
 549            StatusChangedAt = DateTime.UtcNow
 550        }], ct));
 551        return new(1, 0, 0, [$"{row.Article}: заказ ZZap {codeOrder} отменён, продавцу отправлен комментарий «{comment}»
 552    }
 553
 554    /// <summary>Конечные code_track ZZap — дублируют список провайдера статусов, чтобы не тянуть его сюда.</summary>
 555    private static readonly HashSet<int> ZZapSupplierStatusProviderFinalCodes = [11, 153, 10, 142, 169];
 556
 557    public async Task<ZZapCartResult> RemoveAsync(IReadOnlyCollection<long>? codesOrder, bool all, string? user, Cancell
 558    {
 559        var codes = new HashSet<long>(codesOrder ?? []);
 560        if (all)
 561        {
 562            try { codes.UnionWith((await GetApiCartAsync(ct)).Keys); }
 563            catch (Exception ex) when (ex is ZZapApiException or HttpRequestException) { return new(0, 0, 1, [$"ZZap: {e
 564        }
 565        var rows = await _db.ZZapCartItems
 566            .Where(x => x.ProviderKey == ProviderKey && ZZapCartItemStatus.Active.Contains(x.Status) && (all || codes.Co
 567            .ToListAsync(ct);
 568        codes.UnionWith(rows.Select(x => x.CodeOrder));
 569        if (codes.Count == 0) return new(0, 0, 0, ["Нечего удалять."]);
 570
 571        var removed = 0; var failed = 0; var messages = new List<string>(); var closed = new List<ZZapCartItem>();
 572        var rowByCode = rows.ToDictionary(x => x.CodeOrder);
 573        var deleted = new HashSet<long>();
 574        foreach (var code in codes)
 575        {
 576            var label = rowByCode.TryGetValue(code, out var row) ? row.Article : $"code_order {code}";
 577            try
 578            {
 579                await _api.RemoveFromCartAsync([code], ct: ct);
 580                deleted.Add(code);
 581            }
 582            catch (ZZapApiException ex) when (ex.StatusCode is 400 or 404)
 583            {
 584                // Позиции уже нет у ZZap (удалена на сайте или устарела) — зеркало всё равно закрываем.
 585                deleted.Add(code); messages.Add($"{label}: у ZZap позиции уже нет ({ex.Message}); строка Studio2 закрыта
 586            }
 587            catch (Exception ex) when (ex is ZZapApiException or HttpRequestException)
 588            {
 589                failed++; messages.Add($"{label}: {ex.Message}");
 590            }
 591        }
 592
 593        // ZZap отвечает на DELETE успехом, но первый вызов по позиции может ничего не удалить (проверено живыми
 594        // вызовами: позиция остаётся в GET /cart, второй DELETE убирает её). Поэтому сверяемся с корзиной и добиваем.
 595        if (deleted.Count > 0)
 596        {
 597            try
 598            {
 599                var stillThere = (await GetApiCartAsync(ct)).Keys.Where(deleted.Contains).ToList();
 600                foreach (var code in stillThere)
 601                    await _api.RemoveFromCartAsync([code], ct: ct);
 602                if (stillThere.Count > 0)
 603                {
 604                    var remaining = (await GetApiCartAsync(ct)).Keys.Where(deleted.Contains).ToList();
 605                    foreach (var code in remaining)
 606                    {
 607                        deleted.Remove(code); failed++;
 608                        messages.Add($"{(rowByCode.TryGetValue(code, out var r) ? r.Article : $"code_order {code}")}: ZZ
 609                    }
 610                }
 611            }
 612            catch (Exception ex) when (ex is ZZapApiException or HttpRequestException)
 613            {
 614                messages.Add($"Не удалось перепроверить корзину ZZap после удаления: {ex.Message}");
 615            }
 616        }
 617
 618        foreach (var code in deleted)
 619        {
 620            removed++;
 621            if (!rowByCode.TryGetValue(code, out var row)) { messages.Add($"code_order {code}: удалено из корзины ZZap."
 622            messages.Add($"{row.Article}: удалено из корзины ZZap.");
 623            row.Status = ZZapCartItemStatus.Removed; row.ActiveStudioOrderId = null; row.UpdatedAtUtc = DateTime.UtcNow;
 624            closed.Add(row);
 625        }
 626        await _db.SaveChangesAsync(ct);
 627        foreach (var row in closed)
 628            await TrackAsync(() => _supplierState.RemoveFromCartAsync(row.StudioOrderId, ProviderKey, user, ct));
 629        return new(removed, 0, failed, messages);
 630    }
 631
 632    private async Task<List<ZZapCartItem>> GetTrackedActiveAsync(IReadOnlyCollection<long>? codesOrder, CancellationToke
 633    {
 634        var query = _db.ZZapCartItems.Where(x => x.ProviderKey == ProviderKey && ZZapCartItemStatus.Active.Contains(x.St
 635        if (codesOrder is { Count: > 0 }) query = query.Where(x => codesOrder.Contains(x.CodeOrder));
 636        return await query.OrderBy(x => x.Id).ToListAsync(ct);
 637    }
 638
 639    private async Task MarkFailedAsync(List<ZZapCartItem> rows, string error, CancellationToken ct)
 640    {
 641        foreach (var row in rows) { row.Status = ZZapCartItemStatus.Failed; row.LastError = error; row.UpdatedAtUtc = Da
 642        await _db.SaveChangesAsync(ct);
 643    }
 644
 645    private Task TrackCartStatusAsync(ZZapCartItem row, string? user, CancellationToken ct) =>
 646        TrackAsync(() => _supplierState.MarkInCartAsync(row.StudioOrderId, ProviderKey, row.Status, row.Id,
 647            row.CodeOrder.ToString(CultureInfo.InvariantCulture), user, ct: ct));
 648
 649    private async Task TrackAsync(Func<Task> action)
 650    {
 651        try { await action(); }
 652        catch (Exception exception) { _logger.LogWarning(exception, "Не удалось обновить состояние заказа у поставщика Z
 653    }
 654
 655    private async Task RecordHistoryAsync(int orderId, ZZapCartQueueItem item, bool success, string? error, string cartS
 656        string? supplierOrderNumber, string user, CancellationToken ct, long? codeOrder = null)
 657    {
 658        try
 659        {
 660            var entry = new ApiSupplierBasketHistoryEntry(ProviderKey, DisplayName, LogoUrl, success, error,
 661                codeOrder?.ToString(CultureInfo.InvariantCulture), item.Article, item.ProductName, item.SourcePrice, "RU
 662                item.Quantity, item.ShipmentNumber, ClientName: item.ClientName,
 663                WarehouseName: item.Direction ?? BuildDirection(item.SellerName, item.SellerLocation),
 664                CartStatus: cartStatus, SupplierOrderNumber: supplierOrderNumber, SupplierCartUrl: SupplierCartUrl);
 665            await _orderHistory.AddOrderHistory(new OrderHistory
 666            {
 667                OrderId = orderId, ColumnName = ApiSupplierBasketHistoryEntry.HistoryColumnName,
 668                ColumnDisplayName = $"Корзина {DisplayName}",
 669                NewValue = JsonSerializer.Serialize(entry, ApiSupplierBasketHistoryEntry.JsonOptions),
 670                ChangedAt = DateTime.Now, ChangedBy = user
 671            });
 672        }
 673        catch (Exception exception)
 674        {
 675            _logger.LogWarning(exception, "Не удалось записать историю корзины ZZap для заказа {OrderId}", orderId);
 676        }
 677    }
 678
 679    private static ZZapCartQueueItem ToQueueItem(ZZapCartItem row) => new(row.StudioOrderId, row.CodeDocB, row.Article, 
 680        row.ProductName, row.SellerName, row.SellerUserKey, row.SellerLocation, row.Quantity, row.MinimumQuantity, row.S
 681        row.DeliveryDays, row.CodeRegion, BuildDirection(row.SellerName, row.SellerLocation), row.DeliveryDays, row.Deli
 682        row.StudioOrder?.ShipmentNumber, row.StudioOrder?.OzonClient?.Name, row.MinSumOrder, row.QtyMax);
 683
 684    private static string? BuildDirection(string? seller, string? location) =>
 685        string.IsNullOrWhiteSpace(seller) ? location : string.IsNullOrWhiteSpace(location) ? seller : $"{seller} · {loca
 686
 687    private static string Describe(ZZapCartQueueItem item) =>
 688        string.IsNullOrWhiteSpace(item.ShipmentNumber) ? $"{item.Article} {item.Brand}" : $"{item.ShipmentNumber} · {ite
 689
 690    private static string FormatMoney(decimal? value) => value?.ToString("0.##", CultureInfo.InvariantCulture) ?? "—";
 691
 692    private static HashSet<long> ParseCodes(string? codes) => (codes ?? string.Empty)
 693        .Split([';', ','], StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries)
 694        .Select(x => long.TryParse(x, out var code) ? code : 0).Where(x => x > 0).ToHashSet();
 695}