< Summary

Information
Class: Services.ApiServices.FavoritPartsApi.FavoritPartsPriceMapper
Assembly: Services
File(s): /home/runner/work/Studio2Prod/Studio2Prod/AppRepository/ApiServices/FavoritPartsApi/FavoritPartsPriceMapper.cs
Line coverage
87%
Covered lines: 91
Uncovered lines: 13
Coverable lines: 104
Total lines: 186
Line coverage: 87.5%
Branch coverage
69%
Covered branches: 57
Total branches: 82
Branch coverage: 69.5%
Method coverage

Feature is only available for sponsors

Upgrade to PRO version

Metrics

MethodBranch coverage Crap Score Cyclomatic complexity Line coverage
.cctor()100%11100%
Map(...)75%171686.66%
MapGoods(...)71.05%383898.46%
ReadMoscowDate(...)100%11100%
ToMoscowDate(...)100%22100%
ReadOffset(...)50%1392033.33%
BuildDirection(...)100%44100%
NormalizeArticle(...)100%22100%

File(s)

/home/runner/work/Studio2Prod/Studio2Prod/AppRepository/ApiServices/FavoritPartsApi/FavoritPartsPriceMapper.cs

#LineLine coverage
 1using System.Globalization;
 2using System.Text.RegularExpressions;
 3using Newtonsoft.Json.Linq;
 4using OzonDomains;
 5using Services.ApiServices.TradesoftApi.Models.Response;
 6using Services.BackgroundJobsServices;
 7
 8namespace Services.ApiServices.FavoritPartsApi;
 9
 10/// <summary>Разворачивает вложенный ответ hs/hsprice/ (goods[].warehouses[]) в плоский список PreOrderItem —
 11/// один PreOrderItem на пару (товар, склад хранения). Проценка не знает "Склад отгрузки Фаворит"
 12/// (Москва/Краснодар) и итоговую дату отгрузки — они становятся известны только после добавления
 13/// в реальную корзину и GET /ws/v1/cart/ (см. FavoritPartsCartService), поэтому Direction здесь — это
 14/// код склада хранения, не финальное направление отгрузки.</summary>
 15public static class FavoritPartsPriceMapper
 16{
 17    /// <summary>Сервис работает по московскому времени (даты приходят с оффсетом +0300):
 18    /// срок поставки считаем от московской даты, иначе вечером по МСК он завышается на сутки.</summary>
 119    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    {
 725        var items = new List<PreOrderItem>();
 726        var requested = NormalizeArticle(requestedArticle);
 727        var goodsArray = response switch
 728        {
 029            JArray array => array,
 1430            JObject obj when obj["goods"] is JArray g => g,
 031            _ => new JArray()
 732        };
 33
 2834        foreach (var goods in goodsArray.OfType<JObject>())
 35        {
 36            // Номер сравниваем нормализованно: поставщик отдаёт артикул в своём написании
 37            // (дефисы, пробелы, точки), и строгое сравнение молча выбрасывало валидные предложения.
 738            if (NormalizeArticle(goods.Value<string>("number")) == requested)
 739                MapGoods(goods, items, preferredSupplierId, preferredSupplierCurrency, isAnalogue: false, providerKey, s
 40
 741            if (!includeAnalogues) continue;
 42            // Аналоги приходят вложенным массивом с той же структурой, что и goods,
 43            // и своим номером — фильтр по запрошенному артикулу к ним не применяется.
 444            foreach (var analogue in (goods["analogues"] as JArray ?? new JArray()).OfType<JObject>())
 145                MapGoods(analogue, items, preferredSupplierId, preferredSupplierCurrency, isAnalogue: true, providerKey,
 46        }
 747        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    {
 854        var goodsIdRaw = goods.Value<string>("goodsID");
 855        if (!Guid.TryParse(goodsIdRaw, out var goodsId) || goodsId == Guid.Empty)
 056            return;
 57
 858        var number = goods.Value<string>("number") ?? string.Empty;
 859        var brand = goods.Value<string>("brand") ?? string.Empty;
 860        var name = goods.Value<string>("name") ?? number;
 861        var rate = goods.Value<int?>("rate") ?? 1;
 862        var goodsNotRefund = goods.Value<bool?>("notRefund") ?? false;
 863        var warehouses = goods["warehouses"] as JArray ?? new JArray();
 64
 3465        foreach (var warehouse in warehouses.OfType<JObject>())
 66        {
 967            var warehouseIdRaw = warehouse.Value<string>("id");
 968            if (!Guid.TryParse(warehouseIdRaw, out var warehouseGroupId) || warehouseGroupId == Guid.Empty)
 69                continue;
 70
 971            var price = warehouse.Value<decimal?>("price");
 972            if (price is null) continue;
 73
 74            // Документация: если остатка нет, позиция всё равно возвращается с нулевым остатком —
 75            // заказать её нельзя, в проценку такие предложения не отдаём.
 976            var stock = warehouse.Value<decimal?>("stock") ?? 0;
 977            if (stock <= 0) continue;
 78
 879            var code = warehouse.Value<string>("code") ?? string.Empty;
 880            var shipmentDate = ReadMoscowDate(warehouse, "shipmentDate");
 881            var warehouseNotRefund = warehouse.Value<bool?>("notRefund") ?? goodsNotRefund;
 82
 883            var today = DateOnly.FromDateTime(TimeZoneInfo.ConvertTime(DateTimeOffset.UtcNow, MoscowZone).DateTime);
 884            var days = shipmentDate is null ? 0 : Math.Max(0, shipmentDate.Value.DayNumber - today.DayNumber);
 85
 886            items.Add(new PreOrderItem
 887            {
 888                Caption = name,
 889                Producer = brand,
 890                Code = number,
 891                Rest = stock.ToString(CultureInfo.InvariantCulture),
 892                MinQuantity = rate,
 893                DeliveryDays = days.ToString(CultureInfo.InvariantCulture),
 894                DeliveryDaysMin = days,
 895                DeliveryDaysMax = days,
 896                Price = price.Value.ToString("0.##", CultureInfo.InvariantCulture),
 897                CostPrice = price.Value.ToString("0.##", CultureInfo.InvariantCulture),
 898                CostPriceFormatted = price.Value.ToString("0.##", CultureInfo.InvariantCulture),
 899                CurrencyCode = "RUB",
 8100                SourcePrice = price,
 8101                SourceCurrencyCode = "RUB",
 8102                Direction = BuildDirection(code, shipmentDate),
 8103                Title = supplierName,
 8104                SiteUrl = "https://lk.favorit-parts.ru/",
 8105                ItemId = goodsId.ToString(),
 8106                ItemHash = $"{providerKey}:{goodsId}:{warehouseGroupId}".GetHashCode(StringComparison.Ordinal).ToString(
 8107                Description = string.Join("; ", new[]
 8108                {
 8109                    isAnalogue ? "Аналог" : null,
 8110                    $"Артикул: {number}", $"Бренд: {brand}", $"Склад хранения: {code}",
 8111                    $"Наличие: {stock}", shipmentDate is null ? "Дата отгрузки: неизвестна" : $"Отгрузка: {shipmentDate.
 8112                    warehouseNotRefund ? "Возврат: не разрешён" : "Возврат: разрешён"
 56113                }.Where(x => x is not null)),
 8114                PriceDescription = $"Цена {supplierName}: {price.Value:0.##} RUB",
 8115                PreferredSupplierId = preferredSupplierId,
 8116                PreferredSupplierCurrencyCode = preferredSupplierCurrency?.ToString(),
 8117                ProviderData = new Dictionary<string, string?>
 8118                {
 8119                    ["providerKey"] = providerKey,
 8120                    ["goodsId"] = goodsId.ToString(),
 8121                    ["warehouseGroupId"] = warehouseGroupId.ToString(),
 8122                    // Переиспользуем общий data-атрибут partnerWarehouseCode (как Armtek/ML-Auto), чтобы
 8123                    // warehouseGroupId дошёл до txSerializeCartQueues без новой цепочки data-*/JS-полей.
 8124                    ["partnerWarehouseCode"] = warehouseGroupId.ToString(),
 8125                    ["deliveryDestination"] = string.IsNullOrWhiteSpace(deliveryDestination) ? null : deliveryDestinatio
 8126                    ["deliveryConditions"] = warehouseNotRefund ? "Товар невозвратный" : null
 8127                }
 8128            });
 129        }
 8130    }
 131
 132    /// <summary>Дата отгрузки приходит с оффсетом (+0300). Читаем её как DateTimeOffset и приводим
 133    /// к московской дате: разбор в DateTime отдал бы дату в часовом поясе сервера (на UTC-хосте — предыдущий день).</su
 134    internal static DateOnly? ReadMoscowDate(JObject source, string propertyName) =>
 11135        ToMoscowDate(ReadOffset(source, propertyName));
 136
 137    public static DateOnly? ToMoscowDate(DateTimeOffset? value) =>
 23138        value is null ? null : DateOnly.FromDateTime(TimeZoneInfo.ConvertTime(value.Value, MoscowZone).DateTime);
 139
 140    public static DateTimeOffset? ReadOffset(JObject source, string propertyName)
 141    {
 23142        var token = source[propertyName];
 39143        if (token is null || token.Type is JTokenType.Null or JTokenType.Undefined) return null;
 144
 145        // Newtonsoft сам разбирает дату с оффсетом в JValue типа Date: момент времени там уже правильный,
 146        // но хранится как DateTime (Kind=Local), и прямое приведение к DateTimeOffset падает.
 7147        switch (token)
 148        {
 149            case JValue { Value: DateTimeOffset offset }:
 0150                return offset;
 7151            case JValue { Value: DateTime local } when local.Kind is not DateTimeKind.Unspecified:
 7152                return new DateTimeOffset(local);
 153            case JValue { Value: DateTime unspecified }:
 154                // Без оффсета в исходной строке считаем время московским — сервис работает по МСК.
 0155                return new DateTimeOffset(unspecified, MoscowZone.GetUtcOffset(unspecified));
 156        }
 157
 0158        var raw = token.Value<string>();
 0159        if (string.IsNullOrWhiteSpace(raw)) return null;
 0160        if (DateTimeOffset.TryParse(raw, CultureInfo.InvariantCulture, DateTimeStyles.None, out var parsed))
 0161            return parsed;
 162
 163        // Сервис отдаёт оффсет без двоеточия ("2019-02-02T00:00:00+0300") — .NET такой формат не разбирает.
 0164        var normalized = CompactOffset.Replace(raw, "$1:$2");
 0165        return DateTimeOffset.TryParse(normalized, CultureInfo.InvariantCulture, DateTimeStyles.None, out var fixedUp)
 0166            ? fixedUp
 0167            : null;
 168    }
 169
 1170    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    {
 10177        var warehouse = string.IsNullOrWhiteSpace(code) ? "Склад хранения" : code.Trim();
 10178        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) =>
 16183        string.IsNullOrWhiteSpace(value)
 16184            ? string.Empty
 16185            : new string(value.Where(char.IsLetterOrDigit).Select(char.ToUpperInvariant).ToArray());
 186}