< Summary

Information
Class: Services.SupplierStatusServices.OrderSupplierStateService
Assembly: Services
File(s): /home/runner/work/Studio2Prod/Studio2Prod/AppRepository/SupplierStatusServices/OrderSupplierStateService.cs
Line coverage
85%
Covered lines: 214
Uncovered lines: 36
Coverable lines: 250
Total lines: 426
Line coverage: 85.6%
Branch coverage
46%
Covered branches: 93
Total branches: 202
Branch coverage: 46%
Method coverage

Feature is only available for sponsors

Upgrade to PRO version

Metrics

MethodBranch coverage Crap Score Cyclomatic complexity Line coverage
.ctor(...)100%11100%
MarkInCartAsync()85.71%1414100%
MarkOrderedAsync()80%2020100%
RemoveFromCartAsync()100%44100%
ApplySnapshotsAsync()65.62%323292.3%
MarkSyncedAsync()0%620%
ApplyStatusAsync()87.5%1616100%
IsOrderNumberOverwritten(...)100%44100%
Split()100%11100%
RecordOrderNumberOverwriteAsync()50%22100%
GetActiveAsync(...)100%11100%
DescribeProvider(...)9.78%39469223.07%
CartStatusFallbackName(...)75%161270%
Trim(...)75%44100%

File(s)

/home/runner/work/Studio2Prod/Studio2Prod/AppRepository/SupplierStatusServices/OrderSupplierStateService.cs

