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:
2025-12-18 11:24:44 +08:00
parent 98f49ea7ad
commit 4b53862ded
73 changed files with 12688 additions and 305 deletions

View File

@@ -0,0 +1,34 @@
using TakeoutSaaS.Domain.Tenants.Enums;
namespace TakeoutSaaS.AdminApi.Contracts.Requests;
/// <summary>
/// 租户账单分页查询请求QueryString 参数)。
/// </summary>
public sealed record SearchTenantBillsRequest
{
/// <summary>
/// 账单状态筛选。
/// </summary>
public TenantBillingStatus? Status { get; init; }
/// <summary>
/// 账单起始时间UTC筛选。
/// </summary>
public DateTime? From { get; init; }
/// <summary>
/// 账单结束时间UTC筛选。
/// </summary>
public DateTime? To { get; init; }
/// <summary>
/// 页码(从 1 开始)。
/// </summary>
public int Page { get; init; } = 1;
/// <summary>
/// 每页条数。
/// </summary>
public int PageSize { get; init; } = 20;
}

View File

@@ -1,6 +1,7 @@
using MediatR; using MediatR;
using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc; using Microsoft.AspNetCore.Mvc;
using Microsoft.Net.Http.Headers;
using System.ComponentModel.DataAnnotations; using System.ComponentModel.DataAnnotations;
using TakeoutSaaS.Application.App.Billings.Commands; using TakeoutSaaS.Application.App.Billings.Commands;
using TakeoutSaaS.Application.App.Billings.Dto; using TakeoutSaaS.Application.App.Billings.Dto;
@@ -16,7 +17,7 @@ namespace TakeoutSaaS.AdminApi.Controllers;
/// </summary> /// </summary>
[ApiVersion("1.0")] [ApiVersion("1.0")]
[Authorize] [Authorize]
[Route("api/admin/v{version:apiVersion}/bills")] [Route("api/admin/v{version:apiVersion}/billings")]
public sealed class BillingsController(IMediator mediator) : BaseApiController public sealed class BillingsController(IMediator mediator) : BaseApiController
{ {
/// <summary> /// <summary>
@@ -25,11 +26,14 @@ public sealed class BillingsController(IMediator mediator) : BaseApiController
/// <returns>账单分页结果。</returns> /// <returns>账单分页结果。</returns>
[HttpGet] [HttpGet]
[PermissionAuthorize("bill:read")] [PermissionAuthorize("bill:read")]
[ProducesResponseType(typeof(ApiResponse<PagedResult<BillDto>>), StatusCodes.Status200OK)] [ProducesResponseType(typeof(ApiResponse<PagedResult<BillingListDto>>), StatusCodes.Status200OK)]
public async Task<ApiResponse<PagedResult<BillDto>>> GetList([FromQuery] GetBillListQuery query, CancellationToken cancellationToken) public async Task<ApiResponse<PagedResult<BillingListDto>>> GetList([FromQuery] GetBillingListQuery query, CancellationToken cancellationToken)
{ {
// 1. 查询账单列表
var result = await mediator.Send(query, cancellationToken); var result = await mediator.Send(query, cancellationToken);
return ApiResponse<PagedResult<BillDto>>.Ok(result);
// 2. 返回分页结果
return ApiResponse<PagedResult<BillingListDto>>.Ok(result);
} }
/// <summary> /// <summary>
@@ -40,15 +44,15 @@ public sealed class BillingsController(IMediator mediator) : BaseApiController
/// <returns>账单详情。</returns> /// <returns>账单详情。</returns>
[HttpGet("{id:long}")] [HttpGet("{id:long}")]
[PermissionAuthorize("bill:read")] [PermissionAuthorize("bill:read")]
[ProducesResponseType(typeof(ApiResponse<BillDetailDto>), StatusCodes.Status200OK)] [ProducesResponseType(typeof(ApiResponse<BillingDetailDto>), StatusCodes.Status200OK)]
[ProducesResponseType(typeof(ApiResponse<BillDetailDto>), StatusCodes.Status404NotFound)] [ProducesResponseType(typeof(ApiResponse<object>), StatusCodes.Status404NotFound)]
public async Task<ApiResponse<BillDetailDto>> GetDetail(long id, CancellationToken cancellationToken) public async Task<ApiResponse<BillingDetailDto>> GetDetail(long id, CancellationToken cancellationToken)
{ {
var result = await mediator.Send(new GetBillDetailQuery { BillId = id }, cancellationToken); // 1. 查询账单详情(若不存在则抛出业务异常,由全局异常处理转换为 404
var result = await mediator.Send(new GetBillingDetailQuery { BillingId = id }, cancellationToken);
return result is null // 2. 返回详情
? ApiResponse<BillDetailDto>.Error(StatusCodes.Status404NotFound, "账单不存在") return ApiResponse<BillingDetailDto>.Ok(result);
: ApiResponse<BillDetailDto>.Ok(result);
} }
/// <summary> /// <summary>
@@ -59,11 +63,14 @@ public sealed class BillingsController(IMediator mediator) : BaseApiController
/// <returns>创建的账单信息。</returns> /// <returns>创建的账单信息。</returns>
[HttpPost] [HttpPost]
[PermissionAuthorize("bill:create")] [PermissionAuthorize("bill:create")]
[ProducesResponseType(typeof(ApiResponse<BillDto>), StatusCodes.Status200OK)] [ProducesResponseType(typeof(ApiResponse<BillingDetailDto>), StatusCodes.Status200OK)]
public async Task<ApiResponse<BillDto>> Create([FromBody, Required] CreateBillCommand command, CancellationToken cancellationToken) public async Task<ApiResponse<BillingDetailDto>> Create([FromBody, Required] CreateBillingCommand command, CancellationToken cancellationToken)
{ {
// 1. 创建账单
var result = await mediator.Send(command, cancellationToken); var result = await mediator.Send(command, cancellationToken);
return ApiResponse<BillDto>.Ok(result);
// 2. 返回创建结果
return ApiResponse<BillingDetailDto>.Ok(result);
} }
/// <summary> /// <summary>
@@ -72,50 +79,202 @@ public sealed class BillingsController(IMediator mediator) : BaseApiController
/// <param name="id">账单 ID。</param> /// <param name="id">账单 ID。</param>
/// <param name="command">更新状态命令。</param> /// <param name="command">更新状态命令。</param>
/// <param name="cancellationToken">取消标记。</param> /// <param name="cancellationToken">取消标记。</param>
/// <returns>更新后的账单信息。</returns> /// <returns>更新结果。</returns>
[HttpPut("{id:long}/status")] [HttpPut("{id:long}/status")]
[PermissionAuthorize("bill:update")] [PermissionAuthorize("bill:update")]
[ProducesResponseType(typeof(ApiResponse<BillDto>), StatusCodes.Status200OK)] [ProducesResponseType(typeof(ApiResponse<object>), StatusCodes.Status200OK)]
[ProducesResponseType(typeof(ApiResponse<BillDto>), StatusCodes.Status404NotFound)] [ProducesResponseType(typeof(ApiResponse<object>), StatusCodes.Status404NotFound)]
public async Task<ApiResponse<BillDto>> UpdateStatus(long id, [FromBody, Required] UpdateBillStatusCommand command, CancellationToken cancellationToken) public async Task<ApiResponse<object>> UpdateStatus(long id, [FromBody, Required] UpdateBillingStatusCommand command, CancellationToken cancellationToken)
{ {
command = command with { BillId = id }; // 1. 绑定账单标识
var result = await mediator.Send(command, cancellationToken); command = command with { BillingId = id };
return result is null // 2. 更新账单状态(若不存在则抛出业务异常,由全局异常处理转换为 404
? ApiResponse<BillDto>.Error(StatusCodes.Status404NotFound, "账单不存在") await mediator.Send(command, cancellationToken);
: ApiResponse<BillDto>.Ok(result);
// 3. 返回成功结果
return ApiResponse<object>.Ok(null);
}
/// <summary>
/// 取消账单。
/// </summary>
/// <param name="id">账单 ID。</param>
/// <param name="reason">取消原因(可选)。</param>
/// <param name="cancellationToken">取消标记。</param>
/// <returns>取消结果。</returns>
[HttpDelete("{id:long}")]
[PermissionAuthorize("bill:delete")]
[ProducesResponseType(typeof(ApiResponse<object>), StatusCodes.Status200OK)]
[ProducesResponseType(typeof(ApiResponse<object>), StatusCodes.Status404NotFound)]
public async Task<ApiResponse<object>> Cancel(long id, [FromQuery] string? reason, CancellationToken cancellationToken)
{
// 1. 取消账单(取消原因支持可选)
await mediator.Send(new CancelBillingCommand { BillingId = id, Reason = reason ?? string.Empty }, cancellationToken);
// 2. 返回成功结果
return ApiResponse<object>.Ok(null);
} }
/// <summary> /// <summary>
/// 获取账单支付记录。 /// 获取账单支付记录。
/// </summary> /// </summary>
/// <param name="billId">账单 ID。</param> /// <param name="id">账单 ID。</param>
/// <param name="cancellationToken">取消标记。</param> /// <param name="cancellationToken">取消标记。</param>
/// <returns>支付记录列表。</returns> /// <returns>支付记录列表。</returns>
[HttpGet("{billId:long}/payments")] [HttpGet("{id:long}/payments")]
[PermissionAuthorize("bill:read")] [PermissionAuthorize("bill:read")]
[ProducesResponseType(typeof(ApiResponse<List<PaymentDto>>), StatusCodes.Status200OK)] [ProducesResponseType(typeof(ApiResponse<List<PaymentRecordDto>>), StatusCodes.Status200OK)]
public async Task<ApiResponse<List<PaymentDto>>> GetPayments(long billId, CancellationToken cancellationToken) public async Task<ApiResponse<List<PaymentRecordDto>>> GetPayments(long id, CancellationToken cancellationToken)
{ {
var result = await mediator.Send(new GetTenantPaymentsQuery { BillId = billId }, cancellationToken); // 1. 查询支付记录
return ApiResponse<List<PaymentDto>>.Ok(result); var result = await mediator.Send(new GetBillingPaymentsQuery { BillingId = id }, cancellationToken);
// 2. 返回列表
return ApiResponse<List<PaymentRecordDto>>.Ok(result);
} }
/// <summary> /// <summary>
/// 记录支付(线下支付确认)。 /// 记录支付(线下支付确认)。
/// </summary> /// </summary>
/// <param name="billId">账单 ID。</param> /// <param name="id">账单 ID。</param>
/// <param name="command">记录支付命令。</param> /// <param name="command">记录支付命令。</param>
/// <param name="cancellationToken">取消标记。</param> /// <param name="cancellationToken">取消标记。</param>
/// <returns>支付记录信息。</returns> /// <returns>支付记录信息。</returns>
[HttpPost("{billId:long}/payments")] [HttpPost("{id:long}/payments")]
[PermissionAuthorize("bill:pay")] [PermissionAuthorize("bill:pay")]
[ProducesResponseType(typeof(ApiResponse<PaymentDto>), StatusCodes.Status200OK)] [ProducesResponseType(typeof(ApiResponse<PaymentRecordDto>), StatusCodes.Status200OK)]
public async Task<ApiResponse<PaymentDto>> RecordPayment(long billId, [FromBody, Required] RecordPaymentCommand command, CancellationToken cancellationToken) [ProducesResponseType(typeof(ApiResponse<object>), StatusCodes.Status404NotFound)]
public async Task<ApiResponse<PaymentRecordDto>> RecordPayment(long id, [FromBody, Required] RecordPaymentCommand command, CancellationToken cancellationToken)
{ {
command = command with { BillId = billId }; // 1. 绑定账单标识
command = command with { BillingId = id };
// 2. 记录支付
var result = await mediator.Send(command, cancellationToken); var result = await mediator.Send(command, cancellationToken);
return ApiResponse<PaymentDto>.Ok(result);
// 3. 返回支付记录
return ApiResponse<PaymentRecordDto>.Ok(result);
}
/// <summary>
/// 审核支付记录。
/// </summary>
/// <param name="paymentId">支付记录 ID。</param>
/// <param name="command">审核参数。</param>
/// <param name="cancellationToken">取消标记。</param>
/// <returns>审核后的支付记录。</returns>
[HttpPut("payments/{paymentId:long}/verify")]
[PermissionAuthorize("bill:update")]
[ProducesResponseType(typeof(ApiResponse<PaymentRecordDto>), StatusCodes.Status200OK)]
[ProducesResponseType(typeof(ApiResponse<object>), StatusCodes.Status404NotFound)]
public async Task<ApiResponse<PaymentRecordDto>> VerifyPayment(long paymentId, [FromBody, Required] VerifyPaymentCommand command, CancellationToken cancellationToken)
{
// 1. 绑定支付记录标识
command = command with { PaymentId = paymentId };
// 2. (空行后) 审核支付记录
var result = await mediator.Send(command, cancellationToken);
// 3. (空行后) 返回审核结果
return ApiResponse<PaymentRecordDto>.Ok(result);
}
/// <summary>
/// 批量更新账单状态。
/// </summary>
/// <param name="command">批量更新命令。</param>
/// <param name="cancellationToken">取消标记。</param>
/// <returns>更新条数。</returns>
[HttpPost("batch/status")]
[PermissionAuthorize("bill:update")]
[ProducesResponseType(typeof(ApiResponse<int>), StatusCodes.Status200OK)]
public async Task<ApiResponse<int>> BatchUpdateStatus([FromBody, Required] BatchUpdateStatusCommand command, CancellationToken cancellationToken)
{
// 1. 执行批量更新
var affected = await mediator.Send(command, cancellationToken);
// 2. 返回更新条数
return ApiResponse<int>.Ok(affected);
}
/// <summary>
/// 导出账单Excel/PDF/CSV
/// </summary>
/// <param name="query">导出请求。</param>
/// <param name="cancellationToken">取消标记。</param>
/// <returns>导出文件。</returns>
[HttpPost("export")]
[PermissionAuthorize("bill:read")]
[Produces("application/octet-stream")]
[ProducesResponseType(typeof(FileContentResult), StatusCodes.Status200OK)]
public async Task<IActionResult> Export([FromBody, Required] ExportBillingsQuery query, CancellationToken cancellationToken)
{
// 1. 执行导出
var bytes = await mediator.Send(query, cancellationToken);
// 2. (空行后) 解析格式并生成文件名
var extension = ResolveExportFileExtension(query.Format);
var fileName = $"billings_{DateTime.UtcNow:yyyyMMdd_HHmmss}.{extension}";
// 3. (空行后) 显式写入 Content-Disposition确保浏览器以附件形式下载
Response.Headers[HeaderNames.ContentDisposition] = new ContentDispositionHeaderValue("attachment")
{
FileName = fileName,
FileNameStar = fileName
}.ToString();
// 4. (空行后) 返回二进制流(统一 octet-stream避免被默认 JSON Produces 影响)
return File(bytes, "application/octet-stream");
}
/// <summary>
/// 获取账单统计数据。
/// </summary>
/// <param name="query">统计查询参数。</param>
/// <param name="cancellationToken">取消标记。</param>
/// <returns>统计结果。</returns>
[HttpGet("statistics")]
[PermissionAuthorize("bill:read")]
[ProducesResponseType(typeof(ApiResponse<BillingStatisticsDto>), StatusCodes.Status200OK)]
public async Task<ApiResponse<BillingStatisticsDto>> Statistics([FromQuery] GetBillingStatisticsQuery query, CancellationToken cancellationToken)
{
// 1. 查询统计数据
var result = await mediator.Send(query, cancellationToken);
// 2. 返回统计结果
return ApiResponse<BillingStatisticsDto>.Ok(result);
}
/// <summary>
/// 获取逾期账单列表。
/// </summary>
/// <param name="query">逾期列表查询参数。</param>
/// <param name="cancellationToken">取消标记。</param>
/// <returns>逾期账单分页结果。</returns>
[HttpGet("overdue")]
[PermissionAuthorize("bill:read")]
[ProducesResponseType(typeof(ApiResponse<PagedResult<BillingListDto>>), StatusCodes.Status200OK)]
public async Task<ApiResponse<PagedResult<BillingListDto>>> Overdue([FromQuery] GetOverdueBillingsQuery query, CancellationToken cancellationToken)
{
// 1. 查询逾期账单分页列表
var result = await mediator.Send(query, cancellationToken);
// 2. 返回分页结果
return ApiResponse<PagedResult<BillingListDto>>.Ok(result);
}
private static string ResolveExportFileExtension(string? format)
{
// 1. 归一化导出格式
var normalized = (format ?? string.Empty).Trim();
// 2. (空行后) 映射扩展名
return normalized.ToUpperInvariant() switch
{
"PDF" => "pdf",
"CSV" => "csv",
_ => "xlsx"
};
} }
} }

View File

