feat(cqrs): MediatR partial — 3 handler-образца (TD-1)
Подключён 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 <noreply@anthropic.com>
This commit is contained in:
parent
77c7bb52d1
commit
ef8c4a3222
|
|
@ -168,6 +168,12 @@
|
||||||
// см. Resources/EmailTemplates/*.html. Singleton с in-memory cache.
|
// см. Resources/EmailTemplates/*.html. Singleton с in-memory cache.
|
||||||
builder.Services.AddSingleton<foodmarket.Api.Infrastructure.Email.EmailTemplates>();
|
builder.Services.AddSingleton<foodmarket.Api.Infrastructure.Email.EmailTemplates>();
|
||||||
builder.Services.AddDataProtection();
|
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.
|
// FluentValidation: автоматическая регистрация валидаторов из сборки api.
|
||||||
// ValidationFilter гоняет валидаторы на каждом контроллер-action перед
|
// ValidationFilter гоняет валидаторы на каждом контроллер-action перед
|
||||||
// вызовом — fail возвращает 400 ValidationProblemDetails (RFC 7807).
|
// вызовом — fail возвращает 400 ValidationProblemDetails (RFC 7807).
|
||||||
|
|
|
||||||
|
|
@ -25,6 +25,7 @@
|
||||||
<PackageReference Include="CsvHelper" />
|
<PackageReference Include="CsvHelper" />
|
||||||
<PackageReference Include="ClosedXML" />
|
<PackageReference Include="ClosedXML" />
|
||||||
<PackageReference Include="prometheus-net.AspNetCore" />
|
<PackageReference Include="prometheus-net.AspNetCore" />
|
||||||
|
<PackageReference Include="MediatR" />
|
||||||
<PackageReference Include="Microsoft.EntityFrameworkCore.Design" />
|
<PackageReference Include="Microsoft.EntityFrameworkCore.Design" />
|
||||||
</ItemGroup>
|
</ItemGroup>
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,49 @@
|
||||||
|
using MediatR;
|
||||||
|
|
||||||
|
namespace foodmarket.Application.Purchases.Commands;
|
||||||
|
|
||||||
|
/// <summary>CQRS-образец №2 (TD-1): команда создания приёмки (Draft).
|
||||||
|
/// Handler принимает input + контракт <see cref="ISupplyWriter"/> и делегирует
|
||||||
|
/// фактическое сохранение — handler не зависит от EF напрямую, что
|
||||||
|
/// упрощает unit-тестирование.
|
||||||
|
///
|
||||||
|
/// Покрывает только Draft-создание; Posting остаётся в контроллере (где
|
||||||
|
/// stock-логика и serializable-транзакция). Это сознательное «partial»:
|
||||||
|
/// показываем паттерн, не переписываем всё.</summary>
|
||||||
|
public record CreateSupplyCommand(
|
||||||
|
Guid SupplierId, Guid StoreId, Guid CurrencyId,
|
||||||
|
DateTime Date, string? Notes,
|
||||||
|
IReadOnlyList<CreateSupplyLine> Lines) : IRequest<CreateSupplyResult>;
|
||||||
|
|
||||||
|
public record CreateSupplyLine(Guid ProductId, decimal Quantity, decimal UnitPrice);
|
||||||
|
|
||||||
|
public record CreateSupplyResult(Guid Id, string Number, decimal Total);
|
||||||
|
|
||||||
|
/// <summary>Абстракция над персистентностью: позволяет тестировать handler
|
||||||
|
/// без EF/Postgres (in-memory implementation в тестах).</summary>
|
||||||
|
public interface ISupplyWriter
|
||||||
|
{
|
||||||
|
Task<string> NextNumberAsync(int year, CancellationToken ct);
|
||||||
|
Task<Guid> CreateAsync(Guid supplierId, Guid storeId, Guid currencyId,
|
||||||
|
DateTime date, string? notes, string number,
|
||||||
|
IReadOnlyList<CreateSupplyLine> lines, decimal total,
|
||||||
|
CancellationToken ct);
|
||||||
|
}
|
||||||
|
|
||||||
|
public sealed class CreateSupplyHandler : IRequestHandler<CreateSupplyCommand, CreateSupplyResult>
|
||||||
|
{
|
||||||
|
private readonly ISupplyWriter _writer;
|
||||||
|
public CreateSupplyHandler(ISupplyWriter writer) => _writer = writer;
|
||||||
|
|
||||||
|
public async Task<CreateSupplyResult> 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,48 @@
|
||||||
|
using MediatR;
|
||||||
|
|
||||||
|
namespace foodmarket.Application.Sales.Commands;
|
||||||
|
|
||||||
|
/// <summary>CQRS-образец №3 (TD-1): команда проведения чека. Handler
|
||||||
|
/// инкапсулирует валидацию платежа (вынесенную в Application слое уже
|
||||||
|
/// раньше как RetailPaymentValidator) и делегирует stock-операцию
|
||||||
|
/// абстракции IRetailSalePoster.
|
||||||
|
///
|
||||||
|
/// Цель — показать, что Post можно тестировать чисто (без БД, без
|
||||||
|
/// контроллера), проверяя бизнес-логику payment-validation + invocation
|
||||||
|
/// stock writer'а. Сам RetailSalesController остаётся как образец до-CQRS
|
||||||
|
/// контроллера (рефакторинг — отдельный спринт).</summary>
|
||||||
|
public record PostRetailSaleCommand(
|
||||||
|
Guid SaleId,
|
||||||
|
decimal PaidCash,
|
||||||
|
decimal PaidCard,
|
||||||
|
decimal Total,
|
||||||
|
IReadOnlyList<PostSaleLine> Lines) : IRequest<PostRetailSaleResult>;
|
||||||
|
|
||||||
|
public record PostSaleLine(Guid ProductId, decimal Quantity, decimal UnitPrice);
|
||||||
|
|
||||||
|
public record PostRetailSaleResult(bool Posted, string? ErrorMessage, string? ErrorField);
|
||||||
|
|
||||||
|
/// <summary>Абстракция: списать стоки и пометить чек проведённым.</summary>
|
||||||
|
public interface IRetailSalePoster
|
||||||
|
{
|
||||||
|
Task PostAsync(Guid saleId, IReadOnlyList<PostSaleLine> lines, CancellationToken ct);
|
||||||
|
}
|
||||||
|
|
||||||
|
public sealed class PostRetailSaleHandler : IRequestHandler<PostRetailSaleCommand, PostRetailSaleResult>
|
||||||
|
{
|
||||||
|
private readonly IRetailSalePoster _poster;
|
||||||
|
public PostRetailSaleHandler(IRetailSalePoster poster) => _poster = poster;
|
||||||
|
|
||||||
|
public async Task<PostRetailSaleResult> 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,62 @@
|
||||||
|
using System.Globalization;
|
||||||
|
using MediatR;
|
||||||
|
|
||||||
|
namespace foodmarket.Application.Sales.Queries;
|
||||||
|
|
||||||
|
/// <summary>CQRS-образец №1 (TD-1): чистая логика агрегации продаж в C#
|
||||||
|
/// (без БД), пригодная для unit-тестирования без TestContainers. Сам
|
||||||
|
/// контроллер `/api/reports/sales` остаётся как было (pull-фласк +
|
||||||
|
/// группировка в C#) — этот handler — refactor-демонстрация на пути к
|
||||||
|
/// CQRS-разделению.
|
||||||
|
///
|
||||||
|
/// Принимает уже подготовленные «плоские строки» (FlatSale) и параметры
|
||||||
|
/// группировки/окна, возвращает агрегированный список. Идемпотентен,
|
||||||
|
/// safe для параллельного вызова.</summary>
|
||||||
|
public record GetSalesReportQuery(
|
||||||
|
IReadOnlyList<FlatSale> Lines,
|
||||||
|
string GroupBy = "period:day",
|
||||||
|
DateTime? From = null,
|
||||||
|
DateTime? To = null) : IRequest<IReadOnlyList<SalesReportRow>>;
|
||||||
|
|
||||||
|
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<GetSalesReportQuery, IReadOnlyList<SalesReportRow>>
|
||||||
|
{
|
||||||
|
public Task<IReadOnlyList<SalesReportRow>> 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<IGrouping<(string Key, string Label), FlatSale>> 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<IGrouping<(string, string), FlatSale>>(),
|
||||||
|
};
|
||||||
|
|
||||||
|
IReadOnlyList<SalesReportRow> 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
138
tests/food-market.UnitTests/MediatrHandlerTests.cs
Normal file
138
tests/food-market.UnitTests/MediatrHandlerTests.cs
Normal file
|
|
@ -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<CreateSupplyLine> lines)? LastCreated;
|
||||||
|
|
||||||
|
public Task<string> NextNumberAsync(int year, CancellationToken ct)
|
||||||
|
=> Task.FromResult($"П-{year}-000001");
|
||||||
|
|
||||||
|
public Task<Guid> CreateAsync(Guid supplierId, Guid storeId, Guid currencyId,
|
||||||
|
DateTime date, string? notes, string number,
|
||||||
|
IReadOnlyList<CreateSupplyLine> 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<PostSaleLine>()),
|
||||||
|
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<PostSaleLine> lines, CancellationToken ct)
|
||||||
|
{
|
||||||
|
Called = true;
|
||||||
|
return Task.CompletedTask;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
Loading…
Reference in a new issue