Unit: - InvoiceMatcherTests (12): barcode wins, short barcode falls through, alias by code/by name_norm, alias is supplier-scoped, по названию НЕ матчим автоматически (sklad-урок про LD Super Slims), NormalizeName quotes/whitespace/unit-glue/ё→е, OnlyDigits, AliasKeys skip пустые. - ClaudeCliInvoiceExtractorParseTests (8): plain JSON, envelope с result+usage, markdown fence, barcode spaces strip, null-tolerant, 'empty' raises, пустое name пропускается. Parse/FromJson теперь public (internal мешал тестам, InternalsVisibleTo избыточен). Integration: - Matcher_hits_product_by_barcode_from_real_catalog — реальный Postgres + seeded org + создаём товар+штрихкод, matcher находит. - Multi_tenant_isolation_matcher_does_not_see_other_orgs_barcodes — две org, штрихкод orgA НЕ виден в matcher'е orgB (tenant isolation). Все новые тесты зелёные. Pre-existing failures в full-suite не связаны. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
117 lines
4.8 KiB
C#
117 lines
4.8 KiB
C#
using FluentAssertions;
|
||
using foodmarket.Api.Infrastructure.Invoices;
|
||
using Xunit;
|
||
|
||
namespace foodmarket.UnitTests;
|
||
|
||
/// <summary>Sprint 31: порядок сопоставления строки накладной с товаром.
|
||
/// (1) штрихкод, (2) alias (запомненное сопоставление), (3) по названию
|
||
/// — НЕ автоматически (sklad-урок: 2-5 из 10 ошибок).</summary>
|
||
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();
|
||
}
|
||
}
|