Some checks are pending
Auto-tag / Create date-tag (push) Waiting to run
CI / Backend (.NET 8) (push) Waiting to run
CI / Web (React + Vite) (push) Waiting to run
CI / POS (WPF, Windows) (push) Waiting to run
Docker API / Build + push API (push) Waiting to run
Docker API / Deploy API on stage (push) Blocked by required conditions
Docker Web / Build + push Web (push) Waiting to run
Docker Web / Deploy Web on stage (push) Blocked by required conditions
Диапазон коммитов f455fa9..b7eb637 + 3ced706 (handoff).
Backend (src/food-market.api/Infrastructure/Invoices):
- Ean.cs — контрольная цифра EAN-8/13/UPC-A/GTIN-14 (ловит одну ошибку в цифре).
- TableReader.cs — своя читалка .xlsx/.csv без библиотек, UTF-8/1251,
.xls → понятная ошибка; TableInvoiceParser — шаблон накладной.
- InvoiceModels: +Photo/BarcodeSource/Note в InvoiceLine, PhotoHint,
IInvoiceExtractor.ExtractPhotosAsync.
- InvoiceMatcher.FindByBarcode — для photo-matching.
- InvoiceProcessor: ветки photos/excel/vision, MatchFromPhoto с EAN-проверкой
(barcode_source="read" → валидация), SweepAbandonedPhotosAsync раз в час
убирает папки needs_review старше 24ч; DeleteStored (owner-policy: НЕ копить).
- ClaudeCliInvoiceExtractor: ExtractPhotosAsync + BuildPhotosPrompt +
FromPhotosJson (barcode_source → BarcodeSource, comment → Note, unmatched_photos
→ Warnings); дефолт модели claude-sonnet-5-5, PhotoTimeoutSeconds=600.
Backend (Controllers):
- POST /purchases/invoices/upload-photos (≤80 файлов, 60 МБ) +
GET /purchases/invoices/{id}/photo/{n}; Apply/Delete убирают папку (не только файл).
- InvoiceDetailDto +Source/+HasFiles — клиент не ломится за 404 миниатюрами.
- POST /inventory/inventories/import — xlsx/csv → {found, unknown}, повторы ШК
суммируются, документ не создаётся.
- Program.cs: UseResponseCompression + GzipCompressionProvider для application/json.
- InventoriesController.mobile-catalog: weak ETag + If-None-Match → 304;
Cache-Control private max-age=60. Замер 31k: JSON 3.60 МБ → gzip 0.98 МБ.
Frontend:
- lib/shots.ts — ZXing TRY_HARDER на 1x+1600; compress JPEG 0.85 до 2400.
- components/PhotoSession.tsx — полноэкранная пачка (камера + готовые фото).
- components/CameraScanner.tsx: onCodeRef + эффект [], камера не закрывается
после каждого скана.
- pages/InvoicesPage.tsx — четыре способа: камера одиночными, файл (xlsx/csv/img/pdf),
камера многостраничная, фото-приёмка (камера или готовые фото) + «Отправить ещё раз».
- pages/InvoiceReviewPage.tsx — автосохранение 700 мс + индикатор; row-picker панель
«в какую строку?» если fuzzy-match неуверен; linesRef; глобальный keydown; миниатюра
снимка у строки; пометки Note.
- pages/SupplyScanPage.tsx + InventoryMobilePage.tsx — глобальный keydown.
- InventoryMobilePage: поле сканера на виду + импорт Excel/CSV.
- docs/supplier-invoice-template.xlsx — шаблон для поставщиков (9-я строка заголовки).
Хранение файлов (owner-policy 2026-10-05): фото НЕ копим.
- Обычная загрузка → удаление сразу после разбора.
- Фото-приёмка → удаление при Apply/Delete/24h abandoned.
- KeepFilesDays удалён из флоу (конфиг-ключ игнорируется).
Тесты (unit, зелёные):
- EanTests (6): валидные/невалидные/one-digit error.
- TableReaderTests (5): xlsx cells, шапка «Поставщик/Номер/Дата», CSV UTF-8+BOM,
CSV Windows-1251, .xls friendly error.
- ClaudeCliInvoicePhotosParseTests (4): decoded проходит, source=null чистит Barcode,
unmatched_photos → Warnings, comment → Note.
- InvoicePhotoRetentionTests (3): DeleteStored файл/папка/идемпотентность.
Preexisting ApiReferenceDocsJob (Sprint 28) failure не связан с портом.
Прогресс: docs/sklad-port-20261005-progress.md. Пользовательская инструкция:
docs/supply-inventory.md обновлена.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
174 lines
7.4 KiB
C#
174 lines
7.4 KiB
C#
using System.IO.Compression;
|
||
using System.Text;
|
||
using FluentAssertions;
|
||
using foodmarket.Api.Infrastructure.Invoices;
|
||
using Xunit;
|
||
|
||
namespace foodmarket.UnitTests;
|
||
|
||
/// <summary>Табличная читалка (xlsx + csv) + <see cref="TableInvoiceParser"/>:
|
||
/// порт шаблона накладной из sklad (handoff §7). Генерим xlsx руками (zip + XML),
|
||
/// как и в <c>scripts/make_supplier_template.py</c> — так тестируется и наша
|
||
/// собственная читалка, и формат, который сдаём поставщикам.</summary>
|
||
public class TableReaderTests
|
||
{
|
||
private const string M = "http://schemas.openxmlformats.org/spreadsheetml/2006/main";
|
||
|
||
private static string WriteXlsx(IEnumerable<IEnumerable<object?>> 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", $@"<?xml version=""1.0""?>
|
||
<Types xmlns=""http://schemas.openxmlformats.org/package/2006/content-types"">
|
||
<Default Extension=""rels"" ContentType=""application/vnd.openxmlformats-package.relationships+xml""/>
|
||
<Default Extension=""xml"" ContentType=""application/xml""/>
|
||
<Override PartName=""/xl/workbook.xml"" ContentType=""application/vnd.openxmlformats-officedocument.spreadsheetml.sheet.main+xml""/>
|
||
<Override PartName=""/xl/worksheets/sheet1.xml"" ContentType=""application/vnd.openxmlformats-officedocument.spreadsheetml.worksheet+xml""/>
|
||
</Types>");
|
||
|
||
Put("_rels/.rels", $@"<?xml version=""1.0""?>
|
||
<Relationships xmlns=""http://schemas.openxmlformats.org/package/2006/relationships"">
|
||
<Relationship Id=""rId1"" Type=""http://schemas.openxmlformats.org/officeDocument/2006/relationships/officeDocument"" Target=""xl/workbook.xml""/>
|
||
</Relationships>");
|
||
|
||
Put("xl/_rels/workbook.xml.rels", $@"<?xml version=""1.0""?>
|
||
<Relationships xmlns=""http://schemas.openxmlformats.org/package/2006/relationships"">
|
||
<Relationship Id=""rId1"" Type=""http://schemas.openxmlformats.org/officeDocument/2006/relationships/worksheet"" Target=""worksheets/sheet1.xml""/>
|
||
</Relationships>");
|
||
|
||
Put("xl/workbook.xml", $@"<?xml version=""1.0""?>
|
||
<workbook xmlns=""{M}"" xmlns:r=""http://schemas.openxmlformats.org/officeDocument/2006/relationships"">
|
||
<sheets><sheet name=""Sheet1"" sheetId=""1"" r:id=""rId1""/></sheets>
|
||
</workbook>");
|
||
|
||
var sb = new StringBuilder();
|
||
sb.Append($@"<?xml version=""1.0""?><worksheet xmlns=""{M}""><sheetData>");
|
||
int r = 1;
|
||
foreach (var row in rows)
|
||
{
|
||
sb.Append($"<row r=\"{r}\">");
|
||
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($"<c r=\"{ref1}\" t=\"inlineStr\"><is><t>{System.Security.SecurityElement.Escape(s)}</t></is></c>");
|
||
else
|
||
sb.Append($"<c r=\"{ref1}\"><v>{v}</v></c>");
|
||
c++;
|
||
}
|
||
sb.Append("</row>");
|
||
r++;
|
||
}
|
||
sb.Append("</sheetData></worksheet>");
|
||
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<InvoiceExtractionException>()
|
||
.WithMessage("*сохраните файл как .xlsx*");
|
||
}
|
||
finally { File.Delete(tmp); }
|
||
}
|
||
}
|