< Summary

Information
Class: OzonOrdersWeb.WebServices.SystemAlerts.OzonCancelledSupplierOrderHandler
Assembly: OzonOrdersWeb
File(s): /home/runner/work/Studio2Prod/Studio2Prod/OzonOrdersWeb/WebServices/SystemAlerts/OzonCancelledSupplierOrderHandler.cs
Line coverage
91%
Covered lines: 112
Uncovered lines: 11
Coverable lines: 123
Total lines: 242
Line coverage: 91%
Branch coverage
71%
Covered branches: 56
Total branches: 78
Branch coverage: 71.7%
Method coverage

Feature is only available for sponsors

Upgrade to PRO version

Metrics

MethodBranch coverage Crap Score Cyclomatic complexity Line coverage
.ctor(...)100%11100%
get_Order()100%11100%
HandleAsync()83.33%66100%
ResolveAsync()90%101084.21%
ReturnHint(...)37.5%10866.66%
RecordAsync()100%11100%
NotifyAsync()66.66%11650%
BuildMessages(...)91.66%1212100%
Block(...)67.64%3434100%
Html(...)50%22100%
.cctor()100%11100%
ResolveMoscow()100%1166.66%
Moscow(...)100%11100%

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
 20public 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>
 550public sealed class OzonCancelledSupplierOrderHandler(SupplierOrdersSystemAlertSource source, ApplicationDbContext conte
 551    ISupplierOrderCanceller canceller, ITelegramNotifier telegram, TelegramBotConfig telegramConfig,
 552    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
 6165    public sealed record Result(OzonCancelledSupplierOrder Order, CancelAction Action, string Message);
 66
 67    public async Task<IReadOnlyList<Result>> HandleAsync(CancellationToken cancellationToken = default)
 68    {
 669        var rows = await source.FindOzonCancelledLiveAsync(cancellationToken);
 670        if (rows.Count == 0) return [];
 71
 72        var orderIds = rows.Select(x => x.OrderId).Distinct().ToList();
 673        var processed = (await context.OrderHistories.AsNoTracking()
 674                .Where(x => orderIds.Contains(x.OrderId) && x.ColumnName == HistoryColumnName)
 675                .Select(x => new { x.OrderId, x.OldValue })
 676                .ToListAsync(cancellationToken))
 77            .Select(x => (x.OrderId, x.OldValue ?? string.Empty))
 678            .ToHashSet();
 1279        var fresh = rows.Where(x => !processed.Contains((x.OrderId, x.ProviderKey))).ToList();
 780        if (fresh.Count == 0) return [];
 81
 582        var settings = options.Value;
 583        var cancellable = orderIds.ToHashSet();
 584        var results = new List<Result>();
 2085        foreach (var row in fresh)
 86        {
 587            var result = await ResolveAsync(row, settings, cancellable, cancellationToken);
 588            results.Add(result);
 589            await RecordAsync(result, cancellationToken);
 90        }
 91
 592        await NotifyAsync(results, settings, cancellationToken);
 593        return results;
 694    }
 95
 96    private async Task<Result> ResolveAsync(OzonCancelledSupplierOrder row, SystemAlertsOptions settings, IReadOnlySet<i
 97    {
 698        if (row.Delivered) return new(row, CancelAction.ReturnNeeded, $"уже отгружен ({row.StatusName}) — {ReturnHint(ro
 99
 4100        var supported = settings.SupplierAutoCancelProviders.Contains(row.ProviderKey, StringComparer.OrdinalIgnoreCase)
 5101        if (!supported) return new(row, CancelAction.Manual, "в API поставщика нет отмены — отменить у поставщика вручну
 4102        if (!settings.SupplierAutoCancelEnabled) return new(row, CancelAction.Manual, "автоотмена выключена — отменить у
 103
 104        SupplierOrderCancelResult cancel;
 105        try
 106        {
 2107            cancel = await canceller.CancelAsync(row, cancellable, ct);
 1108        }
 0109        catch (OperationCanceledException) when (ct.IsCancellationRequested)
 110        {
 0111            throw;
 112        }
 1113        catch (Exception ex)
 114        {
 1115            logger.LogWarning(ex, "Автоотмена заказа {OrderId} у {Provider} упала", row.OrderId, row.ProviderKey);
 1116            cancel = SupplierOrderCancelResult.Failed(ex.Message);
 1117        }
 118
 2119        return cancel.Outcome switch
 2120        {
 1121            SupplierOrderCancelOutcome.Cancelled => new(row, CancelAction.Cancelled, $"отменён автоматически ({cancel.Me
 0122            SupplierOrderCancelOutcome.ReturnNeeded => new(row, CancelAction.ReturnNeeded, $"{cancel.Message} — {ReturnH
 1123            _ => new(row, CancelAction.CancelFailed, $"автоотмена не удалась: {cancel.Message} — отменить у поставщика в
 2124        };
 5125    }
 126
 1127    private static string ReturnHint(string providerKey) => providerKey switch
 1128    {
 1129        "MlAuto" or "MlAutoRu" => "оформить возврат (CreateReturn, причина 09 «Отказ конечного потребителя»)",
 0130        "Armtek" or "ArmtekBy" => "оформить возврат по фактуре (createReturnInvoice)",
 0131        _ => "оформить возврат поставщику"
 1132    };
 133
 134    private async Task RecordAsync(Result result, CancellationToken ct)
 135    {
 5136        context.OrderHistories.Add(new OzonDomains.Models.OrderHistory
 5137        {
 5138            OrderId = result.Order.OrderId,
 5139            ColumnName = HistoryColumnName,
 5140            ColumnDisplayName = $"Отмена у поставщика {result.Order.ProviderName}",
 5141            OldValue = result.Order.ProviderKey,
 5142            NewValue = $"{result.Action}: {result.Message}",
 5143            ChangedAt = DateTime.Now,
 5144            ChangedBy = "system"
 5145        });
 5146        await context.SaveChangesAsync(ct);
 5147    }
 148
 149    private async Task NotifyAsync(IReadOnlyList<Result> results, SystemAlertsOptions settings, CancellationToken ct)
 150    {
 5151        if (!telegram.IsConfigured || results.Count == 0) return;
 152        try
 153        {
 20154            foreach (var html in BuildMessages(results, settings.SupplierCancelMentions, telegramConfig.SiteBaseUrl))
 5155                await telegram.SendAsync(new TelegramMessage(html, Channel: TelegramChannel.General), ct);
 5156        }
 0157        catch (OperationCanceledException) when (ct.IsCancellationRequested)
 158        {
 0159            throw;
 160        }
 0161        catch (Exception ex)
 162        {
 0163            logger.LogWarning(ex, "Не удалось отправить в Telegram заказы, отменённые в Ozon");
 0164        }
 5165    }
 166
 167    public static IReadOnlyList<string> BuildMessages(IReadOnlyList<Result> results, IReadOnlyList<string> mentions, str
 168    {
 10169        var mentionLine = string.Join(" ", mentions.Where(x => !string.IsNullOrWhiteSpace(x))
 15170            .Select(x => x.Trim()).Select(x => x.StartsWith('@') ? x : "@" + x).Distinct(StringComparer.OrdinalIgnoreCas
 10171        var manual = results.Count(x => x.Action != CancelAction.Cancelled);
 5172        var messages = new List<string>();
 20173        foreach (var chunk in results.Chunk(OrdersPerMessage))
 174        {
 5175            var lines = new List<string>
 5176            {
 5177                $"<b>⚠️ Отменено в Ozon, а заказ у поставщика активен: {results.Count}</b>"
 5178                + (manual > 0 ? $" (нужны действия: {manual})" : " (всё отменено автоматически)")
 5179            };
 10180            if (mentionLine.Length > 0) lines.Add(WebUtility.HtmlEncode(mentionLine));
 10181            lines.AddRange(chunk.Select(x => string.Empty + "\n" + Block(x, siteBaseUrl)));
 10182            var all = SystemAlertLinks.Orders(chunk.Select(x => x.Order.OrderId));
 5183            if (TelegramMessageFactory.AbsoluteUrl(all, siteBaseUrl) is { } allUrl)
 5184                lines.Add($"\n<a href=\"{WebUtility.HtmlEncode(allUrl)}\">Открыть все заказы в Studio2</a>");
 5185            messages.Add(string.Join("\n", lines));
 186        }
 5187        return messages;
 188    }
 189
 190    /// <summary>Карточка заказа: ссылка, отправление, поставщик и его номер заказа, даты, товар и закупка, что сделать.
 191    public static string Block(Result result, string? siteBaseUrl)
 192    {
 5193        var order = result.Order;
 5194        var url = TelegramMessageFactory.AbsoluteUrl(SystemAlertLinks.Orders([order.OrderId]), siteBaseUrl);
 5195        var orderRef = url is null ? $"<b>#{order.OrderId}</b>" : $"<a href=\"{WebUtility.HtmlEncode(url)}\"><b>#{order.
 5196        var icon = result.Action switch
 5197        {
 1198            CancelAction.Cancelled => "✅",
 1199            CancelAction.ReturnNeeded => "↩️",
 3200            _ => "❗"
 5201        };
 202
 5203        var head = $"{icon} {orderRef}";
 10204        if (!string.IsNullOrWhiteSpace(order.ShipmentNumber)) head += $" · отпр. {Html(order.ShipmentNumber)}";
 5205        if (order.AcceptedAt is { } accepted) head += $" · принят {accepted:dd.MM.yyyy}";
 206
 5207        var supplier = string.IsNullOrWhiteSpace(order.SupplierName) || string.Equals(order.SupplierName, order.Provider
 5208            ? Html(order.ProviderName)
 5209            : $"{Html(order.SupplierName)} ({Html(order.ProviderName)})";
 5210        var supplierOrder = order.ExternalOrderNumber ?? order.ExternalOrderId;
 5211        supplier += string.IsNullOrWhiteSpace(supplierOrder) ? ", номер заказа неизвестен" : $", заказ <code>{Html(suppl
 10212        if (order.OrderedAt is { } ordered) supplier += $" от {Moscow(ordered):dd.MM.yyyy}";
 213
 5214        var lines = new List<string> { head, "Поставщик: " + supplier };
 215
 15216        var product = string.Join(" ", new[] { order.Article, order.ProductName }.Where(x => !string.IsNullOrWhiteSpace(
 5217        var purchase = new List<string>();
 5218        if (!string.IsNullOrWhiteSpace(product)) purchase.Add(Html(product.Length <= 80 ? product : product[..79] + "…")
 5219        if (order.Quantity is > 0) purchase.Add($"{order.Quantity} шт.");
 5220        if (order.PurchasePrice is { } price) purchase.Add($"закупка {price.ToString("#,0.##", CultureInfo.GetCultureInf
 5221        if (purchase.Count > 0) lines.Add("Товар: " + string.Join(", ", purchase));
 222
 5223        var status = $"Статус у поставщика: {Html(order.StatusName)}";
 10224        if (order.CancelledAt is { } cancelled) status += $" · отменён в Ozon {Moscow(cancelled):dd.MM.yyyy HH:mm}";
 5225        lines.Add(status);
 5226        lines.Add($"→ {Html(result.Message)}");
 5227        return string.Join("\n", lines);
 228    }
 229
 22230    private static string Html(string? value) => WebUtility.HtmlEncode(value ?? string.Empty);
 231
 1232    private static readonly TimeZoneInfo MoscowZone = ResolveMoscow();
 233
 234    private static TimeZoneInfo ResolveMoscow()
 235    {
 1236        try { return TimeZoneInfo.FindSystemTimeZoneById("Europe/Moscow"); }
 0237        catch (TimeZoneNotFoundException) { return TimeZoneInfo.CreateCustomTimeZone("MSK", TimeSpan.FromHours(3), "MSK"
 1238    }
 239
 240    private static DateTime Moscow(DateTime utc) =>
 10241        TimeZoneInfo.ConvertTimeFromUtc(DateTime.SpecifyKind(utc, DateTimeKind.Utc), MoscowZone);
 242}