@@ -2,6 +2,7 @@ using MediatR;
using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc; using Microsoft.AspNetCore.Mvc;
using System.ComponentModel.DataAnnotations; using System.ComponentModel.DataAnnotations;
using TakeoutSaaS.AdminApi.Contracts.Requests;
using TakeoutSaaS.Application.App.Tenants.Commands; using TakeoutSaaS.Application.App.Tenants.Commands;
using TakeoutSaaS.Application.App.Tenants.Dto; using TakeoutSaaS.Application.App.Tenants.Dto;
using TakeoutSaaS.Application.App.Tenants.Queries; using TakeoutSaaS.Application.App.Tenants.Queries;
@@ -26,10 +27,18 @@ public sealed class TenantBillingsController(IMediator mediator) : BaseApiContro
[HttpGet] [HttpGet]
[PermissionAuthorize("tenant-bill:read")] [PermissionAuthorize("tenant-bill:read")]
[ProducesResponseType(typeof(ApiResponse<PagedResult<TenantBillingDto>>), StatusCodes.Status200OK)] [ProducesResponseType(typeof(ApiResponse<PagedResult<TenantBillingDto>>), StatusCodes.Status200OK)]
public async Task<ApiResponse<PagedResult<TenantBillingDto>>> Search(long tenantId, [FromQuery] SearchTenantBillsQuery query, CancellationToken cancellationToken) public async Task<ApiResponse<PagedResult<TenantBillingDto>>> Search(long tenantId, [FromQuery] SearchTenantBillsRequest request, CancellationToken cancellationToken)
{ {
// 1. 绑定租户标识 // 1. 组装查询对象TenantId 仅来自路由,避免与 QueryString 重复)
query = query with { TenantId = tenantId }; var query = new SearchTenantBillsQuery
{
TenantId = tenantId,
Status = request.Status,
From = request.From,
To = request.To,
Page = request.Page,
PageSize = request.PageSize,
};
// 2. 查询账单列表 // 2. 查询账单列表
var result = await mediator.Send(query, cancellationToken); var result = await mediator.Send(query, cancellationToken);

View File

@@ -1,3 +1,4 @@
using System.Text.Json;
using TakeoutSaaS.Application.App.Billings.Dto; using TakeoutSaaS.Application.App.Billings.Dto;
using TakeoutSaaS.Domain.Tenants.Entities; using TakeoutSaaS.Domain.Tenants.Entities;
@@ -9,7 +10,7 @@ namespace TakeoutSaaS.Application.App.Billings;
internal static class BillingMapping internal static class BillingMapping
{ {
/// <summary> /// <summary>
/// 将账单实体映射为账单 DTO。 /// 将账单实体映射为账单 DTO(旧版)
/// </summary> /// </summary>
/// <param name="bill">账单实体。</param> /// <param name="bill">账单实体。</param>
/// <param name="tenantName">租户名称。</param> /// <param name="tenantName">租户名称。</param>
@@ -31,7 +32,43 @@ internal static class BillingMapping
}; };
/// <summary> /// <summary>
/// 将账单实体与支付记录映射为账单详情 DTO。 /// 将账单实体映射为账单列表 DTO(新版)
/// </summary>
/// <param name="billing">账单实体。</param>
/// <param name="tenantName">租户名称。</param>
/// <returns>账单列表 DTO。</returns>
public static BillingListDto ToBillingListDto(this TenantBillingStatement billing, string? tenantName = null)
=> new()
{
Id = billing.Id,
TenantId = billing.TenantId,
SubscriptionId = billing.SubscriptionId,
TenantName = tenantName ?? string.Empty,
StatementNo = billing.StatementNo,
BillingType = billing.BillingType,
Status = billing.Status,
PeriodStart = billing.PeriodStart,
PeriodEnd = billing.PeriodEnd,
AmountDue = billing.AmountDue,
AmountPaid = billing.AmountPaid,
DiscountAmount = billing.DiscountAmount,
TaxAmount = billing.TaxAmount,
TotalAmount = billing.CalculateTotalAmount(),
Currency = billing.Currency,
DueDate = billing.DueDate,
CreatedAt = billing.CreatedAt,
UpdatedAt = billing.UpdatedAt,
IsOverdue = billing.Status == TakeoutSaaS.Domain.Tenants.Enums.TenantBillingStatus.Overdue
|| (billing.Status == TakeoutSaaS.Domain.Tenants.Enums.TenantBillingStatus.Pending && billing.DueDate < DateTime.UtcNow),
OverdueDays = (billing.Status is TakeoutSaaS.Domain.Tenants.Enums.TenantBillingStatus.Pending
or TakeoutSaaS.Domain.Tenants.Enums.TenantBillingStatus.Overdue)
&& billing.DueDate < DateTime.UtcNow
? (int)(DateTime.UtcNow - billing.DueDate).TotalDays
: 0
};
/// <summary>
/// 将账单实体与支付记录映射为账单详情 DTO旧版
/// </summary> /// </summary>
/// <param name="bill">账单实体。</param> /// <param name="bill">账单实体。</param>
/// <param name="payments">支付记录列表。</param> /// <param name="payments">支付记录列表。</param>
@@ -59,7 +96,64 @@ internal static class BillingMapping
}; };
/// <summary> /// <summary>
/// 将支付记录实体映射为支付 DTO /// 将账单实体与支付记录映射为账单详情 DTO新版
/// </summary>
/// <param name="billing">账单实体。</param>
/// <param name="payments">支付记录列表。</param>
/// <param name="tenantName">租户名称。</param>
/// <returns>账单详情 DTO。</returns>
public static BillingDetailDto ToBillingDetailDto(
this TenantBillingStatement billing,
List<TenantPayment> payments,
string? tenantName = null)
{
// 反序列化账单明细
var lineItems = new List<BillingLineItemDto>();
if (!string.IsNullOrWhiteSpace(billing.LineItemsJson))
{
try
{
lineItems = JsonSerializer.Deserialize<List<BillingLineItemDto>>(billing.LineItemsJson) ?? [];
}
catch
{
lineItems = [];
}
}
return new BillingDetailDto
{
Id = billing.Id,
TenantId = billing.TenantId,
TenantName = tenantName ?? string.Empty,
SubscriptionId = billing.SubscriptionId,
StatementNo = billing.StatementNo,
BillingType = billing.BillingType,
Status = billing.Status,
PeriodStart = billing.PeriodStart,
PeriodEnd = billing.PeriodEnd,
AmountDue = billing.AmountDue,
AmountPaid = billing.AmountPaid,
DiscountAmount = billing.DiscountAmount,
TaxAmount = billing.TaxAmount,
TotalAmount = billing.CalculateTotalAmount(),
Currency = billing.Currency,
DueDate = billing.DueDate,
ReminderSentAt = billing.ReminderSentAt,
OverdueNotifiedAt = billing.OverdueNotifiedAt,
LineItemsJson = billing.LineItemsJson,
LineItems = lineItems,
Payments = payments.Select(p => p.ToPaymentRecordDto()).ToList(),
Notes = billing.Notes,
CreatedAt = billing.CreatedAt,
CreatedBy = billing.CreatedBy,
UpdatedAt = billing.UpdatedAt,
UpdatedBy = billing.UpdatedBy
};
}
/// <summary>
/// 将支付记录实体映射为支付 DTO旧版
/// </summary> /// </summary>
/// <param name="payment">支付记录实体。</param> /// <param name="payment">支付记录实体。</param>
/// <returns>支付 DTO。</returns> /// <returns>支付 DTO。</returns>
@@ -77,4 +171,30 @@ internal static class BillingMapping
Notes = payment.Notes, Notes = payment.Notes,
CreatedAt = payment.CreatedAt CreatedAt = payment.CreatedAt
}; };
/// <summary>
/// 将支付记录实体映射为支付记录 DTO新版
/// </summary>
/// <param name="payment">支付记录实体。</param>
/// <returns>支付记录 DTO。</returns>
public static PaymentRecordDto ToPaymentRecordDto(this TenantPayment payment)
=> new()
{
Id = payment.Id,
TenantId = payment.TenantId,
BillingId = payment.BillingStatementId,
Amount = payment.Amount,
Method = payment.Method,
Status = payment.Status,
TransactionNo = payment.TransactionNo,
ProofUrl = payment.ProofUrl,
IsVerified = payment.VerifiedAt.HasValue,
PaidAt = payment.PaidAt,
VerifiedBy = payment.VerifiedBy,
VerifiedAt = payment.VerifiedAt,
RefundReason = payment.RefundReason,
RefundedAt = payment.RefundedAt,
Notes = payment.Notes,
CreatedAt = payment.CreatedAt
};
} }

View File

@@ -0,0 +1,25 @@
using MediatR;
using TakeoutSaaS.Domain.Tenants.Enums;
namespace TakeoutSaaS.Application.App.Billings.Commands;
/// <summary>
/// 批量更新账单状态命令。
/// </summary>
public sealed record BatchUpdateStatusCommand : IRequest<int>
{
/// <summary>
/// 账单 ID 列表(雪花算法)。
/// </summary>
public long[] BillingIds { get; init; } = [];
/// <summary>
/// 新状态。
/// </summary>
public TenantBillingStatus NewStatus { get; init; }
/// <summary>
/// 批量操作备注。
/// </summary>
public string? Notes { get; init; }
}

View File

@@ -0,0 +1,19 @@
using MediatR;
namespace TakeoutSaaS.Application.App.Billings.Commands;
/// <summary>
/// 取消账单命令。
/// </summary>
public sealed record CancelBillingCommand : IRequest<Unit>
{
/// <summary>
/// 账单 ID雪花算法
/// </summary>
public long BillingId { get; init; }
/// <summary>
/// 取消原因。
/// </summary>
public string Reason { get; init; } = string.Empty;
}

View File

@@ -0,0 +1,41 @@
using MediatR;
using TakeoutSaaS.Application.App.Billings.Dto;
using TakeoutSaaS.Domain.Tenants.Enums;
namespace TakeoutSaaS.Application.App.Billings.Commands;
/// <summary>
/// 创建账单命令。
/// </summary>
public sealed record CreateBillingCommand : IRequest<BillingDetailDto>
{
/// <summary>
/// 租户 ID雪花算法
/// </summary>
public long TenantId { get; init; }
/// <summary>
/// 账单类型。
/// </summary>
public BillingType BillingType { get; init; }
/// <summary>
/// 应付金额。
/// </summary>
public decimal AmountDue { get; init; }
/// <summary>
/// 到期日UTC
/// </summary>
public DateTime DueDate { get; init; }
/// <summary>
/// 账单明细列表。
/// </summary>
public List<BillingLineItemDto> LineItems { get; init; } = [];
/// <summary>
/// 备注。
/// </summary>
public string? Notes { get; init; }
}

View File

@@ -0,0 +1,15 @@
using MediatR;
using TakeoutSaaS.Application.App.Billings.Dto;
namespace TakeoutSaaS.Application.App.Billings.Commands;
/// <summary>
/// 生成订阅账单命令(自动化场景)。
/// </summary>
public sealed record GenerateSubscriptionBillingCommand : IRequest<BillingDetailDto>
{
/// <summary>
/// 订阅 ID雪花算法
/// </summary>
public long SubscriptionId { get; init; }
}

View File

@@ -0,0 +1,10 @@
using MediatR;
namespace TakeoutSaaS.Application.App.Billings.Commands;
/// <summary>
/// 处理逾期账单命令(后台任务场景)。
/// </summary>
public sealed record ProcessOverdueBillingsCommand : IRequest<int>
{
}

View File

