< Summary

Information
Class: Services.BitrixStockServices.BitrixStockSyncReportExcelBuilder
Assembly: Services
File(s): /home/runner/work/Studio2Prod/Studio2Prod/AppRepository/BitrixStockServices/BitrixStockSyncReportExcelBuilder.cs
Line coverage
66%
Covered lines: 399
Uncovered lines: 204
Coverable lines: 603
Total lines: 823
Line coverage: 66.1%
Branch coverage
39%
Covered branches: 173
Total branches: 438
Branch coverage: 39.4%
Method coverage

Feature is only available for sponsors

Upgrade to PRO version

Metrics

MethodBranch coverage Crap Score Cyclomatic complexity Line coverage
.cctor()100%11100%
BuildAsync()34.75%604828258.3%
GetSkipLabel()100%210%
GetTargetQty()100%11100%
BuildArticleHistoryAsync()70.83%2424100%
BuildHeaders(...)50%66100%
WriteHeaderRow(...)100%22100%
WriteWarehouseRow(...)40.17%115911256.3%
ApplyColumnWidths(...)83.33%7671.42%
GetSkipLabelStatic(...)100%11100%

File(s)

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

#LineLine coverage
 1using ClosedXML.Excel;
 2
 3namespace Services.BitrixStockServices;
 4
 5/// <summary>
 6/// Строит Excel-файл для отчёта синхронизации остатков.
 7/// Используется как из веб-контроллера, так и из фонового джоба отправки почты.
 8/// </summary>
 9public static class BitrixStockSyncReportExcelBuilder
 10{
 111    private static readonly Dictionary<string, XLColor> ActionColors =
 112        new(StringComparer.OrdinalIgnoreCase)
 113        {
 114            ["Push"]     = XLColor.FromArgb(0xCF, 0xE2, 0xFF),
 115            ["Decrease"] = XLColor.FromArgb(0xFF, 0xF3, 0xCD),
 116            ["Clear"]    = XLColor.FromArgb(0xF8, 0xD7, 0xDA),
 117            ["Match"]    = XLColor.FromArgb(0xD1, 0xE7, 0xDD),
 118            ["Skip"]     = XLColor.FromArgb(0xE9, 0xEC, 0xEF),
 119            ["NotFound"]       = XLColor.FromArgb(0xF3, 0xE8, 0xFF),
 120            ["FbsNoWarehouse"] = XLColor.FromArgb(0xFF, 0xE8, 0xCC),
 121            ["NotForSale"]     = XLColor.FromArgb(0xFF, 0xCC, 0xCC),
 122            ["CleanupExtra"]          = XLColor.FromArgb(0xCF, 0xFA, 0xFE),
 123            ["ClearDisabledLink"]     = XLColor.FromArgb(0xE9, 0xEC, 0xEF),
 124            ["BatchVariant"]          = XLColor.FromArgb(0xD0, 0xE4, 0xFF),
 125            ["BatchVariantDisabledLink"] = XLColor.FromArgb(0xE9, 0xEC, 0xEF),
 126            ["BatchVariantZeroStock"]    = XLColor.FromArgb(0xFF, 0xF3, 0xCD),
 127            ["BatchVariantNotCreated"]   = XLColor.FromArgb(0xFF, 0xD6, 0xD6),
 128            ["BatchVariantNotInFbs"]     = XLColor.FromArgb(0xFF, 0xE8, 0xCC),
 129        };
 30
 131    private static readonly Dictionary<string, (XLColor bg, XLColor fg, string label)> IndexStyles =
 132        new(StringComparer.OrdinalIgnoreCase)
 133        {
 134            ["SUPER"]         = (XLColor.FromArgb(0xED, 0xE9, 0xFE), XLColor.FromArgb(0x7C, 0x3A, 0xED), "Супервыгодный"
 135            ["GREEN"]         = (XLColor.FromArgb(0xD1, 0xE7, 0xDD), XLColor.FromArgb(0x0A, 0x3C, 0x22), "Выгодный"),
 136            ["YELLOW"]        = (XLColor.FromArgb(0xFF, 0xF3, 0xCD), XLColor.FromArgb(0x66, 0x42, 0x00), "Умеренный"),
 137            ["RED"]           = (XLColor.FromArgb(0xF8, 0xD7, 0xDA), XLColor.FromArgb(0x84, 0x1F, 0x27), "Невыгодный"),
 138            ["WITHOUT_INDEX"] = (XLColor.FromArgb(0xE9, 0xEC, 0xEF), XLColor.Gray,                       "Нет индекса"),
 139            // API-совместимость (старые имена)
 140            ["BENEFIT"]       = (XLColor.FromArgb(0xD1, 0xE7, 0xDD), XLColor.FromArgb(0x0A, 0x3C, 0x22), "Выгодный"),
 141            ["AVERAGE"]       = (XLColor.FromArgb(0xFF, 0xF3, 0xCD), XLColor.FromArgb(0x66, 0x42, 0x00), "Умеренный"),
 142            ["OVERPRICED"]    = (XLColor.FromArgb(0xF8, 0xD7, 0xDA), XLColor.FromArgb(0x84, 0x1F, 0x27), "Невыгодный"),
 143        };
 44
 45    /// <summary>Загружает все строки отчёта постранично и возвращает Excel-файл в байтах.</summary>
 46    public static async Task<(byte[]? bytes, string filename)> BuildAsync(
 47        IBitrixStockSyncReportService reportService,
 48        string reportId,
 49        BitrixStockSyncReportQuery? filterQuery = null,
 50        Action<BitrixStockExcelBuildProgress>? progress = null,
 51        CancellationToken ct = default)
 52    {
 1353        filterQuery ??= new BitrixStockSyncReportQuery();
 54        const int batchSize = 2000;
 1355        var allRows = new List<BitrixStockSyncReportWarehouseLineDto>();
 1356        string? clientName = null;
 1357        DateTime generatedAt = default;
 1358        var isMultiClient = false;
 59
 1360        progress?.Invoke(new BitrixStockExcelBuildProgress
 1361        {
 1362            Stage = BitrixStockSyncReportExportStages.Loading,
 1363            Current = 0,
 1364            Total = 100,
 1365            Percent = 5,
 1366            Message = "Загрузка страниц отчёта"
 1367        });
 68
 1369        int page = 1;
 1370        var totalPages = 1;
 071        while (true)
 72        {
 1373            var q = new BitrixStockSyncReportQuery
 1374            {
 1375                Page = page, PageSize = batchSize,
 1376                Article = filterQuery.Article, OneCStores = filterQuery.OneCStores,
 1377                OzonWarehouse = filterQuery.OzonWarehouse, Action = filterQuery.Action,
 1378                OnlyRequiresAction = filterQuery.OnlyRequiresAction,
 1379                OnlyOzonAppliedSuccess = filterQuery.OnlyOzonAppliedSuccess,
 1380                // Экспорт всегда содержит все строки отчёта, включая «пустые» (OneCQty=0 И OzonQty=0),
 1381                // чтобы выгрузка совпадала с таблицей BitrixStockSyncReportLines и не было путаницы.
 1382                ShowZeroRows = true
 1383            };
 1384            var report = await reportService.GetReportAsync(reportId, q, ct);
 2185            if (report == null) return (null, string.Empty);
 586            totalPages = Math.Max(1, report.TotalPages);
 87
 588            clientName ??= report.OzonClientName;
 1089            if (generatedAt == default) generatedAt = report.GeneratedAtUtc;
 590            if (report.MultiClientIds.Count > 1)
 91            {
 092                isMultiClient = true;
 93            }
 94
 595            allRows.AddRange(report.Rows);
 596            progress?.Invoke(new BitrixStockExcelBuildProgress
 597            {
 598                Stage = BitrixStockSyncReportExportStages.Loading,
 599                Current = page,
 5100                Total = totalPages,
 5101                Percent = 5 + (int)Math.Round((Math.Min(page, totalPages) / (double)totalPages) * 35d),
 5102                Message = $"Загружена страница {page} из {totalPages}"
 5103            });
 5104            if (page >= report.TotalPages) break;
 0105            page++;
 106        }
 107
 108        // Загружаем снимки цен Ozon для этого отчёта
 5109        progress?.Invoke(new BitrixStockExcelBuildProgress
 5110        {
 5111            Stage = BitrixStockSyncReportExportStages.Loading,
 5112            Current = totalPages,
 5113            Total = totalPages,
 5114            Percent = 42,
 5115            Message = "Загрузка снимков цен Ozon"
 5116        });
 5117        var priceInfoList = await reportService.GetOzonPriceInfoAsync(reportId, ct);
 5118        var priceMap = priceInfoList.ToDictionary(
 0119            p => (p.Article.ToUpperInvariant(), p.OzonClientId),
 5120            p => p);
 121
 5122        progress?.Invoke(new BitrixStockExcelBuildProgress
 5123        {
 5124            Stage = BitrixStockSyncReportExportStages.Building,
 5125            Current = 0,
 5126            Total = Math.Max(1, allRows.Count),
 5127            Percent = 45,
 5128            Message = "Подготовка структуры Excel"
 5129        });
 130
 5131        using var wb = new XLWorkbook();
 5132        var ws = wb.Worksheets.Add("Синхронизация остатков");
 133
 5134        var hasPrices = priceMap.Count > 0;
 5135        var hasPushes = hasPrices && priceMap.Values.Any(p => p.PricePushedNew.HasValue || p.PricePushStatus != null);
 5136        var extraCols = hasPrices ? 3 : 0;               // Цена OZ | Цена продажи | Индекс
 5137        var pushCols  = hasPushes ? 7 : 0;               // Цена до | Цена после | Конкурент | Инд.до | Инд.после | Надб
 5138        var totalCols = (isMultiClient ? 15 : 14) + extraCols + pushCols;
 5139        ws.Cell(1, 1).Value = $"Отчёт синхронизации: {clientName}";
 5140        ws.Cell(1, 1).Style.Font.Bold = true;
 5141        ws.Cell(1, 1).Style.Font.FontSize = 13;
 5142        ws.Range(1, 1, 1, totalCols).Merge();
 143
 5144        ws.Cell(2, 1).Value = $"Сформирован: {BitrixStockMskDateTime.Format(generatedAt)} (MSK)  |  Строк: {allRows.Coun
 5145        ws.Cell(2, 1).Style.Font.Italic = true;
 5146        ws.Range(2, 1, 2, totalCols).Merge();
 147
 5148        var baseHeaders = isMultiClient
 5149            ? new[] { "Артикул", "Производитель", "C0", "Δ C0", "Клиент Ozon", "Склад 1С", "Склад Ozon", "1С", "Было (Oz
 5150            : new[] { "Артикул", "Производитель", "C0", "Δ C0", "Склад 1С", "Склад Ozon", "1С", "Было (Ozon)", "Нужно", 
 5151        var pushHeaders = hasPushes
 5152            ? new[] { "Цена до наката", "Цена после наката", "Конкурент", "Индекс до", "Индекс после", "Распродажа", "Да
 5153            : Array.Empty<string>();
 5154        var headers = hasPrices
 5155            ? baseHeaders.Take(4)
 5156                .Concat(new[] { "Цена (со скидками)", "Цена продажи", "Индекс цены" })
 5157                .Concat(pushHeaders)
 5158                .Concat(baseHeaders.Skip(4))
 5159                .ToArray()
 5160            : baseHeaders;
 160161        for (int c = 0; c < headers.Length; c++)
 162        {
 75163            var cell = ws.Cell(4, c + 1);
 75164            cell.Value = headers[c];
 75165            cell.Style.Font.Bold = true;
 75166            cell.Style.Fill.BackgroundColor = XLColor.FromArgb(0x21, 0x96, 0xF3);
 75167            cell.Style.Font.FontColor = XLColor.White;
 75168            cell.Style.Alignment.Horizontal = XLAlignmentHorizontalValues.Center;
 169        }
 170
 5171        int row = 5;
 5172        var renderedRows = 0;
 18173        foreach (var w in allRows)
 174        {
 4175            var col = 1;
 4176            ws.Cell(row, col++).Value = w.Article;
 4177            ws.Cell(row, col++).Value = w.ManufacturerName ?? "";
 4178            var priceCell = ws.Cell(row, col++);
 4179            if (w.OneCPrice.HasValue) { priceCell.Value = w.OneCPrice.Value; priceCell.Style.NumberFormat.Format = "#,##
 4180            else priceCell.Value = "";
 181            // Δ C0
 4182            var deltaC0Cell = ws.Cell(row, col++);
 4183            if (w.OneCPrice.HasValue && w.PrevOneCPrice.HasValue)
 184            {
 0185                var diff = (long)Math.Round(w.OneCPrice.Value) - (long)Math.Round(w.PrevOneCPrice.Value);
 0186                var prevFmt = ((long)Math.Round(w.PrevOneCPrice.Value)).ToString("N0");
 0187                if (diff == 0)
 188                {
 0189                    deltaC0Cell.Value = "=";
 0190                    deltaC0Cell.Style.Font.FontColor = XLColor.Gray;
 191                }
 0192                else if (diff > 0)
 193                {
 0194                    deltaC0Cell.Value = $"↑ +{diff:N0} (было {prevFmt})";
 0195                    deltaC0Cell.Style.Font.FontColor = XLColor.FromArgb(0x19, 0x87, 0x54);
 196                }
 197                else
 198                {
 0199                    deltaC0Cell.Value = $"↓ {diff:N0} (было {prevFmt})";
 0200                    deltaC0Cell.Style.Font.FontColor = XLColor.FromArgb(0xDC, 0x35, 0x45);
 201                }
 202            }
 4203            else deltaC0Cell.Value = "";
 204
 205            // Цены Ozon (если есть снимок для этого артикула + клиента)
 4206            if (hasPrices)
 207            {
 0208                priceMap.TryGetValue(((w.Article ?? string.Empty).ToUpperInvariant(), w.OzonClientId), out var pi);
 209
 0210                var truePriceCell = ws.Cell(row, col++);
 0211                if (pi?.TruePrice is { } tp) { truePriceCell.Value = (double)Math.Round(tp); truePriceCell.Style.NumberF
 0212                else truePriceCell.Value = "";
 213
 0214                var sellingPriceCell = ws.Cell(row, col++);
 0215                if (pi?.SellingPrice is { } sp) { sellingPriceCell.Value = (double)Math.Round(sp); sellingPriceCell.Styl
 0216                else sellingPriceCell.Value = "";
 217
 0218                var indexCell = ws.Cell(row, col++);
 0219                if (!string.IsNullOrEmpty(pi?.ColorIndex) && IndexStyles.TryGetValue(pi.ColorIndex, out var style))
 220                {
 0221                    indexCell.Value = style.label;
 0222                    indexCell.Style.Fill.BackgroundColor = style.bg;
 0223                    indexCell.Style.Font.FontColor       = style.fg;
 0224                    indexCell.Style.Font.Bold            = true;
 0225                    indexCell.Style.Alignment.Horizontal = XLAlignmentHorizontalValues.Center;
 226                }
 0227                else indexCell.Value = "";
 228
 0229                if (hasPushes)
 230                {
 0231                    var pushedOldCell = ws.Cell(row, col++);
 0232                    if (pi?.PricePushedOld is { } po) { pushedOldCell.Value = (double)Math.Round(po); pushedOldCell.Styl
 0233                    else pushedOldCell.Value = "";
 234
 0235                    var pushedNewCell = ws.Cell(row, col++);
 0236                    var batchMult     = pi != null ? BitrixStockOrphanOfferMatching.ExtractBatchMultiplier(pi.OfferId) :
 0237                    var batchSuffix   = batchMult > 1 ? $" ×{batchMult}" : "";
 0238                    if (pi?.PricePushedNew is { } pn)
 239                    {
 0240                        pushedNewCell.Value = (double)Math.Round(pn);
 0241                        pushedNewCell.Style.NumberFormat.Format = batchMult > 1 ? $"#,##0\"{batchSuffix}\"" : "#,##0";
 0242                        pushedNewCell.Style.Font.Bold = true;
 0243                        pushedNewCell.Style.Font.FontColor = XLColor.FromArgb(0x19, 0x87, 0x54);
 244                    }
 0245                    else if (pi?.PricePushStatus == "Unchanged")
 246                    {
 0247                        var curPrice = pi.SellingPrice ?? pi.TruePrice;
 0248                        pushedNewCell.Value = curPrice.HasValue
 0249                            ? $"без изменений · {(long)Math.Round(curPrice.Value):N0}{batchSuffix}"
 0250                            : "без изменений";
 0251                        pushedNewCell.Style.Font.FontColor = XLColor.Gray;
 0252                        pushedNewCell.Style.Font.Italic = true;
 253                    }
 0254                    else if (pi?.PricePushStatus == "Error")
 255                    {
 0256                        var curPrice = pi.SellingPrice ?? pi.TruePrice;
 0257                        var errText  = string.IsNullOrEmpty(pi.PricePushError) ? "Ошибка" : $"Ошибка: {pi.PricePushError
 0258                        pushedNewCell.Value = curPrice.HasValue ? $"{errText} · {(long)Math.Round(curPrice.Value):N0}{ba
 0259                        pushedNewCell.Style.Font.FontColor = XLColor.FromArgb(0xDC, 0x35, 0x45);
 260                    }
 0261                    else pushedNewCell.Value = "";
 262
 0263                    var competitorCell = ws.Cell(row, col++);
 0264                    if (pi?.PushedCompetitorPrice is { } cp) { competitorCell.Value = (double)Math.Round(cp); competitor
 0265                    else competitorCell.Value = "";
 266
 0267                    var idxBeforeCell = ws.Cell(row, col++);
 0268                    if (!string.IsNullOrEmpty(pi?.PushedColorIndexBefore) && IndexStyles.TryGetValue(pi.PushedColorIndex
 269                    {
 0270                        idxBeforeCell.Value = sb.label;
 0271                        idxBeforeCell.Style.Fill.BackgroundColor = sb.bg;
 0272                        idxBeforeCell.Style.Font.FontColor       = sb.fg;
 0273                        idxBeforeCell.Style.Font.Bold            = true;
 0274                        idxBeforeCell.Style.Alignment.Horizontal = XLAlignmentHorizontalValues.Center;
 275                    }
 0276                    else idxBeforeCell.Value = "";
 277
 0278                    var idxAfterCell = ws.Cell(row, col++);
 0279                    if (!string.IsNullOrEmpty(pi?.PushedColorIndexAfter) && IndexStyles.TryGetValue(pi.PushedColorIndexA
 280                    {
 0281                        idxAfterCell.Value = sa.label;
 0282                        idxAfterCell.Style.Fill.BackgroundColor = sa.bg;
 0283                        idxAfterCell.Style.Font.FontColor       = sa.fg;
 0284                        idxAfterCell.Style.Font.Bold            = true;
 0285                        idxAfterCell.Style.Alignment.Horizontal = XLAlignmentHorizontalValues.Center;
 286                    }
 0287                    else idxAfterCell.Value = "";
 288
 0289                    var markupCell = ws.Cell(row, col++);
 0290                    if (pi?.PushedMarkupPct is { } mp)
 291                    {
 0292                        markupCell.Value = mp == 0 ? "—" : $"{mp:0.#}%";
 0293                        if (mp == 0) markupCell.Style.Font.FontColor = XLColor.Gray;
 294                    }
 0295                    else markupCell.Value = "";
 296
 0297                    var pushedAtCell = ws.Cell(row, col++);
 0298                    if (pi?.PricePushedAtUtc is { } pat)
 0299                        pushedAtCell.Value = BitrixStockMskDateTime.Format(pat);
 0300                    else pushedAtCell.Value = "";
 301                }
 302            }
 303
 4304            if (isMultiClient)
 0305                ws.Cell(row, col++).Value = w.OzonClientName ?? "";
 4306            ws.Cell(row, col++).Value = w.OneCStore;
 4307            ws.Cell(row, col++).Value = w.OzonWarehouseName;
 4308            var oneCCell1 = ws.Cell(row, col++);
 4309            if (w.ReservedQty > 0) oneCCell1.Value = $"{w.OneCQty} (-{w.ReservedQty})";
 4310            else oneCCell1.Value = w.OneCQty;
 4311            ws.Cell(row, col++).Value = w.OzonQty;                     // Было
 312
 313            // Нужно — целевой остаток
 4314            var targetQty = GetTargetQty(w);
 4315            var needsCell = ws.Cell(row, col++);
 8316            if (targetQty.HasValue) needsCell.Value = targetQty.Value;
 0317            else needsCell.Value = "—";
 318
 319            // Стало — фактический остаток после наката
 4320            var staleCell = ws.Cell(row, col++);
 4321            if (w.OzonQtyAfter.HasValue) staleCell.Value = w.OzonQtyAfter.Value;
 4322            else staleCell.Value = "—";
 323
 324            // Δ итог — фактическое изменение
 4325            var deltaCell = ws.Cell(row, col++);
 4326            if (w.OzonQtyAfter.HasValue)
 327            {
 0328                var actualDelta = w.OzonQtyAfter.Value - w.OzonQty;
 0329                deltaCell.Value = actualDelta > 0 ? $"+{actualDelta}" : actualDelta.ToString();
 0330                deltaCell.Style.Font.Bold = true;
 0331                deltaCell.Style.Font.FontColor = actualDelta > 0
 0332                    ? XLColor.FromArgb(0x19, 0x87, 0x54)   // зелёный
 0333                    : actualDelta < 0
 0334                        ? XLColor.FromArgb(0xDC, 0x35, 0x45)  // красный
 0335                        : XLColor.Gray;
 336            }
 337            else
 338            {
 339                // Ещё не накатили — показываем ожидаемую дельту (OneCQty - OzonQty)
 4340                deltaCell.Value = w.Delta > 0 ? $"+{w.Delta}" : w.Delta == 0 ? "0" : w.Delta.ToString();
 4341                deltaCell.Style.Font.FontColor = XLColor.Gray;
 342            }
 343
 4344            ws.Cell(row, col++).Value = w.ActionLabel;
 4345            ws.Cell(row, col++).Value = w.Action == BitrixStockSyncReportActions.ClearDisabledLink
 4346                ? (w.OzonApplyStatus switch
 4347                {
 0348                    "Success" => $"⓪₂ Связь снята | {w.OzonWarehouseName} (Ozon ID {w.OzonWarehouseId})",
 0349                    "Failed" => $"✗ {w.OzonApplyError}",
 0350                    _ => "⓪₂ Связь снята"
 4351                })
 4352                : w.OzonApplyStatus switch
 4353            {
 0354                "Success" when !string.IsNullOrEmpty(w.OzonApplyError) => GetSkipLabel(w.OzonApplyError),
 0355                "Success" => "✓ Применено",
 0356                "Failed"  when (w.OzonApplyError ?? "").Contains("Не продается", StringComparison.OrdinalIgnoreCase)
 0357                          => "⊘ Не продается",
 0358                "Failed"  => $"✗ Ошибка: {w.OzonApplyError}",
 0359                "Pending" => "Ожидает",
 0360                "Skipped" => GetSkipLabel(w.OzonApplyError),
 4361                _ => w.Action is BitrixStockSyncReportActions.NotFound or BitrixStockSyncReportActions.FbsNoWarehouse or
 4362                        or BitrixStockSyncReportActions.BatchVariant or BitrixStockSyncReportActions.BatchVariantDisable
 4363                        or BitrixStockSyncReportActions.BatchVariantZeroStock or BitrixStockSyncReportActions.BatchVaria
 4364                        or BitrixStockSyncReportActions.BatchVariantNotInFbs
 4365                    ? "Этап 5" : ""
 4366            };
 4367            ws.Cell(row, col++).Value = w.OzonCleanupStatus switch
 4368            {
 0369                "Success" when (w.OzonCleanupError ?? "").Contains("Сохранён", StringComparison.OrdinalIgnoreCase) => "✓
 0370                "Success" when (w.OzonCleanupError ?? "").Contains("⓪₃", StringComparison.OrdinalIgnoreCase) => "⓪₃ Этап
 0371                "Success" when w.Action == BitrixStockSyncReportActions.CleanupExtra => "⓪₃ Этап 3: обнулён",
 4372                _ when w.Action == BitrixStockSyncReportActions.BatchVariant =>
 0373                    w.BatchMultiplier > 1
 0374                        ? $"×{w.BatchMultiplier} шт. | 1С: {w.OneCQty} → {w.OneCQty / w.BatchMultiplier} пак. (было {w.O
 0375                          + (w.BatchPushedPrice.HasValue ? $" | Цена: {w.BatchPushedPrice.Value:F0} ₽" : "")
 0376                        : "Партия",
 4377                _ when w.Action == BitrixStockSyncReportActions.BatchVariantDisabledLink =>
 0378                    $"⓪ Связь снята (×{w.BatchMultiplier} шт.) | {w.BatchBaseArticle}",
 4379                _ when w.Action == BitrixStockSyncReportActions.BatchVariantZeroStock =>
 0380                    $"⓪ Нет пак. (×{w.BatchMultiplier} шт.) | 1С: {w.OneCQty} шт. < {w.BatchMultiplier} шт./уп.",
 4381                _ when w.Action == BitrixStockSyncReportActions.BatchVariantNotCreated =>
 0382                    $"⚠ Не создан в Ozon FBS (×{w.BatchMultiplier} шт.) | {w.Note}",
 4383                _ when w.Action == BitrixStockSyncReportActions.BatchVariantNotInFbs =>
 0384                    $"⊘ Не в FBS управлении (×{w.BatchMultiplier} шт.) | 1С: {w.OneCQty} шт. | Добавить в ЛК → Управлени
 0385                "Success" when (w.OzonCleanupError ?? "").Contains("Обнулён", StringComparison.OrdinalIgnoreCase) => "⓪ 
 0386                "Success" => "✓",
 0387                "Failed" => $"✗ {w.OzonCleanupError}",
 0388                "Skipped" => w.OzonCleanupError ?? "пропуск",
 4389                _ => ""
 4390            };
 4391            ws.Cell(row, col).Value = w.Note ?? "";
 392
 4393            if (!string.IsNullOrEmpty(w.Action) && ActionColors.TryGetValue(w.Action, out var bg))
 4394                ws.Range(row, 1, row, totalCols).Style.Fill.BackgroundColor = bg;
 395
 4396            row++;
 4397            renderedRows++;
 4398            if (renderedRows % 500 == 0 || renderedRows == allRows.Count)
 399            {
 4400                var renderPercent = 45 + (int)Math.Round((renderedRows / (double)Math.Max(1, allRows.Count)) * 45d);
 4401                progress?.Invoke(new BitrixStockExcelBuildProgress
 4402                {
 4403                    Stage = BitrixStockSyncReportExportStages.Building,
 4404                    Current = renderedRows,
 4405                    Total = allRows.Count,
 4406                    Percent = Math.Min(90, renderPercent),
 4407                    Message = $"Заполнено строк: {renderedRows:N0} / {allRows.Count:N0}"
 4408                });
 409            }
 410        }
 411
 5412        ws.Column(1).Width = 18;   // Артикул
 5413        ws.Column(2).Width = 18;   // Производитель
 5414        ws.Column(3).Width = 10;   // C0
 5415        ws.Column(4).Width = 22;   // Δ C0
 5416        if (hasPrices)
 417        {
 0418            ws.Column(5).Width = 12;   // Цена OZ
 0419            ws.Column(6).Width = 14;   // Цена продажи
 0420            ws.Column(7).Width = 16;   // Индекс цены
 421        }
 5422        if (hasPushes)
 423        {
 0424            ws.Column(8).Width  = 14;  // Цена до наката
 0425            ws.Column(9).Width  = 16;  // Цена после наката
 0426            ws.Column(10).Width = 12;  // Конкурент
 0427            ws.Column(11).Width = 14;  // Индекс до
 0428            ws.Column(12).Width = 14;  // Индекс после
 0429            ws.Column(13).Width = 11;  // Распродажа
 0430            ws.Column(14).Width = 18;  // Дата наката
 431        }
 5432        var shift = extraCols + pushCols;
 5433        if (isMultiClient)
 434        {
 0435            ws.Column(4 + shift + 1).Width = 20;   // Клиент Ozon
 0436            ws.Column(4 + shift + 2).Width = 16;   // Склад 1С
 0437            ws.Column(4 + shift + 3).Width = 22;   // Склад Ozon
 0438            ws.Column(4 + shift + 4).Width = 8;    // 1С
 0439            ws.Column(4 + shift + 5).Width = 10;   // Было
 0440            ws.Column(4 + shift + 6).Width = 10;   // Нужно
 0441            ws.Column(4 + shift + 7).Width = 10;   // Стало
 0442            ws.Column(4 + shift + 8).Width = 16;   // Δ итог
 0443            ws.Column(4 + shift + 9).Width = 26;   // Действие
 0444            ws.Column(4 + shift + 10).Width = 50;  // Статус Ozon
 0445            ws.Column(4 + shift + 11).Width = 30;  // Этап 3
 0446            ws.Column(4 + shift + 12).Width = 40;  // Примечание
 447        }
 448        else
 449        {
 5450            ws.Column(4 + shift + 1).Width = 16;   // Склад 1С
 5451            ws.Column(4 + shift + 2).Width = 22;   // Склад Ozon
 5452            ws.Column(4 + shift + 3).Width = 8;    // 1С
 5453            ws.Column(4 + shift + 4).Width = 10;   // Было
 5454            ws.Column(4 + shift + 5).Width = 10;   // Нужно
 5455            ws.Column(4 + shift + 6).Width = 10;   // Стало
 5456            ws.Column(4 + shift + 7).Width = 16;   // Δ итог
 5457            ws.Column(4 + shift + 8).Width = 26;   // Действие
 5458            ws.Column(4 + shift + 9).Width = 50;   // Статус Ozon
 5459            ws.Column(4 + shift + 10).Width = 30;  // Этап 3
 5460            ws.Column(4 + shift + 11).Width = 40;  // Примечание
 461        }
 5462        ws.SheetView.FreezeRows(4);
 463
 0464        static string GetSkipLabel(string? error) => BitrixStockSyncReportRowPresenter.GetSkipLabel(error);
 465
 466        static int? GetTargetQty(BitrixStockSyncReportWarehouseLineDto w) =>
 4467            BitrixStockSyncReportRowPresenter.GetTargetQty(w);
 468
 5469        progress?.Invoke(new BitrixStockExcelBuildProgress
 5470        {
 5471            Stage = BitrixStockSyncReportExportStages.Saving,
 5472            Current = allRows.Count,
 5473            Total = allRows.Count,
 5474            Percent = 95,
 5475            Message = "Сохранение Excel-файла"
 5476        });
 5477        using var ms = new MemoryStream();
 5478        wb.SaveAs(ms);
 5479        var filename = $"sync-{reportId[..Math.Min(8, reportId.Length)]}-{DateTime.Now:yyyyMMdd}.xlsx";
 5480        return (ms.ToArray(), filename);
 13481    }
 482
 483    /// <summary>Excel по истории артикула во всех отчётах (структура как у отчёта синхронизации).</summary>
 484    public static async Task<(byte[]? bytes, string filename)> BuildArticleHistoryAsync(
 485        IBitrixStockSyncArticleHistoryService historyService,
 486        string article,
 487        CancellationToken ct = default)
 488    {
 5489        article = article.Trim();
 5490        if (string.IsNullOrEmpty(article))
 2491            return (null, string.Empty);
 492
 493        // Полная история по артикулу за всё время — без ограничения на число отчётов.
 3494        var data = await historyService.GetAllAsync(article, null, ct);
 3495        if (data.Reports.Count == 0)
 1496            return (null, string.Empty);
 497
 2498        var hasPrices = data.HasPrices;
 2499        var hasPushes = data.HasPushes;
 500        const bool isMultiClient = false;
 2501        var extraCols = hasPrices ? 3 : 0;
 2502        var pushCols = hasPushes ? 7 : 0;
 2503        var totalCols = (isMultiClient ? 15 : 14) + extraCols + pushCols;
 2504        var totalRows = data.TotalDataRowCount;
 505
 2506        using var wb = new XLWorkbook();
 2507        var ws = wb.Worksheets.Add("Синхронизация остатков");
 508
 2509        ws.Cell(1, 1).Value = $"История синхронизаций по артикулу: {article}";
 2510        ws.Cell(1, 1).Style.Font.Bold = true;
 2511        ws.Cell(1, 1).Style.Font.FontSize = 13;
 2512        ws.Range(1, 1, 1, totalCols).Merge();
 513
 2514        var metaLine =
 2515            $"Экспорт: {BitrixStockMskDateTime.Format(DateTime.UtcNow)} (MSK)  |  Отчётов: {data.Reports.Count}/{data.To
 2516        if (data.ExportTruncated)
 1517            metaLine += $"  |  Показаны последние {data.Reports.Count} отчётов (лимит {BitrixStockArticleHistoryPageDto.
 2518        ws.Cell(2, 1).Value = metaLine;
 2519        ws.Cell(2, 1).Style.Font.Italic = true;
 2520        ws.Range(2, 1, 2, totalCols).Merge();
 521
 2522        var headers = BuildHeaders(hasPrices, hasPushes, isMultiClient);
 2523        WriteHeaderRow(ws, headers, 4);
 524
 2525        var row = 5;
 206526        foreach (var report in data.Reports)
 527        {
 101528            var clientLabel = report.IsMultiClient
 101529                ? string.Join(", ", report.MultiClientNames.Values.DefaultIfEmpty(report.OzonClientName).Distinct())
 101530                : report.OzonClientName;
 101531            var shortId = (report.ReportId ?? "").Length > 8 ? report.ReportId![..8] : report.ReportId ?? "";
 101532            ws.Range(row, 1, row, totalCols).Merge();
 101533            var sepCell = ws.Cell(row, 1);
 101534            sepCell.Value =
 101535                $"Отчёт синхронизации: {clientLabel}  |  Сформирован: {BitrixStockMskDateTime.Format(report.GeneratedAtU
 101536            sepCell.Style.Font.Bold = true;
 101537            sepCell.Style.Fill.BackgroundColor = XLColor.FromArgb(0xE3, 0xF2, 0xFD);
 101538            sepCell.Style.Alignment.Horizontal = XLAlignmentHorizontalValues.Left;
 101539            row++;
 540
 404541            foreach (var item in report.Rows)
 101542                row = WriteWarehouseRow(ws, row, item.Line, item.Price, hasPrices, hasPushes, isMultiClient, totalCols);
 543        }
 544
 2545        ApplyColumnWidths(ws, hasPrices, hasPushes, isMultiClient, extraCols, pushCols);
 2546        ws.SheetView.FreezeRows(4);
 547
 2548        using var ms = new MemoryStream();
 2549        wb.SaveAs(ms);
 16550        var safeArticle = new string(article.Where(c => char.IsLetterOrDigit(c) || c is '-' or '_').ToArray());
 2551        if (string.IsNullOrEmpty(safeArticle)) safeArticle = "article";
 2552        var filename = $"sync-history-{safeArticle}-{DateTime.Now:yyyyMMdd}.xlsx";
 2553        return (ms.ToArray(), filename);
 5554    }
 555
 556    private static string[] BuildHeaders(bool hasPrices, bool hasPushes, bool isMultiClient)
 557    {
 2558        var baseHeaders = isMultiClient
 2559            ? new[] { "Артикул", "Производитель", "C0", "Δ C0", "Клиент Ozon", "Склад 1С", "Склад Ozon", "1С", "Было (Oz
 2560            : new[] { "Артикул", "Производитель", "C0", "Δ C0", "Склад 1С", "Склад Ozon", "1С", "Было (Ozon)", "Нужно", 
 2561        var pushHeaders = hasPushes
 2562            ? new[] { "Цена до наката", "Цена после наката", "Конкурент", "Индекс до", "Индекс после", "Распродажа", "Да
 2563            : Array.Empty<string>();
 2564        return hasPrices
 2565            ? baseHeaders.Take(4)
 2566                .Concat(new[] { "Цена (со скидками)", "Цена продажи", "Индекс цены" })
 2567                .Concat(pushHeaders)
 2568                .Concat(baseHeaders.Skip(4))
 2569                .ToArray()
 2570            : baseHeaders;
 571    }
 572
 573    private static void WriteHeaderRow(IXLWorksheet ws, string[] headers, int headerRow)
 574    {
 104575        for (var c = 0; c < headers.Length; c++)
 576        {
 50577            var cell = ws.Cell(headerRow, c + 1);
 50578            cell.Value = headers[c];
 50579            cell.Style.Font.Bold = true;
 50580            cell.Style.Fill.BackgroundColor = XLColor.FromArgb(0x21, 0x96, 0xF3);
 50581            cell.Style.Font.FontColor = XLColor.White;
 50582            cell.Style.Alignment.Horizontal = XLAlignmentHorizontalValues.Center;
 583        }
 2584    }
 585
 586    private static int WriteWarehouseRow(
 587        IXLWorksheet ws,
 588        int row,
 589        BitrixStockSyncReportWarehouseLineDto w,
 590        OzonPriceInfoDto? pi,
 591        bool hasPrices,
 592        bool hasPushes,
 593        bool isMultiClient,
 594        int totalCols)
 595    {
 101596        var col = 1;
 101597        ws.Cell(row, col++).Value = w.Article;
 101598        ws.Cell(row, col++).Value = w.ManufacturerName ?? "";
 101599        var priceCell = ws.Cell(row, col++);
 101600        if (w.OneCPrice.HasValue) { priceCell.Value = w.OneCPrice.Value; priceCell.Style.NumberFormat.Format = "#,##0"; 
 101601        else priceCell.Value = "";
 602
 101603        var deltaC0Cell = ws.Cell(row, col++);
 101604        if (w.OneCPrice.HasValue && w.PrevOneCPrice.HasValue)
 605        {
 0606            var diff = (long)Math.Round(w.OneCPrice.Value) - (long)Math.Round(w.PrevOneCPrice.Value);
 0607            var prevFmt = ((long)Math.Round(w.PrevOneCPrice.Value)).ToString("N0");
 0608            if (diff == 0)
 609            {
 0610                deltaC0Cell.Value = "=";
 0611                deltaC0Cell.Style.Font.FontColor = XLColor.Gray;
 612            }
 0613            else if (diff > 0)
 614            {
 0615                deltaC0Cell.Value = $"↑ +{diff:N0} (было {prevFmt})";
 0616                deltaC0Cell.Style.Font.FontColor = XLColor.FromArgb(0x19, 0x87, 0x54);
 617            }
 618            else
 619            {
 0620                deltaC0Cell.Value = $"↓ {diff:N0} (было {prevFmt})";
 0621                deltaC0Cell.Style.Font.FontColor = XLColor.FromArgb(0xDC, 0x35, 0x45);
 622            }
 623        }
 101624        else deltaC0Cell.Value = "";
 625
 101626        if (hasPrices)
 627        {
 101628            var truePriceCell = ws.Cell(row, col++);
 303629            if (pi?.TruePrice is { } tp) { truePriceCell.Value = (double)Math.Round(tp); truePriceCell.Style.NumberForma
 0630            else truePriceCell.Value = "";
 631
 101632            var sellingPriceCell = ws.Cell(row, col++);
 101633            if (pi?.SellingPrice is { } sp) { sellingPriceCell.Value = (double)Math.Round(sp); sellingPriceCell.Style.Nu
 101634            else sellingPriceCell.Value = "";
 635
 101636            var indexCell = ws.Cell(row, col++);
 101637            if (!string.IsNullOrEmpty(pi?.ColorIndex) && IndexStyles.TryGetValue(pi.ColorIndex, out var style))
 638            {
 0639                indexCell.Value = style.label;
 0640                indexCell.Style.Fill.BackgroundColor = style.bg;
 0641                indexCell.Style.Font.FontColor = style.fg;
 0642                indexCell.Style.Font.Bold = true;
 0643                indexCell.Style.Alignment.Horizontal = XLAlignmentHorizontalValues.Center;
 644            }
 101645            else indexCell.Value = "";
 646
 101647            if (hasPushes)
 648            {
 101649                var pushedOldCell = ws.Cell(row, col++);
 101650                if (pi?.PricePushedOld is { } po) { pushedOldCell.Value = (double)Math.Round(po); pushedOldCell.Style.Nu
 101651                else pushedOldCell.Value = "";
 652
 101653                var pushedNewCell = ws.Cell(row, col++);
 101654                if (pi?.PricePushedNew is { } pn)
 655                {
 101656                    pushedNewCell.Value = (double)Math.Round(pn);
 101657                    pushedNewCell.Style.NumberFormat.Format = "#,##0";
 101658                    pushedNewCell.Style.Font.Bold = true;
 101659                    pushedNewCell.Style.Font.FontColor = XLColor.FromArgb(0x19, 0x87, 0x54);
 660                }
 0661                else if (pi?.PricePushStatus == "Unchanged")
 662                {
 0663                    var curPrice = pi.SellingPrice ?? pi.TruePrice;
 0664                    pushedNewCell.Value = curPrice.HasValue
 0665                        ? $"без изменений · {(long)Math.Round(curPrice.Value):N0}"
 0666                        : "без изменений";
 0667                    pushedNewCell.Style.Font.FontColor = XLColor.Gray;
 0668                    pushedNewCell.Style.Font.Italic = true;
 669                }
 0670                else if (pi?.PricePushStatus == "Error")
 671                {
 0672                    var curPrice = pi.SellingPrice ?? pi.TruePrice;
 0673                    var errText = string.IsNullOrEmpty(pi.PricePushError) ? "Ошибка" : $"Ошибка: {pi.PricePushError}";
 0674                    pushedNewCell.Value = curPrice.HasValue ? $"{errText} · {(long)Math.Round(curPrice.Value):N0}" : err
 0675                    pushedNewCell.Style.Font.FontColor = XLColor.FromArgb(0xDC, 0x35, 0x45);
 676                }
 0677                else pushedNewCell.Value = "";
 678
 101679                var competitorCell = ws.Cell(row, col++);
 101680                if (pi?.PushedCompetitorPrice is { } cp) { competitorCell.Value = (double)Math.Round(cp); competitorCell
 101681                else competitorCell.Value = "";
 682
 101683                var idxBeforeCell = ws.Cell(row, col++);
 101684                if (!string.IsNullOrEmpty(pi?.PushedColorIndexBefore) && IndexStyles.TryGetValue(pi.PushedColorIndexBefo
 685                {
 0686                    idxBeforeCell.Value = sb.label;
 0687                    idxBeforeCell.Style.Fill.BackgroundColor = sb.bg;
 0688                    idxBeforeCell.Style.Font.FontColor = sb.fg;
 0689                    idxBeforeCell.Style.Font.Bold = true;
 0690                    idxBeforeCell.Style.Alignment.Horizontal = XLAlignmentHorizontalValues.Center;
 691                }
 101692                else idxBeforeCell.Value = "";
 693
 101694                var idxAfterCell = ws.Cell(row, col++);
 101695                if (!string.IsNullOrEmpty(pi?.PushedColorIndexAfter) && IndexStyles.TryGetValue(pi.PushedColorIndexAfter
 696                {
 0697                    idxAfterCell.Value = sa.label;
 0698                    idxAfterCell.Style.Fill.BackgroundColor = sa.bg;
 0699                    idxAfterCell.Style.Font.FontColor = sa.fg;
 0700                    idxAfterCell.Style.Font.Bold = true;
 0701                    idxAfterCell.Style.Alignment.Horizontal = XLAlignmentHorizontalValues.Center;
 702                }
 101703                else idxAfterCell.Value = "";
 704
 101705                var markupCell = ws.Cell(row, col++);
 101706                if (pi?.PushedMarkupPct is { } mp)
 707                {
 0708                    markupCell.Value = mp == 0 ? "—" : $"{mp:0.#}%";
 0709                    if (mp == 0) markupCell.Style.Font.FontColor = XLColor.Gray;
 710                }
 101711                else markupCell.Value = "";
 712
 101713                var pushedAtCell = ws.Cell(row, col++);
 101714                if (pi?.PricePushedAtUtc is { } pat)
 0715                    pushedAtCell.Value = BitrixStockMskDateTime.Format(pat);
 101716                else pushedAtCell.Value = "";
 717            }
 718        }
 719
 101720        if (isMultiClient)
 0721            ws.Cell(row, col++).Value = w.OzonClientName ?? "";
 101722        ws.Cell(row, col++).Value = w.OneCStore;
 101723        ws.Cell(row, col++).Value = w.OzonWarehouseName;
 101724        var oneCCell2 = ws.Cell(row, col++);
 101725        if (w.ReservedQty > 0) oneCCell2.Value = $"{w.OneCQty} (-{w.ReservedQty})";
 101726        else oneCCell2.Value = w.OneCQty;
 101727        ws.Cell(row, col++).Value = w.OzonQty;
 728
 101729        var targetQty = BitrixStockSyncReportRowPresenter.GetTargetQty(w);
 101730        var needsCell = ws.Cell(row, col++);
 202731        if (targetQty.HasValue) needsCell.Value = targetQty.Value;
 0732        else needsCell.Value = "—";
 733
 101734        var staleCell = ws.Cell(row, col++);
 101735        if (w.OzonQtyAfter.HasValue) staleCell.Value = w.OzonQtyAfter.Value;
 101736        else staleCell.Value = "—";
 737
 101738        var deltaCell = ws.Cell(row, col++);
 101739        if (w.OzonQtyAfter.HasValue)
 740        {
 0741            var actualDelta = w.OzonQtyAfter.Value - w.OzonQty;
 0742            deltaCell.Value = actualDelta > 0 ? $"+{actualDelta}" : actualDelta.ToString();
 0743            deltaCell.Style.Font.Bold = true;
 0744            deltaCell.Style.Font.FontColor = actualDelta > 0
 0745                ? XLColor.FromArgb(0x19, 0x87, 0x54)
 0746                : actualDelta < 0
 0747                    ? XLColor.FromArgb(0xDC, 0x35, 0x45)
 0748                    : XLColor.Gray;
 749        }
 750        else
 751        {
 101752            deltaCell.Value = w.Delta > 0 ? $"+{w.Delta}" : w.Delta == 0 ? "0" : w.Delta.ToString();
 101753            deltaCell.Style.Font.FontColor = XLColor.Gray;
 754        }
 755
 101756        ws.Cell(row, col++).Value = w.ActionLabel;
 101757        ws.Cell(row, col++).Value = BitrixStockSyncReportRowPresenter.FormatOzonApplyStatus(w);
 101758        ws.Cell(row, col++).Value = BitrixStockSyncReportRowPresenter.FormatOzonCleanupStatus(w);
 101759        ws.Cell(row, col).Value = w.Note ?? "";
 760
 101761        if (!string.IsNullOrEmpty(w.Action) && ActionColors.TryGetValue(w.Action, out var bg))
 101762            ws.Range(row, 1, row, totalCols).Style.Fill.BackgroundColor = bg;
 763
 101764        return row + 1;
 765    }
 766
 767    private static void ApplyColumnWidths(IXLWorksheet ws, bool hasPrices, bool hasPushes, bool isMultiClient, int extra
 768    {
 2769        ws.Column(1).Width = 18;
 2770        ws.Column(2).Width = 18;
 2771        ws.Column(3).Width = 10;
 2772        ws.Column(4).Width = 22;
 2773        if (hasPrices)
 774        {
 2775            ws.Column(5).Width = 12;
 2776            ws.Column(6).Width = 14;
 2777            ws.Column(7).Width = 16;
 778        }
 2779        if (hasPushes)
 780        {
 2781            ws.Column(8).Width = 14;
 2782            ws.Column(9).Width = 16;
 2783            ws.Column(10).Width = 12;
 2784            ws.Column(11).Width = 14;
 2785            ws.Column(12).Width = 14;
 2786            ws.Column(13).Width = 11;
 2787            ws.Column(14).Width = 18;
 788        }
 2789        var shift = extraCols + pushCols;
 2790        if (isMultiClient)
 791        {
 0792            ws.Column(4 + shift + 1).Width = 20;
 0793            ws.Column(4 + shift + 2).Width = 16;
 0794            ws.Column(4 + shift + 3).Width = 22;
 0795            ws.Column(4 + shift + 4).Width = 8;
 0796            ws.Column(4 + shift + 5).Width = 10;
 0797            ws.Column(4 + shift + 6).Width = 10;
 0798            ws.Column(4 + shift + 7).Width = 10;
 0799            ws.Column(4 + shift + 8).Width = 16;
 0800            ws.Column(4 + shift + 9).Width = 26;
 0801            ws.Column(4 + shift + 10).Width = 50;
 0802            ws.Column(4 + shift + 11).Width = 30;
 0803            ws.Column(4 + shift + 12).Width = 40;
 804        }
 805        else
 806        {
 2807            ws.Column(4 + shift + 1).Width = 16;
 2808            ws.Column(4 + shift + 2).Width = 22;
 2809            ws.Column(4 + shift + 3).Width = 8;
 2810            ws.Column(4 + shift + 4).Width = 10;
 2811            ws.Column(4 + shift + 5).Width = 10;
 2812            ws.Column(4 + shift + 6).Width = 10;
 2813            ws.Column(4 + shift + 7).Width = 16;
 2814            ws.Column(4 + shift + 8).Width = 26;
 2815            ws.Column(4 + shift + 9).Width = 50;
 2816            ws.Column(4 + shift + 10).Width = 30;
 2817            ws.Column(4 + shift + 11).Width = 40;
 818        }
 2819    }
 820
 821    public static string GetSkipLabelStatic(string? error) =>
 30822        BitrixStockSyncReportRowPresenter.GetSkipLabel(error);
 823}