| | | 1 | | using System.Net; |
| | | 2 | | using System.Text; |
| | | 3 | | using System.Text.RegularExpressions; |
| | | 4 | | using OzonOrdersWeb.TransactionServices; |
| | | 5 | | using OzonOrdersWeb.WebServices.OrderHistory; |
| | | 6 | | using Services.LabelServices; |
| | | 7 | | using Services.SupplierStatusServices; |
| | | 8 | | |
| | | 9 | | namespace OzonOrdersWeb.WebServices.Transactions; |
| | | 10 | | |
| | | 11 | | public static class TransactionReportLevel |
| | | 12 | | { |
| | | 13 | | public const string Danger = "danger"; |
| | | 14 | | public const string Warning = "warning"; |
| | | 15 | | public const string Info = "info"; |
| | | 16 | | public const string Success = "success"; |
| | | 17 | | |
| | | 18 | | public static int Rank(string level) => level switch |
| | | 19 | | { |
| | | 20 | | Danger => 0, |
| | | 21 | | Warning => 1, |
| | | 22 | | Info => 2, |
| | | 23 | | _ => 3 |
| | | 24 | | }; |
| | | 25 | | } |
| | | 26 | | |
| | | 27 | | public sealed record TransactionReportLink(string Text, string Url); |
| | | 28 | | |
| | | 29 | | /// <summary>Одна строка результата проведения: что произошло, почему и что делать (рисуется AG Grid в модалке результат |
| | | 30 | | public sealed record TransactionReportRow( |
| | | 31 | | string Level, |
| | | 32 | | string Section, |
| | | 33 | | int? OrderId, |
| | | 34 | | string? ShipmentNumber, |
| | | 35 | | string? Subject, |
| | | 36 | | string? LogoUrl, |
| | | 37 | | string Title, |
| | | 38 | | string? Details, |
| | | 39 | | string? Recommendation, |
| | | 40 | | string? Technical, |
| | | 41 | | IReadOnlyList<TransactionReportLink> Links); |
| | | 42 | | |
| | | 43 | | public sealed record TransactionReportSummary( |
| | | 44 | | int ChangedCount, |
| | | 45 | | int CancelledCount, |
| | | 46 | | string StatusName, |
| | | 47 | | string DateTime, |
| | | 48 | | string UserName, |
| | | 49 | | string? Comment); |
| | | 50 | | |
| | 14 | 51 | | public sealed record TransactionReportDto( |
| | 0 | 52 | | TransactionReportSummary Summary, |
| | 1 | 53 | | IReadOnlyList<TransactionReportRow> Rows, |
| | 1 | 54 | | int Errors, |
| | 1 | 55 | | int Warnings, |
| | 15 | 56 | | int Successes); |
| | | 57 | | |
| | | 58 | | public static class TransactionReportLinks |
| | | 59 | | { |
| | | 60 | | public static TransactionReportLink OrderHistory(int orderId) => |
| | | 61 | | new("История заказа", $"/Studio2/OrderHistory/GetOrderHistory?orderId={orderId}"); |
| | | 62 | | |
| | | 63 | | public static TransactionReportLink SupplierCart(string providerKey, string? text = null) => |
| | | 64 | | new(text ?? "Корзина поставщика", $"/SupplierCart?provider={Uri.EscapeDataString(providerKey.ToLowerInvariant()) |
| | | 65 | | } |
| | | 66 | | |
| | | 67 | | /// <summary>Копит строки результата проведения; шаги добавляют их по мере работы.</summary> |
| | | 68 | | public sealed class TransactionReportBuilder |
| | | 69 | | { |
| | | 70 | | private readonly List<TransactionReportRow> _rows = new(); |
| | | 71 | | |
| | | 72 | | public IReadOnlyList<TransactionReportRow> Rows => _rows; |
| | | 73 | | |
| | | 74 | | public void Add(TransactionReportRow row) => _rows.Add(row); |
| | | 75 | | |
| | | 76 | | public void AddRange(IEnumerable<TransactionReportRow> rows) => _rows.AddRange(rows); |
| | | 77 | | |
| | | 78 | | public TransactionReportDto Build(TransactionReportSummary summary) |
| | | 79 | | { |
| | | 80 | | var rows = _rows |
| | | 81 | | .Select((row, index) => (row, index)) |
| | | 82 | | .OrderBy(x => TransactionReportLevel.Rank(x.row.Level)) |
| | | 83 | | .ThenBy(x => x.index) |
| | | 84 | | .Select(x => x.row) |
| | | 85 | | .ToList(); |
| | | 86 | | return new TransactionReportDto( |
| | | 87 | | summary, rows, |
| | | 88 | | rows.Count(x => x.Level == TransactionReportLevel.Danger), |
| | | 89 | | rows.Count(x => x.Level == TransactionReportLevel.Warning), |
| | | 90 | | rows.Count(x => x.Level == TransactionReportLevel.Success)); |
| | | 91 | | } |
| | | 92 | | } |
| | | 93 | | |
| | | 94 | | /// <summary>Преобразование результатов шагов (Ozon, поставщики, HTML-блоки) в строки отчёта.</summary> |
| | | 95 | | public static partial class TransactionReportMapper |
| | | 96 | | { |
| | | 97 | | public const string SectionOzonShip = "Отгрузка в Ozon"; |
| | | 98 | | public const string SectionSupplierOrder = "Заказ у поставщика"; |
| | | 99 | | public const string SectionSupplierCheck = "Проверка у поставщика"; |
| | | 100 | | public const string SectionOrders = "Статус заказа"; |
| | | 101 | | public const string SectionOther = "Прочее"; |
| | | 102 | | |
| | | 103 | | private static readonly IReadOnlyList<TransactionReportLink> NoLinks = Array.Empty<TransactionReportLink>(); |
| | | 104 | | |
| | | 105 | | public static IEnumerable<TransactionReportRow> FromOzonShip(OrderToSupplierOzonShipResult? result) |
| | | 106 | | { |
| | | 107 | | if (result is null) yield break; |
| | | 108 | | foreach (var entry in result.Entries) |
| | | 109 | | { |
| | | 110 | | var links = new List<TransactionReportLink> { TransactionReportLinks.OrderHistory(entry.OrderId) }; |
| | | 111 | | if (entry.IsSuccess) |
| | | 112 | | { |
| | | 113 | | yield return new(TransactionReportLevel.Success, SectionOzonShip, entry.OrderId, entry.ShipmentNumber, |
| | | 114 | | "Ozon", null, "Отправление отгружено в Ozon", entry.Message, null, null, links); |
| | | 115 | | } |
| | | 116 | | else if (entry.IsSkipped) |
| | | 117 | | { |
| | | 118 | | yield return new(TransactionReportLevel.Info, SectionOzonShip, entry.OrderId, entry.ShipmentNumber, |
| | | 119 | | "Ozon", null, "Отгрузка в Ozon не потребовалась", entry.Message, null, null, links); |
| | | 120 | | } |
| | | 121 | | else |
| | | 122 | | { |
| | | 123 | | var friendly = OzonLabelPrintErrorHelper.FormatShipmentError(entry.ShipmentNumber, entry.Message, entry. |
| | | 124 | | var prefix = entry.ShipmentNumber + ": "; |
| | | 125 | | if (friendly.StartsWith(prefix, StringComparison.Ordinal)) friendly = friendly[prefix.Length..]; |
| | | 126 | | var raw = OzonLabelPrintErrorHelper.TryGetOzonErrorCode(entry.Message, out _) || friendly != entry.Messa |
| | | 127 | | yield return new(TransactionReportLevel.Warning, SectionOzonShip, entry.OrderId, entry.ShipmentNumber, |
| | | 128 | | "Ozon", null, "Не удалось отгрузить отправление в Ozon", friendly, |
| | | 129 | | OzonShipRecommendation(entry.Message), raw, links); |
| | | 130 | | } |
| | | 131 | | } |
| | | 132 | | } |
| | | 133 | | |
| | | 134 | | private static string OzonShipRecommendation(string? raw) |
| | | 135 | | { |
| | | 136 | | if (OzonLabelPrintErrorHelper.TryGetOzonErrorCode(raw, out var code)) |
| | | 137 | | { |
| | | 138 | | return code.ToUpperInvariant() switch |
| | | 139 | | { |
| | | 140 | | "HAS_INCORRECT_STATUS" => |
| | | 141 | | "Если заказ уже отгружался раньше (например, перезаказ у другого поставщика), повторная отгрузка не |
| | | 142 | | "Иначе проверьте статус отправления в личном кабинете Ozon.", |
| | | 143 | | "HAS_INCORRECT_PRODUCT_QUANTITY" => "Сверьте количество в заказе с количеством в отправлении Ozon и повт |
| | | 144 | | "POSTING_NOT_FOUND" or "POSTING_ALREADY_CANCELLED" => "Проверьте отправление в личном кабинете Ozon: оно |
| | | 145 | | _ => "Проверьте отправление в личном кабинете Ozon и при необходимости отгрузите его вручную." |
| | | 146 | | }; |
| | | 147 | | } |
| | | 148 | | |
| | | 149 | | return "Проверьте отправление в личном кабинете Ozon и при необходимости отгрузите его вручную."; |
| | | 150 | | } |
| | | 151 | | |
| | | 152 | | /// <summary>Строки проверки поставщиков. <paramref name="queueProviderByOrder"/> — поставщик из очереди для заказов |
| | | 153 | | public static IEnumerable<TransactionReportRow> FromVerification( |
| | | 154 | | SupplierVerificationDto? verification, IReadOnlyDictionary<int, string>? queueProviderByOrder = null) |
| | | 155 | | { |
| | | 156 | | if (verification is null) yield break; |
| | | 157 | | foreach (var row in verification.Rows) |
| | | 158 | | { |
| | | 159 | | var providerKey = row.ProviderKey; |
| | | 160 | | if (string.IsNullOrWhiteSpace(providerKey) && queueProviderByOrder?.TryGetValue(row.OrderId, out var fromQue |
| | | 161 | | providerKey = fromQueue; |
| | | 162 | | |
| | | 163 | | var subject = string.IsNullOrWhiteSpace(providerKey) ? row.ProviderName : SupplierProviderNames.Describe(pro |
| | | 164 | | var links = new List<TransactionReportLink>(); |
| | | 165 | | if (!string.IsNullOrWhiteSpace(providerKey)) links.Add(TransactionReportLinks.SupplierCart(providerKey)); |
| | | 166 | | links.Add(TransactionReportLinks.OrderHistory(row.OrderId)); |
| | | 167 | | |
| | | 168 | | var level = row.Tone switch |
| | | 169 | | { |
| | | 170 | | "success" => TransactionReportLevel.Success, |
| | | 171 | | "muted" => TransactionReportLevel.Info, |
| | | 172 | | "warning" => TransactionReportLevel.Warning, |
| | | 173 | | _ => TransactionReportLevel.Danger |
| | | 174 | | }; |
| | | 175 | | |
| | | 176 | | var details = new StringBuilder(row.Summary); |
| | | 177 | | if (!string.IsNullOrWhiteSpace(row.SupplierOrderNumber)) details.Append($"\nНомер заказа у поставщика: {row. |
| | | 178 | | if (!string.IsNullOrWhiteSpace(row.SupplierStatusName)) details.Append($"\nСтатус у поставщика: {row.Supplie |
| | | 179 | | if (row.Checks.Count > 0) details.Append('\n').Append(string.Join('\n', row.Checks)); |
| | | 180 | | |
| | | 181 | | yield return new(level, SectionSupplierCheck, row.OrderId, row.ShipmentNumber, subject, |
| | | 182 | | string.IsNullOrWhiteSpace(providerKey) ? row.LogoUrl : OrderHistoryGridRows.SupplierProviderLogoUrl(prov |
| | | 183 | | row.StatusTitle, details.ToString(), VerificationRecommendation(row.Status, subject), null, links); |
| | | 184 | | } |
| | | 185 | | } |
| | | 186 | | |
| | | 187 | | private static string? VerificationRecommendation(string status, string supplier) => status switch |
| | | 188 | | { |
| | | 189 | | SupplierVerificationStatus.NotFound => |
| | | 190 | | $"Позиция не попала к поставщику «{supplier}». Откройте корзину поставщика и проверьте заказ; " + |
| | | 191 | | "если позиции нет — оформите её вручную. Статус заказа в Studio2 уже изменён.", |
| | | 192 | | SupplierVerificationStatus.Mismatch => |
| | | 193 | | $"Данные у поставщика «{supplier}» отличаются от Studio2 — сверьте заказ в кабинете поставщика.", |
| | | 194 | | SupplierVerificationStatus.ApiError => |
| | | 195 | | $"Поставщик «{supplier}» не ответил или вернул ошибку API. Проверьте заказ в его кабинете вручную.", |
| | | 196 | | _ => null |
| | | 197 | | }; |
| | | 198 | | |
| | | 199 | | /// <summary>Строки сервисов оформления: по сообщению на строку.</summary> |
| | | 200 | | public static IEnumerable<TransactionReportRow> FromSupplierMessages( |
| | | 201 | | string providerKey, string supplierName, string section, string title, IEnumerable<string> messages) |
| | | 202 | | { |
| | | 203 | | foreach (var message in messages.Where(m => !string.IsNullOrWhiteSpace(m))) |
| | | 204 | | { |
| | | 205 | | var text = HtmlToText(message); |
| | | 206 | | var warn = text.Contains("не проведён", StringComparison.OrdinalIgnoreCase) |
| | | 207 | | || text.Contains("не проведен", StringComparison.OrdinalIgnoreCase); |
| | | 208 | | yield return new(warn ? TransactionReportLevel.Warning : TransactionReportLevel.Success, section, null, null |
| | | 209 | | supplierName, OrderHistoryGridRows.SupplierProviderLogoUrl(providerKey), title, text, null, null, |
| | | 210 | | new[] { TransactionReportLinks.SupplierCart(providerKey) }); |
| | | 211 | | } |
| | | 212 | | } |
| | | 213 | | |
| | | 214 | | public static IEnumerable<TransactionReportRow> FromOrders(IEnumerable<OzonDomains.Models.Order> orders, string stat |
| | | 215 | | { |
| | | 216 | | foreach (var order in orders.Where(o => o.Id > 0).Take(300)) |
| | | 217 | | { |
| | | 218 | | var status = order.AppStatus?.Name ?? statusName; |
| | | 219 | | yield return new(TransactionReportLevel.Success, SectionOrders, order.Id, order.ShipmentNumber, null, null, |
| | | 220 | | "Заказ проведён", $"Статус: {status}", null, null, new[] { TransactionReportLinks.OrderHistory(order.Id) |
| | | 221 | | } |
| | | 222 | | } |
| | | 223 | | |
| | | 224 | | public static IEnumerable<TransactionReportRow> FromWarnings(IEnumerable<string> warnings) |
| | | 225 | | { |
| | | 226 | | foreach (var warning in warnings.Where(w => !string.IsNullOrWhiteSpace(w) |
| | | 227 | | && !w.StartsWith("Проверка поставщиков:", StringComparison.Ordinal)) |
| | | 228 | | yield return new(TransactionReportLevel.Warning, "Предупреждение", null, null, null, null, |
| | | 229 | | "Проведено с предупреждением", warning, null, null, NoLinks); |
| | | 230 | | } |
| | | 231 | | |
| | | 232 | | // ---------- HTML-блоки шагов, у которых нет структурированного результата ---------- |
| | | 233 | | |
| | | 234 | | [GeneratedRegex(@"(?=<div[^>]*class=['""][^'""]*\balert\b)", RegexOptions.IgnoreCase)] |
| | | 235 | | private static partial Regex AlertSplitRegex(); |
| | | 236 | | |
| | | 237 | | [GeneratedRegex(@"<(?:strong|b)[^>]*>(.*?)</(?:strong|b)>|<div[^>]*fw-semibold[^>]*>(.*?)</div>", RegexOptions.Ignor |
| | | 238 | | private static partial Regex TitleRegex(); |
| | | 239 | | |
| | | 240 | | [GeneratedRegex(@"<a\s[^>]*href=['""]([^'""]+)['""][^>]*>(.*?)</a>", RegexOptions.IgnoreCase | RegexOptions.Singleli |
| | | 241 | | private static partial Regex AnchorRegex(); |
| | | 242 | | |
| | | 243 | | [GeneratedRegex(@"<(?:br\s*/?|/li|/p|/div|/tr|/h\d)>", RegexOptions.IgnoreCase)] |
| | | 244 | | private static partial Regex LineBreakRegex(); |
| | | 245 | | |
| | | 246 | | [GeneratedRegex(@"<li[^>]*>", RegexOptions.IgnoreCase)] |
| | | 247 | | private static partial Regex ListItemRegex(); |
| | | 248 | | |
| | | 249 | | [GeneratedRegex(@"<[^>]+>")] |
| | | 250 | | private static partial Regex TagRegex(); |
| | | 251 | | |
| | | 252 | | [GeneratedRegex(@"[ \t ]+")] |
| | | 253 | | private static partial Regex SpacesRegex(); |
| | | 254 | | |
| | | 255 | | [GeneratedRegex(@"\n{2,}")] |
| | | 256 | | private static partial Regex BlankLinesRegex(); |
| | | 257 | | |
| | | 258 | | public static string HtmlToText(string? html) |
| | | 259 | | { |
| | | 260 | | if (string.IsNullOrWhiteSpace(html)) return string.Empty; |
| | | 261 | | var text = ListItemRegex().Replace(html, "• "); |
| | | 262 | | text = LineBreakRegex().Replace(text, "\n"); |
| | | 263 | | text = TagRegex().Replace(text, string.Empty); |
| | | 264 | | text = WebUtility.HtmlDecode(text); |
| | | 265 | | text = SpacesRegex().Replace(text, " "); |
| | | 266 | | text = string.Join('\n', text.Split('\n').Select(x => x.Trim())); |
| | | 267 | | return BlankLinesRegex().Replace(text, "\n").Trim(); |
| | | 268 | | } |
| | | 269 | | |
| | | 270 | | public static IEnumerable<TransactionReportRow> FromHtml(string? html, string section) |
| | | 271 | | { |
| | | 272 | | if (string.IsNullOrWhiteSpace(html)) yield break; |
| | | 273 | | foreach (var part in AlertSplitRegex().Split(html)) |
| | | 274 | | { |
| | | 275 | | if (string.IsNullOrWhiteSpace(part)) continue; |
| | | 276 | | var text = HtmlToText(part); |
| | | 277 | | if (text.Length == 0) continue; |
| | | 278 | | |
| | | 279 | | var level = Regex.IsMatch(part, @"alert-danger", RegexOptions.IgnoreCase) ? TransactionReportLevel.Danger |
| | | 280 | | : Regex.IsMatch(part, @"alert-warning", RegexOptions.IgnoreCase) ? TransactionReportLevel.Warning |
| | | 281 | | : Regex.IsMatch(part, @"alert-success", RegexOptions.IgnoreCase) ? TransactionReportLevel.Success |
| | | 282 | | : TransactionReportLevel.Info; |
| | | 283 | | |
| | | 284 | | var titleMatch = TitleRegex().Match(part); |
| | | 285 | | var title = titleMatch.Success ? HtmlToText(titleMatch.Groups[1].Success && titleMatch.Groups[1].Length > 0 |
| | | 286 | | string? details = text; |
| | | 287 | | if (title.Length > 0 && text.StartsWith(title, StringComparison.Ordinal)) |
| | | 288 | | details = text[title.Length..].Trim(); |
| | | 289 | | if (title.Length == 0) |
| | | 290 | | { |
| | | 291 | | var lines = text.Split('\n', 2); |
| | | 292 | | title = lines[0]; |
| | | 293 | | details = lines.Length > 1 ? lines[1].Trim() : null; |
| | | 294 | | } |
| | | 295 | | |
| | | 296 | | if (title.Length > 160) |
| | | 297 | | { |
| | | 298 | | details = title + (string.IsNullOrEmpty(details) ? string.Empty : "\n" + details); |
| | | 299 | | title = title[..157] + "…"; |
| | | 300 | | } |
| | | 301 | | |
| | | 302 | | var links = AnchorRegex().Matches(part) |
| | | 303 | | .Select(m => new TransactionReportLink(HtmlToText(m.Groups[2].Value), WebUtility.HtmlDecode(m.Groups[1]. |
| | | 304 | | .Where(l => l.Text.Length > 0 && (l.Url.StartsWith('/') || l.Url.StartsWith("http", StringComparison.Ord |
| | | 305 | | .DistinctBy(l => l.Url) |
| | | 306 | | .ToList(); |
| | | 307 | | |
| | | 308 | | yield return new(level, section, null, null, null, null, title, |
| | | 309 | | string.IsNullOrWhiteSpace(details) ? null : details, null, null, links); |
| | | 310 | | } |
| | | 311 | | } |
| | | 312 | | } |