@@ -7,12 +7,12 @@ namespace TakeoutSaaS.Application.App.Billings.Commands;
/// <summary> /// <summary>
/// 记录支付命令。 /// 记录支付命令。
/// </summary> /// </summary>
public sealed record RecordPaymentCommand : IRequest<PaymentDto> public sealed record RecordPaymentCommand : IRequest<PaymentRecordDto>
{ {
/// <summary> /// <summary>
/// 账单 ID雪花算法 /// 账单 ID雪花算法
/// </summary> /// </summary>
public long BillId { get; init; } public long BillingId { get; init; }
/// <summary> /// <summary>
/// 支付金额。 /// 支付金额。
@@ -22,7 +22,7 @@ public sealed record RecordPaymentCommand : IRequest<PaymentDto>
/// <summary> /// <summary>
/// 支付方式。 /// 支付方式。
/// </summary> /// </summary>
public PaymentMethod Method { get; init; } public TenantPaymentMethod Method { get; init; }
/// <summary> /// <summary>
/// 交易号。 /// 交易号。

View File

@@ -0,0 +1,25 @@
using MediatR;
using TakeoutSaaS.Domain.Tenants.Enums;
namespace TakeoutSaaS.Application.App.Billings.Commands;
/// <summary>
/// 更新账单状态命令。
/// </summary>
public sealed record UpdateBillingStatusCommand : IRequest<Unit>
{
/// <summary>
/// 账单 ID雪花算法
/// </summary>
public long BillingId { get; init; }
/// <summary>
/// 新状态。
/// </summary>
public TenantBillingStatus NewStatus { get; init; }
/// <summary>
/// 备注。
/// </summary>
public string? Notes { get; init; }
}

View File

@@ -0,0 +1,28 @@
using MediatR;
using System.ComponentModel.DataAnnotations;
using TakeoutSaaS.Application.App.Billings.Dto;
namespace TakeoutSaaS.Application.App.Billings.Commands;
/// <summary>
/// 审核支付命令。
/// </summary>
public sealed record VerifyPaymentCommand : IRequest<PaymentRecordDto>
{
/// <summary>
/// 支付记录 ID雪花算法
/// </summary>
[Required]
public long PaymentId { get; init; }
/// <summary>
/// 是否通过审核。
/// </summary>
public bool Approved { get; init; }
/// <summary>
/// 审核备注(可选)。
/// </summary>
[MaxLength(512)]
public string? Notes { get; init; }
}

View File

@@ -0,0 +1,146 @@
using System.Text.Json.Serialization;
using TakeoutSaaS.Domain.Tenants.Enums;
using TakeoutSaaS.Shared.Abstractions.Serialization;
namespace TakeoutSaaS.Application.App.Billings.Dto;
/// <summary>
/// 账单详情 DTO管理员端
/// </summary>
public sealed record BillingDetailDto
{
/// <summary>
/// 账单 ID。
/// </summary>
[JsonConverter(typeof(SnowflakeIdJsonConverter))]
public long Id { get; init; }
/// <summary>
/// 租户 ID。
/// </summary>
[JsonConverter(typeof(SnowflakeIdJsonConverter))]
public long TenantId { get; init; }
/// <summary>
/// 租户名称。
/// </summary>
public string TenantName { get; init; } = string.Empty;
/// <summary>
/// 账单编号。
/// </summary>
public string StatementNo { get; init; } = string.Empty;
/// <summary>
/// 计费周期开始时间UTC
/// </summary>
public DateTime PeriodStart { get; init; }
/// <summary>
/// 计费周期结束时间UTC
/// </summary>
public DateTime PeriodEnd { get; init; }
/// <summary>
/// 账单类型。
/// </summary>
public BillingType BillingType { get; init; }
/// <summary>
/// 账单状态。
/// </summary>
public TenantBillingStatus Status { get; init; }
/// <summary>
/// 应付金额。
/// </summary>
public decimal AmountDue { get; init; }
/// <summary>
/// 已支付金额。
/// </summary>
public decimal AmountPaid { get; init; }
/// <summary>
/// 折扣金额。
/// </summary>
public decimal DiscountAmount { get; init; }
/// <summary>
/// 税费金额。
/// </summary>
public decimal TaxAmount { get; init; }
/// <summary>
/// 总金额(应付金额 - 折扣 + 税费)。
/// </summary>
public decimal TotalAmount { get; init; }
/// <summary>
/// 币种。
/// </summary>
public string Currency { get; init; } = "CNY";
/// <summary>
/// 到期日。
/// </summary>
public DateTime DueDate { get; init; }
/// <summary>
/// 订阅 ID可选
/// </summary>
[JsonConverter(typeof(NullableSnowflakeIdJsonConverter))]
public long? SubscriptionId { get; init; }
/// <summary>
/// 账单明细 JSON原始字符串
/// </summary>
public string? LineItemsJson { get; init; }
/// <summary>
/// 账单明细行项目。
/// </summary>
public IReadOnlyList<BillingLineItemDto> LineItems { get; init; } = [];
/// <summary>
/// 支付记录。
/// </summary>
public IReadOnlyList<PaymentRecordDto> Payments { get; init; } = [];
/// <summary>
/// 提醒发送时间。
/// </summary>
public DateTime? ReminderSentAt { get; init; }
/// <summary>
/// 逾期通知时间。
/// </summary>
public DateTime? OverdueNotifiedAt { get; init; }
/// <summary>
/// 备注。
/// </summary>
public string? Notes { get; init; }
/// <summary>
/// 创建时间。
/// </summary>
public DateTime CreatedAt { get; init; }
/// <summary>
/// 创建人 ID。
/// </summary>
[JsonConverter(typeof(NullableSnowflakeIdJsonConverter))]
public long? CreatedBy { get; init; }
/// <summary>
/// 更新时间。
/// </summary>
public DateTime? UpdatedAt { get; init; }
/// <summary>
/// 更新人 ID。
/// </summary>
[JsonConverter(typeof(NullableSnowflakeIdJsonConverter))]
public long? UpdatedBy { get; init; }
}

View File

@@ -0,0 +1,545 @@
using System.Text.Json.Serialization;
using TakeoutSaaS.Domain.Tenants.Enums;
using TakeoutSaaS.Shared.Abstractions.Serialization;
namespace TakeoutSaaS.Application.App.Billings.Dto.Legacy;
/// <summary>
/// 账单列表 DTO用于列表展示
/// </summary>
public sealed record BillingListDto
{
/// <summary>
/// 账单 ID雪花算法序列化为字符串
/// </summary>
[JsonConverter(typeof(SnowflakeIdJsonConverter))]
public long Id { get; init; }
/// <summary>
/// 租户 ID雪花算法序列化为字符串
/// </summary>
[JsonConverter(typeof(SnowflakeIdJsonConverter))]
public long TenantId { get; init; }
/// <summary>
/// 租户名称。
/// </summary>
public string TenantName { get; init; } = string.Empty;
/// <summary>
/// 关联订阅 ID仅订阅/续费账单可能有值)。
/// </summary>
[JsonConverter(typeof(NullableSnowflakeIdJsonConverter))]
public long? SubscriptionId { get; init; }
/// <summary>
/// 账单编号。
/// </summary>
public string StatementNo { get; init; } = string.Empty;
/// <summary>
/// 账单类型。
/// </summary>
public BillingType BillingType { get; init; }
/// <summary>
/// 计费周期开始时间UTC
/// </summary>
public DateTime PeriodStart { get; init; }
/// <summary>
/// 计费周期结束时间UTC
/// </summary>
public DateTime PeriodEnd { get; init; }
/// <summary>
/// 应付金额。
/// </summary>
public decimal AmountDue { get; init; }
/// <summary>
/// 折扣金额。
/// </summary>
public decimal DiscountAmount { get; init; }
/// <summary>
/// 税费金额。
/// </summary>
public decimal TaxAmount { get; init; }
/// <summary>
/// 总金额(应付金额 - 折扣 + 税费)。
/// </summary>
public decimal TotalAmount { get; init; }
/// <summary>
/// 已付金额。
/// </summary>
public decimal AmountPaid { get; init; }
/// <summary>
/// 币种。
/// </summary>
public string Currency { get; init; } = "CNY";
/// <summary>
/// 账单状态。
/// </summary>
public TenantBillingStatus Status { get; init; }
/// <summary>
/// 到期日UTC
/// </summary>
public DateTime DueDate { get; init; }
/// <summary>
/// 是否已逾期(根据到期日与状态综合判断)。
/// </summary>
public bool IsOverdue { get; init; }
/// <summary>
/// 逾期天数(未逾期为 0
/// </summary>
public int OverdueDays { get; init; }
/// <summary>
/// 创建时间UTC
/// </summary>
public DateTime CreatedAt { get; init; }
/// <summary>
/// 更新时间UTC
/// </summary>
public DateTime? UpdatedAt { get; init; }
}
/// <summary>
/// 账单详情 DTO含明细项
/// </summary>
public sealed record BillingDetailDto
{
/// <summary>
/// 账单 ID雪花算法序列化为字符串
/// </summary>
[JsonConverter(typeof(SnowflakeIdJsonConverter))]
public long Id { get; init; }
/// <summary>
/// 租户 ID雪花算法序列化为字符串
/// </summary>
[JsonConverter(typeof(SnowflakeIdJsonConverter))]
public long TenantId { get; init; }
/// <summary>
/// 租户名称。
/// </summary>
public string TenantName { get; init; } = string.Empty;
/// <summary>
/// 账单编号。
/// </summary>
public string StatementNo { get; init; } = string.Empty;
/// <summary>
/// 关联订阅 ID仅订阅/续费账单可能有值)。
/// </summary>
[JsonConverter(typeof(NullableSnowflakeIdJsonConverter))]
public long? SubscriptionId { get; init; }
/// <summary>
/// 账单类型。
/// </summary>
public BillingType BillingType { get; init; }
/// <summary>
/// 计费周期开始时间UTC
/// </summary>
public DateTime PeriodStart { get; init; }
/// <summary>
/// 计费周期结束时间UTC
/// </summary>
public DateTime PeriodEnd { get; init; }
/// <summary>
/// 应付金额。
/// </summary>
public decimal AmountDue { get; init; }
/// <summary>
/// 折扣金额。
/// </summary>
public decimal DiscountAmount { get; init; }
/// <summary>
/// 税费金额。
/// </summary>
public decimal TaxAmount { get; init; }
/// <summary>
/// 总金额(应付金额 - 折扣 + 税费)。
/// </summary>
public decimal TotalAmount { get; init; }
/// <summary>
/// 已付金额。
/// </summary>
public decimal AmountPaid { get; init; }
/// <summary>
/// 币种。
/// </summary>
public string Currency { get; init; } = "CNY";
/// <summary>
/// 账单状态。
/// </summary>
public TenantBillingStatus Status { get; init; }
/// <summary>
/// 到期日UTC
/// </summary>
public DateTime DueDate { get; init; }
/// <summary>
/// 账单明细 JSON。
/// </summary>
public string? LineItemsJson { get; init; }
/// <summary>
/// 账单明细列表(从 JSON 反序列化)。
/// </summary>
public IReadOnlyList<BillingLineItemDto> LineItems { get; init; } = [];
/// <summary>
/// 支付记录列表。
/// </summary>
public IReadOnlyList<PaymentRecordDto> Payments { get; init; } = [];
/// <summary>
/// 备注信息。
/// </summary>
public string? Notes { get; init; }
/// <summary>
/// 创建时间UTC
/// </summary>
public DateTime CreatedAt { get; init; }
/// <summary>
/// 更新时间UTC
/// </summary>
public DateTime? UpdatedAt { get; init; }
}
/// <summary>
/// 账单明细项 DTO。
/// </summary>
public sealed record BillingLineItemDto
{
/// <summary>
/// 明细类型(如:套餐费、配额包费用、其他费用)。
/// </summary>
public string ItemType { get; init; } = string.Empty;
/// <summary>
/// 描述。
/// </summary>
public string Description { get; init; } = string.Empty;
/// <summary>
/// 数量。
/// </summary>
public decimal Quantity { get; init; }
/// <summary>
/// 单价。
/// </summary>
public decimal UnitPrice { get; init; }
/// <summary>
/// 金额(数量 × 单价)。
/// </summary>
public decimal Amount { get; init; }
/// <summary>
/// 折扣率0-1
/// </summary>
public decimal? DiscountRate { get; init; }
}
/// <summary>
/// 支付记录 DTO。
/// </summary>
public sealed record PaymentRecordDto
{
/// <summary>
/// 支付记录 ID雪花算法序列化为字符串
/// </summary>
[JsonConverter(typeof(SnowflakeIdJsonConverter))]
public long Id { get; init; }
/// <summary>
/// 租户 ID雪花算法序列化为字符串
/// </summary>
[JsonConverter(typeof(SnowflakeIdJsonConverter))]
public long TenantId { get; init; }
/// <summary>
/// 账单 ID雪花算法序列化为字符串
/// </summary>
[JsonConverter(typeof(SnowflakeIdJsonConverter))]
public long BillingId { get; init; }
/// <summary>
/// 支付金额。
/// </summary>
public decimal Amount { get; init; }
/// <summary>
/// 支付方式。
/// </summary>
public TenantPaymentMethod Method { get; init; }
/// <summary>
/// 支付状态。
/// </summary>
public TenantPaymentStatus Status { get; init; }
/// <summary>
/// 支付流水号。
/// </summary>
public string? TransactionNo { get; init; }
/// <summary>
/// 支付凭证 URL。
/// </summary>
public string? ProofUrl { get; init; }
/// <summary>
/// 备注。
/// </summary>
public string? Notes { get; init; }
/// <summary>
/// 审核状态(待审核/已通过/已拒绝)。
/// </summary>
public bool IsVerified { get; init; }
/// <summary>
/// 审核人 ID雪花算法序列化为字符串
/// </summary>
[JsonConverter(typeof(NullableSnowflakeIdJsonConverter))]
public long? VerifiedBy { get; init; }
/// <summary>
/// 审核时间UTC
/// </summary>
public DateTime? VerifiedAt { get; init; }
/// <summary>
/// 退款原因。
/// </summary>
public string? RefundReason { get; init; }
/// <summary>
/// 退款时间UTC
/// </summary>
public DateTime? RefundedAt { get; init; }
/// <summary>
/// 支付时间UTC
/// </summary>
public DateTime? PaidAt { get; init; }
/// <summary>
/// 创建时间UTC
/// </summary>
public DateTime CreatedAt { get; init; }
}
/// <summary>
/// 账单统计 DTO。
/// </summary>
public sealed record BillingStatisticsDto
{
/// <summary>
/// 租户 ID为空表示跨租户统计
/// </summary>
[JsonConverter(typeof(NullableSnowflakeIdJsonConverter))]
public long? TenantId { get; init; }
/// <summary>
/// 统计周期开始时间UTC
/// </summary>
public DateTime StartDate { get; init; }
/// <summary>
/// 统计周期结束时间UTC
/// </summary>
public DateTime EndDate { get; init; }
/// <summary>
/// 分组方式Day/Week/Month
/// </summary>
public string GroupBy { get; init; } = "Day";
/// <summary>
/// 总账单数量。
/// </summary>
public int TotalCount { get; init; }
/// <summary>
/// 待付款账单数量。
/// </summary>
public int PendingCount { get; init; }
/// <summary>
/// 已付款账单数量。
/// </summary>
public int PaidCount { get; init; }
/// <summary>
/// 逾期账单数量。
/// </summary>
public int OverdueCount { get; init; }
/// <summary>
/// 已取消账单数量。
/// </summary>
public int CancelledCount { get; init; }
/// <summary>
/// 总应收金额(账单原始应付)。
/// </summary>
public decimal TotalAmountDue { get; init; }
/// <summary>
/// 总实收金额。
/// </summary>
public decimal TotalAmountPaid { get; init; }
/// <summary>
/// 总未收金额(总金额 - 实收)。
/// </summary>
public decimal TotalAmountUnpaid { get; init; }
/// <summary>
/// 逾期未收金额。
/// </summary>
public decimal TotalOverdueAmount { get; init; }
/// <summary>
/// 分组统计应收金额趋势Key 为分组起始日期 yyyy-MM-dd
/// </summary>
public Dictionary<string, decimal> AmountDueTrend { get; init; } = [];
/// <summary>
/// 分组统计实收金额趋势Key 为分组起始日期 yyyy-MM-dd
/// </summary>
public Dictionary<string, decimal> AmountPaidTrend { get; init; } = [];
/// <summary>
/// 分组统计账单数量趋势Key 为分组起始日期 yyyy-MM-dd
/// </summary>
public Dictionary<string, int> CountTrend { get; init; } = [];
}
/// <summary>
/// 账单导出 DTO。
/// </summary>
public sealed record BillingExportDto
{
/// <summary>
/// 账单 ID雪花算法序列化为字符串
/// </summary>
[JsonConverter(typeof(SnowflakeIdJsonConverter))]
public long Id { get; init; }
/// <summary>
/// 租户 ID雪花算法序列化为字符串
/// </summary>
[JsonConverter(typeof(SnowflakeIdJsonConverter))]
public long TenantId { get; init; }
/// <summary>
/// 租户名称。
/// </summary>
public string TenantName { get; init; } = string.Empty;
/// <summary>
/// 账单编号。
/// </summary>
public string StatementNo { get; init; } = string.Empty;
/// <summary>
/// 关联订阅 ID仅订阅/续费账单可能有值)。
/// </summary>
[JsonConverter(typeof(NullableSnowflakeIdJsonConverter))]
public long? SubscriptionId { get; init; }
/// <summary>
/// 账单类型。
/// </summary>
public BillingType BillingType { get; init; }
/// <summary>
/// 计费周期开始时间UTC
/// </summary>
public DateTime PeriodStart { get; init; }
/// <summary>
/// 计费周期结束时间UTC
/// </summary>
public DateTime PeriodEnd { get; init; }
/// <summary>
/// 应付金额。
/// </summary>
public decimal AmountDue { get; init; }
/// <summary>
/// 折扣金额。
/// </summary>
public decimal DiscountAmount { get; init; }
/// <summary>
/// 税费金额。
/// </summary>
public decimal TaxAmount { get; init; }
/// <summary>
/// 总金额。
/// </summary>
public decimal TotalAmount { get; init; }
/// <summary>
/// 已付金额。
/// </summary>
public decimal AmountPaid { get; init; }
/// <summary>
/// 账单状态。
/// </summary>
public TenantBillingStatus Status { get; init; }
/// <summary>
/// 币种。
/// </summary>
public string Currency { get; init; } = "CNY";
/// <summary>
/// 到期日UTC
/// </summary>
public DateTime DueDate { get; init; }
/// <summary>
/// 备注信息。
/// </summary>
public string? Notes { get; init; }
/// <summary>
/// 账单明细列表。
/// </summary>
public List<BillingLineItemDto> LineItems { get; init; } = [];
}

View File

@@ -0,0 +1,104 @@
using System.Text.Json.Serialization;
using TakeoutSaaS.Domain.Tenants.Enums;
using TakeoutSaaS.Shared.Abstractions.Serialization;
namespace TakeoutSaaS.Application.App.Billings.Dto;
/// <summary>
/// 账单导出 DTO。
/// </summary>
public sealed record BillingExportDto
{
/// <summary>
/// 账单 ID。
/// </summary>
[JsonConverter(typeof(SnowflakeIdJsonConverter))]
public long Id { get; init; }
/// <summary>
/// 租户 ID。
/// </summary>
[JsonConverter(typeof(SnowflakeIdJsonConverter))]
public long TenantId { get; init; }
/// <summary>
/// 租户名称。
/// </summary>
public string TenantName { get; init; } = string.Empty;
/// <summary>
/// 账单编号。
/// </summary>
public string StatementNo { get; init; } = string.Empty;
/// <summary>
/// 订阅 ID可选
/// </summary>
[JsonConverter(typeof(NullableSnowflakeIdJsonConverter))]
public long? SubscriptionId { get; init; }
/// <summary>
/// 账单类型。
/// </summary>
public BillingType BillingType { get; init; }
/// <summary>
/// 账单状态。
/// </summary>
public TenantBillingStatus Status { get; init; }
/// <summary>
/// 计费周期开始时间UTC
/// </summary>
public DateTime PeriodStart { get; init; }
/// <summary>
/// 计费周期结束时间UTC
/// </summary>
public DateTime PeriodEnd { get; init; }
/// <summary>
/// 应付金额。
/// </summary>
public decimal AmountDue { get; init; }
/// <summary>
/// 折扣金额。
/// </summary>
public decimal DiscountAmount { get; init; }
/// <summary>
/// 税费金额。
/// </summary>
public decimal TaxAmount { get; init; }
/// <summary>
/// 总金额(应付金额 - 折扣 + 税费)。
/// </summary>
public decimal TotalAmount { get; init; }
/// <summary>
/// 已支付金额。
/// </summary>
public decimal AmountPaid { get; init; }
/// <summary>
/// 币种。
/// </summary>
public string Currency { get; init; } = "CNY";
/// <summary>
/// 到期日UTC
/// </summary>
public DateTime DueDate { get; init; }
/// <summary>
/// 备注。
/// </summary>
public string? Notes { get; init; }
/// <summary>
/// 账单明细。
/// </summary>
public IReadOnlyList<BillingLineItemDto> LineItems { get; init; } = [];
}

View File

@@ -0,0 +1,37 @@
namespace TakeoutSaaS.Application.App.Billings.Dto;
/// <summary>
/// 账单明细行项目 DTO。
/// </summary>
public sealed record BillingLineItemDto
{
/// <summary>
/// 明细类型(如:订阅费、配额包费用、其他费用)。
/// </summary>
public string ItemType { get; init; } = string.Empty;
/// <summary>
/// 描述。
/// </summary>
public string Description { get; init; } = string.Empty;
/// <summary>
/// 数量。
/// </summary>
public decimal Quantity { get; init; }
/// <summary>
/// 单价。
/// </summary>
public decimal UnitPrice { get; init; }
/// <summary>
/// 金额(数量 × 单价)。
/// </summary>
public decimal Amount { get; init; }
/// <summary>
/// 折扣率0-1可选
/// </summary>
public decimal? DiscountRate { get; init; }
}

View File

@@ -0,0 +1,114 @@
using System.Text.Json.Serialization;
using TakeoutSaaS.Domain.Tenants.Enums;
using TakeoutSaaS.Shared.Abstractions.Serialization;
namespace TakeoutSaaS.Application.App.Billings.Dto;
/// <summary>
/// 账单列表 DTO管理员端列表展示
/// </summary>
public sealed record BillingListDto
{
/// <summary>
/// 账单 ID。
/// </summary>
[JsonConverter(typeof(SnowflakeIdJsonConverter))]
public long Id { get; init; }
/// <summary>
/// 租户 ID。
/// </summary>
[JsonConverter(typeof(SnowflakeIdJsonConverter))]
public long TenantId { get; init; }
/// <summary>
/// 租户名称。
/// </summary>
public string TenantName { get; init; } = string.Empty;
/// <summary>
/// 订阅 ID可选
/// </summary>
[JsonConverter(typeof(NullableSnowflakeIdJsonConverter))]
public long? SubscriptionId { get; init; }
/// <summary>
/// 账单编号。
/// </summary>
public string StatementNo { get; init; } = string.Empty;
/// <summary>
/// 计费周期开始时间UTC
/// </summary>
public DateTime PeriodStart { get; init; }
/// <summary>
/// 计费周期结束时间UTC
/// </summary>
public DateTime PeriodEnd { get; init; }
/// <summary>
/// 账单类型。
/// </summary>
public BillingType BillingType { get; init; }
/// <summary>
/// 账单状态。
/// </summary>
public TenantBillingStatus Status { get; init; }
/// <summary>
/// 应付金额。
/// </summary>
public decimal AmountDue { get; init; }
/// <summary>
/// 已支付金额。
/// </summary>
public decimal AmountPaid { get; init; }
/// <summary>
/// 折扣金额。
/// </summary>
public decimal DiscountAmount { get; init; }
/// <summary>
/// 税费金额。
/// </summary>
public decimal TaxAmount { get; init; }
/// <summary>
/// 总金额(应付金额 - 折扣 + 税费)。
/// </summary>
public decimal TotalAmount { get; init; }
/// <summary>
/// 币种。
/// </summary>
public string Currency { get; init; } = "CNY";
/// <summary>
/// 到期日。
/// </summary>
public DateTime DueDate { get; init; }
/// <summary>
/// 创建时间。
/// </summary>
public DateTime CreatedAt { get; init; }
/// <summary>
/// 更新时间。
/// </summary>
public DateTime? UpdatedAt { get; init; }
/// <summary>
/// 是否逾期。
/// </summary>
public bool IsOverdue { get; init; }
/// <summary>
/// 逾期天数(未逾期为 0
/// </summary>
public int OverdueDays { get; init; }
}

View File

@@ -0,0 +1,91 @@
using System.Text.Json.Serialization;
using TakeoutSaaS.Shared.Abstractions.Serialization;
namespace TakeoutSaaS.Application.App.Billings.Dto;
/// <summary>
/// 账单统计数据 DTO。
/// </summary>
public sealed record BillingStatisticsDto
{
/// <summary>
/// 租户 ID可选管理员可跨租户统计
/// </summary>
[JsonConverter(typeof(NullableSnowflakeIdJsonConverter))]
public long? TenantId { get; init; }
/// <summary>
/// 统计开始时间UTC
/// </summary>
public DateTime StartDate { get; init; }
/// <summary>
/// 统计结束时间UTC
/// </summary>
public DateTime EndDate { get; init; }
/// <summary>
/// 分组方式Day/Week/Month
/// </summary>
public string GroupBy { get; init; } = "Day";
/// <summary>
/// 总账单数量。
/// </summary>
public int TotalCount { get; init; }
/// <summary>
/// 待支付账单数量。
/// </summary>
public int PendingCount { get; init; }
/// <summary>
/// 已支付账单数量。
/// </summary>
public int PaidCount { get; init; }
/// <summary>
/// 逾期账单数量。
/// </summary>
public int OverdueCount { get; init; }
/// <summary>
/// 已取消账单数量。
/// </summary>
public int CancelledCount { get; init; }
/// <summary>
/// 总应收金额。
/// </summary>
public decimal TotalAmountDue { get; init; }
/// <summary>
/// 已收金额。
/// </summary>
public decimal TotalAmountPaid { get; init; }
/// <summary>
/// 未收金额。
/// </summary>
public decimal TotalAmountUnpaid { get; init; }
/// <summary>
/// 逾期金额。
/// </summary>
public decimal TotalOverdueAmount { get; init; }
/// <summary>
/// 应收金额趋势Key 为日期桶字符串)。
/// </summary>
public IReadOnlyDictionary<string, decimal> AmountDueTrend { get; init; } = new Dictionary<string, decimal>();
/// <summary>
/// 实收金额趋势Key 为日期桶字符串)。
/// </summary>
public IReadOnlyDictionary<string, decimal> AmountPaidTrend { get; init; } = new Dictionary<string, decimal>();
/// <summary>
/// 数量趋势Key 为日期桶字符串)。
/// </summary>
public IReadOnlyDictionary<string, int> CountTrend { get; init; } = new Dictionary<string, int>();
}

View File

@@ -0,0 +1,27 @@
namespace TakeoutSaaS.Application.App.Billings.Dto;
/// <summary>
/// 账单趋势数据点 DTO。
/// </summary>
public sealed record BillingTrendPointDto
{
/// <summary>
/// 分组时间点Day/Week/Month 对齐后的时间)。
/// </summary>
public DateTime Period { get; init; }
/// <summary>
/// 账单数量。
/// </summary>
public int Count { get; init; }
/// <summary>
/// 应收金额。
/// </summary>
public decimal AmountDue { get; init; }
/// <summary>
/// 实收金额。
/// </summary>
public decimal AmountPaid { get; init; }
}

View File

@@ -29,12 +29,12 @@ public sealed record PaymentDto
/// <summary> /// <summary>
/// 支付方式。 /// 支付方式。
/// </summary> /// </summary>
public PaymentMethod Method { get; init; } public TenantPaymentMethod Method { get; init; }
/// <summary> /// <summary>
/// 支付状态。 /// 支付状态。
/// </summary> /// </summary>
public PaymentStatus Status { get; init; } public TenantPaymentStatus Status { get; init; }
/// <summary> /// <summary>
/// 交易号。 /// 交易号。

View File

@@ -0,0 +1,95 @@
using System.Text.Json.Serialization;
using TakeoutSaaS.Domain.Tenants.Enums;
using TakeoutSaaS.Shared.Abstractions.Serialization;
namespace TakeoutSaaS.Application.App.Billings.Dto;
/// <summary>
/// 支付记录 DTO管理员端
/// </summary>
public sealed record PaymentRecordDto
{
/// <summary>
/// 支付记录 ID。
/// </summary>
[JsonConverter(typeof(SnowflakeIdJsonConverter))]
public long Id { get; init; }
/// <summary>
/// 租户 ID。
/// </summary>
[JsonConverter(typeof(SnowflakeIdJsonConverter))]
public long TenantId { get; init; }
/// <summary>
/// 关联的账单 ID。
/// </summary>
[JsonConverter(typeof(SnowflakeIdJsonConverter))]
public long BillingId { get; init; }
/// <summary>
/// 支付金额。
/// </summary>
public decimal Amount { get; init; }
/// <summary>
/// 支付方式。
/// </summary>
public TenantPaymentMethod Method { get; init; }
/// <summary>
/// 支付状态。
/// </summary>
public TenantPaymentStatus Status { get; init; }
/// <summary>
/// 交易号。
/// </summary>
public string? TransactionNo { get; init; }
/// <summary>
/// 支付凭证 URL。
/// </summary>
public string? ProofUrl { get; init; }
/// <summary>
/// 支付时间。
/// </summary>
public DateTime? PaidAt { get; init; }
/// <summary>
/// 是否已审核。
/// </summary>
public bool IsVerified { get; init; }
/// <summary>
/// 审核人 ID。
/// </summary>
[JsonConverter(typeof(NullableSnowflakeIdJsonConverter))]
public long? VerifiedBy { get; init; }
/// <summary>
/// 审核时间。
/// </summary>
public DateTime? VerifiedAt { get; init; }
/// <summary>
/// 退款原因。
/// </summary>
public string? RefundReason { get; init; }
/// <summary>
/// 退款时间。
/// </summary>
public DateTime? RefundedAt { get; init; }
/// <summary>
/// 备注信息。
/// </summary>
public string? Notes { get; init; }
/// <summary>
/// 创建时间UTC
/// </summary>
public DateTime CreatedAt { get; init; }
}

View File

@@ -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;
}
}

