| | | 1 | | using System.Globalization; |
| | | 2 | | using System.Text.RegularExpressions; |
| | | 3 | | using Newtonsoft.Json.Linq; |
| | | 4 | | using OzonDomains; |
| | | 5 | | using Services.ApiServices.TradesoftApi.Models.Response; |
| | | 6 | | using Services.BackgroundJobsServices; |
| | | 7 | | |
| | | 8 | | namespace Services.ApiServices.FavoritPartsApi; |
| | | 9 | | |
| | | 10 | | /// <summary>Разворачивает вложенный ответ hs/hsprice/ (goods[].warehouses[]) в плоский список PreOrderItem — |
| | | 11 | | /// один PreOrderItem на пару (товар, склад хранения). Проценка не знает "Склад отгрузки Фаворит" |
| | | 12 | | /// (Москва/Краснодар) и итоговую дату отгрузки — они становятся известны только после добавления |
| | | 13 | | /// в реальную корзину и GET /ws/v1/cart/ (см. FavoritPartsCartService), поэтому Direction здесь — это |
| | | 14 | | /// код склада хранения, не финальное направление отгрузки.</summary> |
| | | 15 | | public static class FavoritPartsPriceMapper |
| | | 16 | | { |
| | | 17 | | /// <summary>Сервис работает по московскому времени (даты приходят с оффсетом +0300): |
| | | 18 | | /// срок поставки считаем от московской даты, иначе вечером по МСК он завышается на сутки.</summary> |
| | 1 | 19 | | private static readonly TimeZoneInfo MoscowZone = RussianTimeZoneHelper.Zone; |
| | | 20 | | |
| | | 21 | | public static IReadOnlyList<PreOrderItem> Map(JToken response, string requestedArticle, |
| | | 22 | | int? preferredSupplierId, CurrencyCode? preferredSupplierCurrency, bool includeAnalogues = false, |
| | | 23 | | string providerKey = "FavoritParts", string supplierName = "Фаворит Союз", string? deliveryDestination = null) |
| | | 24 | | { |
| | 7 | 25 | | var items = new List<PreOrderItem>(); |
| | 7 | 26 | | var requested = NormalizeArticle(requestedArticle); |
| | 7 | 27 | | var goodsArray = response switch |
| | 7 | 28 | | { |
| | 0 | 29 | | JArray array => array, |
| | 14 | 30 | | JObject obj when obj["goods"] is JArray g => g, |
| | 0 | 31 | | _ => new JArray() |
| | 7 | 32 | | }; |
| | | 33 | | |
| | 28 | 34 | | foreach (var goods in goodsArray.OfType<JObject>()) |
| | | 35 | | { |
| | | 36 | | // Номер сравниваем нормализованно: поставщик отдаёт артикул в своём написании |
| | | 37 | | // (дефисы, пробелы, точки), и строгое сравнение молча выбрасывало валидные предложения. |
| | 7 | 38 | | if (NormalizeArticle(goods.Value<string>("number")) == requested) |
| | 7 | 39 | | MapGoods(goods, items, preferredSupplierId, preferredSupplierCurrency, isAnalogue: false, providerKey, s |
| | | 40 | | |
| | 7 | 41 | | if (!includeAnalogues) continue; |
| | | 42 | | // Аналоги приходят вложенным массивом с той же структурой, что и goods, |
| | | 43 | | // и своим номером — фильтр по запрошенному артикулу к ним не применяется. |
| | 4 | 44 | | foreach (var analogue in (goods["analogues"] as JArray ?? new JArray()).OfType<JObject>()) |
| | 1 | 45 | | MapGoods(analogue, items, preferredSupplierId, preferredSupplierCurrency, isAnalogue: true, providerKey, |
| | | 46 | | } |
| | 7 | 47 | | return items; |
| | | 48 | | } |
| | | 49 | | |
| | | 50 | | private static void MapGoods(JObject goods, List<PreOrderItem> items, |
| | | 51 | | int? preferredSupplierId, CurrencyCode? preferredSupplierCurrency, bool isAnalogue, |
| | | 52 | | string providerKey, string supplierName, string? deliveryDestination) |
| | | 53 | | { |
| | 8 | 54 | | var goodsIdRaw = goods.Value<string>("goodsID"); |
| | 8 | 55 | | if (!Guid.TryParse(goodsIdRaw, out var goodsId) || goodsId == Guid.Empty) |
| | 0 | 56 | | return; |
| | | 57 | | |
| | 8 | 58 | | var number = goods.Value<string>("number") ?? string.Empty; |
| | 8 | 59 | | var brand = goods.Value<string>("brand") ?? string.Empty; |
| | 8 | 60 | | var name = goods.Value<string>("name") ?? number; |
| | 8 | 61 | | var rate = goods.Value<int?>("rate") ?? 1; |
| | 8 | 62 | | var goodsNotRefund = goods.Value<bool?>("notRefund") ?? false; |
| | 8 | 63 | | var warehouses = goods["warehouses"] as JArray ?? new JArray(); |
| | | 64 | | |
| | 34 | 65 | | foreach (var warehouse in warehouses.OfType<JObject>()) |
| | | 66 | | { |
| | 9 | 67 | | var warehouseIdRaw = warehouse.Value<string>("id"); |
| | 9 | 68 | | if (!Guid.TryParse(warehouseIdRaw, out var warehouseGroupId) || warehouseGroupId == Guid.Empty) |
| | | 69 | | continue; |
| | | 70 | | |
| | 9 | 71 | | var price = warehouse.Value<decimal?>("price"); |
| | 9 | 72 | | if (price is null) continue; |
| | | 73 | | |
| | | 74 | | // Документация: если остатка нет, позиция всё равно возвращается с нулевым остатком — |
| | | 75 | | // заказать её нельзя, в проценку такие предложения не отдаём. |
| | 9 | 76 | | var stock = warehouse.Value<decimal?>("stock") ?? 0; |
| | 9 | 77 | | if (stock <= 0) continue; |
| | | 78 | | |
| | 8 | 79 | | var code = warehouse.Value<string>("code") ?? string.Empty; |
| | 8 | 80 | | var shipmentDate = ReadMoscowDate(warehouse, "shipmentDate"); |
| | 8 | 81 | | var warehouseNotRefund = warehouse.Value<bool?>("notRefund") ?? goodsNotRefund; |
| | | 82 | | |
| | 8 | 83 | | var today = DateOnly.FromDateTime(TimeZoneInfo.ConvertTime(DateTimeOffset.UtcNow, MoscowZone).DateTime); |
| | 8 | 84 | | var days = shipmentDate is null ? 0 : Math.Max(0, shipmentDate.Value.DayNumber - today.DayNumber); |
| | | 85 | | |
| | 8 | 86 | | items.Add(new PreOrderItem |
| | 8 | 87 | | { |
| | 8 | 88 | | Caption = name, |
| | 8 | 89 | | Producer = brand, |
| | 8 | 90 | | Code = number, |
| | 8 | 91 | | Rest = stock.ToString(CultureInfo.InvariantCulture), |
| | 8 | 92 | | MinQuantity = rate, |
| | 8 | 93 | | DeliveryDays = days.ToString(CultureInfo.InvariantCulture), |
| | 8 | 94 | | DeliveryDaysMin = days, |
| | 8 | 95 | | DeliveryDaysMax = days, |
| | 8 | 96 | | Price = price.Value.ToString("0.##", CultureInfo.InvariantCulture), |
| | 8 | 97 | | CostPrice = price.Value.ToString("0.##", CultureInfo.InvariantCulture), |
| | 8 | 98 | | CostPriceFormatted = price.Value.ToString("0.##", CultureInfo.InvariantCulture), |
| | 8 | 99 | | CurrencyCode = "RUB", |
| | 8 | 100 | | SourcePrice = price, |
| | 8 | 101 | | SourceCurrencyCode = "RUB", |
| | 8 | 102 | | Direction = BuildDirection(code, shipmentDate), |
| | 8 | 103 | | Title = supplierName, |
| | 8 | 104 | | SiteUrl = "https://lk.favorit-parts.ru/", |
| | 8 | 105 | | ItemId = goodsId.ToString(), |
| | 8 | 106 | | ItemHash = $"{providerKey}:{goodsId}:{warehouseGroupId}".GetHashCode(StringComparison.Ordinal).ToString( |
| | 8 | 107 | | Description = string.Join("; ", new[] |
| | 8 | 108 | | { |
| | 8 | 109 | | isAnalogue ? "Аналог" : null, |
| | 8 | 110 | | $"Артикул: {number}", $"Бренд: {brand}", $"Склад хранения: {code}", |
| | 8 | 111 | | $"Наличие: {stock}", shipmentDate is null ? "Дата отгрузки: неизвестна" : $"Отгрузка: {shipmentDate. |
| | 8 | 112 | | warehouseNotRefund ? "Возврат: не разрешён" : "Возврат: разрешён" |
| | 56 | 113 | | }.Where(x => x is not null)), |
| | 8 | 114 | | PriceDescription = $"Цена {supplierName}: {price.Value:0.##} RUB", |
| | 8 | 115 | | PreferredSupplierId = preferredSupplierId, |
| | 8 | 116 | | PreferredSupplierCurrencyCode = preferredSupplierCurrency?.ToString(), |
| | 8 | 117 | | ProviderData = new Dictionary<string, string?> |
| | 8 | 118 | | { |
| | 8 | 119 | | ["providerKey"] = providerKey, |
| | 8 | 120 | | ["goodsId"] = goodsId.ToString(), |
| | 8 | 121 | | ["warehouseGroupId"] = warehouseGroupId.ToString(), |
| | 8 | 122 | | // Переиспользуем общий data-атрибут partnerWarehouseCode (как Armtek/ML-Auto), чтобы |
| | 8 | 123 | | // warehouseGroupId дошёл до txSerializeCartQueues без новой цепочки data-*/JS-полей. |
| | 8 | 124 | | ["partnerWarehouseCode"] = warehouseGroupId.ToString(), |
| | 8 | 125 | | ["deliveryDestination"] = string.IsNullOrWhiteSpace(deliveryDestination) ? null : deliveryDestinatio |
| | 8 | 126 | | ["deliveryConditions"] = warehouseNotRefund ? "Товар невозвратный" : null |
| | 8 | 127 | | } |
| | 8 | 128 | | }); |
| | | 129 | | } |
| | 8 | 130 | | } |
| | | 131 | | |
| | | 132 | | /// <summary>Дата отгрузки приходит с оффсетом (+0300). Читаем её как DateTimeOffset и приводим |
| | | 133 | | /// к московской дате: разбор в DateTime отдал бы дату в часовом поясе сервера (на UTC-хосте — предыдущий день).</su |
| | | 134 | | internal static DateOnly? ReadMoscowDate(JObject source, string propertyName) => |
| | 11 | 135 | | ToMoscowDate(ReadOffset(source, propertyName)); |
| | | 136 | | |
| | | 137 | | public static DateOnly? ToMoscowDate(DateTimeOffset? value) => |
| | 23 | 138 | | value is null ? null : DateOnly.FromDateTime(TimeZoneInfo.ConvertTime(value.Value, MoscowZone).DateTime); |
| | | 139 | | |
| | | 140 | | public static DateTimeOffset? ReadOffset(JObject source, string propertyName) |
| | | 141 | | { |
| | 23 | 142 | | var token = source[propertyName]; |
| | 39 | 143 | | if (token is null || token.Type is JTokenType.Null or JTokenType.Undefined) return null; |
| | | 144 | | |
| | | 145 | | // Newtonsoft сам разбирает дату с оффсетом в JValue типа Date: момент времени там уже правильный, |
| | | 146 | | // но хранится как DateTime (Kind=Local), и прямое приведение к DateTimeOffset падает. |
| | 7 | 147 | | switch (token) |
| | | 148 | | { |
| | | 149 | | case JValue { Value: DateTimeOffset offset }: |
| | 0 | 150 | | return offset; |
| | 7 | 151 | | case JValue { Value: DateTime local } when local.Kind is not DateTimeKind.Unspecified: |
| | 7 | 152 | | return new DateTimeOffset(local); |
| | | 153 | | case JValue { Value: DateTime unspecified }: |
| | | 154 | | // Без оффсета в исходной строке считаем время московским — сервис работает по МСК. |
| | 0 | 155 | | return new DateTimeOffset(unspecified, MoscowZone.GetUtcOffset(unspecified)); |
| | | 156 | | } |
| | | 157 | | |
| | 0 | 158 | | var raw = token.Value<string>(); |
| | 0 | 159 | | if (string.IsNullOrWhiteSpace(raw)) return null; |
| | 0 | 160 | | if (DateTimeOffset.TryParse(raw, CultureInfo.InvariantCulture, DateTimeStyles.None, out var parsed)) |
| | 0 | 161 | | return parsed; |
| | | 162 | | |
| | | 163 | | // Сервис отдаёт оффсет без двоеточия ("2019-02-02T00:00:00+0300") — .NET такой формат не разбирает. |
| | 0 | 164 | | var normalized = CompactOffset.Replace(raw, "$1:$2"); |
| | 0 | 165 | | return DateTimeOffset.TryParse(normalized, CultureInfo.InvariantCulture, DateTimeStyles.None, out var fixedUp) |
| | 0 | 166 | | ? fixedUp |
| | 0 | 167 | | : null; |
| | | 168 | | } |
| | | 169 | | |
| | 1 | 170 | | private static readonly Regex CompactOffset = new(@"([+-]\d{2})(\d{2})$", RegexOptions.Compiled); |
| | | 171 | | |
| | | 172 | | /// <summary>Направление предложения. Код склада хранения у FavoritParts не уникален — «МСК РС» |
| | | 173 | | /// возвращается сразу для нескольких складов с разными ценами и сроками, поэтому к коду добавляем |
| | | 174 | | /// дату отгрузки: иначе в истории проценок и в фильтре направлений строки неразличимы.</summary> |
| | | 175 | | internal static string BuildDirection(string? code, DateOnly? shipmentDate) |
| | | 176 | | { |
| | 10 | 177 | | var warehouse = string.IsNullOrWhiteSpace(code) ? "Склад хранения" : code.Trim(); |
| | 10 | 178 | | return shipmentDate is null ? warehouse : $"{warehouse} · {shipmentDate.Value:dd.MM}"; |
| | | 179 | | } |
| | | 180 | | |
| | | 181 | | /// <summary>Артикул для сравнения: без разделителей и регистра (7160-500 039 S == 7160500039S).</summary> |
| | | 182 | | internal static string NormalizeArticle(string? value) => |
| | 16 | 183 | | string.IsNullOrWhiteSpace(value) |
| | 16 | 184 | | ? string.Empty |
| | 16 | 185 | | : new string(value.Where(char.IsLetterOrDigit).Select(char.ToUpperInvariant).ToArray()); |
| | | 186 | | } |