| | | 1 | | using System.Globalization; |
| | | 2 | | using System.Text.RegularExpressions; |
| | | 3 | | using ClosedXML.Excel; |
| | | 4 | | |
| | | 5 | | namespace Services.ArmtekEtpServices; |
| | | 6 | | |
| | | 7 | | /// <summary>Строка файла «Взаиморасчёты» из кабинета Armtek (Выгрузить файл → Microsoft Office Excel).</summary> |
| | | 8 | | public sealed record ArmtekSettlementExcelRow( |
| | | 9 | | int RowNumber, |
| | | 10 | | string Kunnr, |
| | | 11 | | string? ClientName, |
| | | 12 | | string DocTypeName, |
| | | 13 | | string Vbeln, |
| | | 14 | | string? DocNumber, |
| | | 15 | | DateTime DocDate, |
| | | 16 | | string? PaymentMethodName, |
| | | 17 | | string? Currency, |
| | | 18 | | decimal Amount, |
| | | 19 | | decimal ReturnAmount, |
| | | 20 | | decimal PaymentAmount, |
| | | 21 | | decimal Balance, |
| | | 22 | | DateTime? PayUntil, |
| | | 23 | | string? Contract, |
| | | 24 | | IReadOnlyDictionary<string, string?> Raw); |
| | | 25 | | |
| | | 26 | | public sealed class ArmtekSettlementsExcelResult |
| | | 27 | | { |
| | | 28 | | public List<ArmtekSettlementExcelRow> Rows { get; } = new(); |
| | | 29 | | public List<string> Errors { get; } = new(); |
| | | 30 | | public List<string> Warnings { get; } = new(); |
| | | 31 | | public bool IsValid => Errors.Count == 0 && Rows.Count > 0; |
| | | 32 | | public DateTime? PeriodFrom => Rows.Count == 0 ? null : Rows.Min(r => r.DocDate); |
| | | 33 | | public DateTime? PeriodTo => Rows.Count == 0 ? null : Rows.Max(r => r.DocDate); |
| | | 34 | | } |
| | | 35 | | |
| | | 36 | | /// <summary> |
| | | 37 | | /// Чтение и проверка xlsx «Взаиморасчётов». Колонки ищутся по заголовку в первой строке (порядок не важен), обязательны |
| | | 38 | | /// <see cref="RequiredColumns"/>. Любая ошибка в строке отклоняет весь файл: частичный импорт взаиморасчётов исказил бы |
| | | 39 | | /// Разрыв цепочки сальдо (сальдо = предыдущее − продажа + возврат + платёж) — только предупреждение: строки одного дня |
| | | 40 | | /// кабинет может выгрузить в другом порядке. |
| | | 41 | | /// </summary> |
| | | 42 | | public static class ArmtekSettlementsExcelReader |
| | | 43 | | { |
| | | 44 | | public const int MaxRows = 50_000; |
| | | 45 | | private const int MaxErrors = 50; |
| | | 46 | | |
| | 1 | 47 | | public static readonly IReadOnlyList<string> RequiredColumns = |
| | 1 | 48 | | ["Покупатель", "Вид документа", "Код документа", "Дата документа", "Сумма продажи", "Сумма возврата", "Сумма пла |
| | | 49 | | |
| | 1 | 50 | | public static readonly IReadOnlyList<string> OptionalColumns = |
| | 1 | 51 | | ["Наименование покупателя", "Номер документа", "Способ платежа", "Валюта", "Оплатить до", "Договор"]; |
| | | 52 | | |
| | 1 | 53 | | private static readonly Regex Digits = new(@"^\d{1,12}$", RegexOptions.Compiled); |
| | 1 | 54 | | private static readonly string[] DateFormats = ["dd.MM.yyyy", "d.M.yyyy", "dd.MM.yy", "yyyy-MM-dd", "yyyyMMdd"]; |
| | | 55 | | |
| | | 56 | | public static ArmtekSettlementsExcelResult Read(Stream stream) |
| | | 57 | | { |
| | 5 | 58 | | var result = new ArmtekSettlementsExcelResult(); |
| | | 59 | | XLWorkbook workbook; |
| | | 60 | | try |
| | | 61 | | { |
| | 5 | 62 | | workbook = new XLWorkbook(stream); |
| | 5 | 63 | | } |
| | 0 | 64 | | catch (Exception ex) |
| | | 65 | | { |
| | 0 | 66 | | result.Errors.Add("Файл не читается как Excel (.xlsx): " + ex.Message); |
| | 0 | 67 | | return result; |
| | | 68 | | } |
| | | 69 | | |
| | 5 | 70 | | using (workbook) |
| | | 71 | | { |
| | 5 | 72 | | var sheet = workbook.Worksheets.FirstOrDefault(); |
| | 5 | 73 | | var used = sheet?.RangeUsed(); |
| | 5 | 74 | | if (sheet == null || used == null) |
| | | 75 | | { |
| | 0 | 76 | | result.Errors.Add("В файле нет данных."); |
| | 0 | 77 | | return result; |
| | | 78 | | } |
| | | 79 | | |
| | 5 | 80 | | var headerRow = used.FirstRow().RowNumber(); |
| | 5 | 81 | | var columns = new Dictionary<string, int>(StringComparer.OrdinalIgnoreCase); |
| | 126 | 82 | | foreach (var cell in sheet.Row(headerRow).CellsUsed()) |
| | | 83 | | { |
| | 58 | 84 | | var title = Normalize(cell.GetString()); |
| | 58 | 85 | | if (title.Length == 0) continue; |
| | 58 | 86 | | if (!columns.TryAdd(title, cell.Address.ColumnNumber)) |
| | 0 | 87 | | result.Errors.Add($"Колонка «{title}» встречается в заголовке дважды."); |
| | 58 | 88 | | else if (!RequiredColumns.Contains(title, StringComparer.OrdinalIgnoreCase) && !OptionalColumns.Contains |
| | 2 | 89 | | result.Warnings.Add($"Колонка «{title}» не используется — сохранится только в исходной строке."); |
| | | 90 | | } |
| | | 91 | | |
| | 45 | 92 | | var missing = RequiredColumns.Where(c => !columns.ContainsKey(c)).ToList(); |
| | 5 | 93 | | if (missing.Count > 0) |
| | | 94 | | { |
| | 9 | 95 | | result.Errors.Add("Это не выгрузка «Взаиморасчётов» Armtek: нет колонок " + string.Join(", ", missing.Se |
| | 1 | 96 | | ". Нужен файл из кабинета: Финансы → Взаиморасчёты → Выгрузить файл → Microsoft Office Excel (.xlsx) |
| | 1 | 97 | | return result; |
| | | 98 | | } |
| | | 99 | | |
| | 4 | 100 | | var lastRow = used.LastRow().RowNumber(); |
| | 4 | 101 | | if (lastRow - headerRow > MaxRows) |
| | | 102 | | { |
| | 0 | 103 | | result.Errors.Add($"Слишком много строк: {lastRow - headerRow}, допускается до {MaxRows}."); |
| | 0 | 104 | | return result; |
| | | 105 | | } |
| | | 106 | | |
| | 4 | 107 | | var seen = new Dictionary<(string, string), int>(); |
| | 34 | 108 | | for (var r = headerRow + 1; r <= lastRow; r++) |
| | | 109 | | { |
| | 13 | 110 | | var row = sheet.Row(r); |
| | 26 | 111 | | if (row.CellsUsed().All(c => string.IsNullOrWhiteSpace(c.GetString()))) |
| | | 112 | | continue; |
| | | 113 | | |
| | 13 | 114 | | var errors = new List<string>(); |
| | 96 | 115 | | string? Text(string col) => columns.TryGetValue(col, out var i) ? CellText(row.Cell(i)) : null; |
| | | 116 | | |
| | 13 | 117 | | var kunnr = Text("Покупатель"); |
| | 13 | 118 | | if (kunnr == null || !Digits.IsMatch(kunnr)) errors.Add($"«Покупатель» должен быть кодом из цифр, а не « |
| | 13 | 119 | | var vbeln = Text("Код документа"); |
| | 14 | 120 | | if (vbeln == null || !Digits.IsMatch(vbeln)) errors.Add($"«Код документа» должен быть кодом из цифр, а н |
| | 13 | 121 | | var docType = Text("Вид документа"); |
| | 13 | 122 | | if (string.IsNullOrWhiteSpace(docType)) errors.Add("пустой «Вид документа»"); |
| | | 123 | | |
| | 13 | 124 | | var docDate = ReadDate(row.Cell(columns["Дата документа"]), "Дата документа", errors, required: true); |
| | 13 | 125 | | var payUntil = columns.TryGetValue("Оплатить до", out var payCol) ? ReadDate(row.Cell(payCol), "Оплатить |
| | 13 | 126 | | var amount = ReadDecimal(row.Cell(columns["Сумма продажи"]), "Сумма продажи", errors); |
| | 13 | 127 | | var returnAmount = ReadDecimal(row.Cell(columns["Сумма возврата"]), "Сумма возврата", errors); |
| | 13 | 128 | | var paymentAmount = ReadDecimal(row.Cell(columns["Сумма платежа"]), "Сумма платежа", errors); |
| | 13 | 129 | | var balance = ReadDecimal(row.Cell(columns["Сальдо"]), "Сальдо", errors); |
| | | 130 | | |
| | 13 | 131 | | var currency = Text("Валюта"); |
| | 14 | 132 | | if (currency != null && !Regex.IsMatch(currency, "^[A-Za-z]{3}$")) errors.Add($"«Валюта» должна быть код |
| | | 133 | | |
| | 13 | 134 | | if (kunnr != null && vbeln != null) |
| | | 135 | | { |
| | 13 | 136 | | if (seen.TryGetValue((kunnr, vbeln), out var firstRow)) |
| | 1 | 137 | | errors.Add($"документ {vbeln} уже есть в строке {firstRow}"); |
| | | 138 | | else |
| | 12 | 139 | | seen[(kunnr, vbeln)] = r; |
| | | 140 | | } |
| | | 141 | | |
| | 13 | 142 | | if (errors.Count > 0) |
| | | 143 | | { |
| | 14 | 144 | | foreach (var e in errors) |
| | 10 | 145 | | if (result.Errors.Count < MaxErrors) result.Errors.Add($"Строка {r}: {e}."); |
| | | 146 | | continue; |
| | | 147 | | } |
| | | 148 | | |
| | 319 | 149 | | var raw = columns.ToDictionary(c => c.Key, c => CellText(row.Cell(c.Value)), StringComparer.Ordinal); |
| | 11 | 150 | | result.Rows.Add(new ArmtekSettlementExcelRow(r, kunnr!, Text("Наименование покупателя"), docType!.Trim() |
| | 11 | 151 | | Text("Номер документа"), docDate!.Value, Text("Способ платежа"), currency?.ToUpperInvariant(), |
| | 11 | 152 | | amount!.Value, returnAmount!.Value, paymentAmount!.Value, balance!.Value, payUntil, Text("Договор"), |
| | | 153 | | } |
| | | 154 | | |
| | 4 | 155 | | if (result.Errors.Count >= MaxErrors) |
| | 0 | 156 | | result.Errors.Add($"Показаны первые {MaxErrors} ошибок."); |
| | 4 | 157 | | if (result.Rows.Count == 0 && result.Errors.Count == 0) |
| | 0 | 158 | | result.Errors.Add("В файле нет строк с документами."); |
| | 4 | 159 | | if (result.Errors.Count == 0) |
| | 3 | 160 | | CheckBalanceChain(result); |
| | 4 | 161 | | return result; |
| | | 162 | | } |
| | 5 | 163 | | } |
| | | 164 | | |
| | | 165 | | /// <summary>Сальдо после документа = сальдо до него − продажа + возврат + платёж (так считает кабинет).</summary> |
| | | 166 | | private static void CheckBalanceChain(ArmtekSettlementsExcelResult result) |
| | | 167 | | { |
| | 3 | 168 | | var breaks = new List<int>(); |
| | 22 | 169 | | foreach (var group in result.Rows.GroupBy(x => x.Kunnr)) |
| | | 170 | | { |
| | 3 | 171 | | ArmtekSettlementExcelRow? prev = null; |
| | 26 | 172 | | foreach (var row in group) |
| | | 173 | | { |
| | 10 | 174 | | if (prev != null && Math.Abs(prev.Balance - row.Amount + row.ReturnAmount + row.PaymentAmount - row.Bala |
| | 1 | 175 | | breaks.Add(row.RowNumber); |
| | 10 | 176 | | prev = row; |
| | | 177 | | } |
| | | 178 | | } |
| | 3 | 179 | | if (breaks.Count > 0) |
| | 1 | 180 | | result.Warnings.Add($"Сальдо не сходится с предыдущей строкой в {breaks.Count} строках (первые: {string.Join |
| | 1 | 181 | | "Обычно это другой порядок документов одного дня; суммы загружаются как в файле."); |
| | 3 | 182 | | } |
| | | 183 | | |
| | | 184 | | private static string? CellText(IXLCell cell) |
| | | 185 | | { |
| | 277 | 186 | | var v = cell.Value; |
| | 277 | 187 | | var text = v.Type switch |
| | 277 | 188 | | { |
| | 50 | 189 | | XLDataType.Blank => null, |
| | 277 | 190 | | // Коды документов Excel хранит числом — без дробной части и экспоненты. |
| | 91 | 191 | | XLDataType.Number => v.GetNumber() % 1 == 0 |
| | 91 | 192 | | ? v.GetNumber().ToString("0", CultureInfo.InvariantCulture) |
| | 91 | 193 | | : v.GetNumber().ToString(CultureInfo.InvariantCulture), |
| | 0 | 194 | | XLDataType.DateTime => v.GetDateTime().ToString("dd.MM.yyyy", CultureInfo.InvariantCulture), |
| | 136 | 195 | | _ => cell.GetString() |
| | 277 | 196 | | }; |
| | 277 | 197 | | return string.IsNullOrWhiteSpace(text) ? null : text.Trim(); |
| | | 198 | | } |
| | | 199 | | |
| | | 200 | | private static DateTime? ReadDate(IXLCell cell, string column, List<string> errors, bool required) |
| | | 201 | | { |
| | 26 | 202 | | var v = cell.Value; |
| | 26 | 203 | | if (v.Type == XLDataType.DateTime) return v.GetDateTime().Date; |
| | 26 | 204 | | if (v.Type == XLDataType.Number) return DateTime.FromOADate(v.GetNumber()).Date; |
| | 26 | 205 | | var text = CellText(cell); |
| | 26 | 206 | | if (text == null) |
| | | 207 | | { |
| | 13 | 208 | | if (required) errors.Add($"пустая «{column}»"); |
| | 13 | 209 | | return null; |
| | | 210 | | } |
| | 25 | 211 | | if (DateTime.TryParseExact(text, DateFormats, CultureInfo.InvariantCulture, DateTimeStyles.None, out var d)) ret |
| | 1 | 212 | | errors.Add($"«{column}» не дата: «{text}» (нужно ДД.ММ.ГГГГ)"); |
| | 1 | 213 | | return null; |
| | | 214 | | } |
| | | 215 | | |
| | | 216 | | private static decimal? ReadDecimal(IXLCell cell, string column, List<string> errors) |
| | | 217 | | { |
| | 52 | 218 | | var v = cell.Value; |
| | 103 | 219 | | if (v.Type == XLDataType.Number) return Math.Round((decimal)v.GetNumber(), 3); |
| | 1 | 220 | | var text = CellText(cell); |
| | 1 | 221 | | if (text == null) return 0m; |
| | 1 | 222 | | var normalized = text.Replace(" ", string.Empty).Replace(" ", string.Empty).Replace(',', '.'); |
| | 1 | 223 | | if (decimal.TryParse(normalized, NumberStyles.Number, CultureInfo.InvariantCulture, out var d)) return d; |
| | 1 | 224 | | errors.Add($"«{column}» не число: «{text}»"); |
| | 1 | 225 | | return null; |
| | | 226 | | } |
| | | 227 | | |
| | 58 | 228 | | private static string Normalize(string s) => Regex.Replace(s, @"\s+", " ").Trim(); |
| | | 229 | | } |