View File

@@ -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;
}
}

View File

@@ -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);
}
}

View File

@@ -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}")
};
}
}

View File

@@ -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);
}
}

View File

@@ -28,6 +28,8 @@ public sealed class GetBillListQueryHandler(
request.Status, request.Status,
request.StartDate, request.StartDate,
request.EndDate, request.EndDate,
null,
null,
request.Keyword, request.Keyword,
request.PageNumber, request.PageNumber,
request.PageSize, request.PageSize,

View File

@@ -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;
}
}

View File

@@ -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;
}
}

View File

@@ -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;
}
}

View File

@@ -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;
}
}

View File

@@ -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;
}
}

View File

@@ -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;
}
}

View File

@@ -6,6 +6,7 @@ using TakeoutSaaS.Domain.Tenants.Enums;
using TakeoutSaaS.Domain.Tenants.Repositories; using TakeoutSaaS.Domain.Tenants.Repositories;
using TakeoutSaaS.Shared.Abstractions.Constants; using TakeoutSaaS.Shared.Abstractions.Constants;
using TakeoutSaaS.Shared.Abstractions.Exceptions; using TakeoutSaaS.Shared.Abstractions.Exceptions;
using TakeoutSaaS.Shared.Abstractions.Ids;
namespace TakeoutSaaS.Application.App.Billings.Handlers; namespace TakeoutSaaS.Application.App.Billings.Handlers;
@@ -14,8 +15,9 @@ namespace TakeoutSaaS.Application.App.Billings.Handlers;
/// </summary> /// </summary>
public sealed class RecordPaymentCommandHandler( public sealed class RecordPaymentCommandHandler(
ITenantBillingRepository billingRepository, ITenantBillingRepository billingRepository,
ITenantPaymentRepository paymentRepository) ITenantPaymentRepository paymentRepository,
: IRequestHandler<RecordPaymentCommand, PaymentDto> IIdGenerator idGenerator)
: IRequestHandler<RecordPaymentCommand, PaymentRecordDto>
{ {
/// <summary> /// <summary>
/// 处理记录支付请求。 /// 处理记录支付请求。
@@ -23,44 +25,57 @@ public sealed class RecordPaymentCommandHandler(
/// <param name="request">记录支付命令。</param> /// <param name="request">记录支付命令。</param>
/// <param name="cancellationToken">取消标记。</param> /// <param name="cancellationToken">取消标记。</param>
/// <returns>支付 DTO。</returns> /// <returns>支付 DTO。</returns>
public async Task<PaymentDto> Handle(RecordPaymentCommand request, CancellationToken cancellationToken) public async Task<PaymentRecordDto> Handle(RecordPaymentCommand request, CancellationToken cancellationToken)
{ {
// 1. 查询账单 // 1. 查询账单
var bill = await billingRepository.FindByIdAsync(request.BillId, cancellationToken); var billing = await billingRepository.FindByIdAsync(request.BillingId, cancellationToken);
if (bill is null) if (billing is null)
{ {
throw new BusinessException(ErrorCodes.NotFound, "账单不存在"); 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 var payment = new TenantPayment
{ {
TenantId = bill.TenantId, Id = idGenerator.NextId(),
BillingStatementId = request.BillId, TenantId = billing.TenantId,
BillingStatementId = request.BillingId,
Amount = request.Amount, Amount = request.Amount,
Method = request.Method, Method = request.Method,
Status = PaymentStatus.Success, Status = TenantPaymentStatus.Pending,
TransactionNo = request.TransactionNo, TransactionNo = string.IsNullOrWhiteSpace(request.TransactionNo) ? null : request.TransactionNo.Trim(),
ProofUrl = request.ProofUrl, ProofUrl = request.ProofUrl,
PaidAt = DateTime.UtcNow, PaidAt = now,
Notes = request.Notes Notes = request.Notes
}; };
// 3. 更新账单已付金额 // 5. (空行后) 持久化变更
bill.AmountPaid += request.Amount;
// 4. 如果已付金额 >= 应付金额,标记为已支付
if (bill.AmountPaid >= bill.AmountDue)
{
bill.Status = TenantBillingStatus.Paid;
}
// 5. 持久化变更
await paymentRepository.AddAsync(payment, cancellationToken); await paymentRepository.AddAsync(payment, cancellationToken);
await billingRepository.UpdateAsync(bill, cancellationToken);
await paymentRepository.SaveChangesAsync(cancellationToken); await paymentRepository.SaveChangesAsync(cancellationToken);
// 6. 返回 DTO // 6. (空行后) 返回 DTO
return payment.ToDto(); return payment.ToPaymentRecordDto();
} }
} }

View File

@@ -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;
}
}

View File

@@ -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();
}
}

View File

@@ -0,0 +1,66 @@
using AutoMapper;
using System.Text.Json;
using TakeoutSaaS.Application.App.Billings.Dto;
using TakeoutSaaS.Domain.Tenants.Entities;
using TakeoutSaaS.Domain.Tenants.Enums;
namespace TakeoutSaaS.Application.App.Billings.Mappings;
/// <summary>
/// 账单模块 AutoMapper Profile。
/// </summary>
public sealed class BillingProfile : Profile
{
/// <summary>
/// 初始化映射配置。
/// </summary>
public BillingProfile()
{
// 1. 账单实体 -> 列表 DTO
CreateMap<TenantBillingStatement, BillingListDto>()
.ForMember(x => x.TenantName, opt => opt.Ignore())
.ForMember(x => x.TotalAmount, opt => opt.MapFrom(src => src.CalculateTotalAmount()))
.ForMember(x => x.IsOverdue, opt => opt.MapFrom(src =>
src.Status == TenantBillingStatus.Overdue
|| (src.Status == TenantBillingStatus.Pending && src.DueDate < DateTime.UtcNow)))
.ForMember(x => x.OverdueDays, opt => opt.MapFrom(src =>
src.DueDate < DateTime.UtcNow ? (int)(DateTime.UtcNow - src.DueDate).TotalDays : 0));
// 2. (空行后) 账单实体 -> 详情 DTO
CreateMap<TenantBillingStatement, BillingDetailDto>()
.ForMember(x => x.TenantName, opt => opt.Ignore())
.ForMember(x => x.TotalAmount, opt => opt.MapFrom(src => src.CalculateTotalAmount()))
.ForMember(x => x.LineItemsJson, opt => opt.MapFrom(src => src.LineItemsJson))
.ForMember(x => x.LineItems, opt => opt.MapFrom(src => DeserializeLineItems(src.LineItemsJson)))
.ForMember(x => x.Payments, opt => opt.Ignore());
// 3. (空行后) 账单实体 -> 导出 DTO
CreateMap<TenantBillingStatement, BillingExportDto>()
.ForMember(x => x.TenantName, opt => opt.Ignore())
.ForMember(x => x.TotalAmount, opt => opt.MapFrom(src => src.CalculateTotalAmount()))
.ForMember(x => x.LineItems, opt => opt.MapFrom(src => DeserializeLineItems(src.LineItemsJson)));
// 4. (空行后) 支付实体 -> 支付记录 DTO
CreateMap<TenantPayment, PaymentRecordDto>()
.ForMember(x => x.BillingId, opt => opt.MapFrom(src => src.BillingStatementId))
.ForMember(x => x.IsVerified, opt => opt.MapFrom(src => src.VerifiedAt.HasValue));
}
private static IReadOnlyList<BillingLineItemDto> DeserializeLineItems(string? json)
{
if (string.IsNullOrWhiteSpace(json))
{
return [];
}
try
{
return JsonSerializer.Deserialize<List<BillingLineItemDto>>(json) ?? [];
}
catch
{
return [];
}
}
}

View File

@@ -0,0 +1,19 @@
using MediatR;
namespace TakeoutSaaS.Application.App.Billings.Queries;
/// <summary>
/// 导出账单Excel/PDF/CSV
/// </summary>
public sealed record ExportBillingsQuery : IRequest<byte[]>
{
/// <summary>
/// 要导出的账单 ID 列表。
/// </summary>
public long[] BillingIds { get; init; } = [];
/// <summary>
/// 导出格式Excel/Pdf/Csv
/// </summary>
public string Format { get; init; } = "Excel";
}

View File

@@ -0,0 +1,15 @@
using MediatR;
using TakeoutSaaS.Application.App.Billings.Dto;
namespace TakeoutSaaS.Application.App.Billings.Queries;
/// <summary>
/// 查询账单详情(含明细项)。
/// </summary>
public sealed record GetBillingDetailQuery : IRequest<BillingDetailDto>
{
/// <summary>
/// 账单 ID雪花算法
/// </summary>
public long BillingId { get; init; }
}

View File

@@ -0,0 +1,72 @@
using MediatR;
using TakeoutSaaS.Application.App.Billings.Dto;
using TakeoutSaaS.Domain.Tenants.Enums;
using TakeoutSaaS.Shared.Abstractions.Results;
namespace TakeoutSaaS.Application.App.Billings.Queries;
/// <summary>
/// 分页查询账单列表。
/// </summary>
public sealed record GetBillingListQuery : IRequest<PagedResult<BillingListDto>>
{
/// <summary>
/// 租户 ID可选管理员可查询所有租户
/// </summary>
public long? TenantId { get; init; }
/// <summary>
/// 账单状态筛选。
/// </summary>
public TenantBillingStatus? Status { get; init; }
/// <summary>
/// 账单类型筛选。
/// </summary>
public BillingType? BillingType { get; init; }
/// <summary>
/// 账单起始时间UTC筛选。
/// </summary>
public DateTime? StartDate { get; init; }
/// <summary>
/// 账单结束时间UTC筛选。
/// </summary>
public DateTime? EndDate { get; init; }
/// <summary>
/// 关键词搜索(账单编号)。
/// </summary>
public string? Keyword { get; init; }
/// <summary>
/// 最小应付金额筛选(包含)。
/// </summary>
public decimal? MinAmount { get; init; }
/// <summary>
/// 最大应付金额筛选(包含)。
/// </summary>
public decimal? MaxAmount { get; init; }
/// <summary>
/// 页码(从 1 开始)。
/// </summary>
public int PageNumber { get; init; } = 1;
/// <summary>
/// 每页条数。
/// </summary>
public int PageSize { get; init; } = 20;
/// <summary>
/// 排序字段DueDate/CreatedAt/AmountDue
/// </summary>
public string? SortBy { get; init; }
/// <summary>
/// 是否降序排序。
/// </summary>
public bool SortDesc { get; init; }
}

View File

@@ -0,0 +1,15 @@
using MediatR;
using TakeoutSaaS.Application.App.Billings.Dto;
namespace TakeoutSaaS.Application.App.Billings.Queries;
/// <summary>
/// 查询账单的支付记录。
/// </summary>
public sealed record GetBillingPaymentsQuery : IRequest<List<PaymentRecordDto>>
{
/// <summary>
/// 账单 ID雪花算法
/// </summary>
public long BillingId { get; init; }
}

View File

@@ -0,0 +1,30 @@
using MediatR;
using TakeoutSaaS.Application.App.Billings.Dto;
namespace TakeoutSaaS.Application.App.Billings.Queries;
/// <summary>
/// 查询账单统计数据。
/// </summary>
public sealed record GetBillingStatisticsQuery : IRequest<BillingStatisticsDto>
{
/// <summary>
/// 租户 ID可选管理员可查询所有租户
/// </summary>
public long? TenantId { get; init; }
/// <summary>
/// 统计开始时间UTC
/// </summary>
public DateTime? StartDate { get; init; }
/// <summary>
/// 统计结束时间UTC
/// </summary>
public DateTime? EndDate { get; init; }
/// <summary>
/// 分组方式Day/Week/Month
/// </summary>
public string? GroupBy { get; init; }
}

View File

@@ -0,0 +1,21 @@
using MediatR;
using TakeoutSaaS.Application.App.Billings.Dto;
using TakeoutSaaS.Shared.Abstractions.Results;
namespace TakeoutSaaS.Application.App.Billings.Queries;
/// <summary>
/// 查询逾期账单列表。
/// </summary>
public sealed record GetOverdueBillingsQuery : IRequest<PagedResult<BillingListDto>>
{
/// <summary>
/// 页码(从 1 开始)。
/// </summary>
public int PageNumber { get; init; } = 1;
/// <summary>
/// 每页条数。
/// </summary>
public int PageSize { get; init; } = 20;
}

View File

@@ -0,0 +1,73 @@
using FluentValidation;
using TakeoutSaaS.Application.App.Billings.Commands;
namespace TakeoutSaaS.Application.App.Billings.Validators;
/// <summary>
/// 创建账单命令验证器。
/// </summary>
public sealed class CreateBillingCommandValidator : AbstractValidator<CreateBillingCommand>
{
public CreateBillingCommandValidator()
{
// 1. 租户 ID 必填
RuleFor(x => x.TenantId)
.GreaterThan(0)
.WithMessage("租户 ID 必须大于 0");
// 2. (空行后) 账单类型必填
RuleFor(x => x.BillingType)
.IsInEnum()
.WithMessage("账单类型无效");
// 3. (空行后) 应付金额必须大于 0
RuleFor(x => x.AmountDue)
.GreaterThan(0)
.WithMessage("应付金额必须大于 0");
// 4. (空行后) 到期日必须是未来时间
RuleFor(x => x.DueDate)
.GreaterThan(DateTime.UtcNow)
.WithMessage("到期日必须是未来时间");
// 5. (空行后) 账单明细至少包含一项
RuleFor(x => x.LineItems)
.NotEmpty()
.WithMessage("账单明细不能为空");
// 6. (空行后) 账单明细项验证
RuleForEach(x => x.LineItems)
.ChildRules(lineItem =>
{
lineItem.RuleFor(x => x.ItemType)
.NotEmpty()
.WithMessage("账单明细类型不能为空")
.MaximumLength(50)
.WithMessage("账单明细类型不能超过 50 个字符");
lineItem.RuleFor(x => x.Description)
.NotEmpty()
.WithMessage("账单明细描述不能为空")
.MaximumLength(200)
.WithMessage("账单明细描述不能超过 200 个字符");
lineItem.RuleFor(x => x.Quantity)
.GreaterThan(0)
.WithMessage("账单明细数量必须大于 0");
lineItem.RuleFor(x => x.UnitPrice)
.GreaterThanOrEqualTo(0)
.WithMessage("账单明细单价不能为负数");
lineItem.RuleFor(x => x.Amount)
.GreaterThanOrEqualTo(0)
.WithMessage("账单明细金额不能为负数");
});
// 7. (空行后) 备注长度限制(可选)
RuleFor(x => x.Notes)
.MaximumLength(500)
.WithMessage("备注不能超过 500 个字符")
.When(x => !string.IsNullOrWhiteSpace(x.Notes));
}
}

View File

@@ -0,0 +1,49 @@
using FluentValidation;
using TakeoutSaaS.Application.App.Billings.Commands;
namespace TakeoutSaaS.Application.App.Billings.Validators;
/// <summary>
/// 记录支付命令验证器。
/// </summary>
public sealed class RecordPaymentCommandValidator : AbstractValidator<RecordPaymentCommand>
{
public RecordPaymentCommandValidator()
{
// 1. 账单 ID 必填
RuleFor(x => x.BillingId)
.GreaterThan(0)
.WithMessage("账单 ID 必须大于 0");
// 2. (空行后) 支付金额必须大于 0
RuleFor(x => x.Amount)
.GreaterThan(0)
.WithMessage("支付金额必须大于 0")
.LessThanOrEqualTo(1_000_000_000)
.WithMessage("支付金额不能超过 10 亿");
// 3. (空行后) 支付方式必填
RuleFor(x => x.Method)
.IsInEnum()
.WithMessage("支付方式无效");
// 4. (空行后) 交易号必填
RuleFor(x => x.TransactionNo)
.NotEmpty()
.WithMessage("交易号不能为空")
.MaximumLength(64)
.WithMessage("交易号不能超过 64 个字符");
// 5. (空行后) 支付凭证 URL可选
RuleFor(x => x.ProofUrl)
.MaximumLength(500)
.WithMessage("支付凭证 URL 不能超过 500 个字符")
.When(x => !string.IsNullOrWhiteSpace(x.ProofUrl));
// 6. (空行后) 备注(可选)
RuleFor(x => x.Notes)
.MaximumLength(500)
.WithMessage("备注不能超过 500 个字符")
.When(x => !string.IsNullOrWhiteSpace(x.Notes));
}
}

View File

@@ -0,0 +1,30 @@
using FluentValidation;
using TakeoutSaaS.Application.App.Billings.Commands;
namespace TakeoutSaaS.Application.App.Billings.Validators;
/// <summary>
/// 更新账单状态命令验证器。
/// </summary>
public sealed class UpdateBillingStatusCommandValidator : AbstractValidator<UpdateBillingStatusCommand>
{
public UpdateBillingStatusCommandValidator()
{
// 1. 账单 ID 必填
RuleFor(x => x.BillingId)
.GreaterThan(0)
.WithMessage("账单 ID 必须大于 0");
// 2. (空行后) 状态枚举校验
RuleFor(x => x.NewStatus)
.IsInEnum()
.WithMessage("新状态无效");
// 3. (空行后) 备注长度限制(可选)
RuleFor(x => x.Notes)
.MaximumLength(500)
.WithMessage("备注不能超过 500 个字符")
.When(x => !string.IsNullOrWhiteSpace(x.Notes));
}
}

View File

@@ -20,6 +20,9 @@ public static class AppApplicationServiceCollectionExtensions
{ {
services.AddMediatR(cfg => cfg.RegisterServicesFromAssembly(Assembly.GetExecutingAssembly())); services.AddMediatR(cfg => cfg.RegisterServicesFromAssembly(Assembly.GetExecutingAssembly()));
services.AddValidatorsFromAssembly(Assembly.GetExecutingAssembly()); services.AddValidatorsFromAssembly(Assembly.GetExecutingAssembly());
// (空行后) 注册 AutoMapper Profile
services.AddAutoMapper(Assembly.GetExecutingAssembly());
services.AddTransient(typeof(IPipelineBehavior<,>), typeof(ValidationBehavior<,>)); services.AddTransient(typeof(IPipelineBehavior<,>), typeof(ValidationBehavior<,>));
return services; return services;

View File

@@ -7,11 +7,13 @@
<Nullable>enable</Nullable> <Nullable>enable</Nullable>
<ImplicitUsings>enable</ImplicitUsings> <ImplicitUsings>enable</ImplicitUsings>
</PropertyGroup> </PropertyGroup>
<ItemGroup> <ItemGroup>
<PackageReference Include="Microsoft.AspNetCore.Http.Abstractions" Version="2.3.0" />
<PackageReference Include="Microsoft.Extensions.Identity.Core" Version="10.0.0" />
<PackageReference Include="Microsoft.Extensions.Caching.Abstractions" Version="10.0.0" /> <PackageReference Include="Microsoft.Extensions.Caching.Abstractions" Version="10.0.0" />
<PackageReference Include="MediatR" Version="14.0.0" /> <PackageReference Include="MediatR" Version="14.0.0" />
<PackageReference Include="AutoMapper" Version="12.0.1" /> <PackageReference Include="AutoMapper" Version="12.0.1" />

View File

@@ -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;
}
}

