< Summary

Information
Class: Services.ApiServices.TelegramBotApi.TelegramMessageFactory
Assembly: Services
File(s): /home/runner/work/Studio2Prod/Studio2Prod/AppRepository/ApiServices/TelegramBotApi/TelegramMessageFactory.cs
Line coverage
91%
Covered lines: 325
Uncovered lines: 31
Coverable lines: 356
Total lines: 628
Line coverage: 91.2%
Branch coverage
80%
Covered branches: 237
Total branches: 295
Branch coverage: 80.3%
Method coverage

Feature is only available for sponsors

Upgrade to PRO version

Metrics

MethodBranch coverage Crap Score Cyclomatic complexity Line coverage
IconFor(...)100%55100%
Notice(...)100%11100%
Alert(...)100%22100%
Recovered(...)100%210%
Exception(...)75%44100%
OneCPostingFailure(...)90%1010100%
PreBlocks(...)58.33%191264.28%
Flush()100%22100%
Report(...)0%2040%
FromHtml(...)50%22100%
AlertDigest(...)100%2626100%
ActiveAlerts(...)75%1212100%
Deploy(...)97.22%3636100%
CommitLink()100%44100%
FormatDuration(...)50%22100%
DailySummary(...)83.33%363698.11%
StockRollout(...)79.54%4444100%
.cctor()100%11100%
StockRolloutFailed(...)85%202094.11%
OzonStockTable(...)66.66%66100%
Name()100%44100%
Fit(...)50%22100%
NewOrders(...)50%22100%
NewOrdersHtml(...)83.33%1818100%
AppendDomainTable(...)8.33%951216.66%
AppendSection(...)100%44100%
FormatAlert(...)83.33%1212100%
AbsoluteUrl(...)80%111080%
Shorten(...)100%22100%
TrimStack(...)50%3233.33%

File(s)

/home/runner/work/Studio2Prod/Studio2Prod/AppRepository/ApiServices/TelegramBotApi/TelegramMessageFactory.cs

