✨ feat: 完成账单管理模块后端功能开发及API优化
核心功能:
- 账单CRUD操作(创建、查询、详情、更新状态、删除)
- 支付记录管理(创建支付、审核支付)
- 批量操作支持(批量更新账单状态)
- 统计分析功能(账单统计、逾期账单查询)
- 导出功能(Excel/PDF/CSV)
API端点 (16个):
- GET /api/admin/v1/billings - 账单列表(分页、筛选、排序)
- POST /api/admin/v1/billings - 创建账单
- GET /api/admin/v1/billings/{id} - 账单详情
- DELETE /api/admin/v1/billings/{id} - 删除账单
- PUT /api/admin/v1/billings/{id}/status - 更新状态
- POST /api/admin/v1/billings/batch/status - 批量更新
- GET /api/admin/v1/billings/{id}/payments - 支付记录
- POST /api/admin/v1/billings/{id}/payments - 创建支付
- PUT /api/admin/v1/billings/payments/{paymentId}/verify - 审核支付
- GET /api/admin/v1/billings/statistics - 统计数据
- GET /api/admin/v1/billings/overdue - 逾期账单
- POST /api/admin/v1/billings/export - 导出账单
架构优化:
- 采用CQRS模式分离读写(MediatR + Dapper + EF Core)
- 完整的领域模型设计(TenantBillingStatement, TenantPayment等)
- FluentValidation请求验证
- 状态机管理账单和支付状态流转
API设计优化 (三项改进):
1. 导出API响应Content-Type改为application/octet-stream
2. 支付审核API添加Approved和Notes可选参数,支持通过/拒绝
3. 移除TenantBillings API中重复的TenantId参数
数据库变更:
- 新增账单相关表及关系
- 支持Snowflake ID主键
- 完整的审计字段支持
🤖 Generated with Claude Code
Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,88 @@
|
||||
using MediatR;
|
||||
using TakeoutSaaS.Application.App.Billings.Commands;
|
||||
using TakeoutSaaS.Domain.Tenants.Repositories;
|
||||
using TakeoutSaaS.Shared.Abstractions.Constants;
|
||||
using TakeoutSaaS.Shared.Abstractions.Exceptions;
|
||||
|
||||
namespace TakeoutSaaS.Application.App.Billings.Handlers;
|
||||
|
||||
/// <summary>
|
||||
/// 批量更新账单状态处理器。
|
||||
/// </summary>
|
||||
public sealed class BatchUpdateStatusCommandHandler(
|
||||
ITenantBillingRepository billingRepository)
|
||||
: IRequestHandler<BatchUpdateStatusCommand, int>
|
||||
{
|
||||
/// <summary>
|
||||
/// 处理批量更新账单状态请求。
|
||||
/// </summary>
|
||||
/// <param name="request">批量更新状态命令。</param>
|
||||
/// <param name="cancellationToken">取消标记。</param>
|
||||
/// <returns>成功更新的账单数量。</returns>
|
||||
public async Task<int> Handle(BatchUpdateStatusCommand request, CancellationToken cancellationToken)
|
||||
{
|
||||
// 1. 参数验证
|
||||
if (request.BillingIds.Length == 0)
|
||||
{
|
||||
throw new BusinessException(ErrorCodes.BadRequest, "账单 ID 列表不能为空");
|
||||
}
|
||||
|
||||
// 2. 查询所有账单
|
||||
var billings = await billingRepository.GetByIdsAsync(request.BillingIds, cancellationToken);
|
||||
if (billings.Count == 0)
|
||||
{
|
||||
throw new BusinessException(ErrorCodes.NotFound, "未找到任何匹配的账单");
|
||||
}
|
||||
|
||||
// 3. 批量更新状态
|
||||
var now = DateTime.UtcNow;
|
||||
var updatedCount = 0;
|
||||
foreach (var billing in billings)
|
||||
{
|
||||
// 业务规则检查:某些状态转换可能不允许
|
||||
if (CanTransitionStatus(billing.Status, request.NewStatus))
|
||||
{
|
||||
billing.Status = request.NewStatus;
|
||||
billing.UpdatedAt = now;
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(request.Notes))
|
||||
{
|
||||
billing.Notes = string.IsNullOrWhiteSpace(billing.Notes)
|
||||
? $"[批量操作] {request.Notes}"
|
||||
: $"{billing.Notes}\n[批量操作] {request.Notes}";
|
||||
}
|
||||
|
||||
await billingRepository.UpdateAsync(billing, cancellationToken);
|
||||
updatedCount++;
|
||||
}
|
||||
}
|
||||
|
||||
// 4. 持久化变更
|
||||
await billingRepository.SaveChangesAsync(cancellationToken);
|
||||
|
||||
return updatedCount;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 检查状态转换是否允许。
|
||||
/// </summary>
|
||||
private static bool CanTransitionStatus(
|
||||
TakeoutSaaS.Domain.Tenants.Enums.TenantBillingStatus currentStatus,
|
||||
TakeoutSaaS.Domain.Tenants.Enums.TenantBillingStatus newStatus)
|
||||
{
|
||||
// 已支付的账单不能改为其他状态
|
||||
if (currentStatus == TakeoutSaaS.Domain.Tenants.Enums.TenantBillingStatus.Paid
|
||||
&& newStatus != TakeoutSaaS.Domain.Tenants.Enums.TenantBillingStatus.Paid)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
// 已取消的账单不能改为其他状态
|
||||
if (currentStatus == TakeoutSaaS.Domain.Tenants.Enums.TenantBillingStatus.Cancelled)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
using MediatR;
|
||||
using TakeoutSaaS.Application.App.Billings.Commands;
|
||||
using TakeoutSaaS.Domain.Tenants.Repositories;
|
||||
using TakeoutSaaS.Shared.Abstractions.Constants;
|
||||
using TakeoutSaaS.Shared.Abstractions.Exceptions;
|
||||
|
||||
namespace TakeoutSaaS.Application.App.Billings.Handlers;
|
||||
|
||||
/// <summary>
|
||||
/// 取消账单命令处理器。
|
||||
/// </summary>
|
||||
public sealed class CancelBillingCommandHandler(
|
||||
ITenantBillingRepository billingRepository)
|
||||
: IRequestHandler<CancelBillingCommand, Unit>
|
||||
{
|
||||
/// <inheritdoc />
|
||||
public async Task<Unit> Handle(CancelBillingCommand request, CancellationToken cancellationToken)
|
||||
{
|
||||
// 1. 查询账单
|
||||
var billing = await billingRepository.FindByIdAsync(request.BillingId, cancellationToken);
|
||||
if (billing is null)
|
||||
{
|
||||
throw new BusinessException(ErrorCodes.NotFound, "账单不存在");
|
||||
}
|
||||
|
||||
// 2. (空行后) 取消账单(领域规则校验在实体方法内)
|
||||
billing.Cancel(request.Reason);
|
||||
|
||||
// 3. (空行后) 持久化
|
||||
await billingRepository.UpdateAsync(billing, cancellationToken);
|
||||
await billingRepository.SaveChangesAsync(cancellationToken);
|
||||
|
||||
return Unit.Value;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,65 @@
|
||||
using MediatR;
|
||||
using System.Text.Json;
|
||||
using TakeoutSaaS.Application.App.Billings.Commands;
|
||||
using TakeoutSaaS.Application.App.Billings.Dto;
|
||||
using TakeoutSaaS.Domain.Tenants.Entities;
|
||||
using TakeoutSaaS.Domain.Tenants.Enums;
|
||||
using TakeoutSaaS.Domain.Tenants.Repositories;
|
||||
using TakeoutSaaS.Shared.Abstractions.Constants;
|
||||
using TakeoutSaaS.Shared.Abstractions.Exceptions;
|
||||
using TakeoutSaaS.Shared.Abstractions.Ids;
|
||||
|
||||
namespace TakeoutSaaS.Application.App.Billings.Handlers;
|
||||
|
||||
/// <summary>
|
||||
/// 创建账单命令处理器。
|
||||
/// </summary>
|
||||
public sealed class CreateBillingCommandHandler(
|
||||
ITenantRepository tenantRepository,
|
||||
ITenantBillingRepository billingRepository,
|
||||
IIdGenerator idGenerator)
|
||||
: IRequestHandler<CreateBillingCommand, BillingDetailDto>
|
||||
{
|
||||
/// <inheritdoc />
|
||||
public async Task<BillingDetailDto> Handle(CreateBillingCommand request, CancellationToken cancellationToken)
|
||||
{
|
||||
// 1. 校验租户存在
|
||||
var tenant = await tenantRepository.FindByIdAsync(request.TenantId, cancellationToken);
|
||||
if (tenant is null)
|
||||
{
|
||||
throw new BusinessException(ErrorCodes.NotFound, "租户不存在");
|
||||
}
|
||||
|
||||
// 2. (空行后) 构建账单实体
|
||||
var now = DateTime.UtcNow;
|
||||
var statementNo = $"BIL-{now:yyyyMMdd}-{idGenerator.NextId()}";
|
||||
var lineItemsJson = JsonSerializer.Serialize(request.LineItems);
|
||||
|
||||
var billing = new TenantBillingStatement
|
||||
{
|
||||
TenantId = request.TenantId,
|
||||
StatementNo = statementNo,
|
||||
BillingType = request.BillingType,
|
||||
SubscriptionId = null,
|
||||
PeriodStart = now,
|
||||
PeriodEnd = now,
|
||||
AmountDue = request.AmountDue,
|
||||
DiscountAmount = 0m,
|
||||
TaxAmount = 0m,
|
||||
AmountPaid = 0m,
|
||||
Currency = "CNY",
|
||||
Status = TenantBillingStatus.Pending,
|
||||
DueDate = request.DueDate,
|
||||
LineItemsJson = lineItemsJson,
|
||||
Notes = request.Notes
|
||||
};
|
||||
|
||||
// 3. (空行后) 持久化账单
|
||||
await billingRepository.AddAsync(billing, cancellationToken);
|
||||
await billingRepository.SaveChangesAsync(cancellationToken);
|
||||
|
||||
// 4. (空行后) 返回详情 DTO
|
||||
return billing.ToBillingDetailDto([], tenant.Name);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,44 @@
|
||||
using MediatR;
|
||||
using TakeoutSaaS.Application.App.Billings.Queries;
|
||||
using TakeoutSaaS.Domain.Tenants.Repositories;
|
||||
using TakeoutSaaS.Domain.Tenants.Services;
|
||||
using TakeoutSaaS.Shared.Abstractions.Constants;
|
||||
using TakeoutSaaS.Shared.Abstractions.Exceptions;
|
||||
|
||||
namespace TakeoutSaaS.Application.App.Billings.Handlers;
|
||||
|
||||
/// <summary>
|
||||
/// 导出账单处理器。
|
||||
/// </summary>
|
||||
public sealed class ExportBillingsQueryHandler(
|
||||
ITenantBillingRepository billingRepository,
|
||||
IBillingExportService exportService)
|
||||
: IRequestHandler<ExportBillingsQuery, byte[]>
|
||||
{
|
||||
/// <inheritdoc />
|
||||
public async Task<byte[]> Handle(ExportBillingsQuery request, CancellationToken cancellationToken)
|
||||
{
|
||||
// 1. 参数验证
|
||||
if (request.BillingIds.Length == 0)
|
||||
{
|
||||
throw new BusinessException(ErrorCodes.BadRequest, "账单 ID 列表不能为空");
|
||||
}
|
||||
|
||||
// 2. (空行后) 查询账单数据
|
||||
var billings = await billingRepository.GetByIdsAsync(request.BillingIds, cancellationToken);
|
||||
if (billings.Count == 0)
|
||||
{
|
||||
throw new BusinessException(ErrorCodes.NotFound, "未找到任何匹配的账单");
|
||||
}
|
||||
|
||||
// 3. (空行后) 根据格式导出
|
||||
var format = (request.Format ?? string.Empty).Trim().ToLowerInvariant();
|
||||
return format switch
|
||||
{
|
||||
"excel" or "xlsx" => await exportService.ExportToExcelAsync(billings, cancellationToken),
|
||||
"pdf" => await exportService.ExportToPdfAsync(billings, cancellationToken),
|
||||
"csv" => await exportService.ExportToCsvAsync(billings, cancellationToken),
|
||||
_ => throw new BusinessException(ErrorCodes.BadRequest, $"不支持的导出格式: {request.Format}")
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,102 @@
|
||||
using MediatR;
|
||||
using System.Text.Json;
|
||||
using TakeoutSaaS.Application.App.Billings.Commands;
|
||||
using TakeoutSaaS.Application.App.Billings.Dto;
|
||||
using TakeoutSaaS.Domain.Tenants.Entities;
|
||||
using TakeoutSaaS.Domain.Tenants.Enums;
|
||||
using TakeoutSaaS.Domain.Tenants.Repositories;
|
||||
using TakeoutSaaS.Shared.Abstractions.Constants;
|
||||
using TakeoutSaaS.Shared.Abstractions.Exceptions;
|
||||
using TakeoutSaaS.Shared.Abstractions.Ids;
|
||||
|
||||
namespace TakeoutSaaS.Application.App.Billings.Handlers;
|
||||
|
||||
/// <summary>
|
||||
/// 生成订阅账单命令处理器。
|
||||
/// </summary>
|
||||
public sealed class GenerateSubscriptionBillingCommandHandler(
|
||||
ISubscriptionRepository subscriptionRepository,
|
||||
ITenantBillingRepository billingRepository,
|
||||
IIdGenerator idGenerator)
|
||||
: IRequestHandler<GenerateSubscriptionBillingCommand, BillingDetailDto>
|
||||
{
|
||||
/// <inheritdoc />
|
||||
public async Task<BillingDetailDto> Handle(GenerateSubscriptionBillingCommand request, CancellationToken cancellationToken)
|
||||
{
|
||||
// 1. 查询订阅详情(含租户/套餐信息)
|
||||
var detail = await subscriptionRepository.GetDetailAsync(request.SubscriptionId, cancellationToken);
|
||||
if (detail is null)
|
||||
{
|
||||
throw new BusinessException(ErrorCodes.NotFound, "订阅不存在");
|
||||
}
|
||||
|
||||
// 2. (空行后) 校验套餐价格信息
|
||||
var subscription = detail.Subscription;
|
||||
var package = detail.Package;
|
||||
if (package is null)
|
||||
{
|
||||
throw new BusinessException(ErrorCodes.BusinessError, "订阅未关联有效套餐,无法生成账单");
|
||||
}
|
||||
|
||||
// 3. (空行后) 按订阅周期选择价格(简化规则:优先按年/按月)
|
||||
var billingPeriodDays = (subscription.EffectiveTo - subscription.EffectiveFrom).TotalDays;
|
||||
var amountDue = billingPeriodDays >= 300
|
||||
? package.YearlyPrice
|
||||
: package.MonthlyPrice;
|
||||
|
||||
if (!amountDue.HasValue)
|
||||
{
|
||||
throw new BusinessException(ErrorCodes.BusinessError, "套餐价格未配置,无法生成账单");
|
||||
}
|
||||
|
||||
// 4. (空行后) 幂等校验:同一周期开始时间仅允许存在一张未取消账单
|
||||
var exists = await billingRepository.ExistsNotCancelledByPeriodStartAsync(subscription.TenantId, subscription.EffectiveFrom, cancellationToken);
|
||||
if (exists)
|
||||
{
|
||||
throw new BusinessException(ErrorCodes.Conflict, "该订阅周期的账单已存在");
|
||||
}
|
||||
|
||||
// 5. (空行后) 构建账单实体
|
||||
var now = DateTime.UtcNow;
|
||||
var statementNo = $"BIL-{now:yyyyMMdd}-{idGenerator.NextId()}";
|
||||
var lineItems = new List<BillingLineItemDto>
|
||||
{
|
||||
new()
|
||||
{
|
||||
ItemType = "Subscription",
|
||||
Description = $"套餐 {package.Name} 订阅费用",
|
||||
Quantity = 1,
|
||||
UnitPrice = amountDue.Value,
|
||||
Amount = amountDue.Value,
|
||||
DiscountRate = null
|
||||
}
|
||||
};
|
||||
|
||||
var billing = new TenantBillingStatement
|
||||
{
|
||||
TenantId = subscription.TenantId,
|
||||
StatementNo = statementNo,
|
||||
BillingType = BillingType.Subscription,
|
||||
SubscriptionId = subscription.Id,
|
||||
PeriodStart = subscription.EffectiveFrom,
|
||||
PeriodEnd = subscription.EffectiveTo,
|
||||
AmountDue = amountDue.Value,
|
||||
DiscountAmount = 0m,
|
||||
TaxAmount = 0m,
|
||||
AmountPaid = 0m,
|
||||
Currency = "CNY",
|
||||
Status = TenantBillingStatus.Pending,
|
||||
DueDate = now.AddDays(7),
|
||||
LineItemsJson = JsonSerializer.Serialize(lineItems),
|
||||
Notes = subscription.Notes
|
||||
};
|
||||
|
||||
// 6. (空行后) 持久化账单
|
||||
await billingRepository.AddAsync(billing, cancellationToken);
|
||||
await billingRepository.SaveChangesAsync(cancellationToken);
|
||||
|
||||
// 7. (空行后) 返回详情 DTO
|
||||
return billing.ToBillingDetailDto([], detail.TenantName);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -28,6 +28,8 @@ public sealed class GetBillListQueryHandler(
|
||||
request.Status,
|
||||
request.StartDate,
|
||||
request.EndDate,
|
||||
null,
|
||||
null,
|
||||
request.Keyword,
|
||||
request.PageNumber,
|
||||
request.PageSize,
|
||||
|
||||
@@ -0,0 +1,218 @@
|
||||
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;
|
||||
|
||||
/// <summary>
|
||||
/// 查询账单详情处理器。
|
||||
/// </summary>
|
||||
public sealed class GetBillingDetailQueryHandler(
|
||||
IDapperExecutor dapperExecutor)
|
||||
: IRequestHandler<GetBillingDetailQuery, BillingDetailDto>
|
||||
{
|
||||
/// <summary>
|
||||
/// 处理查询账单详情请求。
|
||||
/// </summary>
|
||||
/// <param name="request">查询命令。</param>
|
||||
/// <param name="cancellationToken">取消标记。</param>
|
||||
/// <returns>账单详情 DTO。</returns>
|
||||
public async Task<BillingDetailDto> 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, "账单不存在");
|
||||
}
|
||||
|
||||
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);
|
||||
long? createdBy = billReader.IsDBNull(20) ? null : billReader.GetInt64(20);
|
||||
long? updatedBy = billReader.IsDBNull(22) ? null : billReader.GetInt64(22);
|
||||
|
||||
// 1.2 (空行后) 反序列化账单明细
|
||||
var lineItems = new List<BillingLineItemDto>();
|
||||
if (!string.IsNullOrWhiteSpace(lineItemsJson))
|
||||
{
|
||||
try
|
||||
{
|
||||
lineItems = JsonSerializer.Deserialize<List<BillingLineItemDto>>(lineItemsJson) ?? [];
|
||||
}
|
||||
catch
|
||||
{
|
||||
lineItems = [];
|
||||
}
|
||||
}
|
||||
|
||||
// 1.3 (空行后) 查询支付记录
|
||||
var payments = new List<PaymentRecordDto>();
|
||||
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.4 (空行后) 组装详情 DTO
|
||||
var amountDue = billReader.GetDecimal(9);
|
||||
var discountAmount = billReader.GetDecimal(10);
|
||||
var taxAmount = billReader.GetDecimal(11);
|
||||
var totalAmount = amountDue - discountAmount + taxAmount;
|
||||
|
||||
return new BillingDetailDto
|
||||
{
|
||||
Id = billReader.GetInt64(0),
|
||||
TenantId = billReader.GetInt64(1),
|
||||
TenantName = billReader.IsDBNull(2) ? string.Empty : billReader.GetString(2),
|
||||
SubscriptionId = billReader.IsDBNull(3) ? null : billReader.GetInt64(3),
|
||||
StatementNo = billReader.GetString(4),
|
||||
BillingType = (BillingType)billReader.GetInt32(5),
|
||||
Status = (TenantBillingStatus)billReader.GetInt32(6),
|
||||
PeriodStart = billReader.GetDateTime(7),
|
||||
PeriodEnd = billReader.GetDateTime(8),
|
||||
AmountDue = billReader.GetDecimal(9),
|
||||
DiscountAmount = billReader.GetDecimal(10),
|
||||
TaxAmount = billReader.GetDecimal(11),
|
||||
TotalAmount = totalAmount,
|
||||
AmountPaid = billReader.GetDecimal(12),
|
||||
Currency = billReader.IsDBNull(13) ? "CNY" : billReader.GetString(13),
|
||||
DueDate = billReader.GetDateTime(14),
|
||||
ReminderSentAt = reminderSentAt,
|
||||
OverdueNotifiedAt = overdueNotifiedAt,
|
||||
LineItemsJson = lineItemsJson,
|
||||
LineItems = lineItems,
|
||||
Payments = payments,
|
||||
Notes = notes,
|
||||
CreatedAt = billReader.GetDateTime(19),
|
||||
CreatedBy = createdBy,
|
||||
UpdatedAt = billReader.IsDBNull(21) ? null : billReader.GetDateTime(21),
|
||||
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;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,233 @@
|
||||
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;
|
||||
using TakeoutSaaS.Shared.Abstractions.Results;
|
||||
|
||||
namespace TakeoutSaaS.Application.App.Billings.Handlers;
|
||||
|
||||
/// <summary>
|
||||
/// 分页查询账单列表处理器。
|
||||
/// </summary>
|
||||
public sealed class GetBillingListQueryHandler(
|
||||
IDapperExecutor dapperExecutor)
|
||||
: IRequestHandler<GetBillingListQuery, PagedResult<BillingListDto>>
|
||||
{
|
||||
/// <summary>
|
||||
/// 处理分页查询账单列表请求。
|
||||
/// </summary>
|
||||
/// <param name="request">查询命令。</param>
|
||||
/// <param name="cancellationToken">取消标记。</param>
|
||||
/// <returns>分页账单列表 DTO。</returns>
|
||||
public async Task<PagedResult<BillingListDto>> Handle(GetBillingListQuery request, CancellationToken cancellationToken)
|
||||
{
|
||||
// 1. 参数规范化
|
||||
var page = request.PageNumber <= 0 ? 1 : request.PageNumber;
|
||||
var pageSize = request.PageSize is <= 0 or > 200 ? 20 : request.PageSize;
|
||||
var keyword = string.IsNullOrWhiteSpace(request.Keyword) ? null : request.Keyword.Trim();
|
||||
var minAmount = request.MinAmount;
|
||||
var maxAmount = request.MaxAmount;
|
||||
var offset = (page - 1) * pageSize;
|
||||
|
||||
// 1.1 (空行后) 金额区间规范化(避免 min > max 导致结果为空)
|
||||
if (minAmount.HasValue && maxAmount.HasValue && minAmount.Value > maxAmount.Value)
|
||||
{
|
||||
(minAmount, maxAmount) = (maxAmount, minAmount);
|
||||
}
|
||||
|
||||
// 2. (空行后) 排序白名单(防 SQL 注入)
|
||||
var orderBy = request.SortBy?.Trim() switch
|
||||
{
|
||||
"DueDate" => "b.\"DueDate\"",
|
||||
"AmountDue" => "b.\"AmountDue\"",
|
||||
"PeriodStart" => "b.\"PeriodStart\"",
|
||||
"PeriodEnd" => "b.\"PeriodEnd\"",
|
||||
"CreatedAt" => "b.\"CreatedAt\"",
|
||||
_ => "b.\"CreatedAt\""
|
||||
};
|
||||
|
||||
// 3. (空行后) 查询总数 + 列表
|
||||
return await dapperExecutor.QueryAsync(
|
||||
DatabaseConstants.AppDataSource,
|
||||
DatabaseConnectionRole.Read,
|
||||
async (connection, token) =>
|
||||
{
|
||||
// 3.1 统计总数
|
||||
var total = await ExecuteScalarIntAsync(
|
||||
connection,
|
||||
BuildCountSql(),
|
||||
[
|
||||
("tenantId", request.TenantId),
|
||||
("status", request.Status.HasValue ? (int)request.Status.Value : null),
|
||||
("billingType", request.BillingType.HasValue ? (int)request.BillingType.Value : null),
|
||||
("startDate", request.StartDate),
|
||||
("endDate", request.EndDate),
|
||||
("minAmount", minAmount),
|
||||
("maxAmount", maxAmount),
|
||||
("keyword", keyword)
|
||||
],
|
||||
token);
|
||||
|
||||
// 3.2 (空行后) 查询列表
|
||||
var listSql = BuildListSql(orderBy, request.SortDesc);
|
||||
await using var listCommand = CreateCommand(
|
||||
connection,
|
||||
listSql,
|
||||
[
|
||||
("tenantId", request.TenantId),
|
||||
("status", request.Status.HasValue ? (int)request.Status.Value : null),
|
||||
("billingType", request.BillingType.HasValue ? (int)request.BillingType.Value : null),
|
||||
("startDate", request.StartDate),
|
||||
("endDate", request.EndDate),
|
||||
("minAmount", minAmount),
|
||||
("maxAmount", maxAmount),
|
||||
("keyword", keyword),
|
||||
("offset", offset),
|
||||
("limit", pageSize)
|
||||
]);
|
||||
|
||||
await using var reader = await listCommand.ExecuteReaderAsync(token);
|
||||
var now = DateTime.UtcNow;
|
||||
var items = new List<BillingListDto>();
|
||||
while (await reader.ReadAsync(token))
|
||||
{
|
||||
var dueDate = reader.GetDateTime(13);
|
||||
var status = (TenantBillingStatus)reader.GetInt32(12);
|
||||
var amountDue = reader.GetDecimal(8);
|
||||
var discountAmount = reader.GetDecimal(9);
|
||||
var taxAmount = reader.GetDecimal(10);
|
||||
var totalAmount = amountDue - discountAmount + taxAmount;
|
||||
|
||||
// 3.2.1 (空行后) 逾期辅助字段
|
||||
var isOverdue = status is TenantBillingStatus.Overdue
|
||||
|| (status is TenantBillingStatus.Pending && dueDate < now);
|
||||
var overdueDays = dueDate < now ? (int)(now - dueDate).TotalDays : 0;
|
||||
|
||||
items.Add(new BillingListDto
|
||||
{
|
||||
Id = reader.GetInt64(0),
|
||||
TenantId = reader.GetInt64(1),
|
||||
TenantName = reader.IsDBNull(2) ? string.Empty : reader.GetString(2),
|
||||
SubscriptionId = reader.IsDBNull(3) ? null : reader.GetInt64(3),
|
||||
StatementNo = reader.GetString(4),
|
||||
BillingType = (BillingType)reader.GetInt32(5),
|
||||
PeriodStart = reader.GetDateTime(6),
|
||||
PeriodEnd = reader.GetDateTime(7),
|
||||
AmountDue = amountDue,
|
||||
DiscountAmount = discountAmount,
|
||||
TaxAmount = taxAmount,
|
||||
TotalAmount = totalAmount,
|
||||
AmountPaid = reader.GetDecimal(11),
|
||||
Currency = reader.IsDBNull(14) ? "CNY" : reader.GetString(14),
|
||||
Status = status,
|
||||
DueDate = dueDate,
|
||||
IsOverdue = isOverdue,
|
||||
OverdueDays = overdueDays,
|
||||
CreatedAt = reader.GetDateTime(15),
|
||||
UpdatedAt = reader.IsDBNull(16) ? null : reader.GetDateTime(16)
|
||||
});
|
||||
}
|
||||
|
||||
// 3.3 (空行后) 返回分页
|
||||
return new PagedResult<BillingListDto>(items, page, pageSize, total);
|
||||
},
|
||||
cancellationToken);
|
||||
}
|
||||
|
||||
private static string BuildCountSql()
|
||||
{
|
||||
return """
|
||||
select count(*)
|
||||
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 (@tenantId::bigint is null or b."TenantId" = @tenantId)
|
||||
and (@status::int is null or b."Status" = @status)
|
||||
and (@billingType::int is null or b."BillingType" = @billingType)
|
||||
and (@startDate::timestamp with time zone is null or b."PeriodStart" >= @startDate)
|
||||
and (@endDate::timestamp with time zone is null or b."PeriodEnd" <= @endDate)
|
||||
and (@minAmount::numeric is null or b."AmountDue" >= @minAmount)
|
||||
and (@maxAmount::numeric is null or b."AmountDue" <= @maxAmount)
|
||||
and (
|
||||
@keyword::text is null
|
||||
or b."StatementNo" ilike ('%' || @keyword::text || '%')
|
||||
or t."Name" ilike ('%' || @keyword::text || '%')
|
||||
);
|
||||
""";
|
||||
}
|
||||
|
||||
private static string BuildListSql(string orderBy, bool sortDesc)
|
||||
{
|
||||
var direction = sortDesc ? "desc" : "asc";
|
||||
|
||||
return $"""
|
||||
select
|
||||
b."Id",
|
||||
b."TenantId",
|
||||
t."Name" as "TenantName",
|
||||
b."SubscriptionId",
|
||||
b."StatementNo",
|
||||
b."BillingType",
|
||||
b."PeriodStart",
|
||||
b."PeriodEnd",
|
||||
b."AmountDue",
|
||||
b."DiscountAmount",
|
||||
b."TaxAmount",
|
||||
b."AmountPaid",
|
||||
b."Status",
|
||||
b."DueDate",
|
||||
b."Currency",
|
||||
b."CreatedAt",
|
||||
b."UpdatedAt"
|
||||
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 (@tenantId::bigint is null or b."TenantId" = @tenantId)
|
||||
and (@status::int is null or b."Status" = @status)
|
||||
and (@billingType::int is null or b."BillingType" = @billingType)
|
||||
and (@startDate::timestamp with time zone is null or b."PeriodStart" >= @startDate)
|
||||
and (@endDate::timestamp with time zone is null or b."PeriodEnd" <= @endDate)
|
||||
and (@minAmount::numeric is null or b."AmountDue" >= @minAmount)
|
||||
and (@maxAmount::numeric is null or b."AmountDue" <= @maxAmount)
|
||||
and (
|
||||
@keyword::text is null
|
||||
or b."StatementNo" ilike ('%' || @keyword::text || '%')
|
||||
or t."Name" ilike ('%' || @keyword::text || '%')
|
||||
)
|
||||
order by {orderBy} {direction}
|
||||
offset @offset
|
||||
limit @limit;
|
||||
""";
|
||||
}
|
||||
|
||||
private static async Task<int> ExecuteScalarIntAsync(
|
||||
IDbConnection connection,
|
||||
string sql,
|
||||
(string Name, object? Value)[] parameters,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
await using var command = CreateCommand(connection, sql, parameters);
|
||||
var result = await command.ExecuteScalarAsync(cancellationToken);
|
||||
return result is null or DBNull ? 0 : Convert.ToInt32(result);
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,139 @@
|
||||
using MediatR;
|
||||
using System.Data;
|
||||
using System.Data.Common;
|
||||
using TakeoutSaaS.Application.App.Billings.Dto;
|
||||
using TakeoutSaaS.Application.App.Billings.Queries;
|
||||
using TakeoutSaaS.Shared.Abstractions.Constants;
|
||||
using TakeoutSaaS.Shared.Abstractions.Data;
|
||||
using TakeoutSaaS.Shared.Abstractions.Exceptions;
|
||||
using TakeoutSaaS.Domain.Tenants.Enums;
|
||||
|
||||
namespace TakeoutSaaS.Application.App.Billings.Handlers;
|
||||
|
||||
/// <summary>
|
||||
/// 查询账单支付记录处理器。
|
||||
/// </summary>
|
||||
public sealed class GetBillingPaymentsQueryHandler(
|
||||
IDapperExecutor dapperExecutor)
|
||||
: IRequestHandler<GetBillingPaymentsQuery, List<PaymentRecordDto>>
|
||||
{
|
||||
/// <summary>
|
||||
/// 处理查询账单支付记录请求。
|
||||
/// </summary>
|
||||
/// <param name="request">查询命令。</param>
|
||||
/// <param name="cancellationToken">取消标记。</param>
|
||||
/// <returns>支付记录列表 DTO。</returns>
|
||||
public async Task<List<PaymentRecordDto>> Handle(GetBillingPaymentsQuery request, CancellationToken cancellationToken)
|
||||
{
|
||||
// 1. 校验账单是否存在
|
||||
return await dapperExecutor.QueryAsync(
|
||||
DatabaseConstants.AppDataSource,
|
||||
DatabaseConnectionRole.Read,
|
||||
async (connection, token) =>
|
||||
{
|
||||
// 1.1 校验账单存在
|
||||
var exists = await ExecuteScalarIntAsync(
|
||||
connection,
|
||||
"""
|
||||
select 1
|
||||
from public.tenant_billing_statements b
|
||||
where b."DeletedAt" is null
|
||||
and b."Id" = @billingId
|
||||
limit 1;
|
||||
""",
|
||||
[
|
||||
("billingId", request.BillingId)
|
||||
],
|
||||
token);
|
||||
|
||||
if (exists == 0)
|
||||
{
|
||||
throw new BusinessException(ErrorCodes.NotFound, "账单不存在");
|
||||
}
|
||||
|
||||
// 1.2 (空行后) 查询支付记录
|
||||
await using var command = CreateCommand(
|
||||
connection,
|
||||
"""
|
||||
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;
|
||||
""",
|
||||
[
|
||||
("billingId", request.BillingId)
|
||||
]);
|
||||
|
||||
await using var reader = await command.ExecuteReaderAsync(token);
|
||||
var results = new List<PaymentRecordDto>();
|
||||
while (await reader.ReadAsync(token))
|
||||
{
|
||||
results.Add(new PaymentRecordDto
|
||||
{
|
||||
Id = reader.GetInt64(0),
|
||||
TenantId = reader.GetInt64(1),
|
||||
BillingId = reader.GetInt64(2),
|
||||
Amount = reader.GetDecimal(3),
|
||||
Method = (TenantPaymentMethod)reader.GetInt32(4),
|
||||
Status = (TenantPaymentStatus)reader.GetInt32(5),
|
||||
TransactionNo = reader.IsDBNull(6) ? null : reader.GetString(6),
|
||||
ProofUrl = reader.IsDBNull(7) ? null : reader.GetString(7),
|
||||
Notes = reader.IsDBNull(8) ? null : reader.GetString(8),
|
||||
VerifiedBy = reader.IsDBNull(9) ? null : reader.GetInt64(9),
|
||||
VerifiedAt = reader.IsDBNull(10) ? null : reader.GetDateTime(10),
|
||||
RefundReason = reader.IsDBNull(11) ? null : reader.GetString(11),
|
||||
RefundedAt = reader.IsDBNull(12) ? null : reader.GetDateTime(12),
|
||||
PaidAt = reader.IsDBNull(13) ? null : reader.GetDateTime(13),
|
||||
IsVerified = !reader.IsDBNull(10),
|
||||
CreatedAt = reader.GetDateTime(14)
|
||||
});
|
||||
}
|
||||
|
||||
return results;
|
||||
},
|
||||
cancellationToken);
|
||||
}
|
||||
|
||||
private static async Task<int> ExecuteScalarIntAsync(
|
||||
IDbConnection connection,
|
||||
string sql,
|
||||
(string Name, object? Value)[] parameters,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
await using var command = CreateCommand(connection, sql, parameters);
|
||||
var result = await command.ExecuteScalarAsync(cancellationToken);
|
||||
return result is null or DBNull ? 0 : Convert.ToInt32(result);
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,187 @@
|
||||
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;
|
||||
|
||||
/// <summary>
|
||||
/// 查询账单统计数据处理器。
|
||||
/// </summary>
|
||||
public sealed class GetBillingStatisticsQueryHandler(
|
||||
IDapperExecutor dapperExecutor)
|
||||
: IRequestHandler<GetBillingStatisticsQuery, BillingStatisticsDto>
|
||||
{
|
||||
/// <summary>
|
||||
/// 处理查询账单统计数据请求。
|
||||
/// </summary>
|
||||
/// <param name="request">查询命令。</param>
|
||||
/// <param name="cancellationToken">取消标记。</param>
|
||||
/// <returns>账单统计数据 DTO。</returns>
|
||||
public async Task<BillingStatisticsDto> 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<string, decimal>();
|
||||
var amountPaidTrend = new Dictionary<string, decimal>();
|
||||
var countTrend = new Dictionary<string, int>();
|
||||
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;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,172 @@
|
||||
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;
|
||||
using TakeoutSaaS.Shared.Abstractions.Results;
|
||||
|
||||
namespace TakeoutSaaS.Application.App.Billings.Handlers;
|
||||
|
||||
/// <summary>
|
||||
/// 查询逾期账单列表处理器。
|
||||
/// </summary>
|
||||
public sealed class GetOverdueBillingsQueryHandler(
|
||||
IDapperExecutor dapperExecutor)
|
||||
: IRequestHandler<GetOverdueBillingsQuery, PagedResult<BillingListDto>>
|
||||
{
|
||||
/// <summary>
|
||||
/// 处理查询逾期账单列表请求。
|
||||
/// </summary>
|
||||
/// <param name="request">查询命令。</param>
|
||||
/// <param name="cancellationToken">取消标记。</param>
|
||||
/// <returns>分页逾期账单列表 DTO。</returns>
|
||||
public async Task<PagedResult<BillingListDto>> Handle(GetOverdueBillingsQuery request, CancellationToken cancellationToken)
|
||||
{
|
||||
// 1. 参数规范化
|
||||
var page = request.PageNumber <= 0 ? 1 : request.PageNumber;
|
||||
var pageSize = request.PageSize is <= 0 or > 200 ? 20 : request.PageSize;
|
||||
var offset = (page - 1) * pageSize;
|
||||
var now = DateTime.UtcNow;
|
||||
|
||||
// 2. (空行后) 查询总数 + 列表
|
||||
return await dapperExecutor.QueryAsync(
|
||||
DatabaseConstants.AppDataSource,
|
||||
DatabaseConnectionRole.Read,
|
||||
async (connection, token) =>
|
||||
{
|
||||
// 2.1 统计总数
|
||||
var total = await ExecuteScalarIntAsync(
|
||||
connection,
|
||||
BuildCountSql(),
|
||||
[
|
||||
("now", now)
|
||||
],
|
||||
token);
|
||||
|
||||
// 2.2 (空行后) 查询列表
|
||||
await using var listCommand = CreateCommand(
|
||||
connection,
|
||||
BuildListSql(),
|
||||
[
|
||||
("now", now),
|
||||
("offset", offset),
|
||||
("limit", pageSize)
|
||||
]);
|
||||
|
||||
await using var reader = await listCommand.ExecuteReaderAsync(token);
|
||||
var items = new List<BillingListDto>();
|
||||
while (await reader.ReadAsync(token))
|
||||
{
|
||||
var dueDate = reader.GetDateTime(13);
|
||||
var status = (TenantBillingStatus)reader.GetInt32(12);
|
||||
var amountDue = reader.GetDecimal(8);
|
||||
var discountAmount = reader.GetDecimal(9);
|
||||
var taxAmount = reader.GetDecimal(10);
|
||||
var totalAmount = amountDue - discountAmount + taxAmount;
|
||||
var overdueDays = dueDate < now ? (int)(now - dueDate).TotalDays : 0;
|
||||
|
||||
items.Add(new BillingListDto
|
||||
{
|
||||
Id = reader.GetInt64(0),
|
||||
TenantId = reader.GetInt64(1),
|
||||
TenantName = reader.IsDBNull(2) ? string.Empty : reader.GetString(2),
|
||||
SubscriptionId = reader.IsDBNull(3) ? null : reader.GetInt64(3),
|
||||
StatementNo = reader.GetString(4),
|
||||
BillingType = (BillingType)reader.GetInt32(5),
|
||||
PeriodStart = reader.GetDateTime(6),
|
||||
PeriodEnd = reader.GetDateTime(7),
|
||||
AmountDue = amountDue,
|
||||
DiscountAmount = discountAmount,
|
||||
TaxAmount = taxAmount,
|
||||
TotalAmount = totalAmount,
|
||||
AmountPaid = reader.GetDecimal(11),
|
||||
Status = status,
|
||||
DueDate = dueDate,
|
||||
Currency = reader.IsDBNull(14) ? "CNY" : reader.GetString(14),
|
||||
IsOverdue = true,
|
||||
OverdueDays = overdueDays,
|
||||
CreatedAt = reader.GetDateTime(15),
|
||||
UpdatedAt = reader.IsDBNull(16) ? null : reader.GetDateTime(16)
|
||||
});
|
||||
}
|
||||
|
||||
// 2.3 (空行后) 返回分页
|
||||
return new PagedResult<BillingListDto>(items, page, pageSize, total);
|
||||
},
|
||||
cancellationToken);
|
||||
}
|
||||
|
||||
private static string BuildCountSql()
|
||||
{
|
||||
return """
|
||||
select count(*)
|
||||
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."DueDate" < @now
|
||||
and b."Status" in (0, 2);
|
||||
""";
|
||||
}
|
||||
|
||||
private static string BuildListSql()
|
||||
{
|
||||
return """
|
||||
select
|
||||
b."Id",
|
||||
b."TenantId",
|
||||
t."Name" as "TenantName",
|
||||
b."SubscriptionId",
|
||||
b."StatementNo",
|
||||
b."BillingType",
|
||||
b."PeriodStart",
|
||||
b."PeriodEnd",
|
||||
b."AmountDue",
|
||||
b."DiscountAmount",
|
||||
b."TaxAmount",
|
||||
b."AmountPaid",
|
||||
b."Status",
|
||||
b."DueDate",
|
||||
b."Currency",
|
||||
b."CreatedAt",
|
||||
b."UpdatedAt"
|
||||
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."DueDate" < @now
|
||||
and b."Status" in (0, 2)
|
||||
order by b."DueDate" asc
|
||||
offset @offset
|
||||
limit @limit;
|
||||
""";
|
||||
}
|
||||
|
||||
private static async Task<int> ExecuteScalarIntAsync(
|
||||
IDbConnection connection,
|
||||
string sql,
|
||||
(string Name, object? Value)[] parameters,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
await using var command = CreateCommand(connection, sql, parameters);
|
||||
var result = await command.ExecuteScalarAsync(cancellationToken);
|
||||
return result is null or DBNull ? 0 : Convert.ToInt32(result);
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,49 @@
|
||||
using MediatR;
|
||||
using TakeoutSaaS.Application.App.Billings.Commands;
|
||||
using TakeoutSaaS.Domain.Tenants.Repositories;
|
||||
|
||||
namespace TakeoutSaaS.Application.App.Billings.Handlers;
|
||||
|
||||
/// <summary>
|
||||
/// 处理逾期账单命令处理器(后台任务)。
|
||||
/// </summary>
|
||||
public sealed class ProcessOverdueBillingsCommandHandler(
|
||||
ITenantBillingRepository billingRepository)
|
||||
: IRequestHandler<ProcessOverdueBillingsCommand, int>
|
||||
{
|
||||
/// <inheritdoc />
|
||||
public async Task<int> Handle(ProcessOverdueBillingsCommand request, CancellationToken cancellationToken)
|
||||
{
|
||||
// 1. 查询逾期账单(到期日已过且未支付)
|
||||
var overdueBillings = await billingRepository.GetOverdueBillingsAsync(cancellationToken);
|
||||
if (overdueBillings.Count == 0)
|
||||
{
|
||||
return 0;
|
||||
}
|
||||
|
||||
// 2. (空行后) 标记为逾期并更新通知时间
|
||||
var now = DateTime.UtcNow;
|
||||
var updatedCount = 0;
|
||||
foreach (var billing in overdueBillings)
|
||||
{
|
||||
var before = billing.Status;
|
||||
billing.MarkAsOverdue();
|
||||
|
||||
if (before != billing.Status)
|
||||
{
|
||||
billing.OverdueNotifiedAt ??= now;
|
||||
await billingRepository.UpdateAsync(billing, cancellationToken);
|
||||
updatedCount++;
|
||||
}
|
||||
}
|
||||
|
||||
// 3. (空行后) 持久化
|
||||
if (updatedCount > 0)
|
||||
{
|
||||
await billingRepository.SaveChangesAsync(cancellationToken);
|
||||
}
|
||||
|
||||
return updatedCount;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -6,6 +6,7 @@ using TakeoutSaaS.Domain.Tenants.Enums;
|
||||
using TakeoutSaaS.Domain.Tenants.Repositories;
|
||||
using TakeoutSaaS.Shared.Abstractions.Constants;
|
||||
using TakeoutSaaS.Shared.Abstractions.Exceptions;
|
||||
using TakeoutSaaS.Shared.Abstractions.Ids;
|
||||
|
||||
namespace TakeoutSaaS.Application.App.Billings.Handlers;
|
||||
|
||||
@@ -14,8 +15,9 @@ namespace TakeoutSaaS.Application.App.Billings.Handlers;
|
||||
/// </summary>
|
||||
public sealed class RecordPaymentCommandHandler(
|
||||
ITenantBillingRepository billingRepository,
|
||||
ITenantPaymentRepository paymentRepository)
|
||||
: IRequestHandler<RecordPaymentCommand, PaymentDto>
|
||||
ITenantPaymentRepository paymentRepository,
|
||||
IIdGenerator idGenerator)
|
||||
: IRequestHandler<RecordPaymentCommand, PaymentRecordDto>
|
||||
{
|
||||
/// <summary>
|
||||
/// 处理记录支付请求。
|
||||
@@ -23,44 +25,57 @@ public sealed class RecordPaymentCommandHandler(
|
||||
/// <param name="request">记录支付命令。</param>
|
||||
/// <param name="cancellationToken">取消标记。</param>
|
||||
/// <returns>支付 DTO。</returns>
|
||||
public async Task<PaymentDto> Handle(RecordPaymentCommand request, CancellationToken cancellationToken)
|
||||
public async Task<PaymentRecordDto> Handle(RecordPaymentCommand request, CancellationToken cancellationToken)
|
||||
{
|
||||
// 1. 查询账单
|
||||
var bill = await billingRepository.FindByIdAsync(request.BillId, cancellationToken);
|
||||
if (bill is null)
|
||||
var billing = await billingRepository.FindByIdAsync(request.BillingId, cancellationToken);
|
||||
if (billing is null)
|
||||
{
|
||||
throw new BusinessException(ErrorCodes.NotFound, "账单不存在");
|
||||
}
|
||||
|
||||
// 2. 构建支付记录
|
||||
// 2. (空行后) 业务规则检查
|
||||
if (billing.Status == TenantBillingStatus.Paid)
|
||||
{
|
||||
throw new BusinessException(ErrorCodes.BusinessError, "已支付账单不允许重复收款");
|
||||
}
|
||||
|
||||
if (billing.Status == TenantBillingStatus.Cancelled)
|
||||
{
|
||||
throw new BusinessException(ErrorCodes.BusinessError, "已取消账单不允许收款");
|
||||
}
|
||||
|
||||
// 3. (空行后) 幂等校验:交易号唯一
|
||||
if (!string.IsNullOrWhiteSpace(request.TransactionNo))
|
||||
{
|
||||
var exists = await paymentRepository.GetByTransactionNoAsync(request.TransactionNo.Trim(), cancellationToken);
|
||||
if (exists is not null)
|
||||
{
|
||||
throw new BusinessException(ErrorCodes.Conflict, "交易号已存在,疑似重复提交");
|
||||
}
|
||||
}
|
||||
|
||||
// 4. (空行后) 构建支付记录(默认待审核)
|
||||
var now = DateTime.UtcNow;
|
||||
var payment = new TenantPayment
|
||||
{
|
||||
TenantId = bill.TenantId,
|
||||
BillingStatementId = request.BillId,
|
||||
Id = idGenerator.NextId(),
|
||||
TenantId = billing.TenantId,
|
||||
BillingStatementId = request.BillingId,
|
||||
Amount = request.Amount,
|
||||
Method = request.Method,
|
||||
Status = PaymentStatus.Success,
|
||||
TransactionNo = request.TransactionNo,
|
||||
Status = TenantPaymentStatus.Pending,
|
||||
TransactionNo = string.IsNullOrWhiteSpace(request.TransactionNo) ? null : request.TransactionNo.Trim(),
|
||||
ProofUrl = request.ProofUrl,
|
||||
PaidAt = DateTime.UtcNow,
|
||||
PaidAt = now,
|
||||
Notes = request.Notes
|
||||
};
|
||||
|
||||
// 3. 更新账单已付金额
|
||||
bill.AmountPaid += request.Amount;
|
||||
|
||||
// 4. 如果已付金额 >= 应付金额,标记为已支付
|
||||
if (bill.AmountPaid >= bill.AmountDue)
|
||||
{
|
||||
bill.Status = TenantBillingStatus.Paid;
|
||||
}
|
||||
|
||||
// 5. 持久化变更
|
||||
// 5. (空行后) 持久化变更
|
||||
await paymentRepository.AddAsync(payment, cancellationToken);
|
||||
await billingRepository.UpdateAsync(bill, cancellationToken);
|
||||
await paymentRepository.SaveChangesAsync(cancellationToken);
|
||||
|
||||
// 6. 返回 DTO
|
||||
return payment.ToDto();
|
||||
// 6. (空行后) 返回 DTO
|
||||
return payment.ToPaymentRecordDto();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,54 @@
|
||||
using MediatR;
|
||||
using TakeoutSaaS.Application.App.Billings.Commands;
|
||||
using TakeoutSaaS.Domain.Tenants.Enums;
|
||||
using TakeoutSaaS.Domain.Tenants.Repositories;
|
||||
using TakeoutSaaS.Shared.Abstractions.Constants;
|
||||
using TakeoutSaaS.Shared.Abstractions.Exceptions;
|
||||
|
||||
namespace TakeoutSaaS.Application.App.Billings.Handlers;
|
||||
|
||||
/// <summary>
|
||||
/// 更新账单状态命令处理器。
|
||||
/// </summary>
|
||||
public sealed class UpdateBillingStatusCommandHandler(
|
||||
ITenantBillingRepository billingRepository)
|
||||
: IRequestHandler<UpdateBillingStatusCommand, Unit>
|
||||
{
|
||||
/// <inheritdoc />
|
||||
public async Task<Unit> Handle(UpdateBillingStatusCommand request, CancellationToken cancellationToken)
|
||||
{
|
||||
// 1. 查询账单
|
||||
var billing = await billingRepository.FindByIdAsync(request.BillingId, cancellationToken);
|
||||
if (billing is null)
|
||||
{
|
||||
throw new BusinessException(ErrorCodes.NotFound, "账单不存在");
|
||||
}
|
||||
|
||||
// 2. (空行后) 状态转换规则校验
|
||||
if (billing.Status == TenantBillingStatus.Paid && request.NewStatus != TenantBillingStatus.Paid)
|
||||
{
|
||||
throw new BusinessException(ErrorCodes.BusinessError, "已支付账单不允许改为其他状态");
|
||||
}
|
||||
|
||||
if (billing.Status == TenantBillingStatus.Cancelled)
|
||||
{
|
||||
throw new BusinessException(ErrorCodes.BusinessError, "已取消账单不允许变更状态");
|
||||
}
|
||||
|
||||
// 3. (空行后) 更新状态与备注
|
||||
billing.Status = request.NewStatus;
|
||||
if (!string.IsNullOrWhiteSpace(request.Notes))
|
||||
{
|
||||
billing.Notes = string.IsNullOrWhiteSpace(billing.Notes)
|
||||
? $"[状态变更] {request.Notes}"
|
||||
: $"{billing.Notes}\n[状态变更] {request.Notes}";
|
||||
}
|
||||
|
||||
// 4. (空行后) 持久化
|
||||
await billingRepository.UpdateAsync(billing, cancellationToken);
|
||||
await billingRepository.SaveChangesAsync(cancellationToken);
|
||||
|
||||
return Unit.Value;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,73 @@
|
||||
using MediatR;
|
||||
using TakeoutSaaS.Application.App.Billings.Commands;
|
||||
using TakeoutSaaS.Application.App.Billings.Dto;
|
||||
using TakeoutSaaS.Domain.Tenants.Repositories;
|
||||
using TakeoutSaaS.Shared.Abstractions.Constants;
|
||||
using TakeoutSaaS.Shared.Abstractions.Exceptions;
|
||||
using TakeoutSaaS.Shared.Abstractions.Security;
|
||||
|
||||
namespace TakeoutSaaS.Application.App.Billings.Handlers;
|
||||
|
||||
/// <summary>
|
||||
/// 审核支付命令处理器。
|
||||
/// </summary>
|
||||
public sealed class VerifyPaymentCommandHandler(
|
||||
ITenantPaymentRepository paymentRepository,
|
||||
ITenantBillingRepository billingRepository,
|
||||
ICurrentUserAccessor currentUserAccessor)
|
||||
: IRequestHandler<VerifyPaymentCommand, PaymentRecordDto>
|
||||
{
|
||||
/// <inheritdoc />
|
||||
public async Task<PaymentRecordDto> Handle(VerifyPaymentCommand request, CancellationToken cancellationToken)
|
||||
{
|
||||
// 1. 校验操作者身份
|
||||
if (!currentUserAccessor.IsAuthenticated || currentUserAccessor.UserId <= 0)
|
||||
{
|
||||
throw new BusinessException(ErrorCodes.Unauthorized, "未登录或无效的操作者身份");
|
||||
}
|
||||
|
||||
// 2. (空行后) 查询支付记录
|
||||
var payment = await paymentRepository.FindByIdAsync(request.PaymentId, cancellationToken);
|
||||
if (payment is null)
|
||||
{
|
||||
throw new BusinessException(ErrorCodes.NotFound, "支付记录不存在");
|
||||
}
|
||||
|
||||
// 3. (空行后) 查询关联账单
|
||||
var billing = await billingRepository.FindByIdAsync(payment.BillingStatementId, cancellationToken);
|
||||
if (billing is null)
|
||||
{
|
||||
throw new BusinessException(ErrorCodes.NotFound, "关联账单不存在");
|
||||
}
|
||||
|
||||
// 4. (空行后) 归一化审核备注
|
||||
var normalizedNotes = string.IsNullOrWhiteSpace(request.Notes) ? null : request.Notes.Trim();
|
||||
|
||||
// 5. (空行后) 根据审核结果更新支付与账单状态
|
||||
if (request.Approved)
|
||||
{
|
||||
payment.Verify(currentUserAccessor.UserId);
|
||||
payment.Notes = normalizedNotes;
|
||||
|
||||
billing.MarkAsPaid(payment.Amount, payment.TransactionNo ?? string.Empty);
|
||||
}
|
||||
else
|
||||
{
|
||||
payment.Reject(currentUserAccessor.UserId, normalizedNotes ?? string.Empty);
|
||||
payment.Notes = normalizedNotes;
|
||||
}
|
||||
|
||||
// 6. (空行后) 持久化更新状态
|
||||
await paymentRepository.UpdateAsync(payment, cancellationToken);
|
||||
if (request.Approved)
|
||||
{
|
||||
await billingRepository.UpdateAsync(billing, cancellationToken);
|
||||
}
|
||||
|
||||
// 7. (空行后) 保存数据库更改
|
||||
await paymentRepository.SaveChangesAsync(cancellationToken);
|
||||
|
||||
// 8. (空行后) 返回 DTO
|
||||
return payment.ToPaymentRecordDto();
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user