using System.IO.Compression; using System.Text; using FluentAssertions; using foodmarket.Api.Infrastructure.Invoices; using Xunit; namespace foodmarket.UnitTests; /// Табличная читалка (xlsx + csv) + : /// порт шаблона накладной из sklad (handoff §7). Генерим xlsx руками (zip + XML), /// как и в scripts/make_supplier_template.py — так тестируется и наша /// собственная читалка, и формат, который сдаём поставщикам. public class TableReaderTests { private const string M = "http://schemas.openxmlformats.org/spreadsheetml/2006/main"; private static string WriteXlsx(IEnumerable> rows) { var tmp = Path.Combine(Path.GetTempPath(), $"fm-tr-{Guid.NewGuid():N}.xlsx"); using var z = new ZipArchive(File.Create(tmp), ZipArchiveMode.Create); void Put(string name, string content) { var e = z.CreateEntry(name); using var w = new StreamWriter(e.Open(), new UTF8Encoding(false)); w.Write(content); } Put("[Content_Types].xml", $@" "); Put("_rels/.rels", $@" "); Put("xl/_rels/workbook.xml.rels", $@" "); Put("xl/workbook.xml", $@" "); var sb = new StringBuilder(); sb.Append($@""); int r = 1; foreach (var row in rows) { sb.Append($""); int c = 0; foreach (var v in row) { var ref1 = $"{(char)('A' + c)}{r}"; if (v is null) { c++; continue; } if (v is string s) sb.Append($"{System.Security.SecurityElement.Escape(s)}"); else sb.Append($"{v}"); c++; } sb.Append(""); r++; } sb.Append(""); Put("xl/worksheets/sheet1.xml", sb.ToString()); return tmp; } [Fact] public void Reads_xlsx_cells() { var tmp = WriteXlsx(new object?[][] { new object?[] { "Штрихкод", "Наименование товара", "Количество", "Цена за единицу" }, new object?[] { "4870001234560", "Молоко 3.2%", 10, 450 }, }); try { var rows = TableReader.Read(tmp); rows[0][0].Should().Be("Штрихкод"); rows[1][2].Should().Be("10"); } finally { File.Delete(tmp); } } [Fact] public void Parses_table_invoice_with_header_rows() { var tmp = WriteXlsx(new object?[][] { new object?[] { "Поставщик", "ТОО Пример" }, new object?[] { "Номер накладной", "7777" }, new object?[] { "Дата", "2026-10-05" }, new object?[] { }, new object?[] { "Штрихкод", "Наименование товара", "Количество", "Ед. изм.", "Цена за единицу", "Сумма" }, new object?[] { "4870001234560", "Молоко 3.2%", 10, "шт", 450, 4500 }, new object?[] { "4607031440148", "Хлеб серый", 20, "шт", 180, 3600 }, }); try { var inv = TableInvoiceParser.Parse(tmp, "накладная.xlsx"); inv.SupplierName.Should().Be("ТОО Пример"); inv.InvoiceNumber.Should().Be("7777"); inv.InvoiceDate!.Value.Date.Should().Be(new DateTime(2026, 10, 5)); inv.Lines.Should().HaveCount(2); inv.Lines[0].Barcode.Should().Be("4870001234560"); inv.Lines[0].Quantity.Should().Be(10); inv.Lines[0].Price.Should().Be(450); inv.Lines[0].Sum.Should().Be(4500); inv.Source.Should().Be("excel"); } finally { File.Delete(tmp); } } [Fact] public void Parses_csv_utf8_with_semicolons() { var tmp = Path.Combine(Path.GetTempPath(), $"fm-{Guid.NewGuid():N}.csv"); File.WriteAllText(tmp, "Штрихкод;Наименование;Количество;Цена\n" + "4870001234560;Молоко 3.2%;10;450\n", new UTF8Encoding(true)); // BOM try { var inv = TableInvoiceParser.Parse(tmp, "накладная.csv"); inv.Lines.Should().HaveCount(1); inv.Lines[0].Barcode.Should().Be("4870001234560"); inv.Lines[0].Name.Should().Be("Молоко 3.2%"); } finally { File.Delete(tmp); } } [Fact] public void Parses_csv_windows1251() { // Excel в русской локали сохраняет CSV в 1251 без BOM — читалка должна // определить и переключиться на 1251. Encoding.RegisterProvider(CodePagesEncodingProvider.Instance); var enc = Encoding.GetEncoding(1251); var tmp = Path.Combine(Path.GetTempPath(), $"fm-{Guid.NewGuid():N}.csv"); File.WriteAllBytes(tmp, enc.GetBytes( "Штрихкод;Наименование;Количество;Цена\n" + "4870001234560;Молоко 3.2%;10;450\n")); try { var inv = TableInvoiceParser.Parse(tmp, "накладная.csv"); inv.Lines.Should().HaveCount(1); inv.Lines[0].Name.Should().Be("Молоко 3.2%"); } finally { File.Delete(tmp); } } [Fact] public void Xls_old_format_throws_friendly() { var tmp = Path.Combine(Path.GetTempPath(), $"fm-{Guid.NewGuid():N}.xls"); File.WriteAllText(tmp, "stub"); try { FluentActions.Invoking(() => TableReader.Read(tmp, "накладная.xls")) .Should().Throw() .WithMessage("*сохраните файл как .xlsx*"); } finally { File.Delete(tmp); } } }