#LineLine coverage
 1using Services.BackgroundJobsServices;
 2using Services.DailyWorkSummaryServices;
 3
 4namespace Services.ApiServices.TelegramBotApi;
 5
 6/// <summary>
 7/// Фабрика типовых сообщений бота: единый вид заголовков и иконок, экранирование и лимиты длины.
 8/// Вызывающий код не собирает HTML руками — берёт готовое сообщение отсюда и отдаёт в <see cref="ITelegramNotifier"/>.
 9/// </summary>
 10public static class TelegramMessageFactory
 11{
 12    private const string AppPrefix = "Studio 2";
 13
 2914    public static string IconFor(TelegramSeverity severity) => severity switch
 2915    {
 616        TelegramSeverity.Success => "✅",
 217        TelegramSeverity.Warning => "⚠️",
 1118        TelegramSeverity.Error => "❌",
 419        TelegramSeverity.Critical => "🚨",
 620        _ => "ℹ️"
 2921    };
 22
 23    /// <summary>Простое сообщение: заголовок + строки.</summary>
 24    public static TelegramMessage Notice(string title, IEnumerable<string>? lines = null,
 25        TelegramSeverity severity = TelegramSeverity.Info, long? chatId = null, bool silent = false) =>
 626        new(new TelegramMessageBuilder()
 627                .Title($"{AppPrefix}: {title}", IconFor(severity))
 628                .Lines(lines, bullet: "")
 629                .Build(),
 630            chatId, silent);
 31
 32    /// <summary>Алерт о проблеме: заголовок, список пунктов и необязательная ссылка на страницу.</summary>
 33    public static TelegramMessage Alert(string title, IEnumerable<string>? lines = null,
 34        TelegramSeverity severity = TelegramSeverity.Error, string? url = null, long? chatId = null)
 35    {
 136        var builder = new TelegramMessageBuilder()
 137            .Title($"{AppPrefix}: {title}", IconFor(severity))
 138            .Lines(lines);
 239        if (!string.IsNullOrWhiteSpace(url)) builder.Blank().Link("Открыть в Studio 2", url);
 140        return new TelegramMessage(builder.Build(), chatId, Channel: TelegramChannel.Alerts, Critical: severity >= Teleg
 41    }
 42
 43    /// <summary>Проблема устранена.</summary>
 44    public static TelegramMessage Recovered(string title, string? details = null, long? chatId = null) =>
 045        new(new TelegramMessageBuilder()
 046                .Title($"{AppPrefix}: восстановлено", IconFor(TelegramSeverity.Success))
 047                .Line(title)
 048                .Line(details)
 049                .Build(),
 050            chatId, Silent: true, Channel: TelegramChannel.Alerts);
 51
 52    /// <summary>Необработанное исключение: источник, контекст и сокращённый стектрейс.</summary>
 53    public static TelegramMessage Exception(string source, Exception ex,
 54        IReadOnlyDictionary<string, string?>? context = null, long? chatId = null)
 55    {
 156        var builder = new TelegramMessageBuilder()
 157            .Title($"{AppPrefix}: ошибка", IconFor(TelegramSeverity.Error))
 158            .Field("Источник", source, code: true)
 159            .Field("Тип", ex.GetType().Name, code: true)
 160            .Field("Сообщение", ex.Message);
 461        foreach (var (key, value) in context ?? new Dictionary<string, string?>())
 162            builder.Field(key, value, code: true);
 163        builder.Pre(TrimStack(ex.StackTrace));
 164        return new TelegramMessage(builder.Build(), chatId, Channel: TelegramChannel.Errors);
 65    }
 66
 67    /// <summary>
 68    /// Ошибка проведения документа в 1С: операция, документ, заказы и полный ответ 1С. Длинный текст не режется,
 69    /// а уходит несколькими сообщениями. Успешные проведения сюда не попадают — для них достаточно всплывающего
 70    /// уведомления в интерфейсе.
 71    /// </summary>
 72    public static IReadOnlyList<TelegramMessage> OneCPostingFailure(string operation, string status,
 73        string? documentNumber, IReadOnlyList<string> shipments, string oneCError, string? systemMessage = null,
 74        string? siteBaseUrl = null, long? chatId = null, string hashtag = "#1с_документ_ошибка")
 75    {
 676        var builder = new TelegramMessageBuilder()
 677            .Title($"{AppPrefix}: ошибка проведения в 1С", IconFor(TelegramSeverity.Error))
 678            .Line(hashtag)
 679            .Field("Операция", operation)
 680            .Field("Статус", status)
 681            .Field("Документ", documentNumber, code: true);
 682        if (shipments.Count > 0)
 83        {
 84            const int shown = 10;
 685            var list = string.Join(", ", shipments.Take(shown));
 686            builder.Field("Заказы", shipments.Count > shown ? $"{list} и ещё {shipments.Count - shown}" : list, code: tr
 87        }
 88
 689        builder.Blank().Raw("<b>Ответ 1С</b>");
 690        PreBlocks(builder, oneCError);
 691        if (!string.IsNullOrWhiteSpace(systemMessage) && !string.Equals(systemMessage.Trim(), oneCError.Trim(), StringCo
 92            {
 593            builder.Blank().Raw("<b>Сообщение системы</b>");
 594            PreBlocks(builder, systemMessage);
 95        }
 96
 697        var url = AbsoluteUrl("/Studio2/JobLogs?tab=onec&failed=1", siteBaseUrl);
 1298        if (url != null) builder.Blank().Link("Открыть журнал проведений 1С", url);
 99
 6100        return builder.BuildChunks()
 10101            .Select(html => new TelegramMessage(html, chatId, Channel: TelegramChannel.OneC))
 6102            .ToList();
 103    }
 104
 105    /// <summary>
 106    /// Длинный текст отдельными блоками <c>&lt;pre&gt;</c> (по строкам, при необходимости — по символам), чтобы
 107    /// <see cref="TelegramMessageBuilder.BuildChunks"/> разнёс его по сообщениям, а не обрезал.
 108    /// Длина считается после HTML-экранирования.
 109    /// </summary>
 110    private static void PreBlocks(TelegramMessageBuilder builder, string text, int maxEncoded = 3000)
 111    {
 11112        var current = new System.Text.StringBuilder();
 113        void Flush()
 114        {
 32115            if (current.Length > 0) builder.Pre(current.ToString());
 16116            current.Clear();
 16117        }
 118
 842119        foreach (var rawLine in text.Replace("\r\n", "\n").Split('\n'))
 120        {
 410121            var line = rawLine;
 410122            while (TelegramMessageBuilder.Encode(line).Length > maxEncoded)
 123            {
 0124                var take = Math.Min(line.Length, Math.Max(1, maxEncoded / 6));
 0125                while (take < line.Length && TelegramMessageBuilder.Encode(line[..(take + 1)]).Length <= maxEncoded) tak
 0126                Flush();
 0127                builder.Pre(line[..take]);
 0128                line = line[take..];
 129            }
 130
 415131            if (TelegramMessageBuilder.Encode(current.ToString() + "\n" + line).Length > maxEncoded) Flush();
 804132            if (current.Length > 0) current.Append('\n');
 410133            current.Append(line);
 134        }
 135
 11136        Flush();
 11137    }
 138
 139    /// <summary>Отчёт по секциям: заголовок + набор «название секции → строки».</summary>
 140    public static TelegramMessage Report(string title, IEnumerable<(string Section, IEnumerable<string> Lines)> sections
 141        TelegramSeverity severity = TelegramSeverity.Info, long? chatId = null, string? imageUrl = null)
 142    {
 0143        var builder = new TelegramMessageBuilder().Title($"{AppPrefix}: {title}", IconFor(severity));
 0144        foreach (var (section, lines) in sections)
 145        {
 0146            builder.Blank().Title(section).Lines(lines);
 147        }
 148
 0149        var maxLength = imageUrl == null ? TelegramMessageBuilder.MaxLength : TelegramMessageBuilder.MaxCaptionLength;
 0150        return new TelegramMessage(builder.Build(maxLength), chatId, ImageUrl: imageUrl);
 151    }
 152
 153    /// <summary>Сообщение из уже готового HTML (для случаев, когда разметку строит вызывающий; текст экранировать самос
 154    public static TelegramMessage FromHtml(string html, long? chatId = null, bool silent = false, string? imageUrl = nul
 1155        new(html.Length > TelegramMessageBuilder.MaxLength ? html[..TelegramMessageBuilder.MaxLength] : html, chatId, si
 156
 157    /// <summary>Сколько заказов показываем в сводке о новых заказах; остальные считаются строкой «…и ещё N».</summary>
 158    public const int MaxNewOrdersShown = 40;
 159
 160    /// <summary>Сколько новых заказов с картинкой уходит отдельными фото-сообщениями; остальные — в текстовой сводке.</
 161    public const int MaxNewOrderPhotos = 10;
 162
 163    private const int MaxProductNameLength = 70;
 164    private const int MaxLinesPerAlert = 5;
 165    private const int MaxLineLength = 220;
 166
 167    /// <summary>
 168    /// Сводка изменений системных алертов: заголовок со счётчиками, затем разделы «Критичные», «Предупреждения»,
 169    /// «Восстановлено». У каждого алерта — раздел, заголовок-ссылка на нужную страницу, описание и число заказов.
 170    /// Длинная сводка разбивается на несколько сообщений.
 171    /// </summary>
 172    public static IReadOnlyList<TelegramMessage> AlertDigest(IReadOnlyList<TelegramAlertItem> items,
 173        string? siteBaseUrl = null, DateTime? nowUtc = null, long? chatId = null)
 174    {
 7175        if (items.Count == 0) return [];
 176
 71177        var critical = items.Where(x => !x.Resolved && x.Severity >= TelegramSeverity.Critical).ToList();
 71178        var warnings = items.Where(x => !x.Resolved && x.Severity < TelegramSeverity.Critical).ToList();
 71179        var resolved = items.Where(x => x.Resolved).ToList();
 180
 5181        var headSeverity = critical.Count > 0 ? TelegramSeverity.Critical
 5182            : warnings.Count > 0 ? TelegramSeverity.Warning
 5183            : TelegramSeverity.Success;
 5184        var headText = headSeverity switch
 5185        {
 3186            TelegramSeverity.Critical => "требуется внимание",
 1187            TelegramSeverity.Warning => "есть предупреждения",
 1188            _ => "всё восстановлено"
 5189        };
 190
 5191        var counters = new List<string>();
 8192        if (critical.Count > 0) counters.Add($"🚨 {critical.Count}");
 7193        if (warnings.Count > 0) counters.Add($"⚠️ {warnings.Count}");
 7194        if (resolved.Count > 0) counters.Add($"✅ {resolved.Count}");
 195
 5196        var builder = new TelegramMessageBuilder()
 5197            .Title($"{AppPrefix}: {headText}", IconFor(headSeverity))
 5198            .Raw($"<i>{string.Join("  ·  ", counters)}</i>");
 199
 5200        AppendDomainTable(builder, items);
 5201        AppendSection(builder, "🚨 Критичные", critical, siteBaseUrl);
 5202        AppendSection(builder, "⚠️ Предупреждения", warnings, siteBaseUrl);
 5203        AppendSection(builder, "✅ Восстановлено", resolved, siteBaseUrl);
 204
 5205        var local = TimeZoneInfo.ConvertTimeFromUtc(nowUtc ?? DateTime.UtcNow, RussianTimeZoneHelper.Zone);
 5206        var footer = $"<i>{local:dd.MM.yyyy HH:mm} МСК</i>";
 5207        var allUrl = AbsoluteUrl("/Studio2/JobLogs?tab=alerts", siteBaseUrl);
 9208        if (allUrl != null) footer += $"  ·  <a href=\"{TelegramMessageBuilder.Encode(allUrl)}\">Все алерты</a>";
 5209        builder.Blank().Raw(footer);
 210
 5211        var silent = critical.Count == 0 && warnings.Count == 0;
 14212        return builder.BuildChunks().Select(html => new TelegramMessage(html, chatId, silent, Channel: TelegramChannel.A
 213    }
 214
 215    /// <summary>Компактная таблица «раздел → сколько критичных / предупреждений / восстановлено»; при одном разделе не 
 216    /// <summary>Слова в заголовке закреплённого сообщения: по ним находим его после перезапуска приложения.</summary>
 217    public const string ActiveAlertsMarker = "активные алерты";
 218
 219    /// <summary>
 220    /// Закреплённое «живое» сообщение со всеми действующими алертами (критичные и предупреждения). Это текущее
 221    /// состояние, а не лента событий: оно редактируется на месте. Влезает в одно сообщение — остальное обрезается
 222    /// с пометкой и ссылкой на вкладку алертов.
 223    /// </summary>
 224    public static TelegramMessage ActiveAlerts(IReadOnlyList<TelegramAlertItem> items, string? siteBaseUrl = null)
 225    {
 12226        var allUrl = AbsoluteUrl("/Studio2/JobLogs?tab=alerts", siteBaseUrl);
 12227        var link = allUrl == null ? "" : $"<a href=\"{TelegramMessageBuilder.Encode(allUrl)}\">Все алерты</a>";
 228
 12229        if (items.Count == 0)
 3230            return new TelegramMessage(new TelegramMessageBuilder()
 3231                .Title($"{AppPrefix}: активных алертов нет", IconFor(TelegramSeverity.Success))
 3232                .Raw(link).Build(), Channel: TelegramChannel.Alerts);
 233
 20234        var critical = items.Where(x => x.Severity >= TelegramSeverity.Critical).ToList();
 20235        var warnings = items.Where(x => x.Severity < TelegramSeverity.Critical).ToList();
 9236        var counters = new List<string>();
 18237        if (critical.Count > 0) counters.Add($"🚨 {critical.Count}");
 11238        if (warnings.Count > 0) counters.Add($"⚠️ {warnings.Count}");
 239
 9240        var builder = new TelegramMessageBuilder()
 9241            .Title($"{AppPrefix}: {ActiveAlertsMarker}", "📌")
 9242            .Raw($"<i>{string.Join("  ·  ", counters)}</i>");
 9243        AppendDomainTable(builder, items);
 9244        AppendSection(builder, "🚨 Критичные", critical, siteBaseUrl);
 9245        AppendSection(builder, "⚠️ Предупреждения", warnings, siteBaseUrl);
 246
 9247        var footer = string.IsNullOrEmpty(link) ? "" : "\n\n" + link;
 9248        var chunks = builder.BuildChunks(TelegramMessageBuilder.MaxLength - 200);
 9249        var html = chunks[0] + (chunks.Count > 1 ? "\n\n<i>…показаны не все алерты</i>" : "") + footer;
 9250        return new TelegramMessage(html, Channel: TelegramChannel.Alerts);
 251    }
 252
 253    private const int MaxDeployChangesShown = 15;
 254
 255    /// <summary>
 256    /// Сообщение о деплое: «идёт обновление» (с версией и списком изменений), «обновлено» или «не удалось».
 257    /// У каждого изменения — ссылки на коммит и PR; в конце ссылка на запуск GitHub Actions.
 258    /// </summary>
 259    public static TelegramMessage Deploy(DeployEvent e, long? chatId = null)
 260    {
 4261        var repo = string.IsNullOrWhiteSpace(e.Repository) ? "EvgeniBondarev/Studio2Prod" : e.Repository!;
 4262        var shortSha = e.Sha.Length > 7 ? e.Sha[..7] : e.Sha;
 263        string CommitLink(string sha, string? text = null) =>
 21264            $"<a href=\"https://github.com/{repo}/commit/{TelegramMessageBuilder.Encode(sha)}\">{TelegramMessageBuilder.
 265
 4266        var (icon, title) = e.Stage switch
 4267        {
 1268            DeployStage.Started => ("🚀", "идёт обновление"),
 2269            DeployStage.Finished => ("✅", "обновлено"),
 1270            _ => ("❌", "обновление не удалось")
 4271        };
 272
 4273        var builder = new TelegramMessageBuilder().Title($"{AppPrefix}: {title}", icon);
 274
 4275        var version = $"Версия: {CommitLink(e.Sha, shortSha)}";
 4276        if (DateTime.TryParse(e.CommitDate, null, System.Globalization.DateTimeStyles.RoundtripKind, out var committed))
 277        {
 1278            var local = TimeZoneInfo.ConvertTime(committed, RussianTimeZoneHelper.Zone);
 1279            version += $" · {local:dd.MM.yyyy HH:mm} МСК";
 280        }
 281
 4282        builder.Raw(version);
 4283        if (e.Stage == DeployStage.Finished && e.DurationSeconds is > 0)
 1284            builder.Raw($"⏱ Заняло {FormatDuration(e.DurationSeconds.Value)}");
 5285        if (!string.IsNullOrWhiteSpace(e.Actor)) builder.Raw($"👤 {TelegramMessageBuilder.Encode(e.Actor)}");
 286
 4287        var changes = DeployChangeParser.Parse(e.Commits);
 4288        if (changes.Count > 0 && e.Stage != DeployStage.Failed)
 289        {
 2290            builder.Blank().Raw($"<b>{(e.Stage == DeployStage.Started ? "Что войдёт" : "Что изменилось")}</b> ({changes.
 38291            foreach (var change in changes.Take(MaxDeployChangesShown))
 292            {
 17293                var links = new List<string>();
 17294                if (change.PullRequest is { } pr)
 1295                    links.Add($"<a href=\"https://github.com/{repo}/pull/{pr}\">PR #{pr}</a>");
 17296                links.Add(CommitLink(change.Sha));
 17297                builder.Raw($"▫️ {TelegramMessageBuilder.Encode(change.Title)}\n      {string.Join(" · ", links)}");
 298            }
 299
 2300            if (changes.Count > MaxDeployChangesShown)
 1301                builder.Raw($"<i>…и ещё {changes.Count - MaxDeployChangesShown}</i>");
 302        }
 303
 4304        if (e.Stage == DeployStage.Started)
 1305            builder.Blank().Raw("<i>Сервис будет недоступен около минуты.</i>");
 4306        if (e.Stage == DeployStage.Failed)
 1307            builder.Blank().Raw("Прежняя версия могла остаться запущенной — проверьте ход сборки.");
 4308        if (!string.IsNullOrWhiteSpace(e.RunUrl))
 2309            builder.Blank().Raw($"<a href=\"{TelegramMessageBuilder.Encode(e.RunUrl!)}\">Ход сборки в GitHub Actions</a>
 310
 4311        return new TelegramMessage(builder.Build(), chatId, Silent: e.Stage != DeployStage.Failed, Channel: TelegramChan
 4312            Critical: e.Stage == DeployStage.Failed);
 313    }
 314
 315    private static string FormatDuration(int seconds) =>
 1316        seconds >= 60 ? $"{seconds / 60} мин {seconds % 60} с" : $"{seconds} с";
 317
 318    /// <summary>
 319    /// Вечерняя сводка дня по данным БД: новые заказы, проведённые транзакции (по типам и сотрудникам),
 320    /// заказано у поставщиков, документы 1С, контроль заказов, накат остатков и состояние на момент отправки.
 321    /// </summary>
 322    public static IReadOnlyList<TelegramMessage> DailySummary(DailyWorkSummary s, string? siteBaseUrl = null, long? chat
 323    {
 2324        var date = s.ToLocal;
 2325        var builder = new TelegramMessageBuilder()
 2326            .Title($"{AppPrefix}: сводка дня за {date:dd.MM.yyyy}", "📊")
 2327            .Raw($"<i>с {s.FromLocal:HH:mm} до {s.ToLocal:HH:mm} МСК</i>");
 328
 2329        builder.Blank().Raw($"🆕 <b>Новые заказы:</b> {s.NewOrders}");
 2330        if (s.NewOrdersByClient.Count > 0)
 3331            builder.Raw(string.Join("  ·  ", s.NewOrdersByClient.Select(c => $"{TelegramMessageBuilder.Encode(c.Name)} {
 332
 2333        if (s.Transactions.Count > 0)
 334        {
 3335            var width = Math.Clamp(s.Transactions.Max(t => t.Type.Length), 8, 26);
 1336            var rows = new List<string> { $"{"Операция".PadRight(width)}  тр.  заказов" };
 6337            foreach (var t in s.Transactions)
 2338                rows.Add($"{Shorten(t.Type, width).PadRight(width)}  {t.Transactions,3}  {t.Orders,7}");
 5339            rows.Add($"{"Итого".PadRight(width)}  {s.Transactions.Sum(x => x.Transactions),3}  {s.Transactions.Sum(x => 
 1340            builder.Blank().Title("Проведено").Pre(string.Join("\n", rows));
 341        }
 342        else
 343        {
 1344            builder.Blank().Raw("<b>Проведено:</b> транзакций не было");
 345        }
 346
 2347        if (s.Employees.Count > 0)
 348        {
 2349            var width = Math.Clamp(s.Employees.Max(e => e.User.Length), 8, 24);
 1350            var rows = new List<string> { $"{"Сотрудник".PadRight(width)}  тр.  заказов" };
 4351            foreach (var e in s.Employees)
 1352                rows.Add($"{Shorten(e.User, width).PadRight(width)}  {e.Transactions,3}  {e.Orders,7}");
 1353            builder.Blank().Title("Кто работал").Pre(string.Join("\n", rows));
 354        }
 355
 2356        var work = new List<string>
 2357        {
 2358            $"🏭 Оформлено у поставщиков: <b>{s.SupplierOrdersPlaced}</b>" +
 2359                (s.SupplierOrdersByProvider.Count > 0
 2360                    ? " (" + string.Join(" · ", s.SupplierOrdersByProvider.Take(6).Select(p => $"{TelegramMessageBuilder
 2361                    : ""),
 2362            $"📄 Документы 1С: проведено <b>{s.OneCPostedOk}</b>" + (s.OneCPostedFailed > 0 ? $" · ❌ ошибок {s.OneCPoste
 2363            $"🛡 Контроль заказов: закрыто <b>{s.ControlTasksClosed}</b> · просрочено сейчас {s.ControlTasksOverdue}",
 2364            s.Rollout.Reports > 0
 2365                ? $"📦 Накат остатков: прогонов {s.Rollout.Reports} · применено {s.Rollout.Applied}" + (s.Rollout.Failed
 2366                : "📦 Накат остатков: прогонов не было"
 2367        };
 2368        builder.Blank().Raw(string.Join("\n", work));
 369
 2370        var state = new List<string>
 2371        {
 2372            s.AlertsCritical + s.AlertsWarning == 0
 2373                ? "✅ Активных алертов нет"
 2374                : $"🚨 Критичных: <b>{s.AlertsCritical}</b> · ⚠️ предупреждений: <b>{s.AlertsWarning}</b>",
 2375            $"Без системного статуса: {s.MissingSystemStatus}"
 2376        };
 2377        if (s.Disk is { } disk)
 1378            state.Add($"💾 Диск: свободно {disk.FreeGb:0.#} из {disk.TotalGb:0.#} ГБ ({(disk.TotalGb > 0 ? disk.FreeGb /
 2379        if (s.OzonStock is { } ozonStock && ozonStock.Any(x => x.Positions > 0 || x.Units > 0))
 0380            builder.Blank().Title("Остатки в Ozon по складам").Pre(OzonStockTable(s.OzonStock));
 381
 2382        builder.Blank().Title("Сейчас").Raw(string.Join("\n", state));
 383
 2384        var alertsUrl = AbsoluteUrl("/Studio2/JobLogs?tab=alerts", siteBaseUrl);
 2385        var controlUrl = AbsoluteUrl("/Studio2/OrderControl", siteBaseUrl);
 2386        var links = new List<string>();
 3387        if (alertsUrl != null) links.Add($"<a href=\"{TelegramMessageBuilder.Encode(alertsUrl)}\">Алерты</a>");
 3388        if (controlUrl != null) links.Add($"<a href=\"{TelegramMessageBuilder.Encode(controlUrl)}\">Контроль заказов</a>
 3389        if (links.Count > 0) builder.Blank().Raw(string.Join("  ·  ", links));
 390
 4391        return builder.BuildChunks().Select(html => new TelegramMessage(html, chatId, Silent: true, Channel: TelegramCha
 392    }
 393
 394    /// <summary>
 395    /// Итог наката остатков Bitrix → Ozon: таблица по складам 1С (добавлено / убавлено / обнулено / ошибок),
 396    /// прочие этапы, активные остатки складов и ссылка на отчёт. Без ошибок приходит без звука.
 397    /// </summary>
 398    public static TelegramMessage StockRollout(TelegramRolloutSummary r, string? siteBaseUrl = null, long? chatId = null
 399    {
 3400        var severity = r.HasErrors ? TelegramSeverity.Warning : TelegramSeverity.Success;
 3401        var builder = new TelegramMessageBuilder()
 3402            .Title($"{AppPrefix}: накат остатков Bitrix → Ozon", IconFor(severity))
 3403            .Raw($"🏢 <b>{TelegramMessageBuilder.Encode(r.Client)}</b> · {r.ProductsInReport} товаров в отчёте");
 404
 3405        if (r.Stores.Count > 0)
 406        {
 3407            var rows = new List<string> { "Склад   добав  убав  обнул  ошиб" };
 24408            foreach (var s in r.Stores)
 9409                rows.Add($"{Fit(s.Store, 6)}  {s.Added,5}  {s.Decreased,4}  {s.Cleared,5}  {s.Failed,4}");
 39410            rows.Add($"{"Итого",-6}  {r.Stores.Sum(x => x.Added),5}  {r.Stores.Sum(x => x.Decreased),4}  {r.Stores.Sum(x
 3411            builder.Blank().Title("Накат по складам").Pre(string.Join("\n", rows));
 412        }
 413
 3414        var other = new List<string>();
 3415        if (r.CleanupSuccess > 0 || r.CleanupFailed > 0)
 3416            other.Add($"🧽 Очистка FBS: {r.CleanupSuccess}" + (r.CleanupFailed > 0 ? $" · ❌ {r.CleanupFailed} ошибок" : 
 3417        if (r.PricePushed > 0 || r.PriceUnchanged > 0 || r.PriceFailed > 0)
 3418            other.Add($"💰 Цены: {r.PricePushed} изменено" + (r.PriceUnchanged > 0 ? $" · ≈{r.PriceUnchanged} без изм." 
 3419                      (r.PriceFailed > 0 ? $" · ❌ {r.PriceFailed} ошибок" : ""));
 3420        if (r.OrphansFound > 0 || r.OrphansZeroed > 0)
 3421            other.Add($"👻 Сироты Ozon: найдено {r.OrphansFound} · обнулено {r.OrphansZeroed}" +
 3422                      (r.OrphansZeroed < r.OrphansFound ? $" · ❌ {r.OrphansFound - r.OrphansZeroed} остались" : ""));
 6423        if (other.Count > 0) builder.Blank().Raw(string.Join("\n", other));
 424
 3425        if (r.Stock.Count > 0)
 426        {
 3427            var rows = new List<string> { "Склад  позиций      штук" };
 24428            foreach (var s in r.Stock)
 9429                rows.Add($"{Fit(s.Store, 5)}  {s.Positions,7}  {s.Units,8:0.##}");
 3430            builder.Blank().Title("Активные остатки в 1С");
 3431            builder.Pre(string.Join("\n", rows));
 3432            if (r.StockAsOfUtc is { } asOf)
 3433                builder.Raw($"<i>по выгрузке от {TimeZoneInfo.ConvertTimeFromUtc(DateTime.SpecifyKind(asOf, DateTimeKind
 434        }
 435
 4436        if (r.OzonStock is { } ozon && ozon.Any(x => x.Positions > 0 || x.Units > 0))
 437        {
 1438            builder.Blank().Title("Остатки в Ozon по складам");
 1439            builder.Pre(OzonStockTable(r.OzonStock));
 1440            builder.Raw("<i>факт из Ozon API после наката</i>");
 441        }
 442
 3443        var finished = TimeZoneInfo.ConvertTimeFromUtc(DateTime.SpecifyKind(r.FinishedAtUtc, DateTimeKind.Utc), RussianT
 3444        var reportUrl = AbsoluteUrl($"/Studio2/BitrixStock?tab=sync&report={Uri.EscapeDataString(r.ReportId)}", siteBase
 3445        var footer = $"<i>{finished:dd.MM.yyyy HH:mm} МСК</i>";
 6446        if (reportUrl != null) footer += $"  ·  <a href=\"{TelegramMessageBuilder.Encode(reportUrl)}\">Открыть отчёт</a>
 3447        builder.Blank().Raw(footer);
 448
 3449        return new TelegramMessage(builder.Build(), chatId, Silent: !r.HasErrors, Channel: TelegramChannel.Stock);
 450    }
 451
 1452    private static readonly string[] RolloutStageNames =
 1453        ["построение отчёта", "накат в Ozon", "очистка FBS", "накат цен", "сироты Ozon"];
 454
 455    /// <summary>
 456    /// Накат остатков прерван: на каком этапе, текст ошибки и то, что успело примениться до сбоя.
 457    /// Приходит со звуком и дублируется в личку (критичное).
 458    /// </summary>
 459    public static TelegramMessage StockRolloutFailed(string reportId, TelegramRolloutSummary? partial, int failedStage,
 460        string error, string? siteBaseUrl = null, long? chatId = null)
 461    {
 4462        var stageName = failedStage is >= 1 and <= 5 ? RolloutStageNames[failedStage - 1] : $"этап {failedStage}";
 4463        var builder = new TelegramMessageBuilder()
 4464            .Title($"{AppPrefix}: накат остатков прерван", IconFor(TelegramSeverity.Error));
 4465        if (partial != null)
 1466            builder.Raw($"🏢 <b>{TelegramMessageBuilder.Encode(partial.Client)}</b> · {partial.ProductsInReport} товаров
 4467        builder.Raw($"Этап {failedStage}: <b>{TelegramMessageBuilder.Encode(stageName)}</b>");
 4468        builder.Blank().Raw($"<b>Ошибка</b>\n<pre>{TelegramMessageBuilder.Encode(error.Length > 600 ? error[..600] + "…"
 469
 4470        if (partial is { Stores.Count: > 0 })
 471        {
 1472            var rows = new List<string> { "Склад   добав  убав  обнул  ошиб" };
 8473            foreach (var s in partial.Stores)
 3474                rows.Add($"{Fit(s.Store, 6)}  {s.Added,5}  {s.Decreased,4}  {s.Cleared,5}  {s.Failed,4}");
 1475            builder.Blank().Title("Успело примениться").Pre(string.Join("\n", rows));
 476        }
 3477        else if (partial != null)
 478        {
 0479            builder.Blank().Raw("Накат по складам до сбоя не применялся.");
 480        }
 481
 4482        var url = AbsoluteUrl($"/Studio2/BitrixStock?tab=sync&report={Uri.EscapeDataString(reportId)}", siteBaseUrl);
 5483        if (url != null) builder.Blank().Raw($"<a href=\"{TelegramMessageBuilder.Encode(url)}\">Открыть отчёт</a>");
 484
 4485        return new TelegramMessage(builder.Build(), chatId, Silent: false, Channel: TelegramChannel.Stock, Critical: tru
 486    }
 487
 488    /// <summary>Таблица остатков по складам Ozon; при нескольких клиентах склад подписывается клиентом.</summary>
 489    public static string OzonStockTable(IReadOnlyList<TelegramOzonStockRow> allRows)
 490    {
 491        // Строки-двойники склада без остатка («B12 (1С:B1)» — подпись связи) не несут информации.
 6492        var rows = allRows.Where(x => x.Positions > 0 || x.Units > 0).ToList();
 2493        if (rows.Count == 0) return "Остатков на складах нет";
 6494        var multiClient = rows.Select(x => x.Client).Distinct().Count() > 1;
 8495        string Name(TelegramOzonStockRow x) => multiClient && !string.IsNullOrWhiteSpace(x.Client) ? $"{x.Client} · {x.W
 6496        var width = Math.Clamp(rows.Max(x => Name(x).Length), 10, 26);
 2497        var lines = new List<string> { $"{"Склад Ozon".PadRight(width)}  позиций      штук" };
 12498        foreach (var x in rows)
 4499            lines.Add($"{Shorten(Name(x), width).PadRight(width)}  {x.Positions,7}  {x.Units,8:0.##}");
 10500        lines.Add($"{"Итого".PadRight(width)}  {rows.Sum(x => x.Positions),7}  {rows.Sum(x => x.Units),8:0.##}");
 2501        return string.Join("\n", lines);
 502    }
 503
 21504    private static string Fit(string text, int width) => text.Length > width ? text[..width] : text.PadRight(width);
 505
 506    /// <summary>
 507    /// Сводка о новых заказах: группы по клиенту, в строке — номер отправления (ссылка на сетку заказов
 508    /// с фильтром по этому номеру), склад отгрузки и артикул. Заказы с картинкой товара (до
 509    /// <see cref="MaxNewOrderPhotos"/>) уходят отдельными сообщениями-фото, остальные — одной текстовой сводкой.
 510    /// </summary>
 511    public static IReadOnlyList<TelegramMessage> NewOrders(IReadOnlyList<TelegramNewOrderItem> orders,
 512        string? siteBaseUrl = null, long? chatId = null)
 513    {
 5514        if (orders.Count == 0) return [];
 515
 122516        var withPhoto = orders.Where(o => !string.IsNullOrWhiteSpace(o.ImageUrl)).Take(MaxNewOrderPhotos).ToList();
 5517        var messages = withPhoto
 11518            .Select(o => new TelegramMessage(NewOrdersHtml([o], siteBaseUrl).Single(), chatId,
 11519                ImageUrl: o.ImageUrl!.Trim(), Channel: TelegramChannel.NewOrders))
 5520            .ToList();
 521
 137522        var rest = orders.Where(o => !withPhoto.Contains(o)).ToList();
 5523        messages.AddRange(NewOrdersHtml(rest, siteBaseUrl)
 12524            .Select(html => new TelegramMessage(html, chatId, Channel: TelegramChannel.NewOrders)));
 5525        return messages;
 526    }
 527
 528    private static IReadOnlyList<string> NewOrdersHtml(IReadOnlyList<TelegramNewOrderItem> orders, string? siteBaseUrl)
 529    {
 16530        if (orders.Count == 0) return [];
 531
 16532        var builder = new TelegramMessageBuilder()
 16533            .Title($"{AppPrefix}: новые заказы ({orders.Count})", "🆕");
 534
 16535        var shown = orders.Take(MaxNewOrdersShown).ToList();
 138536        foreach (var group in shown.GroupBy(o => string.IsNullOrWhiteSpace(o.Client) ? "Без клиента" : o.Client!.Trim())
 537        {
 17538            builder.Blank().Raw($"<b>{TelegramMessageBuilder.Encode(group.Key)}</b> · {group.Count()} шт.");
 178539            foreach (var order in group)
 540            {
 72541                var number = TelegramMessageBuilder.Encode(order.ShipmentNumber);
 72542                var url = AbsoluteUrl(OrdersGridLinks.ByShipmentNumber(order.ShipmentNumber), siteBaseUrl);
 72543                var line = new System.Text.StringBuilder("▫️ ")
 72544                    .Append(url == null ? $"<b>{number}</b>" : $"<a href=\"{TelegramMessageBuilder.Encode(url)}\">{numbe
 72545                if (!string.IsNullOrWhiteSpace(order.Warehouse))
 71546                    line.Append($"\n      Склад: {TelegramMessageBuilder.Encode(order.Warehouse!.Trim())}");
 72547                if (!string.IsNullOrWhiteSpace(order.Key))
 71548                    line.Append($"\n      Код: <code>{TelegramMessageBuilder.Encode(order.Key!.Trim())}</code>");
 72549                if (!string.IsNullOrWhiteSpace(order.ProductName))
 4550                    line.Append($"\n      Товар: {TelegramMessageBuilder.Encode(Shorten(order.ProductName!.Trim(), MaxPr
 72551                builder.Raw(line.ToString());
 552            }
 553        }
 554
 16555        if (orders.Count > shown.Count)
 1556            builder.Blank().Raw($"<i>…и ещё {orders.Count - shown.Count}</i>");
 557
 16558        return builder.BuildChunks();
 559    }
 560
 561    private static void AppendDomainTable(TelegramMessageBuilder builder, IReadOnlyList<TelegramAlertItem> items)
 562    {
 91563        var groups = items.GroupBy(x => x.Domain).ToList();
 28564        if (groups.Count < 2) return;
 565
 0566        var width = Math.Min(16, groups.Max(g => g.Key.Length));
 0567        var rows = new List<string> { $"{"Раздел".PadRight(width)}  крит  пред  норм" };
 0568        foreach (var g in groups.OrderByDescending(g => g.Count(x => !x.Resolved && x.Severity >= TelegramSeverity.Criti
 569        {
 0570            var name = g.Key.Length > width ? g.Key[..width] : g.Key.PadRight(width);
 0571            var crit = g.Count(x => !x.Resolved && x.Severity >= TelegramSeverity.Critical);
 0572            var warn = g.Count(x => !x.Resolved && x.Severity < TelegramSeverity.Critical);
 0573            var ok = g.Count(x => x.Resolved);
 0574            rows.Add($"{name}  {crit,4}  {warn,4}  {ok,4}");
 575        }
 576
 0577        builder.Blank().Pre(string.Join("\n", rows));
 0578    }
 579
 580    private static void AppendSection(TelegramMessageBuilder builder, string heading, List<TelegramAlertItem> items, str
 581    {
 48582        if (items.Count == 0) return;
 18583        builder.Blank().Raw($"<b>{heading}</b> ({items.Count})");
 267584        foreach (var item in items) builder.Raw(FormatAlert(item, siteBaseUrl));
 18585    }
 586
 587    private static string FormatAlert(TelegramAlertItem item, string? siteBaseUrl)
 588    {
 77589        var title = TelegramMessageBuilder.Encode(item.Title);
 77590        var url = AbsoluteUrl(item.Url, siteBaseUrl);
 77591        var head = url == null ? title : $"<a href=\"{TelegramMessageBuilder.Encode(url)}\">{title}</a>";
 77592        var sb = new System.Text.StringBuilder($"\n<b>{TelegramMessageBuilder.Encode(item.Domain)}</b> · {head}");
 593
 79594        if (item.Resolved) return sb.ToString();
 595
 339596        var lines = item.Lines.Where(l => !string.IsNullOrWhiteSpace(l)).Select(l => l.Trim()).ToList();
 75597        if (lines.Count > 0)
 598        {
 72599            var body = lines.Take(MaxLinesPerAlert)
 132600                .Select(l => TelegramMessageBuilder.Encode(l.Length > MaxLineLength ? l[..(MaxLineLength - 1)] + "…" : l
 72601                .ToList();
 72602            if (lines.Count > MaxLinesPerAlert) body.Add($"<i>…и ещё {lines.Count - MaxLinesPerAlert}</i>");
 72603            sb.Append("\n<blockquote>").Append(string.Join("\n", body)).Append("</blockquote>");
 604        }
 605
 76606        if (item.OrderCount > 0) sb.Append($"\n📦 Заказов: <b>{item.OrderCount}</b>");
 75607        return sb.ToString();
 608    }
 609
 610    /// <summary>Относительную ссылку превращает в абсолютную; без <paramref name="siteBaseUrl"/> относительная ссылка н
 611    public static string? AbsoluteUrl(string? url, string? siteBaseUrl)
 612    {
 195613        if (string.IsNullOrWhiteSpace(url)) return null;
 191614        if (url.StartsWith("http://", StringComparison.OrdinalIgnoreCase) || url.StartsWith("https://", StringComparison
 0615            return url;
 198616        if (string.IsNullOrWhiteSpace(siteBaseUrl) || !url.StartsWith('/')) return null;
 184617        return siteBaseUrl.TrimEnd('/') + url;
 618    }
 619
 11620    private static string Shorten(string text, int max) => text.Length <= max ? text : text[..(max - 1)].TrimEnd() + "…"
 621
 622    private static string? TrimStack(string? stack)
 623    {
 2624        if (string.IsNullOrWhiteSpace(stack)) return null;
 0625        var lines = stack.Split('\n', StringSplitOptions.RemoveEmptyEntries).Take(6).Select(l => l.Trim());
 0626        return string.Join('\n', lines);
 627    }
 628}

Methods/Properties

IconFor(Services.ApiServices.TelegramBotApi.TelegramSeverity)
Notice(System.String,System.Collections.Generic.IEnumerable`1<System.String>,Services.ApiServices.TelegramBotApi.TelegramSeverity,System.Nullable`1<System.Int64>,System.Boolean)
Alert(System.String,System.Collections.Generic.IEnumerable`1<System.String>,Services.ApiServices.TelegramBotApi.TelegramSeverity,System.String,System.Nullable`1<System.Int64>)
Recovered(System.String,System.String,System.Nullable`1<System.Int64>)
Exception(System.String,System.Exception,System.Collections.Generic.IReadOnlyDictionary`2<System.String,System.String>,System.Nullable`1<System.Int64>)
OneCPostingFailure(System.String,System.String,System.String,System.Collections.Generic.IReadOnlyList`1<System.String>,System.String,System.String,System.String,System.Nullable`1<System.Int64>,System.String)
PreBlocks(Services.ApiServices.TelegramBotApi.TelegramMessageBuilder,System.String,System.Int32)
Flush()
Report(System.String,System.Collections.Generic.IEnumerable`1<System.ValueTuple`2<System.String,System.Collections.Generic.IEnumerable`1<System.String>>>,Services.ApiServices.TelegramBotApi.TelegramSeverity,System.Nullable`1<System.Int64>,System.String)
FromHtml(System.String,System.Nullable`1<System.Int64>,System.Boolean,System.String)
AlertDigest(System.Collections.Generic.IReadOnlyList`1<Services.ApiServices.TelegramBotApi.TelegramAlertItem>,System.String,System.Nullable`1<System.DateTime>,System.Nullable`1<System.Int64>)
ActiveAlerts(System.Collections.Generic.IReadOnlyList`1<Services.ApiServices.TelegramBotApi.TelegramAlertItem>,System.String)
Deploy(Services.ApiServices.TelegramBotApi.DeployEvent,System.Nullable`1<System.Int64>)
CommitLink()
FormatDuration(System.Int32)
DailySummary(Services.DailyWorkSummaryServices.DailyWorkSummary,System.String,System.Nullable`1<System.Int64>)
StockRollout(Services.ApiServices.TelegramBotApi.TelegramRolloutSummary,System.String,System.Nullable`1<System.Int64>)
.cctor()
StockRolloutFailed(System.String,Services.ApiServices.TelegramBotApi.TelegramRolloutSummary,System.Int32,System.String,System.String,System.Nullable`1<System.Int64>)
OzonStockTable(System.Collections.Generic.IReadOnlyList`1<Services.ApiServices.TelegramBotApi.TelegramOzonStockRow>)
Name()
Fit(System.String,System.Int32)
NewOrders(System.Collections.Generic.IReadOnlyList`1<Services.ApiServices.TelegramBotApi.TelegramNewOrderItem>,System.String,System.Nullable`1<System.Int64>)
NewOrdersHtml(System.Collections.Generic.IReadOnlyList`1<Services.ApiServices.TelegramBotApi.TelegramNewOrderItem>,System.String)
AppendDomainTable(Services.ApiServices.TelegramBotApi.TelegramMessageBuilder,System.Collections.Generic.IReadOnlyList`1<Services.ApiServices.TelegramBotApi.TelegramAlertItem>)
AppendSection(Services.ApiServices.TelegramBotApi.TelegramMessageBuilder,System.String,System.Collections.Generic.List`1<Services.ApiServices.TelegramBotApi.TelegramAlertItem>,System.String)
FormatAlert(Services.ApiServices.TelegramBotApi.TelegramAlertItem,System.String)
AbsoluteUrl(System.String,System.String)
Shorten(System.String,System.Int32)
TrimStack(System.String)