< Summary

Information
Class: OzonOrdersWeb.WebServices.Partners.PartnerOrderActionsService
Assembly: OzonOrdersWeb
File(s): /home/runner/work/Studio2Prod/Studio2Prod/OzonOrdersWeb/WebServices/Partners/PartnerOrderActionsService.cs
Line coverage
24%
Covered lines: 51
Uncovered lines: 158
Coverable lines: 209
Total lines: 381
Line coverage: 24.4%
Branch coverage
18%
Covered branches: 21
Total branches: 114
Branch coverage: 18.4%
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%210%
GetRequirementsAsync()50%10427.27%
GetCountriesAsync()50%3230%
SetCountriesAsync()50%18630.76%
SetExemplarsAsync()50%19629.41%
ShipAsync()50%3250%
PrintLabelAsync()3.12%898325.45%
ShipCoreAsync()0%4260%
LoadStudioSiblingsAsync()0%2040%
ResolveAsync()31.81%832250%
AuditAsync(...)100%210%
RecordPartnerLabelAsync()0%620%

File(s)

/home/runner/work/Studio2Prod/Studio2Prod/OzonOrdersWeb/WebServices/Partners/PartnerOrderActionsService.cs

#LineLine coverage
 1using Microsoft.EntityFrameworkCore;
 2using OzonDomains.Models;
 3using OzonDomains.Models.Partners;
 4using OzonOrdersWeb.WebServices.Orders;
 5using OzonRepositories.Context;
 6using Services.ApiServices.OzonApi;
 7using Services.DataServices;
 8using Services.LabelServices;
 9using Services.PartnerServices;
 10
 11namespace OzonOrdersWeb.WebServices.Partners;
 12
 13public interface IPartnerOrderActionsService
 14{
 15    /// <summary>Что Ozon просит заполнить перед отгрузкой: страны-изготовители и данные экземпляров.</summary>
 16    Task<PartnerActionResult<PartnerShipRequirements>> GetRequirementsAsync(long partnerId, int orderId, CancellationTok
 17
 18    Task<PartnerActionResult<IReadOnlyList<PartnerCountryOption>>> GetCountriesAsync(long partnerId, int orderId, string
 19
 20    Task<PartnerActionResult> SetCountriesAsync(long partnerId, int orderId, IReadOnlyList<PartnerCountrySelection> sele
 21
 22    Task<PartnerActionResult> SetExemplarsAsync(long partnerId, int orderId, IReadOnlyList<OzonExemplarInput> exemplars,
 23
 24    /// <summary>Отгрузка: вызов Ozon API (/v4/posting/fbs/ship) по заказу, как «Отгрузить» в Studio2.</summary>
 25    Task<PartnerActionResult> ShipAsync(long partnerId, int orderId, CancellationToken ct = default);
 26
 27    /// <summary>Печать этикетки как в Studio2: готовая из хранилища либо отгрузка (если нужна) и получение у Ozon.</sum
 28    Task<PartnerActionResult<PartnerPrintedLabel>> PrintLabelAsync(long partnerId, int orderId, CancellationToken ct = d
 29}
 30
 31/// <summary>
 32/// Действия партнёра над заказом Ozon. Вся работа с Ozon идёт через те же сервисы, что и в Studio2
 33/// (<see cref="IOrdersOzonShipService"/>, <see cref="IOzonLabelPrintService"/>, <see cref="IOzonExemplarService"/>),
 34/// а этот класс только проверяет права и видимость заказа, выбирает клиента Ozon и пишет журнал.
 35/// </summary>
 736public sealed class PartnerOrderActionsService(
 737    IPartnerOrdersService visibility,
 738    IPartnerPermissionService permissions,
 739    IPartnerAuditService audit,
 740    ApplicationDbContext db,
 741    OrdersDataServices orderServices,
 742    OzonClientServices clientServices,
 743    IOzonExemplarService exemplars,
 744    IOrdersOzonShipService shipService,
 745    IOzonLabelPrintService labelPrint,
 746    OzonJsonDataBuilder jsonBuilder,
 747    OzonApiDataManager ozonApi,
 748    ILabelFileStorage labelStorage,
 749    ILabelExistenceService labelExistence,
 750    ILabelPrintLogService labelPrintLog,
 751    IOrdersClientStatusSyncService statusSync) : IPartnerOrderActionsService
 52{
 053    private sealed record Target(Order Order, OzonClient Client, string ApiPosting);
 54
 55    public async Task<PartnerActionResult<PartnerShipRequirements>> GetRequirementsAsync(
 56        long partnerId, int orderId, CancellationToken ct = default)
 57    {
 358        var (target, error) = await ResolveAsync(partnerId, orderId, ct, PartnerPermissionCode.Assemble, PartnerPermissi
 659        if (target is null) return new(error!, null);
 60
 61        try
 62        {
 063            var api = ozonApi.SetClient(target.Client.DecryptClientId!, target.Client.DecryptApiKey!);
 064            var products = await api.GetFbsProductsRequiringCountryAsync(target.ApiPosting);
 065            var countries = products
 066                .Select(p => new PartnerCountryRequirement(
 067                    p["sku"]?.ToObject<long>() ?? 0,
 068                    p["name"]?.ToObject<string>() ?? p["offer_id"]?.ToObject<string>() ?? "Товар",
 069                    p["offer_id"]?.ToObject<string>() ?? string.Empty))
 070                .Where(c => c.ProductId > 0).ToList();
 71
 072            exemplars.SetClient(target.Client.DecryptClientId!, target.Client.DecryptApiKey!);
 073            var items = await exemplars.GetRequirementsAsync([target.Order], ct);
 074            var exemplarList = items.Select(i => new PartnerExemplarRequirement(
 075                i.ShipmentNumber, i.Requirement.ProductId, i.ProductName, i.OfferId, i.Requirement.ExemplarId,
 076                i.Requirement.Index, i.Requirement.Quantity, i.Requirement.NeedsMandatoryMark, i.Requirement.NeedsGtd,
 077                i.Requirement.NeedsRnpt, i.Requirement.Unsupported)).ToList();
 78
 079            return new(new PartnerActionResult(true, "Требования Ozon получены."), new PartnerShipRequirements(countries
 80        }
 081        catch (Exception ex)
 82        {
 083            return new(PartnerActionResult.Fail(PartnerActionError.Failed, $"Ozon не отдал требования: {ex.Message}"), n
 84        }
 385    }
 86
 87    public async Task<PartnerActionResult<IReadOnlyList<PartnerCountryOption>>> GetCountriesAsync(
 88        long partnerId, int orderId, string? search, CancellationToken ct = default)
 89    {
 190        var (target, error) = await ResolveAsync(partnerId, orderId, ct, PartnerPermissionCode.Assemble, PartnerPermissi
 291        if (target is null) return new(error!, null);
 92
 93        try
 94        {
 095            var api = ozonApi.SetClient(target.Client.DecryptClientId!, target.Client.DecryptApiKey!);
 096            var countries = await api.GetFbsProductCountriesAsync(search);
 097            IReadOnlyList<PartnerCountryOption> list = countries
 098                .Select(c => new PartnerCountryOption(
 099                    c["name"]?.ToObject<string>() ?? string.Empty,
 0100                    c["country_iso_code"]?.ToObject<string>() ?? string.Empty))
 0101                .Where(c => !string.IsNullOrWhiteSpace(c.CountryIsoCode))
 0102                .OrderBy(c => c.Name, StringComparer.Create(System.Globalization.CultureInfo.GetCultureInfo("ru-RU"), ig
 0103                .ToList();
 0104            return new(new PartnerActionResult(true, "Справочник стран получен."), list);
 105        }
 0106        catch (Exception ex)
 107        {
 0108            return new(PartnerActionResult.Fail(PartnerActionError.Failed, $"Ozon не отдал список стран: {ex.Message}"),
 109        }
 1110    }
 111
 112    public async Task<PartnerActionResult> SetCountriesAsync(
 113        long partnerId, int orderId, IReadOnlyList<PartnerCountrySelection> selections, CancellationToken ct = default)
 114    {
 5115        if (selections.Count == 0 || selections.Any(s => s.ProductId <= 0 || string.IsNullOrWhiteSpace(s.CountryIsoCode)
 2116            return PartnerActionResult.Fail(PartnerActionError.Failed, "Не заполнены страны-изготовители.");
 117
 1118        var (target, error) = await ResolveAsync(partnerId, orderId, ct, PartnerPermissionCode.Assemble, PartnerPermissi
 2119        if (target is null) return error!;
 120
 121        try
 122        {
 0123            var api = ozonApi.SetClient(target.Client.DecryptClientId!, target.Client.DecryptApiKey!);
 0124            foreach (var s in selections)
 0125                await api.SetFbsProductCountryAsync(target.ApiPosting, s.ProductId, s.CountryIsoCode);
 0126        }
 0127        catch (Exception ex)
 128        {
 0129            await AuditAsync(partnerId, "countries_set", target.Order, false, ex.Message, ct);
 0130            return PartnerActionResult.Fail(PartnerActionError.Failed, $"Ozon отклонил страну-изготовитель: {ex.Message}
 131        }
 132
 0133        await AuditAsync(partnerId, "countries_set", target.Order, true, $"Товаров: {selections.Count}", ct);
 0134        return new PartnerActionResult(true, "Страны-изготовители сохранены.");
 3135    }
 136
 137    public async Task<PartnerActionResult> SetExemplarsAsync(
 138        long partnerId, int orderId, IReadOnlyList<OzonExemplarInput> inputs, CancellationToken ct = default)
 139    {
 3140        if (inputs.Count == 0)
 1141            return PartnerActionResult.Fail(PartnerActionError.Failed, "Не переданы данные экземпляров.");
 142
 2143        var (target, error) = await ResolveAsync(partnerId, orderId, ct, PartnerPermissionCode.Assemble, PartnerPermissi
 4144        if (target is null) return error!;
 145
 146        try
 147        {
 0148            exemplars.SetClient(target.Client.DecryptClientId!, target.Client.DecryptApiKey!);
 0149            var result = await exemplars.SaveAsync(target.Order.ShipmentNumber!, inputs, ct);
 0150            if (!result.ShipAvailable)
 151            {
 0152                await AuditAsync(partnerId, "exemplars_set", target.Order, false, string.Join("; ", result.Reasons), ct)
 0153                return PartnerActionResult.Fail(PartnerActionError.Failed,
 0154                    "Ozon не подтвердил готовность отправления к сборке.", result.Reasons);
 155            }
 0156        }
 0157        catch (Exception ex)
 158        {
 0159            await AuditAsync(partnerId, "exemplars_set", target.Order, false, ex.Message, ct);
 0160            return PartnerActionResult.Fail(PartnerActionError.Failed, $"Ozon отклонил данные экземпляров. {ex.Message}"
 161        }
 162
 0163        await AuditAsync(partnerId, "exemplars_set", target.Order, true, $"Экземпляров: {inputs.Count}", ct);
 0164        return new PartnerActionResult(true, "Данные экземпляров сохранены, отправление готово к сборке.");
 3165    }
 166
 167    public async Task<PartnerActionResult> ShipAsync(long partnerId, int orderId, CancellationToken ct = default)
 168    {
 2169        var (target, error) = await ResolveAsync(partnerId, orderId, ct, PartnerPermissionCode.Assemble);
 4170        if (target is null) return error!;
 171
 0172        var result = await ShipCoreAsync(target, ct);
 0173        await AuditAsync(partnerId, "ship", target.Order, result.Success, result.Message, ct);
 0174        return result;
 2175    }
 176
 177    public async Task<PartnerActionResult<PartnerPrintedLabel>> PrintLabelAsync(long partnerId, int orderId, Cancellatio
 178    {
 1179        var (target, error) = await ResolveAsync(partnerId, orderId, ct, PartnerPermissionCode.LabelPrint);
 2180        if (target is null) return new(error!, null);
 181
 0182        var order = target.Order;
 0183        var clients = await clientServices.GetOzonClients();
 184
 185        // Шаг 1, как в Studio2: готовая этикетка из хранилища. Новую у Ozon не запрашиваем.
 0186        var existingName = await labelStorage.FindExistingFileNameAsync(
 0187            OzonPostingNumberHelper.GetLabelFileNameCandidates(order.ShipmentNumber), ct);
 0188        if (existingName is not null)
 189        {
 0190            var bytes = await labelStorage.TryDownloadAsync(existingName, ct);
 0191            if (bytes is { Length: > 0 })
 192            {
 0193                await AuditAsync(partnerId, "label_print", order, true, "Из хранилища", ct);
 0194                await RecordPartnerLabelAsync(partnerId, order.Id, existingName, ct);
 0195                return new(new PartnerActionResult(true, "Этикетка из хранилища."), new PartnerPrintedLabel(bytes, exist
 196            }
 0197        }
 198
 199        try
 200        {
 0201            jsonBuilder.SetClient(target.Client.DecryptClientId!, target.Client.DecryptApiKey!);
 0202            var status = await jsonBuilder.GetProductSatatus(target.ApiPosting);
 203
 0204            if (OzonLabelStatusHelper.IsOzonStatusLabelGenerationBlocked(status))
 205            {
 0206                var blocked = $"Статус Ozon «{OzonLabelStatusHelper.GetOzonStatusDisplayName(status)}»: Ozon не выдаёт э
 0207                await AuditAsync(partnerId, "label_print", order, false, blocked, ct);
 0208                return new(PartnerActionResult.Fail(PartnerActionError.Failed, blocked), null);
 209            }
 210
 211            // Шаг 2, как в Studio2: если отправление ещё не собрано, перед печатью его нужно отгрузить.
 212            // Отгрузка меняет состояние заказа на Ozon, поэтому для неё нужна своя возможность партнёра.
 0213            var autoShipped = false;
 0214            if (status is "awaiting_packaging" or "not_accepted")
 215            {
 0216                if (!await permissions.HasAsync(partnerId, PartnerPermissionCode.Assemble, ct))
 217                {
 218                    const string text = "Отправление ещё не собрано, а для печати его нужно отгрузить. " +
 219                                        "У вас нет возможности «Сборка и отгрузка»: попросите владельца включить её или 
 0220                    await AuditAsync(partnerId, "label_print", order, false, text, ct);
 0221                    return new(PartnerActionResult.Fail(PartnerActionError.Forbidden, text), null);
 222                }
 223
 0224                var shipped = await ShipCoreAsync(target, ct);
 0225                await AuditAsync(partnerId, "ship", order, shipped.Success, $"Перед печатью: {shipped.Message}", ct);
 0226                if (!shipped.Success) return new(shipped, null);
 0227                autoShipped = true;
 0228            }
 229
 230            // Шаг 3: получение этикетки у Ozon и сохранение в хранилище (вся логика, повторы и разбор ошибок — в сервис
 0231            var labels = new Dictionary<string, byte[]>();
 0232            var generated = new List<string>();
 0233            var failed = new List<string>();
 0234            var stored = new List<string>();
 0235            await labelPrint.GenerateOzonLabelsForOrdersAsync(
 0236                [order], clients, labels, generated, failed, new List<string>(), stored);
 237
 0238            if (labels.Count == 0)
 239            {
 0240                var reason = failed.Count > 0 ? string.Join("; ", failed) : "Не удалось получить этикетку.";
 0241                await AuditAsync(partnerId, "label_print", order, false, reason, ct);
 0242                return new(PartnerActionResult.Fail(PartnerActionError.Failed, "Не удалось получить этикетку у Ozon.", f
 243            }
 244
 0245            try { await labelPrint.EnsureScanitAsync([order], clients); }
 0246            catch { /* ScanIt не критичен для печати: подтянется при следующей печати или загрузке заказа */ }
 247
 0248            labelExistence.SetShipmentExists(order.ShipmentNumber!, true);
 0249            var content = labels.Values.First();
 0250            await AuditAsync(partnerId, "label_print", order, true, autoShipped ? "Отгружен и напечатан" : "Получена у O
 0251            if (stored.Contains(order.ShipmentNumber!, StringComparer.OrdinalIgnoreCase))
 252            {
 0253                await RecordPartnerLabelAsync(partnerId, order.Id, $"{order.ShipmentNumber}.pdf", ct);
 254            }
 0255            return new(new PartnerActionResult(true, autoShipped ? "Заказ отгружен, этикетка получена." : "Этикетка полу
 0256                new PartnerPrintedLabel(content, $"{order.ShipmentNumber}.pdf", true, autoShipped));
 257        }
 0258        catch (Exception ex)
 259        {
 0260            var failedList = new List<string>();
 0261            await shipService.RecordFailedShipmentAsync(order, ex, failedList, ct);
 0262            var message = failedList.Count > 0 ? failedList[0] : ex.Message;
 0263            await AuditAsync(partnerId, "label_print", order, false, message, ct);
 0264            return new(PartnerActionResult.Fail(PartnerActionError.Failed, message), null);
 265        }
 1266    }
 267
 268    /// <summary>
 269    /// Отгрузка по логике <c>UpdateOrdersStatus</c> из Studio2: studio-части одного отправления (posting/1, posting/2)
 270    /// уходят в Ozon одной отгрузкой по родительскому posting, остальные — обычной отгрузкой заказа.
 271    /// </summary>
 272    private async Task<PartnerActionResult> ShipCoreAsync(Target target, CancellationToken ct)
 273    {
 0274        var order = target.Order;
 275        try
 276        {
 0277            shipService.SetClient(target.Client.DecryptClientId!, target.Client.DecryptApiKey!);
 0278            jsonBuilder.SetClient(target.Client.DecryptClientId!, target.Client.DecryptApiKey!);
 279
 0280            var siblings = await LoadStudioSiblingsAsync(target.ApiPosting, order, ct);
 281            string message;
 0282            if (siblings.Count > 1)
 283            {
 0284                message = await labelPrint.ProcessStudioLocalSplitGroupShipAsync(siblings, target.ApiPosting, shipServic
 285            }
 286            else
 287            {
 0288                message = await shipService.ProcessOrderStatusAsync(order, splitOption: null);
 289            }
 290
 0291            await statusSync.SyncOrderStatusesAsync(siblings.Select(o => o.Id).DefaultIfEmpty(order.Id).ToList(), ct);
 292
 0293            var status = await jsonBuilder.GetProductSatatus(target.ApiPosting);
 0294            var statusName = OzonLabelStatusHelper.GetOzonStatusDisplayName(status);
 0295            if (OzonLabelStatusHelper.IsOzonStatusLabelGenerationBlocked(status))
 296            {
 0297                return new PartnerActionResult(false,
 0298                    $"Статус Ozon «{statusName}»: отгрузка недоступна.", PartnerActionError.Failed, null, status, status
 299            }
 300
 0301            return new PartnerActionResult(true, message, PartnerActionError.None, null, status, statusName);
 302        }
 0303        catch (Exception ex)
 304        {
 0305            var failed = new List<string>();
 0306            await shipService.RecordFailedShipmentAsync(order, ex, failed, ct);
 0307            return PartnerActionResult.Fail(PartnerActionError.Failed, failed.Count > 0 ? failed[0] : ex.Message);
 308        }
 0309    }
 310
 311    private async Task<List<Order>> LoadStudioSiblingsAsync(string apiPosting, Order order, CancellationToken ct)
 312    {
 0313        if (!OzonPostingNumberHelper.IsStudioLocalSplitPosting(order.ShipmentNumber)) return [order];
 314
 0315        var prefix = apiPosting + "/";
 0316        var ids = await db.Orders.AsNoTracking()
 0317            .Where(o => o.ShipmentNumber != null && o.ShipmentNumber.StartsWith(prefix))
 0318            .Select(o => o.Id).ToArrayAsync(ct);
 0319        return ids.Length == 0 ? [order] : await orderServices.GetOrdersNoTracking(ids);
 0320    }
 321
 322    /// <summary>Права, видимость заказа, Ozon-заказ и клиент Ozon. Любая из переданных возможностей даёт доступ.</summa
 323    private async Task<(Target? Target, PartnerActionResult? Error)> ResolveAsync(
 324        long partnerId, int orderId, CancellationToken ct, params PartnerPermissionCode[] anyOf)
 325    {
 10326        var allowed = false;
 45327        foreach (var code in anyOf)
 328        {
 25329            if (await permissions.HasAsync(partnerId, code, ct)) { allowed = true; break; }
 330        }
 331
 10332        if (!allowed)
 333        {
 13334            var needed = string.Join(" или ", anyOf.Select(c => c switch
 13335            {
 4336                PartnerPermissionCode.Assemble => "«Сборка и отгрузка»",
 4337                PartnerPermissionCode.LabelPrint => "«Печать этикетки»",
 0338                _ => c.ToString()
 13339            }));
 5340            return (null, PartnerActionResult.Fail(PartnerActionError.Forbidden, $"Нужна возможность {needed}."));
 341        }
 342
 343        // Заказ вне правил партнёра и архивный — как будто его нет.
 5344        if (await visibility.GetAsync(partnerId, orderId, ct) is null)
 5345            return (null, PartnerActionResult.Fail(PartnerActionError.NotFound, "Заказ не найден."));
 346
 0347        var order = (await orderServices.GetOrdersNoTracking([orderId])).FirstOrDefault();
 0348        if (order is null)
 0349            return (null, PartnerActionResult.Fail(PartnerActionError.NotFound, "Заказ не найден."));
 350
 0351        if (!OzonLabelOrderEligibility.IsOzonOrder(order.OzonClient?.ClientType, order.ShipmentNumber, order.OzonClient?
 0352            return (null, PartnerActionResult.Fail(PartnerActionError.NotOzonOrder, "Действие доступно только для заказо
 353
 0354        var client = (await clientServices.GetOzonClients()).FirstOrDefault(c => c.ClientId == order.OzonClient?.ClientI
 0355        if (client is null || string.IsNullOrWhiteSpace(client.DecryptClientId) || string.IsNullOrWhiteSpace(client.Decr
 0356            return (null, PartnerActionResult.Fail(PartnerActionError.NoOzonClient, "Для заказа не настроен клиент Ozon.
 357
 0358        return (new Target(order, client, OzonPostingNumberHelper.GetOzonApiPostingNumber(order.ShipmentNumber)), null);
 10359    }
 360
 361    private Task AuditAsync(long partnerId, string action, Order order, bool success, string? details, CancellationToken
 0362        audit.WriteAsync(action, PartnerActorKind.Partner, $"partner:{partnerId}", partnerId, success, details,
 0363            order.ShipmentNumber, ct: ct);
 364
 365    private async Task RecordPartnerLabelAsync(long partnerId, int orderId, string fileName, CancellationToken ct)
 366    {
 0367        var partnerName = await db.Partners.AsNoTracking()
 0368            .Where(x => x.Id == partnerId)
 0369            .Select(x => x.Name)
 0370            .FirstOrDefaultAsync(ct);
 0371        await labelPrintLog.RecordAsync(
 0372            [new LabelPrintLogEntry(
 0373                fileName,
 0374                orderId,
 0375                LabelPrintSource.PartnerApi,
 0376                LabelPrintActorKind.Partner,
 0377                partnerId.ToString(System.Globalization.CultureInfo.InvariantCulture),
 0378                string.IsNullOrWhiteSpace(partnerName) ? $"Партнёр #{partnerId}" : partnerName)],
 0379            ct);
 0380    }
 381}