< Summary

Information
Class: Services.AiAssistant.AiOzonLiveProductService
Assembly: Services
File(s): /home/runner/work/Studio2Prod/Studio2Prod/AppRepository/AiAssistant/AiOzonLiveProductService.cs
Line coverage
0%
Covered lines: 0
Uncovered lines: 280
Coverable lines: 280
Total lines: 476
Line coverage: 0%
Branch coverage
0%
Covered branches: 0
Total branches: 244
Branch coverage: 0%
Method coverage

Feature is only available for sponsors

Upgrade to PRO version

Metrics

MethodBranch coverage Crap Score Cyclomatic complexity Line coverage
.ctor(...)100%210%
LoadAsync()0%506220%
LoadFormattedAsync()100%210%
BuildArticleWithKey(...)0%620%
BuildOfferCandidates(...)0%2040%
AppendOfferIdsFromBitrixHistoryAsync()0%2040%
ResolveOzonClientIdsAsync()0%272160%
FetchForClientAsync()0%311521760%
PriceIndexBackground(...)0%7280%
PriceIndexForeground(...)0%7280%

File(s)

/home/runner/work/Studio2Prod/Studio2Prod/AppRepository/AiAssistant/AiOzonLiveProductService.cs

#LineLine coverage
 1using Microsoft.EntityFrameworkCore;
 2using Microsoft.Extensions.Http;
 3using Microsoft.Extensions.Logging;
 4using Newtonsoft.Json.Linq;
 5using OzonDomains.Models;
 6using OzonRepositories.Context;
 7using Services.ApiServices.OzonApi;
 8using Services.BitrixStockServices;
 9using Services.DataServices;
 10using SlqStudio.Application.Services.EmailService;
 11
 12namespace Services.AiAssistant;
 13
 14/// <summary>
 15/// Актуальные остатки и цены товара из Ozon Seller API (клиент из заказа / BitrixStock).
 16/// </summary>
 17public sealed class AiOzonLiveProductService
 18{
 19    private readonly IHttpClientFactory _httpClientFactory;
 20    private readonly IEmailService? _emailService;
 21    private readonly OzonClientServices _ozonClientServices;
 22    private readonly ApplicationDbContext _db;
 23    private readonly AiOrderScopeApplier _scopeApplier;
 24    private readonly IBitrixStockSyncArticleHistoryService _bitrixHistory;
 25    private readonly ILogger<AiOzonLiveProductService> _logger;
 26
 027    public AiOzonLiveProductService(
 028        IHttpClientFactory httpClientFactory,
 029        IEmailService? emailService,
 030        OzonClientServices ozonClientServices,
 031        ApplicationDbContext db,
 032        AiOrderScopeApplier scopeApplier,
 033        IBitrixStockSyncArticleHistoryService bitrixHistory,
 034        ILogger<AiOzonLiveProductService> logger)
 35    {
 036        _httpClientFactory   = httpClientFactory;
 037        _emailService        = emailService;
 038        _ozonClientServices  = ozonClientServices;
 039        _db                  = db;
 040        _scopeApplier        = scopeApplier;
 041        _bitrixHistory       = bitrixHistory;
 042        _logger              = logger;
 043    }
 44
 45    /// <summary>
 46    /// Загружает данные Ozon API и возвращает:
 47    /// — plaintext-контекст для AI (без markdown-таблиц, чтобы AI не ломал форматирование)
 48    /// — структурированный <see cref="AiOzonLiveUiDto"/> для рендеринга HTML-таблицы на клиенте
 49    /// </summary>
 50    public async Task<(string AiContext, AiOzonLiveUiDto? LiveUi)> LoadAsync(
 51        string article,
 52        string? manufacturerPrefix,
 53        int? orderId,
 54        string? userId,
 55        CancellationToken ct = default)
 56    {
 057        article = article.Trim();
 058        if (string.IsNullOrWhiteSpace(article))
 059            return ("Артикул не указан для запроса к Ozon API.", null);
 60
 061        var articleWithKey = BuildArticleWithKey(article, manufacturerPrefix);
 062        var candidates     = BuildOfferCandidates(article, articleWithKey, manufacturerPrefix);
 063        await AppendOfferIdsFromBitrixHistoryAsync(article, candidates, ct);
 64
 065        var clientIds = await ResolveOzonClientIdsAsync(article, orderId, userId, ct);
 066        if (clientIds.Count == 0)
 67        {
 068            var msg = $"Артикул «{article}»: не удалось определить Ozon-клиента. Уточните заказ с этим товаром.";
 069            return (msg, null);
 70        }
 71
 072        var sb       = new System.Text.StringBuilder();
 073        var liveUi   = new AiOzonLiveUiDto
 074        {
 075            Article      = article,
 076            CheckedAtMsk = AiMskDateTime.Format(DateTime.UtcNow)
 077        };
 78
 079        sb.AppendLine("=== Актуальные данные Ozon Seller API (live) ===");
 080        sb.AppendLine($"Артикул: {article}{(string.IsNullOrWhiteSpace(manufacturerPrefix) ? "" : $" | product key: {arti
 081        sb.AppendLine($"Offer_id кандидаты: {string.Join(", ", candidates.Select(c => $"«{c}»"))}");
 082        sb.AppendLine();
 83
 084        foreach (var clientId in clientIds.Distinct().Take(3))
 85        {
 086            var client = await _ozonClientServices.GetOzonClientAsync(clientId);
 087            if (client == null ||
 088                string.IsNullOrWhiteSpace(client.DecryptClientId) ||
 089                string.IsNullOrWhiteSpace(client.DecryptApiKey))
 90                continue;
 91
 092            var (text, clientUi) = await FetchForClientAsync(client, candidates, ct);
 093            if (text != null)
 94            {
 095                sb.AppendLine(text);
 096                sb.AppendLine();
 97            }
 098            if (clientUi != null)
 099                liveUi.Clients.Add(clientUi);
 100        }
 101
 0102        if (liveUi.Clients.Count == 0)
 0103            sb.AppendLine("Товар не найден в Ozon API ни у одного из доступных клиентов.");
 104
 0105        sb.AppendLine(
 0106            "ПРИМЕЧАНИЕ: live-остатки — факт на Ozon сейчас. " +
 0107            "Остатки в API только целые числа. " +
 0108            "Дробные значения в WarehouseStock/1С — другой источник данных.");
 109
 0110        return (sb.ToString().TrimEnd(), liveUi.Clients.Count > 0 ? liveUi : null);
 0111    }
 112
 113    // Для обратной совместимости
 114    public async Task<string> LoadFormattedAsync(
 115        string article,
 116        string? manufacturerPrefix,
 117        int? orderId,
 118        string? userId,
 119        CancellationToken ct = default)
 120    {
 0121        var (ctx, _) = await LoadAsync(article, manufacturerPrefix, orderId, userId, ct);
 0122        return ctx;
 0123    }
 124
 125    private static string BuildArticleWithKey(string article, string? manufacturerPrefix) =>
 0126        !string.IsNullOrWhiteSpace(manufacturerPrefix)
 0127            ? $"{article}={manufacturerPrefix.Trim()}"
 0128            : article;
 129
 130    private static List<string> BuildOfferCandidates(
 131        string article,
 132        string articleWithKey,
 133        string? manufacturerPrefix)
 134    {
 0135        var set = BitrixOzonStockLinkage
 0136            .BuildOfferIdCandidates(articleWithKey, article)
 0137            .ToList();
 138
 0139        if (!string.IsNullOrWhiteSpace(manufacturerPrefix))
 140        {
 0141            var mfr = manufacturerPrefix.Trim();
 0142            if (!set.Contains($"{article}={mfr}", StringComparer.OrdinalIgnoreCase))
 0143                set.Add($"{article}={mfr}");
 144        }
 145
 0146        return set;
 147    }
 148
 149    private async Task AppendOfferIdsFromBitrixHistoryAsync(
 150        string article,
 151        List<string> candidates,
 152        CancellationToken ct)
 153    {
 154        try
 155        {
 0156            var page = await _bitrixHistory.GetAllAsync(article, maxReports: 3, ct);
 0157            foreach (var offerId in page.Reports
 0158                         .SelectMany(r => r.Rows)
 0159                         .Select(r => r.Line.OfferId)
 0160                         .Where(id => !string.IsNullOrWhiteSpace(id)))
 161            {
 0162                if (!candidates.Contains(offerId!, StringComparer.OrdinalIgnoreCase))
 0163                    candidates.Add(offerId!);
 164            }
 0165        }
 0166        catch (Exception ex)
 167        {
 0168            _logger.LogWarning(ex, "AI Ozon live: bitrix offer_id lookup failed for {Article}", article);
 0169        }
 0170    }
 171
 172    private async Task<List<int>> ResolveOzonClientIdsAsync(
 173        string article,
 174        int? orderId,
 175        string? userId,
 176        CancellationToken ct)
 177    {
 0178        var ids = new List<int>();
 179
 0180        if (orderId is > 0)
 181        {
 0182            var query = _db.Orders.AsNoTracking().Where(o => o.Id == orderId.Value);
 0183            query = await _scopeApplier.ApplyAsync(query, userId, ct);
 0184            var ozonClientId = await query.Select(o => o.OzonClientId).FirstOrDefaultAsync(ct);
 0185            if (ozonClientId is > 0)
 0186                ids.Add(ozonClientId.Value);
 187        }
 188
 0189        if (ids.Count == 0)
 190        {
 0191            var orderQuery = _db.Orders.AsNoTracking()
 0192                .Where(o => o.Article == article && o.OzonClientId != null);
 0193            orderQuery = await _scopeApplier.ApplyAsync(orderQuery, userId, ct);
 0194            var fromOrder = await orderQuery
 0195                .OrderByDescending(o => o.ProcessingDate)
 0196                .Select(o => o.OzonClientId!.Value)
 0197                .FirstOrDefaultAsync(ct);
 0198            if (fromOrder > 0)
 0199                ids.Add(fromOrder);
 200        }
 201
 202        try
 203        {
 0204            var page = await _bitrixHistory.GetAllAsync(article, maxReports: 5, ct);
 0205            foreach (var cid in page.Reports
 0206                         .SelectMany(r => r.Rows)
 0207                         .Select(r => r.Line.OzonClientId)
 0208                         .Where(id => id > 0))
 209            {
 0210                if (!ids.Contains(cid))
 0211                    ids.Add(cid);
 212            }
 0213        }
 0214        catch (Exception ex)
 215        {
 0216            _logger.LogWarning(ex, "AI Ozon live: client resolve from bitrix failed for {Article}", article);
 0217        }
 218
 0219        return ids;
 0220    }
 221
 222    private async Task<(string? Text, AiOzonLiveClientUiDto? Ui)> FetchForClientAsync(
 223        OzonClient client,
 224        IReadOnlyList<string> candidates,
 225        CancellationToken ct)
 226    {
 0227        var api = new OzonApiDataManager(
 0228            _httpClientFactory,
 0229            _emailService,
 0230            client.DecryptClientId!,
 0231            client.DecryptApiKey!);
 232
 0233        var sb  = new System.Text.StringBuilder();
 0234        sb.AppendLine($"--- Ozon-клиент «{client.Name}» ---");
 235
 0236        var ui = new AiOzonLiveClientUiDto { ClientName = client.Name ?? $"id {client.Id}" };
 0237        string? matchedOfferId = null;
 0238        JArray? stockItems     = null;
 239
 240        // ── Остатки ──────────────────────────────────────────────────────────
 241        try
 242        {
 0243            var stocks = await api.GetProductStocks(candidates.ToArray());
 0244            stockItems = stocks["items"] as JArray;
 0245            if (stockItems is { Count: > 0 })
 246            {
 0247                matchedOfferId   = stockItems[0]["offer_id"]?.ToString();
 0248                ui.OfferId     ??= matchedOfferId;
 0249                ui.Found         = true;
 250
 251                // Собираем FBS-SKU для запроса по складам
 0252                var fbsSkus = new List<string>();
 0253                foreach (var item in stockItems)
 254                {
 0255                    if (item["stocks"] is not JArray ss) continue;
 0256                    foreach (var s in ss)
 257                    {
 0258                        if (string.Equals(s["type"]?.ToString(), "fbs", StringComparison.OrdinalIgnoreCase))
 259                        {
 0260                            var sku = s["sku"]?.ToString();
 0261                            if (!string.IsNullOrEmpty(sku) && sku != "0") fbsSkus.Add(sku);
 262                        }
 263                    }
 264                }
 265
 266                // Запрашиваем детализацию по складам (FBS)
 0267                Dictionary<string, (string Name, int Present, int Reserved)[]>? whDetail = null;
 0268                if (fbsSkus.Count > 0)
 269                {
 270                    try
 271                    {
 0272                        var whResp = await api.GetProductWarehouses(fbsSkus.ToArray(), []);
 0273                        var whItems = whResp["result"] as JArray ?? whResp["products"] as JArray;
 0274                        if (whItems is { Count: > 0 })
 275                        {
 0276                            whDetail = whItems
 0277                                .GroupBy(w => w["sku"]?.ToString() ?? "")
 0278                                .ToDictionary(
 0279                                    g => g.Key,
 0280                                    g => g.Select(w => (
 0281                                        Name:     w["warehouse_name"]?.ToString() ?? $"id {w["warehouse_id"]}",
 0282                                        Present:  w["present"]?.Value<int>() ?? 0,
 0283                                        Reserved: w["reserved"]?.Value<int>() ?? 0
 0284                                    )).ToArray());
 285                        }
 0286                    }
 0287                    catch (Exception ex)
 288                    {
 0289                        _logger.LogWarning(ex, "AI Ozon live: stocks-by-warehouse failed");
 0290                    }
 291                }
 292
 0293                sb.AppendLine("Остатки по складам:");
 0294                foreach (var item in stockItems)
 295                {
 0296                    var oid = item["offer_id"]?.ToString() ?? "—";
 0297                    if (item["stocks"] is not JArray whStocks || whStocks.Count == 0)
 298                    {
 0299                        sb.AppendLine($"  {oid}: остатков нет");
 0300                        continue;
 301                    }
 0302                    foreach (var s in whStocks)
 303                    {
 0304                        var whType   = s["type"]?.ToString() ?? "—";
 0305                        var present  = s["present"]?.Value<int?>() ?? s["stock"]?.Value<int?>() ?? 0;
 0306                        var reserved = s["reserved"]?.Value<int?>() ?? 0;
 0307                        var sku      = s["sku"]?.ToString();
 308
 309                        // Для FBS — разворачиваем по складам если есть детализация
 0310                        if (string.Equals(whType, "fbs", StringComparison.OrdinalIgnoreCase)
 0311                            && whDetail != null && sku != null && whDetail.TryGetValue(sku, out var whRows)
 0312                            && whRows.Length > 0)
 313                        {
 0314                            foreach (var wh in whRows)
 315                            {
 0316                                sb.AppendLine($"  {oid} | FBS | {wh.Name}: {wh.Present} шт." +
 0317                                              (wh.Reserved > 0 ? $", резерв {wh.Reserved}" : ""));
 0318                                ui.Stocks.Add(new AiOzonStockRowUiDto
 0319                                {
 0320                                    WarehouseType = "fbs",
 0321                                    WarehouseName = wh.Name,
 0322                                    Present       = wh.Present,
 0323                                    Reserved      = wh.Reserved
 0324                                });
 325                            }
 326                        }
 327                        else
 328                        {
 0329                            sb.AppendLine($"  {oid} | {whType}: {present} шт." +
 0330                                          (reserved > 0 ? $", резерв {reserved}" : ""));
 0331                            ui.Stocks.Add(new AiOzonStockRowUiDto
 0332                            {
 0333                                WarehouseType = whType,
 0334                                Present       = present,
 0335                                Reserved      = reserved
 0336                            });
 337                        }
 338                    }
 339                }
 0340            }
 341            else
 342            {
 0343                sb.AppendLine("Остатки: не найдено.");
 344            }
 0345        }
 0346        catch (Exception ex)
 347        {
 0348            sb.AppendLine($"Остатки: ошибка — {ex.Message}");
 0349        }
 350
 351        // ── Карточка товара ───────────────────────────────────────────────────
 352        try
 353        {
 0354            var info = await api.GetProductInfoListAsync(offerIds: candidates.ToArray());
 0355            if (info["items"] is JArray infoItems && infoItems.Count > 0)
 356            {
 0357                sb.AppendLine("Карточка товара:");
 0358                foreach (var item in infoItems)
 359                {
 0360                    var oid      = item["offer_id"]?.ToString() ?? "—";
 0361                    var name     = item["name"]?.ToString();
 0362                    var archived = item["is_archived"]?.Value<bool>() == true;
 0363                    var pid      = item["id"]?.Value<long?>();
 364
 0365                    matchedOfferId ??= oid;
 0366                    ui.OfferId     ??= oid;
 0367                    ui.Found         = true;
 0368                    ui.ProductName ??= name;
 0369                    ui.ProductId   ??= pid;
 0370                    ui.IsArchived    = archived;
 371
 372                    // Атрибуты: описание, категория, фото
 0373                    var descAttr = item["description_category_id"]?.ToString();
 0374                    if (item["description"] is JToken descToken)
 0375                        ui.Description ??= descToken.ToString().Trim();
 0376                    if (item["category_id"] is JToken catToken)
 0377                        ui.CategoryName ??= catToken.ToString();
 0378                    var images = item["images"] as JArray ?? item["primary_image"] as JArray;
 0379                    if (ui.ImageUrl == null && images is { Count: > 0 })
 0380                        ui.ImageUrl = images[0]?.ToString();
 0381                    if (ui.ImageUrl == null)
 0382                        ui.ImageUrl = item["primary_image"]?.ToString();
 383                    _ = descAttr;
 384
 0385                    sb.AppendLine($"  {oid} | {name ?? "—"} | product_id {pid} | архив: {(archived ? "да" : "нет")}");
 0386                    if (!string.IsNullOrWhiteSpace(ui.Description))
 0387                        sb.AppendLine($"  Описание: {ui.Description[..Math.Min(150, ui.Description.Length)]}…");
 388                }
 389            }
 390            else
 391            {
 0392                sb.AppendLine("Карточка: не найдена.");
 393            }
 0394        }
 0395        catch (Exception ex)
 396        {
 0397            sb.AppendLine($"Карточка: ошибка — {ex.Message}");
 0398        }
 399
 400        // ── Цены ─────────────────────────────────────────────────────────────
 401        try
 402        {
 0403            var priceResp  = await api.GetProductPricesByArticlesModel(candidates.ToArray());
 0404            var priceItems = priceResp.Items;
 0405            if (priceItems is { Count: > 0 })
 406            {
 0407                sb.AppendLine("Цены:");
 0408                foreach (var pi in priceItems)
 409                {
 0410                    var oid      = pi.OfferId ?? "—";
 0411                    var price    = pi.Price?.Price ?? 0;
 0412                    var oldPrice = pi.Price?.OldPrice ?? 0;
 0413                    var mktPrice = pi.Price?.MarketingPrice ?? 0;
 0414                    var minPrice = pi.Price?.MinPrice ?? 0;
 0415                    var idxLabel = BitrixStockSyncReportRowPresenter.FormatPriceIndex(pi.PriceIndexes?.ColorIndex);
 0416                    var idxColor = pi.PriceIndexes?.ColorIndex;
 417
 0418                    matchedOfferId ??= oid;
 0419                    ui.Found = true;
 0420                    ui.Price = new AiOzonPriceUiDto
 0421                    {
 0422                        Price          = (decimal)price,
 0423                        OldPrice       = oldPrice > 0 ? (decimal?)oldPrice : null,
 0424                        MarketingPrice = mktPrice > 0 ? (decimal?)mktPrice : null,
 0425                        MinPrice       = minPrice > 0 ? (decimal?)minPrice : null,
 0426                        PriceIndex     = string.IsNullOrWhiteSpace(idxLabel) ? null : new AiPriceIndexUiDto
 0427                        {
 0428                            Key   = idxColor ?? "",
 0429                            Label = idxLabel,
 0430                            Bg    = PriceIndexBackground(idxColor),
 0431                            Fg    = PriceIndexForeground(idxColor)
 0432                        }
 0433                    };
 434
 0435                    sb.AppendLine($"  {oid}: {price:N0} ₽" +
 0436                                  (mktPrice > 0 ? $", витрина {mktPrice:N0} ₽" : "") +
 0437                                  (minPrice > 0 ? $", min {minPrice:N0} ₽" : "") +
 0438                                  (oldPrice > 0 ? $", старая {oldPrice:N0} ₽" : "") +
 0439                                  (string.IsNullOrWhiteSpace(idxLabel) ? "" : $", индекс «{idxLabel}»"));
 440                }
 441            }
 442            else
 443            {
 0444                sb.AppendLine("Цены: не найдены.");
 445            }
 0446        }
 0447        catch (Exception ex)
 448        {
 0449            sb.AppendLine($"Цены: ошибка — {ex.Message}");
 0450        }
 451
 0452        if (!ui.Found && matchedOfferId == null)
 0453            return (null, null);
 454
 0455        if (!string.IsNullOrWhiteSpace(matchedOfferId))
 0456            sb.AppendLine($"Совпавший offer_id: «{matchedOfferId}».");
 457
 0458        return (sb.ToString().TrimEnd(), ui);
 0459    }
 460
 0461    private static string PriceIndexBackground(string? colorIndex) => colorIndex?.ToLowerInvariant() switch
 0462    {
 0463        "green"  => "#D1FAE5",
 0464        "yellow" => "#FEF9C3",
 0465        "red"    => "#FEE2E2",
 0466        _        => "#E9ECEF"
 0467    };
 468
 0469    private static string PriceIndexForeground(string? colorIndex) => colorIndex?.ToLowerInvariant() switch
 0470    {
 0471        "green"  => "#065F46",
 0472        "yellow" => "#92400E",
 0473        "red"    => "#991B1B",
 0474        _        => "#6C757D"
 0475    };
 476}