| | | 1 | | using Microsoft.EntityFrameworkCore; |
| | | 2 | | using OzonDomains.Models; |
| | | 3 | | using OzonDomains.Models.Partners; |
| | | 4 | | using OzonRepositories.Context; |
| | | 5 | | using Services.LabelServices; |
| | | 6 | | |
| | | 7 | | namespace Services.PartnerServices; |
| | | 8 | | |
| | | 9 | | public interface IPartnerOrdersService |
| | | 10 | | { |
| | | 11 | | Task<PartnerPage<PartnerOrderListItemDto>> ListAsync(long partnerId, PartnerOrderQuery query, CancellationToken ct = |
| | | 12 | | |
| | | 13 | | /// <summary>Заказ, если он попадает под правила партнёра. Иначе null — даже если заказ существует.</summary> |
| | | 14 | | Task<PartnerOrderDetailsDto?> GetAsync(long partnerId, int orderId, CancellationToken ct = default); |
| | | 15 | | |
| | | 16 | | /// <summary>Значения для фильтров: только те статусы, склады и клиенты, что есть среди видимых партнёру заказов.</s |
| | | 17 | | Task<PartnerOrderFilters> GetFiltersAsync(long partnerId, CancellationToken ct = default); |
| | | 18 | | |
| | | 19 | | /// <summary>Быстрые подсказки по номеру, товару, артикулу, трек-номеру и ScanIt в рамках scope партнёра.</summary> |
| | | 20 | | Task<IReadOnlyList<PartnerOrderSuggestion>> SuggestAsync(long partnerId, string? query, int limit = PartnerOrdersSer |
| | | 21 | | |
| | | 22 | | /// <summary>Уже готовая этикетка из хранилища. Новую не создаёт и в Ozon не ходит.</summary> |
| | | 23 | | Task<PartnerLabelFile?> GetExistingLabelAsync(long partnerId, int orderId, CancellationToken ct = default); |
| | | 24 | | |
| | | 25 | | /// <summary> |
| | | 26 | | /// Предпросмотр набора правил, ещё не сохранённых: сколько заказов подойдёт и id первых из них (новые сверху), |
| | | 27 | | /// чтобы владелец мог открыть их в таблице заказов. |
| | | 28 | | /// </summary> |
| | | 29 | | Task<PartnerRulesPreview> PreviewAsync(IReadOnlyList<PartnerRuleDto> rules, int idsLimit = PartnerOrdersService.Prev |
| | | 30 | | } |
| | | 31 | | |
| | 24 | 32 | | public sealed class PartnerOrdersService( |
| | 24 | 33 | | ApplicationDbContext db, |
| | 24 | 34 | | ILabelExistenceService labelExistence, |
| | 24 | 35 | | ILabelFileStorage labelStorage) : IPartnerOrdersService |
| | | 36 | | { |
| | | 37 | | internal const int MaxPageSize = 100; |
| | | 38 | | /// <summary>Больше подсказок в выпадающем списке не помогают выбрать заказ и только нагружают БД.</summary> |
| | | 39 | | public const int MaxSuggestions = 10; |
| | | 40 | | |
| | | 41 | | /// <summary>Сколько id отдаём в ссылку на таблицу заказов: адрес с большим списком перестаёт проходить через сервер |
| | | 42 | | public const int PreviewIdsLimit = 500; |
| | | 43 | | |
| | | 44 | | public async Task<PartnerPage<PartnerOrderListItemDto>> ListAsync( |
| | | 45 | | long partnerId, PartnerOrderQuery query, CancellationToken ct = default) |
| | | 46 | | { |
| | 26 | 47 | | var page = Math.Max(1, query.Page); |
| | 26 | 48 | | var pageSize = Math.Clamp(query.PageSize, 1, MaxPageSize); |
| | | 49 | | |
| | 26 | 50 | | var visible = await VisibleOrdersAsync(partnerId, ct); |
| | 26 | 51 | | var filtered = ApplyFilters(visible, query); |
| | | 52 | | |
| | 26 | 53 | | var total = await filtered.CountAsync(ct); |
| | | 54 | | // Порядок задаём ДО проекции в DTO: ссылка на свойство DTO в OrderBy на MariaDB не переводится в SQL. |
| | 26 | 55 | | var rows = (await Project(filtered |
| | 26 | 56 | | .OrderByDescending(o => o.ProcessingDate) |
| | 26 | 57 | | .ThenByDescending(o => o.Id) |
| | 26 | 58 | | .Skip((page - 1) * pageSize) |
| | 26 | 59 | | .Take(pageSize)) |
| | 26 | 60 | | .ToListAsync(ct)) |
| | 26 | 61 | | .Select(ToDto) |
| | 26 | 62 | | .ToList(); |
| | | 63 | | |
| | 26 | 64 | | var items = await WithLabelsAsync(rows); |
| | 26 | 65 | | return new PartnerPage<PartnerOrderListItemDto>(items, total, page, pageSize); |
| | 26 | 66 | | } |
| | | 67 | | |
| | | 68 | | public async Task<PartnerOrderDetailsDto?> GetAsync(long partnerId, int orderId, CancellationToken ct = default) |
| | | 69 | | { |
| | 5 | 70 | | var one = (await VisibleOrdersAsync(partnerId, ct)).Where(o => o.Id == orderId); |
| | | 71 | | |
| | 5 | 72 | | var row = await Project(one).FirstOrDefaultAsync(ct); |
| | 7 | 73 | | if (row is null) return null; |
| | 3 | 74 | | var item = ToDto(row); |
| | | 75 | | |
| | 3 | 76 | | var extra = await one |
| | 3 | 77 | | .Select(o => new |
| | 3 | 78 | | { |
| | 3 | 79 | | o.DeliveryCity, |
| | 3 | 80 | | Barcode = o.ProductInfo != null ? o.ProductInfo.Barcode : null, |
| | 3 | 81 | | o.Comment, |
| | 3 | 82 | | o.LastStatusChangeDate, |
| | 3 | 83 | | o.Scanit |
| | 3 | 84 | | }) |
| | 3 | 85 | | .FirstAsync(ct); |
| | | 86 | | |
| | 3 | 87 | | var withLabel = (await WithLabelsAsync([item]))[0]; |
| | 3 | 88 | | return new PartnerOrderDetailsDto(withLabel, extra.DeliveryCity, extra.Barcode, extra.Comment, extra.LastStatusC |
| | 5 | 89 | | } |
| | | 90 | | |
| | | 91 | | public async Task<PartnerOrderFilters> GetFiltersAsync(long partnerId, CancellationToken ct = default) |
| | | 92 | | { |
| | 1 | 93 | | var visible = await VisibleOrdersAsync(partnerId, ct); |
| | | 94 | | |
| | 1 | 95 | | var statuses = await visible.Where(o => o.AppStatus != null) |
| | 1 | 96 | | .Select(o => new { o.AppStatus!.Id, o.AppStatus.Name }).Distinct().ToListAsync(ct); |
| | 1 | 97 | | var ozonStatuses = await visible.Where(o => o.ClientStatus != null && o.ClientStatus.Name != null) |
| | 1 | 98 | | .Select(o => o.ClientStatus!.Name!).Distinct().OrderBy(x => x).ToListAsync(ct); |
| | 1 | 99 | | var warehouses = await visible.Where(o => o.ShipmentWarehouse != null) |
| | 1 | 100 | | .Select(o => new { o.ShipmentWarehouse!.Id, o.ShipmentWarehouse.Name }).Distinct().ToListAsync(ct); |
| | 1 | 101 | | var clients = await visible.Where(o => o.OzonClient != null) |
| | 1 | 102 | | .Select(o => new { o.OzonClient!.Id, o.OzonClient.Name }).Distinct().ToListAsync(ct); |
| | | 103 | | |
| | 1 | 104 | | return new PartnerOrderFilters( |
| | | 105 | | ToOptions(statuses.Select(x => (x.Id, (string?)x.Name))), |
| | | 106 | | ToOptions(warehouses.Select(x => (x.Id, (string?)x.Name))), |
| | | 107 | | ToOptions(clients.Select(x => (x.Id, (string?)x.Name))), |
| | 1 | 108 | | ozonStatuses); |
| | 1 | 109 | | } |
| | | 110 | | |
| | | 111 | | public async Task<IReadOnlyList<PartnerOrderSuggestion>> SuggestAsync( |
| | | 112 | | long partnerId, string? query, int limit = MaxSuggestions, CancellationToken ct = default) |
| | | 113 | | { |
| | 2 | 114 | | var search = query?.Trim(); |
| | | 115 | | // Один символ почти всегда даёт широкий скан и бесполезные подсказки. На фронте запрос начинается от 2 символов |
| | 3 | 116 | | if (string.IsNullOrWhiteSpace(search) || search.Length < 2) return []; |
| | | 117 | | |
| | 1 | 118 | | var visible = await VisibleOrdersAsync(partnerId, ct); |
| | 1 | 119 | | var rows = await Project(ApplySearch(visible, search) |
| | 1 | 120 | | .OrderByDescending(o => o.ProcessingDate).ThenByDescending(o => o.Id) |
| | 1 | 121 | | .Take(Math.Clamp(limit, 1, MaxSuggestions))) |
| | 1 | 122 | | .ToListAsync(ct); |
| | | 123 | | |
| | | 124 | | return rows.Select(r => new PartnerOrderSuggestion( |
| | | 125 | | r.Id, r.ShipmentNumber, r.ProductName, r.Article, r.TrackingNumber, r.Scanit, r.OzonStatus)).ToList(); |
| | 2 | 126 | | } |
| | | 127 | | |
| | | 128 | | public async Task<PartnerLabelFile?> GetExistingLabelAsync(long partnerId, int orderId, CancellationToken ct = defau |
| | | 129 | | { |
| | 3 | 130 | | var visible = await VisibleOrdersAsync(partnerId, ct); |
| | 3 | 131 | | var shipment = await visible.Where(o => o.Id == orderId).Select(o => o.ShipmentNumber).FirstOrDefaultAsync(ct); |
| | 5 | 132 | | if (string.IsNullOrWhiteSpace(shipment)) return null; |
| | | 133 | | |
| | 1 | 134 | | var candidates = new[] { shipment, OzonPostingNumberHelper.GetOzonApiPostingNumber(shipment) } |
| | | 135 | | .Where(x => !string.IsNullOrWhiteSpace(x)) |
| | | 136 | | .Select(x => $"{x}.pdf") |
| | 1 | 137 | | .Distinct(StringComparer.OrdinalIgnoreCase) |
| | 1 | 138 | | .ToList(); |
| | | 139 | | |
| | 1 | 140 | | var fileName = await labelStorage.FindExistingFileNameAsync(candidates, ct); |
| | 1 | 141 | | if (fileName is null) return null; |
| | | 142 | | |
| | 1 | 143 | | var bytes = await labelStorage.TryDownloadAsync(fileName, ct); |
| | 1 | 144 | | return bytes is null ? null : new PartnerLabelFile(bytes, fileName); |
| | 3 | 145 | | } |
| | | 146 | | |
| | | 147 | | public async Task<PartnerRulesPreview> PreviewAsync( |
| | | 148 | | IReadOnlyList<PartnerRuleDto> rules, int idsLimit = PreviewIdsLimit, CancellationToken ct = default) |
| | | 149 | | { |
| | | 150 | | var entities = rules.Select(r => new PartnerRule |
| | | 151 | | { |
| | | 152 | | Name = r.Name, |
| | | 153 | | IsActive = r.IsActive, |
| | | 154 | | Conditions = r.Conditions.Select(c => new PartnerRuleCondition |
| | | 155 | | { |
| | | 156 | | Field = c.Field, |
| | | 157 | | ValuesJson = System.Text.Json.JsonSerializer.Serialize(c.Values.Select(v => v.Trim()).ToList()) |
| | | 158 | | }).ToList() |
| | | 159 | | }).ToList(); |
| | | 160 | | |
| | 4 | 161 | | var matched = PartnerOrderScope.Apply(db.Orders.AsNoTracking(), entities); |
| | 4 | 162 | | var total = await matched.CountAsync(ct); |
| | 4 | 163 | | var limit = Math.Clamp(idsLimit, 1, PreviewIdsLimit); |
| | 4 | 164 | | var ids = await matched |
| | 4 | 165 | | .OrderByDescending(o => o.ProcessingDate).ThenByDescending(o => o.Id) |
| | 4 | 166 | | .Select(o => o.Id).Take(limit).ToListAsync(ct); |
| | | 167 | | |
| | 4 | 168 | | return new PartnerRulesPreview(total, ids, total > ids.Count, limit); |
| | 4 | 169 | | } |
| | | 170 | | |
| | | 171 | | private async Task<IQueryable<Order>> VisibleOrdersAsync(long partnerId, CancellationToken ct) |
| | | 172 | | { |
| | 36 | 173 | | var rules = await db.PartnerRules.AsNoTracking() |
| | 36 | 174 | | .Where(r => r.PartnerId == partnerId && r.IsActive) |
| | 36 | 175 | | .Include(r => r.Conditions) |
| | 36 | 176 | | .ToListAsync(ct); |
| | 36 | 177 | | return PartnerOrderScope.Apply(db.Orders.AsNoTracking(), rules); |
| | 36 | 178 | | } |
| | | 179 | | |
| | | 180 | | private static IQueryable<Order> ApplyFilters(IQueryable<Order> q, PartnerOrderQuery f) |
| | | 181 | | { |
| | 26 | 182 | | if (f.StatusId is { } s) q = q.Where(o => o.AppStatusId == s); |
| | 27 | 183 | | if (f.WarehouseId is { } w) q = q.Where(o => o.ShipmentWarehouseId == w); |
| | 26 | 184 | | if (f.ClientId is { } c) q = q.Where(o => o.OzonClientId == c); |
| | 26 | 185 | | var ozonStatus = f.OzonStatus?.Trim(); |
| | 28 | 186 | | if (!string.IsNullOrEmpty(ozonStatus)) q = q.Where(o => o.ClientStatus != null && o.ClientStatus.Name == ozonSta |
| | 26 | 187 | | if (f.From is { } from) q = q.Where(o => o.ProcessingDate >= from.Date); |
| | 26 | 188 | | if (f.To is { } to) q = q.Where(o => o.ProcessingDate < to.Date.AddDays(1)); |
| | | 189 | | |
| | 26 | 190 | | return string.IsNullOrEmpty(f.Search?.Trim()) ? q : ApplySearch(q, f.Search.Trim()); |
| | | 191 | | } |
| | | 192 | | |
| | | 193 | | private static IQueryable<Order> ApplySearch(IQueryable<Order> q, string search) => |
| | 4 | 194 | | q.Where(o => |
| | 4 | 195 | | (o.ShipmentNumber != null && o.ShipmentNumber.Contains(search)) || |
| | 4 | 196 | | (o.Article != null && o.Article.Contains(search)) || |
| | 4 | 197 | | (o.ProductKey != null && o.ProductKey.Contains(search)) || |
| | 4 | 198 | | (o.ProductName != null && o.ProductName.Contains(search)) || |
| | 4 | 199 | | (o.TrackingNumber != null && o.TrackingNumber.Contains(search)) || |
| | 4 | 200 | | (o.Scanit != null && o.Scanit.Contains(search))); |
| | | 201 | | |
| | | 202 | | /// <summary>Строка выборки: enum маркетплейса переводим в текст уже после запроса (Enum.ToString в SQL не переводим |
| | 43 | 203 | | private sealed record OrderRow( |
| | 295 | 204 | | int Id, string? ShipmentNumber, string? ProductName, string? Article, string? Brand, int? Quantity, decimal? Pri |
| | 248 | 205 | | string? ImageUrl, int? StatusId, string? Status, string? OzonStatus, int? WarehouseId, string? Warehouse, |
| | 205 | 206 | | int? ClientId, string? Client, OzonDomains.ClientType? ClientType, DateTime? ProcessingDate, DateTime? ShippingD |
| | 129 | 207 | | string? TrackingNumber, string? Scanit); |
| | | 208 | | |
| | | 209 | | private static IQueryable<OrderRow> Project(IQueryable<Order> q) => |
| | 32 | 210 | | q.Select(o => new OrderRow( |
| | 32 | 211 | | o.Id, |
| | 32 | 212 | | o.ShipmentNumber, |
| | 32 | 213 | | o.ProductName, |
| | 32 | 214 | | // Артикул для партнёра — ключ товара «АРТИКУЛ=КОД» (как offer_id в Ozon, например 122RU19=HSH); нет ключа — |
| | 32 | 215 | | !string.IsNullOrEmpty(o.ProductKey) ? o.ProductKey : o.Article, |
| | 32 | 216 | | o.Manufacturer != null ? o.Manufacturer.Name : null, |
| | 32 | 217 | | o.Quantity, |
| | 32 | 218 | | o.Price, |
| | 32 | 219 | | o.ProductInfo != null ? o.ProductInfo.ImageUrl : null, |
| | 32 | 220 | | o.AppStatusId, |
| | 32 | 221 | | o.AppStatus != null ? o.AppStatus.Name : null, |
| | 32 | 222 | | o.ClientStatus != null ? o.ClientStatus.Name : null, |
| | 32 | 223 | | o.ShipmentWarehouseId, |
| | 32 | 224 | | o.ShipmentWarehouse != null ? o.ShipmentWarehouse.Name : null, |
| | 32 | 225 | | o.OzonClientId, |
| | 32 | 226 | | o.OzonClient != null ? o.OzonClient.Name : null, |
| | 32 | 227 | | o.OzonClient != null ? o.OzonClient.ClientType : (OzonDomains.ClientType?)null, |
| | 32 | 228 | | o.ProcessingDate, |
| | 32 | 229 | | o.ShippingDate, |
| | 32 | 230 | | o.TrackingNumber, |
| | 32 | 231 | | o.Scanit)); |
| | | 232 | | |
| | 41 | 233 | | private static PartnerOrderListItemDto ToDto(OrderRow r) => new( |
| | 41 | 234 | | r.Id, r.ShipmentNumber, r.ProductName, r.Article, r.Brand, r.Quantity, r.Price, r.ImageUrl, |
| | 41 | 235 | | r.StatusId, r.Status, r.OzonStatus, r.WarehouseId, r.Warehouse, r.ClientId, r.Client, |
| | 41 | 236 | | r.ClientType?.ToString(), r.ProcessingDate, r.ShippingDate, r.TrackingNumber, false, r.Scanit); |
| | | 237 | | |
| | | 238 | | private async Task<IReadOnlyList<PartnerOrderListItemDto>> WithLabelsAsync(IReadOnlyList<PartnerOrderListItemDto> ro |
| | | 239 | | { |
| | | 240 | | var numbers = rows.Select(r => r.ShipmentNumber).Where(n => !string.IsNullOrWhiteSpace(n)).Select(n => n!).ToLis |
| | 35 | 241 | | if (numbers.Count == 0) return rows; |
| | | 242 | | |
| | 23 | 243 | | var exists = await labelExistence.CheckBatchAsync(numbers); |
| | 23 | 244 | | return rows |
| | 41 | 245 | | .Select(r => r.ShipmentNumber is { } n && exists.TryGetValue(n, out var has) && has ? r with { HasLabel = tr |
| | 23 | 246 | | .ToList(); |
| | 29 | 247 | | } |
| | | 248 | | |
| | | 249 | | private static IReadOnlyList<PartnerFilterOption> ToOptions(IEnumerable<(int Id, string? Name)> source) => |
| | 11 | 250 | | source.OrderBy(x => x.Name).Select(x => new PartnerFilterOption(x.Id, x.Name ?? x.Id.ToString())).ToList(); |
| | | 251 | | } |