View File

@@ -13,6 +13,16 @@ public sealed class TenantBillingStatement : MultiTenantEntityBase
/// </summary> /// </summary>
public string StatementNo { get; set; } = string.Empty; 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>
/// 账单周期开始时间。 /// 账单周期开始时间。
/// </summary> /// </summary>
@@ -24,15 +34,30 @@ public sealed class TenantBillingStatement : MultiTenantEntityBase
public DateTime PeriodEnd { get; set; } public DateTime PeriodEnd { get; set; }
/// <summary> /// <summary>
/// 应付金额。 /// 应付金额(原始金额)
/// </summary> /// </summary>
public decimal AmountDue { get; set; } public decimal AmountDue { get; set; }
/// <summary>
/// 折扣金额。
/// </summary>
public decimal DiscountAmount { get; set; }
/// <summary>
/// 税费金额。
/// </summary>
public decimal TaxAmount { get; set; }
/// <summary> /// <summary>
/// 实付金额。 /// 实付金额。
/// </summary> /// </summary>
public decimal AmountPaid { get; set; } public decimal AmountPaid { get; set; }
/// <summary>
/// 货币类型(默认 CNY
/// </summary>
public string Currency { get; set; } = "CNY";
/// <summary> /// <summary>
/// 当前付款状态。 /// 当前付款状态。
/// </summary> /// </summary>
@@ -43,8 +68,133 @@ public sealed class TenantBillingStatement : MultiTenantEntityBase
/// </summary> /// </summary>
public DateTime DueDate { get; set; } public DateTime DueDate { get; set; }
/// <summary>
/// 提醒发送时间(续费提醒、逾期提醒等)。
/// </summary>
public DateTime? ReminderSentAt { get; set; }
/// <summary>
/// 逾期通知时间。
/// </summary>
public DateTime? OverdueNotifiedAt { get; set; }
/// <summary> /// <summary>
/// 账单明细 JSON记录各项费用。 /// 账单明细 JSON记录各项费用。
/// </summary> /// </summary>
public string? LineItemsJson { get; set; } 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}";
}
}
} }

View File

@@ -21,12 +21,12 @@ public sealed class TenantPayment : MultiTenantEntityBase
/// <summary> /// <summary>
/// 支付方式。 /// 支付方式。
/// </summary> /// </summary>
public PaymentMethod Method { get; set; } public TenantPaymentMethod Method { get; set; }
/// <summary> /// <summary>
/// 支付状态。 /// 支付状态。
/// </summary> /// </summary>
public PaymentStatus Status { get; set; } public TenantPaymentStatus Status { get; set; }
/// <summary> /// <summary>
/// 交易号。 /// 交易号。
@@ -43,8 +43,116 @@ public sealed class TenantPayment : MultiTenantEntityBase
/// </summary> /// </summary>
public DateTime? PaidAt { get; set; } 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>
/// 备注信息。 /// 备注信息。
/// </summary> /// </summary>
public string? Notes { get; set; } 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}";
}
} }

View File

@@ -0,0 +1,22 @@
namespace TakeoutSaaS.Domain.Tenants.Enums;
/// <summary>
/// 账单导出格式。
/// </summary>
public enum BillingExportFormat
{
/// <summary>
/// Excel 格式(.xlsx
/// </summary>
Excel = 0,
/// <summary>
/// PDF 格式(.pdf
/// </summary>
Pdf = 1,
/// <summary>
/// CSV 格式(.csv
/// </summary>
Csv = 2
}

View File

@@ -0,0 +1,27 @@
namespace TakeoutSaaS.Domain.Tenants.Enums;
/// <summary>
/// 账单类型。
/// </summary>
public enum BillingType
{
/// <summary>
/// 订阅账单(周期性订阅费用)。
/// </summary>
Subscription = 0,
/// <summary>
/// 配额包购买(一次性配额包购买)。
/// </summary>
QuotaPurchase = 1,
/// <summary>
/// 手动创建(管理员手动生成的账单)。
/// </summary>
Manual = 2,
/// <summary>
/// 续费账单(自动续费生成的账单)。
/// </summary>
Renewal = 3
}

View File

@@ -1,9 +1,9 @@
namespace TakeoutSaaS.Domain.Tenants.Enums; namespace TakeoutSaaS.Domain.Tenants.Enums;
/// <summary> /// <summary>
/// 支付方式。 /// 租户支付方式。
/// </summary> /// </summary>
public enum PaymentMethod public enum TenantPaymentMethod
{ {
/// <summary> /// <summary>
/// 线上支付。 /// 线上支付。

View File

@@ -1,9 +1,9 @@
namespace TakeoutSaaS.Domain.Tenants.Enums; namespace TakeoutSaaS.Domain.Tenants.Enums;
/// <summary> /// <summary>
/// 支付状态。 /// 租户支付状态。
/// </summary> /// </summary>
public enum PaymentStatus public enum TenantPaymentStatus
{ {
/// <summary> /// <summary>
/// 待支付。 /// 待支付。

View File

@@ -42,6 +42,14 @@ public interface ITenantBillingRepository
/// <returns>账单实体或 null。</returns> /// <returns>账单实体或 null。</returns>
Task<TenantBillingStatement?> FindByStatementNoAsync(long tenantId, string statementNo, CancellationToken cancellationToken = default); Task<TenantBillingStatement?> FindByStatementNoAsync(long tenantId, string statementNo, CancellationToken cancellationToken = default);
/// <summary>
/// 按账单编号获取账单(不限租户,管理员端使用)。
/// </summary>
/// <param name="statementNo">账单编号。</param>
/// <param name="cancellationToken">取消标记。</param>
/// <returns>账单实体或 null。</returns>
Task<TenantBillingStatement?> GetByStatementNoAsync(string statementNo, CancellationToken cancellationToken = default);
/// <summary> /// <summary>
/// 判断是否已存在指定周期开始时间的未取消账单(用于自动续费幂等)。 /// 判断是否已存在指定周期开始时间的未取消账单(用于自动续费幂等)。
/// </summary> /// </summary>
@@ -54,6 +62,39 @@ public interface ITenantBillingRepository
DateTime periodStart, DateTime periodStart,
CancellationToken cancellationToken = default); CancellationToken cancellationToken = default);
/// <summary>
/// 获取逾期账单列表(已过到期日且未支付)。
/// </summary>
/// <param name="cancellationToken">取消标记。</param>
/// <returns>逾期账单集合。</returns>
Task<IReadOnlyList<TenantBillingStatement>> GetOverdueBillingsAsync(CancellationToken cancellationToken = default);
/// <summary>
/// 获取即将到期的账单列表(未来 N 天内到期且未支付)。
/// </summary>
/// <param name="daysAhead">提前天数。</param>
/// <param name="cancellationToken">取消标记。</param>
/// <returns>即将到期的账单集合。</returns>
Task<IReadOnlyList<TenantBillingStatement>> GetBillingsDueSoonAsync(int daysAhead, CancellationToken cancellationToken = default);
/// <summary>
/// 按租户 ID 获取账单列表。
/// </summary>
/// <param name="tenantId">租户 ID。</param>
/// <param name="cancellationToken">取消标记。</param>
/// <returns>账单集合。</returns>
Task<IReadOnlyList<TenantBillingStatement>> GetByTenantIdAsync(long tenantId, CancellationToken cancellationToken = default);
/// <summary>
/// 按 ID 列表批量获取账单(管理员端/批量操作场景)。
/// </summary>
/// <param name="billingIds">账单 ID 列表。</param>
/// <param name="cancellationToken">取消标记。</param>
/// <returns>账单实体列表。</returns>
Task<IReadOnlyList<TenantBillingStatement>> GetByIdsAsync(
IReadOnlyCollection<long> billingIds,
CancellationToken cancellationToken = default);
/// <summary> /// <summary>
/// 新增账单。 /// 新增账单。
/// </summary> /// </summary>
@@ -84,6 +125,8 @@ public interface ITenantBillingRepository
/// <param name="status">账单状态筛选(可选)。</param> /// <param name="status">账单状态筛选(可选)。</param>
/// <param name="from">开始时间UTC可选。</param> /// <param name="from">开始时间UTC可选。</param>
/// <param name="to">结束时间UTC可选。</param> /// <param name="to">结束时间UTC可选。</param>
/// <param name="minAmount">最小应付金额筛选(包含,可选)。</param>
/// <param name="maxAmount">最大应付金额筛选(包含,可选)。</param>
/// <param name="keyword">关键词搜索(账单号或租户名)。</param> /// <param name="keyword">关键词搜索(账单号或租户名)。</param>
/// <param name="pageNumber">页码(从 1 开始)。</param> /// <param name="pageNumber">页码(从 1 开始)。</param>
/// <param name="pageSize">页大小。</param> /// <param name="pageSize">页大小。</param>
@@ -94,11 +137,29 @@ public interface ITenantBillingRepository
TenantBillingStatus? status, TenantBillingStatus? status,
DateTime? from, DateTime? from,
DateTime? to, DateTime? to,
decimal? minAmount,
decimal? maxAmount,
string? keyword, string? keyword,
int pageNumber, int pageNumber,
int pageSize, int pageSize,
CancellationToken cancellationToken = default); CancellationToken cancellationToken = default);
/// <summary>
/// 获取账单统计数据(用于报表与仪表盘)。
/// </summary>
/// <param name="tenantId">租户 ID可选管理员可查询所有租户。</param>
/// <param name="startDate">统计开始时间UTC。</param>
/// <param name="endDate">统计结束时间UTC。</param>
/// <param name="groupBy">分组方式Day/Week/Month。</param>
/// <param name="cancellationToken">取消标记。</param>
/// <returns>统计结果。</returns>
Task<TenantBillingStatistics> GetStatisticsAsync(
long? tenantId,
DateTime startDate,
DateTime endDate,
string groupBy,
CancellationToken cancellationToken = default);
/// <summary> /// <summary>
/// 按 ID 获取账单(不限租户,管理员端使用)。 /// 按 ID 获取账单(不限租户,管理员端使用)。
/// </summary> /// </summary>
@@ -107,3 +168,80 @@ public interface ITenantBillingRepository
/// <returns>账单实体或 null。</returns> /// <returns>账单实体或 null。</returns>
Task<TenantBillingStatement?> FindByIdAsync(long billingId, CancellationToken cancellationToken = default); Task<TenantBillingStatement?> FindByIdAsync(long billingId, CancellationToken cancellationToken = default);
} }
/// <summary>
/// 账单统计结果。
/// </summary>
public sealed record TenantBillingStatistics
{
/// <summary>
/// 总账单金额(统计区间内)。
/// </summary>
public decimal TotalAmount { get; init; }
/// <summary>
/// 已支付金额(统计区间内)。
/// </summary>
public decimal PaidAmount { get; init; }
/// <summary>
/// 未支付金额(统计区间内)。
/// </summary>
public decimal UnpaidAmount { get; init; }
/// <summary>
/// 逾期金额(统计区间内)。
/// </summary>
public decimal OverdueAmount { get; init; }
/// <summary>
/// 总账单数量(统计区间内)。
/// </summary>
public int TotalCount { get; init; }
/// <summary>
/// 已支付账单数量(统计区间内)。
/// </summary>
public int PaidCount { get; init; }
/// <summary>
/// 未支付账单数量(统计区间内)。
/// </summary>
public int UnpaidCount { get; init; }
/// <summary>
/// 逾期账单数量(统计区间内)。
/// </summary>
public int OverdueCount { get; init; }
/// <summary>
/// 趋势数据(按 groupBy 聚合)。
/// </summary>
public IReadOnlyList<TenantBillingTrendDataPoint> TrendData { get; init; } = [];
}
/// <summary>
/// 账单趋势统计点。
/// </summary>
public sealed record TenantBillingTrendDataPoint
{
/// <summary>
/// 分组时间点Day/Week/Month 的代表日期UTC
/// </summary>
public DateTime Period { get; init; }
/// <summary>
/// 账单数量。
/// </summary>
public int Count { get; init; }
/// <summary>
/// 总金额。
/// </summary>
public decimal TotalAmount { get; init; }
/// <summary>
/// 已支付金额。
/// </summary>
public decimal PaidAmount { get; init; }
}

View File

@@ -15,6 +15,14 @@ public interface ITenantPaymentRepository
/// <returns>支付记录集合。</returns> /// <returns>支付记录集合。</returns>
Task<IReadOnlyList<TenantPayment>> GetByBillingIdAsync(long billingStatementId, CancellationToken cancellationToken = default); Task<IReadOnlyList<TenantPayment>> GetByBillingIdAsync(long billingStatementId, CancellationToken cancellationToken = default);
/// <summary>
/// 计算指定账单的累计已支付金额。
/// </summary>
/// <param name="billingStatementId">账单 ID。</param>
/// <param name="cancellationToken">取消标记。</param>
/// <returns>累计已支付金额。</returns>
Task<decimal> GetTotalPaidAmountAsync(long billingStatementId, CancellationToken cancellationToken = default);
/// <summary> /// <summary>
/// 按 ID 获取支付记录。 /// 按 ID 获取支付记录。
/// </summary> /// </summary>
@@ -23,6 +31,14 @@ public interface ITenantPaymentRepository
/// <returns>支付记录实体或 null。</returns> /// <returns>支付记录实体或 null。</returns>
Task<TenantPayment?> FindByIdAsync(long paymentId, CancellationToken cancellationToken = default); Task<TenantPayment?> FindByIdAsync(long paymentId, CancellationToken cancellationToken = default);
/// <summary>
/// 按交易号获取支付记录。
/// </summary>
/// <param name="transactionNo">交易号。</param>
/// <param name="cancellationToken">取消标记。</param>
/// <returns>支付记录实体或 null。</returns>
Task<TenantPayment?> GetByTransactionNoAsync(string transactionNo, CancellationToken cancellationToken = default);
/// <summary> /// <summary>
/// 新增支付记录。 /// 新增支付记录。
/// </summary> /// </summary>

View File

@@ -0,0 +1,64 @@
using TakeoutSaaS.Domain.Tenants.Entities;
namespace TakeoutSaaS.Domain.Tenants.Services;
/// <summary>
/// 账单领域服务接口。
/// 负责处理账单生成、账单编号生成、逾期处理等跨实体的业务逻辑。
/// </summary>
public interface IBillingDomainService
{
/// <summary>
/// 根据订阅信息生成账单。
/// </summary>
/// <param name="subscription">租户订阅信息。</param>
/// <param name="cancellationToken">取消标记。</param>
/// <returns>生成的账单实体。</returns>
Task<TenantBillingStatement> GenerateSubscriptionBillingAsync(
TenantSubscription subscription,
CancellationToken cancellationToken = default);
/// <summary>
/// 根据配额包购买信息生成账单。
/// </summary>
/// <param name="tenantId">租户 ID。</param>
/// <param name="quotaPackage">配额包信息。</param>
/// <param name="quantity">购买数量。</param>
/// <param name="cancellationToken">取消标记。</param>
/// <returns>生成的账单实体。</returns>
Task<TenantBillingStatement> GenerateQuotaPurchaseBillingAsync(
long tenantId,
QuotaPackage quotaPackage,
int quantity,
CancellationToken cancellationToken = default);
/// <summary>
/// 生成唯一的账单编号。
/// 格式示例BIL-20251217-000001
/// </summary>
/// <returns>账单编号。</returns>
string GenerateStatementNo();
/// <summary>
/// 处理逾期账单(批量标记逾期状态)。
/// </summary>
/// <param name="cancellationToken">取消标记。</param>
/// <returns>处理的账单数量。</returns>
Task<int> ProcessOverdueBillingsAsync(CancellationToken cancellationToken = default);
/// <summary>
/// 计算账单总金额(含折扣和税费)。
/// </summary>
/// <param name="baseAmount">基础金额。</param>
/// <param name="discountAmount">折扣金额。</param>
/// <param name="taxAmount">税费金额。</param>
/// <returns>总金额。</returns>
decimal CalculateTotalAmount(decimal baseAmount, decimal discountAmount, decimal taxAmount);
/// <summary>
/// 验证账单状态是否可以进行支付操作。
/// </summary>
/// <param name="billing">账单实体。</param>
/// <returns>是否可以支付。</returns>
bool CanProcessPayment(TenantBillingStatement billing);
}

View File

@@ -0,0 +1,33 @@
using TakeoutSaaS.Domain.Tenants.Entities;
namespace TakeoutSaaS.Domain.Tenants.Services;
/// <summary>
/// 账单导出服务接口。
/// </summary>
public interface IBillingExportService
{
/// <summary>
/// 导出为 ExcelXLSX
/// </summary>
/// <param name="billings">账单数据。</param>
/// <param name="cancellationToken">取消标记。</param>
/// <returns>文件字节数组。</returns>
Task<byte[]> ExportToExcelAsync(IReadOnlyList<TenantBillingStatement> billings, CancellationToken cancellationToken = default);
/// <summary>
/// 导出为 PDF。
/// </summary>
/// <param name="billings">账单数据。</param>
/// <param name="cancellationToken">取消标记。</param>
/// <returns>文件字节数组。</returns>
Task<byte[]> ExportToPdfAsync(IReadOnlyList<TenantBillingStatement> billings, CancellationToken cancellationToken = default);
/// <summary>
/// 导出为 CSV。
/// </summary>
/// <param name="billings">账单数据。</param>
/// <param name="cancellationToken">取消标记。</param>
/// <returns>文件字节数组。</returns>
Task<byte[]> ExportToCsvAsync(IReadOnlyList<TenantBillingStatement> billings, CancellationToken cancellationToken = default);
}

View File

@@ -8,9 +8,12 @@ using TakeoutSaaS.Domain.Payments.Repositories;
using TakeoutSaaS.Domain.Products.Repositories; using TakeoutSaaS.Domain.Products.Repositories;
using TakeoutSaaS.Domain.Stores.Repositories; using TakeoutSaaS.Domain.Stores.Repositories;
using TakeoutSaaS.Domain.Tenants.Repositories; using TakeoutSaaS.Domain.Tenants.Repositories;
using TakeoutSaaS.Domain.Tenants.Services;
using TakeoutSaaS.Infrastructure.App.Options; using TakeoutSaaS.Infrastructure.App.Options;
using TakeoutSaaS.Infrastructure.App.Persistence; using TakeoutSaaS.Infrastructure.App.Persistence;
using TakeoutSaaS.Infrastructure.App.Persistence.Repositories;
using TakeoutSaaS.Infrastructure.App.Repositories; using TakeoutSaaS.Infrastructure.App.Repositories;
using TakeoutSaaS.Infrastructure.App.Services;
using TakeoutSaaS.Infrastructure.Common.Extensions; using TakeoutSaaS.Infrastructure.Common.Extensions;
using TakeoutSaaS.Shared.Abstractions.Constants; using TakeoutSaaS.Shared.Abstractions.Constants;
@@ -40,8 +43,8 @@ public static class AppServiceCollectionExtensions
services.AddScoped<IPaymentRepository, EfPaymentRepository>(); services.AddScoped<IPaymentRepository, EfPaymentRepository>();
services.AddScoped<IDeliveryRepository, EfDeliveryRepository>(); services.AddScoped<IDeliveryRepository, EfDeliveryRepository>();
services.AddScoped<ITenantRepository, EfTenantRepository>(); services.AddScoped<ITenantRepository, EfTenantRepository>();
services.AddScoped<ITenantBillingRepository, EfTenantBillingRepository>(); services.AddScoped<ITenantBillingRepository, TenantBillingRepository>();
services.AddScoped<ITenantPaymentRepository, EfTenantPaymentRepository>(); services.AddScoped<ITenantPaymentRepository, TenantPaymentRepository>();
services.AddScoped<ITenantAnnouncementRepository, EfTenantAnnouncementRepository>(); services.AddScoped<ITenantAnnouncementRepository, EfTenantAnnouncementRepository>();
services.AddScoped<ITenantAnnouncementReadRepository, EfTenantAnnouncementReadRepository>(); services.AddScoped<ITenantAnnouncementReadRepository, EfTenantAnnouncementReadRepository>();
services.AddScoped<ITenantNotificationRepository, EfTenantNotificationRepository>(); services.AddScoped<ITenantNotificationRepository, EfTenantNotificationRepository>();
@@ -52,6 +55,10 @@ public static class AppServiceCollectionExtensions
services.AddScoped<IStatisticsRepository, EfStatisticsRepository>(); services.AddScoped<IStatisticsRepository, EfStatisticsRepository>();
services.AddScoped<ISubscriptionRepository, EfSubscriptionRepository>(); services.AddScoped<ISubscriptionRepository, EfSubscriptionRepository>();
// 1. 账单领域/导出服务
services.AddScoped<IBillingDomainService, BillingDomainService>();
services.AddScoped<IBillingExportService, BillingExportService>();
services.AddOptions<AppSeedOptions>() services.AddOptions<AppSeedOptions>()
.Bind(configuration.GetSection(AppSeedOptions.SectionName)) .Bind(configuration.GetSection(AppSeedOptions.SectionName))
.ValidateDataAnnotations(); .ValidateDataAnnotations();

