| | | 1 | | using System.Globalization; |
| | | 2 | | using System.Net; |
| | | 3 | | using Microsoft.EntityFrameworkCore; |
| | | 4 | | using Microsoft.Extensions.Options; |
| | | 5 | | using OzonDomains.Models; |
| | | 6 | | using OzonOrdersWeb.TransactionServices; |
| | | 7 | | using OzonRepositories.Context; |
| | | 8 | | using Services.ApiServices.TelegramBotApi; |
| | | 9 | | |
| | | 10 | | namespace OzonOrdersWeb.WebServices.SystemAlerts; |
| | | 11 | | |
| | | 12 | | /// <summary>Отмена заказа у конкретного поставщика через его API.</summary> |
| | | 13 | | public interface ISupplierOrderCanceller |
| | | 14 | | { |
| | | 15 | | /// <param name="cancellableOrderIds">Все заказы Studio2, отменённые в Ozon сейчас: отмена целого заказа поставщика |
| | | 16 | | /// (Форум-Авто) допустима, только если в нём нет других живых заказов.</param> |
| | | 17 | | Task<SupplierOrderCancelResult> CancelAsync(OzonCancelledSupplierOrder order, IReadOnlySet<int> cancellableOrderIds, |
| | | 18 | | } |
| | | 19 | | |
| | | 20 | | public sealed class SupplierOrderCanceller(IServiceProvider services) : ISupplierOrderCanceller |
| | | 21 | | { |
| | | 22 | | public async Task<SupplierOrderCancelResult> CancelAsync(OzonCancelledSupplierOrder order, IReadOnlySet<int> cancell |
| | | 23 | | { |
| | | 24 | | var note = $"Отменено в Ozon, отправление {order.ShipmentNumber}".Trim(); |
| | | 25 | | switch (order.ProviderKey) |
| | | 26 | | { |
| | | 27 | | case ApiSupplierSettings.ZZapMoscowProviderKey: |
| | | 28 | | if (!long.TryParse(order.ExternalOrderNumber ?? order.ExternalOrderId, NumberStyles.Integer, CultureInfo |
| | | 29 | | return SupplierOrderCancelResult.Failed("в Studio2 нет номера заказа ZZap."); |
| | | 30 | | var zzap = await services.GetRequiredService<ZZapCartService>().CancelOrderAsync(codeOrder, note, "syste |
| | | 31 | | var text = string.Join("; ", zzap.Messages); |
| | | 32 | | return zzap.IsSuccess ? SupplierOrderCancelResult.Cancelled(text) : SupplierOrderCancelResult.Failed(tex |
| | | 33 | | case "Armtek" or "ArmtekBy": |
| | | 34 | | return await services.GetRequiredService<ArmtekCartService>().RejectOrderPositionsAsync(order.ProviderKe |
| | | 35 | | case ApiSupplierSettings.ForumAutoProviderKey or ApiSupplierSettings.ForumAutoInterpartsProviderKey or ApiSu |
| | | 36 | | or ApiSupplierSettings.ForumAutoRostovProviderKey or ApiSupplierSettings.ForumAutoIstraProviderKey: |
| | | 37 | | return await services.GetRequiredService<ForumAutoCartService>().CancelOrderAsync(order.ProviderKey, ord |
| | | 38 | | default: |
| | | 39 | | return SupplierOrderCancelResult.Failed("автоотмена для поставщика не подключена."); |
| | | 40 | | } |
| | | 41 | | } |
| | | 42 | | } |
| | | 43 | | |
| | | 44 | | /// <summary> |
| | | 45 | | /// Разбирает заказы алерта <c>ozon-cancelled-supplier-live</c>: отменяет у поставщика через API, где это можно |
| | | 46 | | /// (<see cref="SystemAlertsOptions.SupplierAutoCancelProviders"/>), и пишет сводку в тему General с упоминаниями. |
| | | 47 | | /// Каждый заказ+поставщик обрабатывается один раз: маркер — запись <see cref="HistoryColumnName"/> в истории заказа, |
| | | 48 | | /// неудачная попытка не повторяется, а уходит людям «отменить вручную». |
| | | 49 | | /// </summary> |
| | 5 | 50 | | public sealed class OzonCancelledSupplierOrderHandler(SupplierOrdersSystemAlertSource source, ApplicationDbContext conte |
| | 5 | 51 | | ISupplierOrderCanceller canceller, ITelegramNotifier telegram, TelegramBotConfig telegramConfig, |
| | 5 | 52 | | IOptions<SystemAlertsOptions> options, ILogger<OzonCancelledSupplierOrderHandler> logger) |
| | | 53 | | { |
| | | 54 | | public const string HistoryColumnName = "SupplierAutoCancel"; |
| | | 55 | | private const int OrdersPerMessage = 25; |
| | | 56 | | |
| | | 57 | | public enum CancelAction |
| | | 58 | | { |
| | | 59 | | Cancelled, |
| | | 60 | | CancelFailed, |
| | | 61 | | ReturnNeeded, |
| | | 62 | | Manual |
| | | 63 | | } |
| | | 64 | | |
| | 61 | 65 | | public sealed record Result(OzonCancelledSupplierOrder Order, CancelAction Action, string Message); |
| | | 66 | | |
| | | 67 | | public async Task<IReadOnlyList<Result>> HandleAsync(CancellationToken cancellationToken = default) |
| | | 68 | | { |
| | 6 | 69 | | var rows = await source.FindOzonCancelledLiveAsync(cancellationToken); |
| | 6 | 70 | | if (rows.Count == 0) return []; |
| | | 71 | | |
| | | 72 | | var orderIds = rows.Select(x => x.OrderId).Distinct().ToList(); |
| | 6 | 73 | | var processed = (await context.OrderHistories.AsNoTracking() |
| | 6 | 74 | | .Where(x => orderIds.Contains(x.OrderId) && x.ColumnName == HistoryColumnName) |
| | 6 | 75 | | .Select(x => new { x.OrderId, x.OldValue }) |
| | 6 | 76 | | .ToListAsync(cancellationToken)) |
| | | 77 | | .Select(x => (x.OrderId, x.OldValue ?? string.Empty)) |
| | 6 | 78 | | .ToHashSet(); |
| | 12 | 79 | | var fresh = rows.Where(x => !processed.Contains((x.OrderId, x.ProviderKey))).ToList(); |
| | 7 | 80 | | if (fresh.Count == 0) return []; |
| | | 81 | | |
| | 5 | 82 | | var settings = options.Value; |
| | 5 | 83 | | var cancellable = orderIds.ToHashSet(); |
| | 5 | 84 | | var results = new List<Result>(); |
| | 20 | 85 | | foreach (var row in fresh) |
| | | 86 | | { |
| | 5 | 87 | | var result = await ResolveAsync(row, settings, cancellable, cancellationToken); |
| | 5 | 88 | | results.Add(result); |
| | 5 | 89 | | await RecordAsync(result, cancellationToken); |
| | | 90 | | } |
| | | 91 | | |
| | 5 | 92 | | await NotifyAsync(results, settings, cancellationToken); |
| | 5 | 93 | | return results; |
| | 6 | 94 | | } |
| | | 95 | | |
| | | 96 | | private async Task<Result> ResolveAsync(OzonCancelledSupplierOrder row, SystemAlertsOptions settings, IReadOnlySet<i |
| | | 97 | | { |
| | 6 | 98 | | if (row.Delivered) return new(row, CancelAction.ReturnNeeded, $"уже отгружен ({row.StatusName}) — {ReturnHint(ro |
| | | 99 | | |
| | 4 | 100 | | var supported = settings.SupplierAutoCancelProviders.Contains(row.ProviderKey, StringComparer.OrdinalIgnoreCase) |
| | 5 | 101 | | if (!supported) return new(row, CancelAction.Manual, "в API поставщика нет отмены — отменить у поставщика вручну |
| | 4 | 102 | | if (!settings.SupplierAutoCancelEnabled) return new(row, CancelAction.Manual, "автоотмена выключена — отменить у |
| | | 103 | | |
| | | 104 | | SupplierOrderCancelResult cancel; |
| | | 105 | | try |
| | | 106 | | { |
| | 2 | 107 | | cancel = await canceller.CancelAsync(row, cancellable, ct); |
| | 1 | 108 | | } |
| | 0 | 109 | | catch (OperationCanceledException) when (ct.IsCancellationRequested) |
| | | 110 | | { |
| | 0 | 111 | | throw; |
| | | 112 | | } |
| | 1 | 113 | | catch (Exception ex) |
| | | 114 | | { |
| | 1 | 115 | | logger.LogWarning(ex, "Автоотмена заказа {OrderId} у {Provider} упала", row.OrderId, row.ProviderKey); |
| | 1 | 116 | | cancel = SupplierOrderCancelResult.Failed(ex.Message); |
| | 1 | 117 | | } |
| | | 118 | | |
| | 2 | 119 | | return cancel.Outcome switch |
| | 2 | 120 | | { |
| | 1 | 121 | | SupplierOrderCancelOutcome.Cancelled => new(row, CancelAction.Cancelled, $"отменён автоматически ({cancel.Me |
| | 0 | 122 | | SupplierOrderCancelOutcome.ReturnNeeded => new(row, CancelAction.ReturnNeeded, $"{cancel.Message} — {ReturnH |
| | 1 | 123 | | _ => new(row, CancelAction.CancelFailed, $"автоотмена не удалась: {cancel.Message} — отменить у поставщика в |
| | 2 | 124 | | }; |
| | 5 | 125 | | } |
| | | 126 | | |
| | 1 | 127 | | private static string ReturnHint(string providerKey) => providerKey switch |
| | 1 | 128 | | { |
| | 1 | 129 | | "MlAuto" or "MlAutoRu" => "оформить возврат (CreateReturn, причина 09 «Отказ конечного потребителя»)", |
| | 0 | 130 | | "Armtek" or "ArmtekBy" => "оформить возврат по фактуре (createReturnInvoice)", |
| | 0 | 131 | | _ => "оформить возврат поставщику" |
| | 1 | 132 | | }; |
| | | 133 | | |
| | | 134 | | private async Task RecordAsync(Result result, CancellationToken ct) |
| | | 135 | | { |
| | 5 | 136 | | context.OrderHistories.Add(new OzonDomains.Models.OrderHistory |
| | 5 | 137 | | { |
| | 5 | 138 | | OrderId = result.Order.OrderId, |
| | 5 | 139 | | ColumnName = HistoryColumnName, |
| | 5 | 140 | | ColumnDisplayName = $"Отмена у поставщика {result.Order.ProviderName}", |
| | 5 | 141 | | OldValue = result.Order.ProviderKey, |
| | 5 | 142 | | NewValue = $"{result.Action}: {result.Message}", |
| | 5 | 143 | | ChangedAt = DateTime.Now, |
| | 5 | 144 | | ChangedBy = "system" |
| | 5 | 145 | | }); |
| | 5 | 146 | | await context.SaveChangesAsync(ct); |
| | 5 | 147 | | } |
| | | 148 | | |
| | | 149 | | private async Task NotifyAsync(IReadOnlyList<Result> results, SystemAlertsOptions settings, CancellationToken ct) |
| | | 150 | | { |
| | 5 | 151 | | if (!telegram.IsConfigured || results.Count == 0) return; |
| | | 152 | | try |
| | | 153 | | { |
| | 20 | 154 | | foreach (var html in BuildMessages(results, settings.SupplierCancelMentions, telegramConfig.SiteBaseUrl)) |
| | 5 | 155 | | await telegram.SendAsync(new TelegramMessage(html, Channel: TelegramChannel.General), ct); |
| | 5 | 156 | | } |
| | 0 | 157 | | catch (OperationCanceledException) when (ct.IsCancellationRequested) |
| | | 158 | | { |
| | 0 | 159 | | throw; |
| | | 160 | | } |
| | 0 | 161 | | catch (Exception ex) |
| | | 162 | | { |
| | 0 | 163 | | logger.LogWarning(ex, "Не удалось отправить в Telegram заказы, отменённые в Ozon"); |
| | 0 | 164 | | } |
| | 5 | 165 | | } |
| | | 166 | | |
| | | 167 | | public static IReadOnlyList<string> BuildMessages(IReadOnlyList<Result> results, IReadOnlyList<string> mentions, str |
| | | 168 | | { |
| | 10 | 169 | | var mentionLine = string.Join(" ", mentions.Where(x => !string.IsNullOrWhiteSpace(x)) |
| | 15 | 170 | | .Select(x => x.Trim()).Select(x => x.StartsWith('@') ? x : "@" + x).Distinct(StringComparer.OrdinalIgnoreCas |
| | 10 | 171 | | var manual = results.Count(x => x.Action != CancelAction.Cancelled); |
| | 5 | 172 | | var messages = new List<string>(); |
| | 20 | 173 | | foreach (var chunk in results.Chunk(OrdersPerMessage)) |
| | | 174 | | { |
| | 5 | 175 | | var lines = new List<string> |
| | 5 | 176 | | { |
| | 5 | 177 | | $"<b>⚠️ Отменено в Ozon, а заказ у поставщика активен: {results.Count}</b>" |
| | 5 | 178 | | + (manual > 0 ? $" (нужны действия: {manual})" : " (всё отменено автоматически)") |
| | 5 | 179 | | }; |
| | 10 | 180 | | if (mentionLine.Length > 0) lines.Add(WebUtility.HtmlEncode(mentionLine)); |
| | 10 | 181 | | lines.AddRange(chunk.Select(x => string.Empty + "\n" + Block(x, siteBaseUrl))); |
| | 10 | 182 | | var all = SystemAlertLinks.Orders(chunk.Select(x => x.Order.OrderId)); |
| | 5 | 183 | | if (TelegramMessageFactory.AbsoluteUrl(all, siteBaseUrl) is { } allUrl) |
| | 5 | 184 | | lines.Add($"\n<a href=\"{WebUtility.HtmlEncode(allUrl)}\">Открыть все заказы в Studio2</a>"); |
| | 5 | 185 | | messages.Add(string.Join("\n", lines)); |
| | | 186 | | } |
| | 5 | 187 | | return messages; |
| | | 188 | | } |
| | | 189 | | |
| | | 190 | | /// <summary>Карточка заказа: ссылка, отправление, поставщик и его номер заказа, даты, товар и закупка, что сделать. |
| | | 191 | | public static string Block(Result result, string? siteBaseUrl) |
| | | 192 | | { |
| | 5 | 193 | | var order = result.Order; |
| | 5 | 194 | | var url = TelegramMessageFactory.AbsoluteUrl(SystemAlertLinks.Orders([order.OrderId]), siteBaseUrl); |
| | 5 | 195 | | var orderRef = url is null ? $"<b>#{order.OrderId}</b>" : $"<a href=\"{WebUtility.HtmlEncode(url)}\"><b>#{order. |
| | 5 | 196 | | var icon = result.Action switch |
| | 5 | 197 | | { |
| | 1 | 198 | | CancelAction.Cancelled => "✅", |
| | 1 | 199 | | CancelAction.ReturnNeeded => "↩️", |
| | 3 | 200 | | _ => "❗" |
| | 5 | 201 | | }; |
| | | 202 | | |
| | 5 | 203 | | var head = $"{icon} {orderRef}"; |
| | 10 | 204 | | if (!string.IsNullOrWhiteSpace(order.ShipmentNumber)) head += $" · отпр. {Html(order.ShipmentNumber)}"; |
| | 5 | 205 | | if (order.AcceptedAt is { } accepted) head += $" · принят {accepted:dd.MM.yyyy}"; |
| | | 206 | | |
| | 5 | 207 | | var supplier = string.IsNullOrWhiteSpace(order.SupplierName) || string.Equals(order.SupplierName, order.Provider |
| | 5 | 208 | | ? Html(order.ProviderName) |
| | 5 | 209 | | : $"{Html(order.SupplierName)} ({Html(order.ProviderName)})"; |
| | 5 | 210 | | var supplierOrder = order.ExternalOrderNumber ?? order.ExternalOrderId; |
| | 5 | 211 | | supplier += string.IsNullOrWhiteSpace(supplierOrder) ? ", номер заказа неизвестен" : $", заказ <code>{Html(suppl |
| | 10 | 212 | | if (order.OrderedAt is { } ordered) supplier += $" от {Moscow(ordered):dd.MM.yyyy}"; |
| | | 213 | | |
| | 5 | 214 | | var lines = new List<string> { head, "Поставщик: " + supplier }; |
| | | 215 | | |
| | 15 | 216 | | var product = string.Join(" ", new[] { order.Article, order.ProductName }.Where(x => !string.IsNullOrWhiteSpace( |
| | 5 | 217 | | var purchase = new List<string>(); |
| | 5 | 218 | | if (!string.IsNullOrWhiteSpace(product)) purchase.Add(Html(product.Length <= 80 ? product : product[..79] + "…") |
| | 5 | 219 | | if (order.Quantity is > 0) purchase.Add($"{order.Quantity} шт."); |
| | 5 | 220 | | if (order.PurchasePrice is { } price) purchase.Add($"закупка {price.ToString("#,0.##", CultureInfo.GetCultureInf |
| | 5 | 221 | | if (purchase.Count > 0) lines.Add("Товар: " + string.Join(", ", purchase)); |
| | | 222 | | |
| | 5 | 223 | | var status = $"Статус у поставщика: {Html(order.StatusName)}"; |
| | 10 | 224 | | if (order.CancelledAt is { } cancelled) status += $" · отменён в Ozon {Moscow(cancelled):dd.MM.yyyy HH:mm}"; |
| | 5 | 225 | | lines.Add(status); |
| | 5 | 226 | | lines.Add($"→ {Html(result.Message)}"); |
| | 5 | 227 | | return string.Join("\n", lines); |
| | | 228 | | } |
| | | 229 | | |
| | 22 | 230 | | private static string Html(string? value) => WebUtility.HtmlEncode(value ?? string.Empty); |
| | | 231 | | |
| | 1 | 232 | | private static readonly TimeZoneInfo MoscowZone = ResolveMoscow(); |
| | | 233 | | |
| | | 234 | | private static TimeZoneInfo ResolveMoscow() |
| | | 235 | | { |
| | 1 | 236 | | try { return TimeZoneInfo.FindSystemTimeZoneById("Europe/Moscow"); } |
| | 0 | 237 | | catch (TimeZoneNotFoundException) { return TimeZoneInfo.CreateCustomTimeZone("MSK", TimeSpan.FromHours(3), "MSK" |
| | 1 | 238 | | } |
| | | 239 | | |
| | | 240 | | private static DateTime Moscow(DateTime utc) => |
| | 10 | 241 | | TimeZoneInfo.ConvertTimeFromUtc(DateTime.SpecifyKind(utc, DateTimeKind.Utc), MoscowZone); |
| | | 242 | | } |