| | | 1 | | using System.Globalization; |
| | | 2 | | using System.Text; |
| | | 3 | | using System.Text.Json; |
| | | 4 | | using System.Text.RegularExpressions; |
| | | 5 | | using HtmlAgilityPack; |
| | | 6 | | |
| | | 7 | | namespace Services.ApiServices.ArmtekEtp; |
| | | 8 | | |
| | | 9 | | /// <summary>Строка шапки фактуры: «название: [код] значение».</summary> |
| | | 10 | | public sealed record ArmtekEtpHeaderField(string Label, string? Code, string Text, string? Hidden); |
| | | 11 | | |
| | | 12 | | /// <summary>Колонка таблицы позиций фактуры: заголовок и поле report_info, из которого она строится.</summary> |
| | | 13 | | public sealed record ArmtekEtpColumn(string Title, string? Field, bool Visible); |
| | | 14 | | |
| | 1 | 15 | | public sealed record ArmtekEtpInvoicePage( |
| | 2 | 16 | | IReadOnlyList<ArmtekEtpHeaderField> Header, |
| | 3 | 17 | | IReadOnlyList<JsonElement> Items, |
| | 3 | 18 | | IReadOnlyList<ArmtekEtpColumn> Columns); |
| | | 19 | | |
| | | 20 | | /// <summary>Разбор ответов etp.armtek.ru. Без сети — чтобы проверяться тестами на сохранённых ответах.</summary> |
| | | 21 | | public static class ArmtekEtpParser |
| | | 22 | | { |
| | | 23 | | private static readonly Regex TagRegex = new("<[^>]+>", RegexOptions.Compiled); |
| | | 24 | | private static readonly Regex SpacesRegex = new(@"\s+", RegexOptions.Compiled); |
| | | 25 | | |
| | | 26 | | public static IReadOnlyList<JsonElement> ParseSettlements(string json) |
| | | 27 | | { |
| | | 28 | | using var doc = JsonDocument.Parse(json); |
| | | 29 | | var root = doc.RootElement; |
| | | 30 | | if (!(root.TryGetProperty("status", out var status) && status.ValueKind == JsonValueKind.True)) |
| | | 31 | | throw new ArmtekEtpException("Armtek ETP: взаиморасчёты не получены. " + ReadMessages(root)); |
| | | 32 | | |
| | | 33 | | if (!root.TryGetProperty("data", out var data) || data.ValueKind != JsonValueKind.Array) |
| | | 34 | | return Array.Empty<JsonElement>(); |
| | | 35 | | |
| | | 36 | | return data.EnumerateArray().Select(x => x.Clone()).ToList(); |
| | | 37 | | } |
| | | 38 | | |
| | | 39 | | /// <summary>Ответ AJAX вместо JSON (страница входа) или сообщение типа A («сессия завершена»).</summary> |
| | | 40 | | public static bool IsSessionLost(string json) |
| | | 41 | | { |
| | | 42 | | if (string.IsNullOrWhiteSpace(json) || json.TrimStart()[0] != '{') |
| | | 43 | | return true; |
| | | 44 | | try |
| | | 45 | | { |
| | | 46 | | using var doc = JsonDocument.Parse(json); |
| | | 47 | | if (!doc.RootElement.TryGetProperty("arr_messages", out var messages) || messages.ValueKind != JsonValueKind |
| | | 48 | | return false; |
| | | 49 | | // Сообщение типа A сайт сам обрабатывает выходом из кабинета (informModal → /authorization/logout). |
| | | 50 | | return messages.EnumerateArray().Any(m => MessageType(m) == "A"); |
| | | 51 | | } |
| | | 52 | | catch (JsonException) |
| | | 53 | | { |
| | | 54 | | return true; |
| | | 55 | | } |
| | | 56 | | } |
| | | 57 | | |
| | | 58 | | public static bool LooksLikeLoginPage(string html) => |
| | | 59 | | string.IsNullOrWhiteSpace(html) |
| | | 60 | | || (html.Contains("sign-in-form", StringComparison.Ordinal) && !html.Contains("report_info", StringComparison.Or |
| | | 61 | | |
| | | 62 | | public static ArmtekEtpInvoicePage ParseInvoicePage(string html) |
| | | 63 | | { |
| | | 64 | | var itemsJson = ExtractJsValue(html, "report_info") |
| | | 65 | | ?? throw new ArmtekEtpException("Armtek ETP: на странице фактуры нет report_info — изменилась вёрстка"); |
| | | 66 | | using var items = JsonDocument.Parse(itemsJson); |
| | | 67 | | var rows = items.RootElement.ValueKind == JsonValueKind.Array |
| | | 68 | | ? items.RootElement.EnumerateArray().Select(x => x.Clone()).ToList() |
| | | 69 | | : new List<JsonElement>(); |
| | | 70 | | |
| | | 71 | | var columns = new List<ArmtekEtpColumn>(); |
| | | 72 | | if (ExtractJsValue(html, "report_settings") is { } settingsJson) |
| | | 73 | | { |
| | | 74 | | using var settings = JsonDocument.Parse(settingsJson); |
| | | 75 | | foreach (var c in settings.RootElement.EnumerateArray()) |
| | | 76 | | { |
| | | 77 | | var title = c.TryGetProperty("title", out var t) ? StripHtml(AsString(t) ?? string.Empty) : string.Empty |
| | | 78 | | var field = c.TryGetProperty("data", out var d) ? AsString(d) : null; |
| | | 79 | | var visible = !c.TryGetProperty("visible", out var v) || v.ValueKind != JsonValueKind.False; |
| | | 80 | | columns.Add(new ArmtekEtpColumn(title, field, visible)); |
| | | 81 | | } |
| | | 82 | | } |
| | | 83 | | |
| | | 84 | | return new ArmtekEtpInvoicePage(ParseHeader(html), rows, columns); |
| | | 85 | | } |
| | | 86 | | |
| | | 87 | | private static List<ArmtekEtpHeaderField> ParseHeader(string html) |
| | | 88 | | { |
| | | 89 | | var doc = new HtmlDocument(); |
| | | 90 | | doc.LoadHtml(html); |
| | | 91 | | var result = new List<ArmtekEtpHeaderField>(); |
| | | 92 | | var rows = doc.DocumentNode.SelectNodes( |
| | | 93 | | "//div[contains(@class,'invoice-header-container')]//div[contains(concat(' ',normalize-space(@class),' '),' |
| | | 94 | | if (rows == null) |
| | | 95 | | return result; |
| | | 96 | | |
| | | 97 | | foreach (var row in rows) |
| | | 98 | | { |
| | | 99 | | var label = row.SelectSingleNode(".//*[contains(@class,'row-title')]"); |
| | | 100 | | var content = row.SelectSingleNode(".//div[contains(@class,'row-content')]"); |
| | | 101 | | if (label == null || content == null) |
| | | 102 | | continue; |
| | | 103 | | |
| | | 104 | | var code = content.SelectSingleNode(".//*[contains(@class,'partner_nu')]"); |
| | | 105 | | var hidden = content.SelectSingleNode(".//input[@type='hidden']"); |
| | | 106 | | result.Add(new ArmtekEtpHeaderField( |
| | | 107 | | Clean(label.InnerText).TrimEnd(':'), |
| | | 108 | | code == null ? null : Clean(code.InnerText), |
| | | 109 | | Clean(content.InnerText), |
| | | 110 | | hidden?.GetAttributeValue("value", null))); |
| | | 111 | | } |
| | | 112 | | return result; |
| | | 113 | | } |
| | | 114 | | |
| | | 115 | | /// <summary> |
| | | 116 | | /// Значение JS-переменной вида <c>var name = [...];</c> / <c>{...}</c>: литерал вырезается по балансу скобок |
| | | 117 | | /// с учётом строк, потому что внутри наименований встречаются и скобки, и экранированные кавычки. |
| | | 118 | | /// </summary> |
| | | 119 | | public static string? ExtractJsValue(string html, string name) |
| | | 120 | | { |
| | | 121 | | var match = Regex.Match(html, @"var\s+" + Regex.Escape(name) + @"\s*=\s*(?=[\[{])"); |
| | | 122 | | if (!match.Success) |
| | | 123 | | return null; |
| | | 124 | | |
| | | 125 | | var start = match.Index + match.Length; |
| | | 126 | | var depth = 0; |
| | | 127 | | var inString = false; |
| | | 128 | | for (var i = start; i < html.Length; i++) |
| | | 129 | | { |
| | | 130 | | var ch = html[i]; |
| | | 131 | | if (inString) |
| | | 132 | | { |
| | | 133 | | if (ch == '\\') i++; |
| | | 134 | | else if (ch == '"') inString = false; |
| | | 135 | | continue; |
| | | 136 | | } |
| | | 137 | | switch (ch) |
| | | 138 | | { |
| | | 139 | | case '"': inString = true; break; |
| | | 140 | | case '[': case '{': depth++; break; |
| | | 141 | | case ']': case '}': |
| | | 142 | | if (--depth == 0) |
| | | 143 | | return html.Substring(start, i - start + 1); |
| | | 144 | | break; |
| | | 145 | | } |
| | | 146 | | } |
| | | 147 | | return null; |
| | | 148 | | } |
| | | 149 | | |
| | | 150 | | /// <summary> |
| | | 151 | | /// Тексты arr_messages. На сайте элемент — объект {type, text, …} (showMessage берёт msg.text); регистр полей |
| | | 152 | | /// не фиксируем. Если text нет — все строковые поля, кроме type, чтобы в ошибке не терялся смысл. |
| | | 153 | | /// </summary> |
| | | 154 | | public static string ReadMessages(JsonElement root) |
| | | 155 | | { |
| | | 156 | | if (root.ValueKind != JsonValueKind.Object |
| | | 157 | | || !root.TryGetProperty("arr_messages", out var messages) || messages.ValueKind != JsonValueKind.Array) |
| | | 158 | | return string.Empty; |
| | | 159 | | var parts = new List<string>(); |
| | | 160 | | foreach (var m in messages.EnumerateArray()) |
| | | 161 | | { |
| | | 162 | | string? text = null; |
| | | 163 | | if (m.ValueKind == JsonValueKind.String) |
| | | 164 | | text = m.GetString(); |
| | | 165 | | else if (m.ValueKind == JsonValueKind.Object) |
| | | 166 | | { |
| | | 167 | | var props = m.EnumerateObject().Where(p => p.Value.ValueKind is JsonValueKind.String or JsonValueKind.Nu |
| | | 168 | | text = props.FirstOrDefault(p => p.Name.Equals("text", StringComparison.OrdinalIgnoreCase) |
| | | 169 | | || p.Name.Equals("message", StringComparison.OrdinalIgnoreCase)).Value is { ValueKind: not JsonV |
| | | 170 | | ? AsString(t) |
| | | 171 | | : string.Join(", ", props.Where(p => !p.Name.Equals("type", StringComparison.OrdinalIgnoreCase)) |
| | | 172 | | .Select(p => p.Name + ": " + AsString(p.Value))); |
| | | 173 | | var type = MessageType(m); |
| | | 174 | | if (!string.IsNullOrWhiteSpace(text) && type != null) |
| | | 175 | | text = $"[{type}] {text}"; |
| | | 176 | | } |
| | | 177 | | if (!string.IsNullOrWhiteSpace(text)) |
| | | 178 | | parts.Add(StripHtml(text)); |
| | | 179 | | } |
| | | 180 | | return string.Join("; ", parts); |
| | | 181 | | } |
| | | 182 | | |
| | | 183 | | private static string? MessageType(JsonElement m) => |
| | | 184 | | m.ValueKind == JsonValueKind.Object |
| | | 185 | | ? m.EnumerateObject().FirstOrDefault(p => p.Name.Equals("type", StringComparison.OrdinalIgnoreCase)).Value i |
| | | 186 | | ? t.GetString() |
| | | 187 | | : null |
| | | 188 | | : null; |
| | | 189 | | |
| | | 190 | | public static string? AsString(JsonElement e) => e.ValueKind switch |
| | | 191 | | { |
| | | 192 | | JsonValueKind.String => e.GetString(), |
| | | 193 | | JsonValueKind.Number => e.GetRawText(), |
| | | 194 | | JsonValueKind.True => "true", |
| | | 195 | | JsonValueKind.False => "false", |
| | | 196 | | _ => null |
| | | 197 | | }; |
| | | 198 | | |
| | | 199 | | public static string? Str(JsonElement row, string name) |
| | | 200 | | { |
| | | 201 | | if (!row.TryGetProperty(name, out var v)) |
| | | 202 | | return null; |
| | | 203 | | var s = AsString(v)?.Trim(); |
| | | 204 | | return string.IsNullOrEmpty(s) ? null : s; |
| | | 205 | | } |
| | | 206 | | |
| | | 207 | | public static decimal? Dec(JsonElement row, string name) => |
| | | 208 | | decimal.TryParse(Str(row, name), NumberStyles.Number, CultureInfo.InvariantCulture, out var d) ? d : null; |
| | | 209 | | |
| | | 210 | | /// <summary>Дата SAP yyyyMMdd; «00000000» и пусто — null.</summary> |
| | | 211 | | public static DateTime? SapDate(JsonElement row, string name) => ParseSapDate(Str(row, name)); |
| | | 212 | | |
| | | 213 | | public static DateTime? ParseSapDate(string? s) => |
| | | 214 | | DateTime.TryParseExact(s, "yyyyMMdd", CultureInfo.InvariantCulture, DateTimeStyles.None, out var d) ? d : null; |
| | | 215 | | |
| | | 216 | | private static string StripHtml(string s) => Clean(TagRegex.Replace(s, string.Empty)); |
| | | 217 | | |
| | | 218 | | private static string Clean(string s) => SpacesRegex.Replace(HtmlEntity.DeEntitize(s), " ").Trim(); |
| | | 219 | | } |