using MediatR; using System.Data; using System.Data.Common; using TakeoutSaaS.Application.App.Billings.Dto; using TakeoutSaaS.Application.App.Billings.Queries; using TakeoutSaaS.Domain.Tenants.Enums; using TakeoutSaaS.Shared.Abstractions.Constants; using TakeoutSaaS.Shared.Abstractions.Data; namespace TakeoutSaaS.Application.App.Billings.Handlers; /// /// 查询账单统计数据处理器。 /// public sealed class GetBillingStatisticsQueryHandler( IDapperExecutor dapperExecutor) : IRequestHandler { /// /// 处理查询账单统计数据请求。 /// /// 查询命令。 /// 取消标记。 /// 账单统计数据 DTO。 public async Task Handle(GetBillingStatisticsQuery request, CancellationToken cancellationToken) { // 1. 参数规范化 var startDate = request.StartDate ?? DateTime.UtcNow.AddMonths(-1); var endDate = request.EndDate ?? DateTime.UtcNow; var groupBy = NormalizeGroupBy(request.GroupBy); // 2. 查询统计数据(总览 + 趋势) return await dapperExecutor.QueryAsync( DatabaseConstants.AppDataSource, DatabaseConnectionRole.Read, async (connection, token) => { // 2.1 总览统计 await using var summaryCommand = CreateCommand( connection, BuildSummarySql(), [ ("tenantId", request.TenantId), ("startDate", startDate), ("endDate", endDate), ("now", DateTime.UtcNow) ]); await using var summaryReader = await summaryCommand.ExecuteReaderAsync(token); await summaryReader.ReadAsync(token); var totalCount = summaryReader.IsDBNull(0) ? 0 : summaryReader.GetInt32(0); var pendingCount = summaryReader.IsDBNull(1) ? 0 : summaryReader.GetInt32(1); var paidCount = summaryReader.IsDBNull(2) ? 0 : summaryReader.GetInt32(2); var overdueCount = summaryReader.IsDBNull(3) ? 0 : summaryReader.GetInt32(3); var cancelledCount = summaryReader.IsDBNull(4) ? 0 : summaryReader.GetInt32(4); var totalAmountDue = summaryReader.IsDBNull(5) ? 0m : summaryReader.GetDecimal(5); var totalAmountPaid = summaryReader.IsDBNull(6) ? 0m : summaryReader.GetDecimal(6); var totalAmountUnpaid = summaryReader.IsDBNull(7) ? 0m : summaryReader.GetDecimal(7); var totalOverdueAmount = summaryReader.IsDBNull(8) ? 0m : summaryReader.GetDecimal(8); // 2.2 趋势数据 await using var trendCommand = CreateCommand( connection, BuildTrendSql(groupBy), [ ("tenantId", request.TenantId), ("startDate", startDate), ("endDate", endDate) ]); await using var trendReader = await trendCommand.ExecuteReaderAsync(token); var amountDueTrend = new Dictionary(); var amountPaidTrend = new Dictionary(); var countTrend = new Dictionary(); while (await trendReader.ReadAsync(token)) { var bucket = trendReader.GetDateTime(0); var key = bucket.ToString("yyyy-MM-dd"); amountDueTrend[key] = trendReader.IsDBNull(1) ? 0m : trendReader.GetDecimal(1); amountPaidTrend[key] = trendReader.IsDBNull(2) ? 0m : trendReader.GetDecimal(2); countTrend[key] = trendReader.IsDBNull(3) ? 0 : trendReader.GetInt32(3); } // 2.3 组装 DTO return new BillingStatisticsDto { TenantId = request.TenantId, StartDate = startDate, EndDate = endDate, GroupBy = groupBy, TotalCount = totalCount, PendingCount = pendingCount, PaidCount = paidCount, OverdueCount = overdueCount, CancelledCount = cancelledCount, TotalAmountDue = totalAmountDue, TotalAmountPaid = totalAmountPaid, TotalAmountUnpaid = totalAmountUnpaid, TotalOverdueAmount = totalOverdueAmount, AmountDueTrend = amountDueTrend, AmountPaidTrend = amountPaidTrend, CountTrend = countTrend }; }, cancellationToken); } private static string NormalizeGroupBy(string? groupBy) { return groupBy?.Trim() switch { "Week" => "Week", "Month" => "Month", _ => "Day" }; } private static string BuildSummarySql() { return """ select count(*)::int as "TotalCount", coalesce(sum(case when b."Status" = 0 then 1 else 0 end), 0)::int as "PendingCount", coalesce(sum(case when b."Status" = 1 then 1 else 0 end), 0)::int as "PaidCount", coalesce(sum(case when b."Status" = 2 then 1 else 0 end), 0)::int as "OverdueCount", coalesce(sum(case when b."Status" = 3 then 1 else 0 end), 0)::int as "CancelledCount", coalesce(sum(b."AmountDue"), 0)::numeric as "TotalAmountDue", coalesce(sum(b."AmountPaid"), 0)::numeric as "TotalAmountPaid", coalesce(sum((b."AmountDue" - b."DiscountAmount" + b."TaxAmount") - b."AmountPaid"), 0)::numeric as "TotalAmountUnpaid", coalesce(sum( case when b."Status" in (0, 2) and b."DueDate" < @now then (b."AmountDue" - b."DiscountAmount" + b."TaxAmount") - b."AmountPaid" else 0 end ), 0)::numeric as "TotalOverdueAmount" from public.tenant_billing_statements b where b."DeletedAt" is null and (@tenantId::bigint is null or b."TenantId" = @tenantId) and b."PeriodStart" >= @startDate and b."PeriodEnd" <= @endDate; """; } private static string BuildTrendSql(string groupBy) { var dateTrunc = groupBy switch { "Week" => "week", "Month" => "month", _ => "day" }; return $""" select date_trunc('{dateTrunc}', b."PeriodStart") as "Bucket", coalesce(sum(b."AmountDue"), 0)::numeric as "AmountDue", coalesce(sum(b."AmountPaid"), 0)::numeric as "AmountPaid", count(*)::int as "Count" from public.tenant_billing_statements b where b."DeletedAt" is null and (@tenantId::bigint is null or b."TenantId" = @tenantId) and b."PeriodStart" >= @startDate and b."PeriodEnd" <= @endDate group by 1 order by 1 asc; """; } private static DbCommand CreateCommand(IDbConnection connection, string sql, (string Name, object? Value)[] parameters) { var command = connection.CreateCommand(); command.CommandText = sql; foreach (var (name, value) in parameters) { var p = command.CreateParameter(); p.ParameterName = name; p.Value = value ?? DBNull.Value; command.Parameters.Add(p); } return (DbCommand)command; } }