< Summary

Information
Class: OzonOrdersWeb.WebServices.OrderHistory.OrderHistoryGridRows
Assembly: OzonOrdersWeb
File(s): /home/runner/work/Studio2Prod/Studio2Prod/OzonOrdersWeb/WebServices/OrderHistory/OrderHistoryGridRows.cs
Line coverage
51%
Covered lines: 119
Uncovered lines: 112
Coverable lines: 231
Total lines: 359
Line coverage: 51.5%
Branch coverage
30%
Covered branches: 80
Total branches: 260
Branch coverage: 30.7%
Method coverage

Feature is only available for sponsors

Upgrade to PRO version

Metrics

MethodBranch coverage Crap Score Cyclomatic complexity Line coverage
BuildChanges(...)72%5050100%
DescribeChange(...)10.81%34507414.89%
OneCPostingsForGroup(...)87.5%88100%
MinTimeDiffMinutes(...)100%11100%
NullIfEmpty(...)0%620%
BasketSupplierName(...)0%2040%
DescribeBasketStatus(...)0%110100%
DescribeVerificationStatus(...)0%4260%
SupplierProviderName(...)0%1406370%
SupplierProviderLogoUrl(...)54.05%2593745.45%
SupplierStageName(...)0%4260%
SupplierSourceName(...)0%4260%
DescribeHandoverStatus(...)90%101085.71%
HandoverDetails(...)0%7280%
Add()0%620%

File(s)

/home/runner/work/Studio2Prod/Studio2Prod/OzonOrdersWeb/WebServices/OrderHistory/OrderHistoryGridRows.cs