#LineLine coverage
 1using Microsoft.EntityFrameworkCore;
 2using Microsoft.Extensions.Logging;
 3using OzonDomains.Models;
 4using OzonDomains.Models.SupplierStatuses;
 5using OzonRepositories.Context;
 6
 7namespace Services.SupplierStatusServices;
 8
 9public interface IOrderSupplierStateService
 10{
 11    /// <summary>Заказ попал в корзину поставщика либо сменил статус внутри корзины.</summary>
 12    Task MarkInCartAsync(
 13        int orderId, string providerKey, string cartStatusCode,
 14        long? cartItemId = null, string? externalBasketItemId = null,
 15        string? user = null,
 16        int? deliveryDaysMin = null, int? deliveryDaysMax = null, string? direction = null,
 17        CancellationToken ct = default);
 18
 19    /// <summary>Заказ оформлен у поставщика: стадия переходит в Ordered, фиксируется внешний номер.</summary>
 20    Task MarkOrderedAsync(
 21        int orderId, string providerKey, string? externalOrderNumber,
 22        string? externalOrderId = null, string? externalLineId = null,
 23        string? user = null,
 24        int? deliveryDaysMin = null, int? deliveryDaysMax = null, string? direction = null,
 25        CancellationToken ct = default);
 26
 27    /// <summary>Позиция ушла из корзины до оформления — состояние закрывается.</summary>
 28    Task RemoveFromCartAsync(int orderId, string providerKey, string? user = null, CancellationToken ct = default);
 29
 30    /// <summary>Применяет результаты опроса поставщика к строкам состояния.</summary>
 31    Task<int> ApplySnapshotsAsync(
 32        string providerKey, IReadOnlyList<SupplierStatusSnapshot> snapshots, CancellationToken ct = default);
 33
 34    /// <summary>Помечает строки как опрошенные — чтобы они уступили очередь следующему батчу.</summary>
 35    Task MarkSyncedAsync(IReadOnlyCollection<long> stateIds, string? error, CancellationToken ct = default);
 36}
 37
 38/// <summary>
 39/// Единственная точка записи в OrderSupplierStates и OrderSupplierStateHistory.
 40/// Все изменения стадии и статуса проходят здесь, поэтому история всегда полная.
 41/// </summary>
 42public sealed class OrderSupplierStateService : IOrderSupplierStateService
 43{
 44    /// <summary>Маркер записи в orders_history — по нему таймлайн заказа рисует статус поставщика.</summary>
 45    public const string HistoryColumnName = "SupplierOrderStatus";
 46
 47    /// <summary>Маркер записи в orders_history о перезаписи номера заказа у поставщика — признак возможного дубля заказ
 48    public const string OrderNumberChangedColumnName = "SupplierOrderNumberChanged";
 49
 50    private readonly ApplicationDbContext _db;
 51    private readonly ISupplierStatusResolver _resolver;
 52    private readonly ILogger<OrderSupplierStateService> _logger;
 53
 3254    public OrderSupplierStateService(
 3255        ApplicationDbContext db,
 3256        ISupplierStatusResolver resolver,
 3257        ILogger<OrderSupplierStateService> logger)
 58    {
 3259        _db = db;
 3260        _resolver = resolver;
 3261        _logger = logger;
 3262    }
 63
 64    public async Task MarkInCartAsync(
 65        int orderId, string providerKey, string cartStatusCode,
 66        long? cartItemId = null, string? externalBasketItemId = null,
 67        string? user = null,
 68        int? deliveryDaysMin = null, int? deliveryDaysMax = null, string? direction = null,
 69        CancellationToken ct = default)
 70    {
 71        // Removed — это выход из корзины, а не состояние внутри неё.
 972        if (cartStatusCode == SupplierCartStatusCode.Removed)
 73        {
 174            await RemoveFromCartAsync(orderId, providerKey, user, ct);
 175            return;
 76        }
 77
 878        var state = await GetActiveAsync(orderId, providerKey, ct);
 879        var status = await _resolver.ResolveAsync(
 880            providerKey, SupplierStatusKind.Cart, cartStatusCode, CartStatusFallbackName(cartStatusCode), ct);
 81
 882        if (state is null)
 83        {
 784            state = new OrderSupplierState
 785            {
 786                OrderId = orderId,
 787                ActiveOrderId = orderId,
 788                ProviderKey = providerKey,
 789                SupplierId = status.SupplierId,
 790                Stage = OrderSupplierStage.Cart,
 791                CreatedBy = user,
 792                CreatedAt = DateTime.UtcNow
 793            };
 794            _db.OrderSupplierStates.Add(state);
 95        }
 96
 897        if (cartItemId.HasValue) state.CartItemId = cartItemId;
 898        if (!string.IsNullOrWhiteSpace(externalBasketItemId)) state.ExternalBasketItemId = externalBasketItemId;
 1199        if (deliveryDaysMin.HasValue) state.DeliveryDaysMin = deliveryDaysMin;
 11100        if (deliveryDaysMax.HasValue) state.DeliveryDaysMax = deliveryDaysMax;
 11101        if (!string.IsNullOrWhiteSpace(direction)) state.Direction = Trim(direction, 255);
 102
 8103        await ApplyStatusAsync(state, status, OrderSupplierStage.Cart, OrderSupplierStateSource.Checkout, user, null, ct
 9104    }
 105
 106    public async Task MarkOrderedAsync(
 107        int orderId, string providerKey, string? externalOrderNumber,
 108        string? externalOrderId = null, string? externalLineId = null,
 109        string? user = null,
 110        int? deliveryDaysMin = null, int? deliveryDaysMax = null, string? direction = null,
 111        CancellationToken ct = default)
 112    {
 20113        var state = await GetActiveAsync(orderId, providerKey, ct);
 20114        var status = await _resolver.ResolveAsync(
 20115            providerKey, SupplierStatusKind.Cart, SupplierCartStatusCode.Ordered,
 20116            CartStatusFallbackName(SupplierCartStatusCode.Ordered), ct);
 117
 20118        if (state is null)
 119        {
 120            // Заказ мог быть оформлен без прохождения через нашу корзину — например,
 121            // импортом ручного заказа ML-Auto. Строку создаём сразу в стадии Ordered.
 13122            state = new OrderSupplierState
 13123            {
 13124                OrderId = orderId,
 13125                ActiveOrderId = orderId,
 13126                ProviderKey = providerKey,
 13127                SupplierId = status.SupplierId,
 13128                Stage = OrderSupplierStage.Ordered,
 13129                CreatedBy = user,
 13130                CreatedAt = DateTime.UtcNow
 13131            };
 13132            _db.OrderSupplierStates.Add(state);
 133        }
 134
 20135        var previousOrderNumber = state.ExternalOrderNumber;
 39136        if (!string.IsNullOrWhiteSpace(externalOrderNumber)) state.ExternalOrderNumber = Trim(externalOrderNumber, 50);
 21137        if (!string.IsNullOrWhiteSpace(externalOrderId)) state.ExternalOrderId = Trim(externalOrderId, 50);
 20138        if (!string.IsNullOrWhiteSpace(externalLineId)) state.ExternalLineId = Trim(externalLineId, 50);
 20139        if (deliveryDaysMin.HasValue) state.DeliveryDaysMin = deliveryDaysMin;
 20140        if (deliveryDaysMax.HasValue) state.DeliveryDaysMax = deliveryDaysMax;
 20141        if (!string.IsNullOrWhiteSpace(direction)) state.Direction = Trim(direction, 255);
 142        // Фиксируется один раз, при первом переходе в Ordered — в отличие от LastStatusChangeAt
 143        // не смещается последующими сменами статуса от API, поэтому годится как база для прогноза даты доставки.
 20144        state.OrderedAt ??= DateTime.UtcNow;
 145        // Статус заказа ещё не известен — до первого опроса показываем «Заказан у поставщика».
 20146        state.LastSyncedAt = null;
 20147        state.SyncError = null;
 148
 20149        var comment = string.IsNullOrWhiteSpace(externalOrderNumber)
 20150            ? null
 20151            : $"Заказ у поставщика: {externalOrderNumber}";
 152
 20153        await ApplyStatusAsync(state, status, OrderSupplierStage.Ordered, OrderSupplierStateSource.Checkout, user, comme
 154
 155        // Повторное оформление молча заменило бы номер первого заказа у поставщика, и тот остался бы
 156        // «потерянным» — оставляем след в истории, чтобы дубль можно было заметить и отменить.
 20157        if (IsOrderNumberOverwritten(previousOrderNumber, state.ExternalOrderNumber))
 158        {
 1159            await RecordOrderNumberOverwriteAsync(state, previousOrderNumber!, state.ExternalOrderNumber!, user, ct);
 160        }
 20161    }
 162
 163    public async Task RemoveFromCartAsync(int orderId, string providerKey, string? user = null, CancellationToken ct = d
 164    {
 2165        var state = await GetActiveAsync(orderId, providerKey, ct);
 166        // Закрываем только то, что ещё не оформлено: оформленный заказ из корзины уже ушёл
 167        // и продолжает отслеживаться по API.
 3168        if (state is null || state.Stage != OrderSupplierStage.Cart) return;
 169
 1170        var status = await _resolver.ResolveAsync(
 1171            providerKey, SupplierStatusKind.Cart, SupplierCartStatusCode.Removed,
 1172            CartStatusFallbackName(SupplierCartStatusCode.Removed), ct);
 173
 1174        await ApplyStatusAsync(state, status, OrderSupplierStage.Closed, OrderSupplierStateSource.Checkout, user, null, 
 2175    }
 176
 177    public async Task<int> ApplySnapshotsAsync(
 178        string providerKey, IReadOnlyList<SupplierStatusSnapshot> snapshots, CancellationToken ct = default)
 179    {
 7180        if (snapshots.Count == 0) return 0;
 181
 14182        var orderIds = snapshots.Select(x => x.OrderId).Distinct().ToList();
 7183        var states = await _db.OrderSupplierStates
 7184            .Where(x => x.ProviderKey == providerKey && x.ActiveOrderId != null && orderIds.Contains(x.OrderId))
 7185            .ToListAsync(ct);
 14186        var byOrderId = states.ToDictionary(x => x.OrderId);
 187
 7188        var changed = 0;
 28189        foreach (var snapshot in snapshots)
 190        {
 7191            if (!byOrderId.TryGetValue(snapshot.OrderId, out var state)) continue;
 192
 7193            if (!string.IsNullOrWhiteSpace(snapshot.Error))
 194            {
 1195                state.SyncError = Trim(snapshot.Error, 1000);
 1196                state.LastSyncedAt = DateTime.UtcNow;
 1197                state.UpdatedAt = DateTime.UtcNow;
 1198                continue;
 199            }
 200
 6201            var status = await _resolver.ResolveAsync(
 6202                providerKey, snapshot.Kind, snapshot.ExternalCode, snapshot.StatusName, ct);
 203
 6204            if (!string.IsNullOrWhiteSpace(snapshot.ExternalOrderNumber))
 0205                state.ExternalOrderNumber = Trim(snapshot.ExternalOrderNumber, 50);
 6206            if (!string.IsNullOrWhiteSpace(snapshot.ExternalOrderId))
 0207                state.ExternalOrderId = Trim(snapshot.ExternalOrderId, 50);
 6208            if (!string.IsNullOrWhiteSpace(snapshot.ExternalLineId))
 0209                state.ExternalLineId = Trim(snapshot.ExternalLineId, 50);
 6210            if (snapshot.QuantityConfirmed.HasValue) state.QuantityConfirmed = snapshot.QuantityConfirmed;
 6211            if (snapshot.QuantityShipped.HasValue) state.QuantityShipped = snapshot.QuantityShipped;
 6212            if (snapshot.QuantityRejected.HasValue) state.QuantityRejected = snapshot.QuantityRejected;
 6213            if (snapshot.ExpectedDeliveryAt.HasValue) state.ExpectedDeliveryAt = snapshot.ExpectedDeliveryAt;
 214
 6215            state.SyncError = null;
 6216            state.LastSyncedAt = DateTime.UtcNow;
 217
 218            // Конечный статус закрывает отслеживание: строка выходит из-под уникального индекса
 219            // и перестаёт попадать в выборку джобы. Removed в корзине закрываем всегда, как и в
 220            // MarkInCartAsync/RemoveFromCartAsync — это выход из корзины, а не состояние внутри неё,
 221            // справочник IsFinal тут не авторитетен (обычно не проставлен для карточных статусов).
 6222            var isCartRemoved = snapshot.Kind == SupplierStatusKind.Cart && snapshot.ExternalCode == SupplierCartStatusC
 6223            var targetStage = status.IsFinal || isCartRemoved
 6224                ? OrderSupplierStage.Closed
 6225                : snapshot.Kind == SupplierStatusKind.Cart ? OrderSupplierStage.Cart : OrderSupplierStage.Ordered;
 226
 6227            if (await ApplyStatusAsync(
 6228                    state, status, targetStage, OrderSupplierStateSource.Job, null,
 6229                    snapshot.StatusChangedAt.HasValue ? $"Статус поставщика от {snapshot.StatusChangedAt:dd.MM.yyyy HH:m
 6230                    ct, snapshot.StatusChangedAt))
 231            {
 5232                changed++;
 233            }
 6234        }
 235
 7236        await _db.SaveChangesAsync(ct);
 7237        return changed;
 7238    }
 239
 240    public async Task MarkSyncedAsync(IReadOnlyCollection<long> stateIds, string? error, CancellationToken ct = default)
 241    {
 0242        if (stateIds.Count == 0) return;
 243
 0244        var ids = stateIds.ToList();
 0245        var now = DateTime.UtcNow;
 0246        var trimmedError = Trim(error, 1000);
 247
 0248        await _db.OrderSupplierStates
 0249            .Where(x => ids.Contains(x.Id))
 0250            .ExecuteUpdateAsync(s => s
 0251                .SetProperty(x => x.LastSyncedAt, now)
 0252                .SetProperty(x => x.SyncError, trimmedError), ct);
 0253    }
 254
 255    /// <summary>
 256    /// Ставит статус и стадию, пишет историю и запись в таймлайн заказа.
 257    /// Возвращает true, если состояние действительно изменилось.
 258    /// </summary>
 259    private async Task<bool> ApplyStatusAsync(
 260        OrderSupplierState state, SupplierStatus status, string targetStage,
 261        string source, string? user, string? comment, CancellationToken ct,
 262        DateTime? statusChangedAt = null)
 263    {
 35264        var statusUnchanged = state.SupplierStatusId == status.Id;
 35265        var stageUnchanged = state.Stage == targetStage;
 35266        if (statusUnchanged && stageUnchanged && state.Id != 0)
 267        {
 5268            state.UpdatedAt = DateTime.UtcNow;
 5269            await _db.SaveChangesAsync(ct);
 5270            return false;
 271        }
 272
 30273        var fromStatusId = state.SupplierStatusId;
 30274        var fromStage = state.Id == 0 ? null : state.Stage;
 30275        var fromStatusName = fromStatusId.HasValue
 30276            ? await _db.SupplierStatuses.Where(x => x.Id == fromStatusId).Select(x => x.Name).FirstOrDefaultAsync(ct)
 30277            : null;
 278
 30279        state.SupplierStatusId = status.Id;
 30280        state.Stage = targetStage;
 30281        state.LastStatusChangeAt = statusChangedAt ?? DateTime.UtcNow;
 30282        state.UpdatedAt = DateTime.UtcNow;
 283        // Закрытая строка освобождает уникальный индекс, оставаясь в таблице для истории.
 30284        state.ActiveOrderId = targetStage == OrderSupplierStage.Closed ? null : state.OrderId;
 285
 30286        await _db.SaveChangesAsync(ct);
 287
 30288        var timelineEntry = new OrderHistory
 30289        {
 30290            OrderId = state.OrderId,
 30291            ColumnName = HistoryColumnName,
 30292            ColumnDisplayName = $"Статус поставщика · {DescribeProvider(state.ProviderKey)}",
 30293            OldValue = fromStatusName,
 30294            NewValue = status.Name,
 30295            ChangedAt = DateTime.Now,
 30296            ChangedBy = Trim(user ?? $"Система: {state.ProviderKey}", 255)
 30297        };
 298
 30299        _db.OrderHistories.Add(timelineEntry);
 30300        _db.OrderSupplierStateHistory.Add(new OrderSupplierStateHistory
 30301        {
 30302            OrderSupplierStateId = state.Id,
 30303            OrderId = state.OrderId,
 30304            OrderHistory = timelineEntry,
 30305            FromSupplierStatusId = fromStatusId,
 30306            ToSupplierStatusId = status.Id,
 30307            FromStage = fromStage,
 30308            ToStage = targetStage,
 30309            Comment = Trim(comment, 500),
 30310            Source = source,
 30311            PerformedAt = DateTime.UtcNow,
 30312            PerformedBy = Trim(user, 450)
 30313        });
 314
 30315        await _db.SaveChangesAsync(ct);
 316
 30317        _logger.LogInformation(
 30318            "Заказ {OrderId}: статус у {ProviderKey} '{From}' -> '{To}' (стадия {Stage})",
 30319            state.OrderId, state.ProviderKey, fromStatusName ?? "—", status.Name, targetStage);
 320
 30321        return true;
 35322    }
 323
 324    /// <summary>Номера у Армтека приходят списком через запятую; перестановка и пробелы заменой не считаются.</summary>
 325    private static bool IsOrderNumberOverwritten(string? previous, string? current)
 326    {
 36327        if (string.IsNullOrWhiteSpace(previous) || string.IsNullOrWhiteSpace(current)) return false;
 328
 8329        static HashSet<string> Split(string value) => value
 8330            .Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries)
 8331            .ToHashSet(StringComparer.OrdinalIgnoreCase);
 332
 4333        return !Split(previous).SetEquals(Split(current));
 334    }
 335
 336    private async Task RecordOrderNumberOverwriteAsync(
 337        OrderSupplierState state, string previousNumber, string currentNumber, string? user, CancellationToken ct)
 338    {
 1339        var provider = DescribeProvider(state.ProviderKey);
 1340        var warning =
 1341            $"Внимание: номер заказа у поставщика перезаписан ({previousNumber} → {currentNumber}). " +
 1342            $"Возможно, заказ у {provider} оформлен повторно — проверьте и отмените лишний у поставщика.";
 343
 1344        var timelineEntry = new OrderHistory
 1345        {
 1346            OrderId = state.OrderId,
 1347            ColumnName = OrderNumberChangedColumnName,
 1348            ColumnDisplayName = $"Номер заказа у поставщика · {provider}",
 1349            OldValue = previousNumber,
 1350            NewValue = currentNumber,
 1351            ChangedAt = DateTime.Now,
 1352            ChangedBy = Trim(user ?? $"Система: {state.ProviderKey}", 255)
 1353        };
 354
 1355        _db.OrderHistories.Add(timelineEntry);
 1356        _db.OrderSupplierStateHistory.Add(new OrderSupplierStateHistory
 1357        {
 1358            OrderSupplierStateId = state.Id,
 1359            OrderId = state.OrderId,
 1360            OrderHistory = timelineEntry,
 1361            FromSupplierStatusId = state.SupplierStatusId,
 1362            ToSupplierStatusId = state.SupplierStatusId,
 1363            FromStage = state.Stage,
 1364            ToStage = state.Stage,
 1365            Comment = Trim(warning, 500),
 1366            Source = OrderSupplierStateSource.Checkout,
 1367            PerformedAt = DateTime.UtcNow,
 1368            PerformedBy = Trim(user, 450)
 1369        });
 370
 1371        await _db.SaveChangesAsync(ct);
 372
 1373        _logger.LogWarning(
 1374            "Заказ {OrderId}: номер заказа у {ProviderKey} перезаписан '{From}' -> '{To}', возможен дубль",
 1375            state.OrderId, state.ProviderKey, previousNumber, currentNumber);
 1376    }
 377
 378    private Task<OrderSupplierState?> GetActiveAsync(int orderId, string providerKey, CancellationToken ct) =>
 30379        _db.OrderSupplierStates.FirstOrDefaultAsync(
 30380            x => x.ProviderKey == providerKey && x.ActiveOrderId == orderId, ct);
 381
 134382    public static string DescribeProvider(string providerKey) => providerKey switch
 134383    {
 86384        "Armtek" => "Армтек RU",
 0385        "ArmtekBy" => "Армтек РБ",
 28386        "Avd" => "АВД",
 20387        "MlAuto" => "ML-Auto РБ",
 0388        "MlAutoRu" => "ML-Auto RU",
 0389        "ForumAuto" => "Форум-Авто (Союз)",
 0390        "ForumAutoInterparts" => "Форум-Авто (Интерпартс)",
 0391        "ForumAutoPiter" => "Форум-Авто (Питер)",
 0392        "ForumAutoRostov" => "Форум-Авто (Ростов)",
 0393        "ForumAutoIstra" => "Форум-Авто (Истра)",
 0394        "ProfitLiga" => "Профит-Лига",
 0395        "ZZapMoscow" => "ZZap Москва",
 0396        "FavoritParts" => "FavoritParts",
 0397        "FavoritPartsIstra" => "Фаворит Истра",
 0398        "FavoritPartsRostov" => "Фаворит Ростов",
 0399        "MoskvorechieIstra" => "Москворечье (Истра)",
 0400        "Motex" => "МоТехС",
 0401        "Nikei" => "Nikei",
 0402        "MikadoMskHod20" => "Mikado",
 0403        "Japarts" => "Japarts",
 0404        "Berg" => "Berg",
 0405        "ShateM" => "Шате-М",
 0406        _ => providerKey
 134407    };
 408
 409    /// <summary>
 410    /// Имя для автосоздания статуса корзины, если сид не применялся. При применённом сиде
 411    /// резолвер найдёт запись по коду и это значение не используется.
 412    /// </summary>
 29413    private static string CartStatusFallbackName(string code) => code switch
 29414    {
 7415        SupplierCartStatusCode.Pending => "В корзине, нужна проверка",
 1416        SupplierCartStatusCode.Ready => "Проверен, готов к заказу",
 0417        SupplierCartStatusCode.Processing => "Оформляется",
 20418        SupplierCartStatusCode.Ordered => "Заказан у поставщика",
 0419        SupplierCartStatusCode.Failed => "Ошибка проверки",
 1420        SupplierCartStatusCode.Removed => "Удалён из корзины",
 0421        _ => code
 29422    };
 423
 424    private static string? Trim(string? value, int maxLength) =>
 117425        string.IsNullOrWhiteSpace(value) ? null : value.Length <= maxLength ? value : value[..maxLength];
 426}