| | | 1 | | using Hangfire; |
| | | 2 | | using Microsoft.EntityFrameworkCore; |
| | | 3 | | using OzonDomains; |
| | | 4 | | using OzonRepositories.Context; |
| | | 5 | | using Services.HangfireService; |
| | | 6 | | |
| | | 7 | | namespace OzonOrdersWeb.WebServices.Orders; |
| | | 8 | | |
| | | 9 | | /// <summary> |
| | | 10 | | /// Страховка от потерянных пуш-уведомлений Ozon: по кругу опрашивает статусы незавершённых заказов |
| | | 11 | | /// пачками и записывает изменения. Без неё статус в гриде менялся только при ручном «Обновить» |
| | | 12 | | /// или пуше (с 28.08.2026 Ozon отключает адреса, отвечающие дольше 2,5 с). |
| | | 13 | | /// </summary> |
| | | 14 | | public sealed class OrdersClientStatusReconcileJob |
| | | 15 | | { |
| | | 16 | | public const string JobId = "orders-client-status-reconcile"; |
| | | 17 | | public const string DefaultCron = "*/10 * * * *"; |
| | | 18 | | |
| | | 19 | | /// <summary>Сколько заказов опрашиваем за один запуск (ограничивает нагрузку на API Ozon и БД).</summary> |
| | | 20 | | public const int BatchSize = 150; |
| | | 21 | | |
| | | 22 | | /// <summary>Заказы старше окна не опрашиваем — по ним статус уже не меняется.</summary> |
| | 0 | 23 | | public static readonly TimeSpan Window = TimeSpan.FromDays(45); |
| | | 24 | | |
| | | 25 | | /// <summary>Статусы Ozon, после которых опрос бессмысленен.</summary> |
| | 0 | 26 | | public static readonly string[] FinalStatusNames = |
| | 0 | 27 | | [ |
| | 0 | 28 | | "Доставлен", "Доставлено", "Получено", "Отменён", "Отменён из-за разделения отправления" |
| | 0 | 29 | | ]; |
| | | 30 | | |
| | | 31 | | private static int _cursor; |
| | | 32 | | |
| | | 33 | | private readonly ApplicationDbContext _context; |
| | | 34 | | private readonly IOrdersClientStatusSyncService _statusSync; |
| | | 35 | | private readonly ILogger<OrdersClientStatusReconcileJob> _logger; |
| | | 36 | | private readonly OrdersStatusSyncHealthStore _health; |
| | | 37 | | |
| | 0 | 38 | | public OrdersClientStatusReconcileJob( |
| | 0 | 39 | | ApplicationDbContext context, |
| | 0 | 40 | | IOrdersClientStatusSyncService statusSync, |
| | 0 | 41 | | ILogger<OrdersClientStatusReconcileJob> logger, |
| | 0 | 42 | | OrdersStatusSyncHealthStore health) |
| | | 43 | | { |
| | 0 | 44 | | _health = health; |
| | 0 | 45 | | _context = context; |
| | 0 | 46 | | _statusSync = statusSync; |
| | 0 | 47 | | _logger = logger; |
| | 0 | 48 | | } |
| | | 49 | | |
| | | 50 | | [Queue(HangfireQueueNames.OrderStatusSync)] |
| | | 51 | | [DisableConcurrentExecution(timeoutInSeconds: 0)] |
| | | 52 | | [AutomaticRetry(Attempts = 0)] |
| | | 53 | | public async Task<string> ExecuteAsync() |
| | | 54 | | { |
| | 0 | 55 | | var since = DateTime.Now - Window; |
| | 0 | 56 | | var candidates = _context.Orders |
| | 0 | 57 | | .AsNoTracking() |
| | 0 | 58 | | .Where(o => o.OrderArchive == null |
| | 0 | 59 | | && o.OzonClientId != null |
| | 0 | 60 | | && o.ShipmentNumber != null |
| | 0 | 61 | | && o.ProcessingDate >= since |
| | 0 | 62 | | && (o.ClientStatus == null || !FinalStatusNames.Contains(o.ClientStatus.Name))); |
| | | 63 | | |
| | | 64 | | // Курсор по Id: каждый запуск берёт следующую пачку, дойдя до конца — начинает с начала. |
| | 0 | 65 | | var cursor = Volatile.Read(ref _cursor); |
| | 0 | 66 | | var ids = await candidates.Where(o => o.Id > cursor).OrderBy(o => o.Id).Select(o => o.Id).Take(BatchSize).ToList |
| | 0 | 67 | | if (ids.Count == 0) |
| | | 68 | | { |
| | 0 | 69 | | Volatile.Write(ref _cursor, 0); |
| | 0 | 70 | | ids = await candidates.OrderBy(o => o.Id).Select(o => o.Id).Take(BatchSize).ToListAsync(); |
| | | 71 | | } |
| | | 72 | | |
| | 0 | 73 | | if (ids.Count == 0) |
| | 0 | 74 | | return "Нет заказов для опроса"; |
| | | 75 | | |
| | 0 | 76 | | Volatile.Write(ref _cursor, ids[^1]); |
| | 0 | 77 | | var result = await _statusSync.SyncOrderStatusesAsync(ids); |
| | 0 | 78 | | var failed = result.FailedOrderIds.Count; |
| | 0 | 79 | | var summary = result.Error ?? $"Опрошено {ids.Count}, ошибок запроса {failed}"; |
| | 0 | 80 | | _logger.LogInformation("{JobId}: {Summary}", JobId, summary); |
| | | 81 | | |
| | 0 | 82 | | if (_health.Record(ids.Count, failed, result.Error, out var nowProblem) && nowProblem) |
| | 0 | 83 | | _logger.LogError("Опрос статусов Ozon не получает ответов: {Details}", summary); |
| | | 84 | | |
| | 0 | 85 | | return summary; |
| | 0 | 86 | | } |
| | | 87 | | } |