using Dapper; using Npgsql; namespace Modules.OMS.Orders.Search; public sealed class SearchOrdersHandler(NpgsqlDataSource dataSource) { public async Task HandleAsync(SearchOrdersRequest request, CancellationToken ct) { var page = Math.Max(request.Page, 1); var pageSize = Math.Clamp(request.PageSize, 1, 500); var offset = (page - 1) * pageSize; await using var connection = await dataSource.OpenConnectionAsync(ct); // Read model query: UI-required projection in one server-side query boundary. const string rowsSql = """ select o.id, o.order_no as OrderNo, ch.name as ChannelName, o.ordered_at as OrderedAt, o.customer_name as CustomerName, o.item_summary as ItemSummary, o.total_qty as TotalQty, o.amount as Amount, o.allocation_status as AllocationStatus, o.shipment_status as ShipmentStatus, o.exception_count as ExceptionCount from oms_order_search_projection o join sales_channel ch on ch.id = o.channel_id where o.ordered_at >= @From and o.ordered_at < @ToExclusive and (@ChannelId is null or o.channel_id = @ChannelId) and (@Status is null or o.shipment_status = @Status) and (@Keyword is null or o.search_text ilike '%' || @Keyword || '%') and (@ExceptionOnly = false or o.exception_count > 0) order by o.ordered_at desc, o.id desc limit @PageSize offset @Offset; """; const string summarySql = """ select count(*)::int as TotalCount, coalesce(sum(o.total_qty), 0) as TotalQty, coalesce(sum(o.amount), 0) as TotalAmount, count(*) filter (where o.order_status = 'NEW')::int as NewCount, count(*) filter (where o.shipment_status = 'READY')::int as ReadyToShipCount, count(*) filter (where o.exception_count > 0)::int as ExceptionCount from oms_order_search_projection o where o.ordered_at >= @From and o.ordered_at < @ToExclusive and (@ChannelId is null or o.channel_id = @ChannelId) and (@Status is null or o.shipment_status = @Status) and (@Keyword is null or o.search_text ilike '%' || @Keyword || '%') and (@ExceptionOnly = false or o.exception_count > 0); """; var args = new { From = request.From.ToDateTime(TimeOnly.MinValue), ToExclusive = request.To.AddDays(1).ToDateTime(TimeOnly.MinValue), request.ChannelId, Status = string.IsNullOrWhiteSpace(request.Status) ? null : request.Status, Keyword = string.IsNullOrWhiteSpace(request.Keyword) ? null : request.Keyword.Trim(), request.ExceptionOnly, PageSize = pageSize, Offset = offset, }; var rows = (await connection.QueryAsync( new CommandDefinition(rowsSql, args, cancellationToken: ct))).AsList(); var summary = await connection.QuerySingleAsync( new CommandDefinition(summarySql, args, cancellationToken: ct)); return new SearchOrdersResponse( rows, summary.TotalCount, summary.TotalQty, summary.TotalAmount, new OrderSearchCounters( summary.TotalCount, summary.NewCount, summary.ReadyToShipCount, summary.ExceptionCount)); } private sealed record SummaryRow( int TotalCount, decimal TotalQty, decimal TotalAmount, int NewCount, int ReadyToShipCount, int ExceptionCount); }