Подключён 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>
63 lines
2.8 KiB
C#
63 lines
2.8 KiB
C#
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);
|
|
}
|
|
}
|