< Summary

Information
Class: Services.BitrixStockServices.BitrixStockSyncArticleHistoryService
Assembly: Services
File(s): /home/runner/work/Studio2Prod/Studio2Prod/AppRepository/BitrixStockServices/BitrixStockSyncArticleHistoryService.cs
Line coverage
6%
Covered lines: 24
Uncovered lines: 337
Coverable lines: 361
Total lines: 528
Line coverage: 6.6%
Branch coverage
4%
Covered branches: 11
Total branches: 222
Branch coverage: 4.9%
Method coverage

Feature is only available for sponsors

Upgrade to PRO version

Metrics

MethodBranch coverage Crap Score Cyclomatic complexity Line coverage
.ctor(...)100%11100%
.cctor()100%210%
GetPageAsync()11.76%7253415.78%
GetAllAsync()35%1542030.55%
CountReportsAsync()0%2040%
get_HasPrices()100%210%
LoadGlobalColumnFlagsAsync()0%110100%
.ctor(...)100%210%
get_ReportId()100%210%
get_GeneratedAtUtc()100%210%
get_OzonClientId()100%210%
get_OzonClientName()100%210%
get_MultiClientIds()100%210%
LoadReportMetasAsync()0%110100%
LoadLinesAsync()0%2970540%
LoadPricesAsync()0%210140%
ParsePriceRow(...)0%2352480%
ParseMultiClientIds(...)100%210%
AddParam(...)0%620%
ArticleInClause(...)100%210%
AddArticleParams(...)0%620%
ResolveArticleVariantsAsync()0%156120%
ReadBool(...)0%110100%

File(s)

/home/runner/work/Studio2Prod/Studio2Prod/AppRepository/BitrixStockServices/BitrixStockSyncArticleHistoryService.cs

