| | | 1 | | using Services.BackgroundJobsServices; |
| | | 2 | | using Services.DailyWorkSummaryServices; |
| | | 3 | | |
| | | 4 | | namespace Services.ApiServices.TelegramBotApi; |
| | | 5 | | |
| | | 6 | | /// <summary> |
| | | 7 | | /// Фабрика типовых сообщений бота: единый вид заголовков и иконок, экранирование и лимиты длины. |
| | | 8 | | /// Вызывающий код не собирает HTML руками — берёт готовое сообщение отсюда и отдаёт в <see cref="ITelegramNotifier"/>. |
| | | 9 | | /// </summary> |
| | | 10 | | public static class TelegramMessageFactory |
| | | 11 | | { |
| | | 12 | | private const string AppPrefix = "Studio 2"; |
| | | 13 | | |
| | 29 | 14 | | public static string IconFor(TelegramSeverity severity) => severity switch |
| | 29 | 15 | | { |
| | 6 | 16 | | TelegramSeverity.Success => "✅", |
| | 2 | 17 | | TelegramSeverity.Warning => "⚠️", |
| | 11 | 18 | | TelegramSeverity.Error => "❌", |
| | 4 | 19 | | TelegramSeverity.Critical => "🚨", |
| | 6 | 20 | | _ => "ℹ️" |
| | 29 | 21 | | }; |
| | | 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) => |
| | 6 | 26 | | new(new TelegramMessageBuilder() |
| | 6 | 27 | | .Title($"{AppPrefix}: {title}", IconFor(severity)) |
| | 6 | 28 | | .Lines(lines, bullet: "") |
| | 6 | 29 | | .Build(), |
| | 6 | 30 | | 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 | | { |
| | 1 | 36 | | var builder = new TelegramMessageBuilder() |
| | 1 | 37 | | .Title($"{AppPrefix}: {title}", IconFor(severity)) |
| | 1 | 38 | | .Lines(lines); |
| | 2 | 39 | | if (!string.IsNullOrWhiteSpace(url)) builder.Blank().Link("Открыть в Studio 2", url); |
| | 1 | 40 | | 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) => |
| | 0 | 45 | | new(new TelegramMessageBuilder() |
| | 0 | 46 | | .Title($"{AppPrefix}: восстановлено", IconFor(TelegramSeverity.Success)) |
| | 0 | 47 | | .Line(title) |
| | 0 | 48 | | .Line(details) |
| | 0 | 49 | | .Build(), |
| | 0 | 50 | | 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 | | { |
| | 1 | 56 | | var builder = new TelegramMessageBuilder() |
| | 1 | 57 | | .Title($"{AppPrefix}: ошибка", IconFor(TelegramSeverity.Error)) |
| | 1 | 58 | | .Field("Источник", source, code: true) |
| | 1 | 59 | | .Field("Тип", ex.GetType().Name, code: true) |
| | 1 | 60 | | .Field("Сообщение", ex.Message); |
| | 4 | 61 | | foreach (var (key, value) in context ?? new Dictionary<string, string?>()) |
| | 1 | 62 | | builder.Field(key, value, code: true); |
| | 1 | 63 | | builder.Pre(TrimStack(ex.StackTrace)); |
| | 1 | 64 | | 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 | | { |
| | 6 | 76 | | var builder = new TelegramMessageBuilder() |
| | 6 | 77 | | .Title($"{AppPrefix}: ошибка проведения в 1С", IconFor(TelegramSeverity.Error)) |
| | 6 | 78 | | .Line(hashtag) |
| | 6 | 79 | | .Field("Операция", operation) |
| | 6 | 80 | | .Field("Статус", status) |
| | 6 | 81 | | .Field("Документ", documentNumber, code: true); |
| | 6 | 82 | | if (shipments.Count > 0) |
| | | 83 | | { |
| | | 84 | | const int shown = 10; |
| | 6 | 85 | | var list = string.Join(", ", shipments.Take(shown)); |
| | 6 | 86 | | builder.Field("Заказы", shipments.Count > shown ? $"{list} и ещё {shipments.Count - shown}" : list, code: tr |
| | | 87 | | } |
| | | 88 | | |
| | 6 | 89 | | builder.Blank().Raw("<b>Ответ 1С</b>"); |
| | 6 | 90 | | PreBlocks(builder, oneCError); |
| | 6 | 91 | | if (!string.IsNullOrWhiteSpace(systemMessage) && !string.Equals(systemMessage.Trim(), oneCError.Trim(), StringCo |
| | | 92 | | { |
| | 5 | 93 | | builder.Blank().Raw("<b>Сообщение системы</b>"); |
| | 5 | 94 | | PreBlocks(builder, systemMessage); |
| | | 95 | | } |
| | | 96 | | |
| | 6 | 97 | | var url = AbsoluteUrl("/Studio2/JobLogs?tab=onec&failed=1", siteBaseUrl); |
| | 12 | 98 | | if (url != null) builder.Blank().Link("Открыть журнал проведений 1С", url); |
| | | 99 | | |
| | 6 | 100 | | return builder.BuildChunks() |
| | 10 | 101 | | .Select(html => new TelegramMessage(html, chatId, Channel: TelegramChannel.OneC)) |
| | 6 | 102 | | .ToList(); |
| | | 103 | | } |
| | | 104 | | |
| | | 105 | | /// <summary> |
| | | 106 | | /// Длинный текст отдельными блоками <c><pre></c> (по строкам, при необходимости — по символам), чтобы |
| | | 107 | | /// <see cref="TelegramMessageBuilder.BuildChunks"/> разнёс его по сообщениям, а не обрезал. |
| | | 108 | | /// Длина считается после HTML-экранирования. |
| | | 109 | | /// </summary> |
| | | 110 | | private static void PreBlocks(TelegramMessageBuilder builder, string text, int maxEncoded = 3000) |
| | | 111 | | { |
| | 11 | 112 | | var current = new System.Text.StringBuilder(); |
| | | 113 | | void Flush() |
| | | 114 | | { |
| | 32 | 115 | | if (current.Length > 0) builder.Pre(current.ToString()); |
| | 16 | 116 | | current.Clear(); |
| | 16 | 117 | | } |
| | | 118 | | |
| | 842 | 119 | | foreach (var rawLine in text.Replace("\r\n", "\n").Split('\n')) |
| | | 120 | | { |
| | 410 | 121 | | var line = rawLine; |
| | 410 | 122 | | while (TelegramMessageBuilder.Encode(line).Length > maxEncoded) |
| | | 123 | | { |
| | 0 | 124 | | var take = Math.Min(line.Length, Math.Max(1, maxEncoded / 6)); |
| | 0 | 125 | | while (take < line.Length && TelegramMessageBuilder.Encode(line[..(take + 1)]).Length <= maxEncoded) tak |
| | 0 | 126 | | Flush(); |
| | 0 | 127 | | builder.Pre(line[..take]); |
| | 0 | 128 | | line = line[take..]; |
| | | 129 | | } |
| | | 130 | | |
| | 415 | 131 | | if (TelegramMessageBuilder.Encode(current.ToString() + "\n" + line).Length > maxEncoded) Flush(); |
| | 804 | 132 | | if (current.Length > 0) current.Append('\n'); |
| | 410 | 133 | | current.Append(line); |
| | | 134 | | } |
| | | 135 | | |
| | 11 | 136 | | Flush(); |
| | 11 | 137 | | } |
| | | 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 | | { |
| | 0 | 143 | | var builder = new TelegramMessageBuilder().Title($"{AppPrefix}: {title}", IconFor(severity)); |
| | 0 | 144 | | foreach (var (section, lines) in sections) |
| | | 145 | | { |
| | 0 | 146 | | builder.Blank().Title(section).Lines(lines); |
| | | 147 | | } |
| | | 148 | | |
| | 0 | 149 | | var maxLength = imageUrl == null ? TelegramMessageBuilder.MaxLength : TelegramMessageBuilder.MaxCaptionLength; |
| | 0 | 150 | | 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 |
| | 1 | 155 | | 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 | | { |
| | 7 | 175 | | if (items.Count == 0) return []; |
| | | 176 | | |
| | 71 | 177 | | var critical = items.Where(x => !x.Resolved && x.Severity >= TelegramSeverity.Critical).ToList(); |
| | 71 | 178 | | var warnings = items.Where(x => !x.Resolved && x.Severity < TelegramSeverity.Critical).ToList(); |
| | 71 | 179 | | var resolved = items.Where(x => x.Resolved).ToList(); |
| | | 180 | | |
| | 5 | 181 | | var headSeverity = critical.Count > 0 ? TelegramSeverity.Critical |
| | 5 | 182 | | : warnings.Count > 0 ? TelegramSeverity.Warning |
| | 5 | 183 | | : TelegramSeverity.Success; |
| | 5 | 184 | | var headText = headSeverity switch |
| | 5 | 185 | | { |
| | 3 | 186 | | TelegramSeverity.Critical => "требуется внимание", |
| | 1 | 187 | | TelegramSeverity.Warning => "есть предупреждения", |
| | 1 | 188 | | _ => "всё восстановлено" |
| | 5 | 189 | | }; |
| | | 190 | | |
| | 5 | 191 | | var counters = new List<string>(); |
| | 8 | 192 | | if (critical.Count > 0) counters.Add($"🚨 {critical.Count}"); |
| | 7 | 193 | | if (warnings.Count > 0) counters.Add($"⚠️ {warnings.Count}"); |
| | 7 | 194 | | if (resolved.Count > 0) counters.Add($"✅ {resolved.Count}"); |
| | | 195 | | |
| | 5 | 196 | | var builder = new TelegramMessageBuilder() |
| | 5 | 197 | | .Title($"{AppPrefix}: {headText}", IconFor(headSeverity)) |
| | 5 | 198 | | .Raw($"<i>{string.Join(" · ", counters)}</i>"); |
| | | 199 | | |
| | 5 | 200 | | AppendDomainTable(builder, items); |
| | 5 | 201 | | AppendSection(builder, "🚨 Критичные", critical, siteBaseUrl); |
| | 5 | 202 | | AppendSection(builder, "⚠️ Предупреждения", warnings, siteBaseUrl); |
| | 5 | 203 | | AppendSection(builder, "✅ Восстановлено", resolved, siteBaseUrl); |
| | | 204 | | |
| | 5 | 205 | | var local = TimeZoneInfo.ConvertTimeFromUtc(nowUtc ?? DateTime.UtcNow, RussianTimeZoneHelper.Zone); |
| | 5 | 206 | | var footer = $"<i>{local:dd.MM.yyyy HH:mm} МСК</i>"; |
| | 5 | 207 | | var allUrl = AbsoluteUrl("/Studio2/JobLogs?tab=alerts", siteBaseUrl); |
| | 9 | 208 | | if (allUrl != null) footer += $" · <a href=\"{TelegramMessageBuilder.Encode(allUrl)}\">Все алерты</a>"; |
| | 5 | 209 | | builder.Blank().Raw(footer); |
| | | 210 | | |
| | 5 | 211 | | var silent = critical.Count == 0 && warnings.Count == 0; |
| | 14 | 212 | | 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 | | { |
| | 12 | 226 | | var allUrl = AbsoluteUrl("/Studio2/JobLogs?tab=alerts", siteBaseUrl); |
| | 12 | 227 | | var link = allUrl == null ? "" : $"<a href=\"{TelegramMessageBuilder.Encode(allUrl)}\">Все алерты</a>"; |
| | | 228 | | |
| | 12 | 229 | | if (items.Count == 0) |
| | 3 | 230 | | return new TelegramMessage(new TelegramMessageBuilder() |
| | 3 | 231 | | .Title($"{AppPrefix}: активных алертов нет", IconFor(TelegramSeverity.Success)) |
| | 3 | 232 | | .Raw(link).Build(), Channel: TelegramChannel.Alerts); |
| | | 233 | | |
| | 20 | 234 | | var critical = items.Where(x => x.Severity >= TelegramSeverity.Critical).ToList(); |
| | 20 | 235 | | var warnings = items.Where(x => x.Severity < TelegramSeverity.Critical).ToList(); |
| | 9 | 236 | | var counters = new List<string>(); |
| | 18 | 237 | | if (critical.Count > 0) counters.Add($"🚨 {critical.Count}"); |
| | 11 | 238 | | if (warnings.Count > 0) counters.Add($"⚠️ {warnings.Count}"); |
| | | 239 | | |
| | 9 | 240 | | var builder = new TelegramMessageBuilder() |
| | 9 | 241 | | .Title($"{AppPrefix}: {ActiveAlertsMarker}", "📌") |
| | 9 | 242 | | .Raw($"<i>{string.Join(" · ", counters)}</i>"); |
| | 9 | 243 | | AppendDomainTable(builder, items); |
| | 9 | 244 | | AppendSection(builder, "🚨 Критичные", critical, siteBaseUrl); |
| | 9 | 245 | | AppendSection(builder, "⚠️ Предупреждения", warnings, siteBaseUrl); |
| | | 246 | | |
| | 9 | 247 | | var footer = string.IsNullOrEmpty(link) ? "" : "\n\n" + link; |
| | 9 | 248 | | var chunks = builder.BuildChunks(TelegramMessageBuilder.MaxLength - 200); |
| | 9 | 249 | | var html = chunks[0] + (chunks.Count > 1 ? "\n\n<i>…показаны не все алерты</i>" : "") + footer; |
| | 9 | 250 | | 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 | | { |
| | 4 | 261 | | var repo = string.IsNullOrWhiteSpace(e.Repository) ? "EvgeniBondarev/Studio2Prod" : e.Repository!; |
| | 4 | 262 | | var shortSha = e.Sha.Length > 7 ? e.Sha[..7] : e.Sha; |
| | | 263 | | string CommitLink(string sha, string? text = null) => |
| | 21 | 264 | | $"<a href=\"https://github.com/{repo}/commit/{TelegramMessageBuilder.Encode(sha)}\">{TelegramMessageBuilder. |
| | | 265 | | |
| | 4 | 266 | | var (icon, title) = e.Stage switch |
| | 4 | 267 | | { |
| | 1 | 268 | | DeployStage.Started => ("🚀", "идёт обновление"), |
| | 2 | 269 | | DeployStage.Finished => ("✅", "обновлено"), |
| | 1 | 270 | | _ => ("❌", "обновление не удалось") |
| | 4 | 271 | | }; |
| | | 272 | | |
| | 4 | 273 | | var builder = new TelegramMessageBuilder().Title($"{AppPrefix}: {title}", icon); |
| | | 274 | | |
| | 4 | 275 | | var version = $"Версия: {CommitLink(e.Sha, shortSha)}"; |
| | 4 | 276 | | if (DateTime.TryParse(e.CommitDate, null, System.Globalization.DateTimeStyles.RoundtripKind, out var committed)) |
| | | 277 | | { |
| | 1 | 278 | | var local = TimeZoneInfo.ConvertTime(committed, RussianTimeZoneHelper.Zone); |
| | 1 | 279 | | version += $" · {local:dd.MM.yyyy HH:mm} МСК"; |
| | | 280 | | } |
| | | 281 | | |
| | 4 | 282 | | builder.Raw(version); |
| | 4 | 283 | | if (e.Stage == DeployStage.Finished && e.DurationSeconds is > 0) |
| | 1 | 284 | | builder.Raw($"⏱ Заняло {FormatDuration(e.DurationSeconds.Value)}"); |
| | 5 | 285 | | if (!string.IsNullOrWhiteSpace(e.Actor)) builder.Raw($"👤 {TelegramMessageBuilder.Encode(e.Actor)}"); |
| | | 286 | | |
| | 4 | 287 | | var changes = DeployChangeParser.Parse(e.Commits); |
| | 4 | 288 | | if (changes.Count > 0 && e.Stage != DeployStage.Failed) |
| | | 289 | | { |
| | 2 | 290 | | builder.Blank().Raw($"<b>{(e.Stage == DeployStage.Started ? "Что войдёт" : "Что изменилось")}</b> ({changes. |
| | 38 | 291 | | foreach (var change in changes.Take(MaxDeployChangesShown)) |
| | | 292 | | { |
| | 17 | 293 | | var links = new List<string>(); |
| | 17 | 294 | | if (change.PullRequest is { } pr) |
| | 1 | 295 | | links.Add($"<a href=\"https://github.com/{repo}/pull/{pr}\">PR #{pr}</a>"); |
| | 17 | 296 | | links.Add(CommitLink(change.Sha)); |
| | 17 | 297 | | builder.Raw($"▫️ {TelegramMessageBuilder.Encode(change.Title)}\n {string.Join(" · ", links)}"); |
| | | 298 | | } |
| | | 299 | | |
| | 2 | 300 | | if (changes.Count > MaxDeployChangesShown) |
| | 1 | 301 | | builder.Raw($"<i>…и ещё {changes.Count - MaxDeployChangesShown}</i>"); |
| | | 302 | | } |
| | | 303 | | |
| | 4 | 304 | | if (e.Stage == DeployStage.Started) |
| | 1 | 305 | | builder.Blank().Raw("<i>Сервис будет недоступен около минуты.</i>"); |
| | 4 | 306 | | if (e.Stage == DeployStage.Failed) |
| | 1 | 307 | | builder.Blank().Raw("Прежняя версия могла остаться запущенной — проверьте ход сборки."); |
| | 4 | 308 | | if (!string.IsNullOrWhiteSpace(e.RunUrl)) |
| | 2 | 309 | | builder.Blank().Raw($"<a href=\"{TelegramMessageBuilder.Encode(e.RunUrl!)}\">Ход сборки в GitHub Actions</a> |
| | | 310 | | |
| | 4 | 311 | | return new TelegramMessage(builder.Build(), chatId, Silent: e.Stage != DeployStage.Failed, Channel: TelegramChan |
| | 4 | 312 | | Critical: e.Stage == DeployStage.Failed); |
| | | 313 | | } |
| | | 314 | | |
| | | 315 | | private static string FormatDuration(int seconds) => |
| | 1 | 316 | | 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 | | { |
| | 2 | 324 | | var date = s.ToLocal; |
| | 2 | 325 | | var builder = new TelegramMessageBuilder() |
| | 2 | 326 | | .Title($"{AppPrefix}: сводка дня за {date:dd.MM.yyyy}", "📊") |
| | 2 | 327 | | .Raw($"<i>с {s.FromLocal:HH:mm} до {s.ToLocal:HH:mm} МСК</i>"); |
| | | 328 | | |
| | 2 | 329 | | builder.Blank().Raw($"🆕 <b>Новые заказы:</b> {s.NewOrders}"); |
| | 2 | 330 | | if (s.NewOrdersByClient.Count > 0) |
| | 3 | 331 | | builder.Raw(string.Join(" · ", s.NewOrdersByClient.Select(c => $"{TelegramMessageBuilder.Encode(c.Name)} { |
| | | 332 | | |
| | 2 | 333 | | if (s.Transactions.Count > 0) |
| | | 334 | | { |
| | 3 | 335 | | var width = Math.Clamp(s.Transactions.Max(t => t.Type.Length), 8, 26); |
| | 1 | 336 | | var rows = new List<string> { $"{"Операция".PadRight(width)} тр. заказов" }; |
| | 6 | 337 | | foreach (var t in s.Transactions) |
| | 2 | 338 | | rows.Add($"{Shorten(t.Type, width).PadRight(width)} {t.Transactions,3} {t.Orders,7}"); |
| | 5 | 339 | | rows.Add($"{"Итого".PadRight(width)} {s.Transactions.Sum(x => x.Transactions),3} {s.Transactions.Sum(x => |
| | 1 | 340 | | builder.Blank().Title("Проведено").Pre(string.Join("\n", rows)); |
| | | 341 | | } |
| | | 342 | | else |
| | | 343 | | { |
| | 1 | 344 | | builder.Blank().Raw("<b>Проведено:</b> транзакций не было"); |
| | | 345 | | } |
| | | 346 | | |
| | 2 | 347 | | if (s.Employees.Count > 0) |
| | | 348 | | { |
| | 2 | 349 | | var width = Math.Clamp(s.Employees.Max(e => e.User.Length), 8, 24); |
| | 1 | 350 | | var rows = new List<string> { $"{"Сотрудник".PadRight(width)} тр. заказов" }; |
| | 4 | 351 | | foreach (var e in s.Employees) |
| | 1 | 352 | | rows.Add($"{Shorten(e.User, width).PadRight(width)} {e.Transactions,3} {e.Orders,7}"); |
| | 1 | 353 | | builder.Blank().Title("Кто работал").Pre(string.Join("\n", rows)); |
| | | 354 | | } |
| | | 355 | | |
| | 2 | 356 | | var work = new List<string> |
| | 2 | 357 | | { |
| | 2 | 358 | | $"🏭 Оформлено у поставщиков: <b>{s.SupplierOrdersPlaced}</b>" + |
| | 2 | 359 | | (s.SupplierOrdersByProvider.Count > 0 |
| | 2 | 360 | | ? " (" + string.Join(" · ", s.SupplierOrdersByProvider.Take(6).Select(p => $"{TelegramMessageBuilder |
| | 2 | 361 | | : ""), |
| | 2 | 362 | | $"📄 Документы 1С: проведено <b>{s.OneCPostedOk}</b>" + (s.OneCPostedFailed > 0 ? $" · ❌ ошибок {s.OneCPoste |
| | 2 | 363 | | $"🛡 Контроль заказов: закрыто <b>{s.ControlTasksClosed}</b> · просрочено сейчас {s.ControlTasksOverdue}", |
| | 2 | 364 | | s.Rollout.Reports > 0 |
| | 2 | 365 | | ? $"📦 Накат остатков: прогонов {s.Rollout.Reports} · применено {s.Rollout.Applied}" + (s.Rollout.Failed |
| | 2 | 366 | | : "📦 Накат остатков: прогонов не было" |
| | 2 | 367 | | }; |
| | 2 | 368 | | builder.Blank().Raw(string.Join("\n", work)); |
| | | 369 | | |
| | 2 | 370 | | var state = new List<string> |
| | 2 | 371 | | { |
| | 2 | 372 | | s.AlertsCritical + s.AlertsWarning == 0 |
| | 2 | 373 | | ? "✅ Активных алертов нет" |
| | 2 | 374 | | : $"🚨 Критичных: <b>{s.AlertsCritical}</b> · ⚠️ предупреждений: <b>{s.AlertsWarning}</b>", |
| | 2 | 375 | | $"Без системного статуса: {s.MissingSystemStatus}" |
| | 2 | 376 | | }; |
| | 2 | 377 | | if (s.Disk is { } disk) |
| | 1 | 378 | | state.Add($"💾 Диск: свободно {disk.FreeGb:0.#} из {disk.TotalGb:0.#} ГБ ({(disk.TotalGb > 0 ? disk.FreeGb / |
| | 2 | 379 | | if (s.OzonStock is { } ozonStock && ozonStock.Any(x => x.Positions > 0 || x.Units > 0)) |
| | 0 | 380 | | builder.Blank().Title("Остатки в Ozon по складам").Pre(OzonStockTable(s.OzonStock)); |
| | | 381 | | |
| | 2 | 382 | | builder.Blank().Title("Сейчас").Raw(string.Join("\n", state)); |
| | | 383 | | |
| | 2 | 384 | | var alertsUrl = AbsoluteUrl("/Studio2/JobLogs?tab=alerts", siteBaseUrl); |
| | 2 | 385 | | var controlUrl = AbsoluteUrl("/Studio2/OrderControl", siteBaseUrl); |
| | 2 | 386 | | var links = new List<string>(); |
| | 3 | 387 | | if (alertsUrl != null) links.Add($"<a href=\"{TelegramMessageBuilder.Encode(alertsUrl)}\">Алерты</a>"); |
| | 3 | 388 | | if (controlUrl != null) links.Add($"<a href=\"{TelegramMessageBuilder.Encode(controlUrl)}\">Контроль заказов</a> |
| | 3 | 389 | | if (links.Count > 0) builder.Blank().Raw(string.Join(" · ", links)); |
| | | 390 | | |
| | 4 | 391 | | 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 | | { |
| | 3 | 400 | | var severity = r.HasErrors ? TelegramSeverity.Warning : TelegramSeverity.Success; |
| | 3 | 401 | | var builder = new TelegramMessageBuilder() |
| | 3 | 402 | | .Title($"{AppPrefix}: накат остатков Bitrix → Ozon", IconFor(severity)) |
| | 3 | 403 | | .Raw($"🏢 <b>{TelegramMessageBuilder.Encode(r.Client)}</b> · {r.ProductsInReport} товаров в отчёте"); |
| | | 404 | | |
| | 3 | 405 | | if (r.Stores.Count > 0) |
| | | 406 | | { |
| | 3 | 407 | | var rows = new List<string> { "Склад добав убав обнул ошиб" }; |
| | 24 | 408 | | foreach (var s in r.Stores) |
| | 9 | 409 | | rows.Add($"{Fit(s.Store, 6)} {s.Added,5} {s.Decreased,4} {s.Cleared,5} {s.Failed,4}"); |
| | 39 | 410 | | rows.Add($"{"Итого",-6} {r.Stores.Sum(x => x.Added),5} {r.Stores.Sum(x => x.Decreased),4} {r.Stores.Sum(x |
| | 3 | 411 | | builder.Blank().Title("Накат по складам").Pre(string.Join("\n", rows)); |
| | | 412 | | } |
| | | 413 | | |
| | 3 | 414 | | var other = new List<string>(); |
| | 3 | 415 | | if (r.CleanupSuccess > 0 || r.CleanupFailed > 0) |
| | 3 | 416 | | other.Add($"🧽 Очистка FBS: {r.CleanupSuccess}" + (r.CleanupFailed > 0 ? $" · ❌ {r.CleanupFailed} ошибок" : |
| | 3 | 417 | | if (r.PricePushed > 0 || r.PriceUnchanged > 0 || r.PriceFailed > 0) |
| | 3 | 418 | | other.Add($"💰 Цены: {r.PricePushed} изменено" + (r.PriceUnchanged > 0 ? $" · ≈{r.PriceUnchanged} без изм." |
| | 3 | 419 | | (r.PriceFailed > 0 ? $" · ❌ {r.PriceFailed} ошибок" : "")); |
| | 3 | 420 | | if (r.OrphansFound > 0 || r.OrphansZeroed > 0) |
| | 3 | 421 | | other.Add($"👻 Сироты Ozon: найдено {r.OrphansFound} · обнулено {r.OrphansZeroed}" + |
| | 3 | 422 | | (r.OrphansZeroed < r.OrphansFound ? $" · ❌ {r.OrphansFound - r.OrphansZeroed} остались" : "")); |
| | 6 | 423 | | if (other.Count > 0) builder.Blank().Raw(string.Join("\n", other)); |
| | | 424 | | |
| | 3 | 425 | | if (r.Stock.Count > 0) |
| | | 426 | | { |
| | 3 | 427 | | var rows = new List<string> { "Склад позиций штук" }; |
| | 24 | 428 | | foreach (var s in r.Stock) |
| | 9 | 429 | | rows.Add($"{Fit(s.Store, 5)} {s.Positions,7} {s.Units,8:0.##}"); |
| | 3 | 430 | | builder.Blank().Title("Активные остатки в 1С"); |
| | 3 | 431 | | builder.Pre(string.Join("\n", rows)); |
| | 3 | 432 | | if (r.StockAsOfUtc is { } asOf) |
| | 3 | 433 | | builder.Raw($"<i>по выгрузке от {TimeZoneInfo.ConvertTimeFromUtc(DateTime.SpecifyKind(asOf, DateTimeKind |
| | | 434 | | } |
| | | 435 | | |
| | 4 | 436 | | if (r.OzonStock is { } ozon && ozon.Any(x => x.Positions > 0 || x.Units > 0)) |
| | | 437 | | { |
| | 1 | 438 | | builder.Blank().Title("Остатки в Ozon по складам"); |
| | 1 | 439 | | builder.Pre(OzonStockTable(r.OzonStock)); |
| | 1 | 440 | | builder.Raw("<i>факт из Ozon API после наката</i>"); |
| | | 441 | | } |
| | | 442 | | |
| | 3 | 443 | | var finished = TimeZoneInfo.ConvertTimeFromUtc(DateTime.SpecifyKind(r.FinishedAtUtc, DateTimeKind.Utc), RussianT |
| | 3 | 444 | | var reportUrl = AbsoluteUrl($"/Studio2/BitrixStock?tab=sync&report={Uri.EscapeDataString(r.ReportId)}", siteBase |
| | 3 | 445 | | var footer = $"<i>{finished:dd.MM.yyyy HH:mm} МСК</i>"; |
| | 6 | 446 | | if (reportUrl != null) footer += $" · <a href=\"{TelegramMessageBuilder.Encode(reportUrl)}\">Открыть отчёт</a> |
| | 3 | 447 | | builder.Blank().Raw(footer); |
| | | 448 | | |
| | 3 | 449 | | return new TelegramMessage(builder.Build(), chatId, Silent: !r.HasErrors, Channel: TelegramChannel.Stock); |
| | | 450 | | } |
| | | 451 | | |
| | 1 | 452 | | private static readonly string[] RolloutStageNames = |
| | 1 | 453 | | ["построение отчёта", "накат в 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 | | { |
| | 4 | 462 | | var stageName = failedStage is >= 1 and <= 5 ? RolloutStageNames[failedStage - 1] : $"этап {failedStage}"; |
| | 4 | 463 | | var builder = new TelegramMessageBuilder() |
| | 4 | 464 | | .Title($"{AppPrefix}: накат остатков прерван", IconFor(TelegramSeverity.Error)); |
| | 4 | 465 | | if (partial != null) |
| | 1 | 466 | | builder.Raw($"🏢 <b>{TelegramMessageBuilder.Encode(partial.Client)}</b> · {partial.ProductsInReport} товаров |
| | 4 | 467 | | builder.Raw($"Этап {failedStage}: <b>{TelegramMessageBuilder.Encode(stageName)}</b>"); |
| | 4 | 468 | | builder.Blank().Raw($"<b>Ошибка</b>\n<pre>{TelegramMessageBuilder.Encode(error.Length > 600 ? error[..600] + "…" |
| | | 469 | | |
| | 4 | 470 | | if (partial is { Stores.Count: > 0 }) |
| | | 471 | | { |
| | 1 | 472 | | var rows = new List<string> { "Склад добав убав обнул ошиб" }; |
| | 8 | 473 | | foreach (var s in partial.Stores) |
| | 3 | 474 | | rows.Add($"{Fit(s.Store, 6)} {s.Added,5} {s.Decreased,4} {s.Cleared,5} {s.Failed,4}"); |
| | 1 | 475 | | builder.Blank().Title("Успело примениться").Pre(string.Join("\n", rows)); |
| | | 476 | | } |
| | 3 | 477 | | else if (partial != null) |
| | | 478 | | { |
| | 0 | 479 | | builder.Blank().Raw("Накат по складам до сбоя не применялся."); |
| | | 480 | | } |
| | | 481 | | |
| | 4 | 482 | | var url = AbsoluteUrl($"/Studio2/BitrixStock?tab=sync&report={Uri.EscapeDataString(reportId)}", siteBaseUrl); |
| | 5 | 483 | | if (url != null) builder.Blank().Raw($"<a href=\"{TelegramMessageBuilder.Encode(url)}\">Открыть отчёт</a>"); |
| | | 484 | | |
| | 4 | 485 | | 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)» — подпись связи) не несут информации. |
| | 6 | 492 | | var rows = allRows.Where(x => x.Positions > 0 || x.Units > 0).ToList(); |
| | 2 | 493 | | if (rows.Count == 0) return "Остатков на складах нет"; |
| | 6 | 494 | | var multiClient = rows.Select(x => x.Client).Distinct().Count() > 1; |
| | 8 | 495 | | string Name(TelegramOzonStockRow x) => multiClient && !string.IsNullOrWhiteSpace(x.Client) ? $"{x.Client} · {x.W |
| | 6 | 496 | | var width = Math.Clamp(rows.Max(x => Name(x).Length), 10, 26); |
| | 2 | 497 | | var lines = new List<string> { $"{"Склад Ozon".PadRight(width)} позиций штук" }; |
| | 12 | 498 | | foreach (var x in rows) |
| | 4 | 499 | | lines.Add($"{Shorten(Name(x), width).PadRight(width)} {x.Positions,7} {x.Units,8:0.##}"); |
| | 10 | 500 | | lines.Add($"{"Итого".PadRight(width)} {rows.Sum(x => x.Positions),7} {rows.Sum(x => x.Units),8:0.##}"); |
| | 2 | 501 | | return string.Join("\n", lines); |
| | | 502 | | } |
| | | 503 | | |
| | 21 | 504 | | 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 | | { |
| | 5 | 514 | | if (orders.Count == 0) return []; |
| | | 515 | | |
| | 122 | 516 | | var withPhoto = orders.Where(o => !string.IsNullOrWhiteSpace(o.ImageUrl)).Take(MaxNewOrderPhotos).ToList(); |
| | 5 | 517 | | var messages = withPhoto |
| | 11 | 518 | | .Select(o => new TelegramMessage(NewOrdersHtml([o], siteBaseUrl).Single(), chatId, |
| | 11 | 519 | | ImageUrl: o.ImageUrl!.Trim(), Channel: TelegramChannel.NewOrders)) |
| | 5 | 520 | | .ToList(); |
| | | 521 | | |
| | 137 | 522 | | var rest = orders.Where(o => !withPhoto.Contains(o)).ToList(); |
| | 5 | 523 | | messages.AddRange(NewOrdersHtml(rest, siteBaseUrl) |
| | 12 | 524 | | .Select(html => new TelegramMessage(html, chatId, Channel: TelegramChannel.NewOrders))); |
| | 5 | 525 | | return messages; |
| | | 526 | | } |
| | | 527 | | |
| | | 528 | | private static IReadOnlyList<string> NewOrdersHtml(IReadOnlyList<TelegramNewOrderItem> orders, string? siteBaseUrl) |
| | | 529 | | { |
| | 16 | 530 | | if (orders.Count == 0) return []; |
| | | 531 | | |
| | 16 | 532 | | var builder = new TelegramMessageBuilder() |
| | 16 | 533 | | .Title($"{AppPrefix}: новые заказы ({orders.Count})", "🆕"); |
| | | 534 | | |
| | 16 | 535 | | var shown = orders.Take(MaxNewOrdersShown).ToList(); |
| | 138 | 536 | | foreach (var group in shown.GroupBy(o => string.IsNullOrWhiteSpace(o.Client) ? "Без клиента" : o.Client!.Trim()) |
| | | 537 | | { |
| | 17 | 538 | | builder.Blank().Raw($"<b>{TelegramMessageBuilder.Encode(group.Key)}</b> · {group.Count()} шт."); |
| | 178 | 539 | | foreach (var order in group) |
| | | 540 | | { |
| | 72 | 541 | | var number = TelegramMessageBuilder.Encode(order.ShipmentNumber); |
| | 72 | 542 | | var url = AbsoluteUrl(OrdersGridLinks.ByShipmentNumber(order.ShipmentNumber), siteBaseUrl); |
| | 72 | 543 | | var line = new System.Text.StringBuilder("▫️ ") |
| | 72 | 544 | | .Append(url == null ? $"<b>{number}</b>" : $"<a href=\"{TelegramMessageBuilder.Encode(url)}\">{numbe |
| | 72 | 545 | | if (!string.IsNullOrWhiteSpace(order.Warehouse)) |
| | 71 | 546 | | line.Append($"\n Склад: {TelegramMessageBuilder.Encode(order.Warehouse!.Trim())}"); |
| | 72 | 547 | | if (!string.IsNullOrWhiteSpace(order.Key)) |
| | 71 | 548 | | line.Append($"\n Код: <code>{TelegramMessageBuilder.Encode(order.Key!.Trim())}</code>"); |
| | 72 | 549 | | if (!string.IsNullOrWhiteSpace(order.ProductName)) |
| | 4 | 550 | | line.Append($"\n Товар: {TelegramMessageBuilder.Encode(Shorten(order.ProductName!.Trim(), MaxPr |
| | 72 | 551 | | builder.Raw(line.ToString()); |
| | | 552 | | } |
| | | 553 | | } |
| | | 554 | | |
| | 16 | 555 | | if (orders.Count > shown.Count) |
| | 1 | 556 | | builder.Blank().Raw($"<i>…и ещё {orders.Count - shown.Count}</i>"); |
| | | 557 | | |
| | 16 | 558 | | return builder.BuildChunks(); |
| | | 559 | | } |
| | | 560 | | |
| | | 561 | | private static void AppendDomainTable(TelegramMessageBuilder builder, IReadOnlyList<TelegramAlertItem> items) |
| | | 562 | | { |
| | 91 | 563 | | var groups = items.GroupBy(x => x.Domain).ToList(); |
| | 28 | 564 | | if (groups.Count < 2) return; |
| | | 565 | | |
| | 0 | 566 | | var width = Math.Min(16, groups.Max(g => g.Key.Length)); |
| | 0 | 567 | | var rows = new List<string> { $"{"Раздел".PadRight(width)} крит пред норм" }; |
| | 0 | 568 | | foreach (var g in groups.OrderByDescending(g => g.Count(x => !x.Resolved && x.Severity >= TelegramSeverity.Criti |
| | | 569 | | { |
| | 0 | 570 | | var name = g.Key.Length > width ? g.Key[..width] : g.Key.PadRight(width); |
| | 0 | 571 | | var crit = g.Count(x => !x.Resolved && x.Severity >= TelegramSeverity.Critical); |
| | 0 | 572 | | var warn = g.Count(x => !x.Resolved && x.Severity < TelegramSeverity.Critical); |
| | 0 | 573 | | var ok = g.Count(x => x.Resolved); |
| | 0 | 574 | | rows.Add($"{name} {crit,4} {warn,4} {ok,4}"); |
| | | 575 | | } |
| | | 576 | | |
| | 0 | 577 | | builder.Blank().Pre(string.Join("\n", rows)); |
| | 0 | 578 | | } |
| | | 579 | | |
| | | 580 | | private static void AppendSection(TelegramMessageBuilder builder, string heading, List<TelegramAlertItem> items, str |
| | | 581 | | { |
| | 48 | 582 | | if (items.Count == 0) return; |
| | 18 | 583 | | builder.Blank().Raw($"<b>{heading}</b> ({items.Count})"); |
| | 267 | 584 | | foreach (var item in items) builder.Raw(FormatAlert(item, siteBaseUrl)); |
| | 18 | 585 | | } |
| | | 586 | | |
| | | 587 | | private static string FormatAlert(TelegramAlertItem item, string? siteBaseUrl) |
| | | 588 | | { |
| | 77 | 589 | | var title = TelegramMessageBuilder.Encode(item.Title); |
| | 77 | 590 | | var url = AbsoluteUrl(item.Url, siteBaseUrl); |
| | 77 | 591 | | var head = url == null ? title : $"<a href=\"{TelegramMessageBuilder.Encode(url)}\">{title}</a>"; |
| | 77 | 592 | | var sb = new System.Text.StringBuilder($"\n<b>{TelegramMessageBuilder.Encode(item.Domain)}</b> · {head}"); |
| | | 593 | | |
| | 79 | 594 | | if (item.Resolved) return sb.ToString(); |
| | | 595 | | |
| | 339 | 596 | | var lines = item.Lines.Where(l => !string.IsNullOrWhiteSpace(l)).Select(l => l.Trim()).ToList(); |
| | 75 | 597 | | if (lines.Count > 0) |
| | | 598 | | { |
| | 72 | 599 | | var body = lines.Take(MaxLinesPerAlert) |
| | 132 | 600 | | .Select(l => TelegramMessageBuilder.Encode(l.Length > MaxLineLength ? l[..(MaxLineLength - 1)] + "…" : l |
| | 72 | 601 | | .ToList(); |
| | 72 | 602 | | if (lines.Count > MaxLinesPerAlert) body.Add($"<i>…и ещё {lines.Count - MaxLinesPerAlert}</i>"); |
| | 72 | 603 | | sb.Append("\n<blockquote>").Append(string.Join("\n", body)).Append("</blockquote>"); |
| | | 604 | | } |
| | | 605 | | |
| | 76 | 606 | | if (item.OrderCount > 0) sb.Append($"\n📦 Заказов: <b>{item.OrderCount}</b>"); |
| | 75 | 607 | | return sb.ToString(); |
| | | 608 | | } |
| | | 609 | | |
| | | 610 | | /// <summary>Относительную ссылку превращает в абсолютную; без <paramref name="siteBaseUrl"/> относительная ссылка н |
| | | 611 | | public static string? AbsoluteUrl(string? url, string? siteBaseUrl) |
| | | 612 | | { |
| | 195 | 613 | | if (string.IsNullOrWhiteSpace(url)) return null; |
| | 191 | 614 | | if (url.StartsWith("http://", StringComparison.OrdinalIgnoreCase) || url.StartsWith("https://", StringComparison |
| | 0 | 615 | | return url; |
| | 198 | 616 | | if (string.IsNullOrWhiteSpace(siteBaseUrl) || !url.StartsWith('/')) return null; |
| | 184 | 617 | | return siteBaseUrl.TrimEnd('/') + url; |
| | | 618 | | } |
| | | 619 | | |
| | 11 | 620 | | 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 | | { |
| | 2 | 624 | | if (string.IsNullOrWhiteSpace(stack)) return null; |
| | 0 | 625 | | var lines = stack.Split('\n', StringSplitOptions.RemoveEmptyEntries).Take(6).Select(l => l.Trim()); |
| | 0 | 626 | | return string.Join('\n', lines); |
| | | 627 | | } |
| | | 628 | | } |