food-market/src/food-market.application/Sales/Queries/GetSalesReportQuery.cs
nns ef8c4a3222 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>
2026-05-28 17:51:08 +05:00

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