| | | 1 | | using System.Globalization; |
| | | 2 | | using Newtonsoft.Json.Linq; |
| | | 3 | | using OzonDomains; |
| | | 4 | | using Services.ApiServices.TradesoftApi.Models.Response; |
| | | 5 | | using Services.ProducerAliases; |
| | | 6 | | |
| | | 7 | | namespace Services.ApiServices.BergApi; |
| | | 8 | | |
| | | 9 | | public static class BergPriceMapper |
| | | 10 | | { |
| | | 11 | | public static IReadOnlyList<PreOrderItem> Map(JToken response, int? preferredSupplierId, CurrencyCode? preferredSupp |
| | | 12 | | DateTime? today = null, string? deliveryDestination = null, string? requestedBrand = null, |
| | | 13 | | ProducerBrandAliasMap? brandAliases = null, bool deliveryMappingLinked = false, |
| | | 14 | | string? deliverySettingsUrl = null) |
| | | 15 | | { |
| | 9 | 16 | | var aliases = brandAliases ?? ProducerBrandAliasMap.Empty; |
| | 9 | 17 | | var items = new List<PreOrderItem>(); |
| | 40 | 18 | | foreach (var resource in response["resources"]?.OfType<JObject>() ?? Enumerable.Empty<JObject>()) |
| | | 19 | | { |
| | 11 | 20 | | var article = resource.Value<string>("article") ?? string.Empty; |
| | 11 | 21 | | var producer = resource.SelectToken("brand.name")?.Value<string>() ?? string.Empty; |
| | 11 | 22 | | if (!string.IsNullOrWhiteSpace(requestedBrand) && !BrandMatches(producer, requestedBrand, aliases)) |
| | | 23 | | continue; |
| | 7 | 24 | | var caption = resource.Value<string>("name") ?? article; |
| | | 25 | | // id карточки нужен для заказа; без него предложение остаётся только справочным, |
| | | 26 | | // а ItemId продолжает опираться на артикул, как раньше. |
| | 7 | 27 | | var orderableResourceId = resource.Value<long?>("id"); |
| | 7 | 28 | | var resourceId = orderableResourceId?.ToString(CultureInfo.InvariantCulture) ?? article; |
| | 28 | 29 | | foreach (var offer in resource["offers"]?.OfType<JObject>() ?? Enumerable.Empty<JObject>()) |
| | | 30 | | { |
| | 7 | 31 | | var price = offer.Value<decimal?>("price"); |
| | 7 | 32 | | if (price is null) continue; |
| | | 33 | | |
| | 7 | 34 | | var warehouseName = offer.SelectToken("warehouse.name")?.Value<string>() ?? "Berg"; |
| | 7 | 35 | | var warehouseId = offer.SelectToken("warehouse.id")?.Value<string>() ?? string.Empty; |
| | 7 | 36 | | var assuredDays = offer.Value<int?>("assured_period") ?? 0; |
| | 7 | 37 | | var averageDays = offer.Value<int?>("average_period") ?? assuredDays; |
| | 7 | 38 | | var deliveryDays = ResolveDeliveryDays(offer, assuredDays, averageDays, today ?? DateTime.Today); |
| | 7 | 39 | | var quantity = offer.Value<decimal?>("quantity") ?? 0m; |
| | 7 | 40 | | var multiplication = offer.Value<int?>("multiplication_factor") ?? 0; |
| | 7 | 41 | | var itemId = string.Join("|", resourceId, warehouseId, price.Value.ToString(CultureInfo.InvariantCulture |
| | 7 | 42 | | var description = string.Join("; ", new[] |
| | 7 | 43 | | { |
| | 7 | 44 | | $"Артикул: {article}", $"Бренд: {producer}", $"Склад: {warehouseName}", |
| | 7 | 45 | | $"Наличие: {quantity.ToString("0.##", CultureInfo.InvariantCulture)}", |
| | 7 | 46 | | $"Средний срок: {deliveryDays.Min} д.", $"Гарантированный срок: {deliveryDays.Max} д.", |
| | 7 | 47 | | $"Надёжность: {offer.Value<int?>("reliability")?.ToString(CultureInfo.InvariantCulture) ?? "не указа |
| | 7 | 48 | | offer.Value<bool?>("is_transit") == true ? "Транзит: да" : "Транзит: нет", |
| | 7 | 49 | | offer.Value<bool?>("available_more") == true ? "Доступно больше указанного остатка" : null |
| | 70 | 50 | | }.Where(x => !string.IsNullOrWhiteSpace(x))); |
| | | 51 | | |
| | 7 | 52 | | items.Add(new PreOrderItem |
| | 7 | 53 | | { |
| | 7 | 54 | | Caption = caption, |
| | 7 | 55 | | Producer = producer, |
| | 7 | 56 | | Code = article, |
| | 7 | 57 | | Rest = quantity.ToString("0.##", CultureInfo.InvariantCulture), |
| | 7 | 58 | | MinQuantity = multiplication, |
| | 7 | 59 | | DeliveryDays = deliveryDays.Min.ToString(CultureInfo.InvariantCulture), |
| | 7 | 60 | | DeliveryDaysMin = deliveryDays.Min, |
| | 7 | 61 | | DeliveryDaysMax = deliveryDays.Max, |
| | 7 | 62 | | Price = price.Value.ToString("0.##", CultureInfo.InvariantCulture), |
| | 7 | 63 | | CostPrice = price.Value.ToString("0.##", CultureInfo.InvariantCulture), |
| | 7 | 64 | | CostPriceFormatted = price.Value.ToString("0.##", CultureInfo.InvariantCulture), |
| | 7 | 65 | | CurrencyCode = "RUB", |
| | 7 | 66 | | SourcePrice = price, |
| | 7 | 67 | | SourceCurrencyCode = "RUB", |
| | 7 | 68 | | Direction = warehouseName, |
| | 7 | 69 | | Title = "Берг", |
| | 7 | 70 | | SiteUrl = "https://berg.ru/", |
| | 7 | 71 | | ItemId = itemId, |
| | 7 | 72 | | ItemHash = itemId.GetHashCode(StringComparison.Ordinal).ToString("X", CultureInfo.InvariantCulture), |
| | 7 | 73 | | Description = description, |
| | 7 | 74 | | PriceDescription = $"Цена Berg: {price.Value:0.##} RUB", |
| | 7 | 75 | | PreferredSupplierId = preferredSupplierId, |
| | 7 | 76 | | PreferredSupplierCurrencyCode = preferredSupplierCurrency?.ToString(), |
| | 7 | 77 | | // providerKey включает предложение в корзину и заказ через API: фронтенд |
| | 7 | 78 | | // определяет поставщика строки только по нему. resourceId/warehouseId — адрес |
| | 7 | 79 | | // предложения для place_order, чтобы JS не разбирал ItemId строкой. |
| | 7 | 80 | | ProviderData = new Dictionary<string, string?> |
| | 7 | 81 | | { |
| | 7 | 82 | | ["providerKey"] = "Berg", |
| | 7 | 83 | | ["resourceId"] = orderableResourceId?.ToString(CultureInfo.InvariantCulture), |
| | 7 | 84 | | ["warehouseId"] = string.IsNullOrWhiteSpace(warehouseId) ? null : warehouseId, |
| | 7 | 85 | | ["deliveryDestination"] = string.IsNullOrWhiteSpace(deliveryDestination) ? null : deliveryDestin |
| | 7 | 86 | | // Без связи «склад отгрузки → адрес Berg» place_order отправить нельзя, |
| | 7 | 87 | | // поэтому автоподбор такие предложения пропускает. |
| | 7 | 88 | | ["deliveryMappingLinked"] = deliveryMappingLinked ? "true" : "false", |
| | 7 | 89 | | ["deliverySettingsUrl"] = deliverySettingsUrl |
| | 7 | 90 | | } |
| | 7 | 91 | | }); |
| | | 92 | | } |
| | | 93 | | } |
| | 9 | 94 | | return items; |
| | | 95 | | } |
| | | 96 | | |
| | | 97 | | // Совпадение бренда проверяется в трёх видах, потому что одно и то же имя приходит по-разному: |
| | | 98 | | // 1) алиасы (TecDoc "Kayaba" против Berg "KYB") — словарь OneCProducerAliases; |
| | | 99 | | // 2) транслитерация (TecDoc "LUKOIL" против Berg "ЛУКОЙЛ"); |
| | | 100 | | // 3) простая нормализация (регистр, пробелы, дефисы). |
| | | 101 | | private static bool BrandMatches(string producer, string requestedBrand, ProducerBrandAliasMap aliases) |
| | | 102 | | { |
| | 8 | 103 | | if (string.Equals(producer, requestedBrand, StringComparison.OrdinalIgnoreCase)) |
| | 1 | 104 | | return true; |
| | | 105 | | |
| | 7 | 106 | | var canonicalRequested = aliases.Canonicalize(requestedBrand); |
| | 7 | 107 | | if (canonicalRequested.Length == 0) |
| | 0 | 108 | | return false; |
| | | 109 | | |
| | 7 | 110 | | return aliases.Canonicalize(producer) == canonicalRequested |
| | 7 | 111 | | || aliases.Canonicalize(Transliterate(producer, iotaAsY: true)) == canonicalRequested |
| | 7 | 112 | | || aliases.Canonicalize(Transliterate(producer, iotaAsY: false)) == canonicalRequested; |
| | | 113 | | } |
| | | 114 | | |
| | 1 | 115 | | private static readonly Dictionary<char, string> CyrillicToLatin = new() |
| | 1 | 116 | | { |
| | 1 | 117 | | ['А'] = "A", ['Б'] = "B", ['В'] = "V", ['Г'] = "G", ['Д'] = "D", ['Е'] = "E", ['Ё'] = "E", |
| | 1 | 118 | | ['Ж'] = "ZH", ['З'] = "Z", ['И'] = "I", ['К'] = "K", ['Л'] = "L", ['М'] = "M", ['Н'] = "N", |
| | 1 | 119 | | ['О'] = "O", ['П'] = "P", ['Р'] = "R", ['С'] = "S", ['Т'] = "T", ['У'] = "U", ['Ф'] = "F", |
| | 1 | 120 | | ['Х'] = "KH", ['Ц'] = "TS", ['Ч'] = "CH", ['Ш'] = "SH", ['Щ'] = "SHCH", ['Ъ'] = "", ['Ы'] = "Y", |
| | 1 | 121 | | ['Ь'] = "", ['Э'] = "E", ['Ю'] = "YU", ['Я'] = "YA" |
| | 1 | 122 | | }; |
| | | 123 | | |
| | | 124 | | /// <summary>Й транслитерируется по-разному ("май" → "may", но "Лукойл" → "Lukoil"), поэтому |
| | | 125 | | /// пробуем оба варианта, а не жёстко фиксируем один.</summary> |
| | | 126 | | private static string Transliterate(string value, bool iotaAsY) |
| | | 127 | | { |
| | 10 | 128 | | var builder = new System.Text.StringBuilder(value.Length); |
| | 108 | 129 | | foreach (var ch in value.ToUpperInvariant()) |
| | | 130 | | { |
| | 48 | 131 | | if (ch == 'Й') { builder.Append(iotaAsY ? "Y" : "I"); continue; } |
| | 42 | 132 | | builder.Append(CyrillicToLatin.TryGetValue(ch, out var mapped) ? mapped : ch.ToString()); |
| | | 133 | | } |
| | 10 | 134 | | return builder.ToString(); |
| | | 135 | | } |
| | | 136 | | |
| | | 137 | | private static (int Min, int Max) ResolveDeliveryDays(JObject offer, int assuredDays, int averageDays, DateTime toda |
| | | 138 | | { |
| | 7 | 139 | | var schedule = offer["address_timetable"]?.OfType<JObject>().FirstOrDefault(); |
| | 7 | 140 | | var deliveryFrom = schedule?.Value<string>("delivery_from"); |
| | 7 | 141 | | var deliveryTo = schedule?.Value<string>("delivery_to"); |
| | 7 | 142 | | if (DateTime.TryParse(deliveryFrom, CultureInfo.InvariantCulture, DateTimeStyles.AssumeLocal, out var from)) |
| | | 143 | | { |
| | 1 | 144 | | var min = Math.Max(0, (from.Date - today.Date).Days); |
| | 1 | 145 | | var max = DateTime.TryParse(deliveryTo, CultureInfo.InvariantCulture, DateTimeStyles.AssumeLocal, out var to |
| | 1 | 146 | | ? Math.Max(min, (to.Date - today.Date).Days) |
| | 1 | 147 | | : min; |
| | 1 | 148 | | return (min, max); |
| | | 149 | | } |
| | | 150 | | |
| | 6 | 151 | | return (assuredDays, Math.Max(assuredDays, averageDays)); |
| | | 152 | | } |
| | | 153 | | } |