| | | 1 | | using Microsoft.Extensions.Caching.Memory; |
| | | 2 | | using Services.ApiServices.OzonApi.Filters; |
| | | 3 | | |
| | | 4 | | namespace Services.ApiServices.OzonApi |
| | | 5 | | { |
| | | 6 | | public class AssemblyListProgress |
| | | 7 | | { |
| | | 8 | | public int Step { get; init; } |
| | | 9 | | public int TotalSteps { get; init; } = 4; |
| | | 10 | | public string Message { get; init; } = ""; |
| | | 11 | | public bool IsComplete { get; init; } |
| | | 12 | | public bool IsError { get; init; } |
| | | 13 | | public string? ErrorMessage { get; init; } |
| | | 14 | | } |
| | | 15 | | |
| | | 16 | | public class AssemblyListItem |
| | | 17 | | { |
| | | 18 | | public int Index { get; set; } |
| | | 19 | | public string PostingNumber { get; set; } = ""; |
| | | 20 | | public string Status { get; set; } = ""; |
| | | 21 | | public string StatusLabel { get; set; } = ""; |
| | | 22 | | public long WarehouseId { get; set; } |
| | | 23 | | public string WarehouseName { get; set; } = ""; |
| | | 24 | | public string OfferId { get; set; } = ""; |
| | | 25 | | public string ProductName { get; set; } = ""; |
| | | 26 | | public string? ImageUrl { get; set; } |
| | | 27 | | public int Quantity { get; set; } |
| | | 28 | | public DateTime? CutoffDate { get; set; } |
| | | 29 | | public string? LabelUrl { get; set; } |
| | | 30 | | public string? LabelError { get; set; } |
| | | 31 | | public string? SupplierName { get; set; } |
| | | 32 | | public DateTime? OrderedToSupplierDate { get; set; } |
| | | 33 | | public decimal? OneCBalance { get; set; } |
| | | 34 | | public string? OneCReceiptDoc { get; set; } |
| | | 35 | | public Guid? OneCReceiptDocKey { get; set; } |
| | | 36 | | public string? OneCOtherStock { get; set; } |
| | | 37 | | public string? OneCWarehouseName { get; set; } |
| | | 38 | | } |
| | | 39 | | |
| | | 40 | | public class AssemblyListResult |
| | | 41 | | { |
| | 0 | 42 | | public List<AssemblyListItem> Items { get; set; } = new(); |
| | 0 | 43 | | public int TotalPostings { get; set; } |
| | 0 | 44 | | public DateTime GeneratedAt { get; set; } = DateTime.Now; |
| | | 45 | | } |
| | | 46 | | |
| | | 47 | | public class AssemblyListService |
| | | 48 | | { |
| | | 49 | | internal const int LabelBatchSize = 50; |
| | | 50 | | private static readonly TimeSpan WarehouseListCacheTtl = TimeSpan.FromMinutes(10); |
| | | 51 | | |
| | | 52 | | private static readonly Dictionary<string, string> StatusLabels = new() |
| | | 53 | | { |
| | | 54 | | { "awaiting_registration", "Ожидает регистрации" }, |
| | | 55 | | { "acceptance_in_progress", "Идёт приёмка" }, |
| | | 56 | | { "awaiting_approve", "Ожидает подтверждения" }, |
| | | 57 | | { "awaiting_packaging", "Ожидает упаковки" }, |
| | | 58 | | { "awaiting_deliver", "Ожидает отгрузки" }, |
| | | 59 | | { "arbitration", "Арбитраж" }, |
| | | 60 | | { "client_arbitration", "Клиентский арбитраж" }, |
| | | 61 | | { "delivering", "Доставляется" }, |
| | | 62 | | { "driver_pickup", "У водителя" }, |
| | | 63 | | { "cancelled", "Отменено" }, |
| | | 64 | | { "not_accepted", "Не принято на СЦ" }, |
| | | 65 | | }; |
| | | 66 | | |
| | | 67 | | private readonly IMemoryCache _cache; |
| | | 68 | | |
| | | 69 | | public AssemblyListService(IMemoryCache cache) |
| | | 70 | | { |
| | | 71 | | _cache = cache; |
| | | 72 | | } |
| | | 73 | | |
| | | 74 | | /// <summary> |
| | | 75 | | /// Фильтрует отправления по эффективному складу (для раннего отсечения до этикеток/1С). |
| | | 76 | | /// Эффективный склад — Засыл заказа, если он задан, иначе склад отгрузки Ozon |
| | | 77 | | /// (то же правило, что и TransactionWarehouseHelper.GetEffectiveWarehouseName). |
| | | 78 | | /// </summary> |
| | | 79 | | public static List<UnfulfilledPosting> FilterPostingsByWarehouse( |
| | | 80 | | IEnumerable<UnfulfilledPosting> postings, |
| | | 81 | | string? warehouseFilter, |
| | | 82 | | IReadOnlyDictionary<string, string?>? zasylByPosting = null) |
| | | 83 | | { |
| | | 84 | | if (string.IsNullOrWhiteSpace(warehouseFilter)) |
| | | 85 | | return postings.ToList(); |
| | | 86 | | |
| | | 87 | | return postings |
| | | 88 | | .Where(p => |
| | | 89 | | { |
| | | 90 | | var zasyl = zasylByPosting != null && zasylByPosting.TryGetValue(p.PostingNumber, out var z) ? z : n |
| | | 91 | | var effectiveWarehouse = !string.IsNullOrWhiteSpace(zasyl) ? zasyl : p.WarehouseName; |
| | | 92 | | return string.Equals(effectiveWarehouse, warehouseFilter, StringComparison.OrdinalIgnoreCase); |
| | | 93 | | }) |
| | | 94 | | .ToList(); |
| | | 95 | | } |
| | | 96 | | |
| | | 97 | | public async Task<AssemblyListResult> BuildAsync( |
| | | 98 | | OzonApiDataManager manager, |
| | | 99 | | IReadOnlyList<UnfulfilledPosting> postings, |
| | | 100 | | string status, |
| | | 101 | | string sellerCacheKey, |
| | | 102 | | IProgress<AssemblyListProgress>? progress = null) |
| | | 103 | | { |
| | | 104 | | progress?.Report(new AssemblyListProgress { Step = 2, Message = $"Загрузка данных ({postings.Count} отправле |
| | | 105 | | |
| | | 106 | | var filtered = postings |
| | | 107 | | .Where(p => string.IsNullOrEmpty(status) || p.Status == status) |
| | | 108 | | .ToList(); |
| | | 109 | | |
| | | 110 | | var warehouseMap = await GetWarehouseMapAsync(manager, sellerCacheKey).ConfigureAwait(false); |
| | | 111 | | |
| | | 112 | | progress?.Report(new AssemblyListProgress |
| | | 113 | | { |
| | | 114 | | Step = 3, |
| | | 115 | | Message = "Создаём задания на этикетки...", |
| | | 116 | | }); |
| | | 117 | | |
| | | 118 | | var deliverPostings = filtered.Where(p => p.Status == "awaiting_deliver").ToList(); |
| | | 119 | | var labelsByPosting = await GenerateLabelsAsync(manager, deliverPostings, progress) |
| | | 120 | | .ConfigureAwait(false); |
| | | 121 | | |
| | | 122 | | progress?.Report(new AssemblyListProgress |
| | | 123 | | { |
| | | 124 | | Step = 4, |
| | | 125 | | Message = "Формируем лист подбора...", |
| | | 126 | | }); |
| | | 127 | | |
| | | 128 | | var items = new List<AssemblyListItem>(); |
| | | 129 | | var idx = 1; |
| | | 130 | | |
| | | 131 | | foreach (var posting in filtered) |
| | | 132 | | { |
| | | 133 | | var warehouseName = posting.WarehouseName; |
| | | 134 | | if (string.IsNullOrEmpty(warehouseName) && posting.WarehouseId > 0) |
| | | 135 | | warehouseMap.TryGetValue(posting.WarehouseId, out warehouseName); |
| | | 136 | | if (string.IsNullOrEmpty(warehouseName)) |
| | | 137 | | warehouseName = posting.WarehouseId > 0 ? posting.WarehouseId.ToString() : "—"; |
| | | 138 | | |
| | | 139 | | var labelInfo = labelsByPosting.GetValueOrDefault(posting.PostingNumber); |
| | | 140 | | |
| | | 141 | | foreach (var product in posting.Products) |
| | | 142 | | { |
| | | 143 | | items.Add(new AssemblyListItem |
| | | 144 | | { |
| | | 145 | | Index = idx++, |
| | | 146 | | PostingNumber = posting.PostingNumber, |
| | | 147 | | Status = posting.Status, |
| | | 148 | | StatusLabel = StatusLabels.GetValueOrDefault(posting.Status, posting.Status), |
| | | 149 | | WarehouseId = posting.WarehouseId, |
| | | 150 | | WarehouseName = warehouseName, |
| | | 151 | | OfferId = product.OfferId, |
| | | 152 | | ProductName = product.Name, |
| | | 153 | | Quantity = product.Quantity, |
| | | 154 | | CutoffDate = posting.CutoffDate, |
| | | 155 | | LabelUrl = labelInfo?.FileUrl, |
| | | 156 | | LabelError = labelInfo?.Error, |
| | | 157 | | }); |
| | | 158 | | } |
| | | 159 | | } |
| | | 160 | | |
| | | 161 | | return new AssemblyListResult |
| | | 162 | | { |
| | | 163 | | Items = items, |
| | | 164 | | TotalPostings = filtered.Count, |
| | | 165 | | GeneratedAt = DateTime.Now, |
| | | 166 | | }; |
| | | 167 | | } |
| | | 168 | | |
| | | 169 | | public void ApplyArticleImages(AssemblyListResult result, Dictionary<string, string> articleImages) |
| | | 170 | | { |
| | | 171 | | foreach (var item in result.Items) |
| | | 172 | | { |
| | | 173 | | if (articleImages.TryGetValue(item.OfferId, out var imageUrl)) |
| | | 174 | | item.ImageUrl = imageUrl; |
| | | 175 | | } |
| | | 176 | | } |
| | | 177 | | |
| | | 178 | | private async Task<Dictionary<long, string>> GetWarehouseMapAsync(OzonApiDataManager manager, string sellerCache |
| | | 179 | | { |
| | | 180 | | var cacheKey = $"assembly_ozon_warehouses_{sellerCacheKey}"; |
| | | 181 | | if (_cache.TryGetValue(cacheKey, out Dictionary<long, string>? cached) && cached != null) |
| | | 182 | | return cached; |
| | | 183 | | |
| | | 184 | | var warehouses = await manager.GetWarehouseList().ConfigureAwait(false); |
| | | 185 | | var map = warehouses |
| | | 186 | | .Where(w => w.WarehouseId > 0 && !string.IsNullOrEmpty(w.Name)) |
| | | 187 | | .ToDictionary(w => w.WarehouseId, w => w.Name!); |
| | | 188 | | |
| | | 189 | | _cache.Set(cacheKey, map, WarehouseListCacheTtl); |
| | | 190 | | return map; |
| | | 191 | | } |
| | | 192 | | |
| | | 193 | | private static async Task<Dictionary<string, LabelInfo>> GenerateLabelsAsync( |
| | | 194 | | OzonApiDataManager manager, |
| | | 195 | | List<UnfulfilledPosting> deliverPostings, |
| | | 196 | | IProgress<AssemblyListProgress>? progress) |
| | | 197 | | { |
| | | 198 | | var result = new Dictionary<string, LabelInfo>(StringComparer.Ordinal); |
| | | 199 | | if (deliverPostings.Count == 0) |
| | | 200 | | return result; |
| | | 201 | | |
| | | 202 | | var chunks = deliverPostings |
| | | 203 | | .Select(p => p.PostingNumber) |
| | | 204 | | .Where(n => !string.IsNullOrEmpty(n)) |
| | | 205 | | .Chunk(LabelBatchSize) |
| | | 206 | | .Select(c => c.ToArray()) |
| | | 207 | | .ToList(); |
| | | 208 | | |
| | | 209 | | progress?.Report(new AssemblyListProgress |
| | | 210 | | { |
| | | 211 | | Step = 3, |
| | | 212 | | Message = $"Этикетки: создаём {chunks.Count} заданий для {deliverPostings.Count} отправлений...", |
| | | 213 | | }); |
| | | 214 | | |
| | | 215 | | var createResults = await Task.WhenAll(chunks.Select(async postingNumbers => |
| | | 216 | | { |
| | | 217 | | try |
| | | 218 | | { |
| | | 219 | | var createResp = await manager.CreatePackageLabelTask(postingNumbers).ConfigureAwait(false); |
| | | 220 | | var taskId = createResp.PickTask()?.TaskId ?? 0; |
| | | 221 | | return (TaskId: taskId, PostingNumbers: postingNumbers, Error: (string?)null); |
| | | 222 | | } |
| | | 223 | | catch (Exception ex) |
| | | 224 | | { |
| | | 225 | | return (TaskId: 0L, PostingNumbers: postingNumbers, Error: ex.Message); |
| | | 226 | | } |
| | | 227 | | })).ConfigureAwait(false); |
| | | 228 | | |
| | | 229 | | var taskIdToPostings = new Dictionary<long, string[]>(); |
| | | 230 | | foreach (var created in createResults) |
| | | 231 | | { |
| | | 232 | | if (created.TaskId > 0) |
| | | 233 | | { |
| | | 234 | | taskIdToPostings[created.TaskId] = created.PostingNumbers; |
| | | 235 | | continue; |
| | | 236 | | } |
| | | 237 | | |
| | | 238 | | var error = created.Error ?? "Не удалось создать задание"; |
| | | 239 | | foreach (var pn in created.PostingNumbers) |
| | | 240 | | result[pn] = new LabelInfo { Error = error }; |
| | | 241 | | } |
| | | 242 | | |
| | | 243 | | if (taskIdToPostings.Count == 0) |
| | | 244 | | return result; |
| | | 245 | | |
| | | 246 | | progress?.Report(new AssemblyListProgress |
| | | 247 | | { |
| | | 248 | | Step = 3, |
| | | 249 | | Message = $"Этикетки: ожидаем {taskIdToPostings.Count} заданий...", |
| | | 250 | | }); |
| | | 251 | | |
| | | 252 | | var pollResults = await PackageLabelPollingHelper.PollTasksParallelForFileUrlsAsync( |
| | | 253 | | taskId => manager.GetPackageLabelFile(taskId), |
| | | 254 | | taskIdToPostings).ConfigureAwait(false); |
| | | 255 | | |
| | | 256 | | foreach (var (taskId, postings) in taskIdToPostings) |
| | | 257 | | { |
| | | 258 | | if (!pollResults.TryGetValue(taskId, out var poll) || !poll.IsSuccess || string.IsNullOrEmpty(poll.FileU |
| | | 259 | | { |
| | | 260 | | var error = poll?.Error ?? "Не удалось получить этикетку"; |
| | | 261 | | foreach (var pn in postings) |
| | | 262 | | result[pn] = new LabelInfo { Error = error }; |
| | | 263 | | continue; |
| | | 264 | | } |
| | | 265 | | |
| | | 266 | | foreach (var pn in postings) |
| | | 267 | | result[pn] = new LabelInfo { FileUrl = poll.FileUrl }; |
| | | 268 | | } |
| | | 269 | | |
| | | 270 | | return result; |
| | | 271 | | } |
| | | 272 | | |
| | | 273 | | private class LabelInfo |
| | | 274 | | { |
| | | 275 | | public string? FileUrl { get; init; } |
| | | 276 | | public string? Error { get; init; } |
| | | 277 | | } |
| | | 278 | | } |
| | | 279 | | } |