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); } }