< Summary

Information
Class: Services.ApiServices.OzonApi.OzonCancelSuccessEntry
Assembly: Services
File(s): /home/runner/work/Studio2Prod/Studio2Prod/AppRepository/ApiServices/OzonApi/OzonOrderCancellationService.cs
Line coverage
100%
Covered lines: 1
Uncovered lines: 0
Coverable lines: 1
Total lines: 588
Line coverage: 100%
Branch coverage
N/A
Covered branches: 0
Total branches: 0
Branch coverage: N/A
Method coverage

Feature is only available for sponsors

Upgrade to PRO version

Metrics

MethodBranch coverage Crap Score Cyclomatic complexity Line coverage
get_ShipmentNumber()100%11100%

File(s)

/home/runner/work/Studio2Prod/Studio2Prod/AppRepository/ApiServices/OzonApi/OzonOrderCancellationService.cs

#LineLine coverage
 1using Microsoft.AspNetCore.Http;
 2using Microsoft.EntityFrameworkCore;
 3using System.Net;
 4using System.Text;
 5using OzonDomains;
 6using OzonDomains.Models;
 7using OzonRepositories.Context;
 8using Services.ApiServices.OzonApi.Filters;
 9using Services.DataServices;
 10using Services.LabelServices;
 11using SlqStudio.Application.Services.EmailService;
 12using Services.OzonWebhookServices;
 13
 14namespace Services.ApiServices.OzonApi
 15{
 16    /// <summary>
 17    /// Данные для UI отмены отправления в Ozon (используется на страницах транзакций,
 18    /// где заказу можно назначить статус «Отменен»).
 19    /// </summary>
 20    public class OzonOrderCancelReasonsInfo
 21    {
 22        public const long ReasonRequiresMessageId = 402;
 23
 24        public string? PostingNumber { get; set; }
 25
 26        public string? ErrorMessage { get; set; }
 27
 28        public List<OzonCancelReasonOption> Reasons { get; set; } = new();
 29
 30        public bool CanCancelInOzon => string.IsNullOrEmpty(ErrorMessage) && Reasons.Count > 0;
 31    }
 32
 33    public class OzonCancelReasonOption
 34    {
 35        public long Id { get; set; }
 36
 37        public string Title { get; set; } = string.Empty;
 38
 39        public string? TypeId { get; set; }
 40    }
 41
 42    public sealed record OzonCancelFormRequest(int OrderId, long ReasonId, string? ReasonMessage, string? ReasonTitle);
 43
 5144    public sealed record OzonCancelSuccessEntry(string ShipmentNumber, string? ReasonTitle, string? ReasonMessage);
 45
 46    public sealed record OzonCancelExecutionResult(List<string> Errors, List<OzonCancelSuccessEntry> SuccessEntries);
 47
 48    /// <summary>
 49    /// Общая логика отмены отправлений Ozon для страниц транзакций: загрузка причин отмены,
 50    /// разбор формы и вызов Ozon API отмены. Используется в транзакциях, где заказу можно
 51    /// назначить статус «Отменен».
 52    /// </summary>
 53    public class OzonOrderCancellationService
 54    {
 55        private readonly ApplicationDbContext _context;
 56        private readonly IHttpClientFactory _httpClientFactory;
 57        private readonly IEmailService? _emailService;
 58        private readonly OzonClientServices _ozonClientServices;
 59        private readonly IOzonPostingCancellationService _postingCancellationService;
 60
 61        public OzonOrderCancellationService(
 62            ApplicationDbContext context,
 63            IHttpClientFactory httpClientFactory,
 64            IEmailService? emailService,
 65            OzonClientServices ozonClientServices,
 66            IOzonPostingCancellationService postingCancellationService)
 67        {
 68            _context = context;
 69            _httpClientFactory = httpClientFactory;
 70            _emailService = emailService;
 71            _ozonClientServices = ozonClientServices;
 72            _postingCancellationService = postingCancellationService;
 73        }
 74
 75        public async Task<Dictionary<int, OzonOrderCancelReasonsInfo>> LoadCancelReasonsAsync(
 76            IReadOnlyList<Order> orders)
 77        {
 78            var reasonsByOrderId = new Dictionary<int, OzonOrderCancelReasonsInfo>();
 79
 80            if (orders.Count == 0)
 81                return reasonsByOrderId;
 82
 83            await EnsureOzonClientsResolvedAsync(orders);
 84
 85            foreach (var order in orders)
 86            {
 87                if (!await IsOzonMarketplaceOrderAsync(order))
 88                    continue;
 89
 90                if (string.IsNullOrWhiteSpace(order.ShipmentNumber))
 91                {
 92                    reasonsByOrderId[order.Id] = new OzonOrderCancelReasonsInfo
 93                    {
 94                        ErrorMessage = "Нет номера отправления"
 95                    };
 96                    continue;
 97                }
 98
 99                if (!ResolveOzonClientId(order).HasValue)
 100                {
 101                    reasonsByOrderId[order.Id] = new OzonOrderCancelReasonsInfo
 102                    {
 103                        PostingNumber = order.ShipmentNumber.Trim(),
 104                        ErrorMessage = "Не указан клиент Ozon"
 105                    };
 106                    continue;
 107                }
 108
 109                reasonsByOrderId[order.Id] = new OzonOrderCancelReasonsInfo
 110                {
 111                    PostingNumber = order.ShipmentNumber.Trim()
 112                };
 113            }
 114
 115            var clientGroups = orders
 116                .Where(o => reasonsByOrderId.ContainsKey(o.Id))
 117                .Select(o => new { Order = o, ClientId = ResolveOzonClientId(o) })
 118                .Where(x => x.ClientId.HasValue && !string.IsNullOrWhiteSpace(x.Order.ShipmentNumber))
 119                .GroupBy(x => x.ClientId!.Value, x => x.Order);
 120
 121            foreach (var clientGroup in clientGroups)
 122            {
 123                try
 124                {
 125                    var ozonClient = await _ozonClientServices.GetOzonClientAsync(clientGroup.Key);
 126                    if (ozonClient == null)
 127                    {
 128                        foreach (var order in clientGroup)
 129                            reasonsByOrderId[order.Id].ErrorMessage = "Клиент Ozon не найден";
 130                        continue;
 131                    }
 132
 133                    var clientId = ozonClient.DecryptClientId;
 134                    var apiKey = ozonClient.DecryptApiKey;
 135
 136                    if (string.IsNullOrWhiteSpace(clientId) || string.IsNullOrWhiteSpace(apiKey))
 137                    {
 138                        foreach (var order in clientGroup)
 139                        {
 140                            reasonsByOrderId[order.Id].ErrorMessage = "Не настроены Client-Id или Api-Key Ozon";
 141                        }
 142
 143                        continue;
 144                    }
 145
 146                    var apiManager = new OzonApiDataManager(_httpClientFactory, _emailService, clientId, apiKey);
 147                    var postingNumbers = clientGroup
 148                        .Select(o => o.ShipmentNumber!.Trim())
 149                        .Distinct(StringComparer.OrdinalIgnoreCase)
 150                        .ToList();
 151
 152                    var response = await apiManager.GetFbsCancelReasonsAsync(postingNumbers);
 153                    var reasonsByPosting = (response?.Result ?? new List<FbsCancelReasonPostingResult>())
 154                        .Where(r => !string.IsNullOrWhiteSpace(r.PostingNumber))
 155                        .ToDictionary(
 156                            r => r.PostingNumber!,
 157                            r => r.Reasons ?? new List<FbsCancelReasonItem>(),
 158                            StringComparer.OrdinalIgnoreCase);
 159
 160                    var withoutReasons = new List<Order>();
 161                    foreach (var order in clientGroup)
 162                    {
 163                        var posting = order.ShipmentNumber!.Trim();
 164                        if (!reasonsByPosting.TryGetValue(posting, out var cancelReasons) || cancelReasons.Count == 0)
 165                        {
 166                            reasonsByOrderId[order.Id].ErrorMessage = NoReasonsMessage;
 167                            withoutReasons.Add(order);
 168                            continue;
 169                        }
 170
 171                        reasonsByOrderId[order.Id].Reasons = cancelReasons
 172                            .Select(r => new OzonCancelReasonOption
 173                            {
 174                                Id = r.Id,
 175                                Title = r.Title ?? string.Empty,
 176                                TypeId = r.TypeId
 177                            })
 178                            .ToList();
 179                    }
 180
 181                    if (withoutReasons.Count > 0)
 182                        await RefreshClientStatusesAsync(apiManager, withoutReasons, reasonsByOrderId);
 183                }
 184                catch (Exception ex)
 185                {
 186                    foreach (var order in clientGroup)
 187                        reasonsByOrderId[order.Id].ErrorMessage = ex.Message;
 188                }
 189            }
 190
 191            return reasonsByOrderId;
 192        }
 193
 194        private const string NoReasonsMessage =
 195            "Ozon не вернул причины отмены: отправление, вероятно, уже отменено или отгружено в Ozon";
 196
 197        /// <summary>
 198        /// Ozon не отдаёт причины отмены, когда отправление уже не отменить, а клиентский статус в базе мог устареть
 199        /// (например, «Ожидает упаковки», хотя в Ozon оно давно «Отменён»). Спрашиваем актуальный статус, обновляем
 200        /// ClientStatus заказа и пишем его в подсказку. Статус приложения не трогаем: на странице отмены заказ ещё
 201        /// должен пройти проведение. Сбой проверки не мешает странице — остаётся общая подсказка.
 202        /// </summary>
 203        private async Task RefreshClientStatusesAsync(
 204            OzonApiDataManager apiManager,
 205            IReadOnlyCollection<Order> orders,
 206            Dictionary<int, OzonOrderCancelReasonsInfo> reasonsByOrderId)
 207        {
 208            try
 209            {
 210                var numbers = orders.Select(o => o.ShipmentNumber!.Trim()).Distinct(StringComparer.OrdinalIgnoreCase).To
 211                var statuses = await apiManager.GetProductStatusBatch(numbers);
 212
 213                var updates = new Dictionary<int, string>();
 214                foreach (var order in orders)
 215                {
 216                    var posting = order.ShipmentNumber!.Trim();
 217                    var apiPosting = OzonPostingNumberHelper.GetOzonApiPostingNumber(posting);
 218                    var found = statuses.TryGetValue(posting, out var api) ||
 219                                (!string.IsNullOrEmpty(apiPosting) && statuses.TryGetValue(apiPosting, out api));
 220                    if (!found || !api.Success || string.IsNullOrWhiteSpace(api.Status))
 221                        continue;
 222
 223                    var humanStatus = Services.ParserServices.HelpDictEnum.OzonStatus.OrderStatuses.TryGetValue(api.Stat
 224                        ? mapped
 225                        : api.Status;
 226                    reasonsByOrderId[order.Id].ErrorMessage =
 227                        $"Отправление в Ozon уже в статусе «{humanStatus}» — отмена через API недоступна";
 228
 229                    if (!string.Equals(order.ClientStatus?.Name, humanStatus, StringComparison.OrdinalIgnoreCase))
 230                        updates[order.Id] = humanStatus;
 231                }
 232
 233                if (updates.Count == 0)
 234                    return;
 235
 236                var ids = updates.Keys.ToList();
 237                var tracked = await _context.Orders.Where(o => ids.Contains(o.Id)).ToListAsync();
 238                var statusCache = new Dictionary<string, ClientStatus>(StringComparer.OrdinalIgnoreCase);
 239                var now = DateTime.Now;
 240                foreach (var order in tracked)
 241                {
 242                    var name = updates[order.Id];
 243                    if (!statusCache.TryGetValue(name, out var clientStatus))
 244                    {
 245                        clientStatus = await _context.ClientStatuses.FirstOrDefaultAsync(cs => cs.Name == name);
 246                        if (clientStatus == null)
 247                        {
 248                            clientStatus = new ClientStatus { Name = name };
 249                            _context.ClientStatuses.Add(clientStatus);
 250                            await _context.SaveChangesAsync();
 251                        }
 252
 253                        statusCache[name] = clientStatus;
 254                    }
 255
 256                    order.ClientStatusId = clientStatus.Id;
 257                    order.LastStatusChangeDate = now;
 258                }
 259
 260                await _context.SaveChangesAsync();
 261            }
 262            catch
 263            {
 264                // Подсказка и так объясняет, почему чекбокса нет; проверка статуса — best effort.
 265            }
 266        }
 267
 268        public async Task<OzonCancelExecutionResult> CancelOrdersAsync(
 269            IReadOnlyList<OzonCancelFormRequest> requests,
 270            IReadOnlyDictionary<int, Order> ordersById)
 271        {
 272            var errors = new List<string>();
 273            var successEntries = new List<OzonCancelSuccessEntry>();
 274
 275            if (requests.Count == 0)
 276                return new OzonCancelExecutionResult(errors, successEntries);
 277
 278            await EnsureOzonClientsResolvedAsync(ordersById.Values);
 279
 280            foreach (var request in requests)
 281            {
 282                if (!ordersById.TryGetValue(request.OrderId, out var order))
 283                {
 284                    errors.Add($"Заказ #{request.OrderId} не найден в транзакции.");
 285                    continue;
 286                }
 287
 288                if (!await IsOzonMarketplaceOrderAsync(order))
 289                    continue;
 290
 291                if (string.IsNullOrWhiteSpace(order.ShipmentNumber))
 292                {
 293                    errors.Add($"Заказ {order.ShipmentNumber ?? "#" + order.Id}: нет номера отправления.");
 294                    continue;
 295                }
 296
 297                var ozonClientId = ResolveOzonClientId(order);
 298                if (!ozonClientId.HasValue)
 299                {
 300                    errors.Add($"Заказ {order.ShipmentNumber}: не указан клиент Ozon.");
 301                    continue;
 302                }
 303
 304                if (request.ReasonId <= 0)
 305                {
 306                    errors.Add($"Заказ {order.ShipmentNumber}: выберите причину отмены Ozon.");
 307                    continue;
 308                }
 309
 310                if (request.ReasonId == OzonOrderCancelReasonsInfo.ReasonRequiresMessageId &&
 311                    string.IsNullOrWhiteSpace(request.ReasonMessage))
 312                {
 313                    errors.Add(
 314                        $"Заказ {order.ShipmentNumber}: для причины «Другое (вина продавца)» укажите комментарий.");
 315                    continue;
 316                }
 317
 318                try
 319                {
 320                    var ozonClient = await _ozonClientServices.GetOzonClientAsync(ozonClientId.Value);
 321                    if (ozonClient == null)
 322                    {
 323                        errors.Add($"Заказ {order.ShipmentNumber}: клиент Ozon не найден.");
 324                        continue;
 325                    }
 326
 327                    var clientId = ozonClient.DecryptClientId;
 328                    var apiKey = ozonClient.DecryptApiKey;
 329
 330                    if (string.IsNullOrWhiteSpace(clientId) || string.IsNullOrWhiteSpace(apiKey))
 331                    {
 332                        errors.Add($"Заказ {order.ShipmentNumber}: не настроены учётные данные Ozon API.");
 333                        continue;
 334                    }
 335
 336                    var apiManager = new OzonApiDataManager(_httpClientFactory, _emailService, clientId, apiKey);
 337                    var cancelled = await apiManager.CancelFbsPostingAsync(
 338                        order.ShipmentNumber,
 339                        request.ReasonId,
 340                        request.ReasonMessage);
 341
 342                    if (!cancelled)
 343                    {
 344                        errors.Add($"Заказ {order.ShipmentNumber}: Ozon не подтвердил отмену отправления.");
 345                        continue;
 346                    }
 347
 348                    await _postingCancellationService.RecordAsync(ozonClient.Id, order.ShipmentNumber.Trim(),
 349                        request.ReasonId, request.ReasonTitle, request.ReasonMessage, null, DateTime.UtcNow,
 350                        "Manual", [order.Id]);
 351
 352                    successEntries.Add(new OzonCancelSuccessEntry(
 353                        order.ShipmentNumber.Trim(),
 354                        request.ReasonTitle,
 355                        request.ReasonMessage));
 356                }
 357                catch (Exception ex)
 358                {
 359                    errors.Add($"Заказ {order.ShipmentNumber}: {ex.Message}");
 360                }
 361            }
 362
 363            return new OzonCancelExecutionResult(errors, successEntries);
 364        }
 365
 366        public static List<OzonCancelFormRequest> ParseFormRequests(IFormCollection form)
 367        {
 368            var result = new List<OzonCancelFormRequest>();
 369            var checkedOrderIds = new HashSet<int>();
 370
 371            foreach (var key in form.Keys)
 372            {
 373                if (!key.StartsWith("CancelInOzon[", StringComparison.Ordinal) || !key.EndsWith(']'))
 374                    continue;
 375
 376                var idPart = key["CancelInOzon[".Length..^1];
 377                if (!int.TryParse(idPart, out var orderId))
 378                    continue;
 379
 380                var value = form[key].FirstOrDefault();
 381                if (string.Equals(value, "true", StringComparison.OrdinalIgnoreCase) ||
 382                    string.Equals(value, "on", StringComparison.OrdinalIgnoreCase))
 383                {
 384                    checkedOrderIds.Add(orderId);
 385                }
 386            }
 387
 388            foreach (var orderId in checkedOrderIds)
 389            {
 390                long reasonId = 0;
 391                var reasonKey = $"OzonCancelReasonId[{orderId}]";
 392                if (form.ContainsKey(reasonKey))
 393                    long.TryParse(form[reasonKey].FirstOrDefault(), out reasonId);
 394
 395                string? message = null;
 396                var messageKey = $"OzonCancelReasonMessage[{orderId}]";
 397                if (form.ContainsKey(messageKey))
 398                    message = form[messageKey].FirstOrDefault();
 399
 400                string? reasonTitle = null;
 401                var titleKey = $"OzonCancelReasonTitle[{orderId}]";
 402                if (form.ContainsKey(titleKey))
 403                    reasonTitle = form[titleKey].FirstOrDefault();
 404
 405                result.Add(new OzonCancelFormRequest(orderId, reasonId, message, reasonTitle));
 406            }
 407
 408            return result;
 409        }
 410
 411        public static string? BuildSuccessHtml(IReadOnlyList<OzonCancelSuccessEntry> entries)
 412        {
 413            if (entries == null || entries.Count == 0)
 414                return null;
 415
 416            var sb = new StringBuilder();
 417            sb.Append("<div class='tx-result-extra ozon-cancel-success-summary'>");
 418            sb.Append("<div class='small fw-semibold mb-1'><i class='fas fa-store me-1'></i>Отмена в Ozon</div>");
 419            sb.Append("<ul class='small mb-0 ps-3' style='list-style: disc;'>");
 420            foreach (var entry in entries)
 421            {
 422                sb.Append("<li>");
 423                sb.Append(WebUtility.HtmlEncode(entry.ShipmentNumber));
 424                if (!string.IsNullOrWhiteSpace(entry.ReasonTitle))
 425                {
 426                    sb.Append(" — ");
 427                    sb.Append(WebUtility.HtmlEncode(entry.ReasonTitle));
 428                }
 429
 430                if (!string.IsNullOrWhiteSpace(entry.ReasonMessage))
 431                {
 432                    sb.Append(" (");
 433                    sb.Append(WebUtility.HtmlEncode(entry.ReasonMessage));
 434                    sb.Append(')');
 435                }
 436
 437                sb.Append("</li>");
 438            }
 439
 440            sb.Append("</ul></div>");
 441            return sb.ToString();
 442        }
 443
 444        /// <summary>
 445        /// Клиент Ozon из FK, навигации (в т.ч. скрытое поле формы) или данных заказа в БД.
 446        /// </summary>
 447        private async Task<bool> IsOzonMarketplaceOrderAsync(Order order)
 448        {
 449            if (order.OzonClient?.ClientType == ClientType.OZON)
 450                return true;
 451
 452            var clientId = ResolveOzonClientId(order);
 453            if (!clientId.HasValue)
 454                return false;
 455
 456            try
 457            {
 458                var client = await _ozonClientServices.GetOzonClientAsync(clientId.Value);
 459                if (client == null)
 460                    return false;
 461
 462                order.OzonClient ??= client;
 463                return client.ClientType == ClientType.OZON;
 464            }
 465            catch
 466            {
 467                return false;
 468            }
 469        }
 470
 471        private static int? ResolveOzonClientId(Order order)
 472        {
 473            if (order.OzonClientId is > 0)
 474                return order.OzonClientId;
 475
 476            if (order.OzonClient?.Id is > 0)
 477                return order.OzonClient.Id;
 478
 479            return null;
 480        }
 481
 482        private async Task EnsureOzonClientsResolvedAsync(IEnumerable<Order> orders)
 483        {
 484            var orderList = orders.ToList();
 485            if (orderList.Count == 0)
 486                return;
 487
 488            foreach (var order in orderList)
 489            {
 490                var resolved = ResolveOzonClientId(order);
 491                if (resolved.HasValue)
 492                    order.OzonClientId = resolved;
 493            }
 494
 495            var missingIds = orderList
 496                .Where(o => !ResolveOzonClientId(o).HasValue)
 497                .Select(o => o.Id)
 498                .Distinct()
 499                .ToList();
 500
 501            if (missingIds.Count == 0)
 502                return;
 503
 504            var dbOrders = await _context.Orders
 505                .AsNoTracking()
 506                .Include(o => o.OzonClient)
 507                .Where(o => missingIds.Contains(o.Id))
 508                .ToListAsync();
 509
 510            var dbById = dbOrders.ToDictionary(o => o.Id);
 511            foreach (var order in orderList.Where(o => !ResolveOzonClientId(o).HasValue))
 512            {
 513                if (!dbById.TryGetValue(order.Id, out var dbOrder))
 514                    continue;
 515
 516                ApplyOzonClientFromSource(order, dbOrder);
 517            }
 518
 519            var stillMissing = orderList.Where(o => !ResolveOzonClientId(o).HasValue).ToList();
 520            if (stillMissing.Count == 0)
 521                return;
 522
 523            var shipmentIds = stillMissing
 524                .Where(o => o.ShipmentId.HasValue)
 525                .Select(o => o.ShipmentId!.Value)
 526                .Distinct()
 527                .ToList();
 528
 529            if (shipmentIds.Count > 0)
 530            {
 531                var clientByShipment = await _context.Orders
 532                    .AsNoTracking()
 533                    .Where(o => o.ShipmentId.HasValue &&
 534                                shipmentIds.Contains(o.ShipmentId.Value) &&
 535                                o.OzonClientId != null)
 536                    .GroupBy(o => o.ShipmentId!.Value)
 537                    .Select(g => new { ShipmentId = g.Key, OzonClientId = g.Select(x => x.OzonClientId).First() })
 538                    .ToDictionaryAsync(x => x.ShipmentId, x => x.OzonClientId!.Value);
 539
 540                foreach (var order in stillMissing.Where(o => o.ShipmentId.HasValue))
 541                {
 542                    if (clientByShipment.TryGetValue(order.ShipmentId!.Value, out var clientId))
 543                        order.OzonClientId = clientId;
 544                }
 545            }
 546
 547            stillMissing = orderList.Where(o => !ResolveOzonClientId(o).HasValue).ToList();
 548            var shipmentNumbers = stillMissing
 549                .Select(o => o.ShipmentNumber?.Trim())
 550                .Where(n => !string.IsNullOrWhiteSpace(n))
 551                .Distinct(StringComparer.OrdinalIgnoreCase)
 552                .ToList();
 553
 554            if (shipmentNumbers.Count == 0)
 555                return;
 556
 557            var clientByPosting = await _context.Orders
 558                .AsNoTracking()
 559                .Where(o => o.ShipmentNumber != null &&
 560                            o.OzonClientId != null &&
 561                            shipmentNumbers.Contains(o.ShipmentNumber))
 562                .GroupBy(o => o.ShipmentNumber!)
 563                .Select(g => new { ShipmentNumber = g.Key, OzonClientId = g.Select(x => x.OzonClientId).First() })
 564                .ToDictionaryAsync(x => x.ShipmentNumber, x => x.OzonClientId!.Value, StringComparer.OrdinalIgnoreCase);
 565
 566            foreach (var order in stillMissing)
 567            {
 568                var posting = order.ShipmentNumber?.Trim();
 569                if (string.IsNullOrWhiteSpace(posting))
 570                    continue;
 571
 572                if (clientByPosting.TryGetValue(posting, out var clientId))
 573                    order.OzonClientId = clientId;
 574            }
 575        }
 576
 577        private static void ApplyOzonClientFromSource(Order target, Order source)
 578        {
 579            if (source.OzonClientId is > 0)
 580                target.OzonClientId = source.OzonClientId;
 581            else if (source.OzonClient?.Id is > 0)
 582                target.OzonClientId = source.OzonClient.Id;
 583
 584            if (target.OzonClient == null && source.OzonClient != null)
 585                target.OzonClient = source.OzonClient;
 586        }
 587    }
 588}

Methods/Properties

get_ShipmentNumber()