< Summary

Information
Class: OzonOrdersWeb.TransactionServices.ZZapCartService
Assembly: OzonOrdersWeb
File(s): /home/runner/work/Studio2Prod/Studio2Prod/OzonOrdersWeb/Services/Transactions/ZZapCartService.cs
Line coverage
96%
Covered lines: 374
Uncovered lines: 15
Coverable lines: 389
Total lines: 695
Line coverage: 96.1%
Branch coverage
81%
Covered branches: 214
Total branches: 262
Branch coverage: 81.6%
Method coverage

Feature is only available for sponsors

Upgrade to PRO version

Metrics

MethodBranch coverage Crap Score Cyclomatic complexity Line coverage
.cctor()100%11100%
.ctor(...)50%22100%
CountActiveAsync(...)100%11100%
CountOrderedAsync(...)100%11100%
GetActiveAsync(...)100%11100%
GetOrderedAsync(...)100%11100%
GetApiCartAsync()87.5%1616100%
GetApiOrdersAsync()80%1010100%
ParseOrderLine(...)100%22100%
QueueAsync()94.44%363695.16%
TestAsync()90%5050100%
CheckoutAsync()92.85%1414100%
CheckoutDirectAsync()85%2020100%
OrderComment(...)100%11100%
ValidateQueueItem(...)53.57%402875%
PlaceOrdersAsync()93.75%1616100%
CancelOrderAsync()70%1010100%
RemoveAsync()88.46%292684.44%
GetTrackedActiveAsync()100%44100%
MarkFailedAsync()100%22100%
TrackCartStatusAsync(...)100%11100%
TrackAsync()100%11100%
RecordHistoryAsync()100%4482.35%
ToQueueItem(...)50%66100%
BuildDirection(...)100%44100%
Describe(...)50%22100%
FormatMoney(...)50%44100%
ParseCodes(...)50%44100%

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
 24public sealed record ZZapCartResult(int Added, int Skipped, int Failed, IReadOnlyList<string> Messages)
 25{
 26    public bool IsSuccess => Failed == 0;
 27
 28    public string ToHtmlBlock()
 29    {
 30        var css = Failed > 0 ? "alert-warning" : "alert-success";
 31        var list = string.Join(string.Empty, Messages.Select(x => $"<li>{WebUtility.HtmlEncode(x)}</li>"));
 32        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>
 155    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
 5263    public ZZapCartService(ApplicationDbContext db, ZZapDataManager api, IOrderSupplierStateService supplierState,
 5264        IOrderHistoryDataService orderHistory, ILogger<ZZapCartService>? logger = null) =>
 5265        (_db, _api, _supplierState, _orderHistory, _logger) =
 5266        (db, api, supplierState, orderHistory, logger ?? NullLogger<ZZapCartService>.Instance);
 67
 68    public Task<int> CountActiveAsync(CancellationToken ct = default) =>
 169        _db.ZZapCartItems.CountAsync(x => x.ProviderKey == ProviderKey && ZZapCartItemStatus.Active.Contains(x.Status), 
 70
 71    public Task<int> CountOrderedAsync(CancellationToken ct = default) =>
 172        _db.ZZapCartItems.CountAsync(x => x.ProviderKey == ProviderKey && x.Status == ZZapCartItemStatus.Ordered, ct);
 73
 174    public Task<List<ZZapCartItem>> GetActiveAsync(CancellationToken ct = default) => _db.ZZapCartItems.AsNoTracking()
 175        .Include(x => x.StudioOrder).ThenInclude(x => x!.ProductInfo)
 176        .Include(x => x.StudioOrder).ThenInclude(x => x!.Manufacturer)
 177        .Include(x => x.StudioOrder).ThenInclude(x => x!.OzonClient)
 178        .Where(x => x.ProviderKey == ProviderKey && ZZapCartItemStatus.Active.Contains(x.Status))
 179        .OrderByDescending(x => x.CreatedAtUtc).ToListAsync(ct);
 80
 181    public Task<List<ZZapCartItem>> GetOrderedAsync(int take = 200, CancellationToken ct = default) => _db.ZZapCartItems
 182        .Include(x => x.StudioOrder).ThenInclude(x => x!.OzonClient)
 183        .Where(x => x.ProviderKey == ProviderKey && x.Status == ZZapCartItemStatus.Ordered)
 184        .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    {
 1489        var result = new Dictionary<long, ZZapApiCartLine>();
 2890        for (var page = 1; page <= 10; page++)
 91        {
 1492            var response = await _api.GetCartAsync(page, 100, ct);
 1393            var data = response["result"]?["data"] as JArray;
 1394            if (data is null || data.Count == 0) break;
 4095            foreach (var line in data.OfType<JObject>())
 96            {
 1197                var code = line.Value<long?>("code_order") ?? 0;
 1198                if (code <= 0) continue;
 1099                result[code] = new ZZapApiCartLine(code, line.Value<string>("partnumber"), line.Value<string>("class_man
 10100                    line.Value<string>("class_cat"), line.Value<string>("seller_name"), line.Value<string>("location"),
 10101                    line.Value<decimal?>("price"), line.Value<int?>("qty_order"), line.Value<int?>("delivery_days"),
 10102                    line.Value<string>("descr_delivery"), line.Value<DateTime?>("create_date"));
 103            }
 9104            var info = response["result_info"] as JObject;
 9105            if ((info?.Value<int?>("count") ?? 0) < 100) break;
 106        }
 13107        return result;
 13108    }
 109
 110    /// <summary>Заказы покупателя из GET /orders с текущим статусом продавца.</summary>
 111    public async Task<IReadOnlyList<ZZapApiOrderLine>> GetApiOrdersAsync(int pages = 2, CancellationToken ct = default)
 112    {
 1113        var result = new List<ZZapApiOrderLine>();
 2114        for (var page = 1; page <= pages; page++)
 115        {
 1116            var response = await _api.GetOrdersAsync(page, 100, ct);
 1117            var data = response["result"]?["data"] as JArray;
 1118            if (data is null || data.Count == 0) break;
 119            result.AddRange(data.OfType<JObject>().Select(ParseOrderLine).Where(x => x.CodeOrder > 0));
 1120            if (data.Count < 100) break;
 121        }
 1122        return result;
 1123    }
 124
 4125    public static ZZapApiOrderLine ParseOrderLine(JObject order) => new(
 4126        order.Value<long?>("code_order") ?? 0, order.Value<DateTime?>("create_date"), order.Value<string>("partnumber"),
 4127        order.Value<string>("class_man"), order.Value<string>("class_cat"), order.Value<string>("seller_name"),
 4128        order.Value<string>("seller_phone"), order.Value<decimal?>("price_v2"), order.Value<string>("qty_order"),
 4129        order.Value<int?>("code_track"), order.Value<string>("class_track"),
 4130        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    {
 64136        var added = 0; var skipped = 0; var failed = 0; var messages = new List<string>();
 66137        foreach (var item in queue)
 138        {
 17139            if (item.OrderId <= 0 || string.IsNullOrWhiteSpace(item.CodeDocB) || item.Quantity <= 0)
 140            {
 10141                failed++; messages.Add($"{Describe(item)} — неполные данные предложения ZZap.");
 5142                if (item.OrderId > 0)
 4143                    await RecordHistoryAsync(item.OrderId, item, false, "Неполные данные предложения ZZap.", ApiSupplier
 4144                continue;
 145            }
 12146            var pack = Math.Max(1, item.MinimumQuantity);
 12147            if (item.Quantity % pack != 0)
 148            {
 2149                failed++; messages.Add($"{Describe(item)} — продавец отпускает кратно {pack} шт., в заказе {item.Quantit
 1150                continue;
 151            }
 11152            if (item.QtyMax is > 0 && item.Quantity > item.QtyMax)
 153            {
 2154                failed++; messages.Add($"{Describe(item)} — продавец отдаёт не больше {item.QtyMax} шт., в заказе {item.
 1155                continue;
 156            }
 157            // Минимальная сумма — на весь заказ у продавца: считаем очередь и уже лежащие в корзине строки того же прод
 10158            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 
 2161                    + await _db.ZZapCartItems.Where(x => x.ProviderKey == ProviderKey && x.ActiveStudioOrderId != null &
 2162                        .SumAsync(x => (x.SourcePrice ?? 0m) * x.Quantity, ct);
 2163                if (sellerTotal < item.MinSumOrder)
 164                {
 2165                    failed++; messages.Add($"{Describe(item)} — минимальная сумма заказа у продавца {item.SellerName} {i
 1166                    continue;
 167                }
 168            }
 9169            if (await _db.ZZapCartItems.AnyAsync(x => x.ProviderKey == ProviderKey && x.ActiveStudioOrderId == item.Orde
 170            {
 6171                skipped++; messages.Add($"{Describe(item)} — уже в корзине ZZap."); continue;
 172            }
 173
 174            long codeOrder;
 175            try
 176            {
 7177                var response = await _api.AddToCartAsync(item.CodeDocB, item.Quantity,
 7178                    $"Studio2 {item.ShipmentNumber} (ID {item.OrderId})".Trim(), item.CodeRegion, ct: ct);
 6179                codeOrder = response["result"]?.Value<long?>("code_order") ?? 0;
 6180                if (codeOrder <= 0)
 181                {
 2182                    failed++; messages.Add($"{Describe(item)} — ZZap не вернул код позиции корзины.");
 1183                    await RecordHistoryAsync(item.OrderId, item, false, "ZZap не вернул code_order.", ApiSupplierBasketC
 1184                    continue;
 185                }
 5186            }
 1187            catch (Exception ex) when (ex is ZZapApiException or HttpRequestException)
 188            {
 2189                failed++; messages.Add($"{Describe(item)} — {ex.Message}");
 1190                await RecordHistoryAsync(item.OrderId, item, false, ex.Message, ApiSupplierBasketCartStatus.Failed, null
 1191                continue;
 192            }
 193
 5194            var row = new ZZapCartItem
 5195            {
 5196                ProviderKey = ProviderKey, StudioOrderId = item.OrderId, ActiveStudioOrderId = item.OrderId,
 5197                CodeDocB = item.CodeDocB, CodeOrder = codeOrder, CodeRegion = item.CodeRegion ?? _api.DefaultCodeRegion,
 5198                Article = item.Article, Brand = item.Brand, ProductName = item.ProductName,
 5199                SellerName = item.SellerName, SellerUserKey = item.SellerUserKey, SellerLocation = item.SellerLocation,
 5200                Quantity = item.Quantity, MinimumQuantity = pack, SourcePrice = item.SourcePrice,
 5201                DeliveryDays = item.DeliveryDays, MinSumOrder = item.MinSumOrder, QtyMax = item.QtyMax, CreatedBy = user
 5202            };
 5203            _db.ZZapCartItems.Add(row);
 204            try
 205            {
 5206                await _db.SaveChangesAsync(ct);
 4207            }
 1208            catch (Exception saveEx)
 209            {
 1210                _db.Entry(row).State = EntityState.Detached;
 211                try
 212                {
 1213                    await _api.RemoveFromCartAsync([codeOrder], ct: CancellationToken.None);
 1214                    messages.Add($"{Describe(item)} — позиция снята из корзины ZZap (code_order {codeOrder}).");
 1215                }
 0216                catch (Exception rollbackEx)
 217                {
 0218                    messages.Add($"{Describe(item)} — ВНИМАНИЕ: позиция осталась в корзине ZZap (code_order {codeOrder})
 0219                }
 2220                failed++; messages.Add($"{Describe(item)} — не сохранено в Studio2. {saveEx.Message}");
 1221                continue;
 222            }
 223
 4224            added++;
 4225            messages.Add($"{Describe(item)} — добавлено в корзину ZZap (code_order {codeOrder}); перед оформлением выпол
 4226            await RecordHistoryAsync(item.OrderId, item, true, null, ApiSupplierBasketCartStatus.Pending, null, user, ct
 8227            await TrackAsync(() => _supplierState.MarkInCartAsync(item.OrderId, ProviderKey, ZZapCartItemStatus.Pending,
 8228                codeOrder.ToString(CultureInfo.InvariantCulture), user,
 8229                item.DeliveryDaysMin ?? item.DeliveryDays, item.DeliveryDaysMax ?? item.DeliveryDays,
 8230                item.Direction ?? BuildDirection(item.SellerName, item.SellerLocation), ct));
 4231        }
 16232        return new ZZapCartResult(added, skipped, failed, messages);
 16233    }
 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    {
 8239        var rows = await GetTrackedActiveAsync(codesOrder, ct);
 10240        if (rows.Count == 0) return new(0, 0, 0, [codesOrder is { Count: > 0 }
 2241            ? "Выбранные позиции не привязаны к заказам Studio2: проверять и оформлять можно только строки, добавленные 
 2242            : "В корзине нет позиций, привязанных к заказам Studio2."]);
 243
 18244        var messages = new List<string>(); var ok = 0; var failed = 0;
 245        IReadOnlyDictionary<long, ZZapApiCartLine> apiCart;
 246        ZZapBuyerContact buyer;
 247        try
 248        {
 6249            apiCart = await GetApiCartAsync(ct);
 5250            buyer = await _api.GetBuyerContactAsync(ct);
 5251        }
 1252        catch (Exception ex) when (ex is ZZapApiException or HttpRequestException)
 253        {
 1254            return new(0, 0, rows.Count, [$"ZZap: {ex.Message}"]);
 255        }
 256
 257        foreach (var group in rows.GroupBy(x => x.CodeRegion))
 258        {
 10259            var present = group.Where(x => apiCart.ContainsKey(x.CodeOrder)).ToList();
 12260            foreach (var row in group.Except(present))
 261            {
 1262                row.Status = ZZapCartItemStatus.Failed;
 1263                row.LastError = "Позиции нет в корзине ZZap: удалена на сайте или устарела. Удалите строку и проценьте з
 1264                row.UpdatedAtUtc = DateTime.UtcNow;
 2265                failed++; messages.Add($"{row.Article}: {row.LastError}");
 266            }
 5267            if (present.Count == 0) continue;
 268
 269            // Кратность и максимум продавца — по количеству, которое реально лежит в корзине ZZap.
 16270            foreach (var row in present.ToList())
 271            {
 4272                var liveQty = apiCart[row.CodeOrder].Quantity ?? row.Quantity;
 4273                var pack = Math.Max(1, row.MinimumQuantity);
 4274                string? problem = null;
 5275                if (liveQty % pack != 0) problem = $"Продавец отпускает кратно {pack} шт., в корзине {liveQty} шт.";
 3276                else if (row.QtyMax is > 0 && liveQty > row.QtyMax) problem = $"Продавец отдаёт не больше {row.QtyMax} ш
 4277                if (problem is null) continue;
 3278                row.Status = ZZapCartItemStatus.Failed; row.LastError = problem; row.UpdatedAtUtc = DateTime.UtcNow;
 2279                failed++; messages.Add($"{row.Article}: {problem}");
 1280                present.Remove(row);
 281            }
 4282            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);
 1290                if (sellerTotal >= minSum) continue;
 4291                foreach (var row in sellerRows)
 292                {
 1293                    row.Status = ZZapCartItemStatus.Failed;
 1294                    row.LastError = $"Минимальная сумма заказа у продавца {minSum:0.##} ₽, в корзине у него {sellerTotal
 1295                    row.UpdatedAtUtc = DateTime.UtcNow;
 2296                    failed++; messages.Add($"{row.Article}: {row.LastError}");
 1297                    present.Remove(row);
 298                }
 299            }
 3300            if (present.Count == 0) continue;
 301
 302            try
 303            {
 304                await _api.CreateOrderFromCartAsync(present.Select(x => x.CodeOrder).ToList(), buyer, group.Key, null, t
 4305                foreach (var row in present)
 306                {
 1307                    var live = apiCart[row.CodeOrder];
 2308                    if (live.Price.HasValue) row.SourcePrice = live.Price;
 2309                    if (live.DeliveryDays.HasValue) row.DeliveryDays = live.DeliveryDays.Value;
 2310                    row.Status = ZZapCartItemStatus.Ready; row.LastError = null;
 2311                    row.TestedAtUtc = DateTime.UtcNow; row.UpdatedAtUtc = DateTime.UtcNow;
 2312                    ok++; messages.Add($"{row.Article}: подтверждено ZZap (продавец {live.SellerName ?? row.SellerName},
 313                }
 1314            }
 1315            catch (Exception ex) when (ex is ZZapApiException or HttpRequestException)
 316            {
 4317                foreach (var row in present)
 318                {
 3319                    row.Status = ZZapCartItemStatus.Failed; row.LastError = ex.Message; row.UpdatedAtUtc = DateTime.UtcN
 2320                    failed++; messages.Add($"{row.Article}: {ex.Message}");
 321                }
 1322            }
 2323        }
 324
 5325        await _db.SaveChangesAsync(ct);
 25326        foreach (var row in rows) await TrackCartStatusAsync(row, user, ct);
 5327        return new(ok, 0, failed, messages);
 8328    }
 329
 330    /// <summary>Реальное оформление. Требует свежей проверки; строки атомарно захватываются Ready → Processing,
 331    /// чтобы параллельный запрос не оформил их дважды.</summary>
 332    public async Task<ZZapCartResult> CheckoutAsync(IReadOnlyCollection<long>? codesOrder, string user, CancellationToke
 333    {
 9334        var rows = await GetTrackedActiveAsync(codesOrder, ct);
 9335        if (codesOrder is { Count: > 0 } && rows.Count != codesOrder.Distinct().Count())
 1336            return new(0, 0, codesOrder.Count, ["Часть выбранных позиций не привязана к заказам Studio2 или состав корзи
 9337        if (rows.Count == 0) return new(0, 0, 0, ["В корзине нет позиций, привязанных к заказам Studio2."]);
 338        if (rows.Any(x => x.Status != ZZapCartItemStatus.Ready || x.TestedAtUtc < DateTime.UtcNow - ReadyWindow))
 3339            return new(0, 0, rows.Count, ["Перед оформлением выполните свежую проверку ZZap."]);
 340
 341        var ids = rows.Select(x => x.Id).ToArray();
 4342        var locked = await _db.ZZapCartItems.Where(x => ids.Contains(x.Id) && x.Status == ZZapCartItemStatus.Ready)
 4343            .ExecuteUpdateAsync(s => s.SetProperty(x => x.Status, ZZapCartItemStatus.Processing).SetProperty(x => x.Upda
 4344        if (locked != rows.Count) return new(0, 0, rows.Count, ["Корзина уже оформляется в другом запросе."]);
 23345        foreach (var row in rows) row.Status = ZZapCartItemStatus.Processing;
 346
 4347        return await PlaceOrdersAsync(rows, user, ct);
 9348    }
 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    {
 7355        var messages = new List<string>();
 7356        var items = new List<ZZapCartQueueItem>();
 26357        foreach (var item in queue)
 358        {
 6359            var problem = ValidateQueueItem(item, queue);
 8360            if (problem is not null) { messages.Add($"{Describe(item)} — {problem}"); continue; }
 5361            if (await _db.ZZapCartItems.AnyAsync(x => x.ProviderKey == ProviderKey && x.ActiveStudioOrderId == item.Orde
 362            {
 1363                messages.Add($"{Describe(item)} — уже в корзине ZZap: оформите её на странице корзины или удалите оттуда
 1364                continue;
 365            }
 4366            items.Add(item);
 4367        }
 9368        if (messages.Count > 0) return new(0, 0, messages.Count, messages);
 6369        if (items.Count == 0) return new(0, 0, 0, ["Нет позиций ZZap для оформления."]);
 370
 371        ZZapBuyerContact buyer;
 372        try
 373        {
 4374            buyer = await _api.GetBuyerContactAsync(ct);
 15375            foreach (var item in items)
 4376                await _api.CreateOrderDirectAsync(item.CodeDocB, item.Quantity, buyer, item.CodeRegion, OrderComment(ite
 3377        }
 1378        catch (Exception ex) when (ex is ZZapApiException or HttpRequestException)
 379        {
 1380            return new(0, 0, items.Count, [$"ZZap отклонил проверку заказа: {ex.Message}"]);
 381        }
 382
 6383        var success = 0; var failed = 0;
 12384        foreach (var item in items)
 385        {
 386            long codeOrder;
 387            try
 388            {
 3389                var response = await _api.CreateOrderDirectAsync(item.CodeDocB, item.Quantity, buyer, item.CodeRegion, O
 2390                codeOrder = response["result"]?.Value<long?>("code_order") ?? 0;
 2391                if (codeOrder <= 0) throw new ZZapApiException("ZZap не вернул номер созданного заказа.", requestPath: "
 2392            }
 1393            catch (Exception ex) when (ex is ZZapApiException or HttpRequestException)
 394            {
 2395                failed++; messages.Add($"{Describe(item)} — {ex.Message}");
 1396                await RecordHistoryAsync(item.OrderId, item, false, ex.Message, ApiSupplierBasketCartStatus.Failed, null
 1397                continue;
 398            }
 399
 2400            var row = new ZZapCartItem
 2401            {
 2402                ProviderKey = ProviderKey, StudioOrderId = item.OrderId, ActiveStudioOrderId = null,
 2403                CodeDocB = item.CodeDocB, CodeOrder = codeOrder, CodeRegion = item.CodeRegion ?? _api.DefaultCodeRegion,
 2404                Article = item.Article, Brand = item.Brand, ProductName = item.ProductName,
 2405                SellerName = item.SellerName, SellerUserKey = item.SellerUserKey, SellerLocation = item.SellerLocation,
 2406                Quantity = item.Quantity, MinimumQuantity = Math.Max(1, item.MinimumQuantity), SourcePrice = item.Source
 2407                DeliveryDays = item.DeliveryDays, MinSumOrder = item.MinSumOrder, QtyMax = item.QtyMax,
 2408                Status = ZZapCartItemStatus.Ordered, OrderedAtUtc = DateTime.UtcNow, TestedAtUtc = DateTime.UtcNow, Crea
 2409            };
 2410            _db.ZZapCartItems.Add(row);
 3411            try { await _db.SaveChangesAsync(ct); }
 1412            catch (Exception saveEx)
 413            {
 414                // Заказ у продавца уже создан — его нельзя «снять» как позицию корзины; фиксируем номер в сообщении и и
 1415                _db.Entry(row).State = EntityState.Detached;
 2416                failed++; messages.Add($"{Describe(item)} — ВНИМАНИЕ: заказ ZZap {codeOrder} создан, но не сохранён в St
 1417                await RecordHistoryAsync(item.OrderId, item, false, saveEx.Message, ApiSupplierBasketCartStatus.Ordered,
 1418                continue;
 419            }
 420
 1421            success++;
 1422            messages.Add($"{Describe(item)} — заказ ZZap {codeOrder} оформлен у продавца {item.SellerName} ({item.Quanti
 1423            var code = codeOrder.ToString(CultureInfo.InvariantCulture);
 1424            await RecordHistoryAsync(item.OrderId, item, true, null, ApiSupplierBasketCartStatus.Ordered, code, user, ct
 2425            await TrackAsync(() => _supplierState.MarkOrderedAsync(item.OrderId, ProviderKey, code, code, null, user,
 2426                item.DeliveryDaysMin ?? item.DeliveryDays, item.DeliveryDaysMax ?? item.DeliveryDays,
 2427                item.Direction ?? BuildDirection(item.SellerName, item.SellerLocation), ct));
 1428        }
 3429        return new(success, 0, failed, messages);
 7430    }
 431
 7432    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    {
 7437        if (item.OrderId <= 0 || string.IsNullOrWhiteSpace(item.CodeDocB) || item.Quantity <= 0) return "неполные данные
 5438        var pack = Math.Max(1, item.MinimumQuantity);
 5439        if (item.Quantity % pack != 0) return $"продавец отпускает кратно {pack} шт., в заказе {item.Quantity} шт.";
 5440        if (item.QtyMax is > 0 && item.Quantity > item.QtyMax) return $"продавец отдаёт не больше {item.QtyMax} шт., в з
 5441        if (item.MinSumOrder is > 0 && item.SourcePrice is > 0)
 442        {
 0443            var sellerTotal = queue.Where(x => x.SellerUserKey == item.SellerUserKey && x.SourcePrice is > 0).Sum(x => x
 0444            if (sellerTotal < item.MinSumOrder) return $"минимальная сумма заказа у продавца {item.SellerName} {item.Min
 445        }
 5446        return null;
 447    }
 448
 449    private async Task<ZZapCartResult> PlaceOrdersAsync(List<ZZapCartItem> rows, string user, CancellationToken ct)
 450    {
 12451        var success = 0; var failed = 0; var messages = new List<string>();
 452        ZZapBuyerContact buyer;
 7453        try { buyer = await _api.GetBuyerContactAsync(ct); }
 1454        catch (Exception ex) when (ex is ZZapApiException or HttpRequestException)
 455        {
 1456            await MarkFailedAsync(rows, ex.Message, ct);
 1457            return new(0, 0, rows.Count, [$"ZZap: {ex.Message}"]);
 458        }
 459
 460        // Регион — параметр оформления, поэтому корзина делится по нему; внутри региона один вызов на все позиции.
 461        foreach (var group in rows.GroupBy(x => x.CodeRegion))
 462        {
 3463            var groupRows = group.ToList();
 464            try
 465            {
 466                var response = await _api.CreateOrderFromCartAsync(groupRows.Select(x => x.CodeOrder).ToList(), buyer, g
 3467                    "Заказ Studio2", test: false, ct);
 2468                var confirmed = ParseCodes(response["result"]?.Value<string>("codes_order"));
 10469                foreach (var row in groupRows)
 470                {
 471                    // ZZap возвращает коды оформленных заказов; пустой список считаем подтверждением всей группы —
 472                    // API отвечает 400 на любой отказ, а не частичным результатом.
 3473                    if (confirmed.Count > 0 && !confirmed.Contains(row.CodeOrder))
 474                    {
 2475                        row.Status = ZZapCartItemStatus.Failed; row.LastError = "ZZap не подтвердил оформление этой пози
 3476                        row.UpdatedAtUtc = DateTime.UtcNow; failed++; messages.Add($"{row.Article}: {row.LastError}");
 1477                        continue;
 478                    }
 6479                    row.Status = ZZapCartItemStatus.Ordered; row.ActiveStudioOrderId = null; row.LastError = null;
 4480                    row.OrderedAtUtc = DateTime.UtcNow; row.UpdatedAtUtc = DateTime.UtcNow;
 4481                    success++; messages.Add($"{row.Article}: заказ ZZap {row.CodeOrder} оформлен у продавца {row.SellerN
 482                }
 2483            }
 1484            catch (Exception ex) when (ex is ZZapApiException or HttpRequestException)
 485            {
 4486                foreach (var row in groupRows)
 487                {
 3488                    row.Status = ZZapCartItemStatus.Failed; row.LastError = ex.Message; row.UpdatedAtUtc = DateTime.UtcN
 2489                    failed++; messages.Add($"{row.Article}: {ex.Message}");
 490                }
 1491            }
 3492        }
 493
 3494        await _db.SaveChangesAsync(ct);
 14495        foreach (var row in rows)
 496        {
 4497            if (row.Status == ZZapCartItemStatus.Ordered)
 498            {
 2499                var code = row.CodeOrder.ToString(CultureInfo.InvariantCulture);
 2500                await RecordHistoryAsync(row.StudioOrderId, ToQueueItem(row), true, null, ApiSupplierBasketCartStatus.Or
 4501                await TrackAsync(() => _supplierState.MarkOrderedAsync(row.StudioOrderId, ProviderKey, code, code, null,
 502            }
 503            else
 504            {
 2505                await TrackCartStatusAsync(row, user, ct);
 506            }
 4507        }
 3508        return new(success, 0, failed, messages);
 4509    }
 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    {
 4518        var row = await _db.ZZapCartItems.Include(x => x.StudioOrder)
 4519            .FirstOrDefaultAsync(x => x.ProviderKey == ProviderKey && x.CodeOrder == codeOrder && x.Status == ZZapCartIt
 5520        if (row is null) return new(0, 0, 1, [$"Заказ ZZap {codeOrder} не оформлялся через Studio2 — отмените его на сай
 3521        if (row.ExternalStatusCode is int current && ZZapSupplierStatusProviderFinalCodes.Contains(current))
 1522            return new(0, 0, 1, [$"Заказ ZZap {codeOrder} уже в конечном статусе «{row.ExternalStatusName}»."]);
 523
 2524        var comment = string.IsNullOrWhiteSpace(reason) ? $"Отмена заказа Studio2 {row.StudioOrder?.ShipmentNumber}".Tri
 525        try
 526        {
 2527            await _api.SetOrderStatusAsync(codeOrder, ZZapDataManager.CancelledByBuyerTrackCode, comment, test: true, ct
 1528            await _api.SetOrderStatusAsync(codeOrder, ZZapDataManager.CancelledByBuyerTrackCode, comment, test: false, c
 1529        }
 1530        catch (Exception ex) when (ex is ZZapApiException or HttpRequestException)
 531        {
 1532            return new(0, 0, 1, [$"ZZap: {ex.Message}"]);
 533        }
 534
 1535        row.ExternalStatusCode = ZZapDataManager.CancelledByBuyerTrackCode;
 1536        row.ExternalStatusName = "Заказ отменен покупателем";
 1537        row.UpdatedAtUtc = DateTime.UtcNow;
 1538        await _db.SaveChangesAsync(ct);
 539
 1540        await RecordHistoryAsync(row.StudioOrderId, ToQueueItem(row), true, null, "Cancelled", codeOrder.ToString(Cultur
 2541        await TrackAsync(() => _supplierState.ApplySnapshotsAsync(ProviderKey, [new SupplierStatusSnapshot
 2542        {
 2543            OrderId = row.StudioOrderId,
 2544            Kind = SupplierStatusKind.Order,
 2545            ExternalCode = ZZapDataManager.CancelledByBuyerTrackCode.ToString(CultureInfo.InvariantCulture),
 2546            StatusName = row.ExternalStatusName,
 2547            ExternalOrderNumber = codeOrder.ToString(CultureInfo.InvariantCulture),
 2548            ExternalOrderId = codeOrder.ToString(CultureInfo.InvariantCulture),
 2549            StatusChangedAt = DateTime.UtcNow
 2550        }], ct));
 1551        return new(1, 0, 0, [$"{row.Article}: заказ ZZap {codeOrder} отменён, продавцу отправлен комментарий «{comment}»
 4552    }
 553
 554    /// <summary>Конечные code_track ZZap — дублируют список провайдера статусов, чтобы не тянуть его сюда.</summary>
 1555    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    {
 5559        var codes = new HashSet<long>(codesOrder ?? []);
 5560        if (all)
 561        {
 2562            try { codes.UnionWith((await GetApiCartAsync(ct)).Keys); }
 0563            catch (Exception ex) when (ex is ZZapApiException or HttpRequestException) { return new(0, 0, 1, [$"ZZap: {e
 564        }
 5565        var rows = await _db.ZZapCartItems
 5566            .Where(x => x.ProviderKey == ProviderKey && ZZapCartItemStatus.Active.Contains(x.Status) && (all || codes.Co
 5567            .ToListAsync(ct);
 568        codes.UnionWith(rows.Select(x => x.CodeOrder));
 6569        if (codes.Count == 0) return new(0, 0, 0, ["Нечего удалять."]);
 570
 16571        var removed = 0; var failed = 0; var messages = new List<string>(); var closed = new List<ZZapCartItem>();
 572        var rowByCode = rows.ToDictionary(x => x.CodeOrder);
 4573        var deleted = new HashSet<long>();
 16574        foreach (var code in codes)
 575        {
 4576            var label = rowByCode.TryGetValue(code, out var row) ? row.Article : $"code_order {code}";
 577            try
 578            {
 4579                await _api.RemoveFromCartAsync([code], ct: ct);
 3580                deleted.Add(code);
 3581            }
 1582            catch (ZZapApiException ex) when (ex.StatusCode is 400 or 404)
 583            {
 584                // Позиции уже нет у ZZap (удалена на сайте или устарела) — зеркало всё равно закрываем.
 2585                deleted.Add(code); messages.Add($"{label}: у ZZap позиции уже нет ({ex.Message}); строка Studio2 закрыта
 1586            }
 0587            catch (Exception ex) when (ex is ZZapApiException or HttpRequestException)
 588            {
 0589                failed++; messages.Add($"{label}: {ex.Message}");
 0590            }
 4591        }
 592
 593        // ZZap отвечает на DELETE успехом, но первый вызов по позиции может ничего не удалить (проверено живыми
 594        // вызовами: позиция остаётся в GET /cart, второй DELETE убирает её). Поэтому сверяемся с корзиной и добиваем.
 4595        if (deleted.Count > 0)
 596        {
 597            try
 598            {
 4599                var stillThere = (await GetApiCartAsync(ct)).Keys.Where(deleted.Contains).ToList();
 12600                foreach (var code in stillThere)
 2601                    await _api.RemoveFromCartAsync([code], ct: ct);
 4602                if (stillThere.Count > 0)
 603                {
 2604                    var remaining = (await GetApiCartAsync(ct)).Keys.Where(deleted.Contains).ToList();
 6605                    foreach (var code in remaining)
 606                    {
 2607                        deleted.Remove(code); failed++;
 1608                        messages.Add($"{(rowByCode.TryGetValue(code, out var r) ? r.Article : $"code_order {code}")}: ZZ
 609                    }
 610                }
 4611            }
 0612            catch (Exception ex) when (ex is ZZapApiException or HttpRequestException)
 613            {
 0614                messages.Add($"Не удалось перепроверить корзину ZZap после удаления: {ex.Message}");
 0615            }
 616        }
 617
 14618        foreach (var code in deleted)
 619        {
 3620            removed++;
 3621            if (!rowByCode.TryGetValue(code, out var row)) { messages.Add($"code_order {code}: удалено из корзины ZZap."
 3622            messages.Add($"{row.Article}: удалено из корзины ZZap.");
 9623            row.Status = ZZapCartItemStatus.Removed; row.ActiveStudioOrderId = null; row.UpdatedAtUtc = DateTime.UtcNow;
 3624            closed.Add(row);
 625        }
 4626        await _db.SaveChangesAsync(ct);
 14627        foreach (var row in closed)
 6628            await TrackAsync(() => _supplierState.RemoveFromCartAsync(row.StudioOrderId, ProviderKey, user, ct));
 4629        return new(removed, 0, failed, messages);
 5630    }
 631
 632    private async Task<List<ZZapCartItem>> GetTrackedActiveAsync(IReadOnlyCollection<long>? codesOrder, CancellationToke
 633    {
 17634        var query = _db.ZZapCartItems.Where(x => x.ProviderKey == ProviderKey && ZZapCartItemStatus.Active.Contains(x.St
 32635        if (codesOrder is { Count: > 0 }) query = query.Where(x => codesOrder.Contains(x.CodeOrder));
 17636        return await query.OrderBy(x => x.Id).ToListAsync(ct);
 17637    }
 638
 639    private async Task MarkFailedAsync(List<ZZapCartItem> rows, string error, CancellationToken ct)
 640    {
 7641        foreach (var row in rows) { row.Status = ZZapCartItemStatus.Failed; row.LastError = error; row.UpdatedAtUtc = Da
 1642        await _db.SaveChangesAsync(ct);
 1643    }
 644
 645    private Task TrackCartStatusAsync(ZZapCartItem row, string? user, CancellationToken ct) =>
 14646        TrackAsync(() => _supplierState.MarkInCartAsync(row.StudioOrderId, ProviderKey, row.Status, row.Id,
 14647            row.CodeOrder.ToString(CultureInfo.InvariantCulture), user, ct: ct));
 648
 649    private async Task TrackAsync(Func<Task> action)
 650    {
 35651        try { await action(); }
 3652        catch (Exception exception) { _logger.LogWarning(exception, "Не удалось обновить состояние заказа у поставщика Z
 18653    }
 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        {
 16660            var entry = new ApiSupplierBasketHistoryEntry(ProviderKey, DisplayName, LogoUrl, success, error,
 16661                codeOrder?.ToString(CultureInfo.InvariantCulture), item.Article, item.ProductName, item.SourcePrice, "RU
 16662                item.Quantity, item.ShipmentNumber, ClientName: item.ClientName,
 16663                WarehouseName: item.Direction ?? BuildDirection(item.SellerName, item.SellerLocation),
 16664                CartStatus: cartStatus, SupplierOrderNumber: supplierOrderNumber, SupplierCartUrl: SupplierCartUrl);
 16665            await _orderHistory.AddOrderHistory(new OrderHistory
 16666            {
 16667                OrderId = orderId, ColumnName = ApiSupplierBasketHistoryEntry.HistoryColumnName,
 16668                ColumnDisplayName = $"Корзина {DisplayName}",
 16669                NewValue = JsonSerializer.Serialize(entry, ApiSupplierBasketHistoryEntry.JsonOptions),
 16670                ChangedAt = DateTime.Now, ChangedBy = user
 16671            });
 16672        }
 0673        catch (Exception exception)
 674        {
 0675            _logger.LogWarning(exception, "Не удалось записать историю корзины ZZap для заказа {OrderId}", orderId);
 0676        }
 16677    }
 678
 3679    private static ZZapCartQueueItem ToQueueItem(ZZapCartItem row) => new(row.StudioOrderId, row.CodeDocB, row.Article, 
 3680        row.ProductName, row.SellerName, row.SellerUserKey, row.SellerLocation, row.Quantity, row.MinimumQuantity, row.S
 3681        row.DeliveryDays, row.CodeRegion, BuildDirection(row.SellerName, row.SellerLocation), row.DeliveryDays, row.Deli
 3682        row.StudioOrder?.ShipmentNumber, row.StudioOrder?.OzonClient?.Name, row.MinSumOrder, row.QtyMax);
 683
 684    private static string? BuildDirection(string? seller, string? location) =>
 23685        string.IsNullOrWhiteSpace(seller) ? location : string.IsNullOrWhiteSpace(location) ? seller : $"{seller} · {loca
 686
 687    private static string Describe(ZZapCartQueueItem item) =>
 23688        string.IsNullOrWhiteSpace(item.ShipmentNumber) ? $"{item.Article} {item.Brand}" : $"{item.ShipmentNumber} · {ite
 689
 1690    private static string FormatMoney(decimal? value) => value?.ToString("0.##", CultureInfo.InvariantCulture) ?? "—";
 691
 2692    private static HashSet<long> ParseCodes(string? codes) => (codes ?? string.Empty)
 2693        .Split([';', ','], StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries)
 6694        .Select(x => long.TryParse(x, out var code) ? code : 0).Where(x => x > 0).ToHashSet();
 695}