View File

@@ -0,0 +1,51 @@
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Metadata.Builders;
using TakeoutSaaS.Domain.Tenants.Entities;
using TakeoutSaaS.Domain.Tenants.Enums;
namespace TakeoutSaaS.Infrastructure.App.Persistence.Configurations;
/// <summary>
/// <see cref="TenantBillingStatement"/> EF Core 映射配置。
/// </summary>
public sealed class TenantBillingStatementConfiguration : IEntityTypeConfiguration<TenantBillingStatement>
{
/// <inheritdoc />
public void Configure(EntityTypeBuilder<TenantBillingStatement> builder)
{
builder.ToTable("tenant_billing_statements");
builder.HasKey(x => x.Id);
// 1. 字段约束
builder.Property(x => x.StatementNo).HasMaxLength(64).IsRequired();
builder.Property(x => x.BillingType).HasConversion<int>();
builder.Property(x => x.AmountDue).HasPrecision(18, 2);
builder.Property(x => x.DiscountAmount).HasPrecision(18, 2);
builder.Property(x => x.TaxAmount).HasPrecision(18, 2);
builder.Property(x => x.AmountPaid).HasPrecision(18, 2);
builder.Property(x => x.Currency).HasMaxLength(8).HasDefaultValue("CNY");
builder.Property(x => x.Status).HasConversion<int>();
// 2. (空行后) JSON 字段(当前以 text 存储 JSON 字符串,便于兼容历史迁移)
builder.Property(x => x.LineItemsJson).HasColumnType("text");
// 3. (空行后) 备注字段
builder.Property(x => x.Notes).HasMaxLength(512);
// 4. (空行后) 唯一约束与索引
builder.HasIndex(x => new { x.TenantId, x.StatementNo }).IsUnique();
// 5. (空行后) 性能索引(高频查询:租户+状态+到期日)
builder.HasIndex(x => new { x.TenantId, x.Status, x.DueDate })
.HasDatabaseName("idx_billing_tenant_status_duedate");
// 6. (空行后) 逾期扫描索引(仅索引 Pending/Overdue
builder.HasIndex(x => new { x.Status, x.DueDate })
.HasDatabaseName("idx_billing_status_duedate")
.HasFilter($"\"Status\" IN ({(int)TenantBillingStatus.Pending}, {(int)TenantBillingStatus.Overdue})");
// 7. (空行后) 创建时间索引(支持列表倒序)
builder.HasIndex(x => x.CreatedAt)
.HasDatabaseName("idx_billing_created_at");
}
}

View File

@@ -0,0 +1,40 @@
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Metadata.Builders;
using TakeoutSaaS.Domain.Tenants.Entities;
namespace TakeoutSaaS.Infrastructure.App.Persistence.Configurations;
/// <summary>
/// <see cref="TenantPayment"/> EF Core 映射配置。
/// </summary>
public sealed class TenantPaymentConfiguration : IEntityTypeConfiguration<TenantPayment>
{
/// <inheritdoc />
public void Configure(EntityTypeBuilder<TenantPayment> builder)
{
builder.ToTable("tenant_payments");
builder.HasKey(x => x.Id);
// 1. 字段约束
builder.Property(x => x.BillingStatementId).IsRequired();
builder.Property(x => x.Amount).HasPrecision(18, 2).IsRequired();
builder.Property(x => x.Method).HasConversion<int>();
builder.Property(x => x.Status).HasConversion<int>();
builder.Property(x => x.TransactionNo).HasMaxLength(64);
builder.Property(x => x.ProofUrl).HasMaxLength(512);
builder.Property(x => x.RefundReason).HasMaxLength(512);
builder.Property(x => x.Notes).HasMaxLength(512);
// 2. (空行后) 复合索引:租户+账单
builder.HasIndex(x => new { x.TenantId, x.BillingStatementId });
// 3. (空行后) 支付记录时间排序索引
builder.HasIndex(x => new { x.BillingStatementId, x.PaidAt })
.HasDatabaseName("idx_payment_billing_paidat");
// 4. (空行后) 交易号索引(部分索引:仅非空)
builder.HasIndex(x => x.TransactionNo)
.HasDatabaseName("idx_payment_transaction_no")
.HasFilter("\"TransactionNo\" IS NOT NULL");
}
}

View File

@@ -0,0 +1,379 @@
using Microsoft.EntityFrameworkCore;
using TakeoutSaaS.Domain.Tenants.Entities;
using TakeoutSaaS.Domain.Tenants.Enums;
using TakeoutSaaS.Domain.Tenants.Repositories;
using TakeoutSaaS.Infrastructure.App.Persistence;
namespace TakeoutSaaS.Infrastructure.App.Persistence.Repositories;
/// <summary>
/// 租户账单仓储实现EF Core
/// </summary>
public sealed class TenantBillingRepository(TakeoutAppDbContext context) : ITenantBillingRepository
{
/// <inheritdoc />
public async Task<IReadOnlyList<TenantBillingStatement>> SearchAsync(
long tenantId,
TenantBillingStatus? status,
DateTime? from,
DateTime? to,
CancellationToken cancellationToken = default)
{
// 1. 构建基础查询:忽略全局过滤器,显式过滤租户与软删除
var query = context.TenantBillingStatements
.IgnoreQueryFilters()
.AsNoTracking()
.Where(x => x.DeletedAt == null && x.TenantId == tenantId);
// 2. (空行后) 按状态过滤
if (status.HasValue)
{
query = query.Where(x => x.Status == status.Value);
}
// 3. (空行后) 按日期范围过滤(账单周期)
if (from.HasValue)
{
query = query.Where(x => x.PeriodStart >= from.Value);
}
if (to.HasValue)
{
query = query.Where(x => x.PeriodEnd <= to.Value);
}
// 4. (空行后) 排序返回
return await query
.OrderByDescending(x => x.PeriodEnd)
.ToListAsync(cancellationToken);
}
/// <inheritdoc />
public Task<TenantBillingStatement?> FindByIdAsync(long tenantId, long billingId, CancellationToken cancellationToken = default)
{
return context.TenantBillingStatements
.IgnoreQueryFilters()
.AsNoTracking()
.FirstOrDefaultAsync(x => x.DeletedAt == null && x.TenantId == tenantId && x.Id == billingId, cancellationToken);
}
/// <inheritdoc />
public Task<TenantBillingStatement?> FindByStatementNoAsync(long tenantId, string statementNo, CancellationToken cancellationToken = default)
{
var normalized = statementNo.Trim();
return context.TenantBillingStatements
.IgnoreQueryFilters()
.AsNoTracking()
.FirstOrDefaultAsync(x => x.DeletedAt == null && x.TenantId == tenantId && x.StatementNo == normalized, cancellationToken);
}
/// <inheritdoc />
public Task<TenantBillingStatement?> GetByStatementNoAsync(string statementNo, CancellationToken cancellationToken = default)
{
var normalized = statementNo.Trim();
return context.TenantBillingStatements
.IgnoreQueryFilters()
.AsNoTracking()
.FirstOrDefaultAsync(x => x.DeletedAt == null && x.StatementNo == normalized, cancellationToken);
}
/// <inheritdoc />
public Task<bool> ExistsNotCancelledByPeriodStartAsync(
long tenantId,
DateTime periodStart,
CancellationToken cancellationToken = default)
{
return context.TenantBillingStatements
.IgnoreQueryFilters()
.AsNoTracking()
.AnyAsync(
x => x.DeletedAt == null
&& x.TenantId == tenantId
&& x.PeriodStart == periodStart
&& x.Status != TenantBillingStatus.Cancelled,
cancellationToken);
}
/// <inheritdoc />
public async Task<IReadOnlyList<TenantBillingStatement>> GetOverdueBillingsAsync(CancellationToken cancellationToken = default)
{
// 1. 以当前 UTC 时间作为逾期判断基准
var now = DateTime.UtcNow;
// 2. (空行后) 查询逾期且未结清/未取消的账单
return await context.TenantBillingStatements
.IgnoreQueryFilters()
.AsNoTracking()
.Where(x => x.DeletedAt == null
&& x.DueDate < now
&& x.Status != TenantBillingStatus.Paid
&& x.Status != TenantBillingStatus.Cancelled)
.OrderBy(x => x.DueDate)
.ToListAsync(cancellationToken);
}
/// <inheritdoc />
public async Task<IReadOnlyList<TenantBillingStatement>> GetBillingsDueSoonAsync(int daysAhead, CancellationToken cancellationToken = default)
{
// 1. 计算到期窗口
var now = DateTime.UtcNow;
var dueTo = now.AddDays(daysAhead);
// 2. (空行后) 仅查询待支付账单
return await context.TenantBillingStatements
.IgnoreQueryFilters()
.AsNoTracking()
.Where(x => x.DeletedAt == null
&& x.Status == TenantBillingStatus.Pending
&& x.DueDate >= now
&& x.DueDate <= dueTo)
.OrderBy(x => x.DueDate)
.ToListAsync(cancellationToken);
}
/// <inheritdoc />
public async Task<IReadOnlyList<TenantBillingStatement>> GetByTenantIdAsync(long tenantId, CancellationToken cancellationToken = default)
{
return await context.TenantBillingStatements
.IgnoreQueryFilters()
.AsNoTracking()
.Where(x => x.DeletedAt == null && x.TenantId == tenantId)
.OrderByDescending(x => x.PeriodEnd)
.ToListAsync(cancellationToken);
}
/// <inheritdoc />
public async Task<IReadOnlyList<TenantBillingStatement>> GetByIdsAsync(IReadOnlyCollection<long> billingIds, CancellationToken cancellationToken = default)
{
if (billingIds.Count == 0)
{
return Array.Empty<TenantBillingStatement>();
}
// 1. 忽略全局过滤器以支持管理员端跨租户导出/批量操作
return await context.TenantBillingStatements
.IgnoreQueryFilters()
.AsNoTracking()
.Where(x => x.DeletedAt == null && billingIds.Contains(x.Id))
.OrderByDescending(x => x.PeriodStart)
.ToListAsync(cancellationToken);
}
/// <inheritdoc />
public Task AddAsync(TenantBillingStatement bill, CancellationToken cancellationToken = default)
{
return context.TenantBillingStatements.AddAsync(bill, cancellationToken).AsTask();
}
/// <inheritdoc />
public Task UpdateAsync(TenantBillingStatement bill, CancellationToken cancellationToken = default)
{
context.TenantBillingStatements.Update(bill);
return Task.CompletedTask;
}
/// <inheritdoc />
public Task SaveChangesAsync(CancellationToken cancellationToken = default)
{
return context.SaveChangesAsync(cancellationToken);
}
/// <inheritdoc />
public async Task<(IReadOnlyList<TenantBillingStatement> Items, int Total)> SearchPagedAsync(
long? tenantId,
TenantBillingStatus? status,
DateTime? from,
DateTime? to,
decimal? minAmount,
decimal? maxAmount,
string? keyword,
int pageNumber,
int pageSize,
CancellationToken cancellationToken = default)
{
// 1. 构建基础查询(管理员端跨租户查询,忽略过滤器)
var query = context.TenantBillingStatements
.IgnoreQueryFilters()
.AsNoTracking()
.Where(x => x.DeletedAt == null);
// 2. (空行后) 按租户过滤(可选)
if (tenantId.HasValue)
{
query = query.Where(x => x.TenantId == tenantId.Value);
}
// 3. (空行后) 按状态过滤(可选)
if (status.HasValue)
{
query = query.Where(x => x.Status == status.Value);
}
// 4. (空行后) 按日期范围过滤(账单周期)
if (from.HasValue)
{
query = query.Where(x => x.PeriodStart >= from.Value);
}
if (to.HasValue)
{
query = query.Where(x => x.PeriodEnd <= to.Value);
}
// 5. (空行后) 按金额范围过滤(应付金额,包含边界)
if (minAmount.HasValue)
{
query = query.Where(x => x.AmountDue >= minAmount.Value);
}
if (maxAmount.HasValue)
{
query = query.Where(x => x.AmountDue <= maxAmount.Value);
}
// 6. (空行后) 关键字过滤(账单号或租户名)
if (!string.IsNullOrWhiteSpace(keyword))
{
var normalized = keyword.Trim();
query =
from b in query
join t in context.Tenants
.IgnoreQueryFilters()
.AsNoTracking()
.Where(x => x.DeletedAt == null)
on b.TenantId equals t.Id
where EF.Functions.ILike(b.StatementNo, $"%{normalized}%")
|| EF.Functions.ILike(t.Name, $"%{normalized}%")
select b;
}
// 7. (空行后) 统计总数
var total = await query.CountAsync(cancellationToken);
// 8. (空行后) 分页查询
var items = await query
.OrderByDescending(x => x.PeriodEnd)
.Skip((pageNumber - 1) * pageSize)
.Take(pageSize)
.ToListAsync(cancellationToken);
return (items, total);
}
/// <inheritdoc />
public async Task<TenantBillingStatistics> GetStatisticsAsync(
long? tenantId,
DateTime startDate,
DateTime endDate,
string groupBy,
CancellationToken cancellationToken = default)
{
// 1. 构建基础查询(忽略过滤器,显式过滤软删除/租户/时间范围)
var query = context.TenantBillingStatements
.IgnoreQueryFilters()
.AsNoTracking()
.Where(x => x.DeletedAt == null
&& (!tenantId.HasValue || x.TenantId == tenantId.Value)
&& x.PeriodStart >= startDate
&& x.PeriodEnd <= endDate);
// 2. (空行后) 聚合统计(金额统一使用:应付 - 折扣 + 税费)
var now = DateTime.UtcNow;
var totalAmount = await query.SumAsync(x => x.AmountDue - x.DiscountAmount + x.TaxAmount, cancellationToken);
var paidAmount = await query.Where(x => x.Status == TenantBillingStatus.Paid).SumAsync(x => x.AmountPaid, cancellationToken);
var unpaidAmount = await query.SumAsync(x => (x.AmountDue - x.DiscountAmount + x.TaxAmount) - x.AmountPaid, cancellationToken);
var overdueAmount = await query
.Where(x => (x.Status == TenantBillingStatus.Pending || x.Status == TenantBillingStatus.Overdue) && x.DueDate < now)
.SumAsync(x => (x.AmountDue - x.DiscountAmount + x.TaxAmount) - x.AmountPaid, cancellationToken);
// 3. (空行后) 数量统计
var totalCount = await query.CountAsync(cancellationToken);
var paidCount = await query.CountAsync(x => x.Status == TenantBillingStatus.Paid, cancellationToken);
var unpaidCount = await query.CountAsync(x => x.Status == TenantBillingStatus.Pending || x.Status == TenantBillingStatus.Overdue, cancellationToken);
var overdueCount = await query.CountAsync(x => (x.Status == TenantBillingStatus.Pending || x.Status == TenantBillingStatus.Overdue) && x.DueDate < now, cancellationToken);
// 4. (空行后) 趋势统计
var normalizedGroupBy = NormalizeGroupBy(groupBy);
var trendRaw = await query
.Select(x => new
{
x.PeriodStart,
x.AmountDue,
x.DiscountAmount,
x.TaxAmount,
x.AmountPaid
})
.ToListAsync(cancellationToken);
// 4.1 (空行后) 在内存中按 Day/Week/Month 聚合(避免依赖特定数据库函数扩展)
var trend = trendRaw
.GroupBy(x => GetTrendBucket(x.PeriodStart, normalizedGroupBy))
.Select(g => new TenantBillingTrendDataPoint
{
Period = g.Key,
Count = g.Count(),
TotalAmount = g.Sum(x => x.AmountDue - x.DiscountAmount + x.TaxAmount),
PaidAmount = g.Sum(x => x.AmountPaid)
})
.OrderBy(x => x.Period)
.ToList();
return new TenantBillingStatistics
{
TotalAmount = totalAmount,
PaidAmount = paidAmount,
UnpaidAmount = unpaidAmount,
OverdueAmount = overdueAmount,
TotalCount = totalCount,
PaidCount = paidCount,
UnpaidCount = unpaidCount,
OverdueCount = overdueCount,
TrendData = trend
};
}
/// <inheritdoc />
public Task<TenantBillingStatement?> FindByIdAsync(long billingId, CancellationToken cancellationToken = default)
{
return context.TenantBillingStatements
.IgnoreQueryFilters()
.AsNoTracking()
.FirstOrDefaultAsync(x => x.DeletedAt == null && x.Id == billingId, cancellationToken);
}
private static string NormalizeGroupBy(string groupBy)
{
return groupBy.Trim() switch
{
"Week" => "Week",
"Month" => "Month",
_ => "Day"
};
}
private static DateTime GetTrendBucket(DateTime periodStart, string groupBy)
{
var date = periodStart.Date;
return groupBy switch
{
"Month" => new DateTime(date.Year, date.Month, 1, 0, 0, 0, DateTimeKind.Utc),
"Week" => GetWeekStart(date),
_ => new DateTime(date.Year, date.Month, date.Day, 0, 0, 0, DateTimeKind.Utc)
};
}
private static DateTime GetWeekStart(DateTime date)
{
// 1. 将周一作为一周起始(与 PostgreSQL date_trunc('week', ...) 对齐)
var dayOfWeek = (int)date.DayOfWeek; // Sunday=0, Monday=1, ...
var daysSinceMonday = (dayOfWeek + 6) % 7;
// 2. (空行后) 回退到周一 00:00:00UTC
var monday = date.AddDays(-daysSinceMonday);
return new DateTime(monday.Year, monday.Month, monday.Day, 0, 0, 0, DateTimeKind.Utc);
}
}

View File

