< Summary

Information
Class: Services.BitrixStockServices.BitrixStockRolloutTelegramReporter
Assembly: Services
File(s): /home/runner/work/Studio2Prod/Studio2Prod/AppRepository/BitrixStockServices/BitrixStockRolloutTelegramReporter.cs
Line coverage
11%
Covered lines: 13
Uncovered lines: 97
Coverable lines: 110
Total lines: 205
Line coverage: 11.8%
Branch coverage
4%
Covered branches: 3
Total branches: 74
Branch coverage: 4%
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%
NotifyAsync()0%7280%
NotifyFailedAsync()30%341037.5%
HasActivity(...)100%11100%
BuildSummaryAsync()0%110100%
LoadStoreRowsAsync()0%272160%
LoadParticipatingStoresAsync()0%110100%
AddJson()0%4260%
LoadActiveStockAsync()0%272160%
AddParam(...)0%620%

File(s)

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

#LineLine coverage
 1using System.Data.Common;
 2using System.Text.Json;
 3using Microsoft.EntityFrameworkCore;
 4using Microsoft.Extensions.Logging;
 5using OzonRepositories.Context;
 6using Services.ApiServices.TelegramBotApi;
 7
 8namespace Services.BitrixStockServices;
 9
 10/// <summary>
 11/// Собирает итог наката остатков (по складам 1С) и активные остатки складов из выгрузки 1С
 12/// и отправляет сводку в Telegram. Ошибки не пробрасывает: уведомление не должно ронять пайплайн.
 13/// </summary>
 214public sealed class BitrixStockRolloutTelegramReporter(
 215    IDbContextFactory<ApplicationDbContext> dbFactory,
 216    ITelegramNotifier telegram,
 217    TelegramBotConfig config,
 218    ILogger<BitrixStockRolloutTelegramReporter>? logger = null)
 19{
 20    /// <summary>Склады 1С, по которым в выгрузке есть колонка остатка (имя колонки — <c>{склад}Qty</c>).</summary>
 021    private static readonly string[] KnownStores = ["B1", "B2", "M1", "M2", "M3"];
 22
 23    public async Task NotifyAsync(BitrixStockSyncReportRunDto run, CancellationToken ct = default)
 24    {
 025        if (!telegram.IsConfigured || string.IsNullOrWhiteSpace(run.ReportId)) return;
 026        if (!HasActivity(run)) return;
 27
 28        try
 29        {
 030            var summary = await BuildSummaryAsync(run, ct);
 031            await telegram.SendAsync(TelegramMessageFactory.StockRollout(summary, config.SiteBaseUrl), ct);
 032        }
 033        catch (OperationCanceledException) when (ct.IsCancellationRequested)
 34        {
 035            throw;
 36        }
 037        catch (Exception ex)
 38        {
 039            logger?.LogWarning(ex, "Не удалось отправить итог наката остатков {ReportId} в Telegram", run.ReportId);
 040        }
 041    }
 42
 43    /// <summary>Пайплайн прерван: этап, текст ошибки и частичные результаты (если отчёт уже есть). Не бросает исключени
 44    public async Task NotifyFailedAsync(string reportId, BitrixStockSyncReportRunDto? run, int failedStage, string error
 45        CancellationToken ct = default)
 46    {
 247        if (!telegram.IsConfigured || string.IsNullOrWhiteSpace(reportId)) return;
 48
 49        try
 50        {
 251            TelegramRolloutSummary? partial = null;
 252            if (run != null)
 53            {
 54                try
 55                {
 056                    partial = await BuildSummaryAsync(run, ct);
 057                }
 058                catch (Exception ex) when (ex is not OperationCanceledException)
 59                {
 060                    logger?.LogWarning(ex, "Не удалось собрать частичные результаты прерванного наката {ReportId}", repo
 061                }
 62            }
 63
 264            await telegram.SendAsync(TelegramMessageFactory.StockRolloutFailed(reportId, partial, failedStage, error, co
 265        }
 066        catch (OperationCanceledException) when (ct.IsCancellationRequested)
 67        {
 068            throw;
 69        }
 070        catch (Exception ex)
 71        {
 072            logger?.LogWarning(ex, "Не удалось отправить сообщение о прерванном накате {ReportId} в Telegram", reportId)
 073        }
 274    }
 75
 76    /// <summary>Прогон без наката, очистки, цен и сирот — нечего сообщать (почасовые «холостые» прогоны).</summary>
 77    public static bool HasActivity(BitrixStockSyncReportRunDto run) =>
 378        run.OzonAppliedSuccessCount + run.OzonApplyFailedCount + run.OzonCleanupSuccessCount + run.OzonCleanupFailedCoun
 379        + run.PricePushCount + run.PricePushErrorCount + run.OrphansFound > 0;
 80
 81    public async Task<TelegramRolloutSummary> BuildSummaryAsync(BitrixStockSyncReportRunDto run, CancellationToken ct = 
 82    {
 083        await using var db = await dbFactory.CreateDbContextAsync(ct);
 084        await db.Database.OpenConnectionAsync(ct);
 85        try
 86        {
 087            var conn = db.Database.GetDbConnection();
 088            var stores = await LoadStoreRowsAsync(conn, run.ReportId!, ct);
 089            var participating = await LoadParticipatingStoresAsync(conn, run, stores, ct);
 090            var (stock, asOf) = await LoadActiveStockAsync(conn, participating, ct);
 091            var ozonStock = await OzonWarehouseStockQuery.LoadAsync(conn, run.ReportId!, ct);
 92
 093            var client = run.MultiClientNames is { Count: > 0 }
 094                ? string.Join(", ", run.MultiClientNames)
 095                : run.OzonClientName ?? $"ID {run.OzonClientId}";
 96
 097            return new TelegramRolloutSummary(
 098                run.ReportId!, client, run.CompletedAtUtc ?? run.GeneratedAtUtc, run.ProductsInReport, stores,
 099                run.OzonCleanupSuccessCount, run.OzonCleanupFailedCount,
 0100                run.PricePushCount, run.PricePushUnchangedCount, run.PricePushErrorCount,
 0101                run.OrphansFound, run.OrphansZeroed, stock, asOf, ozonStock);
 102        }
 103        finally
 104        {
 0105            await db.Database.CloseConnectionAsync();
 106        }
 0107    }
 108
 109    /// <summary>Применённое в Ozon по складам 1С: Push — добавлено, Decrease — убавлено, Clear — обнулено.</summary>
 110    private static async Task<List<TelegramRolloutStoreRow>> LoadStoreRowsAsync(DbConnection conn, string reportId, Canc
 111    {
 0112        await using var cmd = conn.CreateCommand();
 0113        cmd.CommandText = @"SELECT OneCStore, Action, OzonApplyStatus, COUNT(*)
 0114FROM BitrixStockSyncReportLines
 0115WHERE ReportId = @rid AND Action IN ('Push','Decrease','Clear','ClearDisabledLink') AND OzonApplyStatus IS NOT NULL
 0116GROUP BY OneCStore, Action, OzonApplyStatus";
 0117        AddParam(cmd, "@rid", reportId);
 118
 0119        var map = new SortedDictionary<string, int[]>(StringComparer.OrdinalIgnoreCase); // added, decreased, cleared, f
 0120        await using var reader = await cmd.ExecuteReaderAsync(ct);
 0121        while (await reader.ReadAsync(ct))
 122        {
 0123            var store = reader.GetString(0);
 0124            var action = reader.GetString(1);
 0125            var status = reader.GetString(2);
 0126            var count = Convert.ToInt32(reader.GetValue(3));
 0127            if (!map.TryGetValue(store, out var row)) map[store] = row = new int[4];
 128
 0129            if (status == BitrixStockSyncReportApplyStatuses.Failed) row[3] += count;
 0130            else if (status == BitrixStockSyncReportApplyStatuses.Success)
 131            {
 0132                if (action == BitrixStockSyncReportActions.Push) row[0] += count;
 0133                else if (action == BitrixStockSyncReportActions.Decrease) row[1] += count;
 0134                else row[2] += count;
 135            }
 136        }
 137
 0138        return map.Select(x => new TelegramRolloutStoreRow(x.Key, x.Value[0], x.Value[1], x.Value[2], x.Value[3])).ToLis
 0139    }
 140
 141    /// <summary>Склады, участвующие в обработке: из настроек пресетов, склады самого отчёта и склады из его строк.</sum
 142    private static async Task<List<string>> LoadParticipatingStoresAsync(DbConnection conn, BitrixStockSyncReportRunDto 
 143        IReadOnlyList<TelegramRolloutStoreRow> rolled, CancellationToken ct)
 144    {
 0145        var stores = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
 146        void AddJson(string? json)
 147        {
 0148            if (string.IsNullOrWhiteSpace(json)) return;
 149            try
 150            {
 0151                foreach (var s in JsonSerializer.Deserialize<List<string>>(json) ?? []) stores.Add(s.Trim());
 0152            }
 0153            catch (JsonException)
 154            {
 155                // Некорректный JSON в настройках не должен ломать сводку.
 0156            }
 0157        }
 158
 0159        AddJson(run.SelectedStores);
 0160        foreach (var r in rolled) stores.Add(r.Store);
 161
 0162        await using var cmd = conn.CreateCommand();
 0163        cmd.CommandText = "SELECT SelectedStoresJson FROM BitrixStockSyncPresets";
 0164        await using var reader = await cmd.ExecuteReaderAsync(ct);
 0165        while (await reader.ReadAsync(ct)) AddJson(reader.IsDBNull(0) ? null : reader.GetString(0));
 166
 0167        return KnownStores.Where(stores.Contains).ToList();
 0168    }
 169
 170    /// <summary>Активные остатки по складам из последней выгрузки 1С: позиции листа склада с остатком &gt; 0.</summary>
 171    private static async Task<(List<TelegramStoreStockRow> Rows, DateTime? AsOfUtc)> LoadActiveStockAsync(
 172        DbConnection conn, IReadOnlyList<string> stores, CancellationToken ct)
 173    {
 0174        var rows = new List<TelegramStoreStockRow>();
 0175        DateTime? asOf = null;
 0176        foreach (var store in stores)
 177        {
 178            // Имя колонки берётся только из белого списка KnownStores — подстановка безопасна.
 0179            await using var cmd = conn.CreateCommand();
 0180            cmd.CommandText = $@"SELECT COUNT(*), COALESCE(SUM({store}Qty), 0), MAX(SyncedAtUtc)
 0181FROM WarehouseStockSnapshot
 0182WHERE SyncRunId = (SELECT MAX(SyncRunId) FROM WarehouseStockSnapshot) AND Warehouse = @store AND {store}Qty > 0";
 0183            AddParam(cmd, "@store", store);
 184
 0185            await using var reader = await cmd.ExecuteReaderAsync(ct);
 0186            if (!await reader.ReadAsync(ct)) continue;
 0187            rows.Add(new TelegramStoreStockRow(store, Convert.ToInt32(reader.GetValue(0)), Convert.ToDecimal(reader.GetV
 0188            if (!reader.IsDBNull(2))
 189            {
 0190                var synced = reader.GetDateTime(2);
 0191                if (asOf == null || synced > asOf) asOf = synced;
 192            }
 0193        }
 194
 0195        return (rows, asOf);
 0196    }
 197
 198    private static void AddParam(DbCommand command, string name, object? value)
 199    {
 0200        var parameter = command.CreateParameter();
 0201        parameter.ParameterName = name;
 0202        parameter.Value = value ?? DBNull.Value;
 0203        command.Parameters.Add(parameter);
 0204    }
 205}