| | | 1 | | using System.Globalization; |
| | | 2 | | using Newtonsoft.Json.Linq; |
| | | 3 | | using Services.ApiServices.TradesoftApi.Models.Response; |
| | | 4 | | |
| | | 5 | | namespace Services.ApiServices.ProfitLigaApi; |
| | | 6 | | |
| | | 7 | | /// <summary>Разворачивает ответ поиска Профит-Лиги в строки общей таблицы проценки. |
| | | 8 | | /// Цены уже в рублях, поэтому конвертация не нужна — исходная цена дублируется в SourcePrice.</summary> |
| | | 9 | | /// <summary>Торговая точка, в которую уедет заказ по этому предложению: связь «склад отгрузки → точка» |
| | | 10 | | /// из настроек API-поставщика. SettingsUrl ведёт на строку этой связи.</summary> |
| | 9 | 11 | | public sealed record ProfitLigaQuotationDestination(string? DisplayName, string? SettingsUrl); |
| | | 12 | | |
| | | 13 | | public static class ProfitLigaPriceMapper |
| | | 14 | | { |
| | | 15 | | public const string ProviderKey = "ProfitLiga"; |
| | | 16 | | public const string SupplierTitle = "Профит-Лига"; |
| | | 17 | | public const string SupplierSiteUrl = "https://pr-lg.ru/"; |
| | | 18 | | |
| | | 19 | | /// <summary>Ключи ProviderData, без которых позицию не положить в корзину поставщика.</summary> |
| | | 20 | | public const string ArticleIdKey = "profitLigaArticleId"; |
| | | 21 | | public const string WarehouseIdKey = "profitLigaWarehouseId"; |
| | | 22 | | public const string ProductCodeKey = "profitLigaProductCode"; |
| | | 23 | | public const string MultiKey = "profitLigaMulti"; |
| | | 24 | | |
| | | 25 | | /// <summary>Предложения только по запрошенному артикулу и бренду: контракт проценки запрещает |
| | | 26 | | /// отдавать замены, а карточка ответа может относиться к другому товару.</summary> |
| | | 27 | | public static IReadOnlyList<PreOrderItem> MapOriginals(JToken? response, string article, string? brand, |
| | | 28 | | int? preferredSupplierId, string? preferredSupplierCurrencyCode, |
| | | 29 | | ProfitLigaQuotationDestination? destination = null) |
| | | 30 | | { |
| | | 31 | | var wantedArticle = Normalize(article); |
| | | 32 | | var wantedBrand = Normalize(brand); |
| | | 33 | | |
| | | 34 | | var items = new List<PreOrderItem>(); |
| | | 35 | | foreach (var card in ProfitLigaJson.Objects(response)) |
| | | 36 | | { |
| | | 37 | | var cardArticle = ProfitLigaJson.String(card, "article"); |
| | | 38 | | var cardBrand = ProfitLigaJson.String(card, "brand"); |
| | | 39 | | if (Normalize(cardArticle) != wantedArticle) continue; |
| | | 40 | | if (wantedBrand.Length > 0 && Normalize(cardBrand) != wantedBrand) continue; |
| | | 41 | | |
| | | 42 | | foreach (var (key, offer) in ProfitLigaJson.KeyedObjects(card["products"])) |
| | | 43 | | { |
| | | 44 | | var mapped = MapOffer(offer, key, cardArticle, cardBrand, |
| | | 45 | | ProfitLigaJson.String(card, "description"), |
| | | 46 | | preferredSupplierId, preferredSupplierCurrencyCode, destination); |
| | | 47 | | if (mapped is not null) items.Add(mapped); |
| | | 48 | | } |
| | | 49 | | } |
| | | 50 | | |
| | | 51 | | return items; |
| | | 52 | | } |
| | | 53 | | |
| | | 54 | | private static PreOrderItem? MapOffer(JObject offer, string? offerKey, string? cardArticle, string? cardBrand, |
| | | 55 | | string? cardDescription, int? preferredSupplierId, string? preferredSupplierCurrencyCode, |
| | | 56 | | ProfitLigaQuotationDestination? destination) |
| | | 57 | | { |
| | | 58 | | var price = ProfitLigaJson.Decimal(offer, "price"); |
| | | 59 | | if (price is null || price.Value <= 0m) return null; |
| | | 60 | | |
| | | 61 | | var quantity = ProfitLigaJson.Decimal(offer, "quantity") ?? 0m; |
| | | 62 | | var articleId = ProfitLigaJson.String(offer, "article_id") ?? string.Empty; |
| | | 63 | | var warehouseId = ProfitLigaJson.String(offer, "warehouse_id") ?? string.Empty; |
| | | 64 | | var productCode = ProfitLigaJson.String(offer, "product_code"); |
| | | 65 | | var warehouseName = ProfitLigaJson.String(offer, "custom_warehouse_name"); |
| | | 66 | | var multi = ProfitLigaJson.Int(offer, "multi") ?? 0; |
| | | 67 | | var days = ToDeliveryDays(ProfitLigaJson.Int(offer, "delivery_time")); |
| | | 68 | | var article = ProfitLigaJson.String(offer, "article") ?? cardArticle ?? string.Empty; |
| | | 69 | | var brand = ProfitLigaJson.String(offer, "brand") ?? cardBrand; |
| | | 70 | | |
| | | 71 | | return new PreOrderItem |
| | | 72 | | { |
| | | 73 | | // В прайсовой строке поставщика наименование искажено (1/4" приходит как 1 4&quot,.), |
| | | 74 | | // поэтому основным берём описание карточки. |
| | | 75 | | Caption = DecodeHtml(cardDescription) ?? DecodeHtml(ProfitLigaJson.String(offer, "description")) ?? article, |
| | | 76 | | Producer = brand, |
| | | 77 | | Code = article, |
| | | 78 | | Rest = quantity.ToString("0.##", CultureInfo.InvariantCulture), |
| | | 79 | | MinQuantity = multi, |
| | | 80 | | DeliveryDays = days.ToString(CultureInfo.InvariantCulture), |
| | | 81 | | DeliveryDaysMin = days, |
| | | 82 | | DeliveryDaysMax = days, |
| | | 83 | | Price = price.Value.ToString("0.##", CultureInfo.InvariantCulture), |
| | | 84 | | CostPrice = price.Value.ToString("0.##", CultureInfo.InvariantCulture), |
| | | 85 | | CostPriceFormatted = $"{price.Value.ToString("0.##", CultureInfo.InvariantCulture)} RUB", |
| | | 86 | | CurrencyCode = "RUB", |
| | | 87 | | SourcePrice = price.Value, |
| | | 88 | | SourceCurrencyCode = "RUB", |
| | | 89 | | Direction = string.IsNullOrWhiteSpace(warehouseName) ? warehouseId : warehouseName, |
| | | 90 | | Title = SupplierTitle, |
| | | 91 | | SiteUrl = SupplierSiteUrl, |
| | | 92 | | ItemId = ProfitLigaJson.BuildCartKey(articleId, warehouseId), |
| | | 93 | | // Ключ словаря products — устойчивый хеш предложения на стороне поставщика. |
| | | 94 | | ItemHash = string.IsNullOrWhiteSpace(offerKey) |
| | | 95 | | ? ProfitLigaJson.BuildCartKey(articleId, warehouseId) |
| | | 96 | | : offerKey, |
| | | 97 | | Description = BuildDescription(offer, multi, warehouseName), |
| | | 98 | | PriceDescription = $"Цена Профит-Лиги: {price.Value.ToString("0.##", CultureInfo.InvariantCulture)} RUB", |
| | | 99 | | PreferredSupplierId = preferredSupplierId, |
| | | 100 | | PreferredSupplierCurrencyCode = preferredSupplierCurrencyCode, |
| | | 101 | | ProviderData = new Dictionary<string, string?> |
| | | 102 | | { |
| | | 103 | | // Без providerKey фронт не свяжет предложение с корзиной поставщика. |
| | | 104 | | ["providerKey"] = ProviderKey, |
| | | 105 | | ["warehouseName"] = warehouseName, |
| | | 106 | | [ArticleIdKey] = articleId, |
| | | 107 | | [WarehouseIdKey] = warehouseId, |
| | | 108 | | [ProductCodeKey] = productCode, |
| | | 109 | | [MultiKey] = multi > 0 ? multi.ToString(CultureInfo.InvariantCulture) : null, |
| | | 110 | | // Колонка «Точка доставки» в таблице проценки — общие для всех поставщиков ключи. |
| | | 111 | | ["deliveryDestination"] = destination?.DisplayName, |
| | | 112 | | ["deliveryMappingAddress"] = destination?.DisplayName, |
| | | 113 | | ["deliveryMappingLinked"] = destination?.DisplayName is null ? "false" : "true", |
| | | 114 | | ["deliveryMappingSettingsUrl"] = destination?.SettingsUrl |
| | | 115 | | } |
| | | 116 | | }; |
| | | 117 | | } |
| | | 118 | | |
| | | 119 | | /// <summary>API отдаёт срок в часах; общая таблица проценки оперирует днями, |
| | | 120 | | /// а часовую надбавку поставщика добавляет SupplierDeliverySurchargeFilter.</summary> |
| | | 121 | | public static int ToDeliveryDays(int? deliveryHours) => |
| | | 122 | | deliveryHours is null or <= 0 ? 0 : (int)Math.Ceiling(deliveryHours.Value / 24m); |
| | | 123 | | |
| | | 124 | | private static string BuildDescription(JObject offer, int multi, string? warehouseName) |
| | | 125 | | { |
| | | 126 | | var parts = new List<string>(); |
| | | 127 | | if (!string.IsNullOrWhiteSpace(warehouseName)) parts.Add($"Склад: {warehouseName}"); |
| | | 128 | | if (multi > 1) parts.Add($"Кратность: {multi}"); |
| | | 129 | | |
| | | 130 | | var showDate = ProfitLigaJson.String(offer, "show_date"); |
| | | 131 | | if (!string.IsNullOrWhiteSpace(showDate)) parts.Add($"Поставка на склад: {showDate}"); |
| | | 132 | | |
| | | 133 | | var deliveryDate = FormatDeliveryDate(ProfitLigaJson.String(offer, "delivery_date")); |
| | | 134 | | if (deliveryDate is not null) |
| | | 135 | | // Дата считается от точки, выставленной в аккаунте поставщика (POST /cart/point), а её |
| | | 136 | | // в проценке не трогаем — иначе параллельные запросы перебивали бы точку друг у друга. |
| | | 137 | | parts.Add($"Доставка до точки: {deliveryDate} (для точки по умолчанию в аккаунте)"); |
| | | 138 | | |
| | | 139 | | var probability = ProfitLigaJson.Decimal(offer, "delivery_probability"); |
| | | 140 | | if (probability is not null) parts.Add($"Вероятность поставки: {probability.Value.ToString("0.#", CultureInfo.In |
| | | 141 | | |
| | | 142 | | if (ProfitLigaJson.Bool(offer, "allow_return")) |
| | | 143 | | { |
| | | 144 | | var returnDays = ProfitLigaJson.Int(offer, "return_days") ?? 0; |
| | | 145 | | parts.Add(returnDays > 0 ? $"Возврат: до {returnDays} дн." : "Возврат: разрешён"); |
| | | 146 | | } |
| | | 147 | | |
| | | 148 | | var waitings = ProfitLigaJson.Decimal(offer, "waitings"); |
| | | 149 | | if (waitings is not null && waitings.Value > 0m) |
| | | 150 | | parts.Add($"Ожидается на склад: {waitings.Value.ToString("0.##", CultureInfo.InvariantCulture)}"); |
| | | 151 | | |
| | | 152 | | if (ProfitLigaJson.Bool(offer, "sale")) |
| | | 153 | | { |
| | | 154 | | var reason = ProfitLigaJson.String(offer, "comment"); |
| | | 155 | | parts.Add(string.IsNullOrWhiteSpace(reason) ? "Уценённый товар" : $"Уценка: {reason}"); |
| | | 156 | | } |
| | | 157 | | |
| | | 158 | | return string.Join("; ", parts); |
| | | 159 | | } |
| | | 160 | | |
| | | 161 | | /// <summary>API отдаёт дату строкой «2026-08-22 08:00:00»; в подсказке нужен привычный формат.</summary> |
| | | 162 | | private static string? FormatDeliveryDate(string? value) |
| | | 163 | | { |
| | | 164 | | if (string.IsNullOrWhiteSpace(value)) return null; |
| | | 165 | | return DateTime.TryParse(value, CultureInfo.InvariantCulture, DateTimeStyles.None, out var parsed) |
| | | 166 | | ? parsed.ToString("dd.MM.yyyy HH:mm", CultureInfo.InvariantCulture) |
| | | 167 | | : value.Trim(); |
| | | 168 | | } |
| | | 169 | | |
| | | 170 | | /// <summary>Наименования приходят с HTML-сущностями, а в строках прайса — с двойным |
| | | 171 | | /// экранированием (&amp;quot;). Двух проходов достаточно для обоих случаев.</summary> |
| | | 172 | | private static string? DecodeHtml(string? value) |
| | | 173 | | { |
| | | 174 | | if (string.IsNullOrWhiteSpace(value)) return null; |
| | | 175 | | |
| | | 176 | | var decoded = System.Net.WebUtility.HtmlDecode(value); |
| | | 177 | | if (decoded.Contains('&') && decoded.Contains(';')) |
| | | 178 | | decoded = System.Net.WebUtility.HtmlDecode(decoded); |
| | | 179 | | return decoded.Trim(); |
| | | 180 | | } |
| | | 181 | | |
| | | 182 | | /// <summary>Артикулы у поставщика и в заказе различаются разделителями и регистром.</summary> |
| | | 183 | | private static string Normalize(string? value) => |
| | | 184 | | string.IsNullOrWhiteSpace(value) |
| | | 185 | | ? string.Empty |
| | | 186 | | : new string(value.Where(char.IsLetterOrDigit).ToArray()).ToUpperInvariant(); |
| | | 187 | | } |