@@ -0,0 +1,76 @@
using Microsoft.EntityFrameworkCore;
using TakeoutSaaS.Domain.Tenants.Entities;
using TakeoutSaaS.Domain.Tenants.Enums;
using TakeoutSaaS.Domain.Tenants.Repositories;
using TakeoutSaaS.Infrastructure.App.Persistence;
namespace TakeoutSaaS.Infrastructure.App.Persistence.Repositories;
/// <summary>
/// 租户支付记录仓储实现EF Core
/// </summary>
public sealed class TenantPaymentRepository(TakeoutAppDbContext context) : ITenantPaymentRepository
{
/// <inheritdoc />
public async Task<IReadOnlyList<TenantPayment>> GetByBillingIdAsync(long billingStatementId, CancellationToken cancellationToken = default)
{
return await context.TenantPayments
.IgnoreQueryFilters()
.AsNoTracking()
.Where(x => x.DeletedAt == null && x.BillingStatementId == billingStatementId)
.OrderByDescending(x => x.CreatedAt)
.ToListAsync(cancellationToken);
}
/// <inheritdoc />
public async Task<decimal> GetTotalPaidAmountAsync(long billingStatementId, CancellationToken cancellationToken = default)
{
// 1. 仅统计支付成功的记录
return await context.TenantPayments
.IgnoreQueryFilters()
.AsNoTracking()
.Where(x => x.DeletedAt == null
&& x.BillingStatementId == billingStatementId
&& x.Status == TenantPaymentStatus.Success)
.SumAsync(x => x.Amount, cancellationToken);
}
/// <inheritdoc />
public Task<TenantPayment?> FindByIdAsync(long paymentId, CancellationToken cancellationToken = default)
{
return context.TenantPayments
.IgnoreQueryFilters()
.AsNoTracking()
.FirstOrDefaultAsync(x => x.DeletedAt == null && x.Id == paymentId, cancellationToken);
}
/// <inheritdoc />
public Task<TenantPayment?> GetByTransactionNoAsync(string transactionNo, CancellationToken cancellationToken = default)
{
var normalized = transactionNo.Trim();
return context.TenantPayments
.IgnoreQueryFilters()
.AsNoTracking()
.FirstOrDefaultAsync(x => x.DeletedAt == null && x.TransactionNo == normalized, cancellationToken);
}
/// <inheritdoc />
public Task AddAsync(TenantPayment payment, CancellationToken cancellationToken = default)
{
return context.TenantPayments.AddAsync(payment, cancellationToken).AsTask();
}
/// <inheritdoc />
public Task UpdateAsync(TenantPayment payment, CancellationToken cancellationToken = default)
{
context.TenantPayments.Update(payment);
return Task.CompletedTask;
}
/// <inheritdoc />
public Task SaveChangesAsync(CancellationToken cancellationToken = default)
{
return context.SaveChangesAsync(cancellationToken);
}
}

View File

@@ -24,6 +24,7 @@ using TakeoutSaaS.Infrastructure.Common.Persistence;
using TakeoutSaaS.Shared.Abstractions.Ids; using TakeoutSaaS.Shared.Abstractions.Ids;
using TakeoutSaaS.Shared.Abstractions.Security; using TakeoutSaaS.Shared.Abstractions.Security;
using TakeoutSaaS.Shared.Abstractions.Tenancy; using TakeoutSaaS.Shared.Abstractions.Tenancy;
using TakeoutSaaS.Infrastructure.App.Persistence.Configurations;
namespace TakeoutSaaS.Infrastructure.App.Persistence; namespace TakeoutSaaS.Infrastructure.App.Persistence;
@@ -762,28 +763,12 @@ public sealed class TakeoutAppDbContext(
private static void ConfigureTenantBilling(EntityTypeBuilder<TenantBillingStatement> builder) private static void ConfigureTenantBilling(EntityTypeBuilder<TenantBillingStatement> builder)
{ {
builder.ToTable("tenant_billing_statements"); new TenantBillingStatementConfiguration().Configure(builder);
builder.HasKey(x => x.Id);
builder.Property(x => x.StatementNo).HasMaxLength(64).IsRequired();
builder.Property(x => x.AmountDue).HasPrecision(18, 2);
builder.Property(x => x.AmountPaid).HasPrecision(18, 2);
builder.Property(x => x.Status).HasConversion<int>();
builder.Property(x => x.LineItemsJson).HasColumnType("text");
builder.HasIndex(x => new { x.TenantId, x.StatementNo }).IsUnique();
} }
private static void ConfigureTenantPayment(EntityTypeBuilder<TenantPayment> builder) private static void ConfigureTenantPayment(EntityTypeBuilder<TenantPayment> builder)
{ {
builder.ToTable("tenant_payments"); new TenantPaymentConfiguration().Configure(builder);
builder.HasKey(x => x.Id);
builder.Property(x => x.BillingStatementId).IsRequired();
builder.Property(x => x.Amount).HasPrecision(18, 2).IsRequired();
builder.Property(x => x.Method).HasConversion<int>();
builder.Property(x => x.Status).HasConversion<int>();
builder.Property(x => x.TransactionNo).HasMaxLength(64);
builder.Property(x => x.ProofUrl).HasMaxLength(512);
builder.Property(x => x.Notes).HasMaxLength(512);
builder.HasIndex(x => new { x.TenantId, x.BillingStatementId });
} }
private static void ConfigureTenantNotification(EntityTypeBuilder<TenantNotification> builder) private static void ConfigureTenantNotification(EntityTypeBuilder<TenantNotification> builder)

View File

@@ -1,155 +0,0 @@
using Microsoft.EntityFrameworkCore;
using TakeoutSaaS.Domain.Tenants.Entities;
using TakeoutSaaS.Domain.Tenants.Enums;
using TakeoutSaaS.Domain.Tenants.Repositories;
using TakeoutSaaS.Infrastructure.App.Persistence;
namespace TakeoutSaaS.Infrastructure.App.Repositories;
/// <summary>
/// EF 租户账单仓储。
/// </summary>
public sealed class EfTenantBillingRepository(TakeoutAppDbContext context) : ITenantBillingRepository
{
/// <inheritdoc />
public Task<IReadOnlyList<TenantBillingStatement>> SearchAsync(
long tenantId,
TenantBillingStatus? status,
DateTime? from,
DateTime? to,
CancellationToken cancellationToken = default)
{
var query = context.TenantBillingStatements.AsNoTracking()
.Where(x => x.TenantId == tenantId);
if (status.HasValue)
{
query = query.Where(x => x.Status == status.Value);
}
if (from.HasValue)
{
query = query.Where(x => x.PeriodStart >= from.Value);
}
if (to.HasValue)
{
query = query.Where(x => x.PeriodEnd <= to.Value);
}
return query
.OrderByDescending(x => x.PeriodEnd)
.ToListAsync(cancellationToken)
.ContinueWith(t => (IReadOnlyList<TenantBillingStatement>)t.Result, cancellationToken);
}
/// <inheritdoc />
public async Task<(IReadOnlyList<TenantBillingStatement> Items, int Total)> SearchPagedAsync(
long? tenantId,
TenantBillingStatus? status,
DateTime? from,
DateTime? to,
string? keyword,
int pageNumber,
int pageSize,
CancellationToken cancellationToken = default)
{
var query = context.TenantBillingStatements.AsNoTracking();
// 1. 按租户过滤(可选)
if (tenantId.HasValue)
{
query = query.Where(x => x.TenantId == tenantId.Value);
}
// 2. 按状态过滤
if (status.HasValue)
{
query = query.Where(x => x.Status == status.Value);
}
// 3. 按日期范围过滤
if (from.HasValue)
{
query = query.Where(x => x.PeriodStart >= from.Value);
}
if (to.HasValue)
{
query = query.Where(x => x.PeriodEnd <= to.Value);
}
// 4. 按关键字过滤(账单编号)
if (!string.IsNullOrWhiteSpace(keyword))
{
var normalizedKeyword = keyword.Trim();
query = query.Where(x => EF.Functions.ILike(x.StatementNo, $"%{normalizedKeyword}%"));
}
// 5. 统计总数
var total = await query.CountAsync(cancellationToken);
// 6. 分页查询
var items = await query
.OrderByDescending(x => x.PeriodEnd)
.Skip((pageNumber - 1) * pageSize)
.Take(pageSize)
.ToListAsync(cancellationToken);
return (items, total);
}
/// <inheritdoc />
public Task<TenantBillingStatement?> FindByIdAsync(long billingId, CancellationToken cancellationToken = default)
{
return context.TenantBillingStatements.AsNoTracking()
.FirstOrDefaultAsync(x => x.Id == billingId, cancellationToken);
}
/// <inheritdoc />
public Task<TenantBillingStatement?> FindByIdAsync(long tenantId, long billingId, CancellationToken cancellationToken = default)
{
return context.TenantBillingStatements.AsNoTracking()
.FirstOrDefaultAsync(x => x.TenantId == tenantId && x.Id == billingId, cancellationToken);
}
/// <inheritdoc />
public Task<TenantBillingStatement?> FindByStatementNoAsync(long tenantId, string statementNo, CancellationToken cancellationToken = default)
{
return context.TenantBillingStatements.AsNoTracking()
.FirstOrDefaultAsync(x => x.TenantId == tenantId && x.StatementNo == statementNo, cancellationToken);
}
/// <inheritdoc />
public Task<bool> ExistsNotCancelledByPeriodStartAsync(
long tenantId,
DateTime periodStart,
CancellationToken cancellationToken = default)
{
return context.TenantBillingStatements.AsNoTracking()
.AnyAsync(
x => x.TenantId == tenantId
&& x.PeriodStart == periodStart
&& x.Status != TenantBillingStatus.Cancelled,
cancellationToken);
}
/// <inheritdoc />
public Task AddAsync(TenantBillingStatement bill, CancellationToken cancellationToken = default)
{
return context.TenantBillingStatements.AddAsync(bill, cancellationToken).AsTask();
}
/// <inheritdoc />
public Task UpdateAsync(TenantBillingStatement bill, CancellationToken cancellationToken = default)
{
context.TenantBillingStatements.Update(bill);
return Task.CompletedTask;
}
/// <inheritdoc />
public Task SaveChangesAsync(CancellationToken cancellationToken = default)
{
return context.SaveChangesAsync(cancellationToken);
}
}

View File

@@ -1,47 +0,0 @@
using Microsoft.EntityFrameworkCore;
using TakeoutSaaS.Domain.Tenants.Entities;
using TakeoutSaaS.Domain.Tenants.Repositories;
using TakeoutSaaS.Infrastructure.App.Persistence;
namespace TakeoutSaaS.Infrastructure.App.Repositories;
/// <summary>
/// EF 租户支付记录仓储。
/// </summary>
public sealed class EfTenantPaymentRepository(TakeoutAppDbContext context) : ITenantPaymentRepository
{
/// <inheritdoc />
public async Task<IReadOnlyList<TenantPayment>> GetByBillingIdAsync(long billingStatementId, CancellationToken cancellationToken = default)
{
return await context.TenantPayments.AsNoTracking()
.Where(x => x.BillingStatementId == billingStatementId)
.OrderByDescending(x => x.PaidAt)
.ToListAsync(cancellationToken);
}
/// <inheritdoc />
public Task<TenantPayment?> FindByIdAsync(long paymentId, CancellationToken cancellationToken = default)
{
return context.TenantPayments.AsNoTracking()
.FirstOrDefaultAsync(x => x.Id == paymentId, cancellationToken);
}
/// <inheritdoc />
public Task AddAsync(TenantPayment payment, CancellationToken cancellationToken = default)
{
return context.TenantPayments.AddAsync(payment, cancellationToken).AsTask();
}
/// <inheritdoc />
public Task UpdateAsync(TenantPayment payment, CancellationToken cancellationToken = default)
{
context.TenantPayments.Update(payment);
return Task.CompletedTask;
}
/// <inheritdoc />
public Task SaveChangesAsync(CancellationToken cancellationToken = default)
{
return context.SaveChangesAsync(cancellationToken);
}
}

View File

@@ -0,0 +1,202 @@
using System.Globalization;
using System.Text.Json;
using TakeoutSaaS.Domain.Tenants.Entities;
using TakeoutSaaS.Domain.Tenants.Enums;
using TakeoutSaaS.Domain.Tenants.Repositories;
using TakeoutSaaS.Domain.Tenants.Services;
using TakeoutSaaS.Shared.Abstractions.Ids;
namespace TakeoutSaaS.Infrastructure.App.Services;
/// <summary>
/// 账单领域服务实现。
/// </summary>
public sealed class BillingDomainService(
ITenantBillingRepository billingRepository,
ITenantPackageRepository tenantPackageRepository,
IIdGenerator idGenerator) : IBillingDomainService
{
/// <inheritdoc />
public async Task<TenantBillingStatement> GenerateSubscriptionBillingAsync(
TenantSubscription subscription,
CancellationToken cancellationToken = default)
{
ArgumentNullException.ThrowIfNull(subscription);
// 1. 校验幂等:同一周期开始时间只能存在一张未取消账单
var exists = await billingRepository.ExistsNotCancelledByPeriodStartAsync(
subscription.TenantId,
subscription.EffectiveFrom,
cancellationToken);
if (exists)
{
throw new InvalidOperationException("该订阅周期的账单已存在。");
}
// 2. (空行后) 查询套餐价格信息
var package = await tenantPackageRepository.FindByIdAsync(subscription.TenantPackageId, cancellationToken);
if (package is null)
{
throw new InvalidOperationException("订阅未关联有效套餐,无法生成账单。");
}
// 3. (空行后) 选择价格(简化规则:优先按年/按月)
var days = (subscription.EffectiveTo - subscription.EffectiveFrom).TotalDays;
var amountDue = days >= 300 ? package.YearlyPrice : package.MonthlyPrice;
if (!amountDue.HasValue)
{
throw new InvalidOperationException("套餐价格未配置,无法生成账单。");
}
// 4. (空行后) 生成账单明细
var lineItems = new List<BillingLineItem>
{
BillingLineItem.Create(
itemType: "Subscription",
description: $"套餐 {package.Name} 订阅费用",
quantity: 1,
unitPrice: amountDue.Value)
};
// 5. (空行后) 构建账单实体
var now = DateTime.UtcNow;
return new TenantBillingStatement
{
Id = idGenerator.NextId(),
TenantId = subscription.TenantId,
StatementNo = GenerateStatementNo(),
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
};
}
/// <inheritdoc />
public Task<TenantBillingStatement> GenerateQuotaPurchaseBillingAsync(
long tenantId,
QuotaPackage quotaPackage,
int quantity,
CancellationToken cancellationToken = default)
{
ArgumentNullException.ThrowIfNull(quotaPackage);
if (quantity <= 0)
{
throw new ArgumentOutOfRangeException(nameof(quantity), "购买数量必须大于 0。");
}
// 1. 计算金额
var amountDue = quotaPackage.Price * quantity;
// 2. (空行后) 生成账单明细
var lineItems = new List<BillingLineItem>
{
BillingLineItem.Create(
itemType: "QuotaPurchase",
description: $"配额包 {quotaPackage.Name} × {quantity}",
quantity: quantity,
unitPrice: quotaPackage.Price)
};
// 3. (空行后) 构建账单实体
var now = DateTime.UtcNow;
var billing = new TenantBillingStatement
{
Id = idGenerator.NextId(),
TenantId = tenantId,
StatementNo = GenerateStatementNo(),
BillingType = BillingType.QuotaPurchase,
SubscriptionId = null,
PeriodStart = now,
PeriodEnd = now,
AmountDue = amountDue,
DiscountAmount = 0m,
TaxAmount = 0m,
AmountPaid = 0m,
Currency = "CNY",
Status = TenantBillingStatus.Pending,
DueDate = now.AddDays(7),
LineItemsJson = JsonSerializer.Serialize(lineItems),
Notes = quotaPackage.Description
};
return Task.FromResult(billing);
}
/// <inheritdoc />
public string GenerateStatementNo()
{
// 1. 账单号格式BILL-{yyyyMMdd}-{序号}
var date = DateTime.UtcNow.ToString("yyyyMMdd", CultureInfo.InvariantCulture);
// 2. (空行后) 使用雪花 ID 作为全局递增序号,确保分布式唯一
var sequence = idGenerator.NextId();
return $"BILL-{date}-{sequence}";
}
/// <inheritdoc />
public async Task<int> ProcessOverdueBillingsAsync(CancellationToken cancellationToken = default)
{
// 1. 查询当前已逾期且未支付/未取消的账单(由仓储按 DueDate 筛选)
var overdueBillings = await billingRepository.GetOverdueBillingsAsync(cancellationToken);
if (overdueBillings.Count == 0)
{
return 0;
}
// 2. (空行后) 批量标记逾期(仅处理 Pending
var processedAt = DateTime.UtcNow;
var updated = 0;
foreach (var billing in overdueBillings)
{
if (billing.Status != TenantBillingStatus.Pending)
{
continue;
}
billing.MarkAsOverdue();
billing.OverdueNotifiedAt ??= processedAt;
billing.UpdatedAt = processedAt;
await billingRepository.UpdateAsync(billing, cancellationToken);
updated++;
}
// 3. (空行后) 持久化
if (updated > 0)
{
await billingRepository.SaveChangesAsync(cancellationToken);
}
return updated;
}
/// <inheritdoc />
public decimal CalculateTotalAmount(decimal baseAmount, decimal discountAmount, decimal taxAmount)
{
return baseAmount - discountAmount + taxAmount;
}
/// <inheritdoc />
public bool CanProcessPayment(TenantBillingStatement billing)
{
ArgumentNullException.ThrowIfNull(billing);
return billing.Status switch
{
TenantBillingStatus.Pending => true,
TenantBillingStatus.Overdue => true,
_ => false
};
}
}

View File

@@ -0,0 +1,203 @@
using ClosedXML.Excel;
using CsvHelper;
using CsvHelper.Configuration;
using QuestPDF.Fluent;
using QuestPDF.Helpers;
using QuestPDF.Infrastructure;
using System.Globalization;
using System.Text;
using TakeoutSaaS.Domain.Tenants.Entities;
using TakeoutSaaS.Domain.Tenants.Services;
namespace TakeoutSaaS.Infrastructure.App.Services;
/// <summary>
/// 账单导出服务实现Excel/PDF/CSV
/// </summary>
public sealed class BillingExportService : IBillingExportService
{
/// <summary>
/// 初始化导出服务并配置 QuestPDF 许可证。
/// </summary>
public BillingExportService()
{
QuestPDF.Settings.License = LicenseType.Community;
}
/// <inheritdoc />
public Task<byte[]> ExportToExcelAsync(IReadOnlyList<TenantBillingStatement> billings, CancellationToken cancellationToken = default)
{
ArgumentNullException.ThrowIfNull(billings);
// 1. 创建工作簿与工作表
using var workbook = new XLWorkbook();
var worksheet = workbook.Worksheets.Add("Billings");
// 2. (空行后) 写入表头
var headers = new[]
{
"Id", "TenantId", "StatementNo", "BillingType", "Status",
"PeriodStart", "PeriodEnd", "AmountDue", "DiscountAmount", "TaxAmount", "TotalAmount",
"AmountPaid", "Currency", "DueDate", "Notes", "LineItemsJson"
};
for (var i = 0; i < headers.Length; i++)
{
worksheet.Cell(1, i + 1).Value = headers[i];
}
// 3. (空行后) 写入数据行
for (var rowIndex = 0; rowIndex < billings.Count; rowIndex++)
{
cancellationToken.ThrowIfCancellationRequested();
var billing = billings[rowIndex];
var totalAmount = billing.CalculateTotalAmount();
var r = rowIndex + 2;
worksheet.Cell(r, 1).Value = billing.Id;
worksheet.Cell(r, 2).Value = billing.TenantId;
worksheet.Cell(r, 3).Value = billing.StatementNo;
worksheet.Cell(r, 4).Value = billing.BillingType.ToString();
worksheet.Cell(r, 5).Value = billing.Status.ToString();
worksheet.Cell(r, 6).Value = billing.PeriodStart.ToString("O", CultureInfo.InvariantCulture);
worksheet.Cell(r, 7).Value = billing.PeriodEnd.ToString("O", CultureInfo.InvariantCulture);
worksheet.Cell(r, 8).Value = billing.AmountDue;
worksheet.Cell(r, 9).Value = billing.DiscountAmount;
worksheet.Cell(r, 10).Value = billing.TaxAmount;
worksheet.Cell(r, 11).Value = totalAmount;
worksheet.Cell(r, 12).Value = billing.AmountPaid;
worksheet.Cell(r, 13).Value = billing.Currency;
worksheet.Cell(r, 14).Value = billing.DueDate.ToString("O", CultureInfo.InvariantCulture);
worksheet.Cell(r, 15).Value = billing.Notes ?? string.Empty;
worksheet.Cell(r, 16).Value = billing.LineItemsJson ?? string.Empty;
}
// 4. (空行后) 自动调整列宽并输出
worksheet.Columns().AdjustToContents();
using var stream = new MemoryStream();
workbook.SaveAs(stream);
return Task.FromResult(stream.ToArray());
}
/// <inheritdoc />
public Task<byte[]> ExportToPdfAsync(IReadOnlyList<TenantBillingStatement> billings, CancellationToken cancellationToken = default)
{
ArgumentNullException.ThrowIfNull(billings);
// 1. 生成 PDF 文档(避免复杂表格,按条目输出)
var document = Document.Create(container =>
{
container.Page(page =>
{
page.Size(PageSizes.A4);
page.Margin(20);
page.DefaultTextStyle(x => x.FontSize(10));
page.Content().Column(column =>
{
column.Spacing(6);
// 2. 标题
column.Item().Text("Billings Export").FontSize(16).SemiBold();
// 3. (空行后) 逐条输出
for (var i = 0; i < billings.Count; i++)
{
cancellationToken.ThrowIfCancellationRequested();
var b = billings[i];
var total = b.CalculateTotalAmount();
column.Item().Border(1).BorderColor(Colors.Grey.Lighten2).Padding(8).Column(item =>
{
item.Spacing(2);
item.Item().Text($"StatementNo: {b.StatementNo}");
item.Item().Text($"TenantId: {b.TenantId} BillingType: {b.BillingType} Status: {b.Status}");
item.Item().Text($"Period: {b.PeriodStart:yyyy-MM-dd} ~ {b.PeriodEnd:yyyy-MM-dd} DueDate: {b.DueDate:yyyy-MM-dd}");
item.Item().Text($"AmountDue: {b.AmountDue:0.##} Discount: {b.DiscountAmount:0.##} Tax: {b.TaxAmount:0.##}");
item.Item().Text($"Total: {total:0.##} Paid: {b.AmountPaid:0.##} Currency: {b.Currency}");
if (!string.IsNullOrWhiteSpace(b.Notes))
{
item.Item().Text($"Notes: {b.Notes}");
}
});
}
});
});
});
// 4. (空行后) 输出字节
var bytes = document.GeneratePdf();
return Task.FromResult(bytes);
}
/// <inheritdoc />
public async Task<byte[]> ExportToCsvAsync(IReadOnlyList<TenantBillingStatement> billings, CancellationToken cancellationToken = default)
{
ArgumentNullException.ThrowIfNull(billings);
// 1. 使用 UTF-8 BOM便于 Excel 直接打开
await using var stream = new MemoryStream();
await using var writer = new StreamWriter(stream, new UTF8Encoding(encoderShouldEmitUTF8Identifier: true), leaveOpen: true);
var config = new CsvConfiguration(CultureInfo.InvariantCulture)
{
HasHeaderRecord = true
};
await using var csv = new CsvWriter(writer, config);
// 2. 写入表头
csv.WriteField("Id");
csv.WriteField("TenantId");
csv.WriteField("StatementNo");
csv.WriteField("BillingType");
csv.WriteField("Status");
csv.WriteField("PeriodStart");
csv.WriteField("PeriodEnd");
csv.WriteField("AmountDue");
csv.WriteField("DiscountAmount");
csv.WriteField("TaxAmount");
csv.WriteField("TotalAmount");
csv.WriteField("AmountPaid");
csv.WriteField("Currency");
csv.WriteField("DueDate");
csv.WriteField("Notes");
csv.WriteField("LineItemsJson");
await csv.NextRecordAsync();
// 3. (空行后) 写入数据行
foreach (var b in billings)
{
cancellationToken.ThrowIfCancellationRequested();
var total = b.CalculateTotalAmount();
csv.WriteField(b.Id);
csv.WriteField(b.TenantId);
csv.WriteField(b.StatementNo);
csv.WriteField(b.BillingType.ToString());
csv.WriteField(b.Status.ToString());
csv.WriteField(b.PeriodStart.ToString("O", CultureInfo.InvariantCulture));
csv.WriteField(b.PeriodEnd.ToString("O", CultureInfo.InvariantCulture));
csv.WriteField(b.AmountDue);
csv.WriteField(b.DiscountAmount);
csv.WriteField(b.TaxAmount);
csv.WriteField(total);
csv.WriteField(b.AmountPaid);
csv.WriteField(b.Currency);
csv.WriteField(b.DueDate.ToString("O", CultureInfo.InvariantCulture));
csv.WriteField(b.Notes ?? string.Empty);
csv.WriteField(b.LineItemsJson ?? string.Empty);
await csv.NextRecordAsync();
}
// 4. (空行后) Flush 并返回字节
await writer.FlushAsync(cancellationToken);
return stream.ToArray();
}
}

View File

@@ -0,0 +1,237 @@
using System;
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace TakeoutSaaS.Infrastructure.Migrations
{
/// <inheritdoc />
public partial class UpdateTenantBillingSchema : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.AddColumn<string>(
name: "RefundReason",
table: "tenant_payments",
type: "character varying(512)",
maxLength: 512,
nullable: true,
comment: "退款原因。");
migrationBuilder.AddColumn<DateTime>(
name: "RefundedAt",
table: "tenant_payments",
type: "timestamp with time zone",
nullable: true,
comment: "退款时间。");
migrationBuilder.AddColumn<DateTime>(
name: "VerifiedAt",
table: "tenant_payments",
type: "timestamp with time zone",
nullable: true,
comment: "审核时间。");
migrationBuilder.AddColumn<long>(
name: "VerifiedBy",
table: "tenant_payments",
type: "bigint",
nullable: true,
comment: "审核人 ID管理员。");
migrationBuilder.AlterColumn<decimal>(
name: "AmountDue",
table: "tenant_billing_statements",
type: "numeric(18,2)",
precision: 18,
scale: 2,
nullable: false,
comment: "应付金额(原始金额)。",
oldClrType: typeof(decimal),
oldType: "numeric(18,2)",
oldPrecision: 18,
oldScale: 2,
oldComment: "应付金额。");
migrationBuilder.AddColumn<int>(
name: "BillingType",
table: "tenant_billing_statements",
type: "integer",
nullable: false,
defaultValue: 0,
comment: "账单类型(订阅账单/配额包账单/手动账单/续费账单)。");
migrationBuilder.AddColumn<string>(
name: "Currency",
table: "tenant_billing_statements",
type: "character varying(8)",
maxLength: 8,
nullable: false,
defaultValue: "CNY",
comment: "货币类型(默认 CNY。");
migrationBuilder.AddColumn<decimal>(
name: "DiscountAmount",
table: "tenant_billing_statements",
type: "numeric(18,2)",
precision: 18,
scale: 2,
nullable: false,
defaultValue: 0m,
comment: "折扣金额。");
migrationBuilder.AddColumn<string>(
name: "Notes",
table: "tenant_billing_statements",
type: "character varying(512)",
maxLength: 512,
nullable: true,
comment: "备注信息(如:人工备注、取消原因等)。");
migrationBuilder.AddColumn<DateTime>(
name: "OverdueNotifiedAt",
table: "tenant_billing_statements",
type: "timestamp with time zone",
nullable: true,
comment: "逾期通知时间。");
migrationBuilder.AddColumn<DateTime>(
name: "ReminderSentAt",
table: "tenant_billing_statements",
type: "timestamp with time zone",
nullable: true,
comment: "提醒发送时间(续费提醒、逾期提醒等)。");
migrationBuilder.AddColumn<long>(
name: "SubscriptionId",
table: "tenant_billing_statements",
type: "bigint",
nullable: true,
comment: "关联的订阅 ID仅当 BillingType 为 Subscription 或 Renewal 时有值)。");
migrationBuilder.AddColumn<decimal>(
name: "TaxAmount",
table: "tenant_billing_statements",
type: "numeric(18,2)",
precision: 18,
scale: 2,
nullable: false,
defaultValue: 0m,
comment: "税费金额。");
migrationBuilder.CreateIndex(
name: "idx_payment_billing_paidat",
table: "tenant_payments",
columns: new[] { "BillingStatementId", "PaidAt" });
migrationBuilder.CreateIndex(
name: "idx_payment_transaction_no",
table: "tenant_payments",
column: "TransactionNo",
filter: "\"TransactionNo\" IS NOT NULL");
migrationBuilder.CreateIndex(
name: "idx_billing_created_at",
table: "tenant_billing_statements",
column: "CreatedAt");
migrationBuilder.CreateIndex(
name: "idx_billing_status_duedate",
table: "tenant_billing_statements",
columns: new[] { "Status", "DueDate" },
filter: "\"Status\" IN (0, 2)");
migrationBuilder.CreateIndex(
name: "idx_billing_tenant_status_duedate",
table: "tenant_billing_statements",
columns: new[] { "TenantId", "Status", "DueDate" });
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropIndex(
name: "idx_payment_billing_paidat",
table: "tenant_payments");
migrationBuilder.DropIndex(
name: "idx_payment_transaction_no",
table: "tenant_payments");
migrationBuilder.DropIndex(
name: "idx_billing_created_at",
table: "tenant_billing_statements");
migrationBuilder.DropIndex(
name: "idx_billing_status_duedate",
table: "tenant_billing_statements");
migrationBuilder.DropIndex(
name: "idx_billing_tenant_status_duedate",
table: "tenant_billing_statements");
migrationBuilder.DropColumn(
name: "RefundReason",
table: "tenant_payments");
migrationBuilder.DropColumn(
name: "RefundedAt",
table: "tenant_payments");
migrationBuilder.DropColumn(
name: "VerifiedAt",
table: "tenant_payments");
migrationBuilder.DropColumn(
name: "VerifiedBy",
table: "tenant_payments");
migrationBuilder.DropColumn(
name: "BillingType",
table: "tenant_billing_statements");
migrationBuilder.DropColumn(
name: "Currency",
table: "tenant_billing_statements");
migrationBuilder.DropColumn(
name: "DiscountAmount",
table: "tenant_billing_statements");
migrationBuilder.DropColumn(
name: "Notes",
table: "tenant_billing_statements");
migrationBuilder.DropColumn(
name: "OverdueNotifiedAt",
table: "tenant_billing_statements");
migrationBuilder.DropColumn(
name: "ReminderSentAt",
table: "tenant_billing_statements");
migrationBuilder.DropColumn(
name: "SubscriptionId",
table: "tenant_billing_statements");
migrationBuilder.DropColumn(
name: "TaxAmount",
table: "tenant_billing_statements");
migrationBuilder.AlterColumn<decimal>(
name: "AmountDue",
table: "tenant_billing_statements",
type: "numeric(18,2)",
precision: 18,
scale: 2,
nullable: false,
comment: "应付金额。",
oldClrType: typeof(decimal),
oldType: "numeric(18,2)",
oldPrecision: 18,
oldScale: 2,
oldComment: "应付金额(原始金额)。");
}
}
}

