using MediatR; using System.Data; using System.Data.Common; using System.Text.Json; 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; using TakeoutSaaS.Shared.Abstractions.Exceptions; namespace TakeoutSaaS.Application.App.Billings.Handlers; /// /// 查询账单详情处理器。 /// public sealed class GetBillingDetailQueryHandler( IDapperExecutor dapperExecutor) : IRequestHandler { /// /// 处理查询账单详情请求。 /// /// 查询命令。 /// 取消标记。 /// 账单详情 DTO。 public async Task Handle(GetBillingDetailQuery request, CancellationToken cancellationToken) { // 1. 查询账单 + 支付记录(同一连接,避免多次往返) return await dapperExecutor.QueryAsync( DatabaseConstants.AppDataSource, DatabaseConnectionRole.Read, async (connection, token) => { // 1.1 查询账单 await using var billCommand = CreateCommand( connection, BuildBillingSql(), [ ("billingId", request.BillingId) ]); await using var billReader = await billCommand.ExecuteReaderAsync(token); if (!await billReader.ReadAsync(token)) { throw new BusinessException(ErrorCodes.NotFound, "账单不存在"); } // 1.2 (空行后) 读取账单行数据到内存(释放 Reader,避免同连接并发执行命令) var billingId = billReader.GetInt64(0); var tenantId = billReader.GetInt64(1); var tenantName = billReader.IsDBNull(2) ? string.Empty : billReader.GetString(2); long? subscriptionId = billReader.IsDBNull(3) ? null : billReader.GetInt64(3); var statementNo = billReader.GetString(4); var billingType = (BillingType)billReader.GetInt32(5); var status = (TenantBillingStatus)billReader.GetInt32(6); var periodStart = billReader.GetDateTime(7); var periodEnd = billReader.GetDateTime(8); var amountDue = billReader.GetDecimal(9); var discountAmount = billReader.GetDecimal(10); var taxAmount = billReader.GetDecimal(11); var amountPaid = billReader.GetDecimal(12); var currency = billReader.IsDBNull(13) ? "CNY" : billReader.GetString(13); var dueDate = billReader.GetDateTime(14); DateTime? reminderSentAt = billReader.IsDBNull(15) ? null : billReader.GetDateTime(15); DateTime? overdueNotifiedAt = billReader.IsDBNull(16) ? null : billReader.GetDateTime(16); var notes = billReader.IsDBNull(17) ? null : billReader.GetString(17); var lineItemsJson = billReader.IsDBNull(18) ? null : billReader.GetString(18); var createdAt = billReader.GetDateTime(19); long? createdBy = billReader.IsDBNull(20) ? null : billReader.GetInt64(20); DateTime? updatedAt = billReader.IsDBNull(21) ? null : billReader.GetDateTime(21); long? updatedBy = billReader.IsDBNull(22) ? null : billReader.GetInt64(22); // 1.3 (空行后) 主动释放账单 Reader,确保后续查询不会触发 Npgsql 并发命令异常 await billReader.DisposeAsync(); // 1.4 (空行后) 反序列化账单明细 var lineItems = new List(); if (!string.IsNullOrWhiteSpace(lineItemsJson)) { try { lineItems = JsonSerializer.Deserialize>(lineItemsJson) ?? []; } catch { lineItems = []; } } // 1.5 (空行后) 查询支付记录 var payments = new List(); await using var paymentCommand = CreateCommand( connection, BuildPaymentsSql(), [ ("billingId", request.BillingId) ]); await using var paymentReader = await paymentCommand.ExecuteReaderAsync(token); while (await paymentReader.ReadAsync(token)) { payments.Add(new PaymentRecordDto { Id = paymentReader.GetInt64(0), TenantId = paymentReader.GetInt64(1), BillingId = paymentReader.GetInt64(2), Amount = paymentReader.GetDecimal(3), Method = (TenantPaymentMethod)paymentReader.GetInt32(4), Status = (TenantPaymentStatus)paymentReader.GetInt32(5), TransactionNo = paymentReader.IsDBNull(6) ? null : paymentReader.GetString(6), ProofUrl = paymentReader.IsDBNull(7) ? null : paymentReader.GetString(7), Notes = paymentReader.IsDBNull(8) ? null : paymentReader.GetString(8), VerifiedBy = paymentReader.IsDBNull(9) ? null : paymentReader.GetInt64(9), VerifiedAt = paymentReader.IsDBNull(10) ? null : paymentReader.GetDateTime(10), RefundReason = paymentReader.IsDBNull(11) ? null : paymentReader.GetString(11), RefundedAt = paymentReader.IsDBNull(12) ? null : paymentReader.GetDateTime(12), PaidAt = paymentReader.IsDBNull(13) ? null : paymentReader.GetDateTime(13), IsVerified = !paymentReader.IsDBNull(10), CreatedAt = paymentReader.GetDateTime(14) }); } // 1.6 (空行后) 组装详情 DTO var totalAmount = amountDue - discountAmount + taxAmount; return new BillingDetailDto { Id = billingId, TenantId = tenantId, TenantName = tenantName, SubscriptionId = subscriptionId, StatementNo = statementNo, BillingType = billingType, Status = status, PeriodStart = periodStart, PeriodEnd = periodEnd, AmountDue = amountDue, DiscountAmount = discountAmount, TaxAmount = taxAmount, TotalAmount = totalAmount, AmountPaid = amountPaid, Currency = currency, DueDate = dueDate, ReminderSentAt = reminderSentAt, OverdueNotifiedAt = overdueNotifiedAt, LineItemsJson = lineItemsJson, LineItems = lineItems, Payments = payments, Notes = notes, CreatedAt = createdAt, CreatedBy = createdBy, UpdatedAt = updatedAt, UpdatedBy = updatedBy }; }, cancellationToken); } private static string BuildBillingSql() { return """ select b."Id", b."TenantId", t."Name" as "TenantName", b."SubscriptionId", b."StatementNo", b."BillingType", b."Status", b."PeriodStart", b."PeriodEnd", b."AmountDue", b."DiscountAmount", b."TaxAmount", b."AmountPaid", b."Currency", b."DueDate", b."ReminderSentAt", b."OverdueNotifiedAt", b."Notes", b."LineItemsJson", b."CreatedAt", b."CreatedBy", b."UpdatedAt", b."UpdatedBy" from public.tenant_billing_statements b join public.tenants t on t."Id" = b."TenantId" and t."DeletedAt" is null where b."DeletedAt" is null and b."Id" = @billingId limit 1; """; } private static string BuildPaymentsSql() { return """ select p."Id", p."TenantId", p."BillingStatementId", p."Amount", p."Method", p."Status", p."TransactionNo", p."ProofUrl", p."Notes", p."VerifiedBy", p."VerifiedAt", p."RefundReason", p."RefundedAt", p."PaidAt", p."CreatedAt" from public.tenant_payments p where p."DeletedAt" is null and p."BillingStatementId" = @billingId order by p."CreatedAt" desc; """; } 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; } }