< Summary

Information
Class: Services.OrderControlServices.OrderDeliveryStatusSynchronizer
Assembly: Services
File(s): /home/runner/work/Studio2Prod/Studio2Prod/AppRepository/OrderControlServices/OrderDeliveryStatusSynchronizer.cs
Line coverage
96%
Covered lines: 72
Uncovered lines: 3
Coverable lines: 75
Total lines: 115
Line coverage: 96%
Branch coverage
63%
Covered branches: 14
Total branches: 22
Branch coverage: 63.6%
Method coverage

Feature is only available for sponsors

Upgrade to PRO version

Metrics

MethodBranch coverage Crap Score Cyclomatic complexity Line coverage
.ctor(...)100%11100%
.cctor()100%11100%
SynchronizeDeliveredAsync(...)100%11100%
SynchronizeCancelledAsync(...)100%11100%
SynchronizeAppStatusAsync()100%11100%
<SynchronizeAppStatusAsync()62.5%161694%

File(s)

/home/runner/work/Studio2Prod/Studio2Prod/AppRepository/OrderControlServices/OrderDeliveryStatusSynchronizer.cs

#LineLine coverage
 1using Microsoft.EntityFrameworkCore;
 2using OzonDomains.Models;
 3using OzonRepositories.Context;
 4using Services.BackgroundJobsServices;
 5
 6namespace Services.OrderControlServices;
 7
 8/// <summary>
 9/// Приводит внутренний статус к конечному «Доставлен» по фактическому Ozon-статусу.
 10/// Используется в SLA-проходе, push-обработчиках и при обновлении статусов через API.
 11/// </summary>
 12public interface IOrderDeliveryStatusSynchronizer
 13{
 14    /// <param name="changedBy">Автор записи в истории заказа. По умолчанию — Ozon, для других площадок передаётся явно.
 15    Task<int> SynchronizeDeliveredAsync(IReadOnlyCollection<int>? orderIds = null, string? changedBy = null, Cancellatio
 16
 17    /// <inheritdoc cref="SynchronizeDeliveredAsync"/>
 18    Task<int> SynchronizeCancelledAsync(IReadOnlyCollection<int>? orderIds = null, string? changedBy = null, Cancellatio
 19}
 20
 221public sealed class OrderDeliveryStatusSynchronizer(ApplicationDbContext db) : IOrderDeliveryStatusSynchronizer
 22{
 23    private const string DeliveredStatusName = "Доставлен";
 24    private const string CancelledStatusName = "Отменен";
 25    // Без orderIds (периодический вызов из OrderControlService.EvaluateAsync) запрос иначе сканит
 26    // весь бэклог Orders без ограничения — на проде это command timeout и исчерпание пула соединений
 27    // (см. инцидент с падением order-control-evaluation, retry 6/10). Батч сам себя дренирует: обработанные
 28    // строки перестают попадать в WHERE на следующем цикле, весь бэклог разгребается за несколько прогонов.
 29    private const int UnboundedBatchSize = 500;
 30    // Доставка/отмена актуальны для недавних заказов — старые (>месяца) не досинкиваем автоматически,
 31    // это заодно режет полный скан по дате вместо скана всей таблицы Orders.
 32    private const int UnboundedLookbackDays = 31;
 133    private static readonly string[] CancelledClientStatusNames = ["Отменен", "Отменён", "Отменено"];
 34
 35    public Task<int> SynchronizeDeliveredAsync(IReadOnlyCollection<int>? orderIds = null, string? changedBy = null, Canc
 136        SynchronizeAppStatusAsync(orderIds, DeliveredStatusName, isCancellation: false, changedBy, cancellationToken);
 37
 38    public Task<int> SynchronizeCancelledAsync(IReadOnlyCollection<int>? orderIds = null, string? changedBy = null, Canc
 139        SynchronizeAppStatusAsync(orderIds, CancelledStatusName, isCancellation: true, changedBy, cancellationToken);
 40
 41    private async Task<int> SynchronizeAppStatusAsync(IReadOnlyCollection<int>? orderIds, string targetStatusName,
 42        bool isCancellation, string? changedBy, CancellationToken cancellationToken)
 43    {
 244        var strategy = db.Database.CreateExecutionStrategy();
 245        return await strategy.ExecuteAsync(async () =>
 246        {
 247            var targetStatusId = await db.AppStatuses
 248                .Where(x => x.Name == targetStatusName)
 249                .Select(x => (int?)x.Id)
 250                .FirstOrDefaultAsync(cancellationToken);
 251            if (!targetStatusId.HasValue)
 052                return 0;
 253
 254            var query = db.Orders.Where(x => x.ClientStatus != null && x.AppStatusId != targetStatusId.Value);
 255            query = isCancellation
 256                ? query.Where(x => CancelledClientStatusNames.Contains(x.ClientStatus!.Name))
 257                : query.Where(x => x.ClientStatus!.IsFinal && !CancelledClientStatusNames.Contains(x.ClientStatus.Name))
 258            if (orderIds is { Count: > 0 })
 259            {
 060                query = query.Where(x => orderIds.Contains(x.Id));
 261            }
 262            else
 263            {
 264                // ProcessingDate — наивное московское время (см. OrderControlService/OrderDailySummaryService),
 265                // сравниваем напрямую без ToUtc.
 266                var cutoff = TimeZoneInfo.ConvertTimeFromUtc(DateTime.UtcNow, RussianTimeZoneHelper.Zone).AddDays(-Unbou
 267                query = query.Where(x => x.ProcessingDate >= cutoff).OrderBy(x => x.ProcessingDate).ThenBy(x => x.Id).Ta
 268            }
 269
 270            var changes = await query
 271                .Select(x => new { x.Id, x.AppStatusId, PreviousStatus = x.AppStatus != null ? x.AppStatus.Name : null, 
 272                .ToListAsync(cancellationToken);
 273            if (changes.Count == 0)
 074                return 0;
 275
 276            var now = DateTime.Now;
 277            var historyTime = DateTime.UtcNow;
 578            var ids = changes.Select(x => x.Id).ToArray();
 279
 280            await using var transaction = await db.Database.BeginTransactionAsync(cancellationToken);
 281            var updated = await db.Orders.Where(x => ids.Contains(x.Id)).ExecuteUpdateAsync(setters => setters
 282                .SetProperty(x => x.AppStatusId, targetStatusId.Value)
 283                .SetProperty(x => x.LastStatusChangeDate, now)
 284                .SetProperty(x => x.UpdatedAt, now), cancellationToken);
 285
 886            var histories = changes.Where(x => x.AppStatusId.HasValue).Select(x => new OrderHistory
 587            {
 588                OrderId = x.Id,
 589                ColumnName = "AppStatusId",
 590                ColumnDisplayName = "Статус",
 591                OldValue = x.PreviousStatus ?? "Не указан",
 592                NewValue = targetStatusName,
 593                ChangedAt = historyTime,
 594                ChangedBy = string.IsNullOrWhiteSpace(changedBy) ? "Система: Ozon" : changedBy,
 595            });
 296            db.OrderHistories.AddRange(histories);
 297
 898            var transitionHistories = changes.Where(x => x.AppStatusId.HasValue).Select(x => new StatusTransitionHistory
 599            {
 5100                EntityId = x.Id,
 5101                EntityType = "Order",
 5102                FromStatusId = x.AppStatusId!.Value,
 5103                ToStatusId = targetStatusId.Value,
 5104                Comment = $"Автоматически: Ozon сообщил статус «{x.OzonStatus}». Системный статус: «{targetStatusName}».
 5105                PerformedAt = historyTime,
 5106                Metadata = $"{{\"source\":\"Ozon\",\"event\":\"{(isCancellation ? "Cancelled" : "Delivered")}StatusSynch
 5107            });
 2108            db.StatusTransitionHistory.AddRange(transitionHistories);
 2109
 2110            await db.SaveChangesAsync(cancellationToken);
 2111            await transaction.CommitAsync(cancellationToken);
 2112            return updated;
 2113        });
 4114    }
 115}