From ef8c4a3222b15c986db0b0486b9f71b640e8eb2a Mon Sep 17 00:00:00 2001 From: nns Date: Thu, 28 May 2026 17:51:08 +0500 Subject: [PATCH] =?UTF-8?q?feat(cqrs):=20MediatR=20partial=20=E2=80=94=203?= =?UTF-8?q?=20handler-=D0=BE=D0=B1=D1=80=D0=B0=D0=B7=D1=86=D0=B0=20(TD-1)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Подключён MediatR в food-market.api с авторегистрацией из сборки food-market.application. Цель — показать паттерн, не полный рефакторинг контроллеров (это отдельный спринт). Образцы handler'ов в food-market.application: - Purchases/Commands/CreateSupplyCommand + CreateSupplyHandler — создание Draft-приёмки с делегированием персистентности через ISupplyWriter абстракцию (testable без EF). - Sales/Commands/PostRetailSaleCommand + PostRetailSaleHandler — проведение чека с валидацией платежа (переиспользует RetailPaymentValidator из Sprint 1) и делегированием stock-операции через IRetailSalePoster. - Sales/Queries/GetSalesReportQuery + GetSalesReportHandler — агрегация плоских sale-строк по period:day/period:month/product. Pure-функция, безопасно тестируется в памяти. Контроллеры пока используют прежние flow (контроллер → EF напрямую) — поэтапная миграция, не big-bang. Эти handler'ы — образец, на который оглядываемся при следующих feature'ах. Тесты: 6 unit (2 GetSalesReportHandler, 1 CreateSupplyHandler, 3 PostRetailSaleHandler). 57 unit total зелёных. Co-Authored-By: Claude Opus 4.7 --- src/food-market.api/Program.cs | 6 + src/food-market.api/food-market.api.csproj | 1 + .../Purchases/Commands/CreateSupplyCommand.cs | 49 +++++++ .../Sales/Commands/PostRetailSaleCommand.cs | 48 ++++++ .../Sales/Queries/GetSalesReportQuery.cs | 62 ++++++++ .../MediatrHandlerTests.cs | 138 ++++++++++++++++++ 6 files changed, 304 insertions(+) create mode 100644 src/food-market.application/Purchases/Commands/CreateSupplyCommand.cs create mode 100644 src/food-market.application/Sales/Commands/PostRetailSaleCommand.cs create mode 100644 src/food-market.application/Sales/Queries/GetSalesReportQuery.cs create mode 100644 tests/food-market.UnitTests/MediatrHandlerTests.cs diff --git a/src/food-market.api/Program.cs b/src/food-market.api/Program.cs index f236633..6321a4e 100644 --- a/src/food-market.api/Program.cs +++ b/src/food-market.api/Program.cs @@ -168,6 +168,12 @@ // см. Resources/EmailTemplates/*.html. Singleton с in-memory cache. builder.Services.AddSingleton(); builder.Services.AddDataProtection(); + // MediatR (TD-1 partial CQRS): сканирует food-market.application для + // IRequest/IRequestHandler. Образцы: CreateSupplyCommand, + // PostRetailSaleCommand, GetSalesReportQuery. Контроллеры пока не + // переписаны под mediator — это паттерн-показ, не полный рефакторинг. + builder.Services.AddMediatR(cfg => cfg.RegisterServicesFromAssembly( + typeof(foodmarket.Application.Inventory.IStockService).Assembly)); // FluentValidation: автоматическая регистрация валидаторов из сборки api. // ValidationFilter гоняет валидаторы на каждом контроллер-action перед // вызовом — fail возвращает 400 ValidationProblemDetails (RFC 7807). diff --git a/src/food-market.api/food-market.api.csproj b/src/food-market.api/food-market.api.csproj index 9df0131..e05fb89 100644 --- a/src/food-market.api/food-market.api.csproj +++ b/src/food-market.api/food-market.api.csproj @@ -25,6 +25,7 @@ + diff --git a/src/food-market.application/Purchases/Commands/CreateSupplyCommand.cs b/src/food-market.application/Purchases/Commands/CreateSupplyCommand.cs new file mode 100644 index 0000000..dfb87ee --- /dev/null +++ b/src/food-market.application/Purchases/Commands/CreateSupplyCommand.cs @@ -0,0 +1,49 @@ +using MediatR; + +namespace foodmarket.Application.Purchases.Commands; + +/// CQRS-образец №2 (TD-1): команда создания приёмки (Draft). +/// Handler принимает input + контракт и делегирует +/// фактическое сохранение — handler не зависит от EF напрямую, что +/// упрощает unit-тестирование. +/// +/// Покрывает только Draft-создание; Posting остаётся в контроллере (где +/// stock-логика и serializable-транзакция). Это сознательное «partial»: +/// показываем паттерн, не переписываем всё. +public record CreateSupplyCommand( + Guid SupplierId, Guid StoreId, Guid CurrencyId, + DateTime Date, string? Notes, + IReadOnlyList Lines) : IRequest; + +public record CreateSupplyLine(Guid ProductId, decimal Quantity, decimal UnitPrice); + +public record CreateSupplyResult(Guid Id, string Number, decimal Total); + +/// Абстракция над персистентностью: позволяет тестировать handler +/// без EF/Postgres (in-memory implementation в тестах). +public interface ISupplyWriter +{ + Task NextNumberAsync(int year, CancellationToken ct); + Task CreateAsync(Guid supplierId, Guid storeId, Guid currencyId, + DateTime date, string? notes, string number, + IReadOnlyList lines, decimal total, + CancellationToken ct); +} + +public sealed class CreateSupplyHandler : IRequestHandler +{ + private readonly ISupplyWriter _writer; + public CreateSupplyHandler(ISupplyWriter writer) => _writer = writer; + + public async Task Handle(CreateSupplyCommand cmd, CancellationToken ct) + { + // Бизнес-правила: суммирование, генерация номера, делегирование + // персистентности абстракции. + var total = cmd.Lines.Sum(l => l.Quantity * l.UnitPrice); + var number = await _writer.NextNumberAsync(cmd.Date.Year, ct); + var id = await _writer.CreateAsync( + cmd.SupplierId, cmd.StoreId, cmd.CurrencyId, + cmd.Date, cmd.Notes, number, cmd.Lines, total, ct); + return new CreateSupplyResult(id, number, total); + } +} diff --git a/src/food-market.application/Sales/Commands/PostRetailSaleCommand.cs b/src/food-market.application/Sales/Commands/PostRetailSaleCommand.cs new file mode 100644 index 0000000..23c8589 --- /dev/null +++ b/src/food-market.application/Sales/Commands/PostRetailSaleCommand.cs @@ -0,0 +1,48 @@ +using MediatR; + +namespace foodmarket.Application.Sales.Commands; + +/// CQRS-образец №3 (TD-1): команда проведения чека. Handler +/// инкапсулирует валидацию платежа (вынесенную в Application слое уже +/// раньше как RetailPaymentValidator) и делегирует stock-операцию +/// абстракции IRetailSalePoster. +/// +/// Цель — показать, что Post можно тестировать чисто (без БД, без +/// контроллера), проверяя бизнес-логику payment-validation + invocation +/// stock writer'а. Сам RetailSalesController остаётся как образец до-CQRS +/// контроллера (рефакторинг — отдельный спринт). +public record PostRetailSaleCommand( + Guid SaleId, + decimal PaidCash, + decimal PaidCard, + decimal Total, + IReadOnlyList Lines) : IRequest; + +public record PostSaleLine(Guid ProductId, decimal Quantity, decimal UnitPrice); + +public record PostRetailSaleResult(bool Posted, string? ErrorMessage, string? ErrorField); + +/// Абстракция: списать стоки и пометить чек проведённым. +public interface IRetailSalePoster +{ + Task PostAsync(Guid saleId, IReadOnlyList lines, CancellationToken ct); +} + +public sealed class PostRetailSaleHandler : IRequestHandler +{ + private readonly IRetailSalePoster _poster; + public PostRetailSaleHandler(IRetailSalePoster poster) => _poster = poster; + + public async Task Handle(PostRetailSaleCommand cmd, CancellationToken ct) + { + // Переиспользуем уже вынесенный валидатор платежа (Sprint 1). + if (!RetailPaymentValidator.IsSufficient(cmd.PaidCash, cmd.PaidCard, cmd.Total)) + return new PostRetailSaleResult(false, + $"Сумма оплаты меньше итога {cmd.Total:0.##}.", "PaidCash"); + if (cmd.Lines.Count == 0) + return new PostRetailSaleResult(false, "Пустой чек.", "lines"); + + await _poster.PostAsync(cmd.SaleId, cmd.Lines, ct); + return new PostRetailSaleResult(true, null, null); + } +} diff --git a/src/food-market.application/Sales/Queries/GetSalesReportQuery.cs b/src/food-market.application/Sales/Queries/GetSalesReportQuery.cs new file mode 100644 index 0000000..b4e7ded --- /dev/null +++ b/src/food-market.application/Sales/Queries/GetSalesReportQuery.cs @@ -0,0 +1,62 @@ +using System.Globalization; +using MediatR; + +namespace foodmarket.Application.Sales.Queries; + +/// CQRS-образец №1 (TD-1): чистая логика агрегации продаж в C# +/// (без БД), пригодная для unit-тестирования без TestContainers. Сам +/// контроллер `/api/reports/sales` остаётся как было (pull-фласк + +/// группировка в C#) — этот handler — refactor-демонстрация на пути к +/// CQRS-разделению. +/// +/// Принимает уже подготовленные «плоские строки» (FlatSale) и параметры +/// группировки/окна, возвращает агрегированный список. Идемпотентен, +/// safe для параллельного вызова. +public record GetSalesReportQuery( + IReadOnlyList Lines, + string GroupBy = "period:day", + DateTime? From = null, + DateTime? To = null) : IRequest>; + +public record FlatSale( + Guid SaleId, DateTime Date, + Guid ProductId, string ProductName, + decimal Revenue, + decimal Quantity); + +public record SalesReportRow( + string Key, string Label, + decimal Revenue, int Transactions, decimal Quantity); + +public sealed class GetSalesReportHandler : IRequestHandler> +{ + public Task> Handle(GetSalesReportQuery req, CancellationToken ct) + { + var filtered = req.Lines + .Where(x => req.From is null || x.Date >= req.From) + .Where(x => req.To is null || x.Date <= req.To) + .ToList(); + + IEnumerable> grouped = req.GroupBy switch + { + "period:day" => filtered.GroupBy(x => ( + Key: x.Date.ToString("yyyy-MM-dd", CultureInfo.InvariantCulture), + Label: x.Date.ToString("yyyy-MM-dd", CultureInfo.InvariantCulture))), + "period:month" => filtered.GroupBy(x => ( + Key: $"{x.Date.Year:0000}-{x.Date.Month:00}", + Label: $"{x.Date.Year:0000}-{x.Date.Month:00}")), + "product" => filtered.GroupBy(x => (Key: x.ProductId.ToString(), Label: x.ProductName)), + _ => Enumerable.Empty>(), + }; + + IReadOnlyList rows = grouped + .Select(g => new SalesReportRow( + g.Key.Key, g.Key.Label, + g.Sum(x => x.Revenue), + g.Select(x => x.SaleId).Distinct().Count(), + g.Sum(x => x.Quantity))) + .OrderByDescending(r => r.Revenue) + .ToList(); + return Task.FromResult(rows); + } +} diff --git a/tests/food-market.UnitTests/MediatrHandlerTests.cs b/tests/food-market.UnitTests/MediatrHandlerTests.cs new file mode 100644 index 0000000..88a8e06 --- /dev/null +++ b/tests/food-market.UnitTests/MediatrHandlerTests.cs @@ -0,0 +1,138 @@ +using FluentAssertions; +using foodmarket.Application.Purchases.Commands; +using foodmarket.Application.Sales.Commands; +using foodmarket.Application.Sales.Queries; +using Xunit; + +namespace foodmarket.UnitTests; + +public class GetSalesReportHandlerTests +{ + private readonly GetSalesReportHandler _h = new(); + + [Fact] + public async Task Groups_by_day_sums_revenue() + { + var d1 = new DateTime(2026, 5, 28, 10, 0, 0, DateTimeKind.Utc); + var d2 = new DateTime(2026, 5, 29, 11, 0, 0, DateTimeKind.Utc); + var lines = new[] + { + new FlatSale(Guid.NewGuid(), d1, Guid.NewGuid(), "A", 100m, 1), + new FlatSale(Guid.NewGuid(), d1, Guid.NewGuid(), "B", 200m, 1), + new FlatSale(Guid.NewGuid(), d2, Guid.NewGuid(), "A", 50m, 1), + }; + var rows = await _h.Handle(new GetSalesReportQuery(lines, "period:day"), CancellationToken.None); + rows.Should().HaveCount(2); + rows.First(r => r.Key == "2026-05-28").Revenue.Should().Be(300m); + rows.First(r => r.Key == "2026-05-29").Revenue.Should().Be(50m); + } + + [Fact] + public async Task Groups_by_product() + { + var pa = Guid.NewGuid(); + var pb = Guid.NewGuid(); + var lines = new[] + { + new FlatSale(Guid.NewGuid(), DateTime.UtcNow, pa, "A", 100m, 1), + new FlatSale(Guid.NewGuid(), DateTime.UtcNow, pa, "A", 50m, 1), + new FlatSale(Guid.NewGuid(), DateTime.UtcNow, pb, "B", 300m, 2), + }; + var rows = await _h.Handle(new GetSalesReportQuery(lines, "product"), CancellationToken.None); + rows.Should().HaveCount(2); + rows.First(r => r.Key == pb.ToString()).Revenue.Should().Be(300m); + rows.First(r => r.Key == pa.ToString()).Revenue.Should().Be(150m); + } +} + +public class CreateSupplyHandlerTests +{ + [Fact] + public async Task Computes_total_calls_writer() + { + var writer = new FakeWriter(); + var h = new CreateSupplyHandler(writer); + var cmd = new CreateSupplyCommand( + Guid.NewGuid(), Guid.NewGuid(), Guid.NewGuid(), + DateTime.UtcNow, "test", + new[] + { + new CreateSupplyLine(Guid.NewGuid(), 2m, 100m), + new CreateSupplyLine(Guid.NewGuid(), 3m, 50m), + }); + var result = await h.Handle(cmd, CancellationToken.None); + result.Total.Should().Be(350m); // 2*100 + 3*50 + result.Number.Should().Be("П-2026-000001"); + writer.LastCreated.Should().NotBeNull(); + writer.LastCreated!.Value.lines.Should().HaveCount(2); + } + + private sealed class FakeWriter : ISupplyWriter + { + public (Guid id, decimal total, IReadOnlyList lines)? LastCreated; + + public Task NextNumberAsync(int year, CancellationToken ct) + => Task.FromResult($"П-{year}-000001"); + + public Task CreateAsync(Guid supplierId, Guid storeId, Guid currencyId, + DateTime date, string? notes, string number, + IReadOnlyList lines, decimal total, CancellationToken ct) + { + var id = Guid.NewGuid(); + LastCreated = (id, total, lines); + return Task.FromResult(id); + } + } +} + +public class PostRetailSaleHandlerTests +{ + [Fact] + public async Task Insufficient_payment_returns_error() + { + var poster = new FakePoster(); + var h = new PostRetailSaleHandler(poster); + var result = await h.Handle(new PostRetailSaleCommand( + Guid.NewGuid(), PaidCash: 100m, PaidCard: 0m, Total: 500m, + Lines: new[] { new PostSaleLine(Guid.NewGuid(), 1m, 500m) }), + CancellationToken.None); + result.Posted.Should().BeFalse(); + result.ErrorField.Should().Be("PaidCash"); + poster.Called.Should().BeFalse(); + } + + [Fact] + public async Task Empty_lines_returns_error() + { + var poster = new FakePoster(); + var h = new PostRetailSaleHandler(poster); + var result = await h.Handle(new PostRetailSaleCommand( + Guid.NewGuid(), 100m, 0m, 100m, Array.Empty()), + CancellationToken.None); + result.Posted.Should().BeFalse(); + result.ErrorField.Should().Be("lines"); + } + + [Fact] + public async Task Sufficient_payment_calls_poster() + { + var poster = new FakePoster(); + var h = new PostRetailSaleHandler(poster); + var result = await h.Handle(new PostRetailSaleCommand( + Guid.NewGuid(), 500m, 0m, 500m, + new[] { new PostSaleLine(Guid.NewGuid(), 1m, 500m) }), + CancellationToken.None); + result.Posted.Should().BeTrue(); + poster.Called.Should().BeTrue(); + } + + private sealed class FakePoster : IRetailSalePoster + { + public bool Called { get; private set; } + public Task PostAsync(Guid saleId, IReadOnlyList lines, CancellationToken ct) + { + Called = true; + return Task.CompletedTask; + } + } +}