✨ 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,84 @@
|
||||
namespace TakeoutSaaS.Domain.Tenants.Entities;
|
||||
|
||||
/// <summary>
|
||||
/// 账单明细项(值对象)。
|
||||
/// 用于记录账单中的单项费用明细,如套餐费用、配额包费用等。
|
||||
/// </summary>
|
||||
public sealed class BillingLineItem
|
||||
{
|
||||
/// <summary>
|
||||
/// 明细项类型(如:套餐费、配额包、其他费用)。
|
||||
/// </summary>
|
||||
public string ItemType { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// 明细项描述。
|
||||
/// </summary>
|
||||
public string Description { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// 数量。
|
||||
/// </summary>
|
||||
public decimal Quantity { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 单价。
|
||||
/// </summary>
|
||||
public decimal UnitPrice { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 金额(数量 × 单价)。
|
||||
/// </summary>
|
||||
public decimal Amount { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 折扣率(0-1 之间,如 0.1 表示 10% 折扣)。
|
||||
/// </summary>
|
||||
public decimal DiscountRate { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 创建账单明细项。
|
||||
/// </summary>
|
||||
/// <param name="itemType">明细项类型。</param>
|
||||
/// <param name="description">描述。</param>
|
||||
/// <param name="quantity">数量。</param>
|
||||
/// <param name="unitPrice">单价。</param>
|
||||
/// <param name="discountRate">折扣率。</param>
|
||||
/// <returns>账单明细项实例。</returns>
|
||||
public static BillingLineItem Create(
|
||||
string itemType,
|
||||
string description,
|
||||
decimal quantity,
|
||||
decimal unitPrice,
|
||||
decimal discountRate = 0)
|
||||
{
|
||||
var amount = quantity * unitPrice * (1 - discountRate);
|
||||
return new BillingLineItem
|
||||
{
|
||||
ItemType = itemType,
|
||||
Description = description,
|
||||
Quantity = quantity,
|
||||
UnitPrice = unitPrice,
|
||||
Amount = amount,
|
||||
DiscountRate = discountRate
|
||||
};
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 计算折扣后的金额。
|
||||
/// </summary>
|
||||
/// <returns>折扣后金额。</returns>
|
||||
public decimal CalculateDiscountedAmount()
|
||||
{
|
||||
return Quantity * UnitPrice * (1 - DiscountRate);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 获取折扣金额。
|
||||
/// </summary>
|
||||
/// <returns>折扣金额。</returns>
|
||||
public decimal GetDiscountAmount()
|
||||
{
|
||||
return Quantity * UnitPrice * DiscountRate;
|
||||
}
|
||||
}
|
||||
@@ -13,6 +13,16 @@ public sealed class TenantBillingStatement : MultiTenantEntityBase
|
||||
/// </summary>
|
||||
public string StatementNo { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// 账单类型(订阅账单/配额包账单/手动账单/续费账单)。
|
||||
/// </summary>
|
||||
public BillingType BillingType { get; set; } = BillingType.Subscription;
|
||||
|
||||
/// <summary>
|
||||
/// 关联的订阅 ID(仅当 BillingType 为 Subscription 或 Renewal 时有值)。
|
||||
/// </summary>
|
||||
public long? SubscriptionId { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 账单周期开始时间。
|
||||
/// </summary>
|
||||
@@ -24,15 +34,30 @@ public sealed class TenantBillingStatement : MultiTenantEntityBase
|
||||
public DateTime PeriodEnd { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 应付金额。
|
||||
/// 应付金额(原始金额)。
|
||||
/// </summary>
|
||||
public decimal AmountDue { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 折扣金额。
|
||||
/// </summary>
|
||||
public decimal DiscountAmount { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 税费金额。
|
||||
/// </summary>
|
||||
public decimal TaxAmount { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 实付金额。
|
||||
/// </summary>
|
||||
public decimal AmountPaid { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 货币类型(默认 CNY)。
|
||||
/// </summary>
|
||||
public string Currency { get; set; } = "CNY";
|
||||
|
||||
/// <summary>
|
||||
/// 当前付款状态。
|
||||
/// </summary>
|
||||
@@ -43,8 +68,133 @@ public sealed class TenantBillingStatement : MultiTenantEntityBase
|
||||
/// </summary>
|
||||
public DateTime DueDate { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 提醒发送时间(续费提醒、逾期提醒等)。
|
||||
/// </summary>
|
||||
public DateTime? ReminderSentAt { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 逾期通知时间。
|
||||
/// </summary>
|
||||
public DateTime? OverdueNotifiedAt { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 账单明细 JSON,记录各项费用。
|
||||
/// </summary>
|
||||
public string? LineItemsJson { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 备注信息(如:人工备注、取消原因等)。
|
||||
/// </summary>
|
||||
public string? Notes { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 计算总金额(应付金额 - 折扣 + 税费)。
|
||||
/// </summary>
|
||||
/// <returns>总金额。</returns>
|
||||
public decimal CalculateTotalAmount()
|
||||
{
|
||||
return AmountDue - DiscountAmount + TaxAmount;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 标记为已支付(直接结清)。
|
||||
/// </summary>
|
||||
public void MarkAsPaid()
|
||||
{
|
||||
// 1. 计算剩余应付金额
|
||||
var remainingAmount = CalculateTotalAmount() - AmountPaid;
|
||||
|
||||
// 2. 若已结清则直接返回
|
||||
if (remainingAmount <= 0)
|
||||
{
|
||||
Status = TenantBillingStatus.Paid;
|
||||
return;
|
||||
}
|
||||
|
||||
// 3. 补足剩余金额并标记为已支付
|
||||
MarkAsPaid(remainingAmount, string.Empty);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 标记为已支付。
|
||||
/// </summary>
|
||||
/// <param name="amount">支付金额。</param>
|
||||
/// <param name="transactionNo">交易号。</param>
|
||||
public void MarkAsPaid(decimal amount, string transactionNo)
|
||||
{
|
||||
if (Status == TenantBillingStatus.Paid)
|
||||
{
|
||||
throw new InvalidOperationException("账单已经处于已支付状态,不能重复标记。");
|
||||
}
|
||||
|
||||
if (Status == TenantBillingStatus.Cancelled)
|
||||
{
|
||||
throw new InvalidOperationException("已取消的账单不能标记为已支付。");
|
||||
}
|
||||
|
||||
// 1. 累加支付金额
|
||||
AmountPaid += amount;
|
||||
|
||||
// 2. 如果实付金额大于等于应付总额,则标记为已支付
|
||||
if (AmountPaid >= CalculateTotalAmount())
|
||||
{
|
||||
Status = TenantBillingStatus.Paid;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 标记为逾期。
|
||||
/// </summary>
|
||||
public void MarkAsOverdue()
|
||||
{
|
||||
// 1. 仅待支付账单允许标记逾期
|
||||
if (Status != TenantBillingStatus.Pending)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
// 2. 未超过到期日则不处理
|
||||
if (DateTime.UtcNow <= DueDate)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
// 3. 标记为逾期(通知时间由外部流程在发送通知时写入)
|
||||
Status = TenantBillingStatus.Overdue;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 取消账单。
|
||||
/// </summary>
|
||||
public void Cancel()
|
||||
{
|
||||
Cancel(null);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 取消账单。
|
||||
/// </summary>
|
||||
/// <param name="reason">取消原因。</param>
|
||||
public void Cancel(string? reason)
|
||||
{
|
||||
if (Status == TenantBillingStatus.Paid)
|
||||
{
|
||||
throw new InvalidOperationException("已支付的账单不能取消。");
|
||||
}
|
||||
|
||||
if (Status == TenantBillingStatus.Cancelled)
|
||||
{
|
||||
throw new InvalidOperationException("账单已经处于取消状态。");
|
||||
}
|
||||
|
||||
// 1. 变更状态
|
||||
Status = TenantBillingStatus.Cancelled;
|
||||
|
||||
// 2. 记录取消原因(可选)
|
||||
if (!string.IsNullOrWhiteSpace(reason))
|
||||
{
|
||||
Notes = string.IsNullOrWhiteSpace(Notes) ? $"[取消原因] {reason}" : $"{Notes}\n[取消原因] {reason}";
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -21,12 +21,12 @@ public sealed class TenantPayment : MultiTenantEntityBase
|
||||
/// <summary>
|
||||
/// 支付方式。
|
||||
/// </summary>
|
||||
public PaymentMethod Method { get; set; }
|
||||
public TenantPaymentMethod Method { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 支付状态。
|
||||
/// </summary>
|
||||
public PaymentStatus Status { get; set; }
|
||||
public TenantPaymentStatus Status { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 交易号。
|
||||
@@ -43,8 +43,116 @@ public sealed class TenantPayment : MultiTenantEntityBase
|
||||
/// </summary>
|
||||
public DateTime? PaidAt { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 退款原因。
|
||||
/// </summary>
|
||||
public string? RefundReason { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 退款时间。
|
||||
/// </summary>
|
||||
public DateTime? RefundedAt { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 审核人 ID(管理员)。
|
||||
/// </summary>
|
||||
public long? VerifiedBy { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 审核时间。
|
||||
/// </summary>
|
||||
public DateTime? VerifiedAt { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 备注信息。
|
||||
/// </summary>
|
||||
public string? Notes { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 审核支付记录(确认支付有效性)。
|
||||
/// </summary>
|
||||
public void Verify()
|
||||
{
|
||||
if (Status != TenantPaymentStatus.Pending)
|
||||
{
|
||||
throw new InvalidOperationException("只有待审核的支付记录才能被审核。");
|
||||
}
|
||||
|
||||
if (VerifiedAt.HasValue)
|
||||
{
|
||||
throw new InvalidOperationException("该支付记录已经被审核过。");
|
||||
}
|
||||
|
||||
// 1. 标记为支付成功
|
||||
Status = TenantPaymentStatus.Success;
|
||||
|
||||
// 2. 写入审核时间与支付时间
|
||||
VerifiedAt = DateTime.UtcNow;
|
||||
PaidAt ??= DateTime.UtcNow;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 审核支付记录(确认支付有效性)。
|
||||
/// </summary>
|
||||
/// <param name="verifierId">审核人 ID。</param>
|
||||
public void Verify(long verifierId)
|
||||
{
|
||||
Verify();
|
||||
VerifiedBy = verifierId;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 退款。
|
||||
/// </summary>
|
||||
public void Refund()
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(RefundReason))
|
||||
{
|
||||
throw new InvalidOperationException("退款原因不能为空。");
|
||||
}
|
||||
|
||||
Refund(RefundReason);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 退款。
|
||||
/// </summary>
|
||||
/// <param name="reason">退款原因。</param>
|
||||
public void Refund(string reason)
|
||||
{
|
||||
if (Status == TenantPaymentStatus.Refunded)
|
||||
{
|
||||
throw new InvalidOperationException("该支付记录已经处于退款状态。");
|
||||
}
|
||||
|
||||
if (Status != TenantPaymentStatus.Success)
|
||||
{
|
||||
throw new InvalidOperationException("只有支付成功的记录才能退款。");
|
||||
}
|
||||
|
||||
// 1. 标记退款状态
|
||||
Status = TenantPaymentStatus.Refunded;
|
||||
|
||||
// 2. 写入退款原因与退款时间
|
||||
RefundReason = reason;
|
||||
RefundedAt = DateTime.UtcNow;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 拒绝支付(审核不通过)。
|
||||
/// </summary>
|
||||
/// <param name="verifierId">审核人 ID。</param>
|
||||
/// <param name="reason">拒绝原因。</param>
|
||||
public void Reject(long verifierId, string reason)
|
||||
{
|
||||
if (Status != TenantPaymentStatus.Pending)
|
||||
{
|
||||
throw new InvalidOperationException("只有待审核的支付记录才能被拒绝。");
|
||||
}
|
||||
|
||||
Status = TenantPaymentStatus.Failed;
|
||||
VerifiedBy = verifierId;
|
||||
VerifiedAt = DateTime.UtcNow;
|
||||
Notes = $"拒绝原因: {reason}";
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user