< Summary

Information
Class: OzonOrdersWeb.WebServices.SupplierCart.SupplierCartGridOrderItem
Assembly: OzonOrdersWeb
File(s): /home/runner/work/Studio2Prod/Studio2Prod/OzonOrdersWeb/WebServices/SupplierCart/SupplierCartGridRows.cs
Line coverage
61%
Covered lines: 11
Uncovered lines: 7
Coverable lines: 18
Total lines: 884
Line coverage: 61.1%
Branch coverage
N/A
Covered branches: 0
Total branches: 0
Branch coverage: N/A
Method coverage

Feature is only available for sponsors

Upgrade to PRO version

Metrics

MethodBranch coverage Crap Score Cyclomatic complexity Line coverage
get_Article()100%11100%
get_Brand()100%11100%
get_Name()100%210%
get_Quantity()100%11100%
get_Reserved()100%210%
get_Confirmed()100%210%
get_Cancelled()100%210%
get_Price()100%11100%
get_Sum()100%11100%
get_Status()100%11100%
get_StatusTone()100%11100%
get_StatusTitle()100%11100%
get_DeliveryAt()100%11100%
get_DeliveryText()100%210%
get_Warehouse()100%210%
get_Country()100%210%
get_Comment()100%11100%
get_Rejected()100%11100%

File(s)

/home/runner/work/Studio2Prod/Studio2Prod/OzonOrdersWeb/WebServices/SupplierCart/SupplierCartGridRows.cs

