< Summary

Information
Class: Services.OrderControlServices.OrderControlService
Assembly: Services
File(s): /home/runner/work/Studio2Prod/Studio2Prod/AppRepository/OrderControlServices/OrderControlService.cs
Line coverage
78%
Covered lines: 281
Uncovered lines: 76
Coverable lines: 357
Total lines: 533
Line coverage: 78.7%
Branch coverage
65%
Covered branches: 91
Total branches: 138
Branch coverage: 65.9%
Method coverage

Feature is only available for sponsors

Upgrade to PRO version

Metrics

MethodBranch coverage Crap Score Cyclomatic complexity Line coverage
.ctor(...)100%22100%
EvaluateAsync()82.35%353489.38%
GetAvailableTasksAsync()0%620%
GetAvailableTaskCountAsync()50%2287.5%
ClaimAsync()66.66%6683.33%
CloseAsync()0%2040%
RemoveFromQueueAsync()50%2292.3%
FindPermittedTaskAsync()25%5466.66%
GetCandidatesAsync()83.33%131281.81%
CandidateOrders(...)100%44100%
.cctor()100%11100%
GetNewUnprocessedAsync()100%22100%
GetSupplierOverdueAsync()100%210%
GetUnprocessedOver12HoursAsync()100%11100%
GetNoMovementAsync()100%11100%
ReadHoursParameter(...)66.66%66100%
LookbackCutoffLocal()100%11100%
StillRequiresAction(...)50%443681.81%
IsCancelledByMarketplace(...)50%88100%
IsOzonOrder(...)50%22100%
BuildKey(...)100%22100%
SelectDailyStatusRefreshBatch(...)100%88100%
ToUtc(...)100%11100%
NotifyCreatedTaskAsync()50%2266.66%
.ctor(...)100%11100%
get_Id()100%11100%
get_ShipmentNumber()100%11100%
get_ProcessingDate()100%11100%
get_LastStatusChangeDate()100%210%
get_AppStatusName()100%11100%
get_ClientStatusName()100%210%
get_SupplierExpectedDeliveryHours()100%11100%
get_Order()100%11100%
get_Candidate()100%11100%

File(s)

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

