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(); } }