| | | 1 | | using Microsoft.EntityFrameworkCore; |
| | | 2 | | using Microsoft.Extensions.Logging; |
| | | 3 | | using OzonDomains.Models; |
| | | 4 | | using OzonDomains.Models.SupplierStatuses; |
| | | 5 | | using OzonRepositories.Context; |
| | | 6 | | using Services.ApiServices.ArmtekApi; |
| | | 7 | | using Services.ApiServices.ArmtekApi.Models.Response; |
| | | 8 | | |
| | | 9 | | namespace Services.SupplierStatusServices.Providers; |
| | | 10 | | |
| | | 11 | | /// <summary> |
| | | 12 | | /// Статусы Armtek. Корзина локальная — читается из БД; заказ опрашивается через |
| | | 13 | | /// ws_order/getOrder2 со STATUS=1. Базовый класс общий для RU и BY, ключ и клиент задаёт наследник. |
| | | 14 | | /// </summary> |
| | | 15 | | public abstract class ArmtekSupplierStatusProviderBase : ISupplierStatusProvider |
| | | 16 | | { |
| | | 17 | | private readonly ApplicationDbContext _db; |
| | | 18 | | private readonly ArmtekDataManager _armtek; |
| | | 19 | | private readonly LocalCartStatusReader _cartReader; |
| | | 20 | | private readonly ILogger _logger; |
| | | 21 | | |
| | | 22 | | protected ArmtekSupplierStatusProviderBase( |
| | | 23 | | ApplicationDbContext db, ArmtekDataManager armtek, LocalCartStatusReader cartReader, ILogger logger) |
| | | 24 | | { |
| | | 25 | | _db = db; |
| | | 26 | | _armtek = armtek; |
| | | 27 | | _cartReader = cartReader; |
| | | 28 | | _logger = logger; |
| | | 29 | | } |
| | | 30 | | |
| | | 31 | | public abstract string ProviderKey { get; } |
| | | 32 | | |
| | | 33 | | public Task<IReadOnlyList<SupplierStatusSnapshot>> FetchCartAsync( |
| | | 34 | | IReadOnlyList<OrderSupplierStateRef> states, CancellationToken ct) => |
| | | 35 | | _cartReader.ReadAsync(ProviderKey, states, ct); |
| | | 36 | | |
| | | 37 | | public async Task<IReadOnlyList<SupplierStatusSnapshot>> FetchOrderAsync( |
| | | 38 | | IReadOnlyList<OrderSupplierStateRef> states, CancellationToken ct) |
| | | 39 | | { |
| | | 40 | | if (states.Count == 0) return []; |
| | | 41 | | |
| | | 42 | | var orderIds = states.Select(x => x.OrderId).Distinct().ToList(); |
| | | 43 | | |
| | | 44 | | // Один заказ Studio2 может разложиться на несколько Vbeln (аллокации по складам), |
| | | 45 | | // поэтому берём все связки и группируем по заказу Armtek — один HTTP-вызов на Vbeln. |
| | | 46 | | var links = await _db.ArmtekOrderItems |
| | | 47 | | .Where(x => orderIds.Contains(x.StudioOrderId) |
| | | 48 | | && x.ArmtekOrder != null && x.ArmtekOrder.ProviderKey == ProviderKey) |
| | | 49 | | .Select(x => new |
| | | 50 | | { |
| | | 51 | | x.StudioOrderId, |
| | | 52 | | x.Posnr, |
| | | 53 | | x.ArmtekOrder!.Vbeln, |
| | | 54 | | x.ArmtekOrder.Kunrg, |
| | | 55 | | x.ArmtekOrder.Vkorg |
| | | 56 | | }) |
| | | 57 | | .ToListAsync(ct); |
| | | 58 | | |
| | | 59 | | if (links.Count == 0) return []; |
| | | 60 | | |
| | | 61 | | var result = new List<SupplierStatusSnapshot>(); |
| | | 62 | | foreach (var group in links.GroupBy(x => new { x.Vbeln, x.Kunrg, x.Vkorg })) |
| | | 63 | | { |
| | | 64 | | ArmtekGetOrder2Response? resp; |
| | | 65 | | bool hasErrors; |
| | | 66 | | string? errorText = null; |
| | | 67 | | try |
| | | 68 | | { |
| | | 69 | | var response = await _armtek.GetOrder2Async( |
| | | 70 | | group.Key.Kunrg, group.Key.Vbeln, group.Key.Vkorg, detailedStatus: true, ct); |
| | | 71 | | resp = response.Resp; |
| | | 72 | | hasErrors = response.HasErrors; |
| | | 73 | | if (hasErrors) |
| | | 74 | | errorText = string.Join("; ", response.Messages.Where(m => m.Type is "E" or "A").Select(m => m.Text) |
| | | 75 | | } |
| | | 76 | | catch (Exception ex) when (ex is ArmtekApiException or HttpRequestException or TaskCanceledException) |
| | | 77 | | { |
| | | 78 | | _logger.LogInformation("{Provider}: не удалось прочитать заказ {Vbeln}: {Error}", ProviderKey, group.Key |
| | | 79 | | foreach (var link in group) |
| | | 80 | | { |
| | | 81 | | result.Add(ErrorSnapshot(link.StudioOrderId, $"{ProviderKey}: {ex.Message}")); |
| | | 82 | | } |
| | | 83 | | continue; |
| | | 84 | | } |
| | | 85 | | |
| | | 86 | | // Таймаут getOrder2?STATUS=1 отдаёт MESSAGES[E] вместе с частичным HEADER/ITEMS. |
| | | 87 | | // Если статуса выжать не из чего — пишем ошибку и не трогаем известный статус. |
| | | 88 | | if (resp is null) |
| | | 89 | | { |
| | | 90 | | foreach (var link in group) |
| | | 91 | | { |
| | | 92 | | result.Add(ErrorSnapshot(link.StudioOrderId, errorText ?? $"{ProviderKey}: пустой ответ getOrder2")) |
| | | 93 | | } |
| | | 94 | | continue; |
| | | 95 | | } |
| | | 96 | | |
| | | 97 | | var (statusName, expectedAt) = ResolveStatus(resp); |
| | | 98 | | if (string.IsNullOrWhiteSpace(statusName)) |
| | | 99 | | { |
| | | 100 | | foreach (var link in group) |
| | | 101 | | { |
| | | 102 | | result.Add(ErrorSnapshot(link.StudioOrderId, errorText ?? $"{ProviderKey}: статус не определён")); |
| | | 103 | | } |
| | | 104 | | continue; |
| | | 105 | | } |
| | | 106 | | |
| | | 107 | | foreach (var link in group) |
| | | 108 | | { |
| | | 109 | | result.Add(new SupplierStatusSnapshot |
| | | 110 | | { |
| | | 111 | | OrderId = link.StudioOrderId, |
| | | 112 | | Kind = SupplierStatusKind.Order, |
| | | 113 | | ExternalCode = statusName, |
| | | 114 | | StatusName = statusName, |
| | | 115 | | ExternalOrderNumber = group.Key.Vbeln, |
| | | 116 | | ExternalLineId = link.Posnr, |
| | | 117 | | ExpectedDeliveryAt = expectedAt |
| | | 118 | | }); |
| | | 119 | | } |
| | | 120 | | } |
| | | 121 | | |
| | | 122 | | return result; |
| | | 123 | | } |
| | | 124 | | |
| | | 125 | | /// <summary> |
| | | 126 | | /// Приоритет: Delivered (отгружено) > Ready (готово к отгрузке) > Processing.SubStatus. |
| | | 127 | | /// Имена полей внутри STATUSES не подтверждены живым ответом API (см. комментарий |
| | | 128 | | /// в ArmtekOrderStatuses) — Newtonsoft при несовпадении молча оставит списки пустыми, |
| | | 129 | | /// поэтому при пустой детализации откатываемся на HEADER.ORDER_STATUS. |
| | | 130 | | /// </summary> |
| | | 131 | | private (string? StatusName, DateTime? ExpectedAt) ResolveStatus(ArmtekGetOrder2Response resp) |
| | | 132 | | { |
| | | 133 | | var statuses = resp.Statuses; |
| | | 134 | | if (statuses is not null) |
| | | 135 | | { |
| | | 136 | | if (statuses.Delivered.Count > 0) |
| | | 137 | | return ("Отгружен", null); |
| | | 138 | | |
| | | 139 | | if (statuses.Ready.Count > 0) |
| | | 140 | | { |
| | | 141 | | var expected = statuses.Ready |
| | | 142 | | .Select(x => ParseArmtekDate(x.ExpectedDate) ?? ParseArmtekDate(x.DeliveryDate)) |
| | | 143 | | .OfType<DateTime>() |
| | | 144 | | .OrderBy(x => x) |
| | | 145 | | .FirstOrDefault(); |
| | | 146 | | return ("Готов к отгрузке", expected == default ? null : expected); |
| | | 147 | | } |
| | | 148 | | |
| | | 149 | | if (statuses.Processing.Count > 0) |
| | | 150 | | { |
| | | 151 | | var first = statuses.Processing[0]; |
| | | 152 | | var subStatus = string.IsNullOrWhiteSpace(first.SubStatus) ? "В обработке" : first.SubStatus; |
| | | 153 | | var expected = statuses.Processing |
| | | 154 | | .Select(x => ParseArmtekDate(x.ExpectedDate) ?? ParseArmtekDate(x.Date)) |
| | | 155 | | .OfType<DateTime>() |
| | | 156 | | .OrderBy(x => x) |
| | | 157 | | .FirstOrDefault(); |
| | | 158 | | return (subStatus, expected == default ? null : expected); |
| | | 159 | | } |
| | | 160 | | |
| | | 161 | | _logger.LogDebug( |
| | | 162 | | "{Provider}: STATUSES пуст для заказа {Order} — используем HEADER.ORDER_STATUS", |
| | | 163 | | ProviderKey, resp.Header?.Order); |
| | | 164 | | } |
| | | 165 | | |
| | | 166 | | var headerStatus = resp.Header?.OrderStatus; |
| | | 167 | | return (string.IsNullOrWhiteSpace(headerStatus) ? null : headerStatus, null); |
| | | 168 | | } |
| | | 169 | | |
| | | 170 | | private static DateTime? ParseArmtekDate(string? value) => |
| | | 171 | | DateTime.TryParse(value, out var parsed) ? parsed : null; |
| | | 172 | | |
| | | 173 | | private SupplierStatusSnapshot ErrorSnapshot(int orderId, string error) => new() |
| | | 174 | | { |
| | | 175 | | OrderId = orderId, |
| | | 176 | | Kind = SupplierStatusKind.Order, |
| | | 177 | | StatusName = string.Empty, |
| | | 178 | | Error = error |
| | | 179 | | }; |
| | | 180 | | } |
| | | 181 | | |
| | | 182 | | public sealed class ArmtekSupplierStatusProvider : ArmtekSupplierStatusProviderBase |
| | | 183 | | { |
| | | 184 | | public ArmtekSupplierStatusProvider( |
| | | 185 | | ApplicationDbContext db, ArmtekDataManager armtek, LocalCartStatusReader cartReader, |
| | | 186 | | ILogger<ArmtekSupplierStatusProvider> logger) |
| | | 187 | | : base(db, armtek, cartReader, logger) |
| | | 188 | | { |
| | | 189 | | } |
| | | 190 | | |
| | | 191 | | public override string ProviderKey => ApiSupplierSettings.ArmtekProviderKey; |
| | | 192 | | } |
| | | 193 | | |
| | | 194 | | public sealed class ArmtekBySupplierStatusProvider : ArmtekSupplierStatusProviderBase |
| | | 195 | | { |
| | | 196 | | public ArmtekBySupplierStatusProvider( |
| | | 197 | | ApplicationDbContext db, ArmtekByDataManager armtek, LocalCartStatusReader cartReader, |
| | | 198 | | ILogger<ArmtekBySupplierStatusProvider> logger) |
| | 0 | 199 | | : base(db, armtek, cartReader, logger) |
| | | 200 | | { |
| | 0 | 201 | | } |
| | | 202 | | |
| | 0 | 203 | | public override string ProviderKey => ApiSupplierSettings.ArmtekByProviderKey; |
| | | 204 | | } |