#LineLine coverage
 1using System.Linq.Expressions;
 2using System.Text.Json;
 3using Microsoft.EntityFrameworkCore;
 4using OzonDomains;
 5using OzonDomains.Models;
 6using OzonRepositories.Context;
 7using Services.BackgroundJobsServices;
 8using Services.SignalRServices;
 9
 10namespace Services.OrderControlServices;
 11
 12public sealed class OrderControlService : IOrderControlService
 13{
 14    private const int DefaultEvaluationIntervalMinutes = 5;
 15
 16    // Окно отбора кандидатов. Без него правила читали Orders за всю историю: на проде это давало
 17    // 30 356 кандидатов NoStatusMovement и 4 741 SupplierArrivalOverdue, которые каждые 5 минут
 18    // грузились полными сущностями и уходили в синк статусов Ozon. Заказ, застрявший больше двух
 19    // месяцев назад, уже либо имеет задачу, либо разбирается вручную — заводить по нему SLA поздно.
 20    private const int CandidateLookbackDays = 60;
 21
 22    // Потолок одного прохода актуализации статусов: за ним стоят HTTP-вызовы к Ozon и
 23    // UPDATE ... WHERE Id IN (...). Без потолка urgent-кандидаты попадали в батч целиком —
 24    // на проде ловились IN-списки на 18 707 идентификаторов.
 25    private const int MaxStatusRefreshBatch = 300;
 26
 27    private readonly ApplicationDbContext _db;
 28    private readonly IOrderDeliveryStatusSynchronizer _deliveryStatusSynchronizer;
 29    private readonly IOrderControlCandidateStatusSynchronizer _candidateStatusSynchronizer;
 30    private readonly TimeProvider _timeProvider;
 31
 1632    public OrderControlService(ApplicationDbContext db,
 1633        IOrderDeliveryStatusSynchronizer deliveryStatusSynchronizer,
 1634        IOrderControlCandidateStatusSynchronizer candidateStatusSynchronizer,
 1635        TimeProvider? timeProvider = null)
 36    {
 1637        _db = db;
 1638        _deliveryStatusSynchronizer = deliveryStatusSynchronizer;
 1639        _candidateStatusSynchronizer = candidateStatusSynchronizer;
 1640        _timeProvider = timeProvider ?? TimeProvider.System;
 1641    }
 42
 43    public async Task<int> EvaluateAsync(CancellationToken cancellationToken = default)
 44    {
 1545        var nowUtc = _timeProvider.GetUtcNow().UtcDateTime;
 1546        var localNow = TimeZoneInfo.ConvertTimeFromUtc(nowUtc, RussianTimeZoneHelper.Zone);
 1547        var settings = await _db.OrderControlSettings.AsNoTracking().FirstOrDefaultAsync(x => x.Id == 1, cancellationTok
 1548        var evaluationIntervalMinutes = Math.Clamp(settings?.IntervalMinutes ?? DefaultEvaluationIntervalMinutes, 1, 24 
 1549        await _deliveryStatusSynchronizer.SynchronizeCancelledAsync(cancellationToken: cancellationToken);
 1550        await _deliveryStatusSynchronizer.SynchronizeDeliveredAsync(cancellationToken: cancellationToken);
 1551        var rules = await _db.OrderControlRules.AsNoTracking().Where(x => x.IsActive).ToListAsync(cancellationToken);
 1552        var created = 0;
 53
 1554        var initialCandidates = new Dictionary<int, List<Candidate>>();
 5655        foreach (var rule in rules)
 1356            initialCandidates[rule.Id] = await GetCandidatesAsync(rule, localNow, null, cancellationToken);
 57
 2858        var rulesById = rules.ToDictionary(x => x.Id);
 1559        var candidateEntries = initialCandidates
 42160            .SelectMany(x => x.Value.Select(candidate => new CandidateEntry(
 42161                candidate,
 42162                BuildKey(rulesById[x.Key], candidate.Order, candidate.AnchorUtc, localNow))))
 1563            .ToList();
 42364        var candidateKeys = candidateEntries.Select(x => x.DeduplicationKey).Distinct().ToArray();
 1565        var existingKeys = candidateKeys.Length == 0
 1566            ? new HashSet<string>(StringComparer.Ordinal)
 1567            : (await _db.OrderControlTasks.AsNoTracking()
 1568                .Where(x => candidateKeys.Contains(x.DeduplicationKey))
 1569                .Select(x => x.DeduplicationKey)
 1570                .ToListAsync(cancellationToken)).ToHashSet(StringComparer.Ordinal);
 1571        var activeOrderIds = await _db.OrderControlTasks.AsNoTracking()
 1572            .Where(x => OrderControlTaskStatus.Active.Contains(x.Status) &&
 1573                        x.Order != null && x.Order.OzonClient != null && x.Order.OzonClient.ClientType == ClientType.OZO
 1574            .OrderByDescending(x => x.Status == OrderControlTaskStatus.Overdue)
 1575            .ThenBy(x => x.DueAtUtc)
 1576            .Select(x => x.OrderId)
 1577            .ToListAsync(cancellationToken);
 1578        var refreshCandidates = candidateEntries
 40879            .Where(x => !existingKeys.Contains(x.DeduplicationKey))
 40780            .OrderBy(x => x.Candidate.WarningAtUtc)
 40781            .ThenBy(x => x.Candidate.DueAtUtc)
 40782            .ThenBy(x => x.Candidate.Order.ProcessingDate)
 1583            .ToList();
 1584        var urgentCandidateIds = refreshCandidates
 40785            .Where(x => x.Candidate.WarningAtUtc <= nowUtc)
 40786            .Select(x => x.Candidate.Order.Id)
 1587            .Distinct()
 1588            .ToArray();
 1589        var allRefreshOrderIds = refreshCandidates
 40790            .Select(x => x.Candidate.Order.Id)
 1591            .Concat(activeOrderIds)
 1592            .Distinct()
 1593            .ToArray();
 1594        var orderIdsToRefresh = SelectDailyStatusRefreshBatch(allRefreshOrderIds, urgentCandidateIds, nowUtc, evaluation
 1595            .ToHashSet();
 96        // Набор заказов, чей статус реально удалось обновить в этом проходе — а не общий флаг успеха
 97        // на весь батч. Раньше одна неудавшаяся заявка к Ozon (таймаут по конкретному shipment'у) блокировала
 98        // создание задач для ВСЕХ кандидатов тика, включая никак не связанные с этим заказы.
 1599        var refreshedOrderIds = await _candidateStatusSynchronizer.SynchronizeAsync(orderIdsToRefresh, cancellationToken
 100
 15101        var createdKeys = new HashSet<string>(existingKeys, StringComparer.Ordinal);
 15102        var refreshedIdList = refreshedOrderIds.ToArray();
 56103        foreach (var rule in rules)
 104        {
 105            // После ответа Ozon перечитываем кандидатов: отменённые и доставленные заказы в SLA не попадут.
 106            // Сужаем до заказов, чей статус реально обновился: по остальным задача всё равно не создаётся,
 107            // а без сужения это был второй полный проход по Orders за тик.
 13108            var candidates = await GetCandidatesAsync(rule, localNow, refreshedIdList, cancellationToken);
 638109            foreach (var candidate in candidates)
 110            {
 306111                var key = BuildKey(rule, candidate.Order, candidate.AnchorUtc, localNow);
 306112                if (!createdKeys.Add(key))
 113                    continue;
 114
 305115                var task = new OrderControlTask
 305116                {
 305117                    RuleId = rule.Id,
 305118                    OrderId = candidate.Order.Id,
 305119                    DeduplicationKey = key,
 305120                    Priority = rule.Priority,
 305121                    TargetAppRoleId = rule.TargetAppRoleId,
 305122                    WarningAtUtc = candidate.WarningAtUtc,
 305123                    DueAtUtc = candidate.DueAtUtc,
 305124                    Status = nowUtc >= candidate.DueAtUtc ? OrderControlTaskStatus.Overdue : OrderControlTaskStatus.Open
 305125                    SnapshotJson = JsonSerializer.Serialize(new
 305126                    {
 305127                        candidate.Reason,
 305128                        ShipmentNumber = candidate.Order.ShipmentNumber,
 305129                        Status = candidate.Order.AppStatusName,
 305130                        candidate.AnchorUtc
 305131                    }),
 305132                    CreatedAtUtc = nowUtc,
 305133                    UpdatedAtUtc = nowUtc
 305134                };
 305135                _db.OrderControlTasks.Add(task);
 136                try
 137                {
 305138                    await _db.SaveChangesAsync(cancellationToken);
 305139                }
 0140                catch (DbUpdateException)
 141                {
 142                    // Уникальный индекс на DeduplicationKey: параллельный запуск EvaluateAsync
 143                    // (второй сервер, ручной RunNow) уже создал эту же задачу — не роняем весь проход.
 0144                    _db.Entry(task).State = EntityState.Detached;
 0145                    continue;
 146                }
 305147                _db.OrderControlTaskEvents.Add(new OrderControlTaskEvent
 305148                {
 305149                    TaskId = task.Id,
 305150                    EventType = task.Status == OrderControlTaskStatus.Overdue ? "CreatedOverdue" : "Created",
 305151                    Details = candidate.Reason,
 305152                    CreatedAtUtc = nowUtc
 305153                });
 305154                await _db.SaveChangesAsync(cancellationToken);
 305155                await NotifyCreatedTaskAsync(task, candidate.Order, cancellationToken);
 305156                created++;
 305157            }
 13158        }
 159
 15160        var active = await _db.OrderControlTasks
 15161            .Include(x => x.Order).ThenInclude(x => x!.AppStatus)
 15162            .Include(x => x.Order).ThenInclude(x => x!.ClientStatus)
 15163            .Include(x => x.Order).ThenInclude(x => x!.OzonClient)
 15164            .Include(x => x.Rule)
 15165            .Where(x => OrderControlTaskStatus.Active.Contains(x.Status))
 15166            .ToListAsync(cancellationToken);
 684167        foreach (var task in active)
 168        {
 327169            if (task.Order is null || task.Rule is null)
 170                continue;
 327171            if (!IsOzonOrder(task.Order))
 172            {
 1173                task.Status = OrderControlTaskStatus.Completed;
 1174                task.ClosedAtUtc = nowUtc;
 1175                task.CloseReason = "Закрыто автоматически: OrderControl применяется только к заказам Ozon.";
 1176                task.UpdatedAtUtc = nowUtc;
 1177                _db.OrderControlTaskEvents.Add(new OrderControlTaskEvent { TaskId = task.Id, EventType = "AutoCompleted"
 1178                continue;
 179            }
 326180            if (!StillRequiresAction(task.Rule.RuleType, task.Order, localNow))
 181            {
 0182                task.Status = OrderControlTaskStatus.Completed;
 0183                task.ClosedAtUtc = nowUtc;
 0184                task.CloseReason = "Закрыто автоматически: состояние заказа изменилось.";
 0185                task.UpdatedAtUtc = nowUtc;
 0186                _db.OrderControlTaskEvents.Add(new OrderControlTaskEvent { TaskId = task.Id, EventType = "AutoCompleted"
 187            }
 326188            else if (nowUtc >= task.DueAtUtc && task.Status != OrderControlTaskStatus.Overdue)
 189            {
 0190                task.Status = OrderControlTaskStatus.Overdue;
 0191                task.EscalatedAtUtc ??= nowUtc;
 0192                task.UpdatedAtUtc = nowUtc;
 0193                _db.OrderControlTaskEvents.Add(new OrderControlTaskEvent { TaskId = task.Id, EventType = "Overdue", Crea
 194            }
 195        }
 15196        await _db.SaveChangesAsync(cancellationToken);
 15197        return created;
 15198    }
 199
 200    public async Task<IReadOnlyList<OrderControlTask>> GetAvailableTasksAsync(string userId, bool isAdmin, CancellationT
 201    {
 0202        var roleIds = await _db.UserAppRoles.Where(x => x.UserId == userId).Select(x => x.AppRoleId).ToListAsync(cancell
 0203        var query = _db.OrderControlTasks.AsNoTracking()
 0204            .Include(x => x.Order).ThenInclude(x => x!.AppStatus).ThenInclude(x => x!.StatusColor)
 0205            .Include(x => x.Order).ThenInclude(x => x!.ClientStatus).ThenInclude(x => x!.StatusColor)
 0206            .Include(x => x.Order).ThenInclude(x => x!.Supplier)
 0207            .Include(x => x.Order).ThenInclude(x => x!.ProductInfo)
 0208            .Include(x => x.Order).ThenInclude(x => x!.EtProducer)
 0209            .Include(x => x.Order).ThenInclude(x => x!.Manufacturer)
 0210            .Include(x => x.Order).ThenInclude(x => x!.OzonClient)
 0211            .Include(x => x.Rule).Include(x => x.TargetAppRole)
 0212            .Where(x => OrderControlTaskStatus.Active.Contains(x.Status) &&
 0213                        x.Order != null && x.Order.OzonClient != null && x.Order.OzonClient.ClientType == ClientType.OZO
 0214        if (!isAdmin)
 0215            query = query.Where(x => roleIds.Contains(x.TargetAppRoleId));
 0216        return await query.OrderByDescending(x => x.Status == OrderControlTaskStatus.Overdue)
 0217            .ThenBy(x => x.DueAtUtc)
 0218            .ThenBy(x => x.Order!.ProcessingDate)
 0219            .ToListAsync(cancellationToken);
 0220    }
 221
 222    public async Task<int> GetAvailableTaskCountAsync(string userId, bool isAdmin, CancellationToken cancellationToken =
 223    {
 1224        var roleIds = await _db.UserAppRoles.Where(x => x.UserId == userId).Select(x => x.AppRoleId).ToListAsync(cancell
 1225        var query = _db.OrderControlTasks.AsNoTracking()
 1226            .Where(x => OrderControlTaskStatus.Active.Contains(x.Status) &&
 1227                        x.Order != null && x.Order.OzonClient != null && x.Order.OzonClient.ClientType == ClientType.OZO
 1228        if (!isAdmin)
 0229            query = query.Where(x => roleIds.Contains(x.TargetAppRoleId));
 1230        return await query.CountAsync(cancellationToken);
 1231    }
 232
 233    public async Task<bool> ClaimAsync(long taskId, string userId, bool isAdmin, CancellationToken cancellationToken = d
 234    {
 2235        if (!isAdmin)
 236        {
 0237            var permitted = await FindPermittedTaskAsync(taskId, userId, isAdmin, cancellationToken);
 0238            if (permitted is null)
 0239                return false;
 240        }
 241
 242        // ExecuteUpdateAsync с условием ClaimedByUserId == null в WHERE делает захват атомарным —
 243        // read-then-write через сущность допускал бы двух пользователей, claim'ящих одну задачу одновременно.
 2244        var now = DateTime.UtcNow;
 2245        var updated = await _db.OrderControlTasks
 2246            .Where(x => x.Id == taskId && OrderControlTaskStatus.Active.Contains(x.Status) && x.ClaimedByUserId == null)
 2247            .ExecuteUpdateAsync(setters => setters
 2248                .SetProperty(x => x.ClaimedByUserId, userId)
 2249                .SetProperty(x => x.ClaimedAtUtc, now)
 2250                .SetProperty(x => x.Status, OrderControlTaskStatus.InProgress)
 2251                .SetProperty(x => x.UpdatedAtUtc, now), cancellationToken);
 2252        if (updated == 0)
 1253            return false;
 1254        _db.OrderControlTaskEvents.Add(new OrderControlTaskEvent { TaskId = taskId, EventType = "Claimed", UserId = user
 1255        await _db.SaveChangesAsync(cancellationToken);
 1256        return true;
 2257    }
 258
 259    public async Task<bool> CloseAsync(long taskId, string userId, string reason, bool isAdmin, CancellationToken cancel
 260    {
 0261        if (string.IsNullOrWhiteSpace(reason))
 0262            return false;
 0263        var task = await FindPermittedTaskAsync(taskId, userId, isAdmin, cancellationToken);
 0264        if (task is null)
 0265            return false;
 0266        task.Status = OrderControlTaskStatus.Closed;
 0267        task.ClosedAtUtc = DateTime.UtcNow;
 0268        task.ClosedByUserId = userId;
 0269        task.CloseReason = reason.Trim();
 0270        task.UpdatedAtUtc = DateTime.UtcNow;
 0271        _db.OrderControlTaskEvents.Add(new OrderControlTaskEvent { TaskId = task.Id, EventType = "ClosedManually", UserI
 0272        await _db.SaveChangesAsync(cancellationToken);
 0273        return true;
 0274    }
 275
 276    public async Task<bool> RemoveFromQueueAsync(long taskId, string userId, bool isAdmin, CancellationToken cancellatio
 277    {
 1278        var task = await FindPermittedTaskAsync(taskId, userId, isAdmin, cancellationToken);
 1279        if (task is null)
 0280            return false;
 281
 282        // Статус, а не физическое удаление: сохраняет OrderControlTaskEvents (аудит) и не даёт
 283        // ближайшему EvaluateAsync тут же пересоздать ту же задачу по свободному DeduplicationKey.
 1284        var now = DateTime.UtcNow;
 1285        task.Status = OrderControlTaskStatus.Removed;
 1286        task.ClosedAtUtc = now;
 1287        task.ClosedByUserId = userId;
 1288        task.CloseReason = "Удалено из очереди без указания причины.";
 1289        task.UpdatedAtUtc = now;
 1290        _db.OrderControlTaskEvents.Add(new OrderControlTaskEvent { TaskId = task.Id, EventType = "RemovedFromQueue", Use
 1291        await _db.SaveChangesAsync(cancellationToken);
 1292        return true;
 1293    }
 294
 295    private async Task<OrderControlTask?> FindPermittedTaskAsync(long taskId, string userId, bool isAdmin, CancellationT
 296    {
 1297        var task = await _db.OrderControlTasks.FirstOrDefaultAsync(x => x.Id == taskId && OrderControlTaskStatus.Active.
 1298        if (task is null || isAdmin)
 1299            return task;
 0300        var allowed = await _db.UserAppRoles.AnyAsync(x => x.UserId == userId && x.AppRoleId == task.TargetAppRoleId, ct
 0301        return allowed ? task : null;
 1302    }
 303
 304    /// <summary>
 305    /// Кандидаты правила. <paramref name="restrictToOrderIds"/> сужает выборку до конкретных заказов —
 306    /// на повторном проходе после актуализации статусов задачи всё равно создаются только для них.
 307    /// </summary>
 308    private async Task<List<Candidate>> GetCandidatesAsync(OrderControlRule rule, DateTime localNow,
 309        IReadOnlyCollection<int>? restrictToOrderIds, CancellationToken ct)
 310    {
 26311        if (restrictToOrderIds is { Count: 0 })
 6312            return [];
 313
 20314        return rule.RuleType switch
 20315        {
 3316            "NewUnprocessedToday" => await GetNewUnprocessedAsync(localNow, restrictToOrderIds, ct),
 15317            "UnprocessedOver12Hours" => await GetUnprocessedOver12HoursAsync(rule, restrictToOrderIds, ct),
 0318            "SupplierArrivalOverdue" => await GetSupplierOverdueAsync(restrictToOrderIds, ct),
 2319            "NoStatusMovement" => await GetNoMovementAsync(rule, restrictToOrderIds, ct),
 0320            _ => []
 20321        };
 26322    }
 323
 324    /// <summary>
 325    /// Базовый запрос кандидатов — без Include: правилам нужны только идентификатор, номер
 326    /// отправления, даты и имена статусов, а не граф Order целиком (на проде это были десятки
 327    /// тысяч полных сущностей за прогон). Проекция навешивается последней, уже поверх фильтров:
 328    /// EF не транслирует Where поверх конструктора record.
 329    /// </summary>
 330    private IQueryable<Order> CandidateOrders(IReadOnlyCollection<int>? restrictToOrderIds)
 331    {
 19332        var query = _db.Orders.AsNoTracking()
 19333            .Where(x => x.OzonClient != null && x.OzonClient.ClientType == ClientType.OZON);
 19334        return restrictToOrderIds is { Count: > 0 }
 19335            ? query.Where(x => restrictToOrderIds.Contains(x.Id))
 19336            : query;
 337    }
 338
 1339    private static readonly Expression<Func<Order, CandidateOrder>> ToCandidateOrder = x => new CandidateOrder(
 1340        x.Id,
 1341        x.ShipmentNumber,
 1342        x.ProcessingDate,
 1343        x.LastStatusChangeDate,
 1344        x.AppStatus != null ? x.AppStatus.Name : null,
 1345        x.ClientStatus != null ? x.ClientStatus.Name : null,
 1346        x.Supplier != null ? x.Supplier.ExpectedDeliveryHours : null);
 347
 348    private async Task<List<Candidate>> GetNewUnprocessedAsync(DateTime localNow,
 349        IReadOnlyCollection<int>? restrictToOrderIds, CancellationToken ct)
 350    {
 351        // ProcessingDate хранится как наивное московское время (см. OrderDailySummaryService),
 352        // поэтому границы дня сравниваются напрямую, без ToUtc — иначе окно съезжает на 3 часа.
 4353        if (localNow.TimeOfDay < new TimeSpan(15, 30, 0)) return [];
 2354        var start = localNow.Date;
 2355        var end = start.AddDays(1);
 2356        var orders = await CandidateOrders(restrictToOrderIds)
 2357            .Where(x => x.ProcessingDate >= start && x.ProcessingDate < end &&
 2358                        (x.AppStatus == null || x.AppStatus.Name == "Не указан") &&
 2359                        (x.ClientStatus == null || (x.ClientStatus.Name != "Отменен" && x.ClientStatus.Name != "Отменён"
 2360            .Select(ToCandidateOrder)
 2361            .ToListAsync(ct);
 2362        var warning = ToUtc(start.AddHours(15).AddMinutes(30));
 2363        var due = ToUtc(start.AddHours(16));
 4364        return orders.Select(x => new Candidate(x, ToUtc(x.ProcessingDate ?? start), warning, due, "Заказ создан сегодня
 3365    }
 366
 367    private async Task<List<Candidate>> GetSupplierOverdueAsync(IReadOnlyCollection<int>? restrictToOrderIds, Cancellati
 368    {
 0369        var lookbackCutoff = LookbackCutoffLocal();
 0370        var orders = await CandidateOrders(restrictToOrderIds)
 0371            .Where(x => x.AppStatus != null && x.AppStatus.Name == "Заказан поставщику" &&
 0372                        (x.ClientStatus == null || (x.ClientStatus.Name != "Отменен" && x.ClientStatus.Name != "Отменён"
 0373                        x.Supplier != null && x.Supplier.ExpectedDeliveryHours != null &&
 0374                        x.LastStatusChangeDate != null && x.LastStatusChangeDate >= lookbackCutoff)
 0375            .Select(ToCandidateOrder)
 0376            .ToListAsync(ct);
 0377        return orders.Select(x =>
 0378        {
 0379            var anchor = ToUtc(x.LastStatusChangeDate!.Value);
 0380            var due = anchor.AddHours(x.SupplierExpectedDeliveryHours!.Value);
 0381            return new Candidate(x, anchor, due, due, "Истёк норматив поставщика.");
 0382        }).Where(x => DateTime.UtcNow >= x.WarningAtUtc).ToList();
 0383    }
 384
 385    private async Task<List<Candidate>> GetUnprocessedOver12HoursAsync(OrderControlRule rule,
 386        IReadOnlyCollection<int>? restrictToOrderIds, CancellationToken ct)
 387    {
 15388        var processingTimeoutHours = ReadHoursParameter(rule, defaultHours: 12);
 15389        var nowUtc = DateTime.UtcNow;
 15390        var lookbackCutoff = LookbackCutoffLocal();
 15391        var orders = await CandidateOrders(restrictToOrderIds)
 15392            .Where(x => x.ProcessingDate != null && x.ProcessingDate >= lookbackCutoff &&
 15393                        (x.AppStatus == null || x.AppStatus.Name == "Не указан"))
 15394            .Select(ToCandidateOrder)
 15395            .ToListAsync(ct);
 396
 15397        return orders.Select(x =>
 15398        {
 713399            var anchor = ToUtc(x.ProcessingDate!.Value);
 713400            var due = anchor.AddHours(processingTimeoutHours);
 713401            return new Candidate(x, anchor, due, due,
 713402                $"Заказ более {processingTimeoutHours} часов находится без системного статуса.");
 728403        }).Where(x => x.DueAtUtc <= nowUtc).ToList();
 15404    }
 405
 406    private async Task<List<Candidate>> GetNoMovementAsync(OrderControlRule rule,
 407        IReadOnlyCollection<int>? restrictToOrderIds, CancellationToken ct)
 408    {
 2409        var hours = ReadHoursParameter(rule, defaultHours: 24);
 2410        var cutoff = DateTime.UtcNow.AddHours(-hours);
 2411        var lookbackCutoff = LookbackCutoffLocal();
 2412        var orders = await CandidateOrders(restrictToOrderIds)
 2413            .Where(x => x.LastStatusChangeDate != null && x.LastStatusChangeDate <= cutoff &&
 2414                        x.LastStatusChangeDate >= lookbackCutoff && x.AppStatus != null &&
 2415                        x.AppStatus.Name != "Отменен" && x.AppStatus.Name != "Доставлен" &&
 2416                        (x.ClientStatus == null || (x.ClientStatus.Name != "Отменен" && x.ClientStatus.Name != "Отменён"
 2417            .Select(ToCandidateOrder)
 2418            .ToListAsync(ct);
 2419        return orders.Select(x => new Candidate(x, ToUtc(x.LastStatusChangeDate!.Value), ToUtc(x.LastStatusChangeDate!.V
 2420    }
 421
 422    private static int ReadHoursParameter(OrderControlRule rule, int defaultHours)
 423    {
 17424        if (string.IsNullOrWhiteSpace(rule.ParametersJson))
 1425            return defaultHours;
 16426        using var json = JsonDocument.Parse(rule.ParametersJson);
 16427        return json.RootElement.TryGetProperty("hours", out var value) && value.TryGetInt32(out var configured)
 16428            ? Math.Clamp(configured, 1, 24 * 30)
 16429            : defaultHours;
 16430    }
 431
 432    /// <summary>
 433    /// Нижняя граница окна кандидатов. ProcessingDate и LastStatusChangeDate — наивное московское
 434    /// время, поэтому граница считается в нём же, без ToUtc.
 435    /// </summary>
 436    private DateTime LookbackCutoffLocal() =>
 17437        TimeZoneInfo.ConvertTimeFromUtc(_timeProvider.GetUtcNow().UtcDateTime, RussianTimeZoneHelper.Zone)
 17438            .AddDays(-CandidateLookbackDays);
 439
 326440    private static bool StillRequiresAction(string ruleType, Order order, DateTime localNow) => ruleType switch
 326441    {
 1442        "NewUnprocessedToday" => order.ProcessingDate is not null &&
 1443                                  order.ProcessingDate >= localNow.Date &&
 1444                                  order.ProcessingDate < localNow.Date.AddDays(1) &&
 1445                                  (order.AppStatus is null || order.AppStatus.Name == "Не указан"),
 305446        "UnprocessedOver12Hours" => order.ProcessingDate is not null && (order.AppStatus is null || order.AppStatus.Name
 0447        "SupplierArrivalOverdue" => order.AppStatus?.Name == "Заказан поставщику" && !IsCancelledByMarketplace(order),
 20448        "NoStatusMovement" => order.AppStatus?.Name is not ("Отменен" or "Доставлен") && !IsCancelledByMarketplace(order
 0449        _ => false
 326450    };
 451
 452    private static bool IsCancelledByMarketplace(Order order) =>
 20453        order.ClientStatus?.Name is "Отменен" or "Отменён" or "Отменено";
 454
 327455    private static bool IsOzonOrder(Order order) => order.OzonClient?.ClientType == ClientType.OZON;
 456
 714457    private static string BuildKey(OrderControlRule rule, CandidateOrder order, DateTime anchorUtc, DateTime localNow) =
 714458        ? $"{rule.Id}:{order.Id}:{localNow:yyyyMMdd}"
 714459        : $"{rule.Id}:{order.Id}:{anchorUtc:yyyyMMddHHmmss}";
 460
 461    /// <summary>
 462    /// Делит очередь актуализации статусов на циклические батчи в сутки — число батчей считается
 463    /// от фактического интервала прогона (OrderControlSettings.IntervalMinutes), а не от константы,
 464    /// иначе при интервале, отличном от 5 минут, часть заказов никогда не попадёт в свой слот.
 465    /// Задачи с наступившим предупреждением проверяются без ожидания своего слота, но и они
 466    /// подчиняются потолку <see cref="MaxStatusRefreshBatch"/>: за батчем стоят HTTP-вызовы к Ozon,
 467    /// и неограниченный urgent растягивал один прогон на десятки минут. Порядок urgent сохраняется
 468    /// (вызывающий передаёт их отсортированными по времени предупреждения), поэтому при обрезке
 469    /// первыми проверяются самые срочные, а остальные догоняют следующими тиками.
 470    /// </summary>
 471    private static IReadOnlyList<int> SelectDailyStatusRefreshBatch(
 472        IReadOnlyCollection<int> allOrderIds,
 473        IReadOnlyCollection<int> urgentOrderIds,
 474        DateTime nowUtc,
 475        int evaluationIntervalMinutes)
 476    {
 15477        var all = allOrderIds.Distinct().ToArray();
 15478        if (all.Length == 0)
 6479            return [];
 480
 9481        var allSet = all.ToHashSet();
 9482        var urgent = urgentOrderIds.Where(allSet.Contains).Distinct().Take(MaxStatusRefreshBatch).ToArray();
 9483        var urgentSet = urgent.ToHashSet();
 558484        var rotating = all.Where(x => !urgentSet.Contains(x)).OrderBy(x => x).ToArray();
 9485        var remainingCapacity = MaxStatusRefreshBatch - urgent.Length;
 9486        if (rotating.Length == 0 || remainingCapacity <= 0)
 6487            return urgent;
 488
 3489        var evaluationsPerDay = Math.Max(1, 24 * 60 / evaluationIntervalMinutes);
 3490        var batchSize = Math.Max(1, (int)Math.Ceiling(all.Length / (double)evaluationsPerDay));
 3491        batchSize = Math.Min(batchSize, remainingCapacity);
 3492        var slot = (int)(nowUtc.TimeOfDay.TotalMinutes / evaluationIntervalMinutes) % evaluationsPerDay;
 3493        var start = (slot * batchSize) % rotating.Length;
 3494        var batch = new List<int>(urgent.Length + batchSize);
 3495        batch.AddRange(urgent);
 30496        for (var index = 0; index < batchSize; index++)
 12497            batch.Add(rotating[(start + index) % rotating.Length]);
 3498        return batch;
 499    }
 500
 719501    private static DateTime ToUtc(DateTime local) => TimeZoneInfo.ConvertTimeToUtc(DateTime.SpecifyKind(local, DateTimeK
 502    private async Task NotifyCreatedTaskAsync(OrderControlTask task, CandidateOrder order, CancellationToken ct)
 503    {
 504        try
 505        {
 305506            var users = await _db.UserAppRoles.Where(x => x.AppRoleId == task.TargetAppRoleId).Select(x => x.UserId).ToL
 305507            await NotificationService.NotifyUsersAsync(users, "Контроль заказов", "warning", $"Требует действия заказ {o
 0508            _db.OrderControlTaskEvents.Add(new OrderControlTaskEvent { TaskId = task.Id, EventType = "SignalRDelivered",
 0509            await _db.SaveChangesAsync(ct);
 0510        }
 305511        catch (Exception ex)
 512        {
 305513            _db.OrderControlTaskEvents.Add(new OrderControlTaskEvent { TaskId = task.Id, EventType = "SignalRFailed", De
 305514            await _db.SaveChangesAsync(ct);
 515        }
 305516    }
 517
 518    /// <summary>
 519    /// Проекция заказа под нужды правил SLA — вместо графа Order с Include на AppStatus,
 520    /// ClientStatus и Supplier.
 521    /// </summary>
 715522    private sealed record CandidateOrder(
 1833523        int Id,
 610524        string? ShipmentNumber,
 1122525        DateTime? ProcessingDate,
 0526        DateTime? LastStatusChangeDate,
 305527        string? AppStatusName,
 0528        string? ClientStatusName,
 715529        int? SupplierExpectedDeliveryHours);
 530
 8348531    private sealed record Candidate(CandidateOrder Order, DateTime AnchorUtc, DateTime WarningAtUtc, DateTime DueAtUtc, 
 3666532    private sealed record CandidateEntry(Candidate Candidate, string DeduplicationKey);
 533}

Methods/Properties

.ctor(OzonRepositories.Context.ApplicationDbContext,Services.OrderControlServices.IOrderDeliveryStatusSynchronizer,Services.OrderControlServices.IOrderControlCandidateStatusSynchronizer,System.TimeProvider)
EvaluateAsync()
GetAvailableTasksAsync()
GetAvailableTaskCountAsync()
ClaimAsync()
CloseAsync()
RemoveFromQueueAsync()
FindPermittedTaskAsync()
GetCandidatesAsync()
CandidateOrders(System.Collections.Generic.IReadOnlyCollection`1<System.Int32>)
.cctor()
GetNewUnprocessedAsync()
GetSupplierOverdueAsync()
GetUnprocessedOver12HoursAsync()
GetNoMovementAsync()
ReadHoursParameter(OzonDomains.Models.OrderControlRule,System.Int32)
LookbackCutoffLocal()
StillRequiresAction(System.String,OzonDomains.Models.Order,System.DateTime)
IsCancelledByMarketplace(OzonDomains.Models.Order)
IsOzonOrder(OzonDomains.Models.Order)
BuildKey(OzonDomains.Models.OrderControlRule,Services.OrderControlServices.OrderControlService/CandidateOrder,System.DateTime,System.DateTime)
SelectDailyStatusRefreshBatch(System.Collections.Generic.IReadOnlyCollection`1<System.Int32>,System.Collections.Generic.IReadOnlyCollection`1<System.Int32>,System.DateTime,System.Int32)
ToUtc(System.DateTime)
NotifyCreatedTaskAsync()
.ctor(System.Int32,System.String,System.Nullable`1<System.DateTime>,System.Nullable`1<System.DateTime>,System.String,System.String,System.Nullable`1<System.Int32>)
get_Id()
get_ShipmentNumber()
get_ProcessingDate()
get_LastStatusChangeDate()
get_AppStatusName()
get_ClientStatusName()
get_SupplierExpectedDeliveryHours()
get_Order()
get_Candidate()