#LineLine coverage
 1using System.Globalization;
 2using OzonDomains.Models;
 3using OzonOrdersWeb.Areas.Studio2.ViewModels.SupplierCart;
 4
 5namespace OzonOrdersWeb.WebServices.SupplierCart;
 6
 7/// <summary>Заказ Studio2, связанный со строкой корзины или заказом поставщика.</summary>
 8public sealed record SupplierCartStudioOrderCell(int Id, string Number, string? Client);
 9
 10/// <summary>Вариант выбора в ячейке (адрес доставки Armtek / ML-Auto).</summary>
 11public sealed record SupplierCartGridOption(string Value, string Name);
 12
 13/// <summary>Плашка над гридом (ошибка API, несвязанные адреса, лимит выборки). Tone — тон темы (danger|warning|info).</
 14public sealed record SupplierCartGridNotice(string Tone, string Text, string? LinkText = null, string? LinkAction = null
 15
 16/// <summary>
 17/// Строка грида «Корзина»: общий вид строки для всех API-поставщиков. Id — идентификатор, который ждут POST-действия
 18/// поставщика (LocalCartItemId, ApiItemId или code_order ZZap), поэтому выбор строк не зависит от DOM.
 19/// </summary>
 20public sealed record SupplierCartGridCartRow
 21{
 22    public string Id { get; init; } = string.Empty;
 23    public bool Selectable { get; init; }
 24    public string? ImageUrl { get; init; }
 25    public string Product { get; init; } = string.Empty;
 26    public string? Article { get; init; }
 27    public string? Brand { get; init; }
 28    public SupplierCartStudioOrderCell? StudioOrder { get; init; }
 29    public string? MissingOrderHint { get; init; }
 30    public DateTime? AddedAt { get; init; }
 31    public string? Warehouse { get; init; }
 32    public string? WarehouseMeta { get; init; }
 33    public int? Period { get; init; }
 34    public decimal? Quantity { get; init; }
 35    public decimal? Price { get; init; }
 36    public decimal? Sum { get; init; }
 37    public string Currency { get; init; } = "RUB";
 38    public string? Status { get; init; }
 39    public string? StatusName { get; init; }
 40    public string? StatusTone { get; init; }
 41    public string? StatusTitle { get; init; }
 42    public string? Comment { get; init; }
 43    public string? CommentTone { get; init; }
 44    /// <summary>Адрес доставки Armtek («KUNRG|KUNZA») или ML-Auto (Id адреса).</summary>
 45    public string? DeliveryValue { get; init; }
 46    public string? DeliveryName { get; init; }
 47    /// <summary>Склад отгрузки Studio2 связан с адресом поставщика в настройках.</summary>
 48    public bool? DeliveryLinked { get; init; }
 49    public bool CanRequote { get; init; }
 50    public bool ApiOnly { get; init; }
 51    public string? Point { get; init; }
 52    public string? LocationCode { get; init; }
 53    public long? ZZapCode { get; init; }
 54    /// <summary>Есть ли позиция в API-корзине ZZap (null — ZZap не ответил).</summary>
 55    public bool? InZZap { get; init; }
 56    public string? ZZapInfo { get; init; }
 57}
 58
 59/// <summary>Позиция заказа поставщика (раскрытие заказа).</summary>
 60public sealed record SupplierCartGridOrderItem
 61{
 6062    public string? Article { get; init; }
 6063    public string? Brand { get; init; }
 064    public string? Name { get; init; }
 6065    public decimal? Quantity { get; init; }
 066    public decimal? Reserved { get; init; }
 067    public decimal? Confirmed { get; init; }
 068    public decimal? Cancelled { get; init; }
 6069    public decimal? Price { get; init; }
 6070    public decimal? Sum { get; init; }
 6071    public string? Status { get; init; }
 6072    public string? StatusTone { get; init; }
 6073    public string? StatusTitle { get; init; }
 6074    public DateTime? DeliveryAt { get; init; }
 075    public string? DeliveryText { get; init; }
 076    public string? Warehouse { get; init; }
 077    public string? Country { get; init; }
 6078    public string? Comment { get; init; }
 6179    public bool Rejected { get; init; }
 80}
 81
 82/// <summary>Строка грида «Заказы»: заказ (или позиция заказа, если поставщик ведёт статус на позиции).</summary>
 83public sealed record SupplierCartGridOrderRow
 84{
 85    public string Id { get; init; } = string.Empty;
 86    public string Number { get; init; } = string.Empty;
 87    public string? NumberMeta { get; init; }
 88    public bool IsTest { get; init; }
 89    public DateTime? CreatedAt { get; init; }
 90    public string? CreatedText { get; init; }
 91    public string? Status { get; init; }
 92    public string? StatusTone { get; init; }
 93    public IReadOnlyList<SupplierCartGridNotice>? StatusNotes { get; init; }
 94    public decimal? Amount { get; init; }
 95    public string Currency { get; init; } = "RUB";
 96    public int? ItemsCount { get; init; }
 97    public decimal? Quantity { get; init; }
 98    public string? QuantityText { get; init; }
 99    public decimal? Price { get; init; }
 100    public string? Product { get; init; }
 101    public string? Article { get; init; }
 102    public string? Brand { get; init; }
 103    public string? Supplier { get; init; }
 104    public string? SupplierMeta { get; init; }
 105    public string? Delivery { get; init; }
 106    public string? DeliveryMeta { get; init; }
 107    public string? Payment { get; init; }
 108    public string? Buyer { get; init; }
 109    public string? BuyerCode { get; init; }
 110    public string? OrderType { get; init; }
 111    public string? Invoice { get; init; }
 112    public string? Contract { get; init; }
 113    public string? Credit { get; init; }
 114    public string? MinSum { get; init; }
 115    public string? Comment { get; init; }
 116    public string? Source { get; init; }
 117    public string? Point { get; init; }
 118    public DateTime? UpdatedAt { get; init; }
 119    public string? UpdatedText { get; init; }
 120    /// <summary>Группа строк одного заказа (АВД: позиции одного заказа) — итог и детали заказа.</summary>
 121    public decimal? OrderTotal { get; init; }
 122    public string? OrderApiId { get; init; }
 123    public string? User { get; init; }
 124    public string? Brands { get; init; }
 125    public IReadOnlyList<SupplierCartStudioOrderCell> StudioOrders { get; init; } = Array.Empty<SupplierCartStudioOrderC
 126    public bool CanCancel { get; init; }
 127    public IReadOnlyList<SupplierCartGridOrderItem> Items { get; init; } = Array.Empty<SupplierCartGridOrderItem>();
 128}
 129
 130/// <summary>Данные вкладки поставщика для гридов «Корзина» и «Заказы».</summary>
 131public sealed record SupplierCartGridPayload
 132{
 133    public string Provider { get; init; } = string.Empty;
 134    public string ProviderKey { get; init; } = string.Empty;
 135    public string DisplayName { get; init; } = string.Empty;
 136    public string Currency { get; init; } = "RUB";
 137    public IReadOnlyList<SupplierCartGridCartRow> Cart { get; init; } = Array.Empty<SupplierCartGridCartRow>();
 138    public IReadOnlyList<SupplierCartGridOrderRow> Orders { get; init; } = Array.Empty<SupplierCartGridOrderRow>();
 139    /// <summary>Число заказов для бейджа вкладки (у Nikei и Berg строки грида — позиции, а не заказы).</summary>
 140    public int OrderCount { get; init; }
 141    public string? Error { get; init; }
 142    public string? OrdersError { get; init; }
 143    public IReadOnlyList<SupplierCartGridNotice> CartNotices { get; init; } = Array.Empty<SupplierCartGridNotice>();
 144    public IReadOnlyList<SupplierCartGridNotice> OrderNotices { get; init; } = Array.Empty<SupplierCartGridNotice>();
 145    public IReadOnlyList<SupplierCartGridOption> DeliveryOptions { get; init; } = Array.Empty<SupplierCartGridOption>();
 146}
 147
 148/// <summary>
 149/// Строки гридов страницы «Корзина поставщиков» (/Studio2/SupplierCart) из <see cref="SupplierCartSection"/>.
 150/// Сюда переехала логика, которая раньше жила в Razor: тоны статусов, имена статусов корзины, суммы АВД,
 151/// выбор строки, связь заказов с Studio2, фолбэки «Studio2 ?? API». Грид сортирует и фильтрует по этим полям.
 152/// </summary>
 153public static class SupplierCartGridRows
 154{
 155    public const string ToneSuccess = "success";
 156    public const string ToneInfo = "info";
 157    public const string ToneWarning = "warning";
 158    public const string ToneDanger = "danger";
 159    public const string ToneMuted = "muted";
 160
 161    public const string SourceStudio2 = "studio2";
 162    public const string SourceApi = "api";
 163
 164    private const string DefaultMissingHint = "Проверьте комментарий позиции";
 165    private const string UnlinkedMissingHint = "Заказ Studio2 удалён или не привязан";
 166    private const string ZZapMissingHint = "Добавлено на сайте ZZap, не из проценки Studio2";
 167
 168    /// <summary>Маршрут вкладки (query provider) → ключ поставщика и название. Один источник для вкладок и JSON.
 169    /// Currency — валюта, которую страница всегда показывала для поставщика; null — валюта секции.</summary>
 170    public static readonly IReadOnlyList<(string Route, string ProviderKey, string Name, string? Currency)> Providers =
 171    [
 172        ("avd", ApiSupplierSettings.AvdProviderKey, "АВД", null),
 173        ("armtek", "Armtek", "Армтек RU", null),
 174        ("armtekby", "ArmtekBy", "Армтек BY", null),
 175        ("mlauto", "MlAuto", "ML-Auto BY", null),
 176        ("mlautoru", "MlAutoRu", "ML-Auto RU", null),
 177        ("forumauto", ApiSupplierSettings.ForumAutoProviderKey, "Форум-Авто (Союз)", null),
 178        ("forumautointerparts", ApiSupplierSettings.ForumAutoInterpartsProviderKey, "Форум-Авто (Интерпартс)", null),
 179        ("forumautopiter", ApiSupplierSettings.ForumAutoPiterProviderKey, "Форум-Авто (Питер)", null),
 180        ("forumautorostov", ApiSupplierSettings.ForumAutoRostovProviderKey, "Форум-Авто (Ростов)", null),
 181        ("forumautoistra", ApiSupplierSettings.ForumAutoIstraProviderKey, "Форум-Авто (Истра)", null),
 182        ("favoritparts", ApiSupplierSettings.FavoritPartsProviderKey, "Фаворит Союз", null),
 183        ("favoritpartsistra", ApiSupplierSettings.FavoritPartsIstraProviderKey, "Фаворит Истра", null),
 184        ("favoritpartsrostov", ApiSupplierSettings.FavoritPartsRostovProviderKey, "Фаворит Ростов", null),
 185        ("moskvorechieistra", ApiSupplierSettings.MoskvorechieIstraProviderKey, "Москворечье (Истра)", "RUB"),
 186        ("motex", ApiSupplierSettings.MotexProviderKey, "МоТехС", "BYN"),
 187        ("shatem", ApiSupplierSettings.ShateMProviderKey, "Шате‑М", "BYN"),
 188        ("profitliga", ApiSupplierSettings.ProfitLigaProviderKey, "Профит-Лига", "RUB"),
 189        ("nikei", ApiSupplierSettings.NikeiProviderKey, "Nikei", "RUB"),
 190        ("mikadomskhod20", ApiSupplierSettings.MikadoMskHod20ProviderKey, "Микадо МскХод20", "RUB"),
 191        ("zzapmoscow", ApiSupplierSettings.ZZapMoscowProviderKey, "ZZap Москва", "RUB"),
 192        ("japarts", ApiSupplierSettings.JapartsProviderKey, "Japarts", "RUB"),
 193        ("berg", ApiSupplierSettings.BergProviderKey, "Берг", "RUB")
 194    ];
 195
 196    public static bool IsKnownRoute(string? route) =>
 197        route is not null && Providers.Any(p => string.Equals(p.Route, route, StringComparison.OrdinalIgnoreCase));
 198
 199    /// <summary>Нормализует маршрут вкладки: неизвестный или пустой — АВД.</summary>
 200    public static string NormalizeRoute(string? route)
 201    {
 202        var value = route?.Trim().ToLowerInvariant();
 203        return IsKnownRoute(value) ? value! : "avd";
 204    }
 205
 206    /// <summary>Тон статуса заказа поставщика по тексту статуса (русские формулировки разных API).</summary>
 207    public static string OrderStatusTone(string? status)
 208    {
 209        var value = (status ?? string.Empty).ToLowerInvariant();
 210        if (value.Contains("отмен") || value.Contains("отказ") || value.Contains("аннул") || value.Contains("deny")) ret
 211        if (value.Contains("выдан") || value.Contains("готов") || value.Contains("получ") || value.Contains("заверш") ||
 212        if (value.Contains("отправ") || value.Contains("отгруж") || value.Contains("в пути") || value.Contains("доставк"
 213        if (value.Contains("ожид") || value.Contains("обработ") || value.Contains("принят") || value.Contains("подтверж"
 214        return ToneMuted;
 215    }
 216
 217    /// <summary>Имя статуса строки корзины Studio2 (общие коды Pending/Ready/… у всех гибридных корзин).</summary>
 218    public static string CartStatusName(string? status) => status switch
 219    {
 220        "Pending" => "Ожидает проверки",
 221        "Ready" => "Проверено",
 222        "Processing" => "Оформляется",
 223        "Ordered" => "Оформлено",
 224        "Failed" => "Ошибка",
 225        "Removed" => "Удалено",
 226        _ => status ?? string.Empty
 227    };
 228
 229    public static string CartStatusTone(string? status) => status switch
 230    {
 231        "Pending" => ToneWarning,
 232        "Ready" or "Ordered" => ToneSuccess,
 233        "Processing" => ToneInfo,
 234        "Failed" => ToneDanger,
 235        _ => ToneMuted
 236    };
 237
 238    public static SupplierCartGridPayload Build(string route, SupplierCartSection section, Func<string, string> imageUrl
 239    {
 240        route = NormalizeRoute(route);
 241        var currency = Providers.First(p => p.Route == route).Currency ?? section.CurrencyCode;
 242        var payload = new SupplierCartGridPayload
 243        {
 244            Provider = route,
 245            ProviderKey = section.ProviderKey,
 246            DisplayName = section.DisplayName,
 247            Currency = currency,
 248            Error = section.Error,
 249            OrdersError = section.OrdersError
 250        };
 251
 252        var result = route switch
 253        {
 254            "avd" => payload with
 255            {
 256                Cart = section.Rows.Select(r => Cart(r, imageUrl, currency) with
 257                {
 258                    Id = r.ApiItemId,
 259                    Selectable = long.TryParse(r.ApiItemId, out _),
 260                    AddedAt = r.AddedAt,
 261                    Warehouse = $"№ {r.ApiItemId} · {r.ApiSupplierName}",
 262                    WarehouseMeta = Blank(r.ApiRegion),
 263                    Period = r.ApiPeriod
 264                }).ToList(),
 265                Orders = AvdOrders(section),
 266                OrderCount = section.Orders.Count
 267            },
 268            "armtek" or "armtekby" => Armtek(payload, section, imageUrl, currency),
 269            "mlauto" or "mlautoru" => MlAuto(payload, section, imageUrl, currency, route == "mlauto"),
 270            "forumauto" or "forumautointerparts" or "forumautopiter" or "forumautorostov" or "forumautoistra" => payload
 271            {
 272                Cart = section.Rows.Select(r => StatusCart(r, imageUrl, currency) with
 273                {
 274                    Id = Id(r.LocalCartItemId), AddedAt = r.AddedAt, Period = r.ApiPeriod
 275                }).ToList(),
 276                Orders = ForumAutoOrders(section, currency),
 277                OrderCount = section.ForumAutoApiOrders.Count
 278            },
 279            "favoritparts" or "favoritpartsistra" or "favoritpartsrostov" => payload with
 280            {
 281                Cart = section.Rows.Select(r => StatusCart(r, imageUrl, currency) with
 282                {
 283                    Id = r.ApiItemId, AddedAt = r.AddedAt, Period = r.ApiPeriod
 284                }).ToList(),
 285                Orders = FavoritPartsOrders(section, currency),
 286                OrderCount = section.FavoritPartsApiOrders.Count
 287            },
 288            "moskvorechieistra" => payload with
 289            {
 290                Cart = section.Rows.Select(r => StatusCart(r, imageUrl, currency) with
 291                {
 292                    Id = Id(r.LocalCartItemId), AddedAt = r.AddedAt, Warehouse = Blank(r.ApiRegion), Period = r.ApiPerio
 293                }).ToList(),
 294                Orders = section.MoskvorechieIstraOrders.Select(o => new SupplierCartGridOrderRow
 295                {
 296                    Id = o.Id.ToString(CultureInfo.InvariantCulture),
 297                    Number = o.Number ?? $"API #{o.Id}",
 298                    CreatedAt = Local(o.CreatedAt),
 299                    Status = o.Status,
 300                    StatusTone = ToneSuccess,
 301                    ItemsCount = o.ItemsCount,
 302                    Amount = o.Amount,
 303                    Currency = currency
 304                }).ToList(),
 305                OrderCount = section.MoskvorechieIstraOrders.Count
 306            },
 307            "motex" => payload with
 308            {
 309                Cart = section.Rows.Select(r => StatusCart(r, imageUrl, currency) with
 310                {
 311                    Id = Id(r.LocalCartItemId), Warehouse = Blank(r.ApiRegion)
 312                }).ToList(),
 313                Orders = section.MotexOrders.Select(o => new SupplierCartGridOrderRow
 314                {
 315                    Id = o.Id.ToString(CultureInfo.InvariantCulture),
 316                    Number = o.ExternalId ?? "—",
 317                    NumberMeta = o.ClientOrderNum,
 318                    CreatedAt = Local(o.CreatedAt),
 319                    Status = o.Status,
 320                    StatusTone = OrderStatusTone(o.Status),
 321                    ItemsCount = o.ItemsCount,
 322                    Amount = o.Amount,
 323                    Currency = currency
 324                }).ToList(),
 325                OrderCount = section.MotexOrders.Count
 326            },
 327            "shatem" => payload with
 328            {
 329                Cart = section.Rows.Select(r => StatusCart(r, imageUrl, currency) with
 330                {
 331                    Id = r.ApiItemId, Warehouse = Blank(r.ApiRegion), LocationCode = Blank(r.ApiRegion)
 332                }).ToList(),
 333                Orders = section.ShateMOrders.Select(o => new SupplierCartGridOrderRow
 334                {
 335                    Id = o.Id.ToString(CultureInfo.InvariantCulture),
 336                    Number = o.ExternalId ?? "—",
 337                    CreatedAt = Local(o.CreatedAt),
 338                    Status = o.Status,
 339                    StatusTone = OrderStatusTone(o.Status),
 340                    ItemsCount = o.ItemsCount,
 341                    Amount = o.Amount,
 342                    Currency = currency
 343                }).ToList(),
 344                OrderCount = section.ShateMOrders.Count
 345            },
 346            "profitliga" => payload with
 347            {
 348                Cart = section.Rows.Select(r => StatusCart(r, imageUrl, currency) with
 349                {
 350                    Id = Id(r.LocalCartItemId), Warehouse = Blank(r.ApiRegion), Point = Blank(r.ProfitLigaPointName),
 351                    Comment = Blank(r.ApiComment), CommentTone = ToneDanger
 352                }).ToList(),
 353                Orders = section.ProfitLigaOrders.Select(ProfitLigaOrder(currency)).ToList(),
 354                OrderCount = section.ProfitLigaOrders.Count
 355            },
 356            "nikei" => Nikei(payload, section, imageUrl, currency),
 357            "mikadomskhod20" => payload with
 358            {
 359                Cart = section.Rows.Select(r => StatusCart(r, imageUrl, currency) with
 360                {
 361                    Id = Id(r.LocalCartItemId), Warehouse = Blank(r.ApiRegion),
 362                    Comment = Blank(r.ApiComment), CommentTone = ToneDanger
 363                }).ToList()
 364            },
 365            "zzapmoscow" => ZZap(payload, section, imageUrl, currency),
 366            "japarts" => payload with
 367            {
 368                Cart = section.Rows.Select(r => Cart(r, imageUrl, currency) with
 369                {
 370                    Id = r.ApiItemId,
 371                    Period = r.ApiPeriod,
 372                    Warehouse = Blank(r.ApiRegion),
 373                    // Japarts не хранит связь в Studio2: без номера отправления показываем комментарий позиции.
 374                    MissingOrderHint = Blank(r.ApiComment) ?? DefaultMissingHint
 375                }).ToList(),
 376                Orders = section.JapartsOrders.Select(o => new SupplierCartGridOrderRow
 377                {
 378                    Id = $"{o.GlobalId}|{o.OrderNumber}|{o.Article}",
 379                    Number = o.OrderNumber,
 380                    NumberMeta = Blank(o.GlobalId),
 381                    Product = Blank(o.ProductName),
 382                    Article = o.Article,
 383                    Brand = o.Brand,
 384                    Quantity = o.QuantityOrdered,
 385                    QuantityText = $"{Qty(o.QuantityOrdered)} / {Qty(o.QuantityBought)}",
 386                    Price = o.Price,
 387                    Status = o.Status,
 388                    StatusTone = OrderStatusTone(o.Status),
 389                    UpdatedAt = o.LastUpdated,
 390                    Comment = Blank(o.Comment),
 391                    Currency = currency
 392                }).ToList(),
 393                OrderCount = section.JapartsOrders.Count
 394            },
 395            "berg" => payload with
 396            {
 397                Cart = section.Rows.Select(r => Cart(r, imageUrl, currency) with
 398                {
 399                    Id = Id(r.LocalCartItemId),
 400                    AddedAt = r.AddedAt?.ToLocalTime(),
 401                    Warehouse = Blank(r.ApiSupplierName),
 402                    Comment = Blank(r.ApiComment),
 403                    CommentTone = ToneDanger
 404                }).ToList(),
 405                Orders = section.BergOrders.SelectMany(o => o.Items.Select(line => new SupplierCartGridOrderRow
 406                {
 407                    Id = $"{o.Id}-{line.Sequence}",
 408                    Number = o.Id.ToString(CultureInfo.InvariantCulture),
 409                    IsTest = o.IsTest,
 410                    CreatedAt = o.CreatedAt?.ToLocalTime(),
 411                    Product = Blank(line.Name),
 412                    Article = line.Article,
 413                    Brand = line.Brand,
 414                    Quantity = line.Quantity,
 415                    Price = line.Price,
 416                    Amount = line.Price.HasValue && line.Quantity.HasValue ? line.Price * line.Quantity : null,
 417                    Status = line.StateName,
 418                    StatusTone = OrderStatusTone(line.StateName),
 419                    Supplier = Blank(line.WarehouseName),
 420                    StudioOrders = Studio(line.StudioOrderId, line.StudioOrderNumber, null),
 421                    Currency = currency
 422                })).ToList(),
 423                OrderCount = section.BergOrders.Count
 424            },
 425            _ => payload
 426        };
 427
 428        // У гибридных корзин ошибка секции — предупреждение о строках поставщика, а не отказ API: корзина Studio2 работ
 429        return route is "profitliga" or "nikei" or "zzapmoscow" && result.Error is not null
 430            ? result with { Error = null, CartNotices = result.CartNotices.Prepend(new SupplierCartGridNotice(ToneWarnin
 431            : result;
 432    }
 433
 434    // ---------- Корзина ----------
 435
 436    /// <summary>Общие ячейки строки корзины (бывший партиал _CartItemCells): картинка, товар, заказ Studio2.</summary>
 437    private static SupplierCartGridCartRow Cart(SupplierCartRow r, Func<string, string> imageUrl, string currency) => ne
 438    {
 439        ImageUrl = string.IsNullOrWhiteSpace(r.StudioProductImageUrl) ? null : imageUrl(r.StudioProductImageUrl),
 440        Product = FirstNonEmpty(r.StudioProductName, r.ApiItemName) ?? string.Empty,
 441        Article = FirstNonEmpty(r.StudioArticle, r.ApiItemNumber),
 442        Brand = FirstNonEmpty(r.StudioManufacturer, r.ApiCatalogName),
 443        StudioOrder = r.StudioOrderId is int id ? new SupplierCartStudioOrderCell(id, r.StudioOrderNumber ?? id.ToString
 444        MissingOrderHint = DefaultMissingHint,
 445        Selectable = true,
 446        Quantity = r.ApiQuantity,
 447        Price = r.ApiPrice,
 448        Sum = r.Sum,
 449        Currency = currency
 450    };
 451
 452    /// <summary>Строка гибридной корзины: статус позиции Studio2 хранится в ApiSupplierName.</summary>
 453    private static SupplierCartGridCartRow StatusCart(SupplierCartRow r, Func<string, string> imageUrl, string currency)
 454        Cart(r, imageUrl, currency) with
 455        {
 456            Status = r.ApiSupplierName,
 457            StatusName = CartStatusName(r.ApiSupplierName),
 458            StatusTone = CartStatusTone(r.ApiSupplierName)
 459        };
 460
 461    private static SupplierCartGridPayload Armtek(SupplierCartGridPayload payload, SupplierCartSection section, Func<str
 462    {
 463        var options = section.ArmtekDeliveryOptions.Select(o => new SupplierCartGridOption(o.Value, o.DisplayName)).ToLi
 464        var known = options.Select(o => o.Value).ToHashSet(StringComparer.Ordinal);
 465        var extra = new List<SupplierCartGridOption>();
 466        var cart = section.Rows.Select(r =>
 467        {
 468            var value = string.IsNullOrWhiteSpace(r.ArmtekBuyerKunnr) || string.IsNullOrWhiteSpace(r.ArmtekDeliveryAddre
 469                ? null
 470                : $"{r.ArmtekBuyerKunnr}|{r.ArmtekDeliveryAddressKunnr}";
 471            var name = r.ArmtekDeliveryAddressName ?? r.ArmtekDeliveryAddressKunnr;
 472            // Адрес строки, которого нет в справочнике покупателей, остаётся выбранным — как отдельный вариант списка.
 473            if (value is not null && !known.Contains(value) && extra.All(x => x.Value != value))
 474                extra.Add(new SupplierCartGridOption(value, $"{name} (нет в справочнике)"));
 475            return StatusCart(r, imageUrl, currency) with
 476            {
 477                Id = Id(r.LocalCartItemId),
 478                AddedAt = r.AddedAt,
 479                Warehouse = Blank(r.ApiRegion),
 480                WarehouseMeta = Blank($"{r.ApiCatalogName} {r.ApiItemNumber}".Trim()),
 481                Period = r.ApiPeriod,
 482                MissingOrderHint = UnlinkedMissingHint,
 483                DeliveryValue = value,
 484                DeliveryName = value is null ? null : $"{r.ArmtekBuyerKunnr} · {name}",
 485                DeliveryLinked = r.ArmtekDeliveryMappingLinked,
 486                CanRequote = string.Equals(r.ApiSupplierName, ArmtekCartItemStatus.Failed, StringComparison.OrdinalIgnor
 487                StatusTitle = Blank(r.ApiComment)
 488            };
 489        }).ToList();
 490
 491        var notices = new List<SupplierCartGridNotice>();
 492        if (!string.IsNullOrWhiteSpace(section.ArmtekDeliveryOptionsError))
 493            notices.Add(new SupplierCartGridNotice(ToneWarning, section.ArmtekDeliveryOptionsError));
 494        if (cart.Any(r => r.DeliveryValue is null))
 495            notices.Add(new SupplierCartGridNotice(ToneWarning,
 496                "Для части позиций не выбран клиент и адрес доставки Armtek — оформление таких строк заблокировано. Выбе
 497                "настройках API-поставщика", section.ProviderKey == "ArmtekBy" ? "ArmtekBy" : "Armtek"));
 498
 499        return payload with
 500        {
 501            Cart = cart,
 502            CartNotices = notices,
 503            DeliveryOptions = extra.Concat(options).ToList(),
 504            Orders = section.ArmtekApiOrders.Select(o => new SupplierCartGridOrderRow
 505            {
 506                Id = $"{o.OrderNumber}|{o.BuyerCode}",
 507                Number = o.OrderNumber,
 508                CreatedAt = ParseRu(o.OrderDate),
 509                CreatedText = o.OrderDate,
 510                Buyer = Blank(o.BuyerName),
 511                BuyerCode = Blank(o.BuyerCode),
 512                OrderType = Blank(o.OrderType),
 513                Status = o.Status,
 514                StatusTone = OrderStatusTone(o.Status),
 515                Amount = o.Amount,
 516                Currency = string.IsNullOrWhiteSpace(o.Currency) ? currency : o.Currency,
 517                Delivery = Blank(o.DeliveryMethod),
 518                DeliveryMeta = Blank($"{o.DeliveryFrom} — {o.DeliveryTo}".Trim(' ', '—')),
 519                Invoice = Blank(o.Invoice),
 520                Contract = Blank(o.Contract),
 521                Credit = Blank(o.CreditStatus),
 522                MinSum = Blank(o.MinSumStatus),
 523                Comment = Blank(o.Comment) ?? Blank(o.ExpeditionComment),
 524                StudioOrders = Links(section, o.OrderNumber)
 525            }).ToList(),
 526            OrderCount = section.ArmtekApiOrders.Count
 527        };
 528    }
 529
 530    private static SupplierCartGridPayload MlAuto(SupplierCartGridPayload payload, SupplierCartSection section, Func<str
 531    {
 532        var cart = section.Rows.Select(r => StatusCart(r, imageUrl, currency) with
 533        {
 534            Id = Id(r.LocalCartItemId),
 535            Warehouse = Blank(r.ApiRegion),
 536            WarehouseMeta = $"Склад отгрузки: {r.MlAutoShipmentWarehouseName ?? "не указан"}",
 537            Period = r.ApiPeriod,
 538            MissingOrderHint = UnlinkedMissingHint,
 539            DeliveryValue = Blank(r.MlAutoDeliveryAddressId),
 540            DeliveryName = Blank(r.MlAutoDeliveryAddressName),
 541            DeliveryLinked = r.MlAutoDeliveryMappingLinked,
 542            CanRequote = isBy && string.Equals(r.ApiSupplierName, MlAutoCartItemStatus.Failed, StringComparison.OrdinalI
 543        }).ToList();
 544
 545        var cartNotices = new List<SupplierCartGridNotice>();
 546        if (section.Error is not null) cartNotices.Add(new SupplierCartGridNotice(ToneWarning, section.Error));
 547        if (isBy && section.Rows.Any(x => !x.MlAutoDeliveryMappingLinked && string.IsNullOrWhiteSpace(x.MlAutoDeliveryAd
 548            cartNotices.Add(new SupplierCartGridNotice(ToneWarning,
 549                "Для части позиций склад отгрузки Studio2 не связан с адресом ML-Auto. Выберите адрес вручную в колонке 
 550                "настройках ML-Auto", "MlAuto"));
 551
 552        var orderNotices = new List<SupplierCartGridNotice>();
 553        if (isBy && section.MlAutoApiOrders.Count >= 60)
 554            orderNotices.Add(new SupplierCartGridNotice(ToneInfo, "Показаны 60 самых свежих заказов периода. Сузьте диап
 555
 556        var localByNumber = section.MlAutoOrders.ToLookup(x => x.OrderNumber, StringComparer.Ordinal);
 557        var orders = section.MlAutoApiOrders.Select(o =>
 558        {
 559            var local = localByNumber[o.Number].FirstOrDefault();
 560            var notes = o.DetailsError is null ? null : new[] { new SupplierCartGridNotice(ToneDanger, "Состав не загруж
 561            return new SupplierCartGridOrderRow
 562            {
 563                Id = o.Number,
 564                Number = o.Number,
 565                NumberMeta = local is null ? null : $"Studio2: {local.DeliveryAddressName}",
 566                CreatedAt = o.CreatedAt,
 567                Status = o.Status,
 568                StatusTone = OrderStatusTone(o.Status),
 569                StatusNotes = notes,
 570                Payment = o.PaymentType == "—" ? null : o.PaymentType,
 571                ItemsCount = o.ItemsCount,
 572                Quantity = o.Quantity,
 573                Amount = o.Amount,
 574                Currency = currency,
 575                Delivery = o.DeliveryRange == "—" ? null : o.DeliveryRange,
 576                Comment = Blank(o.Comment),
 577                Source = local is null ? SourceApi : SourceStudio2,
 578                Brands = string.Join(", ", o.Items.Select(x => x.Brand).Where(x => !string.IsNullOrWhiteSpace(x)).Distin
 579                StudioOrders = Links(section, o.Number),
 580                Items = o.Items.Select(i => new SupplierCartGridOrderItem
 581                {
 582                    Article = i.Pin,
 583                    Brand = i.Brand,
 584                    Quantity = i.Quantity,
 585                    Price = i.Price,
 586                    Sum = i.Sum,
 587                    Status = i.Status,
 588                    StatusTone = OrderStatusTone(i.Status),
 589                    StatusTitle = $"Код статуса ML-Auto: {i.StatusCode}",
 590                    DeliveryAt = i.EstimatedDelivery,
 591                    Comment = Blank(i.Comment),
 592                    Rejected = i.Rejected
 593                }).ToList()
 594            };
 595        }).ToList();
 596
 597        return payload with
 598        {
 599            Error = null,
 600            Cart = cart,
 601            CartNotices = cartNotices,
 602            OrderNotices = orderNotices,
 603            DeliveryOptions = section.MlAutoDeliveryOptions.Select(o => new SupplierCartGridOption(o.Id, o.DisplayName))
 604            Orders = orders,
 605            OrderCount = section.MlAutoApiOrders.Count
 606        };
 607    }
 608
 609    private static SupplierCartGridPayload Nikei(SupplierCartGridPayload payload, SupplierCartSection section, Func<stri
 610    {
 611        var index = 0;
 612        var cart = section.Rows.Select(r =>
 613        {
 614            index++;
 615            var apiOnly = r.LocalCartItemId is null;
 616            var row = StatusCart(r, imageUrl, currency) with
 617            {
 618                // Строки, добавленные в корзину Nikei вне Studio2, нельзя ни выбрать, ни оформить.
 619                Id = apiOnly ? $"api-{index}" : Id(r.LocalCartItemId),
 620                Selectable = !apiOnly,
 621                ApiOnly = apiOnly,
 622                Warehouse = Blank(r.ApiRegion),
 623                Period = r.ApiPeriod,
 624                Comment = Blank(r.ApiComment),
 625                CommentTone = apiOnly ? ToneMuted : ToneDanger
 626            };
 627            return apiOnly
 628                ? row with { Status = "ApiOnly", StatusName = "Только Nikei", StatusTone = ToneMuted, MissingOrderHint =
 629                : row;
 630        }).ToList();
 631
 632        return payload with
 633        {
 634            Cart = cart,
 635            Orders = section.NikeiOrders.Select((o, i) => new SupplierCartGridOrderRow
 636            {
 637                Id = $"{o.Invoice}-{o.Id?.ToString(CultureInfo.InvariantCulture) ?? i.ToString(CultureInfo.InvariantCult
 638                Number = o.Invoice,
 639                CreatedAt = o.CreatedAt,
 640                Product = Blank(o.ProductName),
 641                Article = o.Article,
 642                Brand = o.Brand,
 643                Quantity = o.Quantity,
 644                Price = o.Price,
 645                Delivery = Blank(o.ShippingName),
 646                DeliveryMeta = Blank(o.ShippingAddress),
 647                Status = o.Status,
 648                StatusTone = OrderStatusTone(o.Status),
 649                StudioOrders = Studio(o.StudioOrderId, o.StudioOrderNumber, null),
 650                Currency = currency
 651            }).ToList(),
 652            OrderCount = section.NikeiOrders.Select(x => x.Invoice).Distinct().Count()
 653        };
 654    }
 655
 656    private static SupplierCartGridPayload ZZap(SupplierCartGridPayload payload, SupplierCartSection section, Func<strin
 657    {
 658        var cart = section.Rows.Select(r =>
 659        {
 660            var row = StatusCart(r, imageUrl, currency) with
 661            {
 662                Id = r.ZZapCodeOrder?.ToString(CultureInfo.InvariantCulture) ?? string.Empty,
 663                Selectable = r.ZZapCodeOrder.HasValue,
 664                Warehouse = Blank(r.ApiRegion),
 665                ZZapCode = r.ZZapCodeOrder,
 666                InZZap = r.ZZapInApiCart,
 667                ZZapInfo = r.ZZapInApiCart == true ? r.ZZapApiDeliveryText ?? "Позиция есть в корзине ZZap" : null,
 668                Period = r.ApiPeriod,
 669                MissingOrderHint = ZZapMissingHint,
 670                Comment = Blank(r.ApiComment),
 671                CommentTone = ToneDanger
 672            };
 673            return r.ApiSupplierName == "ApiOnly"
 674                ? row with
 675                {
 676                    StatusName = "Только в ZZap",
 677                    StatusTone = ToneMuted,
 678                    StatusTitle = "Позиция добавлена в корзину на сайте ZZap; проверка и оформление из Studio2 недоступн
 679                    ApiOnly = true
 680                }
 681                : row;
 682        }).ToList();
 683
 684        var orderNotices = section.OrdersError is null
 685            ? Array.Empty<SupplierCartGridNotice>()
 686            : new[] { new SupplierCartGridNotice(ToneWarning, $"ZZap не ответил, показаны только заказы Studio2: {sectio
 687
 688        return payload with
 689        {
 690            Cart = cart,
 691            OrdersError = null,
 692            OrderNotices = orderNotices,
 693            Orders = section.ZZapOrders.Select(o => new SupplierCartGridOrderRow
 694            {
 695                Id = o.CodeOrder.ToString(CultureInfo.InvariantCulture),
 696                Number = o.CodeOrder.ToString(CultureInfo.InvariantCulture),
 697                CreatedAt = o.CreatedAt,
 698                Product = Blank(o.ProductName),
 699                Article = o.Article,
 700                Brand = o.Brand,
 701                Supplier = Blank(o.SellerName),
 702                SupplierMeta = Blank(o.SellerPhone),
 703                QuantityText = o.Quantity,
 704                Price = o.Price,
 705                Status = o.Status,
 706                StatusTone = OrderStatusTone(o.Status),
 707                Comment = Blank(o.Message),
 708                UpdatedAt = o.StatusChangedAt,
 709                StudioOrders = Studio(o.StudioOrderId, o.StudioOrderNumber, o.StudioClientName),
 710                CanCancel = o.CanCancel,
 711                Currency = currency
 712            }).ToList(),
 713            OrderCount = section.ZZapOrders.Count
 714        };
 715    }
 716
 717    // ---------- Заказы ----------
 718
 719    /// <summary>Заказы АВД: строка — позиция заказа; итог, счёт и пользователь заказа — в каждой его строке (группировк
 720    private static IReadOnlyList<SupplierCartGridOrderRow> AvdOrders(SupplierCartSection section) =>
 721        section.Orders.SelectMany(order =>
 722        {
 723            var user = order.Items.Select(item => item.User).FirstOrDefault(u => !string.IsNullOrWhiteSpace(u));
 724            return order.Items.Select((item, index) =>
 725            {
 726                var price = item.Price ?? item.DealerPrice;
 727                var status = string.IsNullOrWhiteSpace(item.Status) ? "Не указан" : item.Status;
 728                return new SupplierCartGridOrderRow
 729                {
 730                    Id = $"{order.OrderId}-{(string.IsNullOrEmpty(item.Id) ? index.ToString(CultureInfo.InvariantCulture
 731                    Number = order.OrderNumber,
 732                    NumberMeta = Blank(order.OrderInfo),
 733                    CreatedAt = ParseRu(order.OrderDate),
 734                    CreatedText = order.OrderDate,
 735                    Product = Blank(item.ItemName),
 736                    Article = Blank(item.ItemNumber),
 737                    Brand = Blank(item.CatalogName),
 738                    Supplier = Blank(item.SupplierName),
 739                    Price = price,
 740                    Quantity = item.Quantity,
 741                    Amount = price.HasValue && item.Quantity.HasValue ? price * item.Quantity : null,
 742                    Status = status,
 743                    StatusTone = string.IsNullOrWhiteSpace(item.Status) ? ToneMuted : OrderStatusTone(item.Status),
 744                    UpdatedAt = ParseRu(item.StatusDate),
 745                    UpdatedText = Blank(item.StatusDate),
 746                    OrderTotal = order.Summa,
 747                    OrderApiId = order.OrderId,
 748                    Invoice = Blank(order.SchetNumber),
 749                    User = user,
 750                    ItemsCount = order.Items.Count,
 751                    Currency = "RUB"
 752                };
 753            });
 754        }).ToList();
 755
 756    private static IReadOnlyList<SupplierCartGridOrderRow> ForumAutoOrders(SupplierCartSection section, string currency)
 757        section.ForumAutoApiOrders.Select(o =>
 758        {
 759            var key = o.Did.ToString(CultureInfo.InvariantCulture);
 760            return new SupplierCartGridOrderRow
 761            {
 762                Id = key,
 763                Number = $"№{o.Did}",
 764                CreatedAt = o.CreatedAt,
 765                Status = o.Status,
 766                StatusTone = OrderStatusTone(o.Status),
 767                StatusNotes = o.DetailsError is null ? null : new[] { new SupplierCartGridNotice(ToneDanger, "Ошибка заг
 768                Delivery = Blank(o.DeliveryAt),
 769                ItemsCount = o.Items.Count,
 770                StudioOrders = Links(section, key),
 771                Brands = string.Join(", ", o.Items.Select(x => x.Brand).Where(x => !string.IsNullOrWhiteSpace(x)).Distin
 772                Currency = currency,
 773                Items = o.Items.Select(i => new SupplierCartGridOrderItem
 774                {
 775                    Article = i.Article,
 776                    Brand = i.Brand,
 777                    Quantity = i.Ordered,
 778                    Reserved = i.Reserved,
 779                    Confirmed = i.Confirmed,
 780                    Price = i.Price,
 781                    Country = Blank(i.Country)
 782                }).ToList()
 783            };
 784        }).ToList();
 785
 786    private static IReadOnlyList<SupplierCartGridOrderRow> FavoritPartsOrders(SupplierCartSection section, string curren
 787        section.FavoritPartsApiOrders.Select(o =>
 788        {
 789            var notes = new List<SupplierCartGridNotice>();
 790            if (o.DetailsError is not null) notes.Add(new SupplierCartGridNotice(ToneDanger, "Ошибка загрузки", o.Detail
 791            if (!string.IsNullOrWhiteSpace(o.PostError)) notes.Add(new SupplierCartGridNotice(ToneDanger, "Не проведён у
 792            var cancelled = o.Items.Sum(x => x.CountCancel);
 793            if (cancelled > 0) notes.Add(new SupplierCartGridNotice(ToneWarning, $"Снято: {Qty(cancelled)} шт."));
 794            return new SupplierCartGridOrderRow
 795            {
 796                Id = o.Id.ToString(),
 797                Number = string.IsNullOrWhiteSpace(o.Number) ? $"№{o.Id}" : $"№{o.Number}",
 798                CreatedAt = o.CreatedAt?.ToLocalTime(),
 799                Status = o.Status,
 800                StatusTone = OrderStatusTone(o.Status),
 801                StatusNotes = notes.Count == 0 ? null : notes,
 802                Delivery = o.ShippingDate?.ToString("dd.MM.yyyy", CultureInfo.InvariantCulture),
 803                ItemsCount = o.Items.Count,
 804                Amount = o.Sum,
 805                Point = Blank(o.TradePointName),
 806                StudioOrders = Links(section, o.Id.ToString()),
 807                Brands = string.Join(", ", o.Items.Select(x => x.Brand).Where(x => !string.IsNullOrWhiteSpace(x)).Distin
 808                Currency = currency,
 809                Items = o.Items.Select(i => new SupplierCartGridOrderItem
 810                {
 811                    Article = i.Number,
 812                    Brand = i.Brand,
 813                    Quantity = i.Count,
 814                    Cancelled = i.CountCancel > 0 ? i.CountCancel : null,
 815                    Price = i.Price
 816                }).ToList()
 817            };
 818        }).ToList();
 819
 820    private static Func<ProfitLigaApiOrderRow, SupplierCartGridOrderRow> ProfitLigaOrder(string currency) => o =>
 821    {
 822        // Профит-Лига возвращает служебный код «Created» вместо русского статуса.
 823        var status = string.Equals(o.Status, "Created", StringComparison.OrdinalIgnoreCase) ? "Создан" : o.Status;
 824        return new SupplierCartGridOrderRow
 825        {
 826            Id = o.Id.ToString(CultureInfo.InvariantCulture),
 827            Number = o.OrderNumber,
 828            CreatedAt = Local(o.CreatedAt),
 829            Point = Blank(o.PointName),
 830            Delivery = Blank(o.DeliveryDate),
 831            Status = status,
 832            StatusTone = OrderStatusTone(status),
 833            ItemsCount = o.ItemsCount,
 834            Amount = o.Amount,
 835            Currency = currency,
 836            Items = o.Items.Select(i => new SupplierCartGridOrderItem
 837            {
 838                Article = i.Article,
 839                Brand = i.Brand,
 840                Name = Blank(i.ProductName),
 841                Quantity = i.Quantity,
 842                Price = i.Price,
 843                Sum = i.Price * i.Quantity,
 844                Status = Blank(i.Status),
 845                StatusTone = OrderStatusTone(i.Status),
 846                Warehouse = Blank(i.WarehouseName),
 847                DeliveryText = Blank(i.ShowDate)
 848            }).ToList()
 849        };
 850    };
 851
 852    // ---------- Утилиты ----------
 853
 854    private static IReadOnlyList<SupplierCartStudioOrderCell> Links(SupplierCartSection section, string supplierOrder) =
 855        section.StudioOrderLinksBySupplierOrder.TryGetValue(supplierOrder, out var links)
 856            ? links.Select(l => new SupplierCartStudioOrderCell(l.OrderId, l.ShipmentNumber ?? l.OrderId.ToString(Cultur
 857            : Array.Empty<SupplierCartStudioOrderCell>();
 858
 859    private static IReadOnlyList<SupplierCartStudioOrderCell> Studio(int? id, string? number, string? client) =>
 860        id is int value
 861            ? new[] { new SupplierCartStudioOrderCell(value, number ?? value.ToString(CultureInfo.InvariantCulture), cli
 862            : Array.Empty<SupplierCartStudioOrderCell>();
 863
 864    private static string Id(long? value) => value?.ToString(CultureInfo.InvariantCulture) ?? string.Empty;
 865
 866    private static string? Blank(string? value) => string.IsNullOrWhiteSpace(value) ? null : value.Trim();
 867
 868    private static string? FirstNonEmpty(params string?[] values) => values.Select(Blank).FirstOrDefault(v => v is not n
 869
 870    private static string Qty(decimal value) => value.ToString("0.##", CultureInfo.GetCultureInfo("ru-RU"));
 871
 872    /// <summary>Время API, сохранённое в UTC, — к локальному времени сервера (как раньше показывала страница).</summary
 873    private static DateTime Local(DateTime value) => DateTime.SpecifyKind(value.ToLocalTime(), DateTimeKind.Unspecified)
 874
 875    /// <summary>Даты АВД и Armtek приходят строкой; для сортировки и фильтра по дате разбираем её, текст остаётся как е
 876    public static DateTime? ParseRu(string? value)
 877    {
 878        if (string.IsNullOrWhiteSpace(value)) return null;
 879        var ru = CultureInfo.GetCultureInfo("ru-RU");
 880        string[] formats = ["dd.MM.yyyy HH:mm:ss", "dd.MM.yyyy HH:mm", "dd.MM.yyyy", "yyyy-MM-dd HH:mm:ss", "yyyy-MM-ddT
 881        if (DateTime.TryParseExact(value.Trim(), formats, ru, DateTimeStyles.None, out var exact)) return exact;
 882        return DateTime.TryParse(value, ru, DateTimeStyles.None, out var parsed) ? parsed : null;
 883    }
 884}