| | | 1 | | using Microsoft.AspNetCore.Authorization; |
| | | 2 | | using Microsoft.AspNetCore.Mvc; |
| | | 3 | | using OzonDomains.Models.Partners; |
| | | 4 | | using OzonOrdersWeb.Authorization; |
| | | 5 | | using OzonOrdersWeb.Extensions; |
| | | 6 | | using OzonOrdersWeb.WebServices.Partners; |
| | | 7 | | using Services.LabelServices; |
| | | 8 | | using Services.PartnerServices; |
| | | 9 | | |
| | | 10 | | namespace OzonOrdersWeb.Api.Partner; |
| | | 11 | | |
| | | 12 | | /// <summary>Заказы партнёра. Видны только заказы, подходящие под правила, которые задал владелец.</summary> |
| | | 13 | | [ApiController] |
| | | 14 | | [ApiExplorerSettings(GroupName = PartnerOfficeExtensions.SwaggerDocName)] |
| | | 15 | | [ServiceEndpoint] |
| | | 16 | | [Authorize(AuthenticationSchemes = PartnerOfficeExtensions.PartnerCookieScheme)] |
| | | 17 | | [Route("partner-api/orders")] |
| | | 18 | | public sealed class PartnerOrdersController( |
| | | 19 | | IPartnerOrdersService orders, |
| | | 20 | | IPartnerPermissionService permissions, |
| | | 21 | | IPartnerAuditService audit, |
| | | 22 | | IPartnerOrderActionsService actions) : ControllerBase |
| | | 23 | | { |
| | | 24 | | private long PartnerId => User.TryGetPartnerId(out var id) ? id : 0; |
| | | 25 | | |
| | | 26 | | /// <summary>Список заказов с фильтрами и постраничной выдачей, новые сверху.</summary> |
| | | 27 | | /// <param name="search">Подстрока: номер отправления, артикул, название, трек-номер.</param> |
| | | 28 | | /// <param name="statusId">Статус заказа.</param> |
| | | 29 | | /// <param name="ozonStatus">Отображаемый статус Ozon; точное совпадение.</param> |
| | | 30 | | /// <param name="warehouseId">Склад отгрузки.</param> |
| | | 31 | | /// <param name="clientId">Клиент (кабинет Ozon).</param> |
| | | 32 | | /// <param name="from">Дата принятия заказа, с (включительно).</param> |
| | | 33 | | /// <param name="to">Дата принятия заказа, по (включительно).</param> |
| | | 34 | | /// <param name="page">Номер страницы с 1.</param> |
| | | 35 | | /// <param name="pageSize">Размер страницы, до 100.</param> |
| | | 36 | | [HttpGet] |
| | | 37 | | [ProducesResponseType(typeof(PartnerPage<PartnerOrderListItemDto>), StatusCodes.Status200OK)] |
| | | 38 | | public async Task<IActionResult> List( |
| | | 39 | | [FromQuery] string? search, [FromQuery] int? statusId, [FromQuery] string? ozonStatus, [FromQuery] int? warehous |
| | | 40 | | [FromQuery] int? clientId, [FromQuery] DateTime? from, [FromQuery] DateTime? to, |
| | | 41 | | [FromQuery] int page = 1, [FromQuery] int pageSize = 50, CancellationToken ct = default) => |
| | | 42 | | Ok(await orders.ListAsync(PartnerId, |
| | | 43 | | new PartnerOrderQuery(search, statusId, warehouseId, clientId, from, to, page, pageSize, ozonStatus), ct)); |
| | | 44 | | |
| | | 45 | | /// <summary>Значения для фильтров: статусы, склады и клиенты среди видимых партнёру заказов.</summary> |
| | | 46 | | [HttpGet("filters")] |
| | | 47 | | [ProducesResponseType(typeof(PartnerOrderFilters), StatusCodes.Status200OK)] |
| | | 48 | | public async Task<IActionResult> Filters(CancellationToken ct) => |
| | | 49 | | Ok(await orders.GetFiltersAsync(PartnerId, ct)); |
| | | 50 | | |
| | | 51 | | /// <summary>До 10 подсказок по заказам в рамках видимости партнёра. Для производительности запрос короче 2 символов |
| | | 52 | | [HttpGet("suggestions")] |
| | | 53 | | [ProducesResponseType(typeof(IReadOnlyList<PartnerOrderSuggestion>), StatusCodes.Status200OK)] |
| | | 54 | | public async Task<IActionResult> Suggestions([FromQuery] string? q, [FromQuery] int limit = PartnerOrdersService.Max |
| | | 55 | | Ok(await orders.SuggestAsync(PartnerId, q, limit, ct)); |
| | | 56 | | |
| | | 57 | | /// <summary>Карточка заказа. Заказ вне правил партнёра отдаёт 404, как будто его нет.</summary> |
| | | 58 | | [HttpGet("{id:int}")] |
| | | 59 | | [ProducesResponseType(typeof(PartnerOrderDetailsDto), StatusCodes.Status200OK)] |
| | | 60 | | [ProducesResponseType(StatusCodes.Status404NotFound)] |
| | | 61 | | public async Task<IActionResult> Get(int id, CancellationToken ct) |
| | | 62 | | { |
| | | 63 | | var order = await orders.GetAsync(PartnerId, id, ct); |
| | | 64 | | return order is null ? NotFound() : Ok(order); |
| | | 65 | | } |
| | | 66 | | |
| | | 67 | | /// <summary> |
| | | 68 | | /// Скачать готовую этикетку (PDF). Нужна возможность «печать этикетки». |
| | | 69 | | /// Новую этикетку не создаёт и заказ в Ozon не отгружает: это отдельное действие. |
| | | 70 | | /// </summary> |
| | | 71 | | [HttpGet("{id:int}/label")] |
| | | 72 | | [Produces("application/pdf", "application/json")] |
| | | 73 | | [ProducesResponseType(typeof(FileContentResult), StatusCodes.Status200OK)] |
| | | 74 | | [ProducesResponseType(StatusCodes.Status403Forbidden)] |
| | | 75 | | [ProducesResponseType(StatusCodes.Status404NotFound)] |
| | | 76 | | public async Task<IActionResult> Label(int id, CancellationToken ct) |
| | | 77 | | { |
| | | 78 | | if (!await permissions.HasAsync(PartnerId, PartnerPermissionCode.LabelPrint, ct)) |
| | | 79 | | return StatusCode(StatusCodes.Status403Forbidden, new { error = "Печать этикеток не разрешена." }); |
| | | 80 | | |
| | | 81 | | var label = await orders.GetExistingLabelAsync(PartnerId, id, ct); |
| | | 82 | | if (label is null) return NotFound(new { error = "Этикетка не найдена." }); |
| | | 83 | | |
| | | 84 | | var details = await orders.GetAsync(PartnerId, id, ct); |
| | | 85 | | await audit.WriteAsync("label_download", PartnerActorKind.Partner, User.Identity?.Name ?? "partner", PartnerId, |
| | | 86 | | orderNumber: details?.Order.ShipmentNumber, ipAddress: HttpContext.Connection.RemoteIpAddress?.ToString(), c |
| | | 87 | | return File(label.Content, "application/pdf", label.FileName); |
| | | 88 | | } |
| | | 89 | | |
| | | 90 | | // --------------------------------------------------------------------------------------------- |
| | | 91 | | // Действия над заказом Ozon. Работают через те же сервисы Studio2, что кнопки в Studio2: |
| | | 92 | | // данные экземпляров -> отгрузка (/v4/posting/fbs/ship) -> печать этикетки. |
| | | 93 | | // --------------------------------------------------------------------------------------------- |
| | | 94 | | |
| | | 95 | | /// <summary> |
| | | 96 | | /// Что Ozon просит заполнить перед отгрузкой: страны-изготовители и данные экземпляров (Честный ЗНАК, ГТД, РНПТ). |
| | | 97 | | /// Нужна возможность «Сборка и отгрузка» или «Печать этикетки». |
| | | 98 | | /// </summary> |
| | | 99 | | [HttpGet("{id:int}/ozon/requirements")] |
| | | 100 | | [ProducesResponseType(typeof(PartnerShipRequirements), StatusCodes.Status200OK)] |
| | | 101 | | public async Task<IActionResult> OzonRequirements(int id, CancellationToken ct) |
| | | 102 | | { |
| | | 103 | | var r = await actions.GetRequirementsAsync(PartnerId, id, ct); |
| | | 104 | | return r.Ok ? Ok(r.Value) : Failure(r.Result); |
| | | 105 | | } |
| | | 106 | | |
| | | 107 | | /// <summary>Справочник стран-изготовителей Ozon для заказа (поиск по названию).</summary> |
| | | 108 | | [HttpGet("{id:int}/ozon/countries")] |
| | | 109 | | [ProducesResponseType(typeof(IReadOnlyList<PartnerCountryOption>), StatusCodes.Status200OK)] |
| | | 110 | | public async Task<IActionResult> OzonCountries(int id, [FromQuery] string? search, CancellationToken ct) |
| | | 111 | | { |
| | | 112 | | var r = await actions.GetCountriesAsync(PartnerId, id, search, ct); |
| | | 113 | | return r.Ok ? Ok(r.Value) : Failure(r.Result); |
| | | 114 | | } |
| | | 115 | | |
| | | 116 | | /// <summary>Сохраняет в Ozon страны-изготовители товаров заказа.</summary> |
| | | 117 | | [HttpPut("{id:int}/ozon/countries")] |
| | | 118 | | [ProducesResponseType(typeof(PartnerActionResult), StatusCodes.Status200OK)] |
| | | 119 | | public async Task<IActionResult> SetOzonCountries(int id, [FromBody] SetCountriesBody body, CancellationToken ct) |
| | | 120 | | { |
| | | 121 | | var r = await actions.SetCountriesAsync(PartnerId, id, body.Selections ?? [], ct); |
| | | 122 | | return r.Success ? Ok(r) : Failure(r); |
| | | 123 | | } |
| | | 124 | | |
| | | 125 | | /// <summary> |
| | | 126 | | /// Сохраняет в Ozon данные экземпляров и ждёт вердикта о готовности отправления к сборке. |
| | | 127 | | /// Без этого отгрузка отвечает EXEMPLAR_INFO_NOT_FILLED_COMPLETELY. |
| | | 128 | | /// </summary> |
| | | 129 | | [HttpPut("{id:int}/ozon/exemplars")] |
| | | 130 | | [ProducesResponseType(typeof(PartnerActionResult), StatusCodes.Status200OK)] |
| | | 131 | | public async Task<IActionResult> SetOzonExemplars(int id, [FromBody] SetExemplarsBody body, CancellationToken ct) |
| | | 132 | | { |
| | | 133 | | var r = await actions.SetExemplarsAsync(PartnerId, id, body.Exemplars ?? [], ct); |
| | | 134 | | return r.Success ? Ok(r) : Failure(r); |
| | | 135 | | } |
| | | 136 | | |
| | | 137 | | /// <summary> |
| | | 138 | | /// Отгрузка: вызов Ozon API по заказу, как «Отгрузить» в Studio2. Нужна возможность «Сборка и отгрузка». |
| | | 139 | | /// Studio-части одного отправления (posting/1, posting/2) уходят в Ozon одной отгрузкой. |
| | | 140 | | /// </summary> |
| | | 141 | | [HttpPost("{id:int}/ship")] |
| | | 142 | | [ProducesResponseType(typeof(PartnerActionResult), StatusCodes.Status200OK)] |
| | | 143 | | [ProducesResponseType(StatusCodes.Status403Forbidden)] |
| | | 144 | | [ProducesResponseType(StatusCodes.Status404NotFound)] |
| | | 145 | | [ProducesResponseType(StatusCodes.Status422UnprocessableEntity)] |
| | | 146 | | public async Task<IActionResult> Ship(int id, CancellationToken ct) |
| | | 147 | | { |
| | | 148 | | var r = await actions.ShipAsync(PartnerId, id, ct); |
| | | 149 | | return r.Success ? Ok(r) : Failure(r); |
| | | 150 | | } |
| | | 151 | | |
| | | 152 | | /// <summary> |
| | | 153 | | /// Печать этикетки как в Studio2. Сначала берётся готовая из хранилища Studio2; если её нет, этикетка |
| | | 154 | | /// запрашивается у Ozon API и сохраняется в хранилище Studio2 (дальше её видят и в Studio2). Если отправление |
| | | 155 | | /// ещё не собрано, перед печатью его нужно отгрузить: для этого нужна и возможность «Сборка и отгрузка». |
| | | 156 | | /// Нужна возможность «Печать этикетки». Ответ: PDF; заголовки <c>X-Label-Generated</c> и <c>X-Auto-Shipped</c>. |
| | | 157 | | /// </summary> |
| | | 158 | | [HttpPost("{id:int}/label")] |
| | | 159 | | [Produces("application/pdf", "application/json")] |
| | | 160 | | [ProducesResponseType(typeof(FileContentResult), StatusCodes.Status200OK)] |
| | | 161 | | [ProducesResponseType(StatusCodes.Status403Forbidden)] |
| | | 162 | | [ProducesResponseType(StatusCodes.Status404NotFound)] |
| | | 163 | | [ProducesResponseType(StatusCodes.Status422UnprocessableEntity)] |
| | | 164 | | public async Task<IActionResult> PrintLabel(int id, CancellationToken ct) |
| | | 165 | | { |
| | | 166 | | var r = await actions.PrintLabelAsync(PartnerId, id, ct); |
| | | 167 | | if (!r.Ok) return Failure(r.Result); |
| | | 168 | | |
| | | 169 | | Response.Headers["X-Label-Generated"] = r.Value!.Generated ? "true" : "false"; |
| | | 170 | | Response.Headers["X-Auto-Shipped"] = r.Value.AutoShipped ? "true" : "false"; |
| | | 171 | | return File(r.Value.Content, "application/pdf", r.Value.FileName); |
| | | 172 | | } |
| | | 173 | | |
| | | 174 | | private IActionResult Failure(PartnerActionResult r) |
| | | 175 | | { |
| | | 176 | | var body = new { error = r.Message, reasons = r.Reasons }; |
| | | 177 | | return r.Error switch |
| | | 178 | | { |
| | | 179 | | PartnerActionError.NotFound => NotFound(body), |
| | | 180 | | PartnerActionError.Forbidden => StatusCode(StatusCodes.Status403Forbidden, body), |
| | | 181 | | PartnerActionError.NotOzonOrder => BadRequest(body), |
| | | 182 | | PartnerActionError.NoOzonClient => StatusCode(StatusCodes.Status409Conflict, body), |
| | | 183 | | _ => UnprocessableEntity(body) |
| | | 184 | | }; |
| | | 185 | | } |
| | | 186 | | } |
| | | 187 | | |
| | | 188 | | public sealed class SetCountriesBody |
| | | 189 | | { |
| | | 190 | | public List<PartnerCountrySelection>? Selections { get; set; } |
| | | 191 | | } |
| | | 192 | | |
| | | 193 | | public sealed class SetExemplarsBody |
| | | 194 | | { |
| | 0 | 195 | | public List<OzonExemplarInput>? Exemplars { get; set; } |
| | | 196 | | } |