| | | 1 | | using System.Net; |
| | | 2 | | using System.Text.Json; |
| | | 3 | | using System.Xml.Linq; |
| | | 4 | | using Microsoft.Extensions.Caching.Memory; |
| | | 5 | | using OzonDomains.Models; |
| | | 6 | | using OzonDomains.Models.SupplierStatuses; |
| | | 7 | | using OzonOrdersWeb.Areas.Studio2.ViewModels.SupplierCart; |
| | | 8 | | using Services.ApiServices.AvdApi; |
| | | 9 | | using Services.DataServices; |
| | | 10 | | using Services.SupplierStatusServices; |
| | | 11 | | |
| | | 12 | | namespace OzonOrdersWeb.TransactionServices; |
| | | 13 | | |
| | | 14 | | public sealed record AvdBasketQueueItem( |
| | | 15 | | int OrderId, |
| | | 16 | | string ItemHash, |
| | | 17 | | decimal Quantity, |
| | | 18 | | string? Article, |
| | | 19 | | string? ProductName, |
| | | 20 | | string? ShipmentNumber, |
| | | 21 | | string? ClientName, |
| | | 22 | | decimal? Price = null, |
| | | 23 | | int? DeliveryDaysMin = null, |
| | | 24 | | int? DeliveryDaysMax = null, |
| | | 25 | | string? Direction = null); |
| | | 26 | | |
| | | 27 | | public sealed record AvdDirectCheckoutResult(bool IsSuccess, IReadOnlyList<string> Messages); |
| | | 28 | | |
| | | 29 | | /// <summary> |
| | | 30 | | /// Данные размещения в корзине API-поставщика для записи в orders_history — провайдеронезависимая |
| | | 31 | | /// форма (ProviderKey/LogoUrl), чтобы таймлайн истории заказа умел показывать любого будущего |
| | | 32 | | /// поставщика с реальным API-заказом, не только АВД. |
| | | 33 | | /// </summary> |
| | | 34 | | public sealed class AvdBasketPlacementService |
| | | 35 | | { |
| | | 36 | | private const string ProviderKey = "Avd"; |
| | | 37 | | private const string SupplierDisplayName = "АВД"; |
| | | 38 | | private const string LogoUrl = "/images/avd-logo.svg"; |
| | | 39 | | private const string CurrencyCode = "RUB"; |
| | | 40 | | |
| | | 41 | | private readonly AvdDataManager _avd; |
| | | 42 | | private readonly IOrderHistoryDataService _orderHistoryService; |
| | | 43 | | private readonly IMemoryCache _cache; |
| | | 44 | | private readonly IOrderSupplierStateService _supplierStateService; |
| | | 45 | | |
| | 8 | 46 | | public AvdBasketPlacementService( |
| | 8 | 47 | | AvdDataManager avd, |
| | 8 | 48 | | IOrderHistoryDataService orderHistoryService, |
| | 8 | 49 | | IMemoryCache cache, |
| | 8 | 50 | | IOrderSupplierStateService supplierStateService) |
| | | 51 | | { |
| | 8 | 52 | | _avd = avd; |
| | 8 | 53 | | _orderHistoryService = orderHistoryService; |
| | 8 | 54 | | _cache = cache; |
| | 8 | 55 | | _supplierStateService = supplierStateService; |
| | 8 | 56 | | } |
| | | 57 | | |
| | | 58 | | public async Task<AvdBasketPlacementResult> PlaceQueuedItemsAsync( |
| | | 59 | | IReadOnlyCollection<AvdBasketQueueItem> items, |
| | | 60 | | string changedBy, |
| | | 61 | | CancellationToken cancellationToken = default) |
| | | 62 | | { |
| | 7 | 63 | | if (items.Count == 0) |
| | 1 | 64 | | return AvdBasketPlacementResult.Empty(); |
| | | 65 | | |
| | 6 | 66 | | var entries = new List<AvdBasketPlacementEntry>(); |
| | 28 | 67 | | foreach (var item in items) |
| | | 68 | | { |
| | | 69 | | // Человекочитаемый номер заказа для комментария и результата — номер отправления, а не внутренний Id Studio |
| | 8 | 70 | | var orderLabel = !string.IsNullOrWhiteSpace(item.ShipmentNumber) ? item.ShipmentNumber! : $"ID {item.OrderId |
| | 8 | 71 | | var articleLabel = !string.IsNullOrWhiteSpace(item.Article) ? item.Article! : orderLabel; |
| | | 72 | | |
| | 8 | 73 | | if (await WasSuccessfullyPlacedAsync(item.OrderId)) |
| | | 74 | | { |
| | 1 | 75 | | entries.Add(AvdBasketPlacementEntry.Skipped(articleLabel, |
| | 1 | 76 | | $"заказ {orderLabel} уже был добавлен в корзину АВД ранее.")); |
| | 1 | 77 | | continue; |
| | | 78 | | } |
| | | 79 | | |
| | 7 | 80 | | if (string.IsNullOrWhiteSpace(item.ItemHash)) |
| | | 81 | | { |
| | 1 | 82 | | var missingHashMessage = $"заказ {orderLabel} — не найден hash предложения АВД."; |
| | 1 | 83 | | entries.Add(AvdBasketPlacementEntry.Failed(articleLabel, missingHashMessage)); |
| | 1 | 84 | | await TryRecordHistoryAsync(item, orderLabel, success: false, apiItemId: null, missingHashMessage, chang |
| | 1 | 85 | | continue; |
| | | 86 | | } |
| | | 87 | | |
| | | 88 | | // Комментарий уходит в АВД и виден там в их собственной корзине — номер отправления + внутренний Id |
| | | 89 | | // Studio2 в скобках (по этому Id /SupplierCart находит системный заказ через StudioOrderCommentPattern). |
| | 6 | 90 | | var comment = $"Studio2 {orderLabel} (ID {item.OrderId})"; |
| | 6 | 91 | | var detailParts = new List<string> { $"заказ {orderLabel}" }; |
| | 12 | 92 | | if (!string.IsNullOrWhiteSpace(item.ProductName)) detailParts.Add(item.ProductName!); |
| | 12 | 93 | | if (!string.IsNullOrWhiteSpace(item.ClientName)) detailParts.Add(item.ClientName!); |
| | 6 | 94 | | var detail = string.Join(", ", detailParts); |
| | | 95 | | |
| | | 96 | | try |
| | | 97 | | { |
| | 6 | 98 | | var response = await _avd.InsertToBasketAsync(item.ItemHash, item.Quantity, comment, onlyThis: false, ca |
| | 20 | 99 | | var apiItemId = response.Descendants().FirstOrDefault(x => x.Name.LocalName == "InsertToBasketResult")?. |
| | 5 | 100 | | if (TryGetInsertError(apiItemId, out var insertError)) |
| | | 101 | | { |
| | 1 | 102 | | entries.Add(AvdBasketPlacementEntry.Failed(articleLabel, $"{detail} — {insertError}")); |
| | 1 | 103 | | await TryRecordHistoryAsync(item, orderLabel, success: false, apiItemId: null, insertError, changedB |
| | 1 | 104 | | continue; |
| | | 105 | | } |
| | | 106 | | |
| | 4 | 107 | | if (string.IsNullOrWhiteSpace(apiItemId)) |
| | | 108 | | { |
| | | 109 | | const string missingResultError = "АВД не вернул идентификатор добавленной позиции."; |
| | 0 | 110 | | entries.Add(AvdBasketPlacementEntry.Failed(articleLabel, $"{detail} — {missingResultError}")); |
| | 0 | 111 | | await TryRecordHistoryAsync(item, orderLabel, success: false, apiItemId: null, missingResultError, c |
| | 0 | 112 | | continue; |
| | | 113 | | } |
| | | 114 | | |
| | 4 | 115 | | entries.Add(AvdBasketPlacementEntry.Success(articleLabel, $"{detail} — добавлено в корзину АВД")); |
| | 4 | 116 | | await TryRecordHistoryAsync(item, orderLabel, success: true, apiItemId, errorMessage: null, changedBy); |
| | 4 | 117 | | await TryTrackCartAsync(item.OrderId, SupplierCartStatusCode.Pending, changedBy, apiItemId, |
| | 4 | 118 | | item.DeliveryDaysMin, item.DeliveryDaysMax, item.Direction); |
| | 4 | 119 | | _cache.Remove(SupplierCartCacheKeys.AvdBasketCount); |
| | 4 | 120 | | } |
| | 1 | 121 | | catch (AvdApiException ex) |
| | | 122 | | { |
| | 1 | 123 | | entries.Add(AvdBasketPlacementEntry.Failed(articleLabel, $"{detail} — {ex.Message}")); |
| | 1 | 124 | | await TryRecordHistoryAsync(item, orderLabel, success: false, apiItemId: null, ex.Message, changedBy); |
| | | 125 | | } |
| | 5 | 126 | | } |
| | | 127 | | |
| | 6 | 128 | | return new AvdBasketPlacementResult(entries); |
| | 7 | 129 | | } |
| | | 130 | | |
| | | 131 | | /// <summary>Добавляет выбранные позиции в корзину АВД и сразу оформляет только эти строки.</summary> |
| | | 132 | | public async Task<AvdDirectCheckoutResult> CheckoutDirectAsync( |
| | | 133 | | IReadOnlyCollection<AvdBasketQueueItem> items, |
| | | 134 | | string changedBy, |
| | | 135 | | CancellationToken cancellationToken = default) |
| | | 136 | | { |
| | 1 | 137 | | if (items.Count == 0) |
| | 0 | 138 | | return new(false, ["Не выбраны позиции АВД для оформления."]); |
| | 3 | 139 | | if (items.GroupBy(x => x.OrderId).Any(x => x.Key <= 0 || x.Count() > 1) || |
| | 2 | 140 | | items.Any(x => string.IsNullOrWhiteSpace(x.ItemHash) || x.Quantity <= 0)) |
| | 0 | 141 | | return new(false, ["Для прямого оформления АВД требуется ровно одно корректное предложение на заказ."]); |
| | | 142 | | |
| | 1 | 143 | | var inserted = new List<(AvdBasketQueueItem Item, long BasketId)>(); |
| | 1 | 144 | | var messages = new List<string>(); |
| | 4 | 145 | | foreach (var item in items) |
| | | 146 | | { |
| | 1 | 147 | | var orderLabel = string.IsNullOrWhiteSpace(item.ShipmentNumber) ? $"ID {item.OrderId}" : item.ShipmentNumber |
| | | 148 | | try |
| | | 149 | | { |
| | 1 | 150 | | var response = await _avd.InsertToBasketAsync(item.ItemHash, item.Quantity, |
| | 1 | 151 | | $"Studio2 {orderLabel} (ID {item.OrderId})", onlyThis: false, cancellationToken); |
| | 4 | 152 | | var rawId = response.Descendants().FirstOrDefault(x => x.Name.LocalName == "InsertToBasketResult")?.Valu |
| | 1 | 153 | | if (TryGetInsertError(rawId, out var error) || !long.TryParse(rawId, out var basketId) || basketId <= 0) |
| | | 154 | | { |
| | 0 | 155 | | var message = string.IsNullOrWhiteSpace(error) ? "АВД не вернул идентификатор добавленной позиции." |
| | 0 | 156 | | messages.Add($"{item.Article ?? orderLabel}: {message}"); |
| | 0 | 157 | | await TryRecordHistoryAsync(item, orderLabel, false, null, message, changedBy); |
| | 0 | 158 | | continue; |
| | | 159 | | } |
| | 1 | 160 | | inserted.Add((item, basketId)); |
| | 1 | 161 | | } |
| | 0 | 162 | | catch (AvdApiException ex) |
| | | 163 | | { |
| | 0 | 164 | | messages.Add($"{item.Article ?? orderLabel}: {ex.Message}"); |
| | 0 | 165 | | await TryRecordHistoryAsync(item, orderLabel, false, null, ex.Message, changedBy); |
| | | 166 | | } |
| | 1 | 167 | | } |
| | | 168 | | |
| | 1 | 169 | | if (inserted.Count != items.Count) |
| | | 170 | | { |
| | 0 | 171 | | _cache.Remove(SupplierCartCacheKeys.AvdBasketCount); |
| | 0 | 172 | | messages.Add("Корзина АВД могла получить часть позиций; заказ не создан."); |
| | 0 | 173 | | return new(false, messages); |
| | | 174 | | } |
| | | 175 | | |
| | | 176 | | try |
| | | 177 | | { |
| | 2 | 178 | | var orderId = await _avd.CreateOrderByBasketIdsAsync(inserted.Select(x => x.BasketId).ToArray(), |
| | 1 | 179 | | "Studio2: прямое оформление", cancellationToken); |
| | 4 | 180 | | foreach (var (item, basketId) in inserted) |
| | | 181 | | { |
| | 1 | 182 | | var orderLabel = string.IsNullOrWhiteSpace(item.ShipmentNumber) ? $"ID {item.OrderId}" : item.ShipmentNu |
| | 1 | 183 | | await TryRecordHistoryAsync(item, orderLabel, true, basketId.ToString(), null, changedBy, |
| | 1 | 184 | | orderId.ToString()); |
| | 1 | 185 | | await TryTrackOrderedAsync(item.OrderId, orderId.ToString(), basketId.ToString(), changedBy, |
| | 1 | 186 | | item.DeliveryDaysMin, item.DeliveryDaysMax, item.Direction); |
| | 1 | 187 | | } |
| | 1 | 188 | | _cache.Remove(SupplierCartCacheKeys.AvdBasketCount); |
| | 1 | 189 | | messages.Add($"Создан заказ АВД №{orderId}."); |
| | 1 | 190 | | return new(true, messages); |
| | | 191 | | } |
| | 0 | 192 | | catch (AvdApiException ex) |
| | | 193 | | { |
| | 0 | 194 | | _cache.Remove(SupplierCartCacheKeys.AvdBasketCount); |
| | 0 | 195 | | messages.Add($"АВД не оформил заказ: {ex.Message}. Добавленные позиции остались в корзине АВД."); |
| | 0 | 196 | | return new(false, messages); |
| | | 197 | | } |
| | 1 | 198 | | } |
| | | 199 | | |
| | | 200 | | /// <summary> |
| | | 201 | | /// Фиксирует стадию корзины у поставщика. Сбой отслеживания не должен ломать уже выполненное |
| | | 202 | | /// размещение — та же логика, что у TryRecordHistoryAsync. |
| | | 203 | | /// </summary> |
| | | 204 | | private async Task TryTrackCartAsync(int orderId, string cartStatusCode, string changedBy, string? basketItemId, |
| | | 205 | | int? deliveryDaysMin = null, int? deliveryDaysMax = null, string? direction = null) |
| | | 206 | | { |
| | | 207 | | try |
| | | 208 | | { |
| | 4 | 209 | | await _supplierStateService.MarkInCartAsync( |
| | 4 | 210 | | orderId, ProviderKey, cartStatusCode, cartItemId: null, |
| | 4 | 211 | | externalBasketItemId: basketItemId, user: changedBy, |
| | 4 | 212 | | deliveryDaysMin: deliveryDaysMin, deliveryDaysMax: deliveryDaysMax, direction: direction); |
| | 4 | 213 | | } |
| | 0 | 214 | | catch |
| | | 215 | | { |
| | | 216 | | // Отслеживание статуса — вспомогательный журнал, не критичный путь размещения. |
| | 0 | 217 | | } |
| | 4 | 218 | | } |
| | | 219 | | |
| | | 220 | | /// <summary>Фиксирует оформление заказа у АВД. Сбой не должен ломать уже созданный заказ.</summary> |
| | | 221 | | private async Task TryTrackOrderedAsync(int orderId, string externalOrderId, string? basketItemId, string changedBy, |
| | | 222 | | int? deliveryDaysMin = null, int? deliveryDaysMax = null, string? direction = null) |
| | | 223 | | { |
| | | 224 | | try |
| | | 225 | | { |
| | 1 | 226 | | await _supplierStateService.MarkOrderedAsync( |
| | 1 | 227 | | orderId, ProviderKey, externalOrderNumber: externalOrderId, |
| | 1 | 228 | | externalOrderId: externalOrderId, externalLineId: basketItemId, user: changedBy, |
| | 1 | 229 | | deliveryDaysMin: deliveryDaysMin, deliveryDaysMax: deliveryDaysMax, direction: direction); |
| | 1 | 230 | | } |
| | 0 | 231 | | catch |
| | | 232 | | { |
| | | 233 | | // См. TryTrackCartAsync. |
| | 0 | 234 | | } |
| | 1 | 235 | | } |
| | | 236 | | |
| | | 237 | | private static bool TryGetInsertError(string? result, out string error) |
| | | 238 | | { |
| | 8 | 239 | | error = result switch |
| | 8 | 240 | | { |
| | 0 | 241 | | "0" => "АВД: ошибка добавления позиции в корзину.", |
| | 0 | 242 | | "1" => "АВД: не удалось проверить пользователя.", |
| | 0 | 243 | | "2" => "АВД: IP-адрес не разрешён для клиента.", |
| | 0 | 244 | | "3" => "АВД: ошибка аутентификации пользователя.", |
| | 0 | 245 | | "4" => "АВД: hash предложения недействителен.", |
| | 0 | 246 | | "5" => "АВД: ошибка подключения к базе данных.", |
| | 0 | 247 | | "6" => "АВД: количество не соответствует кратности.", |
| | 2 | 248 | | "7" => "АВД: запрошенное количество превышает остаток поставщика.", |
| | 6 | 249 | | _ => string.Empty |
| | 8 | 250 | | }; |
| | | 251 | | |
| | 8 | 252 | | return error.Length > 0; |
| | | 253 | | } |
| | | 254 | | |
| | | 255 | | private async Task<bool> WasSuccessfullyPlacedAsync(int orderId) |
| | | 256 | | { |
| | 8 | 257 | | var history = await _orderHistoryService.GetByOrderIdAsync(orderId); |
| | 8 | 258 | | return history.Any(entry => |
| | 8 | 259 | | { |
| | 3 | 260 | | if (!string.Equals(entry.ColumnName, ApiSupplierBasketHistoryEntry.HistoryColumnName, |
| | 3 | 261 | | StringComparison.Ordinal) || string.IsNullOrWhiteSpace(entry.NewValue)) |
| | 0 | 262 | | return false; |
| | 8 | 263 | | |
| | 8 | 264 | | // Под этим ColumnName пишут все API-поставщики, поэтому идемпотентность АВД должна |
| | 8 | 265 | | // учитывать только собственные записи: успешное размещение в Армтеке не отменяет АВД. |
| | 3 | 266 | | var placement = ApiSupplierBasketHistoryEntry.TryParse(entry.NewValue); |
| | 3 | 267 | | return placement is { Success: true } && |
| | 3 | 268 | | string.Equals(placement.ProviderKey, ProviderKey, StringComparison.Ordinal) && |
| | 3 | 269 | | !TryGetInsertError(placement.ApiItemId, out _); |
| | 8 | 270 | | }); |
| | 8 | 271 | | } |
| | | 272 | | |
| | | 273 | | /// <summary>Пишет запись в orders_history — по тому же безопасному принципу, что и |
| | | 274 | | /// OrderHistoryOneCPostingRecorder: ошибка журнала не должна ломать уже выполненное размещение.</summary> |
| | | 275 | | private async Task TryRecordHistoryAsync( |
| | | 276 | | AvdBasketQueueItem item, |
| | | 277 | | string orderLabel, |
| | | 278 | | bool success, |
| | | 279 | | string? apiItemId, |
| | | 280 | | string? errorMessage, |
| | | 281 | | string changedBy, |
| | | 282 | | string? supplierOrderNumber = null) |
| | | 283 | | { |
| | | 284 | | try |
| | | 285 | | { |
| | 8 | 286 | | var entry = new ApiSupplierBasketHistoryEntry( |
| | 8 | 287 | | ProviderKey, |
| | 8 | 288 | | SupplierDisplayName, |
| | 8 | 289 | | LogoUrl, |
| | 8 | 290 | | success, |
| | 8 | 291 | | errorMessage, |
| | 8 | 292 | | apiItemId, |
| | 8 | 293 | | item.Article, |
| | 8 | 294 | | item.ProductName, |
| | 8 | 295 | | item.Price, |
| | 8 | 296 | | CurrencyCode, |
| | 8 | 297 | | item.Quantity, |
| | 8 | 298 | | orderLabel, |
| | 8 | 299 | | ClientName: item.ClientName, |
| | 8 | 300 | | CartStatus: success ? ApiSupplierBasketCartStatus.Ordered : ApiSupplierBasketCartStatus.Failed, |
| | 8 | 301 | | SupplierOrderNumber: supplierOrderNumber, |
| | 8 | 302 | | SupplierCartUrl: "/SupplierCart?provider=avd&supplierTab=basket"); |
| | | 303 | | |
| | 8 | 304 | | await _orderHistoryService.AddOrderHistory(new OrderHistory |
| | 8 | 305 | | { |
| | 8 | 306 | | OrderId = item.OrderId, |
| | 8 | 307 | | ColumnName = ApiSupplierBasketHistoryEntry.HistoryColumnName, |
| | 8 | 308 | | ColumnDisplayName = $"Корзина {SupplierDisplayName}", |
| | 8 | 309 | | OldValue = null, |
| | 8 | 310 | | NewValue = JsonSerializer.Serialize(entry, ApiSupplierBasketHistoryEntry.JsonOptions), |
| | 8 | 311 | | ChangedAt = DateTime.Now, |
| | 8 | 312 | | ChangedBy = changedBy |
| | 8 | 313 | | }); |
| | 8 | 314 | | } |
| | 0 | 315 | | catch |
| | | 316 | | { |
| | | 317 | | // Не ломаем размещение в корзине из-за журнала истории. |
| | 0 | 318 | | } |
| | 8 | 319 | | } |
| | | 320 | | } |
| | | 321 | | |
| | | 322 | | public sealed class AvdBasketPlacementResult |
| | | 323 | | { |
| | | 324 | | public IReadOnlyCollection<AvdBasketPlacementEntry> Entries { get; } |
| | | 325 | | |
| | | 326 | | public int SuccessCount => Entries.Count(e => e.Status == AvdBasketPlacementStatus.Added); |
| | | 327 | | public int FailedCount => Entries.Count(e => e.Status == AvdBasketPlacementStatus.Failed); |
| | | 328 | | public int SkippedCount => Entries.Count(e => e.Status == AvdBasketPlacementStatus.Skipped); |
| | | 329 | | |
| | | 330 | | public AvdBasketPlacementResult(IReadOnlyCollection<AvdBasketPlacementEntry> entries) => Entries = entries; |
| | | 331 | | |
| | | 332 | | public static AvdBasketPlacementResult Empty() => new(Array.Empty<AvdBasketPlacementEntry>()); |
| | | 333 | | |
| | | 334 | | public string ToHtmlBlock() |
| | | 335 | | { |
| | | 336 | | if (Entries.Count == 0) |
| | | 337 | | return string.Empty; |
| | | 338 | | |
| | | 339 | | var rows = Entries |
| | | 340 | | .Take(20) |
| | | 341 | | .Select(e => $"<li><b>{WebUtility.HtmlEncode(e.Label)}</b>: {WebUtility.HtmlEncode(e.Message)}</li>"); |
| | | 342 | | |
| | | 343 | | var hiddenCount = Math.Max(0, Entries.Count - 20); |
| | | 344 | | var hiddenTail = hiddenCount > 0 ? $"<li>... и еще {hiddenCount}</li>" : string.Empty; |
| | | 345 | | |
| | | 346 | | return "<div class='alert alert-info border-primary-subtle mb-0'>" + |
| | | 347 | | "<div class='fw-semibold mb-1'><i class='fas fa-cart-shopping me-1 text-primary'></i>Корзина АВД</div>" + |
| | | 348 | | $"<div class='small mb-1'>Добавлено: <b>{SuccessCount}</b>, уже было в корзине: <b>{SkippedCount}</b>, с |
| | | 349 | | $"<ul class='mb-0 small'>{string.Join(string.Empty, rows)}{hiddenTail}</ul>" + |
| | | 350 | | "</div>"; |
| | | 351 | | } |
| | | 352 | | } |
| | | 353 | | |
| | | 354 | | public enum AvdBasketPlacementStatus |
| | | 355 | | { |
| | | 356 | | Added, |
| | | 357 | | Failed, |
| | | 358 | | Skipped |
| | | 359 | | } |
| | | 360 | | |
| | | 361 | | public sealed record AvdBasketPlacementEntry(string Label, AvdBasketPlacementStatus Status, string Message) |
| | | 362 | | { |
| | | 363 | | public static AvdBasketPlacementEntry Success(string label, string message) => new(label, AvdBasketPlacementStatus.A |
| | | 364 | | public static AvdBasketPlacementEntry Failed(string label, string message) => new(label, AvdBasketPlacementStatus.Fa |
| | | 365 | | public static AvdBasketPlacementEntry Skipped(string label, string message) => new(label, AvdBasketPlacementStatus.S |
| | | 366 | | } |