| | | 1 | | using System.Linq.Expressions; |
| | | 2 | | using Microsoft.EntityFrameworkCore; |
| | | 3 | | using Microsoft.Extensions.Options; |
| | | 4 | | using OzonDomains.Models; |
| | | 5 | | using OzonDomains.Models.SupplierStatuses; |
| | | 6 | | using OzonRepositories.Context; |
| | | 7 | | |
| | | 8 | | namespace OzonOrdersWeb.WebServices.SystemAlerts; |
| | | 9 | | |
| | | 10 | | public enum SupplierStatusOutcome |
| | | 11 | | { |
| | | 12 | | Other, |
| | | 13 | | Rejected, |
| | | 14 | | Delivered |
| | | 15 | | } |
| | | 16 | | |
| | | 17 | | /// <summary> |
| | | 18 | | /// В справочнике статусов поставщиков нет признака «отказ»: статусы приходят из API как есть |
| | | 19 | | /// (у ML-Auto — просто «Код 6»). Классификация идёт по коду статуса (точно) и по названию (по ключевым словам). |
| | | 20 | | /// </summary> |
| | | 21 | | public static class SupplierStatusClassifier |
| | | 22 | | { |
| | | 23 | | public static SupplierStatusOutcome Classify(string providerKey, string? externalCode, string? name, SystemAlertsOpt |
| | | 24 | | { |
| | 42 | 25 | | if (!string.IsNullOrWhiteSpace(externalCode)) |
| | | 26 | | { |
| | 42 | 27 | | if (settings.SupplierRejectedStatusCodes.TryGetValue(providerKey, out var rejected) && |
| | 42 | 28 | | rejected.Contains(externalCode, StringComparer.OrdinalIgnoreCase)) |
| | 1 | 29 | | return SupplierStatusOutcome.Rejected; |
| | 41 | 30 | | if (settings.SupplierDeliveredStatusCodes.TryGetValue(providerKey, out var delivered) && |
| | 41 | 31 | | delivered.Contains(externalCode, StringComparer.OrdinalIgnoreCase)) |
| | 2 | 32 | | return SupplierStatusOutcome.Delivered; |
| | | 33 | | } |
| | | 34 | | |
| | 39 | 35 | | var text = $"{name} {externalCode}".ToLowerInvariant(); |
| | 354 | 36 | | if (settings.SupplierRejectedKeywords.Any(x => text.Contains(x, StringComparison.OrdinalIgnoreCase)) && |
| | 56 | 37 | | !settings.SupplierRejectedExcludeKeywords.Any(x => text.Contains(x, StringComparison.OrdinalIgnoreCase))) |
| | 16 | 38 | | return SupplierStatusOutcome.Rejected; |
| | 178 | 39 | | return settings.SupplierDeliveredKeywords.Any(x => text.Contains(x, StringComparison.OrdinalIgnoreCase)) |
| | 23 | 40 | | ? SupplierStatusOutcome.Delivered |
| | 23 | 41 | | : SupplierStatusOutcome.Other; |
| | | 42 | | } |
| | | 43 | | } |
| | | 44 | | |
| | | 45 | | /// <summary> |
| | | 46 | | /// Исход заказов у API-поставщиков: не оформился, поставщик отказал, статус неизвестен, просрочка поставки, |
| | | 47 | | /// расхождение с отменой в Ozon, двойной заказ. Анализируются только свежие заказы (<see cref="SystemAlertsOptions.Supp |
| | | 48 | | /// </summary> |
| | | 49 | | public sealed class SupplierOrdersSystemAlertSource(ApplicationDbContext context, IOptions<SystemAlertsOptions> options) |
| | | 50 | | { |
| | | 51 | | private const int MaxOrdersInLine = 8; |
| | | 52 | | |
| | | 53 | | /// <summary> |
| | | 54 | | /// Состояния заказов у поставщиков, по которым ещё нужны действия. Заказ, который в Ozon уже доставлен или |
| | | 55 | | /// отменён, считается обработанным: ошибки поставщика по нему не показываем. |
| | | 56 | | /// </summary> |
| | | 57 | | private IQueryable<OrderSupplierState> ActiveStates(SystemAlertsOptions settings, bool excludeCancelled = true) => |
| | | 58 | | context.OrderSupplierStates.AsNoTracking().Where(OzonOrderRelevance.NotHandledByOzon(settings, excludeCancelled) |
| | | 59 | | |
| | | 60 | | public async Task<IReadOnlyList<SystemAlertFinding>> CollectAsync(CancellationToken cancellationToken = default) |
| | | 61 | | { |
| | | 62 | | var settings = options.Value; |
| | | 63 | | if (!settings.SupplierChecksEnabled) return []; |
| | | 64 | | |
| | | 65 | | var now = DateTime.UtcNow; |
| | | 66 | | var maxAgeSince = now.AddDays(-Math.Max(1, settings.SupplierOrderMaxAgeDays)); |
| | | 67 | | var statuses = await LoadStatusesAsync(settings, cancellationToken); |
| | | 68 | | var Provider = await LoadProviderNamesAsync(cancellationToken); |
| | | 69 | | |
| | | 70 | | var findings = new List<SystemAlertFinding>(); |
| | | 71 | | await AddCartProblemsAsync(findings, settings, now, maxAgeSince, statuses, Provider, cancellationToken); |
| | | 72 | | await AddRejectedAsync(findings, settings, now, statuses, Provider, cancellationToken); |
| | | 73 | | await AddBlindAsync(findings, settings, now, Provider, cancellationToken); |
| | | 74 | | await AddOverdueAndStaleAsync(findings, settings, now, maxAgeSince, statuses, Provider, cancellationToken); |
| | | 75 | | await AddOzonCancelledAsync(findings, settings, now, statuses, Provider, cancellationToken); |
| | | 76 | | await AddDoubleOrdersAsync(findings, settings, maxAgeSince, statuses, Provider, cancellationToken); |
| | | 77 | | return findings; |
| | | 78 | | } |
| | | 79 | | |
| | | 80 | | /// <summary> |
| | | 81 | | /// Заказы алерта <c>ozon-cancelled-supplier-live</c> построчно: отправление отменено в Ozon, а заказ у поставщика н |
| | | 82 | | /// Источник для автоотмены у поставщика (<see cref="OzonCancelledSupplierOrderHandler"/>). |
| | | 83 | | /// </summary> |
| | | 84 | | public async Task<IReadOnlyList<OzonCancelledSupplierOrder>> FindOzonCancelledLiveAsync(CancellationToken cancellati |
| | | 85 | | { |
| | | 86 | | var settings = options.Value; |
| | | 87 | | if (!settings.SupplierChecksEnabled) return []; |
| | | 88 | | var statuses = await LoadStatusesAsync(settings, cancellationToken); |
| | | 89 | | var provider = await LoadProviderNamesAsync(cancellationToken); |
| | | 90 | | return await FindOzonCancelledLiveAsync(settings, DateTime.UtcNow, statuses, provider, cancellationToken); |
| | | 91 | | } |
| | | 92 | | |
| | | 93 | | private async Task<Func<string, string>> LoadProviderNamesAsync(CancellationToken cancellationToken) |
| | | 94 | | { |
| | | 95 | | var names = await context.ApiSupplierSettings.AsNoTracking() |
| | | 96 | | .Where(x => x.DisplayName != null) |
| | | 97 | | .ToDictionaryAsync(x => x.ProviderKey, x => x.DisplayName!, StringComparer.OrdinalIgnoreCase, cancellationTo |
| | | 98 | | return key => names.TryGetValue(key, out var display) && !string.IsNullOrWhiteSpace(display) ? display : key; |
| | | 99 | | } |
| | | 100 | | |
| | | 101 | | private async Task<List<OzonCancelledSupplierOrder>> FindOzonCancelledLiveAsync(SystemAlertsOptions settings, DateTi |
| | | 102 | | StatusCatalog statuses, Func<string, string> provider, CancellationToken ct) |
| | | 103 | | { |
| | | 104 | | var since = now.AddHours(-settings.WindowHoursFor("ozon-cancelled-supplier-live")); |
| | | 105 | | var rejectedIds = statuses.Rejected; |
| | | 106 | | var rows = await (from link in context.OzonPostingCancellationOrders.AsNoTracking() |
| | | 107 | | join state in ActiveStates(settings, excludeCancelled: false) on link.OrderId equals state.Ord |
| | | 108 | | where link.Cancellation.OccurredAt >= since && state.Stage == OrderSupplierStage.Ordered |
| | | 109 | | && (state.SupplierStatusId == null || !rejectedIds.Contains(state.SupplierStatusId.Value |
| | | 110 | | select new |
| | | 111 | | { |
| | | 112 | | state.Id, state.OrderId, state.ProviderKey, state.SupplierStatusId, state.ExternalOrderNum |
| | | 113 | | state.ExternalLineId, state.OrderedAt, CancelledAt = link.Cancellation.OccurredAt, |
| | | 114 | | ShipmentNumber = state.Order != null ? state.Order.ShipmentNumber : null, |
| | | 115 | | SupplierName = state.Order != null && state.Order.Supplier != null ? state.Order.Supplier. |
| | | 116 | | AcceptedAt = state.Order != null ? state.Order.ProcessingDate : null, |
| | | 117 | | Article = state.Order != null ? state.Order.Article : null, |
| | | 118 | | ProductName = state.Order != null ? state.Order.ProductName : null, |
| | | 119 | | Quantity = state.Order != null ? state.Order.Quantity : null, |
| | | 120 | | PurchasePrice = state.Order != null ? state.Order.PurchasePrice : null |
| | | 121 | | }).ToListAsync(ct); |
| | | 122 | | // Одно отправление может отменяться несколькими событиями: одна строка на состояние, дата — последней отмены. |
| | | 123 | | return rows.GroupBy(x => x.Id).Select(g => g.OrderByDescending(x => x.CancelledAt).First()) |
| | | 124 | | .OrderByDescending(x => x.OrderId) |
| | | 125 | | .Select(x => new OzonCancelledSupplierOrder(x.OrderId, x.ShipmentNumber, x.ProviderKey, provider(x.ProviderK |
| | | 126 | | statuses.NameOf(x.SupplierStatusId), statuses.IsDelivered(x.SupplierStatusId), |
| | | 127 | | x.ExternalOrderNumber, x.ExternalOrderId, x.ExternalLineId) |
| | | 128 | | { |
| | | 129 | | SupplierName = x.SupplierName, AcceptedAt = x.AcceptedAt, OrderedAt = x.OrderedAt, CancelledAt = x.Cance |
| | | 130 | | Article = x.Article, ProductName = x.ProductName, Quantity = x.Quantity, PurchasePrice = x.PurchasePrice |
| | | 131 | | }) |
| | | 132 | | .ToList(); |
| | | 133 | | } |
| | | 134 | | |
| | | 135 | | private sealed record StatusInfo(int Id, string ProviderKey, string Kind, string? Code, string Name, bool IsFinal, S |
| | | 136 | | |
| | | 137 | | private sealed class StatusCatalog(List<StatusInfo> all) |
| | | 138 | | { |
| | | 139 | | public Dictionary<int, StatusInfo> ById { get; } = all.ToDictionary(x => x.Id); |
| | | 140 | | public List<int> Rejected { get; } = all.Where(x => x.Kind == SupplierStatusKind.Order && x.Outcome == SupplierS |
| | | 141 | | public List<int> Delivered { get; } = all.Where(x => x.Kind == SupplierStatusKind.Order && x.Outcome == Supplier |
| | | 142 | | public List<int> Final { get; } = all.Where(x => x.IsFinal).Select(x => x.Id).ToList(); |
| | | 143 | | public List<int> CartFailed { get; } = all.Where(x => x.Kind == SupplierStatusKind.Cart && x.Code == SupplierCar |
| | | 144 | | |
| | | 145 | | public string NameOf(int? id) => id.HasValue && ById.TryGetValue(id.Value, out var s) ? s.Name : "статус неизвес |
| | | 146 | | public bool IsDelivered(int? id) => id.HasValue && ById.TryGetValue(id.Value, out var s) && s.Outcome == Supplie |
| | | 147 | | } |
| | | 148 | | |
| | | 149 | | private async Task<StatusCatalog> LoadStatusesAsync(SystemAlertsOptions settings, CancellationToken cancellationToke |
| | | 150 | | { |
| | | 151 | | var rows = await context.SupplierStatuses.AsNoTracking() |
| | | 152 | | .Select(x => new { x.Id, x.ProviderKey, x.StatusKind, x.ExternalCode, x.Name, x.IsFinal }) |
| | | 153 | | .ToListAsync(cancellationToken); |
| | | 154 | | return new StatusCatalog(rows.Select(x => new StatusInfo(x.Id, x.ProviderKey, x.StatusKind, x.ExternalCode, x.Na |
| | | 155 | | x.StatusKind == SupplierStatusKind.Order |
| | | 156 | | ? SupplierStatusClassifier.Classify(x.ProviderKey, x.ExternalCode, x.Name, settings) |
| | | 157 | | : SupplierStatusOutcome.Other)).ToList()); |
| | | 158 | | } |
| | | 159 | | |
| | | 160 | | /// <summary>1. Не оформился: позиции корзины в Failed, ошибки опроса корзины и «залежавшиеся» позиции.</summary> |
| | | 161 | | private async Task AddCartProblemsAsync(List<SystemAlertFinding> findings, SystemAlertsOptions settings, DateTime no |
| | | 162 | | DateTime maxAgeSince, StatusCatalog statuses, Func<string, string> provider, CancellationToken ct) |
| | | 163 | | { |
| | | 164 | | var failedSince = now.AddHours(-settings.WindowHoursFor("supplier-cart-failed")); |
| | | 165 | | var failedIds = statuses.CartFailed; |
| | | 166 | | var failed = await ActiveStates(settings) |
| | | 167 | | .Where(x => x.Stage == OrderSupplierStage.Cart && x.UpdatedAt >= failedSince |
| | | 168 | | && ((x.SupplierStatusId != null && failedIds.Contains(x.SupplierStatusId.Value)) || x.SyncError |
| | | 169 | | .Select(x => new { x.OrderId, x.ProviderKey, x.SyncError }) |
| | | 170 | | .ToListAsync(ct); |
| | | 171 | | if (failed.Count > 0) |
| | | 172 | | findings.Add(Grouped("supplier-cart-failed", "suppliers", Severity(failed.Count, settings.DangerFailureCount |
| | | 173 | | "Не удалось оформить заказ у поставщика: позиции в корзине с ошибкой", |
| | | 174 | | failed.Select(x => (x.OrderId, x.ProviderKey, x.SyncError)), provider, "Ошибка оформления")); |
| | | 175 | | |
| | | 176 | | var staleBefore = now.AddHours(-Math.Max(1, settings.SupplierCartStaleHours)); |
| | | 177 | | var stale = await ActiveStates(settings) |
| | | 178 | | .Where(x => x.Stage == OrderSupplierStage.Cart && x.CreatedAt >= maxAgeSince && x.CreatedAt < staleBefore |
| | | 179 | | && (x.SupplierStatusId == null || !failedIds.Contains(x.SupplierStatusId.Value)) && x.SyncError |
| | | 180 | | && (x.SupplierStatusId == null || !statuses.Final.Contains(x.SupplierStatusId.Value))) |
| | | 181 | | .Select(x => new { x.OrderId, x.ProviderKey }) |
| | | 182 | | .ToListAsync(ct); |
| | | 183 | | if (stale.Count > 0) |
| | | 184 | | findings.Add(Grouped("supplier-cart-stale", "suppliers", "warning", |
| | | 185 | | $"Позиции лежат в корзине поставщика и не заказаны дольше {settings.SupplierCartStaleHours} ч", |
| | | 186 | | stale.Select(x => (x.OrderId, x.ProviderKey, (string?)null)), provider, "Лежит в корзине без заказа")); |
| | | 187 | | } |
| | | 188 | | |
| | | 189 | | /// <summary>2. Поставщик не смог продать — по одному алерту на поставщика, чтобы заглушать их независимо.</summary> |
| | | 190 | | private async Task AddRejectedAsync(List<SystemAlertFinding> findings, SystemAlertsOptions settings, DateTime now, |
| | | 191 | | StatusCatalog statuses, Func<string, string> provider, CancellationToken ct) |
| | | 192 | | { |
| | | 193 | | var since = now.AddHours(-settings.WindowHoursFor("supplier-rejected")); |
| | | 194 | | var rejectedIds = statuses.Rejected; |
| | | 195 | | var rows = await ActiveStates(settings) |
| | | 196 | | .Where(x => x.Stage != OrderSupplierStage.Cart && (x.LastStatusChangeAt ?? x.UpdatedAt) >= since |
| | | 197 | | && ((x.SupplierStatusId != null && rejectedIds.Contains(x.SupplierStatusId.Value)) |
| | | 198 | | || x.QuantityRejected > 0 |
| | | 199 | | || (x.Stage == OrderSupplierStage.Closed && x.QuantityConfirmed != null && x.QuantityConfirm |
| | | 200 | | // Уже перезаказан у другого поставщика: отказ отработан. |
| | | 201 | | .Where(x => !context.OrderSupplierStates.Any(o => o.OrderId == x.OrderId && o.Id != x.Id && o.Stage != Order |
| | | 202 | | && o.CreatedAt >= x.CreatedAt |
| | | 203 | | && (o.SupplierStatusId == null || !rejectedIds.Contains(o. |
| | | 204 | | // Отправление отменено в Ozon: переставлять заказ некуда, это случай ozon-cancelled-supplier-live. |
| | | 205 | | .Where(x => !context.OzonPostingCancellationOrders.Any(c => c.OrderId == x.OrderId)) |
| | | 206 | | .Where(x => x.Order == null || x.Order.AppStatus == null || !x.Order.AppStatus.IsFinal) |
| | | 207 | | .Select(x => new { x.OrderId, x.ProviderKey, x.SupplierStatusId }) |
| | | 208 | | .ToListAsync(ct); |
| | | 209 | | |
| | | 210 | | foreach (var group in rows.GroupBy(x => x.ProviderKey, StringComparer.OrdinalIgnoreCase)) |
| | | 211 | | { |
| | | 212 | | var orderIds = group.Select(x => x.OrderId).Distinct().OrderByDescending(x => x).ToList(); |
| | | 213 | | var reasons = group.GroupBy(x => statuses.NameOf(x.SupplierStatusId)).OrderByDescending(x => x.Count()) |
| | | 214 | | .Take(3).Select(x => $"{x.Key} ({x.Count()})"); |
| | | 215 | | findings.Add(new SystemAlertFinding( |
| | | 216 | | "supplier-rejected-" + group.Key.ToLowerInvariant(), "suppliers", |
| | | 217 | | Severity(orderIds.Count, settings.SupplierRejectedDangerCount), |
| | | 218 | | $"Поставщик {provider(group.Key)} не смог выполнить заказы: {orderIds.Count}", |
| | | 219 | | [$"Заказы: {OrderList(orderIds)}", "Причины: " + string.Join("; ", reasons), "Нужно перезаказать у друго |
| | | 220 | | OrderUrl(orderIds), |
| | | 221 | | Orders: SystemAlertOrderRef.Build(group.Select(x => (x.OrderId, |
| | | 222 | | $"{provider(group.Key)}: {statuses.NameOf(x.SupplierStatusId)}" |
| | | 223 | | + (x.SupplierStatusId.HasValue && rejectedIds.Contains(x.SupplierStatusId.Value) ? string.Empty : " |
| | | 224 | | } |
| | | 225 | | } |
| | | 226 | | |
| | | 227 | | /// <summary>3. Слепая зона: заказ у поставщика есть, а статус мы получить не можем.</summary> |
| | | 228 | | private async Task AddBlindAsync(List<SystemAlertFinding> findings, SystemAlertsOptions settings, DateTime now, |
| | | 229 | | Func<string, string> provider, CancellationToken ct) |
| | | 230 | | { |
| | | 231 | | var since = now.AddHours(-settings.WindowHoursFor("supplier-sync-blind")); |
| | | 232 | | var rows = await ActiveStates(settings) |
| | | 233 | | .Where(x => x.Stage == OrderSupplierStage.Ordered && x.SyncError != null && x.LastSyncedAt >= since) |
| | | 234 | | .Select(x => new { x.OrderId, x.ProviderKey, x.SyncError }) |
| | | 235 | | .ToListAsync(ct); |
| | | 236 | | // Если сам API поставщика отвечал ошибками (авторизация, лимиты, 5xx) — первоисточник это аудит API, иначе — за |
| | | 237 | | var providersWithApiErrors = (await context.ApiSupplierRequestAuditEntries.AsNoTracking() |
| | | 238 | | .Where(x => !x.IsSuccess && x.OccurredAtUtc >= since).Select(x => x.ProviderKey).Distinct().ToListAsync(ct)) |
| | | 239 | | .ToHashSet(StringComparer.OrdinalIgnoreCase); |
| | | 240 | | |
| | | 241 | | foreach (var group in rows.GroupBy(x => x.ProviderKey, StringComparer.OrdinalIgnoreCase)) |
| | | 242 | | { |
| | | 243 | | var orderIds = group.Select(x => x.OrderId).Distinct().OrderByDescending(x => x).ToList(); |
| | | 244 | | var topError = group.GroupBy(x => Shorten(x.SyncError)).OrderByDescending(x => x.Count()).First().Key; |
| | | 245 | | findings.Add(new SystemAlertFinding( |
| | | 246 | | "supplier-sync-blind-" + group.Key.ToLowerInvariant(), "suppliers", |
| | | 247 | | Severity(orderIds.Count, settings.SupplierBlindDangerCount), |
| | | 248 | | $"Не получаем статусы заказов у поставщика {provider(group.Key)}: {orderIds.Count}", |
| | | 249 | | [$"Заказы: {OrderList(orderIds)}", "Ошибка: " + topError], |
| | | 250 | | providersWithApiErrors.Contains(group.Key) ? SystemAlertLinks.ApiAuditFailures(group.Key) : OrderUrl(ord |
| | | 251 | | Orders: SystemAlertOrderRef.Build(group.Select(x => (x.OrderId, $"{provider(group.Key)}: {Shorten(x.Sync |
| | | 252 | | } |
| | | 253 | | } |
| | | 254 | | |
| | | 255 | | /// <summary>4. Просрочка поставки и статус, который давно не менялся.</summary> |
| | | 256 | | private async Task AddOverdueAndStaleAsync(List<SystemAlertFinding> findings, SystemAlertsOptions settings, DateTime |
| | | 257 | | DateTime maxAgeSince, StatusCatalog statuses, Func<string, string> provider, CancellationToken ct) |
| | | 258 | | { |
| | | 259 | | var closedIds = statuses.Rejected.Concat(statuses.Delivered).Concat(statuses.Final).Distinct().ToList(); |
| | | 260 | | var open = ActiveStates(settings) |
| | | 261 | | .Where(x => x.Stage == OrderSupplierStage.Ordered && (x.OrderedAt ?? x.CreatedAt) >= maxAgeSince |
| | | 262 | | && (x.SupplierStatusId == null || !closedIds.Contains(x.SupplierStatusId.Value)) |
| | | 263 | | && x.SyncError == null); |
| | | 264 | | |
| | | 265 | | var overdueBefore = now.AddHours(-Math.Max(1, settings.SupplierDeliveryGraceHours)); |
| | | 266 | | var overdue = await open.Where(x => x.ExpectedDeliveryAt != null && x.ExpectedDeliveryAt < overdueBefore) |
| | | 267 | | .Select(x => new { x.OrderId, x.ProviderKey }).ToListAsync(ct); |
| | | 268 | | if (overdue.Count > 0) |
| | | 269 | | findings.Add(Grouped("supplier-delivery-overdue", "suppliers", "warning", "Поставка у поставщика просрочена" |
| | | 270 | | overdue.Select(x => (x.OrderId, x.ProviderKey, (string?)null)), provider, "Поставка просрочена", orderSe |
| | | 271 | | |
| | | 272 | | var staleBefore = now.AddDays(-Math.Max(1, settings.SupplierStatusStaleDays)); |
| | | 273 | | var stale = await open.Where(x => (x.LastStatusChangeAt ?? x.OrderedAt ?? x.CreatedAt) < staleBefore |
| | | 274 | | && (x.ExpectedDeliveryAt == null || x.ExpectedDeliveryAt >= overdueBefore)) |
| | | 275 | | .Select(x => new { x.OrderId, x.ProviderKey }).ToListAsync(ct); |
| | | 276 | | if (stale.Count > 0) |
| | | 277 | | findings.Add(Grouped("supplier-status-stale", "suppliers", "warning", |
| | | 278 | | $"Статус заказа у поставщика не менялся дольше {settings.SupplierStatusStaleDays} дн.", |
| | | 279 | | stale.Select(x => (x.OrderId, x.ProviderKey, (string?)null)), provider, "Статус не менялся", orderSeveri |
| | | 280 | | } |
| | | 281 | | |
| | | 282 | | /// <summary>5. Отправление отменено в Ozon, а заказ у поставщика ещё жив — его нужно отменить (или оформить возврат |
| | | 283 | | private async Task AddOzonCancelledAsync(List<SystemAlertFinding> findings, SystemAlertsOptions settings, DateTime n |
| | | 284 | | StatusCatalog statuses, Func<string, string> provider, CancellationToken ct) |
| | | 285 | | { |
| | | 286 | | var rows = await FindOzonCancelledLiveAsync(settings, now, statuses, provider, ct); |
| | | 287 | | if (rows.Count == 0) return; |
| | | 288 | | |
| | | 289 | | var orderIds = rows.Select(x => x.OrderId).Distinct().OrderByDescending(x => x).ToList(); |
| | | 290 | | var lines = rows.Take(MaxOrdersInLine) |
| | | 291 | | .Select(x => $"#{x.OrderId} — {x.ProviderName}: {x.StatusName}" + (x.Delivered ? " (уже отгружен — нужен воз |
| | | 292 | | .ToList(); |
| | | 293 | | if (orderIds.Count > MaxOrdersInLine) lines.Add($"…и ещё {orderIds.Count - MaxOrdersInLine}"); |
| | | 294 | | findings.Add(new SystemAlertFinding("ozon-cancelled-supplier-live", "suppliers", "danger", |
| | | 295 | | $"Отправление отменено в Ozon, а заказ у поставщика активен: {orderIds.Count}", lines, OrderUrl(orderIds), |
| | | 296 | | Orders: SystemAlertOrderRef.Build(rows.Select(x => (x.OrderId, |
| | | 297 | | $"Отменён в Ozon, у {x.ProviderName} активен: {x.StatusName}" + (x.Delivered ? " — нужен возврат" : stri |
| | | 298 | | } |
| | | 299 | | |
| | | 300 | | /// <summary>6. Один заказ Studio2 оформлен у двух поставщиков одновременно (перезаказ после отказа не считается).</ |
| | | 301 | | private async Task AddDoubleOrdersAsync(List<SystemAlertFinding> findings, SystemAlertsOptions settings, DateTime ma |
| | | 302 | | Func<string, string> provider, CancellationToken ct) |
| | | 303 | | { |
| | | 304 | | var rejectedIds = statuses.Rejected; |
| | | 305 | | var live = await ActiveStates(settings) |
| | | 306 | | .Where(x => x.Stage == OrderSupplierStage.Ordered && (x.OrderedAt ?? x.CreatedAt) >= maxAgeSince |
| | | 307 | | && (x.SupplierStatusId == null || !rejectedIds.Contains(x.SupplierStatusId.Value))) |
| | | 308 | | .Select(x => new { x.OrderId, x.ProviderKey }) |
| | | 309 | | .ToListAsync(ct); |
| | | 310 | | var doubles = live.GroupBy(x => x.OrderId) |
| | | 311 | | .Where(g => g.Select(x => x.ProviderKey).Distinct(StringComparer.OrdinalIgnoreCase).Count() > 1) |
| | | 312 | | .OrderByDescending(g => g.Key).ToList(); |
| | | 313 | | if (doubles.Count == 0) return; |
| | | 314 | | |
| | | 315 | | var lines = doubles.Take(MaxOrdersInLine) |
| | | 316 | | .Select(g => $"#{g.Key}: " + string.Join(", ", g.Select(x => provider(x.ProviderKey)).Distinct())) |
| | | 317 | | .ToList(); |
| | | 318 | | if (doubles.Count > MaxOrdersInLine) lines.Add($"…и ещё {doubles.Count - MaxOrdersInLine}"); |
| | | 319 | | findings.Add(new SystemAlertFinding("supplier-double-order", "suppliers", "danger", |
| | | 320 | | $"Заказ оформлен у нескольких поставщиков одновременно: {doubles.Count}", lines, OrderUrl(doubles.Select(g = |
| | | 321 | | Orders: SystemAlertOrderRef.Build(doubles.Select(g => (g.Key, |
| | | 322 | | "Оформлен сразу у: " + string.Join(", ", g.Select(x => provider(x.ProviderKey)).Distinct())))))); |
| | | 323 | | } |
| | | 324 | | |
| | | 325 | | private static SystemAlertFinding Grouped(string key, string domain, string severity, string title, |
| | | 326 | | IEnumerable<(int OrderId, string ProviderKey, string? Error)> rows, Func<string, string> provider, string reason |
| | | 327 | | string? orderSeverity = null) |
| | | 328 | | { |
| | | 329 | | var list = rows.ToList(); |
| | | 330 | | var lines = list.GroupBy(x => x.ProviderKey, StringComparer.OrdinalIgnoreCase).OrderByDescending(g => g.Count()) |
| | | 331 | | .Select(g => |
| | | 332 | | { |
| | | 333 | | var ids = g.Select(x => x.OrderId).Distinct().OrderByDescending(x => x).ToList(); |
| | | 334 | | var error = g.Select(x => x.Error).FirstOrDefault(x => !string.IsNullOrWhiteSpace(x)); |
| | | 335 | | return $"{provider(g.Key)}: {ids.Count} — {OrderList(ids)}" + (error == null ? string.Empty : $" ({Short |
| | | 336 | | }).ToList(); |
| | | 337 | | return new SystemAlertFinding(key, domain, severity, title, lines, OrderUrl(list.Select(x => x.OrderId).Distinct |
| | | 338 | | Orders: SystemAlertOrderRef.Build(list.Select(x => (x.OrderId, |
| | | 339 | | $"{reasonLabel}: {provider(x.ProviderKey)}" + (string.IsNullOrWhiteSpace(x.Error) ? string.Empty : $" — |
| | | 340 | | } |
| | | 341 | | |
| | | 342 | | private static string Severity(int count, int dangerThreshold) => count >= Math.Max(1, dangerThreshold) ? "danger" : |
| | | 343 | | |
| | | 344 | | private static string OrderList(IReadOnlyList<int> ids) => |
| | | 345 | | string.Join(", ", ids.Take(MaxOrdersInLine).Select(x => "#" + x)) + (ids.Count > MaxOrdersInLine ? $" …(+{ids.Co |
| | | 346 | | |
| | | 347 | | /// <summary>Ссылка ведёт в сетку заказов, ограниченную затронутыми заказами.</summary> |
| | | 348 | | private static string OrderUrl(IReadOnlyList<int> ids) => SystemAlertLinks.Orders(ids); |
| | | 349 | | |
| | | 350 | | private static string Shorten(string? text) => |
| | | 351 | | string.IsNullOrWhiteSpace(text) ? "—" : text.Length <= 120 ? text.Trim() : text[..119] + "…"; |
| | | 352 | | } |
| | | 353 | | |
| | | 354 | | /// <summary>Заказ Studio2, отменённый в Ozon, при неотказанном заказе у поставщика.</summary> |
| | | 355 | | /// <param name="Delivered">Поставщик уже отгрузил (статус классифицирован как выполненный) — отменять поздно, нужен воз |
| | | 356 | | public sealed record OzonCancelledSupplierOrder(int OrderId, string? ShipmentNumber, string ProviderKey, string Provider |
| | | 357 | | string StatusName, bool Delivered, string? ExternalOrderNumber, string? ExternalOrderId, string? ExternalLineId) |
| | | 358 | | { |
| | | 359 | | /// <summary>Поставщик заказа Studio2 (справочник поставщиков), если отличается от имени провайдера API.</summary> |
| | | 360 | | public string? SupplierName { get; init; } |
| | | 361 | | public DateTime? AcceptedAt { get; init; } |
| | | 362 | | /// <summary>Когда заказ оформлен у поставщика (UTC).</summary> |
| | | 363 | | public DateTime? OrderedAt { get; init; } |
| | | 364 | | /// <summary>Когда пришла отмена отправления из Ozon (UTC).</summary> |
| | | 365 | | public DateTime? CancelledAt { get; init; } |
| | | 366 | | public string? Article { get; init; } |
| | | 367 | | public string? ProductName { get; init; } |
| | | 368 | | public int? Quantity { get; init; } |
| | | 369 | | public decimal? PurchasePrice { get; init; } |
| | | 370 | | } |
| | | 371 | | |
| | | 372 | | /// <summary>Условие «заказ в Ozon ещё не обработан»: не доставлен и (опционально) не отменён.</summary> |
| | | 373 | | public static class OzonOrderRelevance |
| | | 374 | | { |
| | | 375 | | private static readonly System.Reflection.MethodInfo StringContains = |
| | | 376 | | typeof(string).GetMethod(nameof(string.Contains), [typeof(string)])!; |
| | | 377 | | |
| | | 378 | | /// <summary> |
| | | 379 | | /// <c>x => x.Order == null || x.Order.ClientStatus == null || !(ClientStatus.IsFinal || имя содержит основу «дос |
| | | 380 | | /// [|| «отмен»])</c>. Собрано деревом выражений, чтобы переводилось в SQL и принимало набор ключевых слов из настро |
| | | 381 | | /// </summary> |
| | | 382 | | public static Expression<Func<OrderSupplierState, bool>> NotHandledByOzon(SystemAlertsOptions settings, bool exclude |
| | | 383 | | { |
| | | 384 | | var state = Expression.Parameter(typeof(OrderSupplierState), "x"); |
| | | 385 | | var order = Expression.Property(state, nameof(OrderSupplierState.Order)); |
| | | 386 | | var status = Expression.Property(order, nameof(Order.ClientStatus)); |
| | | 387 | | var name = Expression.Property(status, nameof(ClientStatus.Name)); |
| | | 388 | | |
| | | 389 | | Expression handled = Expression.Property(status, nameof(ClientStatus.IsFinal)); |
| | | 390 | | var stems = excludeCancelled ? settings.OzonDeliveredStatusKeywords.Concat(settings.OzonCancelledStatusKeywords) |
| | | 391 | | foreach (var stem in stems.Where(x => !string.IsNullOrWhiteSpace(x))) |
| | | 392 | | handled = Expression.OrElse(handled, Expression.Call(name, StringContains, Expression.Constant(stem))); |
| | | 393 | | |
| | | 394 | | var body = Expression.OrElse( |
| | | 395 | | Expression.Equal(order, Expression.Constant(null, typeof(Order))), |
| | | 396 | | Expression.OrElse( |
| | | 397 | | Expression.Equal(status, Expression.Constant(null, typeof(ClientStatus))), |
| | | 398 | | Expression.Not(handled))); |
| | | 399 | | return Expression.Lambda<Func<OrderSupplierState, bool>>(body, state); |
| | | 400 | | } |
| | | 401 | | } |