| | | 1 | | using System.Text.Json; |
| | | 2 | | using OzonDomains.Models.OzonHandover; |
| | | 3 | | using OzonRepositories.Data; |
| | | 4 | | using Services.SignalRServices; |
| | | 5 | | |
| | | 6 | | namespace Services.OzonHandoverServices; |
| | | 7 | | |
| | | 8 | | /// <summary> |
| | | 9 | | /// Обрабатывает JSON-документ выдачи/приёма-передачи Ozon: сопоставляет позиции с заказами |
| | | 10 | | /// по item_no (== Order.ShipmentNumber) и сохраняет батч/акты/позиции. |
| | | 11 | | /// </summary> |
| | | 12 | | public class OzonHandoverImportService : IOzonHandoverImportService |
| | | 13 | | { |
| | 1 | 14 | | private static readonly JsonSerializerOptions RawJsonOptions = new() { WriteIndented = false }; |
| | | 15 | | |
| | | 16 | | private readonly OzonHandoverRepository _repository; |
| | | 17 | | private readonly OrderRepository _orderRepository; |
| | | 18 | | private readonly IOzonHandoverPhotoStorage _photoStorage; |
| | | 19 | | |
| | 5 | 20 | | public OzonHandoverImportService(OzonHandoverRepository repository, OrderRepository orderRepository, |
| | 5 | 21 | | IOzonHandoverPhotoStorage photoStorage) |
| | | 22 | | { |
| | 5 | 23 | | _repository = repository; |
| | 5 | 24 | | _orderRepository = orderRepository; |
| | 5 | 25 | | _photoStorage = photoStorage; |
| | 5 | 26 | | } |
| | | 27 | | |
| | | 28 | | public async Task<OzonHandoverImportResult> ImportAsync(OzonHandoverImportRequest request, CancellationToken ct = de |
| | | 29 | | { |
| | 7 | 30 | | var itemNumbers = request.Groups |
| | 7 | 31 | | .Where(g => !IsFbo(g.ReturnType ?? request.ReturnType ?? request.Closed.ReturnType)) |
| | 6 | 32 | | .SelectMany(g => g.Acts) |
| | 6 | 33 | | .Where(a => !IsFbo(a.ReturnType ?? request.ReturnType ?? request.Closed.ReturnType)) |
| | 6 | 34 | | .SelectMany(a => a.Items) |
| | 6 | 35 | | .Select(i => i.ItemNo) |
| | 6 | 36 | | .Where(n => !string.IsNullOrEmpty(n)) |
| | 7 | 37 | | .Distinct() |
| | 7 | 38 | | .ToList(); |
| | | 39 | | |
| | 7 | 40 | | var orderIdsByItemNo = await _orderRepository.GetOrderIdsByShipmentNumbersAsync(itemNumbers); |
| | | 41 | | |
| | 7 | 42 | | var batch = new OzonHandoverImportBatch |
| | 7 | 43 | | { |
| | 7 | 44 | | DocumentType = request.DocumentType, |
| | 7 | 45 | | GeneratedAt = request.GeneratedAt, |
| | 7 | 46 | | ReturnType = request.ReturnType, |
| | 7 | 47 | | ClosedKind = request.Closed.Kind, |
| | 7 | 48 | | ClosedId = request.Closed.Id, |
| | 7 | 49 | | SummaryActsCount = request.Summary?.ActsCount ?? 0, |
| | 7 | 50 | | SummaryItemsCount = request.Summary?.ItemsCount ?? 0, |
| | 7 | 51 | | SummaryTotalValue = request.Summary?.TotalValue ?? 0, |
| | 7 | 52 | | SummaryAcceptedItemsCount = request.Summary?.Accepted?.ItemsCount ?? 0, |
| | 7 | 53 | | SummaryAcceptedValue = request.Summary?.Accepted?.TotalValue ?? 0, |
| | 7 | 54 | | SummaryRejectedItemsCount = request.Summary?.Rejected?.ItemsCount ?? 0, |
| | 7 | 55 | | SummaryRejectedValue = request.Summary?.Rejected?.TotalValue ?? 0, |
| | 7 | 56 | | SummaryPendingItemsCount = request.Summary?.Pending?.ItemsCount ?? 0, |
| | 7 | 57 | | SummaryPendingValue = request.Summary?.Pending?.TotalValue ?? 0, |
| | 7 | 58 | | SummaryDefectCount = request.Summary?.DefectCount ?? 0, |
| | 7 | 59 | | SummaryMissortCount = request.Summary?.MissortCount ?? 0, |
| | 7 | 60 | | RawJson = JsonSerializer.Serialize(request, RawJsonOptions) |
| | 7 | 61 | | }; |
| | | 62 | | |
| | 7 | 63 | | var unmatchedItemNumbers = new List<string>(); |
| | 7 | 64 | | var matchedItems = new List<(string ItemNo, int OrderId)>(); |
| | 7 | 65 | | var warnings = new List<string>(); |
| | 7 | 66 | | var itemsCount = 0; |
| | 7 | 67 | | var savedPhotosCount = 0; |
| | 7 | 68 | | var failedPhotosCount = 0; |
| | | 69 | | |
| | 28 | 70 | | foreach (var group in request.Groups) |
| | | 71 | | { |
| | 28 | 72 | | foreach (var actDto in group.Acts) |
| | | 73 | | { |
| | 7 | 74 | | var act = new OzonHandoverAct |
| | 7 | 75 | | { |
| | 7 | 76 | | GroupKey = group.GroupKey, |
| | 7 | 77 | | GroupName = group.GroupName, |
| | 7 | 78 | | ActNo = actDto.ActNo, |
| | 7 | 79 | | ActDate = actDto.ActDate, |
| | 7 | 80 | | ActType = actDto.ActType, |
| | 7 | 81 | | Pvz = actDto.Pvz, |
| | 7 | 82 | | AccountLabel = actDto.Account, |
| | 7 | 83 | | Source = actDto.Source, |
| | 7 | 84 | | ReturnType = actDto.ReturnType ?? group.ReturnType ?? request.ReturnType ?? request.Closed.ReturnTyp |
| | 7 | 85 | | BoxBarcode = actDto.BoxBarcode, |
| | 7 | 86 | | ReturnId = actDto.ReturnId |
| | 7 | 87 | | }; |
| | | 88 | | |
| | 28 | 89 | | foreach (var itemDto in actDto.Items) |
| | | 90 | | { |
| | 7 | 91 | | itemsCount++; |
| | | 92 | | |
| | 7 | 93 | | var item = new OzonHandoverActItem |
| | 7 | 94 | | { |
| | 7 | 95 | | RowNo = itemDto.RowNo, |
| | 7 | 96 | | ItemNo = itemDto.ItemNo, |
| | 7 | 97 | | ProductName = itemDto.ProductName, |
| | 7 | 98 | | Barcode = itemDto.Barcode, |
| | 7 | 99 | | Value = itemDto.Value, |
| | 7 | 100 | | ValueApi = itemDto.ValueApi, |
| | 7 | 101 | | WeightG = itemDto.WeightG, |
| | 7 | 102 | | Status = itemDto.Status, |
| | 7 | 103 | | Defect = itemDto.Defect, |
| | 7 | 104 | | Missort = itemDto.Missort, |
| | 7 | 105 | | Comment = itemDto.Comment, |
| | 7 | 106 | | MarkedAt = itemDto.MarkedAt, |
| | 7 | 107 | | Sku = itemDto.Sku, |
| | 7 | 108 | | OfferId = itemDto.OfferId, |
| | 7 | 109 | | AccountLabel = itemDto.Account, |
| | 7 | 110 | | ReturnId = itemDto.ReturnId, |
| | 7 | 111 | | OzonStatus = itemDto.OzonStatus, |
| | 7 | 112 | | OzonStatusName = itemDto.OzonStatusName, |
| | 7 | 113 | | ReturnType = itemDto.ReturnType ?? act.ReturnType, |
| | 7 | 114 | | BoxBarcode = itemDto.BoxBarcode, |
| | 7 | 115 | | Reason = itemDto.Reason, |
| | 7 | 116 | | ReasonIsDefect = itemDto.ReasonIsDefect, |
| | 7 | 117 | | ClientComment = itemDto.ClientComment, |
| | 7 | 118 | | ClientPhotosExpiresAt = itemDto.ClientPhotosExpiresAt, |
| | 7 | 119 | | PhotosCount = itemDto.PhotosCount, |
| | 7 | 120 | | PhotosError = itemDto.PhotosError, |
| | 7 | 121 | | RejectionComment = itemDto.RejectionComment, |
| | 7 | 122 | | RejectionReason = itemDto.RejectionReason, |
| | 7 | 123 | | RuPostTracking = itemDto.RuPostTracking, |
| | 7 | 124 | | ReturnNumber = itemDto.ReturnNumber, |
| | 7 | 125 | | OrderNumber = itemDto.OrderNumber, |
| | 7 | 126 | | ClientReturnMethod = itemDto.ClientReturnMethod, |
| | 7 | 127 | | WarehouseId = itemDto.WarehouseId |
| | 7 | 128 | | }; |
| | | 129 | | |
| | 7 | 130 | | if (!IsFbo(item.ReturnType) && !string.IsNullOrEmpty(itemDto.ItemNo) && |
| | 7 | 131 | | orderIdsByItemNo.TryGetValue(itemDto.ItemNo, out var orderId)) |
| | | 132 | | { |
| | 2 | 133 | | item.OrderId = orderId; |
| | 2 | 134 | | matchedItems.Add((itemDto.ItemNo, orderId)); |
| | | 135 | | } |
| | | 136 | | else |
| | | 137 | | { |
| | 5 | 138 | | unmatchedItemNumbers.Add(itemDto.ItemNo); |
| | | 139 | | } |
| | | 140 | | |
| | 7 | 141 | | var photoResult = await StorePhotosAsync(itemDto, request.Closed.Id, item, ct).ConfigureAwait(false) |
| | 7 | 142 | | savedPhotosCount += photoResult.SavedCount; |
| | 7 | 143 | | failedPhotosCount += photoResult.FailedCount; |
| | 7 | 144 | | if (photoResult.Warnings.Count > 0) |
| | | 145 | | { |
| | 1 | 146 | | item.PhotosError = JoinErrors(item.PhotosError, photoResult.Warnings); |
| | 1 | 147 | | warnings.AddRange(photoResult.Warnings); |
| | | 148 | | } |
| | | 149 | | |
| | 7 | 150 | | act.Items.Add(item); |
| | 7 | 151 | | } |
| | | 152 | | |
| | 7 | 153 | | batch.Acts.Add(act); |
| | 7 | 154 | | } |
| | 7 | 155 | | } |
| | | 156 | | |
| | 7 | 157 | | batch.MatchedItemsCount = matchedItems.Count; |
| | 7 | 158 | | batch.UnmatchedItemsCount = unmatchedItemNumbers.Count; |
| | | 159 | | |
| | 7 | 160 | | var existing = !string.IsNullOrEmpty(request.Closed.Id) |
| | 7 | 161 | | ? await _repository.GetByClosedIdAsync(request.Closed.Id, ct) |
| | 7 | 162 | | : null; |
| | | 163 | | |
| | 7 | 164 | | var saved = await _repository.UpsertBatchAsync(batch, ct); |
| | | 165 | | |
| | 7 | 166 | | var orderInfoById = await _orderRepository.GetHandoverMatchInfoByIdsAsync( |
| | 9 | 167 | | matchedItems.Select(m => m.OrderId)); |
| | | 168 | | |
| | 7 | 169 | | var matchedOrders = matchedItems |
| | 7 | 170 | | .Select(m => |
| | 7 | 171 | | { |
| | 2 | 172 | | orderInfoById.TryGetValue(m.OrderId, out var info); |
| | 2 | 173 | | return new OzonHandoverMatchedOrderDto |
| | 2 | 174 | | { |
| | 2 | 175 | | ItemNo = m.ItemNo, |
| | 2 | 176 | | OrderId = m.OrderId, |
| | 2 | 177 | | OrderNumber = info?.ShipmentNumber, |
| | 2 | 178 | | Article = info?.Article, |
| | 2 | 179 | | ClientName = info?.ClientName, |
| | 2 | 180 | | ClientStatus = info?.ClientStatusName, |
| | 2 | 181 | | ClientStatusColor = info?.ClientStatusColor, |
| | 2 | 182 | | AppStatus = info?.AppStatusName, |
| | 2 | 183 | | AppStatusColor = info?.AppStatusColor |
| | 2 | 184 | | }; |
| | 7 | 185 | | }) |
| | 7 | 186 | | .ToList(); |
| | | 187 | | |
| | 7 | 188 | | var result = new OzonHandoverImportResult |
| | 7 | 189 | | { |
| | 7 | 190 | | BatchId = saved.Id, |
| | 7 | 191 | | WasUpdated = existing != null, |
| | 7 | 192 | | ActsCount = saved.Acts.Count, |
| | 7 | 193 | | ItemsCount = itemsCount, |
| | 7 | 194 | | MatchedItemsCount = matchedItems.Count, |
| | 7 | 195 | | UnmatchedItemsCount = unmatchedItemNumbers.Count, |
| | 7 | 196 | | UnmatchedItemNumbers = unmatchedItemNumbers, |
| | 7 | 197 | | MatchedOrders = matchedOrders, |
| | 7 | 198 | | SavedPhotosCount = savedPhotosCount, |
| | 7 | 199 | | FailedPhotosCount = failedPhotosCount, |
| | 7 | 200 | | Warnings = warnings |
| | 7 | 201 | | }; |
| | | 202 | | |
| | 7 | 203 | | await NotifyAsync(result); |
| | | 204 | | |
| | 7 | 205 | | return result; |
| | 7 | 206 | | } |
| | | 207 | | |
| | | 208 | | private async Task<PhotoStoreResult> StorePhotosAsync(OzonHandoverItemDto dto, string batchId, |
| | | 209 | | OzonHandoverActItem item, CancellationToken ct) |
| | | 210 | | { |
| | 7 | 211 | | if (dto.ClientPhotos.Count == 0) |
| | 2 | 212 | | return PhotoStoreResult.Empty; |
| | | 213 | | |
| | 5 | 214 | | var result = new PhotoStoreResult(); |
| | 5 | 215 | | var itemKey = dto.ReturnId?.ToString() ?? $"{dto.ItemNo}|{dto.RowNo}"; |
| | 20 | 216 | | for (var index = 0; index < dto.ClientPhotos.Count; index++) |
| | | 217 | | { |
| | | 218 | | try |
| | | 219 | | { |
| | 5 | 220 | | var stored = await _photoStorage.DownloadAndStoreAsync(dto.ClientPhotos[index], batchId, itemKey, index |
| | 5 | 221 | | .ConfigureAwait(false); |
| | 4 | 222 | | item.Photos.Add(new OzonHandoverActItemPhoto |
| | 4 | 223 | | { |
| | 4 | 224 | | Sequence = index + 1, |
| | 4 | 225 | | StoragePath = stored.StoragePath, |
| | 4 | 226 | | ContentType = stored.ContentType |
| | 4 | 227 | | }); |
| | 4 | 228 | | result.SavedCount++; |
| | 4 | 229 | | } |
| | 1 | 230 | | catch (Exception ex) when (ex is HttpRequestException or InvalidOperationException || |
| | 1 | 231 | | (ex is TaskCanceledException && !ct.IsCancellationRequested)) |
| | | 232 | | { |
| | 1 | 233 | | result.FailedCount++; |
| | 1 | 234 | | result.Warnings.Add($"Фото возврата {itemKey} №{index + 1} не сохранено: {ex.Message}"); |
| | 1 | 235 | | } |
| | | 236 | | } |
| | | 237 | | |
| | 5 | 238 | | return result; |
| | 7 | 239 | | } |
| | | 240 | | |
| | 20 | 241 | | private static bool IsFbo(string? returnType) => string.Equals(returnType, "fbo", StringComparison.OrdinalIgnoreCase |
| | | 242 | | |
| | | 243 | | private static string JoinErrors(string? existing, IEnumerable<string> errors) |
| | | 244 | | { |
| | 3 | 245 | | var all = new[] { existing }.Concat(errors).Where(x => !string.IsNullOrWhiteSpace(x)); |
| | 1 | 246 | | return string.Join("\n", all); |
| | | 247 | | } |
| | | 248 | | |
| | | 249 | | private sealed class PhotoStoreResult |
| | | 250 | | { |
| | 1 | 251 | | public static readonly PhotoStoreResult Empty = new(); |
| | 15 | 252 | | public int SavedCount { get; set; } |
| | 9 | 253 | | public int FailedCount { get; set; } |
| | 16 | 254 | | public List<string> Warnings { get; } = new(); |
| | | 255 | | } |
| | | 256 | | |
| | | 257 | | /// <summary> |
| | | 258 | | /// Уведомление в чат-виджет (не блокирует импорт при сбое отправки) — срабатывает |
| | | 259 | | /// одинаково и для машинного POST /api/ozon-handover/import, и для ручной загрузки |
| | | 260 | | /// файла на /Studio2/Orders/Upload, поскольку обе точки входа вызывают этот сервис. |
| | | 261 | | /// </summary> |
| | | 262 | | private async Task NotifyAsync(OzonHandoverImportResult result) |
| | | 263 | | { |
| | | 264 | | try |
| | | 265 | | { |
| | 7 | 266 | | var action = result.WasUpdated ? "обновлён" : "создан"; |
| | 7 | 267 | | var text = $"Импорт выдачи Ozon: батч #{result.BatchId} {action}, " + |
| | 7 | 268 | | $"актов {result.ActsCount}, позиций {result.ItemsCount}, " + |
| | 7 | 269 | | $"сопоставлено с заказами {result.MatchedItemsCount}" + |
| | 7 | 270 | | (result.UnmatchedItemsCount > 0 ? $", без пары {result.UnmatchedItemsCount}" : "") + "."; |
| | | 271 | | |
| | 7 | 272 | | await NotificationService.NotifyAsync("Ozon", "#2563EB", text, "fa-box-open"); |
| | 0 | 273 | | } |
| | 7 | 274 | | catch |
| | | 275 | | { |
| | | 276 | | // Сбой отправки уведомления не должен ронять сам импорт. |
| | 7 | 277 | | } |
| | 7 | 278 | | } |
| | | 279 | | } |