< Summary

Information
Class: OzonOrdersWeb.WebServices.SystemAlerts.SupplierOrderCanceller
Assembly: OzonOrdersWeb
File(s): /home/runner/work/Studio2Prod/Studio2Prod/OzonOrdersWeb/WebServices/SystemAlerts/OzonCancelledSupplierOrderHandler.cs
Line coverage
0%
Covered lines: 0
Uncovered lines: 12
Coverable lines: 12
Total lines: 242
Line coverage: 0%
Branch coverage
0%
Covered branches: 0
Total branches: 41
Branch coverage: 0%
Method coverage

Feature is only available for sponsors

Upgrade to PRO version

Metrics

MethodBranch coverage Crap Score Cyclomatic complexity Line coverage
.ctor(...)100%210%
CancelAsync()0%1722410%

File(s)

/home/runner/work/Studio2Prod/Studio2Prod/OzonOrdersWeb/WebServices/SystemAlerts/OzonCancelledSupplierOrderHandler.cs

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