#LineLine coverage
 1using System.Text.Json;
 2using Microsoft.EntityFrameworkCore;
 3using Microsoft.Extensions.Caching.Memory;
 4using Microsoft.Extensions.Logging;
 5using Newtonsoft.Json.Linq;
 6using OzonRepositories.Context;
 7
 8namespace Services.BitrixStockServices;
 9
 710public class BitrixStockSyncArticleHistoryService(
 711    IDbContextFactory<ApplicationDbContext> dbFactory,
 712    IMemoryCache cache,
 713    ILogger<BitrixStockSyncArticleHistoryService> logger) : IBitrixStockSyncArticleHistoryService
 14{
 015    private static readonly TimeSpan CacheTtl = TimeSpan.FromMinutes(5);
 16    private const string CompletedStatus = "Completed";
 17
 18    public async Task<BitrixStockArticleHistoryPageDto> GetPageAsync(
 19        string article,
 20        int page = 1,
 21        int pageSize = 20,
 22        CancellationToken cancellationToken = default)
 23    {
 424        article = article.Trim();
 425        if (string.IsNullOrEmpty(article))
 226            return new BitrixStockArticleHistoryPageDto { Article = article };
 27
 228        page = Math.Max(1, page);
 229        pageSize = Math.Clamp(pageSize, 1, 50);
 30        // Ключ по нормализованному артикулу: «8934833060A0» и «89348-33060-A0» дают один результат.
 231        var cacheKey = $"bitrix-art-hist:{BitrixStockArticleVariants.Normalize(article)}:{page}:{pageSize}";
 232        if (cache.TryGetValue<BitrixStockArticleHistoryPageDto>(cacheKey, out var cached) && cached != null)
 233            return cached;
 34
 035        await using var db = await dbFactory.CreateDbContextAsync(cancellationToken);
 036        var conn = db.Database.GetDbConnection();
 037        if (conn.State != System.Data.ConnectionState.Open)
 038            await conn.OpenAsync(cancellationToken);
 39
 040        var articles = await ResolveArticleVariantsAsync(conn, article, cancellationToken);
 041        var totalCount = await CountReportsAsync(conn, articles, cancellationToken);
 042        var globalFlags = await LoadGlobalColumnFlagsAsync(conn, articles, cancellationToken);
 043        var result = new BitrixStockArticleHistoryPageDto
 044        {
 045            Article = article,
 046            Page = page,
 047            PageSize = pageSize,
 048            TotalReportCount = totalCount,
 049            HasPrices = globalFlags.HasPrices,
 050            HasPushes = globalFlags.HasPushes,
 051            IsMultiClient = globalFlags.IsMultiClient
 052        };
 53
 054        if (totalCount == 0)
 55        {
 056            cache.Set(cacheKey, result, CacheTtl);
 057            return result;
 58        }
 59
 060        var reportMetas = await LoadReportMetasAsync(conn, articles, page, pageSize, cancellationToken);
 061        if (reportMetas.Count == 0)
 62        {
 063            cache.Set(cacheKey, result, CacheTtl);
 064            return result;
 65        }
 66
 067        var reportIds = reportMetas.Select(r => r.ReportId).ToList();
 068        var linesByReport = await LoadLinesAsync(conn, articles, reportIds, cancellationToken);
 069        var pricesByReport = await LoadPricesAsync(conn, articles, reportIds, cancellationToken);
 70
 071        foreach (var meta in reportMetas)
 72        {
 073            var block = new BitrixStockArticleHistoryReportBlockDto
 074            {
 075                ReportId = meta.ReportId,
 076                GeneratedAtUtc = meta.GeneratedAtUtc,
 077                OzonClientName = meta.OzonClientName,
 078                MultiClientIds = meta.MultiClientIds.Count > 0 ? meta.MultiClientIds : new List<int> { meta.OzonClientId
 079            };
 80
 081            if (block.IsMultiClient)
 82            {
 083                foreach (var row in linesByReport.GetValueOrDefault(meta.ReportId) ?? new List<BitrixStockSyncReportWare
 84                {
 085                    if (row.OzonClientId > 0 && !string.IsNullOrEmpty(row.OzonClientName))
 086                        block.MultiClientNames[row.OzonClientId] = row.OzonClientName;
 87                }
 88            }
 89
 090            var priceMap = pricesByReport.GetValueOrDefault(meta.ReportId)
 091                ?? new Dictionary<int, OzonPriceInfoDto>();
 092            block.HasPrices = priceMap.Count > 0;
 093            block.HasPushes = priceMap.Values.Any(p =>
 094                p.PricePushedNew.HasValue || !string.IsNullOrEmpty(p.PricePushStatus));
 95
 096            foreach (var line in linesByReport.GetValueOrDefault(meta.ReportId) ?? new List<BitrixStockSyncReportWarehou
 97            {
 098                priceMap.TryGetValue(line.OzonClientId, out var price);
 099                block.Rows.Add(new BitrixStockArticleHistoryRowDto { Line = line, Price = price });
 100            }
 101
 0102            result.Reports.Add(block);
 103        }
 104
 0105        cache.Set(cacheKey, result, CacheTtl);
 0106        return result;
 4107    }
 108
 109    public async Task<BitrixStockArticleHistoryPageDto> GetAllAsync(
 110        string article,
 111        int? maxReports = null,
 112        CancellationToken cancellationToken = default)
 113    {
 3114        article = article.Trim();
 3115        if (string.IsNullOrEmpty(article))
 2116            return new BitrixStockArticleHistoryPageDto { Article = article };
 117
 1118        var limit = maxReports is > 0 ? maxReports.Value : int.MaxValue;
 1119        var normalized = BitrixStockArticleVariants.Normalize(article);
 1120        var cacheKey = limit == int.MaxValue
 1121            ? $"bitrix-art-hist-all:{normalized}"
 1122            : $"bitrix-art-hist-all:{normalized}:{limit}";
 1123        if (cache.TryGetValue<BitrixStockArticleHistoryPageDto>(cacheKey, out var cached) && cached != null)
 1124            return cached;
 125
 126        const int pageSize = 50;
 0127        var first = await GetPageAsync(article, 1, pageSize, cancellationToken);
 0128        if (first.TotalReportCount == 0)
 129        {
 0130            cache.Set(cacheKey, first, CacheTtl);
 0131            return first;
 132        }
 133
 0134        var merged = new BitrixStockArticleHistoryPageDto
 0135        {
 0136            Article = first.Article,
 0137            Page = 1,
 0138            PageSize = first.TotalReportCount,
 0139            TotalReportCount = first.TotalReportCount,
 0140            HasPrices = first.HasPrices,
 0141            HasPushes = first.HasPushes,
 0142            IsMultiClient = first.IsMultiClient,
 0143            ExportReportLimit = limit == int.MaxValue ? null : limit,
 0144            Reports = new List<BitrixStockArticleHistoryReportBlockDto>(first.Reports)
 0145        };
 146
 0147        BitrixStockArticleHistoryPaging.ApplyReportLimit(merged, limit);
 148
 0149        foreach (var page in BitrixStockArticleHistoryPaging.EnumeratePagesAfterFirst(first.TotalReportCount, pageSize))
 150        {
 0151            if (merged.Reports.Count >= limit)
 152                break;
 153
 0154            var chunk = await GetPageAsync(article, page, pageSize, cancellationToken);
 0155            merged.Reports.AddRange(chunk.Reports);
 0156            BitrixStockArticleHistoryPaging.ApplyReportLimit(merged, limit);
 157        }
 158
 0159        merged.ExportTruncated = merged.TotalReportCount > merged.Reports.Count;
 0160        cache.Set(cacheKey, merged, CacheTtl);
 0161        return merged;
 3162    }
 163
 164    private static async Task<int> CountReportsAsync(
 165        System.Data.Common.DbConnection conn,
 166        IReadOnlyList<string> articles,
 167        CancellationToken ct)
 168    {
 0169        await using var cmd = conn.CreateCommand();
 0170        cmd.CommandText = $@"
 0171SELECT COUNT(DISTINCT r.ReportId)
 0172FROM BitrixStockSyncReports r
 0173INNER JOIN BitrixStockSyncReportLines l
 0174  ON l.ReportId = r.ReportId AND l.Article IN ({ArticleInClause(articles)})
 0175WHERE r.Status = @status;";
 0176        AddArticleParams(cmd, articles);
 0177        AddParam(cmd, "@status", CompletedStatus);
 0178        return Convert.ToInt32(await cmd.ExecuteScalarAsync(ct) ?? 0);
 0179    }
 180
 0181    private sealed record GlobalColumnFlags(bool HasPrices, bool HasPushes, bool IsMultiClient);
 182
 183    private async Task<GlobalColumnFlags> LoadGlobalColumnFlagsAsync(
 184        System.Data.Common.DbConnection conn,
 185        IReadOnlyList<string> articles,
 186        CancellationToken ct)
 187    {
 0188        var cacheKey = $"bitrix-art-hist-flags:{BitrixStockArticleVariants.Normalize(articles[0])}";
 0189        if (cache.TryGetValue(cacheKey, out GlobalColumnFlags? cached) && cached != null)
 0190            return cached;
 191
 0192        var inClause = ArticleInClause(articles);
 0193        await using var cmd = conn.CreateCommand();
 0194        cmd.CommandText = $@"
 0195SELECT
 0196  EXISTS (
 0197    SELECT 1 FROM BitrixStockSyncReportOzonPrices p
 0198    INNER JOIN BitrixStockSyncReports r ON r.ReportId = p.ReportId AND r.Status = @status
 0199    WHERE p.Article IN ({inClause})
 0200      AND p.PriceDataJson IS NOT NULL AND p.PriceDataJson != ''
 0201  ) AS HasPrices,
 0202  EXISTS (
 0203    SELECT 1 FROM BitrixStockSyncReportOzonPrices p
 0204    INNER JOIN BitrixStockSyncReports r ON r.ReportId = p.ReportId AND r.Status = @status
 0205    WHERE p.Article IN ({inClause})
 0206      AND (p.PricePushedNew IS NOT NULL OR (p.PricePushStatus IS NOT NULL AND p.PricePushStatus != ''))
 0207  ) AS HasPushes,
 0208  EXISTS (
 0209    SELECT 1 FROM BitrixStockSyncReports r
 0210    WHERE r.Status = @status
 0211      AND r.MultiClientIdsJson IS NOT NULL
 0212      AND r.MultiClientIdsJson != '[]'
 0213      AND EXISTS (
 0214        SELECT 1 FROM BitrixStockSyncReportLines l
 0215        WHERE l.ReportId = r.ReportId AND l.Article IN ({inClause})
 0216      )
 0217  ) AS IsMultiClient;";
 0218        AddArticleParams(cmd, articles);
 0219        AddParam(cmd, "@status", CompletedStatus);
 220
 0221        await using var reader = await cmd.ExecuteReaderAsync(ct);
 0222        var flags = new GlobalColumnFlags(false, false, false);
 0223        if (await reader.ReadAsync(ct))
 224        {
 0225            flags = new GlobalColumnFlags(
 0226                ReadBool(reader, 0),
 0227                ReadBool(reader, 1),
 0228                ReadBool(reader, 2));
 229        }
 230
 0231        cache.Set(cacheKey, flags, CacheTtl);
 0232        return flags;
 0233    }
 234
 0235    private sealed record ReportMetaRow(
 0236        string ReportId,
 0237        DateTime GeneratedAtUtc,
 0238        int OzonClientId,
 0239        string OzonClientName,
 0240        List<int> MultiClientIds);
 241
 242    private static async Task<List<ReportMetaRow>> LoadReportMetasAsync(
 243        System.Data.Common.DbConnection conn,
 244        IReadOnlyList<string> articles,
 245        int page,
 246        int pageSize,
 247        CancellationToken ct)
 248    {
 0249        await using var cmd = conn.CreateCommand();
 0250        cmd.CommandText = $@"
 0251SELECT r.ReportId, r.GeneratedAtUtc, r.OzonClientId, r.OzonClientName,
 0252       COALESCE(r.MultiClientIdsJson, '[]')
 0253FROM BitrixStockSyncReports r
 0254WHERE r.Status = @status
 0255  AND EXISTS (
 0256    SELECT 1 FROM BitrixStockSyncReportLines l
 0257    WHERE l.ReportId = r.ReportId AND l.Article IN ({ArticleInClause(articles)})
 0258  )
 0259ORDER BY r.GeneratedAtUtc DESC
 0260LIMIT @take OFFSET @skip;";
 0261        AddArticleParams(cmd, articles);
 0262        AddParam(cmd, "@status", CompletedStatus);
 0263        AddParam(cmd, "@take", pageSize);
 0264        AddParam(cmd, "@skip", (page - 1) * pageSize);
 265
 0266        var list = new List<ReportMetaRow>();
 0267        await using var r = await cmd.ExecuteReaderAsync(ct);
 0268        while (await r.ReadAsync(ct))
 269        {
 0270            list.Add(new ReportMetaRow(
 0271                r.GetString(0),
 0272                r.GetDateTime(1),
 0273                r.GetInt32(2),
 0274                r.IsDBNull(3) ? "" : r.GetString(3),
 0275                ParseMultiClientIds(r.IsDBNull(4) ? "[]" : r.GetString(4))));
 276        }
 0277        return list;
 0278    }
 279
 280    private static async Task<Dictionary<string, List<BitrixStockSyncReportWarehouseLineDto>>> LoadLinesAsync(
 281        System.Data.Common.DbConnection conn,
 282        IReadOnlyList<string> articles,
 283        List<string> reportIds,
 284        CancellationToken ct)
 285    {
 0286        var result = new Dictionary<string, List<BitrixStockSyncReportWarehouseLineDto>>(StringComparer.OrdinalIgnoreCas
 0287        if (reportIds.Count == 0) return result;
 288
 0289        var inClause = string.Join(",", reportIds.Select((_, i) => $"@rid{i}"));
 0290        await using var cmd = conn.CreateCommand();
 0291        cmd.CommandText = $@"
 0292SELECT l.ReportId, l.Id, l.ProductId, l.Article, l.OneCStore, l.OzonWarehouseName, l.OzonWarehouseId, l.MappingDescripti
 0293l.OneCQty, l.OzonQty, l.Delta, l.Action, l.ActionLabel, l.ActionIcon, l.RowClass, l.Note, l.OzonProductId, l.OfferId,
 0294l.OzonApplyStatus, l.OzonAppliedUtc, l.OzonApplyError, l.OzonQtyAfter,
 0295sm.ManufacturerName,
 0296l.OneCPrice, l.PrevOneCPrice,
 0297l.IsArchived, l.IsAutoArchived,
 0298COALESCE(l.OzonClientId, 0) AS OzonClientId, COALESCE(l.OzonClientName, '') AS OzonClientName,
 0299l.OzonCleanupStatus, l.OzonCleanupAtUtc, l.OzonCleanupError,
 0300COALESCE(l.InPromo, 0) AS InPromo, l.PromoName,
 0301COALESCE(l.IsOzonOrphan, 0) AS IsOzonOrphan, l.OzonOrphanStatus
 0302FROM BitrixStockSyncReportLines l
 0303LEFT JOIN (
 0304    SELECT Article, MIN(Manufacturer) AS ManufacturerName
 0305    FROM WarehouseStockSnapshot
 0306    WHERE Manufacturer IS NOT NULL AND TRIM(Manufacturer) != ''
 0307    GROUP BY Article
 0308) sm ON sm.Article = l.Article
 0309WHERE l.Article IN ({ArticleInClause(articles)})
 0310  AND l.ReportId IN ({inClause})
 0311ORDER BY l.ReportId, l.OneCStore, l.OzonWarehouseName, l.OzonClientId;";
 0312        AddArticleParams(cmd, articles);
 0313        for (var i = 0; i < reportIds.Count; i++)
 0314            AddParam(cmd, $"@rid{i}", reportIds[i]);
 315
 0316        await using var reader = await cmd.ExecuteReaderAsync(ct);
 0317        while (await reader.ReadAsync(ct))
 318        {
 0319            var reportId = reader.GetString(0);
 0320            var line = new BitrixStockSyncReportWarehouseLineDto
 0321            {
 0322                LineId = reader.GetInt64(1),
 0323                ProductId = reader.GetInt32(2),
 0324                Article = reader.GetString(3),
 0325                OneCStore = reader.GetString(4),
 0326                OzonWarehouseName = reader.GetString(5),
 0327                OzonWarehouseId = reader.GetInt64(6),
 0328                MappingDescription = reader.GetString(7),
 0329                OneCQty = reader.GetInt32(8),
 0330                OzonQty = reader.GetInt32(9),
 0331                Delta = reader.GetInt32(10),
 0332                Action = reader.GetString(11),
 0333                ActionLabel = reader.GetString(12),
 0334                ActionIcon = reader.GetString(13),
 0335                RowClass = reader.GetString(14),
 0336                Note = reader.IsDBNull(15) ? null : reader.GetString(15),
 0337                OzonProductId = reader.IsDBNull(16) ? null : reader.GetInt64(16),
 0338                OfferId = reader.IsDBNull(17) ? null : reader.GetString(17),
 0339                OzonApplyStatus = reader.IsDBNull(18) ? null : reader.GetString(18),
 0340                OzonAppliedUtc = reader.IsDBNull(19) ? null : reader.GetDateTime(19),
 0341                OzonApplyError = reader.IsDBNull(20) ? null : reader.GetString(20),
 0342                OzonQtyAfter = reader.IsDBNull(21) ? null : reader.GetInt32(21),
 0343                ManufacturerName = reader.IsDBNull(22) ? null : reader.GetString(22),
 0344                OneCPrice = reader.IsDBNull(23) ? null : Convert.ToDecimal(reader.GetValue(23)),
 0345                PrevOneCPrice = reader.IsDBNull(24) ? null : Convert.ToDecimal(reader.GetValue(24)),
 0346                IsArchived = !reader.IsDBNull(25) && reader.GetInt32(25) != 0,
 0347                IsAutoArchived = !reader.IsDBNull(26) && reader.GetInt32(26) != 0,
 0348                OzonClientId = reader.IsDBNull(27) ? 0 : reader.GetInt32(27),
 0349                OzonClientName = reader.IsDBNull(28) ? "" : reader.GetString(28),
 0350                OzonCleanupStatus = reader.IsDBNull(29) ? null : reader.GetString(29),
 0351                OzonCleanupAtUtc = reader.IsDBNull(30) ? null : reader.GetDateTime(30),
 0352                OzonCleanupError = reader.IsDBNull(31) ? null : reader.GetString(31),
 0353                InPromo = !reader.IsDBNull(32) && reader.GetInt32(32) != 0,
 0354                PromoName = reader.IsDBNull(33) ? null : reader.GetString(33),
 0355                IsOzonOrphan = !reader.IsDBNull(34) && reader.GetInt32(34) != 0,
 0356                OzonOrphanStatus = reader.IsDBNull(35) ? null : reader.GetString(35)
 0357            };
 0358            if (!result.TryGetValue(reportId, out var rows))
 359            {
 0360                rows = new List<BitrixStockSyncReportWarehouseLineDto>();
 0361                result[reportId] = rows;
 362            }
 0363            rows.Add(line);
 364        }
 0365        return result;
 0366    }
 367
 368    private async Task<Dictionary<string, Dictionary<int, OzonPriceInfoDto>>> LoadPricesAsync(
 369        System.Data.Common.DbConnection conn,
 370        IReadOnlyList<string> articles,
 371        List<string> reportIds,
 372        CancellationToken ct)
 373    {
 0374        var result = new Dictionary<string, Dictionary<int, OzonPriceInfoDto>>(StringComparer.OrdinalIgnoreCase);
 0375        if (reportIds.Count == 0) return result;
 376
 0377        var inClause = string.Join(",", reportIds.Select((_, i) => $"@prid{i}"));
 0378        await using var cmd = conn.CreateCommand();
 0379        cmd.CommandText = $@"
 0380SELECT ReportId, Article, OzonClientId, PriceDataJson,
 0381       PricePushedOld, PricePushedNew, PricePushedAtUtc, PushedMarkupPct,
 0382       PushedColorIndexBefore, PushedColorIndexAfter, PushedCompetitorPrice,
 0383       PricePushStatus, PricePushError
 0384FROM BitrixStockSyncReportOzonPrices
 0385WHERE Article IN ({ArticleInClause(articles)})
 0386  AND ReportId IN ({inClause});";
 0387        AddArticleParams(cmd, articles);
 0388        for (var i = 0; i < reportIds.Count; i++)
 0389            AddParam(cmd, $"@prid{i}", reportIds[i]);
 390
 0391        await using var reader = await cmd.ExecuteReaderAsync(ct);
 0392        while (await reader.ReadAsync(ct))
 393        {
 0394            var reportId = reader.GetString(0);
 0395            var dto = ParsePriceRow(reader, reportId);
 0396            if (dto == null) continue;
 0397            if (!result.TryGetValue(reportId, out var map))
 398            {
 0399                map = new Dictionary<int, OzonPriceInfoDto>();
 0400                result[reportId] = map;
 401            }
 0402            map[dto.OzonClientId] = dto;
 403        }
 0404        return result;
 0405    }
 406
 407    private OzonPriceInfoDto? ParsePriceRow(System.Data.Common.DbDataReader reader, string reportId)
 408    {
 409        try
 410        {
 0411            var json = reader.IsDBNull(3) ? null : reader.GetString(3);
 0412            if (string.IsNullOrEmpty(json)) return null;
 0413            var item = JObject.Parse(json);
 0414            var priceObj = item["price"] as JObject ?? item;
 0415            var indexesObj = item["price_indexes"] as JObject;
 0416            var truePrice = priceObj["price"]?.Value<decimal?>()
 0417                         ?? priceObj["customer_price"]?.Value<decimal?>()
 0418                         ?? priceObj["true_price"]?.Value<decimal?>();
 0419            var sellingPrice = priceObj["marketing_seller_price"]?.Value<decimal?>()
 0420                            ?? priceObj["price"]?.Value<decimal?>();
 0421            var colorIndex = indexesObj?["color_index"]?.Value<string>();
 422
 0423            return new OzonPriceInfoDto
 0424            {
 0425                Article = reader.GetString(1),
 0426                OzonClientId = reader.GetInt32(2),
 0427                TruePrice = truePrice,
 0428                SellingPrice = sellingPrice,
 0429                ColorIndex = colorIndex,
 0430                PricePushedOld = reader.IsDBNull(4) ? null : reader.GetDecimal(4),
 0431                PricePushedNew = reader.IsDBNull(5) ? null : reader.GetDecimal(5),
 0432                PricePushedAtUtc = reader.IsDBNull(6) ? null : reader.GetDateTime(6),
 0433                PushedMarkupPct = reader.IsDBNull(7) ? null : reader.GetDecimal(7),
 0434                PushedColorIndexBefore = reader.IsDBNull(8) ? null : reader.GetString(8),
 0435                PushedColorIndexAfter = reader.IsDBNull(9) ? null : reader.GetString(9),
 0436                PushedCompetitorPrice = reader.IsDBNull(10) ? null : reader.GetDecimal(10),
 0437                PricePushStatus = reader.IsDBNull(11) ? null : reader.GetString(11),
 0438                PricePushError = reader.IsDBNull(12) ? null : reader.GetString(12),
 0439            };
 440        }
 0441        catch (Exception ex)
 442        {
 0443            logger.LogWarning(ex,
 0444                "BitrixStock article history: failed to parse Ozon price JSON for report {ReportId}, article {Article}, 
 0445                reportId,
 0446                reader.IsDBNull(1) ? "" : reader.GetString(1),
 0447                reader.IsDBNull(2) ? 0 : reader.GetInt32(2));
 0448            return null;
 449        }
 0450    }
 451
 452    private static List<int> ParseMultiClientIds(string json) =>
 0453        BitrixStockMultiClientParser.ParseMultiClientIds(json);
 454
 455    private static void AddParam(System.Data.Common.DbCommand cmd, string name, object? value)
 456    {
 0457        var p = cmd.CreateParameter();
 0458        p.ParameterName = name;
 0459        p.Value = value ?? DBNull.Value;
 0460        cmd.Parameters.Add(p);
 0461    }
 462
 463    /// <summary>Список параметров для <c>Article IN (…)</c> — все написания артикула.</summary>
 464    // Без COLLATE: колонки Article в BitrixStockSyncReportLines, BitrixStockSyncReportOzonPrices
 465    // и WarehouseStockSnapshot уже объявлены как utf8mb4_unicode_ci, а явный COLLATE на колонке
 466    // делает предикат несаргабельным — индекс по Article перестаёт использоваться и запрос
 467    // уходит в полный скан (COUNT по истории упирался в CommandTimeout).
 468    private static string ArticleInClause(IReadOnlyList<string> articles) =>
 0469        string.Join(",", articles.Select((_, i) => $"@art{i}"));
 470
 471    private static void AddArticleParams(System.Data.Common.DbCommand cmd, IReadOnlyList<string> articles)
 472    {
 0473        for (var i = 0; i < articles.Count; i++)
 0474            AddParam(cmd, $"@art{i}", articles[i]);
 0475    }
 476
 477    /// <summary>
 478    /// Все написания артикула, встречающиеся в базе: «8934833060A0» и «89348-33060-A0» — одна и та же
 479    /// деталь, но в 1С и в Ozon это разные карточки, и строки отчёта пишутся под тем написанием,
 480    /// которое пришло из источника.
 481    /// Кандидаты берутся из <c>WarehouseStockSnapshot</c> (таблица маленькая, скан дешёвый) и из
 482    /// <c>BitrixStockSyncReportLines</c> префиксным range-scan'ом по индексу Article — полный скан
 483    /// 2,7 млн строк с REPLACE() здесь недопустим.
 484    /// </summary>
 485    private static async Task<List<string>> ResolveArticleVariantsAsync(
 486        System.Data.Common.DbConnection conn,
 487        string article,
 488        CancellationToken ct)
 489    {
 0490        var patterns = BitrixStockArticleVariants.BuildPrefixPatterns(article);
 0491        if (patterns.Count == 0)
 0492            return new List<string> { article };
 493
 0494        var candidates = new List<string>();
 0495        var likeClause = string.Join(" OR ", patterns.Select((_, i) => $"Article LIKE @pat{i}"));
 496
 0497        await using var cmd = conn.CreateCommand();
 0498        cmd.CommandText = $@"
 0499SELECT DISTINCT Article FROM WarehouseStockSnapshot WHERE {likeClause}
 0500UNION
 0501SELECT DISTINCT Article FROM BitrixStockSyncReportLines WHERE {likeClause};";
 0502        for (var i = 0; i < patterns.Count; i++)
 0503            AddParam(cmd, $"@pat{i}", patterns[i]);
 504
 0505        await using var reader = await cmd.ExecuteReaderAsync(ct);
 0506        while (await reader.ReadAsync(ct))
 507        {
 0508            if (!reader.IsDBNull(0))
 0509                candidates.Add(reader.GetString(0));
 510        }
 511
 0512        return BitrixStockArticleVariants.SelectMatching(article, candidates);
 0513    }
 514
 515    private static bool ReadBool(System.Data.Common.DbDataReader reader, int ordinal)
 516    {
 0517        if (reader.IsDBNull(ordinal))
 0518            return false;
 0519        return reader.GetValue(ordinal) switch
 0520        {
 0521            bool b => b,
 0522            int i => i != 0,
 0523            long l => l != 0,
 0524            decimal d => d != 0,
 0525            _ => Convert.ToBoolean(reader.GetValue(ordinal))
 0526        };
 527    }
 528}