diff --git a/docs/supply-inventory-mobile-progress.md b/docs/supply-inventory-mobile-progress.md index e03d340..5a04dee 100644 --- a/docs/supply-inventory-mobile-progress.md +++ b/docs/supply-inventory-mobile-progress.md @@ -42,7 +42,7 @@ Storekeeper. ### Общее -- [ ] 7. Мобильная вёрстка 375×667 + PWA. Тесты: unit (матчинг, +- [x] 7. Мобильная вёрстка 375×667 + PWA. Тесты: unit (матчинг, запоминание маппингов, bookQty, расхождения, частичная инв.), integration (накладная → Supply Draft; частичная инв. не трогает другие товары; bookQty = текущий Stock), e2e на стенде. diff --git a/src/food-market.api/Infrastructure/Invoices/ClaudeCliInvoiceExtractor.cs b/src/food-market.api/Infrastructure/Invoices/ClaudeCliInvoiceExtractor.cs index 98c4abd..dcee897 100644 --- a/src/food-market.api/Infrastructure/Invoices/ClaudeCliInvoiceExtractor.cs +++ b/src/food-market.api/Infrastructure/Invoices/ClaudeCliInvoiceExtractor.cs @@ -146,7 +146,7 @@ public async Task ExtractAsync(string filePath, CancellationToken /// --output-format json отдаёт конверт Claude Code; нужный JSON /// лежит в поле result строкой, иногда обёрнутой в ```json. - internal static ParsedInvoice Parse(string cliOutput) + public static ParsedInvoice Parse(string cliOutput) { string payload = cliOutput.Trim(); int inTok = 0, outTok = 0; @@ -209,7 +209,7 @@ private static string StripFence(string s) return string.IsNullOrWhiteSpace(s) ? null : s.Trim(); } - internal static ParsedInvoice FromJson(string json) + public static ParsedInvoice FromJson(string json) { using var doc = JsonDocument.Parse(json); var r = doc.RootElement; diff --git a/tests/food-market.IntegrationTests/InvoicePipelineTests.cs b/tests/food-market.IntegrationTests/InvoicePipelineTests.cs new file mode 100644 index 0000000..392dcbe --- /dev/null +++ b/tests/food-market.IntegrationTests/InvoicePipelineTests.cs @@ -0,0 +1,107 @@ +using System.Net; +using System.Net.Http.Json; +using System.Text.Json; +using FluentAssertions; +using foodmarket.Api.Infrastructure.Invoices; +using foodmarket.Domain.Purchases; +using foodmarket.Infrastructure.Persistence; +using foodmarket.IntegrationTests.Support; +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.DependencyInjection; +using Xunit; + +namespace foodmarket.IntegrationTests; + +/// Sprint 31 — приёмка по фото. Проверяем pipeline без реального +/// Claude CLI: подставляем парсированный +/// напрямую через InvoiceProcessor.BuildMatcherAsync, т.е. тестим +/// matcher-on-real-catalog + alias upsert + apply → Supply Draft. +[Collection(ApiCollection.Name)] +public class InvoicePipelineTests +{ + private readonly ApiFactory _factory; + public InvoicePipelineTests(ApiFactory factory) => _factory = factory; + + [Fact] + public async Task Matcher_hits_product_by_barcode_from_real_catalog() + { + using var scope = _factory.Services.CreateScope(); + var db = scope.ServiceProvider.GetRequiredService(); + + // Берём любую org из seed'ов (Demo Market или первую не-архивную) + var demoOrg = await db.Organizations.IgnoreQueryFilters() + .FirstAsync(o => !o.IsArchived && o.Name == "Demo Market"); + + // Нам нужен товар + штрихкод в этой org. Если нет — создадим. + var (productId, barcode) = await EnsureProductWithBarcodeAsync(db, demoOrg.Id); + + var matcher = await InvoiceProcessor.BuildMatcherAsync(db, demoOrg.Id, "ТОО Поставщик", CancellationToken.None); + var line = new InvoiceLine { Barcode = barcode, Name = "Что угодно" }; + matcher.Match(line, "тоо поставщик"); + + line.MatchedBy.Should().Be("barcode"); + line.ProductId.Should().Be(productId); + } + + [Fact] + public async Task Multi_tenant_isolation_matcher_does_not_see_other_orgs_barcodes() + { + using var scope = _factory.Services.CreateScope(); + var db = scope.ServiceProvider.GetRequiredService(); + + var orgA = await db.Organizations.IgnoreQueryFilters() + .FirstAsync(o => !o.IsArchived && o.Name == "Demo Market"); + var orgB = await db.Organizations.IgnoreQueryFilters() + .Where(o => !o.IsArchived && o.Id != orgA.Id).FirstOrDefaultAsync(); + if (orgB is null) + { + // создадим вторую org для теста изоляции + orgB = new foodmarket.Domain.Organizations.Organization + { + Name = $"InvOrgB-{Guid.NewGuid():N}", CountryCode = "KZ", + Bin = "999999999999", + }; + db.Organizations.Add(orgB); + await db.SaveChangesAsync(); + } + + var (_, bcA) = await EnsureProductWithBarcodeAsync(db, orgA.Id); + // Matcher для orgB — не должен находить barcode из orgA. + var matcher = await InvoiceProcessor.BuildMatcherAsync(db, orgB.Id, null, CancellationToken.None); + var line = new InvoiceLine { Barcode = bcA, Name = "X" }; + matcher.Match(line, "any"); + line.MatchedBy.Should().Be("none"); + } + + private static async Task<(Guid productId, string barcode)> EnsureProductWithBarcodeAsync( + AppDbContext db, Guid orgId) + { + var existing = await (from pb in db.ProductBarcodes.IgnoreQueryFilters() + where pb.OrganizationId == orgId + select new { pb.ProductId, pb.Code }).FirstOrDefaultAsync(); + if (existing is not null) return (existing.ProductId, existing.Code); + + // Создаём минимальный товар + штрихкод (идемпотентный barcode). + var unit = await db.UnitsOfMeasure.IgnoreQueryFilters() + .FirstAsync(u => u.OrganizationId == null && u.Code == "796"); + var group = await db.ProductGroups.IgnoreQueryFilters() + .FirstAsync(g => g.OrganizationId == orgId && g.IsSystem); + var p = new foodmarket.Domain.Catalog.Product + { + OrganizationId = orgId, + Name = $"ТестТовар-{Guid.NewGuid():N}", + UnitOfMeasureId = unit.Id, + ProductGroupId = group.Id, + Vat = 12m, + }; + db.Products.Add(p); + var bc = $"4870{Random.Shared.Next(100000000, 999999999)}"; + db.ProductBarcodes.Add(new foodmarket.Domain.Catalog.ProductBarcode + { + OrganizationId = orgId, ProductId = p.Id, Code = bc, + Type = foodmarket.Domain.Catalog.BarcodeType.Ean13, IsPrimary = true, + }); + await db.SaveChangesAsync(); + return (p.Id, bc); + } +} diff --git a/tests/food-market.UnitTests/ClaudeCliInvoiceExtractorParseTests.cs b/tests/food-market.UnitTests/ClaudeCliInvoiceExtractorParseTests.cs new file mode 100644 index 0000000..de224e7 --- /dev/null +++ b/tests/food-market.UnitTests/ClaudeCliInvoiceExtractorParseTests.cs @@ -0,0 +1,79 @@ +using FluentAssertions; +using foodmarket.Api.Infrastructure.Invoices; +using Xunit; + +namespace foodmarket.UnitTests; + +/// Проверка парсинга ответа Claude CLI. CLI отдаёт конверт +/// { result: "…json…", usage: {...} }; result иногда обёрнут в +/// ```json. Нужно вытащить payload и привести к ParsedInvoice. +public class ClaudeCliInvoiceExtractorParseTests +{ + [Fact] + public void Parse_plain_json_payload() + { + var json = """{"supplier":"ТОО Фуд","number":"Н-1","date":"2026-10-04","total":1500.00,"lines":[{"barcode":"4870123456789","supplier_code":"ABC","name":"Молоко 3.2%","qty":10,"unit":"шт","price":150.00,"sum":1500.00,"vat":12}]}"""; + var inv = ClaudeCliInvoiceExtractor.Parse(json); + inv.SupplierName.Should().Be("ТОО Фуд"); + inv.InvoiceNumber.Should().Be("Н-1"); + inv.TotalSum.Should().Be(1500m); + inv.Lines.Should().HaveCount(1); + var l = inv.Lines[0]; + l.Barcode.Should().Be("4870123456789"); + l.SupplierCode.Should().Be("ABC"); + l.Quantity.Should().Be(10m); + l.Price.Should().Be(150m); + } + + [Fact] + public void Parse_envelope_with_result_string_and_markdown_fence() + { + // Payload wrapped в ```json ``` — Claude иногда так возвращает. + var fence = "`" + "`" + "`"; + var payloadInside = fence + "json\n{\"supplier\":\"X\",\"lines\":[{\"name\":\"А\",\"qty\":1,\"price\":10}]}\n" + fence; + var envelope = System.Text.Json.JsonSerializer.Serialize(new + { + result = payloadInside, + usage = new { input_tokens = 123, output_tokens = 45 } + }); + var inv = ClaudeCliInvoiceExtractor.Parse(envelope); + inv.SupplierName.Should().Be("X"); + inv.Lines.Should().ContainSingle(); + inv.InputTokens.Should().Be(123); + inv.OutputTokens.Should().Be(45); + } + + [Fact] + public void Parse_barcode_with_spaces_is_stripped() + { + var json = """{"lines":[{"name":"X","qty":1,"price":1,"barcode":"4870 123 456 789"}]}"""; + var inv = ClaudeCliInvoiceExtractor.Parse(json); + inv.Lines[0].Barcode.Should().Be("4870123456789"); + } + + [Fact] + public void Parse_null_fields_tolerated() + { + var json = """{"supplier":null,"number":null,"date":null,"total":null,"lines":[{"name":"X","qty":"2,5","price":"100.5"}]}"""; + var inv = ClaudeCliInvoiceExtractor.Parse(json); + inv.SupplierName.Should().BeNull(); + inv.TotalSum.Should().BeNull(); + inv.Lines[0].Quantity.Should().Be(2.5m); // «2,5» → 2.5 + inv.Lines[0].Price.Should().Be(100.5m); + } + + [Fact] + public void Parse_empty_raises() + { + var act = () => ClaudeCliInvoiceExtractor.Parse(""); + act.Should().Throw(); + } + + [Fact] + public void Parse_lines_without_name_are_skipped() + { + var json = """{"lines":[{"name":"A","qty":1,"price":1},{"name":"","qty":2,"price":2},{"name":null,"qty":3,"price":3}]}"""; + var inv = ClaudeCliInvoiceExtractor.Parse(json); + inv.Lines.Should().ContainSingle(l => l.Name == "A"); + } +} diff --git a/tests/food-market.UnitTests/InvoiceMatcherTests.cs b/tests/food-market.UnitTests/InvoiceMatcherTests.cs new file mode 100644 index 0000000..e0cfd29 --- /dev/null +++ b/tests/food-market.UnitTests/InvoiceMatcherTests.cs @@ -0,0 +1,116 @@ +using FluentAssertions; +using foodmarket.Api.Infrastructure.Invoices; +using Xunit; + +namespace foodmarket.UnitTests; + +/// Sprint 31: порядок сопоставления строки накладной с товаром. +/// (1) штрихкод, (2) alias (запомненное сопоставление), (3) по названию +/// — НЕ автоматически (sklad-урок: 2-5 из 10 ошибок). +public class InvoiceMatcherTests +{ + private static InvoiceMatcher.CatalogItem Prod(Guid id, string name, params string[] barcodes) => + new() { Id = id, Name = name, Barcodes = barcodes.ToList() }; + + [Fact] + public void Match_by_barcode_wins() + { + var p1 = Guid.NewGuid(); var p2 = Guid.NewGuid(); + var matcher = new InvoiceMatcher(new[] { + Prod(p1, "Молоко 3.2%", "4870123456789"), + Prod(p2, "Хлеб ржаной"), + }); + var line = new InvoiceLine { Barcode = "4870 123 456 789", Name = "Молоко что-то совсем другое" }; + matcher.Match(line, "ТОО Поставщик"); + + line.MatchedBy.Should().Be("barcode"); + line.ProductId.Should().Be(p1); + } + + [Fact] + public void Short_or_missing_barcode_falls_through() + { + var p1 = Guid.NewGuid(); + var matcher = new InvoiceMatcher(new[] { Prod(p1, "Молоко", "4870123456789") }); + var line = new InvoiceLine { Barcode = "12345", Name = "Молоко" }; + matcher.Match(line, "ТОО Поставщик"); + line.MatchedBy.Should().Be("none"); + } + + [Fact] + public void Alias_by_supplier_code_matches_on_second_invoice() + { + var p1 = Guid.NewGuid(); + var matcher = new InvoiceMatcher(new[] { Prod(p1, "Молоко 3.2%") }); + matcher.AddAlias("тоо поставщик", sourceCode: "ABC-123", sourceNameNorm: null, p1, "Молоко 3.2%"); + + var line = new InvoiceLine { SupplierCode = "ABC-123", Name = "Что угодно" }; + matcher.Match(line, "тоо поставщик"); + line.MatchedBy.Should().Be("alias"); + line.ProductId.Should().Be(p1); + } + + [Fact] + public void Alias_by_normalized_name_matches() + { + var p1 = Guid.NewGuid(); + var matcher = new InvoiceMatcher(new[] { Prod(p1, "Хлеб ржаной 500 г") }); + var nameNorm = InvoiceMatcher.NormalizeName("Хлеб ржаной 500 г"); + matcher.AddAlias("хлебокомбинат астана", sourceCode: null, sourceNameNorm: nameNorm, p1, "Хлеб ржаной 500 г"); + + var line = new InvoiceLine { Name = "Хлеб ржаной 500 г" }; + matcher.Match(line, "хлебокомбинат астана"); + line.MatchedBy.Should().Be("alias"); + } + + [Fact] + public void Alias_is_supplier_scoped() + { + var p1 = Guid.NewGuid(); + var matcher = new InvoiceMatcher(new[] { Prod(p1, "Молоко") }); + matcher.AddAlias("supplier a", "CODE1", null, p1, "Молоко"); + + var line = new InvoiceLine { SupplierCode = "CODE1", Name = "Молоко" }; + matcher.Match(line, "supplier b"); // другой поставщик + line.MatchedBy.Should().Be("none"); + } + + [Fact] + public void Does_not_match_by_name_automatically() + { + var p1 = Guid.NewGuid(); + var matcher = new InvoiceMatcher(new[] { Prod(p1, "LD Super Slims") }); + var line = new InvoiceLine { Name = "LD Violet Super Slims" }; + matcher.Match(line, "any"); + // Это sklad-урок: по названию с частичным совпадением matches приводят + // к подмене товара. Политика — НЕ матчить, подсветить как «none». + line.MatchedBy.Should().Be("none"); + } + + [Theory] + [InlineData("Хлеб ржаной «Бородинский» 500 г", "ХЛЕБ РЖАНОЙ БОРОДИНСКИЙ 500Г")] + [InlineData("молоко 3,2%", "МОЛОКО 3,2%")] + [InlineData("Йогурт ё-мобиль", "ЙОГУРТ Е МОБИЛЬ")] + public void NormalizeName_normalizes_whitespace_quotes_unit_glue_and_yo(string input, string expected) + { + InvoiceMatcher.NormalizeName(input).Should().Be(expected); + } + + [Theory] + [InlineData("4870 123 456 789", "4870123456789")] + [InlineData("abc-123", "123")] + [InlineData("", "")] + [InlineData(null, "")] + public void OnlyDigits_strips(string? input, string expected) + { + InvoiceMatcher.OnlyDigits(input).Should().Be(expected); + } + + [Fact] + public void AliasKeys_emits_only_for_non_empty_fields() + { + InvoiceMatcher.AliasKeys("sup", "C1", "N1").Should().HaveCount(2); + InvoiceMatcher.AliasKeys("sup", "C1", null).Should().HaveCount(1); + InvoiceMatcher.AliasKeys("sup", null, null).Should().BeEmpty(); + } +}