View File

@@ -6221,13 +6221,17 @@ namespace TakeoutSaaS.Infrastructure.Migrations
b.Property<decimal>("AmountDue") b.Property<decimal>("AmountDue")
.HasPrecision(18, 2) .HasPrecision(18, 2)
.HasColumnType("numeric(18,2)") .HasColumnType("numeric(18,2)")
.HasComment("应付金额。"); .HasComment("应付金额(原始金额)。");
b.Property<decimal>("AmountPaid") b.Property<decimal>("AmountPaid")
.HasPrecision(18, 2) .HasPrecision(18, 2)
.HasColumnType("numeric(18,2)") .HasColumnType("numeric(18,2)")
.HasComment("实付金额。"); .HasComment("实付金额。");
b.Property<int>("BillingType")
.HasColumnType("integer")
.HasComment("账单类型(订阅账单/配额包账单/手动账单/续费账单)。");
b.Property<DateTime>("CreatedAt") b.Property<DateTime>("CreatedAt")
.HasColumnType("timestamp with time zone") .HasColumnType("timestamp with time zone")
.HasComment("创建时间UTC。"); .HasComment("创建时间UTC。");
@@ -6236,6 +6240,14 @@ namespace TakeoutSaaS.Infrastructure.Migrations
.HasColumnType("bigint") .HasColumnType("bigint")
.HasComment("创建人用户标识,匿名或系统操作时为 null。"); .HasComment("创建人用户标识,匿名或系统操作时为 null。");
b.Property<string>("Currency")
.IsRequired()
.ValueGeneratedOnAdd()
.HasMaxLength(8)
.HasColumnType("character varying(8)")
.HasDefaultValue("CNY")
.HasComment("货币类型(默认 CNY。");
b.Property<DateTime?>("DeletedAt") b.Property<DateTime?>("DeletedAt")
.HasColumnType("timestamp with time zone") .HasColumnType("timestamp with time zone")
.HasComment("软删除时间UTC未删除时为 null。"); .HasComment("软删除时间UTC未删除时为 null。");
@@ -6244,6 +6256,11 @@ namespace TakeoutSaaS.Infrastructure.Migrations
.HasColumnType("bigint") .HasColumnType("bigint")
.HasComment("删除人用户标识(软删除),未删除时为 null。"); .HasComment("删除人用户标识(软删除),未删除时为 null。");
b.Property<decimal>("DiscountAmount")
.HasPrecision(18, 2)
.HasColumnType("numeric(18,2)")
.HasComment("折扣金额。");
b.Property<DateTime>("DueDate") b.Property<DateTime>("DueDate")
.HasColumnType("timestamp with time zone") .HasColumnType("timestamp with time zone")
.HasComment("到期日。"); .HasComment("到期日。");
@@ -6252,6 +6269,15 @@ namespace TakeoutSaaS.Infrastructure.Migrations
.HasColumnType("text") .HasColumnType("text")
.HasComment("账单明细 JSON记录各项费用。"); .HasComment("账单明细 JSON记录各项费用。");
b.Property<string>("Notes")
.HasMaxLength(512)
.HasColumnType("character varying(512)")
.HasComment("备注信息(如:人工备注、取消原因等)。");
b.Property<DateTime?>("OverdueNotifiedAt")
.HasColumnType("timestamp with time zone")
.HasComment("逾期通知时间。");
b.Property<DateTime>("PeriodEnd") b.Property<DateTime>("PeriodEnd")
.HasColumnType("timestamp with time zone") .HasColumnType("timestamp with time zone")
.HasComment("账单周期结束时间。"); .HasComment("账单周期结束时间。");
@@ -6260,6 +6286,10 @@ namespace TakeoutSaaS.Infrastructure.Migrations
.HasColumnType("timestamp with time zone") .HasColumnType("timestamp with time zone")
.HasComment("账单周期开始时间。"); .HasComment("账单周期开始时间。");
b.Property<DateTime?>("ReminderSentAt")
.HasColumnType("timestamp with time zone")
.HasComment("提醒发送时间(续费提醒、逾期提醒等)。");
b.Property<string>("StatementNo") b.Property<string>("StatementNo")
.IsRequired() .IsRequired()
.HasMaxLength(64) .HasMaxLength(64)
@@ -6270,6 +6300,15 @@ namespace TakeoutSaaS.Infrastructure.Migrations
.HasColumnType("integer") .HasColumnType("integer")
.HasComment("当前付款状态。"); .HasComment("当前付款状态。");
b.Property<long?>("SubscriptionId")
.HasColumnType("bigint")
.HasComment("关联的订阅 ID仅当 BillingType 为 Subscription 或 Renewal 时有值)。");
b.Property<decimal>("TaxAmount")
.HasPrecision(18, 2)
.HasColumnType("numeric(18,2)")
.HasComment("税费金额。");
b.Property<long>("TenantId") b.Property<long>("TenantId")
.HasColumnType("bigint") .HasColumnType("bigint")
.HasComment("所属租户 ID。"); .HasComment("所属租户 ID。");
@@ -6284,9 +6323,19 @@ namespace TakeoutSaaS.Infrastructure.Migrations
b.HasKey("Id"); b.HasKey("Id");
b.HasIndex("CreatedAt")
.HasDatabaseName("idx_billing_created_at");
b.HasIndex("Status", "DueDate")
.HasDatabaseName("idx_billing_status_duedate")
.HasFilter("\"Status\" IN (0, 2)");
b.HasIndex("TenantId", "StatementNo") b.HasIndex("TenantId", "StatementNo")
.IsUnique(); .IsUnique();
b.HasIndex("TenantId", "Status", "DueDate")
.HasDatabaseName("idx_billing_tenant_status_duedate");
b.ToTable("tenant_billing_statements", null, t => b.ToTable("tenant_billing_statements", null, t =>
{ {
t.HasComment("租户账单,用于呈现周期性收费。"); t.HasComment("租户账单,用于呈现周期性收费。");
@@ -6555,6 +6604,15 @@ namespace TakeoutSaaS.Infrastructure.Migrations
.HasColumnType("character varying(512)") .HasColumnType("character varying(512)")
.HasComment("支付凭证 URL。"); .HasComment("支付凭证 URL。");
b.Property<string>("RefundReason")
.HasMaxLength(512)
.HasColumnType("character varying(512)")
.HasComment("退款原因。");
b.Property<DateTime?>("RefundedAt")
.HasColumnType("timestamp with time zone")
.HasComment("退款时间。");
b.Property<int>("Status") b.Property<int>("Status")
.HasColumnType("integer") .HasColumnType("integer")
.HasComment("支付状态。"); .HasComment("支付状态。");
@@ -6576,8 +6634,23 @@ namespace TakeoutSaaS.Infrastructure.Migrations
.HasColumnType("bigint") .HasColumnType("bigint")
.HasComment("最后更新人用户标识,匿名或系统操作时为 null。"); .HasComment("最后更新人用户标识,匿名或系统操作时为 null。");
b.Property<DateTime?>("VerifiedAt")
.HasColumnType("timestamp with time zone")
.HasComment("审核时间。");
b.Property<long?>("VerifiedBy")
.HasColumnType("bigint")
.HasComment("审核人 ID管理员。");
b.HasKey("Id"); b.HasKey("Id");
b.HasIndex("TransactionNo")
.HasDatabaseName("idx_payment_transaction_no")
.HasFilter("\"TransactionNo\" IS NOT NULL");
b.HasIndex("BillingStatementId", "PaidAt")
.HasDatabaseName("idx_payment_billing_paidat");
b.HasIndex("TenantId", "BillingStatementId"); b.HasIndex("TenantId", "BillingStatementId");
b.ToTable("tenant_payments", null, t => b.ToTable("tenant_payments", null, t =>