| | | 1 | | using System.Text.Encodings.Web; |
| | | 2 | | using System.Text.Json; |
| | | 3 | | using System.Text.Unicode; |
| | | 4 | | |
| | | 5 | | namespace OzonOrdersWeb.TransactionServices; |
| | | 6 | | |
| | | 7 | | /// <summary>Статус позиции в корзине поставщика на момент записи в историю.</summary> |
| | | 8 | | public static class ApiSupplierBasketCartStatus |
| | | 9 | | { |
| | | 10 | | public const string Pending = "Pending"; |
| | | 11 | | public const string Ready = "Ready"; |
| | | 12 | | public const string Ordered = "Ordered"; |
| | | 13 | | public const string Failed = "Failed"; |
| | | 14 | | } |
| | | 15 | | |
| | | 16 | | /// <summary> |
| | | 17 | | /// Запись в orders_history о размещении позиции в корзине API-поставщика. Форма общая |
| | | 18 | | /// (ProviderKey/LogoUrl), чтобы таймлайн истории заказа умел показывать любого поставщика с реальным |
| | | 19 | | /// API-заказом, не только АВД. |
| | | 20 | | /// </summary> |
| | | 21 | | /// <remarks> |
| | | 22 | | /// Поля после <paramref name="ShipmentNumber"/> добавлены позже и обязаны оставаться nullable |
| | | 23 | | /// со значением по умолчанию: позиционные вызовы и уже записанные в БД строки не должны ломаться. |
| | | 24 | | /// </remarks> |
| | 118 | 25 | | public sealed record ApiSupplierBasketHistoryEntry( |
| | 122 | 26 | | string ProviderKey, |
| | 145 | 27 | | string SupplierDisplayName, |
| | 137 | 28 | | string LogoUrl, |
| | 116 | 29 | | bool Success, |
| | 132 | 30 | | string? ErrorMessage, |
| | 108 | 31 | | string? ApiItemId, |
| | 144 | 32 | | string? Article, |
| | 144 | 33 | | string? ProductName, |
| | 105 | 34 | | decimal? Price, |
| | 105 | 35 | | string? CurrencyCode, |
| | 106 | 36 | | decimal? Quantity, |
| | 106 | 37 | | string? ShipmentNumber, |
| | 105 | 38 | | string? ClientName = null, |
| | 105 | 39 | | string? WarehouseName = null, |
| | 105 | 40 | | string? BuyerKunnr = null, |
| | 105 | 41 | | string? DeliveryAddressName = null, |
| | 110 | 42 | | string? CartStatus = null, |
| | 109 | 43 | | string? SupplierOrderNumber = null, |
| | 222 | 44 | | string? SupplierCartUrl = null) |
| | | 45 | | { |
| | | 46 | | /// <summary>Маркер в orders_history.ColumnName — по нему таймлайн истории заказа распознаёт эту запись |
| | | 47 | | /// и не путает её с обычным изменением поля заказа (rollback тоже игнорирует нераспознанные ColumnName).</summary> |
| | | 48 | | public const string HistoryColumnName = "ApiSupplierBasketPlacement"; |
| | | 49 | | |
| | | 50 | | // По умолчанию System.Text.Json экранирует кириллицу как \uXXXX — в БД должен лежать читаемый текст. |
| | 1 | 51 | | public static readonly JsonSerializerOptions JsonOptions = new() |
| | 1 | 52 | | { |
| | 1 | 53 | | Encoder = JavaScriptEncoder.Create(UnicodeRanges.All) |
| | 1 | 54 | | }; |
| | | 55 | | |
| | 1 | 56 | | private static readonly JsonSerializerOptions ReadOptions = new() { PropertyNameCaseInsensitive = true }; |
| | | 57 | | |
| | | 58 | | /// <summary> |
| | | 59 | | /// Разбирает NewValue из истории. Здесь и только здесь живёт совместимость со старыми форматами: |
| | | 60 | | /// до объединения Armtek писал под тем же ColumnName свой набор полей (pin/brand/error) и без |
| | | 61 | | /// названия поставщика с логотипом. |
| | | 62 | | /// </summary> |
| | | 63 | | public static ApiSupplierBasketHistoryEntry? TryParse(string? json) |
| | | 64 | | { |
| | 17 | 65 | | if (string.IsNullOrWhiteSpace(json)) |
| | 2 | 66 | | return null; |
| | | 67 | | |
| | | 68 | | ApiSupplierBasketHistoryEntry? entry; |
| | | 69 | | try |
| | | 70 | | { |
| | 15 | 71 | | entry = JsonSerializer.Deserialize<ApiSupplierBasketHistoryEntry>(json, ReadOptions); |
| | 13 | 72 | | } |
| | 2 | 73 | | catch (JsonException) |
| | | 74 | | { |
| | 2 | 75 | | return null; |
| | | 76 | | } |
| | | 77 | | |
| | 13 | 78 | | if (entry is null) |
| | 0 | 79 | | return null; |
| | | 80 | | |
| | 13 | 81 | | var legacy = ReadLegacyFields(json); |
| | 13 | 82 | | return entry with |
| | 13 | 83 | | { |
| | 13 | 84 | | SupplierDisplayName = string.IsNullOrWhiteSpace(entry.SupplierDisplayName) |
| | 13 | 85 | | ? ResolveDisplayName(entry.ProviderKey) |
| | 13 | 86 | | : entry.SupplierDisplayName, |
| | 13 | 87 | | LogoUrl = string.IsNullOrWhiteSpace(entry.LogoUrl) ? ResolveLogoUrl(entry.ProviderKey) : entry.LogoUrl, |
| | 13 | 88 | | Article = string.IsNullOrWhiteSpace(entry.Article) ? legacy.Pin : entry.Article, |
| | 13 | 89 | | ProductName = string.IsNullOrWhiteSpace(entry.ProductName) ? legacy.Brand : entry.ProductName, |
| | 13 | 90 | | ErrorMessage = string.IsNullOrWhiteSpace(entry.ErrorMessage) ? legacy.Error : entry.ErrorMessage |
| | 13 | 91 | | }; |
| | 2 | 92 | | } |
| | | 93 | | |
| | 2 | 94 | | public static string ResolveDisplayName(string? providerKey) => providerKey switch |
| | 2 | 95 | | { |
| | 0 | 96 | | "Avd" => "АВД", |
| | 1 | 97 | | "Armtek" => "Армтек RU", |
| | 1 | 98 | | "ArmtekBy" => "Армтек BY", |
| | 0 | 99 | | "Japarts" => "Japarts", |
| | 0 | 100 | | "Berg" => "Берг", |
| | 0 | 101 | | "ZZapMoscow" => "ZZap Москва", |
| | 0 | 102 | | _ => string.IsNullOrWhiteSpace(providerKey) ? "API-поставщик" : providerKey |
| | 2 | 103 | | }; |
| | | 104 | | |
| | 11 | 105 | | public static string ResolveLogoUrl(string? providerKey) => providerKey switch |
| | 11 | 106 | | { |
| | 5 | 107 | | "Armtek" or "ArmtekBy" => "/images/armtek-logo.svg", |
| | 1 | 108 | | "Avd" => "/images/avd-logo.svg", |
| | 0 | 109 | | "Japarts" => "/images/japarts-logo.jpg", |
| | 0 | 110 | | "Berg" => "/images/berg-logo-2026.png", |
| | 0 | 111 | | "ZZapMoscow" => "/images/zzap-logo.svg", |
| | 5 | 112 | | _ => string.Empty |
| | 11 | 113 | | }; |
| | | 114 | | |
| | | 115 | | /// <summary>Плоский текст для data-new: фильтры истории заказа ищут по нему, иначе строка пропадает при поиске.</su |
| | 1 | 116 | | public string BuildFilterText() => string.Join(" ", new[] |
| | 1 | 117 | | { |
| | 1 | 118 | | SupplierDisplayName, Article, ProductName, ShipmentNumber, ClientName, |
| | 1 | 119 | | WarehouseName, BuyerKunnr, DeliveryAddressName, SupplierOrderNumber, ApiItemId, ErrorMessage |
| | 1 | 120 | | } |
| | 12 | 121 | | .Where(x => !string.IsNullOrWhiteSpace(x))); |
| | | 122 | | |
| | | 123 | | private static (string? Pin, string? Brand, string? Error) ReadLegacyFields(string json) |
| | | 124 | | { |
| | | 125 | | try |
| | | 126 | | { |
| | 13 | 127 | | using var document = JsonDocument.Parse(json); |
| | 13 | 128 | | if (document.RootElement.ValueKind != JsonValueKind.Object) |
| | 0 | 129 | | return (null, null, null); |
| | 13 | 130 | | return (ReadString(document.RootElement, "pin"), ReadString(document.RootElement, "brand"), |
| | 13 | 131 | | ReadString(document.RootElement, "error")); |
| | | 132 | | } |
| | 0 | 133 | | catch (JsonException) |
| | | 134 | | { |
| | 0 | 135 | | return (null, null, null); |
| | | 136 | | } |
| | 13 | 137 | | } |
| | | 138 | | |
| | | 139 | | // Старые payload-ы писали Pin/Brand с большой буквы, а error — с маленькой, поэтому имя ищем без учёта регистра. |
| | 39 | 140 | | private static string? ReadString(JsonElement root, string name) => root.EnumerateObject() |
| | 633 | 141 | | .Where(x => string.Equals(x.Name, name, StringComparison.OrdinalIgnoreCase) && x.Value.ValueKind == JsonValueKin |
| | 5 | 142 | | .Select(x => x.Value.GetString()) |
| | 39 | 143 | | .FirstOrDefault(); |
| | | 144 | | } |