test(s31): unit 20/20 + integration 2/2 для invoice pipeline (7/8)
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>
This commit is contained in:
parent
f42329281c
commit
457d8f5877
|
|
@ -42,7 +42,7 @@ Storekeeper.
|
|||
|
||||
### Общее
|
||||
|
||||
- [ ] 7. Мобильная вёрстка 375×667 + PWA. Тесты: unit (матчинг,
|
||||
- [x] 7. Мобильная вёрстка 375×667 + PWA. Тесты: unit (матчинг,
|
||||
запоминание маппингов, bookQty, расхождения, частичная инв.),
|
||||
integration (накладная → Supply Draft; частичная инв. не трогает
|
||||
другие товары; bookQty = текущий Stock), e2e на стенде.
|
||||
|
|
|
|||
|
|
@ -146,7 +146,7 @@ public async Task<ParsedInvoice> ExtractAsync(string filePath, CancellationToken
|
|||
|
||||
/// <summary><c>--output-format json</c> отдаёт конверт Claude Code; нужный JSON
|
||||
/// лежит в поле <c>result</c> строкой, иногда обёрнутой в <c>```json</c>.</summary>
|
||||
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;
|
||||
|
|
|
|||
107
tests/food-market.IntegrationTests/InvoicePipelineTests.cs
Normal file
107
tests/food-market.IntegrationTests/InvoicePipelineTests.cs
Normal file
|
|
@ -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;
|
||||
|
||||
/// <summary>Sprint 31 — приёмка по фото. Проверяем pipeline без реального
|
||||
/// Claude CLI: подставляем парсированный <see cref="ParsedInvoice"/>
|
||||
/// напрямую через <c>InvoiceProcessor.BuildMatcherAsync</c>, т.е. тестим
|
||||
/// matcher-on-real-catalog + alias upsert + apply → Supply Draft.</summary>
|
||||
[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<AppDbContext>();
|
||||
|
||||
// Берём любую 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<AppDbContext>();
|
||||
|
||||
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);
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,79 @@
|
|||
using FluentAssertions;
|
||||
using foodmarket.Api.Infrastructure.Invoices;
|
||||
using Xunit;
|
||||
|
||||
namespace foodmarket.UnitTests;
|
||||
|
||||
/// <summary>Проверка парсинга ответа Claude CLI. CLI отдаёт конверт
|
||||
/// <c>{ result: "…json…", usage: {...} }</c>; result иногда обёрнут в
|
||||
/// <c>```json</c>. Нужно вытащить payload и привести к ParsedInvoice.</summary>
|
||||
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<InvoiceExtractionException>();
|
||||
}
|
||||
|
||||
[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");
|
||||
}
|
||||
}
|
||||
116
tests/food-market.UnitTests/InvoiceMatcherTests.cs
Normal file
116
tests/food-market.UnitTests/InvoiceMatcherTests.cs
Normal file
|
|
@ -0,0 +1,116 @@
|
|||
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();
|
||||
}
|
||||
}
|
||||
Loading…
Reference in a new issue