+
+
onFiles(e.target.files)} />
- onFiles(e.target.files)} />
+ {/* Фото-приёмка — отдельной подсказкой: главный сценарий. */}
+
+
Фото товаров + накладная
+ {sendPhotos.isPending ? (
+
+ Отправляю снимки{sendPct != null && ` · ${sendPct}%`}
+
+ ) : (
+
+
+
+ {
+ const files = Array.from(e.target.files ?? []); e.target.value = ''
+ if (files.length) setSession({ mode: 'goods', files })
+ }} />
+
+ )}
+
+ Снимите подряд накладную (все листы) и каждый товар — штрихкод и название,
+ можно несколько снимков на товар. Или выберите уже сделанные фото — их можно
+ и смешивать со снятыми. Приёмка соберётся сама за 1-3 минуты, останется
+ проверить её на глаз. Подходит и для накладных без штрихкодов.
+
+
+
+ {sendPhotos.isError && unsent && (
+
+
+ {unsent.length} снимков не отправились.
+
+
+
+ )}
{err && (
{err}
)}
diff --git a/src/food-market.web/src/pages/SupplyScanPage.tsx b/src/food-market.web/src/pages/SupplyScanPage.tsx
index f293221..3238258 100644
--- a/src/food-market.web/src/pages/SupplyScanPage.tsx
+++ b/src/food-market.web/src/pages/SupplyScanPage.tsx
@@ -116,6 +116,27 @@ export function SupplyScanPage() {
}
}
+ // Глобальный keydown для аппаратного сканера (handoff §4): если фокус не в
+ // INPUT/TEXTAREA, серия цифр < 100 мс + Enter + длина ≥ 6 — это скан.
+ const addScanRef = useRef(addScan)
+ addScanRef.current = addScan
+ useEffect(() => {
+ let buf = '', last = 0
+ const onKey = (e: KeyboardEvent) => {
+ const el = document.activeElement as HTMLElement | null
+ if (el && (el.tagName === 'INPUT' || el.tagName === 'TEXTAREA')) return
+ const now = Date.now()
+ if (now - last > 100) buf = ''
+ last = now
+ if (e.key === 'Enter') {
+ if (buf.length >= 6) { e.preventDefault(); addScanRef.current(buf) }
+ buf = ''
+ } else if (/^\d$/.test(e.key)) buf += e.key
+ }
+ addEventListener('keydown', onKey)
+ return () => removeEventListener('keydown', onKey)
+ }, [])
+
const total = useMemo(() => rows.reduce((s, r) => s + r.qty * r.price, 0), [rows])
const save = async () => {
diff --git a/tests/food-market.UnitTests/ClaudeCliInvoicePhotosParseTests.cs b/tests/food-market.UnitTests/ClaudeCliInvoicePhotosParseTests.cs
new file mode 100644
index 0000000..8a934d4
--- /dev/null
+++ b/tests/food-market.UnitTests/ClaudeCliInvoicePhotosParseTests.cs
@@ -0,0 +1,74 @@
+using FluentAssertions;
+using foodmarket.Api.Infrastructure.Invoices;
+using Xunit;
+
+namespace foodmarket.UnitTests;
+
+///
Разбор JSON-ответа модели для фото-приёмки (handoff §5): к обычной
+/// шапке добавляются photo, barcode_source (invoice|decoded|read),
+/// comment → Note, и unmatched_photos → Warnings.
+/// barcode_source=null (или отсутствует) означает «цифры не прочитаны уверенно»
+/// и мы обнуляем Barcode, чтобы не подставить что-то наугад.
+public class ClaudeCliInvoicePhotosParseTests
+{
+ [Fact]
+ public void Photos_decoded_source_passes_through()
+ {
+ var json = """
+ {
+ "supplier":"ТОО X","number":"7","date":"2026-10-05","total":4500,
+ "invoice_photos":[1],
+ "lines":[
+ {"name":"Молоко 3.2%","qty":10,"price":450,"sum":4500,
+ "barcode":"4870001234561","barcode_source":"decoded","photo":2,"comment":null}
+ ],
+ "unmatched_photos":[]
+ }
+ """;
+ var inv = ClaudeCliInvoiceExtractor.FromPhotosJson(json);
+ inv.Lines.Should().HaveCount(1);
+ var l = inv.Lines[0];
+ l.Barcode.Should().Be("4870001234561");
+ l.BarcodeSource.Should().Be("decoded");
+ l.Photo.Should().Be(2);
+ inv.Warnings.Should().BeEmpty();
+ }
+
+ [Fact]
+ public void Photos_source_null_clears_barcode()
+ {
+ // Если модель не уверена в цифрах, barcode_source=null → штрихкод не берём,
+ // чтобы не привязать строку к чужому товару.
+ var json = """
+ {"supplier":"X","lines":[
+ {"name":"A","qty":1,"price":10,"barcode":"4870000000000","barcode_source":null,"photo":null}
+ ]}
+ """;
+ var inv = ClaudeCliInvoiceExtractor.FromPhotosJson(json);
+ inv.Lines[0].Barcode.Should().BeNull();
+ inv.Lines[0].BarcodeSource.Should().BeNull();
+ }
+
+ [Fact]
+ public void Unmatched_photos_become_warning()
+ {
+ var json = """
+ {"supplier":"X","lines":[{"name":"A","qty":1,"price":10}],
+ "unmatched_photos":[5,7]}
+ """;
+ var inv = ClaudeCliInvoiceExtractor.FromPhotosJson(json);
+ inv.Warnings.Should().ContainMatch("*5*7*");
+ }
+
+ [Fact]
+ public void Comment_lands_in_note()
+ {
+ var json = """
+ {"supplier":"X","lines":[
+ {"name":"А","qty":1,"price":10,"barcode_source":"invoice","comment":"сверьте вес"}
+ ]}
+ """;
+ var inv = ClaudeCliInvoiceExtractor.FromPhotosJson(json);
+ inv.Lines[0].Note.Should().Be("сверьте вес");
+ }
+}
diff --git a/tests/food-market.UnitTests/EanTests.cs b/tests/food-market.UnitTests/EanTests.cs
new file mode 100644
index 0000000..7b1fbb8
--- /dev/null
+++ b/tests/food-market.UnitTests/EanTests.cs
@@ -0,0 +1,57 @@
+using FluentAssertions;
+using foodmarket.Api.Infrastructure.Invoices;
+using Xunit;
+
+namespace foodmarket.UnitTests;
+
+///
Контрольная цифра EAN-8/EAN-13/UPC-A/GTIN-14. Проверка нужна в
+/// фото-приёмке: если модель переписала цифры штрихкода со снимка товара
+/// сама (barcode_source="read"), одна ошибка в любой цифре иначе
+/// молча дала бы чужой товар. Порт из ~/ref/sklad.
+public class EanTests
+{
+ // Валидные настоящие штрихкоды (из handoff: «15/15 реальных проходят»).
+ public static TheoryData
Valid => new()
+ {
+ "4870001234560", // EAN-13
+ "4607031440148", // EAN-13 (пример из KZ-ритейла)
+ "0012000161551", // UPC-A дополненный до 13
+ "73513537", // EAN-8
+ "012345678905", // UPC-A (12)
+ };
+
+ [Theory, MemberData(nameof(Valid))]
+ public void Valid_passes(string digits) => Ean.IsValid(digits).Should().BeTrue();
+
+ [Fact]
+ public void Broken_check_digit_fails()
+ {
+ // Все эти — валидные минус один «кривой» символ контрольной.
+ Ean.IsValid("4870001234561").Should().BeFalse();
+ Ean.IsValid("4607031440149").Should().BeFalse();
+ }
+
+ [Theory]
+ [InlineData("")]
+ [InlineData(null)]
+ [InlineData("12345")] // короткий
+ [InlineData("12345678901")] // 11 — не стандарт
+ [InlineData("12345678901A")] // буква
+ [InlineData("4870 001 234 56")] // пробелы — Ean.IsValid смотрит на точные цифры
+ public void Non_digit_or_bad_length_fails(string? d) => Ean.IsValid(d).Should().BeFalse();
+
+ [Fact]
+ public void One_digit_error_in_middle_caught()
+ {
+ // Из handoff: «15/15 с одной испорченной цифрой ловятся» —
+ // гарантируется весовой суммой 3,1,3,1,…
+ var ok = "4870001234560";
+ foreach (var i in new[] { 0, 3, 5, 8, 11 })
+ {
+ var mutated = ok.ToCharArray();
+ mutated[i] = (char)('0' + ((mutated[i] - '0' + 5) % 10)); // ± 5 в этой позиции
+ Ean.IsValid(new string(mutated)).Should().BeFalse(
+ $"позиция {i}: ошибку в одной цифре должен поймать");
+ }
+ }
+}
diff --git a/tests/food-market.UnitTests/InvoicePhotoRetentionTests.cs b/tests/food-market.UnitTests/InvoicePhotoRetentionTests.cs
new file mode 100644
index 0000000..615893b
--- /dev/null
+++ b/tests/food-market.UnitTests/InvoicePhotoRetentionTests.cs
@@ -0,0 +1,53 @@
+using FluentAssertions;
+using foodmarket.Api.Infrastructure.Invoices;
+using foodmarket.Domain.Purchases;
+using Xunit;
+
+namespace foodmarket.UnitTests;
+
+/// Owner-policy 2026-10-05 (handoff §5): фото накладных на сервере
+/// НЕ копим. тихо удаляет и
+/// одиночный файл, и папку фото-пачки, обнуляя StoredPath, после чего
+/// запись InvoiceUpload (строки в ParsedJson) остаётся жить.
+public class InvoicePhotoRetentionTests
+{
+ [Fact]
+ public void DeleteStored_removes_single_file()
+ {
+ var tmp = Path.Combine(Path.GetTempPath(), $"fm-{Guid.NewGuid():N}.jpg");
+ File.WriteAllBytes(tmp, new byte[] { 0xFF, 0xD8, 0xFF });
+ var up = new InvoiceUpload { StoredPath = tmp, FileName = "x.jpg" };
+
+ InvoiceProcessor.DeleteStored(up);
+
+ File.Exists(tmp).Should().BeFalse();
+ up.StoredPath.Should().BeEmpty();
+ }
+
+ [Fact]
+ public void DeleteStored_removes_photo_folder_recursively()
+ {
+ var dir = Path.Combine(Path.GetTempPath(), $"fm-photos-{Guid.NewGuid():N}");
+ Directory.CreateDirectory(dir);
+ File.WriteAllText(Path.Combine(dir, "01.jpg"), "");
+ File.WriteAllText(Path.Combine(dir, "02.jpg"), "");
+ File.WriteAllText(Path.Combine(dir, "codes.json"), "[]");
+ var up = new InvoiceUpload { StoredPath = dir, Source = "photos", FileName = "фото-приёмка" };
+
+ InvoiceProcessor.DeleteStored(up);
+
+ Directory.Exists(dir).Should().BeFalse();
+ up.StoredPath.Should().BeEmpty();
+ }
+
+ [Fact]
+ public void DeleteStored_is_idempotent()
+ {
+ // Файла нет на диске — не должно падать (сработает повторно, если
+ // SweepAbandonedPhotos дошёл раньше Apply).
+ var up = new InvoiceUpload { StoredPath = "/tmp/fm-does-not-exist-" + Guid.NewGuid() };
+ FluentActions.Invoking(() => InvoiceProcessor.DeleteStored(up))
+ .Should().NotThrow();
+ up.StoredPath.Should().BeEmpty();
+ }
+}
diff --git a/tests/food-market.UnitTests/TableReaderTests.cs b/tests/food-market.UnitTests/TableReaderTests.cs
new file mode 100644
index 0000000..487ade0
--- /dev/null
+++ b/tests/food-market.UnitTests/TableReaderTests.cs
@@ -0,0 +1,173 @@
+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); }
+ }
+}