#LineLine coverage
 1using OzonDomains.Models;
 2using OzonOrdersWeb.Areas.Studio2.ViewModels.OrderHistory;
 3using OzonOrdersWeb.TransactionServices;
 4using Services.OneCServices;
 5using Services.SupplierStatusServices;
 6
 7namespace OzonOrdersWeb.WebServices.OrderHistory;
 8
 9/// <summary>Смена статуса у API-поставщика в строке истории (цвета статусов — данные справочника).</summary>
 10public sealed record OrderHistorySupplierStatusCell(
 11    string ProviderName, string? LogoUrl, string? FromName, string? FromColor, string ToName, string? ToColor,
 12    string? Stage, string? Source, string? Comment);
 13
 14/// <summary>Размещение позиции в корзине API-поставщика в строке истории.</summary>
 15public sealed record OrderHistoryBasketCell(
 16    string Tone, string Icon, string Text, string? LogoUrl, string? SupplierOrderNumber, string? ApiItemId,
 17    string? Product, string? Article, string? Quantity, string? ShipmentNumber, string? ClientName,
 18    string? WarehouseName, string? Buyer, string? DeliveryAddress, string? Error, string? CartUrl);
 19
 20/// <summary>Системная проверка заказа у поставщика (повторное чтение API после проведения) в строке истории.</summary>
 21public sealed record OrderHistoryVerificationCell(
 22    string Tone, string Icon, string Title, string ProviderName, string? LogoUrl, string? Stage, string Summary,
 23    string? SupplierOrderNumber, string? SupplierStatus, IReadOnlyList<string> Checks, DateTime? VerifiedAt);
 24
 25/// <summary>Событие создания или выдачи PDF-этикетки в общей ленте заказа.</summary>
 26public sealed record OrderHistoryLabelPrintCell(string FileName, string Source);
 27
 28/// <summary>
 29/// Строка грида «История изменений»: одно изменённое поле. Строки одной операции (OperationId, минута, автор)
 30/// имеют общий <see cref="OpKey"/> — по нему грид объединяет ячейки операции (rowSpan).
 31/// </summary>
 32public sealed record OrderHistoryChangeRow(
 33    int Id,
 34    DateTime ChangedAt,
 35    string OpKey,
 36    int OpIndex,
 37    DateTime OpTime,
 38    string User,
 39    string? UserName,
 40    string? UserAvatar,
 41    string UserKind,
 42    string Field,
 43    string Kind,
 44    string? Old,
 45    string? New,
 46    OrderHistorySupplierStatusCell? SupplierStatus,
 47    OrderHistoryBasketCell? Basket,
 48    OrderHistoryVerificationCell? Verification,
 49    long? SnapshotId,
 50    int OneCCount,
 51    bool OneCOk,
 52    long PointTicks,
 53    string SearchText,
 54    OrderHistoryLabelPrintCell? LabelPrint = null);
 55
 56/// <summary>Операция истории: проведения 1С, показанные по кнопке в строке (разметка — партиал заголовка 1С).</summary>
 57public sealed record OrderHistoryOperation(int Index, string OpKey, IReadOnlyList<OrderHistoryOneCPostingViewModel> OneC
 58
 59public sealed record OrderHistoryChangeGrid(IReadOnlyList<OrderHistoryChangeRow> Rows, IReadOnlyList<OrderHistoryOperati
 60
 61/// <summary>
 62/// Строки вкладок истории заказа для гридов AG Grid. Раньше эти вычисления жили в GetOrderHistory.cshtml —
 63/// теперь по ним работают фильтры и сортировка грида.
 64/// </summary>
 65public static class OrderHistoryGridRows
 66{
 67    public const string UserKindUser = "user";
 68    public const string UserKindSystem = "system";
 69    public const string UserKindRollback = "rollback";
 70
 71    public static OrderHistoryChangeGrid BuildChanges(OrderHistoryViewModel model)
 72    {
 473        var rows = new List<OrderHistoryChangeRow>();
 474        var operations = new List<OrderHistoryOperation>();
 875        var all = model.Histories.Values.SelectMany(x => x).ToList();
 76
 477        var groups = all
 578            .GroupBy(h => new
 579            {
 580                OperationId = h.OperationId?.ToString(),
 581                RoundedTime = new DateTime(h.ChangedAt.Ticks - h.ChangedAt.Ticks % TimeSpan.TicksPerMinute, h.ChangedAt.
 582                h.ChangedBy
 583            })
 984            .OrderByDescending(g => g.Max(h => h.ChangedAt))
 485            .ToList();
 86
 1687        foreach (var group in groups)
 88        {
 989            var changes = group.OrderBy(h => h.ColumnDisplayName ?? string.Empty).ToList();
 990            var opTime = group.Max(h => h.ChangedAt);
 491            var opIndex = operations.Count;
 492            var opKey = $"{group.Key.OperationId}|{group.Key.RoundedTime.Ticks}|{group.Key.ChangedBy}";
 493            var changedBy = group.Key.ChangedBy ?? string.Empty;
 494            var isRollback = changedBy.StartsWith("rollback:", StringComparison.OrdinalIgnoreCase);
 495            var displayUser = isRollback ? changedBy["rollback:".Length..] : changedBy;
 496            var isSystem = !isRollback && (string.IsNullOrWhiteSpace(changedBy)
 497                || changedBy.Contains("system", StringComparison.OrdinalIgnoreCase)
 498                || changedBy.Contains("auto", StringComparison.OrdinalIgnoreCase)
 499                || changedBy.Contains("robot", StringComparison.OrdinalIgnoreCase));
 4100            model.UserProfiles.TryGetValue(displayUser, out var profile);
 4101            var userName = profile?.DisplayName is { } name && !string.Equals(name, profile.LoginOrEmail, StringComparis
 4102            var login = profile?.LoginOrEmail ?? displayUser;
 103
 4104            var postings = OneCPostingsForGroup(model, changes, group.Key.ChangedBy, opTime);
 4105            operations.Add(new OrderHistoryOperation(opIndex, opKey, postings));
 4106            long? snapshotId = group.Key.OperationId is { } opId && model.CalculationSnapshotIdsByOperationId.TryGetValu
 107
 18108            foreach (var change in changes)
 109            {
 5110                var (kind, field, oldValue, newValue, status, basket, verification, filterText) = DescribeChange(model, 
 5111                rows.Add(new OrderHistoryChangeRow(
 5112                    change.Id,
 5113                    change.ChangedAt,
 5114                    opKey,
 5115                    opIndex,
 5116                    opTime,
 5117                    string.IsNullOrWhiteSpace(login) ? "Неизвестно" : login,
 5118                    userName,
 5119                    profile?.AvatarUrl,
 5120                    isRollback ? UserKindRollback : isSystem ? UserKindSystem : UserKindUser,
 5121                    field,
 5122                    kind,
 5123                    oldValue,
 5124                    newValue,
 5125                    status,
 5126                    basket,
 5127                    verification,
 5128                    snapshotId,
 5129                    postings.Count,
 1130                    postings.All(p => p.Success),
 5131                    opTime.Ticks,
 5132                    filterText));
 133            }
 134        }
 135
 16136        foreach (var label in model.LabelPrints.OrderByDescending(x => x.OccurredAtUtc).ThenByDescending(x => x.Id))
 137        {
 2138            var opIndex = operations.Count;
 2139            var opKey = $"label|{label.Id}";
 2140            var source = label.UsesFileDate
 2141                ? "Дата файла"
 2142                : label.Source == LabelPrintSource.PartnerApi ? "Partner API" : "Studio2";
 2143            var actor = string.IsNullOrWhiteSpace(label.ActorName) ? "Неизвестно" : label.ActorName;
 2144            var labelCell = new OrderHistoryLabelPrintCell(label.FileName, source);
 2145            operations.Add(new OrderHistoryOperation(opIndex, opKey, []));
 2146            rows.Add(new OrderHistoryChangeRow(
 2147                -(opIndex + 1),
 2148                label.OccurredAtUtc,
 2149                opKey,
 2150                opIndex,
 2151                label.OccurredAtUtc,
 2152                actor,
 2153                null,
 2154                null,
 2155                UserKindUser,
 2156                "Печать этикетки",
 2157                "labelPrint",
 2158                null,
 2159                source,
 2160                null,
 2161                null,
 2162                null,
 2163                null,
 2164                0,
 2165                true,
 2166                label.OccurredAtUtc.Ticks,
 2167                string.Join(' ', "Печать этикетки", label.FileName, source, actor),
 2168                labelCell));
 169        }
 170
 18171        return new OrderHistoryChangeGrid(rows.OrderByDescending(x => x.OpTime).ThenBy(x => x.OpIndex).ToList(), operati
 172    }
 173
 174    private static (string Kind, string Field, string? Old, string? New, OrderHistorySupplierStatusCell? Status,
 175        OrderHistoryBasketCell? Basket, OrderHistoryVerificationCell? Verification, string SearchText) DescribeChange(Or
 176    {
 5177        if (change.ColumnName == OrderSupplierStateService.HistoryColumnName)
 178        {
 0179            model.SupplierStatusTimelineByHistoryId.TryGetValue(change.Id, out var timeline);
 0180            var providerKey = timeline?.ProviderKey;
 0181            var cell = new OrderHistorySupplierStatusCell(
 0182                providerKey is null ? change.ColumnDisplayName ?? "Статус поставщика" : SupplierProviderName(providerKey
 0183                providerKey is null ? null : SupplierProviderLogoUrl(providerKey),
 0184                string.IsNullOrWhiteSpace(timeline?.FromStatusName ?? change.OldValue) ? null : timeline?.FromStatusName
 0185                timeline?.FromStatusColor,
 0186                timeline?.ToStatusName ?? change.NewValue ?? "Статус не указан",
 0187                timeline?.ToStatusColor,
 0188                timeline is null ? null : $"{SupplierStageName(timeline.FromStage)} → {SupplierStageName(timeline.ToStag
 0189                timeline is null ? null : SupplierSourceName(timeline.Source),
 0190                timeline?.Comment);
 0191            var field = change.ColumnDisplayName ?? "Статус поставщика";
 0192            return ("supplierStatus", field, cell.FromName, cell.ToName, cell, null, null,
 0193                string.Join(' ', field, cell.ProviderName, cell.FromName, cell.ToName, cell.Comment));
 194        }
 195
 5196        if (change.ColumnName == ApiSupplierBasketHistoryEntry.HistoryColumnName)
 197        {
 0198            var field = change.ColumnDisplayName ?? "Корзина API-поставщика";
 0199            var entry = ApiSupplierBasketHistoryEntry.TryParse(change.NewValue);
 0200            if (entry is null) return ("basket", field, null, null, null, null, null, field);
 0201            var (tone, icon, text) = DescribeBasketStatus(entry);
 0202            var quantity = entry.Price.HasValue || entry.Quantity.HasValue
 0203                ? (entry.Quantity?.ToString("0.##") ?? "1") + (entry.Price.HasValue ? $" × {entry.Price.Value:0.##} {ent
 0204                : null;
 0205            var product = string.IsNullOrWhiteSpace(entry.ProductName) && string.IsNullOrWhiteSpace(entry.Article)
 0206                ? null
 0207                : string.IsNullOrWhiteSpace(entry.ProductName) ? "Без названия" : entry.ProductName;
 0208            var cell = new OrderHistoryBasketCell(tone, icon, text, NullIfEmpty(entry.LogoUrl), NullIfEmpty(entry.Suppli
 0209                NullIfEmpty(entry.ApiItemId), product, NullIfEmpty(entry.Article), quantity, NullIfEmpty(entry.ShipmentN
 0210                NullIfEmpty(entry.ClientName), NullIfEmpty(entry.WarehouseName), NullIfEmpty(entry.BuyerKunnr),
 0211                NullIfEmpty(entry.DeliveryAddressName), NullIfEmpty(entry.ErrorMessage), NullIfEmpty(entry.SupplierCartU
 0212            return ("basket", field, null, text, null, cell, null, entry.BuildFilterText());
 213        }
 214
 5215        if (change.ColumnName == SupplierVerificationHistoryEntry.HistoryColumnName)
 216        {
 0217            var field = change.ColumnDisplayName ?? "Проверка заказа у поставщика";
 0218            var entry = SupplierVerificationHistoryEntry.TryParse(change.NewValue);
 0219            if (entry is null) return ("verification", field, null, null, null, null, null, field);
 0220            var (tone, icon) = DescribeVerificationStatus(entry.Status);
 0221            var title = SupplierVerificationStatus.Title(entry.Status);
 0222            var cell = new OrderHistoryVerificationCell(tone, icon, title, entry.SupplierDisplayName,
 0223                SupplierProviderLogoUrl(entry.ProviderKey), SupplierStageName(entry.Stage), entry.Summary,
 0224                NullIfEmpty(entry.SupplierOrderNumber), NullIfEmpty(entry.SupplierStatusName),
 0225                entry.Checks ?? Array.Empty<string>(), entry.VerifiedAt);
 0226            return ("verification", field, null, title, null, null, cell, entry.BuildFilterText());
 227        }
 228
 5229        var display = change.ColumnDisplayName ?? "Не указано";
 5230        var oldValue = string.IsNullOrEmpty(change.OldValue) ? null : change.FormattedOldValue;
 5231        var newValue = string.IsNullOrEmpty(change.NewValue) ? null : change.FormattedNewValue;
 5232        return ("change", display, oldValue, newValue, null, null, null, string.Join(' ', display, change.OldValue, chan
 233    }
 234
 235    /// <summary>Проведения 1С операции: по привязке к строкам истории, ключу пакета или времени (±10 мин, с поправкой н
 236    private static List<OrderHistoryOneCPostingViewModel> OneCPostingsForGroup(
 237        OrderHistoryViewModel model, IReadOnlyCollection<OzonDomains.Models.OrderHistory> changes, string? changedBy, Da
 238    {
 7239        if (model.OneCPostings.Count == 0) return [];
 2240        var historyIds = changes.Select(c => c.Id).ToHashSet();
 1241        var batchKey = OneCPostingSummaryBuilder.BuildHistoryBatchKey(model.OrderId, opTime, changedBy);
 1242        return model.OneCPostings
 2243            .Where(p => p.LinkedHistoryIds.Any(historyIds.Contains)
 2244                        || (!string.IsNullOrEmpty(p.HistoryBatchKey) && p.HistoryBatchKey == batchKey)
 2245                        || MinTimeDiffMinutes(p.PostedAt, opTime) <= 10)
 1246            .DistinctBy(p => p.Id)
 1247            .OrderByDescending(p => p.PostedAt)
 1248            .ToList();
 249    }
 250
 251    public static double MinTimeDiffMinutes(DateTime a, DateTime b)
 252    {
 1253        var direct = Math.Abs((a - b).TotalMinutes);
 1254        var plus3 = Math.Abs((a.AddHours(3) - b).TotalMinutes);
 1255        var minus3 = Math.Abs((a.AddHours(-3) - b).TotalMinutes);
 1256        return Math.Min(direct, Math.Min(plus3, minus3));
 257    }
 258
 0259    private static string? NullIfEmpty(string? value) => string.IsNullOrWhiteSpace(value) ? null : value;
 260
 0261    private static string BasketSupplierName(ApiSupplierBasketHistoryEntry entry) => entry.ProviderKey switch
 0262    {
 0263        "MlAuto" => "ML-Auto BY",
 0264        "MlAutoRu" => "ML-Auto RU",
 0265        _ => entry.SupplierDisplayName
 0266    };
 267
 0268    public static (string Tone, string Icon, string Text) DescribeBasketStatus(ApiSupplierBasketHistoryEntry entry) => e
 0269    {
 0270        { CartStatus: "Ordered" } => ("success", "fa-circle-check", $"Заказ размещён у поставщика {BasketSupplierName(en
 0271        { CartStatus: "Failed" } or { Success: false } => ("danger", "fa-circle-xmark", $"Ошибка добавления в корзину {B
 0272        { CartStatus: "Pending" } => ("info", "fa-hourglass-half", $"В корзине {BasketSupplierName(entry)} — ждёт оформл
 0273        _ => ("success", "fa-cart-shopping", $"Добавлено в корзину {BasketSupplierName(entry)}")
 0274    };
 275
 0276    public static (string Tone, string Icon) DescribeVerificationStatus(string status) => status switch
 0277    {
 0278        SupplierVerificationStatus.Confirmed => ("success", "fa-circle-check"),
 0279        SupplierVerificationStatus.NotVerifiable => ("muted", "fa-circle-question"),
 0280        SupplierVerificationStatus.ApiError => ("warning", "fa-plug-circle-exclamation"),
 0281        _ => ("danger", "fa-circle-xmark")
 0282    };
 283
 0284    public static string SupplierProviderName(string providerKey) => providerKey switch
 0285    {
 0286        "Armtek" => "Армтек RU",
 0287        "ArmtekBy" => "Армтек РБ",
 0288        "Avd" => "АВД",
 0289        "MlAuto" => "ML-Auto РБ",
 0290        "MlAutoRu" => "ML-Auto RU",
 0291        "MikadoMskHod20" => "Микадо МскХод20",
 0292        "ZZapMoscow" => "ZZap Москва",
 0293        "Japarts" => "Japarts",
 0294        _ => providerKey
 0295    };
 296
 3297    public static string? SupplierProviderLogoUrl(string providerKey) => providerKey switch
 3298    {
 1299        "Armtek" or "ArmtekBy" => "/images/armtek-logo.svg",
 0300        "Avd" => "/images/avd-logo.svg",
 0301        "MlAuto" => "https://www.ml-auto.by/media/files/settings/inside-placeholder-logo-mobile.png",
 0302        "MlAutoRu" => "https://ml-auto.ru/media/files/settings/inside-placeholder-logo-mobile.png",
 0303        "MikadoMskHod20" => "https://mikado-parts.ru/img/MikadoLogo.svg",
 0304        "ZZapMoscow" => "/images/zzap-logo.svg",
 0305        "Japarts" => "/images/japarts-logo.jpg",
 2306        _ => null
 3307    };
 308
 0309    public static string SupplierStageName(string? stage) => stage switch
 0310    {
 0311        "Cart" => "Корзина",
 0312        "Ordered" => "Заказ оформлен",
 0313        "Closed" => "Завершён",
 0314        _ => "—"
 0315    };
 316
 0317    public static string SupplierSourceName(string source) => source switch
 0318    {
 0319        "Checkout" => "Действие пользователя",
 0320        "Job" => "Синхронизация с поставщиком",
 0321        "Manual" => "Изменено вручную",
 0322        _ => source
 0323    };
 324
 325    /// <summary>Статус позиции акта возврата: тон бейджа темы, иконка, подпись.</summary>
 3326    public static (string Tone, string Icon, string Text) DescribeHandoverStatus(string? status) => status?.ToLowerInvar
 3327    {
 1328        "accepted" => ("success", "fa-circle-check", "Принято"),
 1329        "rejected" => ("danger", "fa-circle-xmark", "Отказ"),
 0330        "pending" => ("info", "fa-hourglass-half", "Ожидает"),
 1331        _ => ("muted", "fa-question-circle", status ?? "—")
 3332    };
 333
 334    /// <summary>Подробности позиции возврата (подпись → значение) — то, что раньше шло строкой под позицией.</summary>
 335    public static List<KeyValuePair<string, string>> HandoverDetails(OrderHistoryHandoverItemViewModel h)
 336    {
 0337        var list = new List<KeyValuePair<string, string>>();
 338        void Add(string label, string? value)
 339        {
 0340            if (!string.IsNullOrWhiteSpace(value)) list.Add(new(label, value));
 0341        }
 342
 0343        Add("Комментарий", h.Comment);
 0344        Add("Статус Ozon", h.OzonStatusName);
 0345        Add("ID возврата", h.ReturnId?.ToString());
 0346        Add("Коробка", h.BoxBarcode);
 0347        Add("Причина", h.Reason is null ? null : h.Reason + (h.ReasonIsDefect ? " (причина брака)" : string.Empty));
 0348        Add("Комментарий клиента", h.ClientComment);
 0349        Add("Причина отказа", h.RejectionReason);
 0350        Add("Комментарий отказа", h.RejectionComment);
 0351        Add("Трек", h.RuPostTracking);
 0352        Add("Номер возврата", h.ReturnNumber);
 0353        Add("Номер заказа Ozon", h.OrderNumber);
 0354        Add("Способ возврата", h.ClientReturnMethod);
 0355        Add("Склад Ozon", h.WarehouseId?.ToString());
 0356        Add("Фото", h.PhotosError);
 0357        return list;
 358    }
 359}