< Summary

Information
Class: Services.YandexServices.YandexOrderStatusSyncService
Assembly: Services
File(s): /home/runner/work/Studio2Prod/Studio2Prod/AppRepository/YandexServices/YandexOrderStatusSyncService.cs
Line coverage
97%
Covered lines: 94
Uncovered lines: 2
Coverable lines: 96
Total lines: 235
Line coverage: 97.9%
Branch coverage
80%
Covered branches: 34
Total branches: 42
Branch coverage: 80.9%
Method coverage

Feature is only available for sponsors

Upgrade to PRO version

Metrics

MethodBranch coverage Crap Score Cyclomatic complexity Line coverage
.ctor(...)100%11100%
SyncAsync()72.72%222294.59%
ApplyClientStatusesAsync()100%1414100%
RecordCancellationsAsync()100%22100%
ParseUpdateDate(...)50%22100%
get_Status()100%11100%

File(s)

/home/runner/work/Studio2Prod/Studio2Prod/AppRepository/YandexServices/YandexOrderStatusSyncService.cs

#LineLine coverage
 1using Microsoft.EntityFrameworkCore;
 2using Newtonsoft.Json.Linq;
 3using OzonDomains.Models;
 4using OzonRepositories.Context;
 5using Services.OrderControlServices;
 6using Services.OzonWebhookServices;
 7using Services.ParserServices.HelpDictEnum;
 8
 9namespace Services.YandexServices;
 10
 11public sealed class YandexOrderStatusSyncResult
 12{
 13    /// <summary>Заказов, у которых сменился ClientStatus.</summary>
 14    public int ClientStatusUpdated { get; init; }
 15
 16    /// <summary>Отправлений, для которых зафиксирована причина отмены.</summary>
 17    public int CancellationsRecorded { get; init; }
 18
 19    /// <summary>Заказов, переведённых в AppStatus «Отменен».</summary>
 20    public int CancelledAppStatusUpdated { get; init; }
 21
 22    /// <summary>Заказов, переведённых в AppStatus «Доставлен».</summary>
 23    public int DeliveredAppStatusUpdated { get; init; }
 24}
 25
 26public interface IYandexOrderStatusSyncService
 27{
 28    Task<YandexOrderStatusSyncResult> SyncAsync(
 29        OzonClient client,
 30        JArray apiOrders,
 31        CancellationToken cancellationToken = default);
 32}
 33
 34/// <summary>
 35/// Приводит статусы заказов Яндекс.Маркета в Studio2 к тому, что отдаёт Partner API,
 36/// и фиксирует причины отмены — так же, как это делается для Ozon
 37/// (<see cref="OzonWebhookServices.Handlers.PostingCancelledNotificationHandler"/>
 38/// и OrdersClientStatusSyncService).
 39/// </summary>
 40/// <remarks>
 41/// Отдельный шаг нужен потому, что <c>OrdersDataServices.UpdateOrder</c> сверяет свойства рефлексией
 42/// и навигационный <c>ClientStatus</c> у уже существующего заказа так не обновляется. Причина отмены
 43/// пишется в ту же таблицу <see cref="OzonPostingCancellation"/>, что и у Ozon (source = <c>YandexApi</c>),
 44/// поэтому тултип «Причина отмены» на странице заказов работает для Яндекса без изменений во вьюхах.
 45/// </remarks>
 46public sealed class YandexOrderStatusSyncService : IYandexOrderStatusSyncService
 47{
 48    private const string CancelledApiStatus = "CANCELLED";
 49    private const string CancellationSource = "YandexApi";
 50    private const string HistoryAuthor = "Система: Яндекс.Маркет";
 51
 52    private readonly ApplicationDbContext _db;
 53    private readonly IOzonPostingCancellationService _cancellationService;
 54    private readonly IOrderDeliveryStatusSynchronizer _deliveryStatusSynchronizer;
 55
 656    public YandexOrderStatusSyncService(
 657        ApplicationDbContext db,
 658        IOzonPostingCancellationService cancellationService,
 659        IOrderDeliveryStatusSynchronizer deliveryStatusSynchronizer)
 60    {
 661        _db = db;
 662        _cancellationService = cancellationService;
 663        _deliveryStatusSynchronizer = deliveryStatusSynchronizer;
 664    }
 65
 66    public async Task<YandexOrderStatusSyncResult> SyncAsync(
 67        OzonClient client,
 68        JArray apiOrders,
 69        CancellationToken cancellationToken = default)
 70    {
 771        if (client.Id <= 0 || apiOrders.Count == 0)
 72        {
 073            return new YandexOrderStatusSyncResult();
 74        }
 75
 76        // Номер отправления в Studio2 — это orderId Яндекса
 777        var apiStates = new Dictionary<string, YandexOrderState>(StringComparer.OrdinalIgnoreCase);
 2878        foreach (var apiOrder in apiOrders.OfType<JObject>())
 79        {
 780            var orderId = apiOrder["orderId"]?.ToString();
 781            if (string.IsNullOrWhiteSpace(orderId))
 82                continue;
 83
 784            var status = apiOrder["status"]?.ToString();
 785            if (string.IsNullOrWhiteSpace(status))
 86                continue;
 87
 788            apiStates[orderId] = new YandexOrderState(
 789                status,
 790                apiOrder["substatus"]?.ToString(),
 791                ParseUpdateDate(apiOrder["updateDate"]?.ToString()));
 92        }
 93
 794        if (apiStates.Count == 0)
 95        {
 096            return new YandexOrderStatusSyncResult();
 97        }
 98
 799        var shipmentNumbers = apiStates.Keys.ToList();
 100
 101        // Разбитые вручную отправления («12345/2») намеренно не трогаем: их пропускает и AddOrders,
 102        // чтобы не перетирать ручную работу менеджера.
 7103        var orders = await _db.Orders
 7104            .Include(o => o.ClientStatus)
 7105            .Where(o => o.OzonClientId == client.Id
 7106                        && o.ShipmentNumber != null
 7107                        && shipmentNumbers.Contains(o.ShipmentNumber))
 7108            .ToListAsync(cancellationToken);
 109
 7110        if (orders.Count == 0)
 111        {
 1112            return new YandexOrderStatusSyncResult();
 113        }
 114
 6115        var statusUpdated = await ApplyClientStatusesAsync(orders, apiStates, cancellationToken);
 6116        var cancellationsRecorded = await RecordCancellationsAsync(client, orders, apiStates, cancellationToken);
 117
 12118        var touchedIds = orders.Select(o => o.Id).ToArray();
 6119        var cancelledUpdated = await _deliveryStatusSynchronizer
 6120            .SynchronizeCancelledAsync(touchedIds, HistoryAuthor, cancellationToken);
 6121        var deliveredUpdated = await _deliveryStatusSynchronizer
 6122            .SynchronizeDeliveredAsync(touchedIds, HistoryAuthor, cancellationToken);
 123
 6124        return new YandexOrderStatusSyncResult
 6125        {
 6126            ClientStatusUpdated = statusUpdated,
 6127            CancellationsRecorded = cancellationsRecorded,
 6128            CancelledAppStatusUpdated = cancelledUpdated,
 6129            DeliveredAppStatusUpdated = deliveredUpdated
 6130        };
 7131    }
 132
 133    /// <summary>
 134    /// Переставляет ClientStatus заказов на актуальный статус Маркета, создавая справочную запись при первой встрече.
 135    /// </summary>
 136    private async Task<int> ApplyClientStatusesAsync(
 137        List<Order> orders,
 138        Dictionary<string, YandexOrderState> apiStates,
 139        CancellationToken cancellationToken)
 140    {
 6141        var statusCache = new Dictionary<string, ClientStatus>(StringComparer.OrdinalIgnoreCase);
 6142        var updated = 0;
 6143        var now = DateTime.Now;
 144
 24145        foreach (var order in orders)
 146        {
 6147            if (!apiStates.TryGetValue(order.ShipmentNumber!, out var state))
 148                continue;
 149
 6150            var statusName = YandexStatus.GetStatus(state.Status);
 6151            if (string.IsNullOrWhiteSpace(statusName))
 152                continue;
 153
 6154            if (!statusCache.TryGetValue(statusName, out var clientStatus))
 155            {
 6156                clientStatus = await _db.ClientStatuses
 6157                    .FirstOrDefaultAsync(cs => cs.Name == statusName, cancellationToken);
 158
 6159                if (clientStatus == null)
 160                {
 4161                    clientStatus = new ClientStatus { Name = statusName };
 4162                    await _db.ClientStatuses.AddAsync(clientStatus, cancellationToken);
 4163                    await _db.SaveChangesAsync(cancellationToken);
 164                }
 165
 6166                statusCache[statusName] = clientStatus;
 167            }
 168
 169            // Сверяем по Id, а не по названию: в справочнике статус отмены записан как «Отменён»,
 170            // а словарь Маркета даёт «Отменен» — коллация БД считает их одним значением, C# нет.
 171            // Сравнение строк давало бы «обновление» статуса на каждом прогоне джоба.
 6172            if (order.ClientStatusId == clientStatus.Id)
 173                continue;
 174
 4175            order.ClientStatus = clientStatus;
 4176            order.ClientStatusId = clientStatus.Id;
 4177            order.LastStatusChangeDate = now;
 4178            updated++;
 4179        }
 180
 6181        if (updated > 0)
 182        {
 4183            await _db.SaveChangesAsync(cancellationToken);
 184        }
 185
 6186        return updated;
 6187    }
 188
 189    /// <summary>
 190    /// Фиксирует причину отмены (substatus Маркета) в общей таблице причин отмены.
 191    /// </summary>
 192    private async Task<int> RecordCancellationsAsync(
 193        OzonClient client,
 194        List<Order> orders,
 195        Dictionary<string, YandexOrderState> apiStates,
 196        CancellationToken cancellationToken)
 197    {
 6198        var recorded = 0;
 199
 6200        var cancelledGroups = orders
 6201            .Where(o => apiStates.TryGetValue(o.ShipmentNumber!, out var state)
 6202                        && string.Equals(state.Status, CancelledApiStatus, StringComparison.OrdinalIgnoreCase))
 10203            .GroupBy(o => o.ShipmentNumber!, StringComparer.OrdinalIgnoreCase);
 204
 20205        foreach (var group in cancelledGroups)
 206        {
 4207            var state = apiStates[group.Key];
 208
 4209            await _cancellationService.RecordAsync(
 4210                client.Id,
 4211                group.Key,
 4212                reasonId: null,
 4213                reasonTitle: YandexStatus.GetCancellationReason(state.Substatus),
 4214                reasonMessage: state.Substatus,
 4215                reasonTypeId: null,
 4216                occurredAt: state.UpdatedAt,
 4217                source: CancellationSource,
 4218                orderIds: group.Select(o => o.Id).ToArray(),
 4219                cancellationToken: cancellationToken);
 220
 4221            recorded++;
 222        }
 223
 6224        return recorded;
 6225    }
 226
 227    private static DateTime ParseUpdateDate(string? value)
 228    {
 7229        return DateTimeOffset.TryParse(value, out var parsed)
 7230            ? parsed.DateTime
 7231            : DateTime.Now;
 232    }
 233
 24234    private readonly record struct YandexOrderState(string Status, string? Substatus, DateTime UpdatedAt);
 235}