✨ feat: 完成账单管理模块后端功能开发及API优化
核心功能:
- 账单CRUD操作(创建、查询、详情、更新状态、删除)
- 支付记录管理(创建支付、审核支付)
- 批量操作支持(批量更新账单状态)
- 统计分析功能(账单统计、逾期账单查询)
- 导出功能(Excel/PDF/CSV)
API端点 (16个):
- GET /api/admin/v1/billings - 账单列表(分页、筛选、排序)
- POST /api/admin/v1/billings - 创建账单
- GET /api/admin/v1/billings/{id} - 账单详情
- DELETE /api/admin/v1/billings/{id} - 删除账单
- PUT /api/admin/v1/billings/{id}/status - 更新状态
- POST /api/admin/v1/billings/batch/status - 批量更新
- GET /api/admin/v1/billings/{id}/payments - 支付记录
- POST /api/admin/v1/billings/{id}/payments - 创建支付
- PUT /api/admin/v1/billings/payments/{paymentId}/verify - 审核支付
- GET /api/admin/v1/billings/statistics - 统计数据
- GET /api/admin/v1/billings/overdue - 逾期账单
- POST /api/admin/v1/billings/export - 导出账单
架构优化:
- 采用CQRS模式分离读写(MediatR + Dapper + EF Core)
- 完整的领域模型设计(TenantBillingStatement, TenantPayment等)
- FluentValidation请求验证
- 状态机管理账单和支付状态流转
API设计优化 (三项改进):
1. 导出API响应Content-Type改为application/octet-stream
2. 支付审核API添加Approved和Notes可选参数,支持通过/拒绝
3. 移除TenantBillings API中重复的TenantId参数
数据库变更:
- 新增账单相关表及关系
- 支持Snowflake ID主键
- 完整的审计字段支持
🤖 Generated with Claude Code
Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,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;
|
||||
}
|
||||
@@ -1,6 +1,7 @@
|
||||
using MediatR;
|
||||
using Microsoft.AspNetCore.Authorization;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using Microsoft.Net.Http.Headers;
|
||||
using System.ComponentModel.DataAnnotations;
|
||||
using TakeoutSaaS.Application.App.Billings.Commands;
|
||||
using TakeoutSaaS.Application.App.Billings.Dto;
|
||||
@@ -16,7 +17,7 @@ namespace TakeoutSaaS.AdminApi.Controllers;
|
||||
/// </summary>
|
||||
[ApiVersion("1.0")]
|
||||
[Authorize]
|
||||
[Route("api/admin/v{version:apiVersion}/bills")]
|
||||
[Route("api/admin/v{version:apiVersion}/billings")]
|
||||
public sealed class BillingsController(IMediator mediator) : BaseApiController
|
||||
{
|
||||
/// <summary>
|
||||
@@ -25,11 +26,14 @@ public sealed class BillingsController(IMediator mediator) : BaseApiController
|
||||
/// <returns>账单分页结果。</returns>
|
||||
[HttpGet]
|
||||
[PermissionAuthorize("bill:read")]
|
||||
[ProducesResponseType(typeof(ApiResponse<PagedResult<BillDto>>), StatusCodes.Status200OK)]
|
||||
public async Task<ApiResponse<PagedResult<BillDto>>> GetList([FromQuery] GetBillListQuery query, CancellationToken cancellationToken)
|
||||
[ProducesResponseType(typeof(ApiResponse<PagedResult<BillingListDto>>), StatusCodes.Status200OK)]
|
||||
public async Task<ApiResponse<PagedResult<BillingListDto>>> GetList([FromQuery] GetBillingListQuery query, CancellationToken cancellationToken)
|
||||
{
|
||||
// 1. 查询账单列表
|
||||
var result = await mediator.Send(query, cancellationToken);
|
||||
return ApiResponse<PagedResult<BillDto>>.Ok(result);
|
||||
|
||||
// 2. 返回分页结果
|
||||
return ApiResponse<PagedResult<BillingListDto>>.Ok(result);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
@@ -40,15 +44,15 @@ public sealed class BillingsController(IMediator mediator) : BaseApiController
|
||||
/// <returns>账单详情。</returns>
|
||||
[HttpGet("{id:long}")]
|
||||
[PermissionAuthorize("bill:read")]
|
||||
[ProducesResponseType(typeof(ApiResponse<BillDetailDto>), StatusCodes.Status200OK)]
|
||||
[ProducesResponseType(typeof(ApiResponse<BillDetailDto>), StatusCodes.Status404NotFound)]
|
||||
public async Task<ApiResponse<BillDetailDto>> GetDetail(long id, CancellationToken cancellationToken)
|
||||
[ProducesResponseType(typeof(ApiResponse<BillingDetailDto>), StatusCodes.Status200OK)]
|
||||
[ProducesResponseType(typeof(ApiResponse<object>), StatusCodes.Status404NotFound)]
|
||||
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
|
||||
? ApiResponse<BillDetailDto>.Error(StatusCodes.Status404NotFound, "账单不存在")
|
||||
: ApiResponse<BillDetailDto>.Ok(result);
|
||||
// 2. 返回详情
|
||||
return ApiResponse<BillingDetailDto>.Ok(result);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
@@ -59,11 +63,14 @@ public sealed class BillingsController(IMediator mediator) : BaseApiController
|
||||
/// <returns>创建的账单信息。</returns>
|
||||
[HttpPost]
|
||||
[PermissionAuthorize("bill:create")]
|
||||
[ProducesResponseType(typeof(ApiResponse<BillDto>), StatusCodes.Status200OK)]
|
||||
public async Task<ApiResponse<BillDto>> Create([FromBody, Required] CreateBillCommand command, CancellationToken cancellationToken)
|
||||
[ProducesResponseType(typeof(ApiResponse<BillingDetailDto>), StatusCodes.Status200OK)]
|
||||
public async Task<ApiResponse<BillingDetailDto>> Create([FromBody, Required] CreateBillingCommand command, CancellationToken cancellationToken)
|
||||
{
|
||||
// 1. 创建账单
|
||||
var result = await mediator.Send(command, cancellationToken);
|
||||
return ApiResponse<BillDto>.Ok(result);
|
||||
|
||||
// 2. 返回创建结果
|
||||
return ApiResponse<BillingDetailDto>.Ok(result);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
@@ -72,50 +79,202 @@ public sealed class BillingsController(IMediator mediator) : BaseApiController
|
||||
/// <param name="id">账单 ID。</param>
|
||||
/// <param name="command">更新状态命令。</param>
|
||||
/// <param name="cancellationToken">取消标记。</param>
|
||||
/// <returns>更新后的账单信息。</returns>
|
||||
/// <returns>更新结果。</returns>
|
||||
[HttpPut("{id:long}/status")]
|
||||
[PermissionAuthorize("bill:update")]
|
||||
[ProducesResponseType(typeof(ApiResponse<BillDto>), StatusCodes.Status200OK)]
|
||||
[ProducesResponseType(typeof(ApiResponse<BillDto>), StatusCodes.Status404NotFound)]
|
||||
public async Task<ApiResponse<BillDto>> UpdateStatus(long id, [FromBody, Required] UpdateBillStatusCommand command, CancellationToken cancellationToken)
|
||||
[ProducesResponseType(typeof(ApiResponse<object>), StatusCodes.Status200OK)]
|
||||
[ProducesResponseType(typeof(ApiResponse<object>), StatusCodes.Status404NotFound)]
|
||||
public async Task<ApiResponse<object>> UpdateStatus(long id, [FromBody, Required] UpdateBillingStatusCommand command, CancellationToken cancellationToken)
|
||||
{
|
||||
command = command with { BillId = id };
|
||||
var result = await mediator.Send(command, cancellationToken);
|
||||
// 1. 绑定账单标识
|
||||
command = command with { BillingId = id };
|
||||
|
||||
return result is null
|
||||
? ApiResponse<BillDto>.Error(StatusCodes.Status404NotFound, "账单不存在")
|
||||
: ApiResponse<BillDto>.Ok(result);
|
||||
// 2. 更新账单状态(若不存在则抛出业务异常,由全局异常处理转换为 404)
|
||||
await mediator.Send(command, cancellationToken);
|
||||
|
||||
// 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>
|
||||
/// <param name="billId">账单 ID。</param>
|
||||
/// <param name="id">账单 ID。</param>
|
||||
/// <param name="cancellationToken">取消标记。</param>
|
||||
/// <returns>支付记录列表。</returns>
|
||||
[HttpGet("{billId:long}/payments")]
|
||||
[HttpGet("{id:long}/payments")]
|
||||
[PermissionAuthorize("bill:read")]
|
||||
[ProducesResponseType(typeof(ApiResponse<List<PaymentDto>>), StatusCodes.Status200OK)]
|
||||
public async Task<ApiResponse<List<PaymentDto>>> GetPayments(long billId, CancellationToken cancellationToken)
|
||||
[ProducesResponseType(typeof(ApiResponse<List<PaymentRecordDto>>), StatusCodes.Status200OK)]
|
||||
public async Task<ApiResponse<List<PaymentRecordDto>>> GetPayments(long id, CancellationToken cancellationToken)
|
||||
{
|
||||
var result = await mediator.Send(new GetTenantPaymentsQuery { BillId = billId }, cancellationToken);
|
||||
return ApiResponse<List<PaymentDto>>.Ok(result);
|
||||
// 1. 查询支付记录
|
||||
var result = await mediator.Send(new GetBillingPaymentsQuery { BillingId = id }, cancellationToken);
|
||||
|
||||
// 2. 返回列表
|
||||
return ApiResponse<List<PaymentRecordDto>>.Ok(result);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 记录支付(线下支付确认)。
|
||||
/// </summary>
|
||||
/// <param name="billId">账单 ID。</param>
|
||||
/// <param name="id">账单 ID。</param>
|
||||
/// <param name="command">记录支付命令。</param>
|
||||
/// <param name="cancellationToken">取消标记。</param>
|
||||
/// <returns>支付记录信息。</returns>
|
||||
[HttpPost("{billId:long}/payments")]
|
||||
[HttpPost("{id:long}/payments")]
|
||||
[PermissionAuthorize("bill:pay")]
|
||||
[ProducesResponseType(typeof(ApiResponse<PaymentDto>), StatusCodes.Status200OK)]
|
||||
public async Task<ApiResponse<PaymentDto>> RecordPayment(long billId, [FromBody, Required] RecordPaymentCommand command, CancellationToken cancellationToken)
|
||||
[ProducesResponseType(typeof(ApiResponse<PaymentRecordDto>), StatusCodes.Status200OK)]
|
||||
[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);
|
||||
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"
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,6 +2,7 @@ using MediatR;
|
||||
using Microsoft.AspNetCore.Authorization;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using System.ComponentModel.DataAnnotations;
|
||||
using TakeoutSaaS.AdminApi.Contracts.Requests;
|
||||
using TakeoutSaaS.Application.App.Tenants.Commands;
|
||||
using TakeoutSaaS.Application.App.Tenants.Dto;
|
||||
using TakeoutSaaS.Application.App.Tenants.Queries;
|
||||
@@ -26,10 +27,18 @@ public sealed class TenantBillingsController(IMediator mediator) : BaseApiContro
|
||||
[HttpGet]
|
||||
[PermissionAuthorize("tenant-bill:read")]
|
||||
[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. 绑定租户标识
|
||||
query = query with { TenantId = tenantId };
|
||||
// 1. 组装查询对象(TenantId 仅来自路由,避免与 QueryString 重复)
|
||||
var query = new SearchTenantBillsQuery
|
||||
{
|
||||
TenantId = tenantId,
|
||||
Status = request.Status,
|
||||
From = request.From,
|
||||
To = request.To,
|
||||
Page = request.Page,
|
||||
PageSize = request.PageSize,
|
||||
};
|
||||
|
||||
// 2. 查询账单列表
|
||||
var result = await mediator.Send(query, cancellationToken);
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
using System.Text.Json;
|
||||
using TakeoutSaaS.Application.App.Billings.Dto;
|
||||
using TakeoutSaaS.Domain.Tenants.Entities;
|
||||
|
||||
@@ -9,7 +10,7 @@ namespace TakeoutSaaS.Application.App.Billings;
|
||||
internal static class BillingMapping
|
||||
{
|
||||
/// <summary>
|
||||
/// 将账单实体映射为账单 DTO。
|
||||
/// 将账单实体映射为账单 DTO(旧版)。
|
||||
/// </summary>
|
||||
/// <param name="bill">账单实体。</param>
|
||||
/// <param name="tenantName">租户名称。</param>
|
||||
@@ -31,7 +32,43 @@ internal static class BillingMapping
|
||||
};
|
||||
|
||||
/// <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>
|
||||
/// <param name="bill">账单实体。</param>
|
||||
/// <param name="payments">支付记录列表。</param>
|
||||
@@ -59,7 +96,64 @@ internal static class BillingMapping
|
||||
};
|
||||
|
||||
/// <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>
|
||||
/// <param name="payment">支付记录实体。</param>
|
||||
/// <returns>支付 DTO。</returns>
|
||||
@@ -77,4 +171,30 @@ internal static class BillingMapping
|
||||
Notes = payment.Notes,
|
||||
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
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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; }
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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; }
|
||||
}
|
||||
@@ -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; }
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
using MediatR;
|
||||
|
||||
namespace TakeoutSaaS.Application.App.Billings.Commands;
|
||||
|
||||
/// <summary>
|
||||
/// 处理逾期账单命令(后台任务场景)。
|
||||
/// </summary>
|
||||
public sealed record ProcessOverdueBillingsCommand : IRequest<int>
|
||||
{
|
||||
}
|
||||
@@ -7,12 +7,12 @@ namespace TakeoutSaaS.Application.App.Billings.Commands;
|
||||
/// <summary>
|
||||
/// 记录支付命令。
|
||||
/// </summary>
|
||||
public sealed record RecordPaymentCommand : IRequest<PaymentDto>
|
||||
public sealed record RecordPaymentCommand : IRequest<PaymentRecordDto>
|
||||
{
|
||||
/// <summary>
|
||||
/// 账单 ID(雪花算法)。
|
||||
/// </summary>
|
||||
public long BillId { get; init; }
|
||||
public long BillingId { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// 支付金额。
|
||||
@@ -22,7 +22,7 @@ public sealed record RecordPaymentCommand : IRequest<PaymentDto>
|
||||
/// <summary>
|
||||
/// 支付方式。
|
||||
/// </summary>
|
||||
public PaymentMethod Method { get; init; }
|
||||
public TenantPaymentMethod Method { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// 交易号。
|
||||
|
||||
@@ -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; }
|
||||
}
|
||||
@@ -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; }
|
||||
}
|
||||
@@ -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; }
|
||||
}
|
||||
@@ -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; } = [];
|
||||
}
|
||||
@@ -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; } = [];
|
||||
}
|
||||
@@ -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; }
|
||||
}
|
||||
@@ -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; }
|
||||
}
|
||||
@@ -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>();
|
||||
}
|
||||
@@ -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; }
|
||||
}
|
||||
@@ -29,12 +29,12 @@ public sealed record PaymentDto
|
||||
/// <summary>
|
||||
/// 支付方式。
|
||||
/// </summary>
|
||||
public PaymentMethod Method { get; init; }
|
||||
public TenantPaymentMethod Method { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// 支付状态。
|
||||
/// </summary>
|
||||
public PaymentStatus Status { get; init; }
|
||||
public TenantPaymentStatus Status { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// 交易号。
|
||||
|
||||
@@ -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; }
|
||||
}
|
||||
@@ -0,0 +1,88 @@
|
||||
using MediatR;
|
||||
using TakeoutSaaS.Application.App.Billings.Commands;
|
||||
using TakeoutSaaS.Domain.Tenants.Repositories;
|
||||
using TakeoutSaaS.Shared.Abstractions.Constants;
|
||||
using TakeoutSaaS.Shared.Abstractions.Exceptions;
|
||||
|
||||
namespace TakeoutSaaS.Application.App.Billings.Handlers;
|
||||
|
||||
/// <summary>
|
||||
/// 批量更新账单状态处理器。
|
||||
/// </summary>
|
||||
public sealed class BatchUpdateStatusCommandHandler(
|
||||
ITenantBillingRepository billingRepository)
|
||||
: IRequestHandler<BatchUpdateStatusCommand, int>
|
||||
{
|
||||
/// <summary>
|
||||
/// 处理批量更新账单状态请求。
|
||||
/// </summary>
|
||||
/// <param name="request">批量更新状态命令。</param>
|
||||
/// <param name="cancellationToken">取消标记。</param>
|
||||
/// <returns>成功更新的账单数量。</returns>
|
||||
public async Task<int> Handle(BatchUpdateStatusCommand request, CancellationToken cancellationToken)
|
||||
{
|
||||
// 1. 参数验证
|
||||
if (request.BillingIds.Length == 0)
|
||||
{
|
||||
throw new BusinessException(ErrorCodes.BadRequest, "账单 ID 列表不能为空");
|
||||
}
|
||||
|
||||
// 2. 查询所有账单
|
||||
var billings = await billingRepository.GetByIdsAsync(request.BillingIds, cancellationToken);
|
||||
if (billings.Count == 0)
|
||||
{
|
||||
throw new BusinessException(ErrorCodes.NotFound, "未找到任何匹配的账单");
|
||||
}
|
||||
|
||||
// 3. 批量更新状态
|
||||
var now = DateTime.UtcNow;
|
||||
var updatedCount = 0;
|
||||
foreach (var billing in billings)
|
||||
{
|
||||
// 业务规则检查:某些状态转换可能不允许
|
||||
if (CanTransitionStatus(billing.Status, request.NewStatus))
|
||||
{
|
||||
billing.Status = request.NewStatus;
|
||||
billing.UpdatedAt = now;
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(request.Notes))
|
||||
{
|
||||
billing.Notes = string.IsNullOrWhiteSpace(billing.Notes)
|
||||
? $"[批量操作] {request.Notes}"
|
||||
: $"{billing.Notes}\n[批量操作] {request.Notes}";
|
||||
}
|
||||
|
||||
await billingRepository.UpdateAsync(billing, cancellationToken);
|
||||
updatedCount++;
|
||||
}
|
||||
}
|
||||
|
||||
// 4. 持久化变更
|
||||
await billingRepository.SaveChangesAsync(cancellationToken);
|
||||
|
||||
return updatedCount;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 检查状态转换是否允许。
|
||||
/// </summary>
|
||||
private static bool CanTransitionStatus(
|
||||
TakeoutSaaS.Domain.Tenants.Enums.TenantBillingStatus currentStatus,
|
||||
TakeoutSaaS.Domain.Tenants.Enums.TenantBillingStatus newStatus)
|
||||
{
|
||||
// 已支付的账单不能改为其他状态
|
||||
if (currentStatus == TakeoutSaaS.Domain.Tenants.Enums.TenantBillingStatus.Paid
|
||||
&& newStatus != TakeoutSaaS.Domain.Tenants.Enums.TenantBillingStatus.Paid)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
// 已取消的账单不能改为其他状态
|
||||
if (currentStatus == TakeoutSaaS.Domain.Tenants.Enums.TenantBillingStatus.Cancelled)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
using MediatR;
|
||||
using TakeoutSaaS.Application.App.Billings.Commands;
|
||||
using TakeoutSaaS.Domain.Tenants.Repositories;
|
||||
using TakeoutSaaS.Shared.Abstractions.Constants;
|
||||
using TakeoutSaaS.Shared.Abstractions.Exceptions;
|
||||
|
||||
namespace TakeoutSaaS.Application.App.Billings.Handlers;
|
||||
|
||||
/// <summary>
|
||||
/// 取消账单命令处理器。
|
||||
/// </summary>
|
||||
public sealed class CancelBillingCommandHandler(
|
||||
ITenantBillingRepository billingRepository)
|
||||
: IRequestHandler<CancelBillingCommand, Unit>
|
||||
{
|
||||
/// <inheritdoc />
|
||||
public async Task<Unit> Handle(CancelBillingCommand request, CancellationToken cancellationToken)
|
||||
{
|
||||
// 1. 查询账单
|
||||
var billing = await billingRepository.FindByIdAsync(request.BillingId, cancellationToken);
|
||||
if (billing is null)
|
||||
{
|
||||
throw new BusinessException(ErrorCodes.NotFound, "账单不存在");
|
||||
}
|
||||
|
||||
// 2. (空行后) 取消账单(领域规则校验在实体方法内)
|
||||
billing.Cancel(request.Reason);
|
||||
|
||||
// 3. (空行后) 持久化
|
||||
await billingRepository.UpdateAsync(billing, cancellationToken);
|
||||
await billingRepository.SaveChangesAsync(cancellationToken);
|
||||
|
||||
return Unit.Value;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,65 @@
|
||||
using MediatR;
|
||||
using System.Text.Json;
|
||||
using TakeoutSaaS.Application.App.Billings.Commands;
|
||||
using TakeoutSaaS.Application.App.Billings.Dto;
|
||||
using TakeoutSaaS.Domain.Tenants.Entities;
|
||||
using TakeoutSaaS.Domain.Tenants.Enums;
|
||||
using TakeoutSaaS.Domain.Tenants.Repositories;
|
||||
using TakeoutSaaS.Shared.Abstractions.Constants;
|
||||
using TakeoutSaaS.Shared.Abstractions.Exceptions;
|
||||
using TakeoutSaaS.Shared.Abstractions.Ids;
|
||||
|
||||
namespace TakeoutSaaS.Application.App.Billings.Handlers;
|
||||
|
||||
/// <summary>
|
||||
/// 创建账单命令处理器。
|
||||
/// </summary>
|
||||
public sealed class CreateBillingCommandHandler(
|
||||
ITenantRepository tenantRepository,
|
||||
ITenantBillingRepository billingRepository,
|
||||
IIdGenerator idGenerator)
|
||||
: IRequestHandler<CreateBillingCommand, BillingDetailDto>
|
||||
{
|
||||
/// <inheritdoc />
|
||||
public async Task<BillingDetailDto> Handle(CreateBillingCommand request, CancellationToken cancellationToken)
|
||||
{
|
||||
// 1. 校验租户存在
|
||||
var tenant = await tenantRepository.FindByIdAsync(request.TenantId, cancellationToken);
|
||||
if (tenant is null)
|
||||
{
|
||||
throw new BusinessException(ErrorCodes.NotFound, "租户不存在");
|
||||
}
|
||||
|
||||
// 2. (空行后) 构建账单实体
|
||||
var now = DateTime.UtcNow;
|
||||
var statementNo = $"BIL-{now:yyyyMMdd}-{idGenerator.NextId()}";
|
||||
var lineItemsJson = JsonSerializer.Serialize(request.LineItems);
|
||||
|
||||
var billing = new TenantBillingStatement
|
||||
{
|
||||
TenantId = request.TenantId,
|
||||
StatementNo = statementNo,
|
||||
BillingType = request.BillingType,
|
||||
SubscriptionId = null,
|
||||
PeriodStart = now,
|
||||
PeriodEnd = now,
|
||||
AmountDue = request.AmountDue,
|
||||
DiscountAmount = 0m,
|
||||
TaxAmount = 0m,
|
||||
AmountPaid = 0m,
|
||||
Currency = "CNY",
|
||||
Status = TenantBillingStatus.Pending,
|
||||
DueDate = request.DueDate,
|
||||
LineItemsJson = lineItemsJson,
|
||||
Notes = request.Notes
|
||||
};
|
||||
|
||||
// 3. (空行后) 持久化账单
|
||||
await billingRepository.AddAsync(billing, cancellationToken);
|
||||
await billingRepository.SaveChangesAsync(cancellationToken);
|
||||
|
||||
// 4. (空行后) 返回详情 DTO
|
||||
return billing.ToBillingDetailDto([], tenant.Name);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,44 @@
|
||||
using MediatR;
|
||||
using TakeoutSaaS.Application.App.Billings.Queries;
|
||||
using TakeoutSaaS.Domain.Tenants.Repositories;
|
||||
using TakeoutSaaS.Domain.Tenants.Services;
|
||||
using TakeoutSaaS.Shared.Abstractions.Constants;
|
||||
using TakeoutSaaS.Shared.Abstractions.Exceptions;
|
||||
|
||||
namespace TakeoutSaaS.Application.App.Billings.Handlers;
|
||||
|
||||
/// <summary>
|
||||
/// 导出账单处理器。
|
||||
/// </summary>
|
||||
public sealed class ExportBillingsQueryHandler(
|
||||
ITenantBillingRepository billingRepository,
|
||||
IBillingExportService exportService)
|
||||
: IRequestHandler<ExportBillingsQuery, byte[]>
|
||||
{
|
||||
/// <inheritdoc />
|
||||
public async Task<byte[]> Handle(ExportBillingsQuery request, CancellationToken cancellationToken)
|
||||
{
|
||||
// 1. 参数验证
|
||||
if (request.BillingIds.Length == 0)
|
||||
{
|
||||
throw new BusinessException(ErrorCodes.BadRequest, "账单 ID 列表不能为空");
|
||||
}
|
||||
|
||||
// 2. (空行后) 查询账单数据
|
||||
var billings = await billingRepository.GetByIdsAsync(request.BillingIds, cancellationToken);
|
||||
if (billings.Count == 0)
|
||||
{
|
||||
throw new BusinessException(ErrorCodes.NotFound, "未找到任何匹配的账单");
|
||||
}
|
||||
|
||||
// 3. (空行后) 根据格式导出
|
||||
var format = (request.Format ?? string.Empty).Trim().ToLowerInvariant();
|
||||
return format switch
|
||||
{
|
||||
"excel" or "xlsx" => await exportService.ExportToExcelAsync(billings, cancellationToken),
|
||||
"pdf" => await exportService.ExportToPdfAsync(billings, cancellationToken),
|
||||
"csv" => await exportService.ExportToCsvAsync(billings, cancellationToken),
|
||||
_ => throw new BusinessException(ErrorCodes.BadRequest, $"不支持的导出格式: {request.Format}")
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,102 @@
|
||||
using MediatR;
|
||||
using System.Text.Json;
|
||||
using TakeoutSaaS.Application.App.Billings.Commands;
|
||||
using TakeoutSaaS.Application.App.Billings.Dto;
|
||||
using TakeoutSaaS.Domain.Tenants.Entities;
|
||||
using TakeoutSaaS.Domain.Tenants.Enums;
|
||||
using TakeoutSaaS.Domain.Tenants.Repositories;
|
||||
using TakeoutSaaS.Shared.Abstractions.Constants;
|
||||
using TakeoutSaaS.Shared.Abstractions.Exceptions;
|
||||
using TakeoutSaaS.Shared.Abstractions.Ids;
|
||||
|
||||
namespace TakeoutSaaS.Application.App.Billings.Handlers;
|
||||
|
||||
/// <summary>
|
||||
/// 生成订阅账单命令处理器。
|
||||
/// </summary>
|
||||
public sealed class GenerateSubscriptionBillingCommandHandler(
|
||||
ISubscriptionRepository subscriptionRepository,
|
||||
ITenantBillingRepository billingRepository,
|
||||
IIdGenerator idGenerator)
|
||||
: IRequestHandler<GenerateSubscriptionBillingCommand, BillingDetailDto>
|
||||
{
|
||||
/// <inheritdoc />
|
||||
public async Task<BillingDetailDto> Handle(GenerateSubscriptionBillingCommand request, CancellationToken cancellationToken)
|
||||
{
|
||||
// 1. 查询订阅详情(含租户/套餐信息)
|
||||
var detail = await subscriptionRepository.GetDetailAsync(request.SubscriptionId, cancellationToken);
|
||||
if (detail is null)
|
||||
{
|
||||
throw new BusinessException(ErrorCodes.NotFound, "订阅不存在");
|
||||
}
|
||||
|
||||
// 2. (空行后) 校验套餐价格信息
|
||||
var subscription = detail.Subscription;
|
||||
var package = detail.Package;
|
||||
if (package is null)
|
||||
{
|
||||
throw new BusinessException(ErrorCodes.BusinessError, "订阅未关联有效套餐,无法生成账单");
|
||||
}
|
||||
|
||||
// 3. (空行后) 按订阅周期选择价格(简化规则:优先按年/按月)
|
||||
var billingPeriodDays = (subscription.EffectiveTo - subscription.EffectiveFrom).TotalDays;
|
||||
var amountDue = billingPeriodDays >= 300
|
||||
? package.YearlyPrice
|
||||
: package.MonthlyPrice;
|
||||
|
||||
if (!amountDue.HasValue)
|
||||
{
|
||||
throw new BusinessException(ErrorCodes.BusinessError, "套餐价格未配置,无法生成账单");
|
||||
}
|
||||
|
||||
// 4. (空行后) 幂等校验:同一周期开始时间仅允许存在一张未取消账单
|
||||
var exists = await billingRepository.ExistsNotCancelledByPeriodStartAsync(subscription.TenantId, subscription.EffectiveFrom, cancellationToken);
|
||||
if (exists)
|
||||
{
|
||||
throw new BusinessException(ErrorCodes.Conflict, "该订阅周期的账单已存在");
|
||||
}
|
||||
|
||||
// 5. (空行后) 构建账单实体
|
||||
var now = DateTime.UtcNow;
|
||||
var statementNo = $"BIL-{now:yyyyMMdd}-{idGenerator.NextId()}";
|
||||
var lineItems = new List<BillingLineItemDto>
|
||||
{
|
||||
new()
|
||||
{
|
||||
ItemType = "Subscription",
|
||||
Description = $"套餐 {package.Name} 订阅费用",
|
||||
Quantity = 1,
|
||||
UnitPrice = amountDue.Value,
|
||||
Amount = amountDue.Value,
|
||||
DiscountRate = null
|
||||
}
|
||||
};
|
||||
|
||||
var billing = new TenantBillingStatement
|
||||
{
|
||||
TenantId = subscription.TenantId,
|
||||
StatementNo = statementNo,
|
||||
BillingType = BillingType.Subscription,
|
||||
SubscriptionId = subscription.Id,
|
||||
PeriodStart = subscription.EffectiveFrom,
|
||||
PeriodEnd = subscription.EffectiveTo,
|
||||
AmountDue = amountDue.Value,
|
||||
DiscountAmount = 0m,
|
||||
TaxAmount = 0m,
|
||||
AmountPaid = 0m,
|
||||
Currency = "CNY",
|
||||
Status = TenantBillingStatus.Pending,
|
||||
DueDate = now.AddDays(7),
|
||||
LineItemsJson = JsonSerializer.Serialize(lineItems),
|
||||
Notes = subscription.Notes
|
||||
};
|
||||
|
||||
// 6. (空行后) 持久化账单
|
||||
await billingRepository.AddAsync(billing, cancellationToken);
|
||||
await billingRepository.SaveChangesAsync(cancellationToken);
|
||||
|
||||
// 7. (空行后) 返回详情 DTO
|
||||
return billing.ToBillingDetailDto([], detail.TenantName);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -28,6 +28,8 @@ public sealed class GetBillListQueryHandler(
|
||||
request.Status,
|
||||
request.StartDate,
|
||||
request.EndDate,
|
||||
null,
|
||||
null,
|
||||
request.Keyword,
|
||||
request.PageNumber,
|
||||
request.PageSize,
|
||||
|
||||
@@ -0,0 +1,218 @@
|
||||
using MediatR;
|
||||
using System.Data;
|
||||
using System.Data.Common;
|
||||
using System.Text.Json;
|
||||
using TakeoutSaaS.Application.App.Billings.Dto;
|
||||
using TakeoutSaaS.Application.App.Billings.Queries;
|
||||
using TakeoutSaaS.Domain.Tenants.Enums;
|
||||
using TakeoutSaaS.Shared.Abstractions.Constants;
|
||||
using TakeoutSaaS.Shared.Abstractions.Data;
|
||||
using TakeoutSaaS.Shared.Abstractions.Exceptions;
|
||||
|
||||
namespace TakeoutSaaS.Application.App.Billings.Handlers;
|
||||
|
||||
/// <summary>
|
||||
/// 查询账单详情处理器。
|
||||
/// </summary>
|
||||
public sealed class GetBillingDetailQueryHandler(
|
||||
IDapperExecutor dapperExecutor)
|
||||
: IRequestHandler<GetBillingDetailQuery, BillingDetailDto>
|
||||
{
|
||||
/// <summary>
|
||||
/// 处理查询账单详情请求。
|
||||
/// </summary>
|
||||
/// <param name="request">查询命令。</param>
|
||||
/// <param name="cancellationToken">取消标记。</param>
|
||||
/// <returns>账单详情 DTO。</returns>
|
||||
public async Task<BillingDetailDto> Handle(GetBillingDetailQuery request, CancellationToken cancellationToken)
|
||||
{
|
||||
// 1. 查询账单 + 支付记录(同一连接,避免多次往返)
|
||||
return await dapperExecutor.QueryAsync(
|
||||
DatabaseConstants.AppDataSource,
|
||||
DatabaseConnectionRole.Read,
|
||||
async (connection, token) =>
|
||||
{
|
||||
// 1.1 查询账单
|
||||
await using var billCommand = CreateCommand(
|
||||
connection,
|
||||
BuildBillingSql(),
|
||||
[
|
||||
("billingId", request.BillingId)
|
||||
]);
|
||||
|
||||
await using var billReader = await billCommand.ExecuteReaderAsync(token);
|
||||
if (!await billReader.ReadAsync(token))
|
||||
{
|
||||
throw new BusinessException(ErrorCodes.NotFound, "账单不存在");
|
||||
}
|
||||
|
||||
DateTime? reminderSentAt = billReader.IsDBNull(15) ? null : billReader.GetDateTime(15);
|
||||
DateTime? overdueNotifiedAt = billReader.IsDBNull(16) ? null : billReader.GetDateTime(16);
|
||||
var notes = billReader.IsDBNull(17) ? null : billReader.GetString(17);
|
||||
var lineItemsJson = billReader.IsDBNull(18) ? null : billReader.GetString(18);
|
||||
long? createdBy = billReader.IsDBNull(20) ? null : billReader.GetInt64(20);
|
||||
long? updatedBy = billReader.IsDBNull(22) ? null : billReader.GetInt64(22);
|
||||
|
||||
// 1.2 (空行后) 反序列化账单明细
|
||||
var lineItems = new List<BillingLineItemDto>();
|
||||
if (!string.IsNullOrWhiteSpace(lineItemsJson))
|
||||
{
|
||||
try
|
||||
{
|
||||
lineItems = JsonSerializer.Deserialize<List<BillingLineItemDto>>(lineItemsJson) ?? [];
|
||||
}
|
||||
catch
|
||||
{
|
||||
lineItems = [];
|
||||
}
|
||||
}
|
||||
|
||||
// 1.3 (空行后) 查询支付记录
|
||||
var payments = new List<PaymentRecordDto>();
|
||||
await using var paymentCommand = CreateCommand(
|
||||
connection,
|
||||
BuildPaymentsSql(),
|
||||
[
|
||||
("billingId", request.BillingId)
|
||||
]);
|
||||
|
||||
await using var paymentReader = await paymentCommand.ExecuteReaderAsync(token);
|
||||
while (await paymentReader.ReadAsync(token))
|
||||
{
|
||||
payments.Add(new PaymentRecordDto
|
||||
{
|
||||
Id = paymentReader.GetInt64(0),
|
||||
TenantId = paymentReader.GetInt64(1),
|
||||
BillingId = paymentReader.GetInt64(2),
|
||||
Amount = paymentReader.GetDecimal(3),
|
||||
Method = (TenantPaymentMethod)paymentReader.GetInt32(4),
|
||||
Status = (TenantPaymentStatus)paymentReader.GetInt32(5),
|
||||
TransactionNo = paymentReader.IsDBNull(6) ? null : paymentReader.GetString(6),
|
||||
ProofUrl = paymentReader.IsDBNull(7) ? null : paymentReader.GetString(7),
|
||||
Notes = paymentReader.IsDBNull(8) ? null : paymentReader.GetString(8),
|
||||
VerifiedBy = paymentReader.IsDBNull(9) ? null : paymentReader.GetInt64(9),
|
||||
VerifiedAt = paymentReader.IsDBNull(10) ? null : paymentReader.GetDateTime(10),
|
||||
RefundReason = paymentReader.IsDBNull(11) ? null : paymentReader.GetString(11),
|
||||
RefundedAt = paymentReader.IsDBNull(12) ? null : paymentReader.GetDateTime(12),
|
||||
PaidAt = paymentReader.IsDBNull(13) ? null : paymentReader.GetDateTime(13),
|
||||
IsVerified = !paymentReader.IsDBNull(10),
|
||||
CreatedAt = paymentReader.GetDateTime(14)
|
||||
});
|
||||
}
|
||||
|
||||
// 1.4 (空行后) 组装详情 DTO
|
||||
var amountDue = billReader.GetDecimal(9);
|
||||
var discountAmount = billReader.GetDecimal(10);
|
||||
var taxAmount = billReader.GetDecimal(11);
|
||||
var totalAmount = amountDue - discountAmount + taxAmount;
|
||||
|
||||
return new BillingDetailDto
|
||||
{
|
||||
Id = billReader.GetInt64(0),
|
||||
TenantId = billReader.GetInt64(1),
|
||||
TenantName = billReader.IsDBNull(2) ? string.Empty : billReader.GetString(2),
|
||||
SubscriptionId = billReader.IsDBNull(3) ? null : billReader.GetInt64(3),
|
||||
StatementNo = billReader.GetString(4),
|
||||
BillingType = (BillingType)billReader.GetInt32(5),
|
||||
Status = (TenantBillingStatus)billReader.GetInt32(6),
|
||||
PeriodStart = billReader.GetDateTime(7),
|
||||
PeriodEnd = billReader.GetDateTime(8),
|
||||
AmountDue = billReader.GetDecimal(9),
|
||||
DiscountAmount = billReader.GetDecimal(10),
|
||||
TaxAmount = billReader.GetDecimal(11),
|
||||
TotalAmount = totalAmount,
|
||||
AmountPaid = billReader.GetDecimal(12),
|
||||
Currency = billReader.IsDBNull(13) ? "CNY" : billReader.GetString(13),
|
||||
DueDate = billReader.GetDateTime(14),
|
||||
ReminderSentAt = reminderSentAt,
|
||||
OverdueNotifiedAt = overdueNotifiedAt,
|
||||
LineItemsJson = lineItemsJson,
|
||||
LineItems = lineItems,
|
||||
Payments = payments,
|
||||
Notes = notes,
|
||||
CreatedAt = billReader.GetDateTime(19),
|
||||
CreatedBy = createdBy,
|
||||
UpdatedAt = billReader.IsDBNull(21) ? null : billReader.GetDateTime(21),
|
||||
UpdatedBy = updatedBy
|
||||
};
|
||||
},
|
||||
cancellationToken);
|
||||
}
|
||||
|
||||
private static string BuildBillingSql()
|
||||
{
|
||||
return """
|
||||
select
|
||||
b."Id",
|
||||
b."TenantId",
|
||||
t."Name" as "TenantName",
|
||||
b."SubscriptionId",
|
||||
b."StatementNo",
|
||||
b."BillingType",
|
||||
b."Status",
|
||||
b."PeriodStart",
|
||||
b."PeriodEnd",
|
||||
b."AmountDue",
|
||||
b."DiscountAmount",
|
||||
b."TaxAmount",
|
||||
b."AmountPaid",
|
||||
b."Currency",
|
||||
b."DueDate",
|
||||
b."ReminderSentAt",
|
||||
b."OverdueNotifiedAt",
|
||||
b."Notes",
|
||||
b."LineItemsJson",
|
||||
b."CreatedAt",
|
||||
b."CreatedBy",
|
||||
b."UpdatedAt",
|
||||
b."UpdatedBy"
|
||||
from public.tenant_billing_statements b
|
||||
join public.tenants t on t."Id" = b."TenantId" and t."DeletedAt" is null
|
||||
where b."DeletedAt" is null
|
||||
and b."Id" = @billingId
|
||||
limit 1;
|
||||
""";
|
||||
}
|
||||
|
||||
private static string BuildPaymentsSql()
|
||||
{
|
||||
return """
|
||||
select
|
||||
p."Id",
|
||||
p."TenantId",
|
||||
p."BillingStatementId",
|
||||
p."Amount",
|
||||
p."Method",
|
||||
p."Status",
|
||||
p."TransactionNo",
|
||||
p."ProofUrl",
|
||||
p."Notes",
|
||||
p."VerifiedBy",
|
||||
p."VerifiedAt",
|
||||
p."RefundReason",
|
||||
p."RefundedAt",
|
||||
p."PaidAt",
|
||||
p."CreatedAt"
|
||||
from public.tenant_payments p
|
||||
where p."DeletedAt" is null
|
||||
and p."BillingStatementId" = @billingId
|
||||
order by p."CreatedAt" desc;
|
||||
""";
|
||||
}
|
||||
|
||||
private static DbCommand CreateCommand(IDbConnection connection, string sql, (string Name, object? Value)[] parameters)
|
||||
{
|
||||
var command = connection.CreateCommand();
|
||||
command.CommandText = sql;
|
||||
|
||||
foreach (var (name, value) in parameters)
|
||||
{
|
||||
var p = command.CreateParameter();
|
||||
p.ParameterName = name;
|
||||
p.Value = value ?? DBNull.Value;
|
||||
command.Parameters.Add(p);
|
||||
}
|
||||
|
||||
return (DbCommand)command;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,233 @@
|
||||
using MediatR;
|
||||
using System.Data;
|
||||
using System.Data.Common;
|
||||
using TakeoutSaaS.Application.App.Billings.Dto;
|
||||
using TakeoutSaaS.Application.App.Billings.Queries;
|
||||
using TakeoutSaaS.Domain.Tenants.Enums;
|
||||
using TakeoutSaaS.Shared.Abstractions.Constants;
|
||||
using TakeoutSaaS.Shared.Abstractions.Data;
|
||||
using TakeoutSaaS.Shared.Abstractions.Results;
|
||||
|
||||
namespace TakeoutSaaS.Application.App.Billings.Handlers;
|
||||
|
||||
/// <summary>
|
||||
/// 分页查询账单列表处理器。
|
||||
/// </summary>
|
||||
public sealed class GetBillingListQueryHandler(
|
||||
IDapperExecutor dapperExecutor)
|
||||
: IRequestHandler<GetBillingListQuery, PagedResult<BillingListDto>>
|
||||
{
|
||||
/// <summary>
|
||||
/// 处理分页查询账单列表请求。
|
||||
/// </summary>
|
||||
/// <param name="request">查询命令。</param>
|
||||
/// <param name="cancellationToken">取消标记。</param>
|
||||
/// <returns>分页账单列表 DTO。</returns>
|
||||
public async Task<PagedResult<BillingListDto>> Handle(GetBillingListQuery request, CancellationToken cancellationToken)
|
||||
{
|
||||
// 1. 参数规范化
|
||||
var page = request.PageNumber <= 0 ? 1 : request.PageNumber;
|
||||
var pageSize = request.PageSize is <= 0 or > 200 ? 20 : request.PageSize;
|
||||
var keyword = string.IsNullOrWhiteSpace(request.Keyword) ? null : request.Keyword.Trim();
|
||||
var minAmount = request.MinAmount;
|
||||
var maxAmount = request.MaxAmount;
|
||||
var offset = (page - 1) * pageSize;
|
||||
|
||||
// 1.1 (空行后) 金额区间规范化(避免 min > max 导致结果为空)
|
||||
if (minAmount.HasValue && maxAmount.HasValue && minAmount.Value > maxAmount.Value)
|
||||
{
|
||||
(minAmount, maxAmount) = (maxAmount, minAmount);
|
||||
}
|
||||
|
||||
// 2. (空行后) 排序白名单(防 SQL 注入)
|
||||
var orderBy = request.SortBy?.Trim() switch
|
||||
{
|
||||
"DueDate" => "b.\"DueDate\"",
|
||||
"AmountDue" => "b.\"AmountDue\"",
|
||||
"PeriodStart" => "b.\"PeriodStart\"",
|
||||
"PeriodEnd" => "b.\"PeriodEnd\"",
|
||||
"CreatedAt" => "b.\"CreatedAt\"",
|
||||
_ => "b.\"CreatedAt\""
|
||||
};
|
||||
|
||||
// 3. (空行后) 查询总数 + 列表
|
||||
return await dapperExecutor.QueryAsync(
|
||||
DatabaseConstants.AppDataSource,
|
||||
DatabaseConnectionRole.Read,
|
||||
async (connection, token) =>
|
||||
{
|
||||
// 3.1 统计总数
|
||||
var total = await ExecuteScalarIntAsync(
|
||||
connection,
|
||||
BuildCountSql(),
|
||||
[
|
||||
("tenantId", request.TenantId),
|
||||
("status", request.Status.HasValue ? (int)request.Status.Value : null),
|
||||
("billingType", request.BillingType.HasValue ? (int)request.BillingType.Value : null),
|
||||
("startDate", request.StartDate),
|
||||
("endDate", request.EndDate),
|
||||
("minAmount", minAmount),
|
||||
("maxAmount", maxAmount),
|
||||
("keyword", keyword)
|
||||
],
|
||||
token);
|
||||
|
||||
// 3.2 (空行后) 查询列表
|
||||
var listSql = BuildListSql(orderBy, request.SortDesc);
|
||||
await using var listCommand = CreateCommand(
|
||||
connection,
|
||||
listSql,
|
||||
[
|
||||
("tenantId", request.TenantId),
|
||||
("status", request.Status.HasValue ? (int)request.Status.Value : null),
|
||||
("billingType", request.BillingType.HasValue ? (int)request.BillingType.Value : null),
|
||||
("startDate", request.StartDate),
|
||||
("endDate", request.EndDate),
|
||||
("minAmount", minAmount),
|
||||
("maxAmount", maxAmount),
|
||||
("keyword", keyword),
|
||||
("offset", offset),
|
||||
("limit", pageSize)
|
||||
]);
|
||||
|
||||
await using var reader = await listCommand.ExecuteReaderAsync(token);
|
||||
var now = DateTime.UtcNow;
|
||||
var items = new List<BillingListDto>();
|
||||
while (await reader.ReadAsync(token))
|
||||
{
|
||||
var dueDate = reader.GetDateTime(13);
|
||||
var status = (TenantBillingStatus)reader.GetInt32(12);
|
||||
var amountDue = reader.GetDecimal(8);
|
||||
var discountAmount = reader.GetDecimal(9);
|
||||
var taxAmount = reader.GetDecimal(10);
|
||||
var totalAmount = amountDue - discountAmount + taxAmount;
|
||||
|
||||
// 3.2.1 (空行后) 逾期辅助字段
|
||||
var isOverdue = status is TenantBillingStatus.Overdue
|
||||
|| (status is TenantBillingStatus.Pending && dueDate < now);
|
||||
var overdueDays = dueDate < now ? (int)(now - dueDate).TotalDays : 0;
|
||||
|
||||
items.Add(new BillingListDto
|
||||
{
|
||||
Id = reader.GetInt64(0),
|
||||
TenantId = reader.GetInt64(1),
|
||||
TenantName = reader.IsDBNull(2) ? string.Empty : reader.GetString(2),
|
||||
SubscriptionId = reader.IsDBNull(3) ? null : reader.GetInt64(3),
|
||||
StatementNo = reader.GetString(4),
|
||||
BillingType = (BillingType)reader.GetInt32(5),
|
||||
PeriodStart = reader.GetDateTime(6),
|
||||
PeriodEnd = reader.GetDateTime(7),
|
||||
AmountDue = amountDue,
|
||||
DiscountAmount = discountAmount,
|
||||
TaxAmount = taxAmount,
|
||||
TotalAmount = totalAmount,
|
||||
AmountPaid = reader.GetDecimal(11),
|
||||
Currency = reader.IsDBNull(14) ? "CNY" : reader.GetString(14),
|
||||
Status = status,
|
||||
DueDate = dueDate,
|
||||
IsOverdue = isOverdue,
|
||||
OverdueDays = overdueDays,
|
||||
CreatedAt = reader.GetDateTime(15),
|
||||
UpdatedAt = reader.IsDBNull(16) ? null : reader.GetDateTime(16)
|
||||
});
|
||||
}
|
||||
|
||||
// 3.3 (空行后) 返回分页
|
||||
return new PagedResult<BillingListDto>(items, page, pageSize, total);
|
||||
},
|
||||
cancellationToken);
|
||||
}
|
||||
|
||||
private static string BuildCountSql()
|
||||
{
|
||||
return """
|
||||
select count(*)
|
||||
from public.tenant_billing_statements b
|
||||
join public.tenants t on t."Id" = b."TenantId" and t."DeletedAt" is null
|
||||
where b."DeletedAt" is null
|
||||
and (@tenantId::bigint is null or b."TenantId" = @tenantId)
|
||||
and (@status::int is null or b."Status" = @status)
|
||||
and (@billingType::int is null or b."BillingType" = @billingType)
|
||||
and (@startDate::timestamp with time zone is null or b."PeriodStart" >= @startDate)
|
||||
and (@endDate::timestamp with time zone is null or b."PeriodEnd" <= @endDate)
|
||||
and (@minAmount::numeric is null or b."AmountDue" >= @minAmount)
|
||||
and (@maxAmount::numeric is null or b."AmountDue" <= @maxAmount)
|
||||
and (
|
||||
@keyword::text is null
|
||||
or b."StatementNo" ilike ('%' || @keyword::text || '%')
|
||||
or t."Name" ilike ('%' || @keyword::text || '%')
|
||||
);
|
||||
""";
|
||||
}
|
||||
|
||||
private static string BuildListSql(string orderBy, bool sortDesc)
|
||||
{
|
||||
var direction = sortDesc ? "desc" : "asc";
|
||||
|
||||
return $"""
|
||||
select
|
||||
b."Id",
|
||||
b."TenantId",
|
||||
t."Name" as "TenantName",
|
||||
b."SubscriptionId",
|
||||
b."StatementNo",
|
||||
b."BillingType",
|
||||
b."PeriodStart",
|
||||
b."PeriodEnd",
|
||||
b."AmountDue",
|
||||
b."DiscountAmount",
|
||||
b."TaxAmount",
|
||||
b."AmountPaid",
|
||||
b."Status",
|
||||
b."DueDate",
|
||||
b."Currency",
|
||||
b."CreatedAt",
|
||||
b."UpdatedAt"
|
||||
from public.tenant_billing_statements b
|
||||
join public.tenants t on t."Id" = b."TenantId" and t."DeletedAt" is null
|
||||
where b."DeletedAt" is null
|
||||
and (@tenantId::bigint is null or b."TenantId" = @tenantId)
|
||||
and (@status::int is null or b."Status" = @status)
|
||||
and (@billingType::int is null or b."BillingType" = @billingType)
|
||||
and (@startDate::timestamp with time zone is null or b."PeriodStart" >= @startDate)
|
||||
and (@endDate::timestamp with time zone is null or b."PeriodEnd" <= @endDate)
|
||||
and (@minAmount::numeric is null or b."AmountDue" >= @minAmount)
|
||||
and (@maxAmount::numeric is null or b."AmountDue" <= @maxAmount)
|
||||
and (
|
||||
@keyword::text is null
|
||||
or b."StatementNo" ilike ('%' || @keyword::text || '%')
|
||||
or t."Name" ilike ('%' || @keyword::text || '%')
|
||||
)
|
||||
order by {orderBy} {direction}
|
||||
offset @offset
|
||||
limit @limit;
|
||||
""";
|
||||
}
|
||||
|
||||
private static async Task<int> ExecuteScalarIntAsync(
|
||||
IDbConnection connection,
|
||||
string sql,
|
||||
(string Name, object? Value)[] parameters,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
await using var command = CreateCommand(connection, sql, parameters);
|
||||
var result = await command.ExecuteScalarAsync(cancellationToken);
|
||||
return result is null or DBNull ? 0 : Convert.ToInt32(result);
|
||||
}
|
||||
|
||||
private static DbCommand CreateCommand(IDbConnection connection, string sql, (string Name, object? Value)[] parameters)
|
||||
{
|
||||
var command = connection.CreateCommand();
|
||||
command.CommandText = sql;
|
||||
|
||||
foreach (var (name, value) in parameters)
|
||||
{
|
||||
var p = command.CreateParameter();
|
||||
p.ParameterName = name;
|
||||
p.Value = value ?? DBNull.Value;
|
||||
command.Parameters.Add(p);
|
||||
}
|
||||
|
||||
return (DbCommand)command;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,139 @@
|
||||
using MediatR;
|
||||
using System.Data;
|
||||
using System.Data.Common;
|
||||
using TakeoutSaaS.Application.App.Billings.Dto;
|
||||
using TakeoutSaaS.Application.App.Billings.Queries;
|
||||
using TakeoutSaaS.Shared.Abstractions.Constants;
|
||||
using TakeoutSaaS.Shared.Abstractions.Data;
|
||||
using TakeoutSaaS.Shared.Abstractions.Exceptions;
|
||||
using TakeoutSaaS.Domain.Tenants.Enums;
|
||||
|
||||
namespace TakeoutSaaS.Application.App.Billings.Handlers;
|
||||
|
||||
/// <summary>
|
||||
/// 查询账单支付记录处理器。
|
||||
/// </summary>
|
||||
public sealed class GetBillingPaymentsQueryHandler(
|
||||
IDapperExecutor dapperExecutor)
|
||||
: IRequestHandler<GetBillingPaymentsQuery, List<PaymentRecordDto>>
|
||||
{
|
||||
/// <summary>
|
||||
/// 处理查询账单支付记录请求。
|
||||
/// </summary>
|
||||
/// <param name="request">查询命令。</param>
|
||||
/// <param name="cancellationToken">取消标记。</param>
|
||||
/// <returns>支付记录列表 DTO。</returns>
|
||||
public async Task<List<PaymentRecordDto>> Handle(GetBillingPaymentsQuery request, CancellationToken cancellationToken)
|
||||
{
|
||||
// 1. 校验账单是否存在
|
||||
return await dapperExecutor.QueryAsync(
|
||||
DatabaseConstants.AppDataSource,
|
||||
DatabaseConnectionRole.Read,
|
||||
async (connection, token) =>
|
||||
{
|
||||
// 1.1 校验账单存在
|
||||
var exists = await ExecuteScalarIntAsync(
|
||||
connection,
|
||||
"""
|
||||
select 1
|
||||
from public.tenant_billing_statements b
|
||||
where b."DeletedAt" is null
|
||||
and b."Id" = @billingId
|
||||
limit 1;
|
||||
""",
|
||||
[
|
||||
("billingId", request.BillingId)
|
||||
],
|
||||
token);
|
||||
|
||||
if (exists == 0)
|
||||
{
|
||||
throw new BusinessException(ErrorCodes.NotFound, "账单不存在");
|
||||
}
|
||||
|
||||
// 1.2 (空行后) 查询支付记录
|
||||
await using var command = CreateCommand(
|
||||
connection,
|
||||
"""
|
||||
select
|
||||
p."Id",
|
||||
p."TenantId",
|
||||
p."BillingStatementId",
|
||||
p."Amount",
|
||||
p."Method",
|
||||
p."Status",
|
||||
p."TransactionNo",
|
||||
p."ProofUrl",
|
||||
p."Notes",
|
||||
p."VerifiedBy",
|
||||
p."VerifiedAt",
|
||||
p."RefundReason",
|
||||
p."RefundedAt",
|
||||
p."PaidAt",
|
||||
p."CreatedAt"
|
||||
from public.tenant_payments p
|
||||
where p."DeletedAt" is null
|
||||
and p."BillingStatementId" = @billingId
|
||||
order by p."CreatedAt" desc;
|
||||
""",
|
||||
[
|
||||
("billingId", request.BillingId)
|
||||
]);
|
||||
|
||||
await using var reader = await command.ExecuteReaderAsync(token);
|
||||
var results = new List<PaymentRecordDto>();
|
||||
while (await reader.ReadAsync(token))
|
||||
{
|
||||
results.Add(new PaymentRecordDto
|
||||
{
|
||||
Id = reader.GetInt64(0),
|
||||
TenantId = reader.GetInt64(1),
|
||||
BillingId = reader.GetInt64(2),
|
||||
Amount = reader.GetDecimal(3),
|
||||
Method = (TenantPaymentMethod)reader.GetInt32(4),
|
||||
Status = (TenantPaymentStatus)reader.GetInt32(5),
|
||||
TransactionNo = reader.IsDBNull(6) ? null : reader.GetString(6),
|
||||
ProofUrl = reader.IsDBNull(7) ? null : reader.GetString(7),
|
||||
Notes = reader.IsDBNull(8) ? null : reader.GetString(8),
|
||||
VerifiedBy = reader.IsDBNull(9) ? null : reader.GetInt64(9),
|
||||
VerifiedAt = reader.IsDBNull(10) ? null : reader.GetDateTime(10),
|
||||
RefundReason = reader.IsDBNull(11) ? null : reader.GetString(11),
|
||||
RefundedAt = reader.IsDBNull(12) ? null : reader.GetDateTime(12),
|
||||
PaidAt = reader.IsDBNull(13) ? null : reader.GetDateTime(13),
|
||||
IsVerified = !reader.IsDBNull(10),
|
||||
CreatedAt = reader.GetDateTime(14)
|
||||
});
|
||||
}
|
||||
|
||||
return results;
|
||||
},
|
||||
cancellationToken);
|
||||
}
|
||||
|
||||
private static async Task<int> ExecuteScalarIntAsync(
|
||||
IDbConnection connection,
|
||||
string sql,
|
||||
(string Name, object? Value)[] parameters,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
await using var command = CreateCommand(connection, sql, parameters);
|
||||
var result = await command.ExecuteScalarAsync(cancellationToken);
|
||||
return result is null or DBNull ? 0 : Convert.ToInt32(result);
|
||||
}
|
||||
|
||||
private static DbCommand CreateCommand(IDbConnection connection, string sql, (string Name, object? Value)[] parameters)
|
||||
{
|
||||
var command = connection.CreateCommand();
|
||||
command.CommandText = sql;
|
||||
|
||||
foreach (var (name, value) in parameters)
|
||||
{
|
||||
var p = command.CreateParameter();
|
||||
p.ParameterName = name;
|
||||
p.Value = value ?? DBNull.Value;
|
||||
command.Parameters.Add(p);
|
||||
}
|
||||
|
||||
return (DbCommand)command;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,187 @@
|
||||
using MediatR;
|
||||
using System.Data;
|
||||
using System.Data.Common;
|
||||
using TakeoutSaaS.Application.App.Billings.Dto;
|
||||
using TakeoutSaaS.Application.App.Billings.Queries;
|
||||
using TakeoutSaaS.Domain.Tenants.Enums;
|
||||
using TakeoutSaaS.Shared.Abstractions.Constants;
|
||||
using TakeoutSaaS.Shared.Abstractions.Data;
|
||||
|
||||
namespace TakeoutSaaS.Application.App.Billings.Handlers;
|
||||
|
||||
/// <summary>
|
||||
/// 查询账单统计数据处理器。
|
||||
/// </summary>
|
||||
public sealed class GetBillingStatisticsQueryHandler(
|
||||
IDapperExecutor dapperExecutor)
|
||||
: IRequestHandler<GetBillingStatisticsQuery, BillingStatisticsDto>
|
||||
{
|
||||
/// <summary>
|
||||
/// 处理查询账单统计数据请求。
|
||||
/// </summary>
|
||||
/// <param name="request">查询命令。</param>
|
||||
/// <param name="cancellationToken">取消标记。</param>
|
||||
/// <returns>账单统计数据 DTO。</returns>
|
||||
public async Task<BillingStatisticsDto> Handle(GetBillingStatisticsQuery request, CancellationToken cancellationToken)
|
||||
{
|
||||
// 1. 参数规范化
|
||||
var startDate = request.StartDate ?? DateTime.UtcNow.AddMonths(-1);
|
||||
var endDate = request.EndDate ?? DateTime.UtcNow;
|
||||
var groupBy = NormalizeGroupBy(request.GroupBy);
|
||||
|
||||
// 2. (空行后) 查询统计数据(总览 + 趋势)
|
||||
return await dapperExecutor.QueryAsync(
|
||||
DatabaseConstants.AppDataSource,
|
||||
DatabaseConnectionRole.Read,
|
||||
async (connection, token) =>
|
||||
{
|
||||
// 2.1 总览统计
|
||||
await using var summaryCommand = CreateCommand(
|
||||
connection,
|
||||
BuildSummarySql(),
|
||||
[
|
||||
("tenantId", request.TenantId),
|
||||
("startDate", startDate),
|
||||
("endDate", endDate),
|
||||
("now", DateTime.UtcNow)
|
||||
]);
|
||||
|
||||
await using var summaryReader = await summaryCommand.ExecuteReaderAsync(token);
|
||||
await summaryReader.ReadAsync(token);
|
||||
|
||||
var totalCount = summaryReader.IsDBNull(0) ? 0 : summaryReader.GetInt32(0);
|
||||
var pendingCount = summaryReader.IsDBNull(1) ? 0 : summaryReader.GetInt32(1);
|
||||
var paidCount = summaryReader.IsDBNull(2) ? 0 : summaryReader.GetInt32(2);
|
||||
var overdueCount = summaryReader.IsDBNull(3) ? 0 : summaryReader.GetInt32(3);
|
||||
var cancelledCount = summaryReader.IsDBNull(4) ? 0 : summaryReader.GetInt32(4);
|
||||
var totalAmountDue = summaryReader.IsDBNull(5) ? 0m : summaryReader.GetDecimal(5);
|
||||
var totalAmountPaid = summaryReader.IsDBNull(6) ? 0m : summaryReader.GetDecimal(6);
|
||||
var totalAmountUnpaid = summaryReader.IsDBNull(7) ? 0m : summaryReader.GetDecimal(7);
|
||||
var totalOverdueAmount = summaryReader.IsDBNull(8) ? 0m : summaryReader.GetDecimal(8);
|
||||
|
||||
// 2.2 (空行后) 趋势数据
|
||||
await using var trendCommand = CreateCommand(
|
||||
connection,
|
||||
BuildTrendSql(groupBy),
|
||||
[
|
||||
("tenantId", request.TenantId),
|
||||
("startDate", startDate),
|
||||
("endDate", endDate)
|
||||
]);
|
||||
|
||||
await using var trendReader = await trendCommand.ExecuteReaderAsync(token);
|
||||
var amountDueTrend = new Dictionary<string, decimal>();
|
||||
var amountPaidTrend = new Dictionary<string, decimal>();
|
||||
var countTrend = new Dictionary<string, int>();
|
||||
while (await trendReader.ReadAsync(token))
|
||||
{
|
||||
var bucket = trendReader.GetDateTime(0);
|
||||
var key = bucket.ToString("yyyy-MM-dd");
|
||||
|
||||
amountDueTrend[key] = trendReader.IsDBNull(1) ? 0m : trendReader.GetDecimal(1);
|
||||
amountPaidTrend[key] = trendReader.IsDBNull(2) ? 0m : trendReader.GetDecimal(2);
|
||||
countTrend[key] = trendReader.IsDBNull(3) ? 0 : trendReader.GetInt32(3);
|
||||
}
|
||||
|
||||
// 2.3 (空行后) 组装 DTO
|
||||
return new BillingStatisticsDto
|
||||
{
|
||||
TenantId = request.TenantId,
|
||||
StartDate = startDate,
|
||||
EndDate = endDate,
|
||||
GroupBy = groupBy,
|
||||
TotalCount = totalCount,
|
||||
PendingCount = pendingCount,
|
||||
PaidCount = paidCount,
|
||||
OverdueCount = overdueCount,
|
||||
CancelledCount = cancelledCount,
|
||||
TotalAmountDue = totalAmountDue,
|
||||
TotalAmountPaid = totalAmountPaid,
|
||||
TotalAmountUnpaid = totalAmountUnpaid,
|
||||
TotalOverdueAmount = totalOverdueAmount,
|
||||
AmountDueTrend = amountDueTrend,
|
||||
AmountPaidTrend = amountPaidTrend,
|
||||
CountTrend = countTrend
|
||||
};
|
||||
},
|
||||
cancellationToken);
|
||||
}
|
||||
|
||||
private static string NormalizeGroupBy(string? groupBy)
|
||||
{
|
||||
return groupBy?.Trim() switch
|
||||
{
|
||||
"Week" => "Week",
|
||||
"Month" => "Month",
|
||||
_ => "Day"
|
||||
};
|
||||
}
|
||||
|
||||
private static string BuildSummarySql()
|
||||
{
|
||||
return """
|
||||
select
|
||||
count(*)::int as "TotalCount",
|
||||
coalesce(sum(case when b."Status" = 0 then 1 else 0 end), 0)::int as "PendingCount",
|
||||
coalesce(sum(case when b."Status" = 1 then 1 else 0 end), 0)::int as "PaidCount",
|
||||
coalesce(sum(case when b."Status" = 2 then 1 else 0 end), 0)::int as "OverdueCount",
|
||||
coalesce(sum(case when b."Status" = 3 then 1 else 0 end), 0)::int as "CancelledCount",
|
||||
coalesce(sum(b."AmountDue"), 0)::numeric as "TotalAmountDue",
|
||||
coalesce(sum(b."AmountPaid"), 0)::numeric as "TotalAmountPaid",
|
||||
coalesce(sum((b."AmountDue" - b."DiscountAmount" + b."TaxAmount") - b."AmountPaid"), 0)::numeric as "TotalAmountUnpaid",
|
||||
coalesce(sum(
|
||||
case
|
||||
when b."Status" in (0, 2) and b."DueDate" < @now
|
||||
then (b."AmountDue" - b."DiscountAmount" + b."TaxAmount") - b."AmountPaid"
|
||||
else 0
|
||||
end
|
||||
), 0)::numeric as "TotalOverdueAmount"
|
||||
from public.tenant_billing_statements b
|
||||
where b."DeletedAt" is null
|
||||
and (@tenantId::bigint is null or b."TenantId" = @tenantId)
|
||||
and b."PeriodStart" >= @startDate
|
||||
and b."PeriodEnd" <= @endDate;
|
||||
""";
|
||||
}
|
||||
|
||||
private static string BuildTrendSql(string groupBy)
|
||||
{
|
||||
var dateTrunc = groupBy switch
|
||||
{
|
||||
"Week" => "week",
|
||||
"Month" => "month",
|
||||
_ => "day"
|
||||
};
|
||||
|
||||
return $"""
|
||||
select
|
||||
date_trunc('{dateTrunc}', b."PeriodStart") as "Bucket",
|
||||
coalesce(sum(b."AmountDue"), 0)::numeric as "AmountDue",
|
||||
coalesce(sum(b."AmountPaid"), 0)::numeric as "AmountPaid",
|
||||
count(*)::int as "Count"
|
||||
from public.tenant_billing_statements b
|
||||
where b."DeletedAt" is null
|
||||
and (@tenantId::bigint is null or b."TenantId" = @tenantId)
|
||||
and b."PeriodStart" >= @startDate
|
||||
and b."PeriodEnd" <= @endDate
|
||||
group by 1
|
||||
order by 1 asc;
|
||||
""";
|
||||
}
|
||||
|
||||
private static DbCommand CreateCommand(IDbConnection connection, string sql, (string Name, object? Value)[] parameters)
|
||||
{
|
||||
var command = connection.CreateCommand();
|
||||
command.CommandText = sql;
|
||||
|
||||
foreach (var (name, value) in parameters)
|
||||
{
|
||||
var p = command.CreateParameter();
|
||||
p.ParameterName = name;
|
||||
p.Value = value ?? DBNull.Value;
|
||||
command.Parameters.Add(p);
|
||||
}
|
||||
|
||||
return (DbCommand)command;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,172 @@
|
||||
using MediatR;
|
||||
using System.Data;
|
||||
using System.Data.Common;
|
||||
using TakeoutSaaS.Application.App.Billings.Dto;
|
||||
using TakeoutSaaS.Application.App.Billings.Queries;
|
||||
using TakeoutSaaS.Domain.Tenants.Enums;
|
||||
using TakeoutSaaS.Shared.Abstractions.Constants;
|
||||
using TakeoutSaaS.Shared.Abstractions.Data;
|
||||
using TakeoutSaaS.Shared.Abstractions.Results;
|
||||
|
||||
namespace TakeoutSaaS.Application.App.Billings.Handlers;
|
||||
|
||||
/// <summary>
|
||||
/// 查询逾期账单列表处理器。
|
||||
/// </summary>
|
||||
public sealed class GetOverdueBillingsQueryHandler(
|
||||
IDapperExecutor dapperExecutor)
|
||||
: IRequestHandler<GetOverdueBillingsQuery, PagedResult<BillingListDto>>
|
||||
{
|
||||
/// <summary>
|
||||
/// 处理查询逾期账单列表请求。
|
||||
/// </summary>
|
||||
/// <param name="request">查询命令。</param>
|
||||
/// <param name="cancellationToken">取消标记。</param>
|
||||
/// <returns>分页逾期账单列表 DTO。</returns>
|
||||
public async Task<PagedResult<BillingListDto>> Handle(GetOverdueBillingsQuery request, CancellationToken cancellationToken)
|
||||
{
|
||||
// 1. 参数规范化
|
||||
var page = request.PageNumber <= 0 ? 1 : request.PageNumber;
|
||||
var pageSize = request.PageSize is <= 0 or > 200 ? 20 : request.PageSize;
|
||||
var offset = (page - 1) * pageSize;
|
||||
var now = DateTime.UtcNow;
|
||||
|
||||
// 2. (空行后) 查询总数 + 列表
|
||||
return await dapperExecutor.QueryAsync(
|
||||
DatabaseConstants.AppDataSource,
|
||||
DatabaseConnectionRole.Read,
|
||||
async (connection, token) =>
|
||||
{
|
||||
// 2.1 统计总数
|
||||
var total = await ExecuteScalarIntAsync(
|
||||
connection,
|
||||
BuildCountSql(),
|
||||
[
|
||||
("now", now)
|
||||
],
|
||||
token);
|
||||
|
||||
// 2.2 (空行后) 查询列表
|
||||
await using var listCommand = CreateCommand(
|
||||
connection,
|
||||
BuildListSql(),
|
||||
[
|
||||
("now", now),
|
||||
("offset", offset),
|
||||
("limit", pageSize)
|
||||
]);
|
||||
|
||||
await using var reader = await listCommand.ExecuteReaderAsync(token);
|
||||
var items = new List<BillingListDto>();
|
||||
while (await reader.ReadAsync(token))
|
||||
{
|
||||
var dueDate = reader.GetDateTime(13);
|
||||
var status = (TenantBillingStatus)reader.GetInt32(12);
|
||||
var amountDue = reader.GetDecimal(8);
|
||||
var discountAmount = reader.GetDecimal(9);
|
||||
var taxAmount = reader.GetDecimal(10);
|
||||
var totalAmount = amountDue - discountAmount + taxAmount;
|
||||
var overdueDays = dueDate < now ? (int)(now - dueDate).TotalDays : 0;
|
||||
|
||||
items.Add(new BillingListDto
|
||||
{
|
||||
Id = reader.GetInt64(0),
|
||||
TenantId = reader.GetInt64(1),
|
||||
TenantName = reader.IsDBNull(2) ? string.Empty : reader.GetString(2),
|
||||
SubscriptionId = reader.IsDBNull(3) ? null : reader.GetInt64(3),
|
||||
StatementNo = reader.GetString(4),
|
||||
BillingType = (BillingType)reader.GetInt32(5),
|
||||
PeriodStart = reader.GetDateTime(6),
|
||||
PeriodEnd = reader.GetDateTime(7),
|
||||
AmountDue = amountDue,
|
||||
DiscountAmount = discountAmount,
|
||||
TaxAmount = taxAmount,
|
||||
TotalAmount = totalAmount,
|
||||
AmountPaid = reader.GetDecimal(11),
|
||||
Status = status,
|
||||
DueDate = dueDate,
|
||||
Currency = reader.IsDBNull(14) ? "CNY" : reader.GetString(14),
|
||||
IsOverdue = true,
|
||||
OverdueDays = overdueDays,
|
||||
CreatedAt = reader.GetDateTime(15),
|
||||
UpdatedAt = reader.IsDBNull(16) ? null : reader.GetDateTime(16)
|
||||
});
|
||||
}
|
||||
|
||||
// 2.3 (空行后) 返回分页
|
||||
return new PagedResult<BillingListDto>(items, page, pageSize, total);
|
||||
},
|
||||
cancellationToken);
|
||||
}
|
||||
|
||||
private static string BuildCountSql()
|
||||
{
|
||||
return """
|
||||
select count(*)
|
||||
from public.tenant_billing_statements b
|
||||
join public.tenants t on t."Id" = b."TenantId" and t."DeletedAt" is null
|
||||
where b."DeletedAt" is null
|
||||
and b."DueDate" < @now
|
||||
and b."Status" in (0, 2);
|
||||
""";
|
||||
}
|
||||
|
||||
private static string BuildListSql()
|
||||
{
|
||||
return """
|
||||
select
|
||||
b."Id",
|
||||
b."TenantId",
|
||||
t."Name" as "TenantName",
|
||||
b."SubscriptionId",
|
||||
b."StatementNo",
|
||||
b."BillingType",
|
||||
b."PeriodStart",
|
||||
b."PeriodEnd",
|
||||
b."AmountDue",
|
||||
b."DiscountAmount",
|
||||
b."TaxAmount",
|
||||
b."AmountPaid",
|
||||
b."Status",
|
||||
b."DueDate",
|
||||
b."Currency",
|
||||
b."CreatedAt",
|
||||
b."UpdatedAt"
|
||||
from public.tenant_billing_statements b
|
||||
join public.tenants t on t."Id" = b."TenantId" and t."DeletedAt" is null
|
||||
where b."DeletedAt" is null
|
||||
and b."DueDate" < @now
|
||||
and b."Status" in (0, 2)
|
||||
order by b."DueDate" asc
|
||||
offset @offset
|
||||
limit @limit;
|
||||
""";
|
||||
}
|
||||
|
||||
private static async Task<int> ExecuteScalarIntAsync(
|
||||
IDbConnection connection,
|
||||
string sql,
|
||||
(string Name, object? Value)[] parameters,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
await using var command = CreateCommand(connection, sql, parameters);
|
||||
var result = await command.ExecuteScalarAsync(cancellationToken);
|
||||
return result is null or DBNull ? 0 : Convert.ToInt32(result);
|
||||
}
|
||||
|
||||
private static DbCommand CreateCommand(IDbConnection connection, string sql, (string Name, object? Value)[] parameters)
|
||||
{
|
||||
var command = connection.CreateCommand();
|
||||
command.CommandText = sql;
|
||||
|
||||
foreach (var (name, value) in parameters)
|
||||
{
|
||||
var p = command.CreateParameter();
|
||||
p.ParameterName = name;
|
||||
p.Value = value ?? DBNull.Value;
|
||||
command.Parameters.Add(p);
|
||||
}
|
||||
|
||||
return (DbCommand)command;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,49 @@
|
||||
using MediatR;
|
||||
using TakeoutSaaS.Application.App.Billings.Commands;
|
||||
using TakeoutSaaS.Domain.Tenants.Repositories;
|
||||
|
||||
namespace TakeoutSaaS.Application.App.Billings.Handlers;
|
||||
|
||||
/// <summary>
|
||||
/// 处理逾期账单命令处理器(后台任务)。
|
||||
/// </summary>
|
||||
public sealed class ProcessOverdueBillingsCommandHandler(
|
||||
ITenantBillingRepository billingRepository)
|
||||
: IRequestHandler<ProcessOverdueBillingsCommand, int>
|
||||
{
|
||||
/// <inheritdoc />
|
||||
public async Task<int> Handle(ProcessOverdueBillingsCommand request, CancellationToken cancellationToken)
|
||||
{
|
||||
// 1. 查询逾期账单(到期日已过且未支付)
|
||||
var overdueBillings = await billingRepository.GetOverdueBillingsAsync(cancellationToken);
|
||||
if (overdueBillings.Count == 0)
|
||||
{
|
||||
return 0;
|
||||
}
|
||||
|
||||
// 2. (空行后) 标记为逾期并更新通知时间
|
||||
var now = DateTime.UtcNow;
|
||||
var updatedCount = 0;
|
||||
foreach (var billing in overdueBillings)
|
||||
{
|
||||
var before = billing.Status;
|
||||
billing.MarkAsOverdue();
|
||||
|
||||
if (before != billing.Status)
|
||||
{
|
||||
billing.OverdueNotifiedAt ??= now;
|
||||
await billingRepository.UpdateAsync(billing, cancellationToken);
|
||||
updatedCount++;
|
||||
}
|
||||
}
|
||||
|
||||
// 3. (空行后) 持久化
|
||||
if (updatedCount > 0)
|
||||
{
|
||||
await billingRepository.SaveChangesAsync(cancellationToken);
|
||||
}
|
||||
|
||||
return updatedCount;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -6,6 +6,7 @@ using TakeoutSaaS.Domain.Tenants.Enums;
|
||||
using TakeoutSaaS.Domain.Tenants.Repositories;
|
||||
using TakeoutSaaS.Shared.Abstractions.Constants;
|
||||
using TakeoutSaaS.Shared.Abstractions.Exceptions;
|
||||
using TakeoutSaaS.Shared.Abstractions.Ids;
|
||||
|
||||
namespace TakeoutSaaS.Application.App.Billings.Handlers;
|
||||
|
||||
@@ -14,8 +15,9 @@ namespace TakeoutSaaS.Application.App.Billings.Handlers;
|
||||
/// </summary>
|
||||
public sealed class RecordPaymentCommandHandler(
|
||||
ITenantBillingRepository billingRepository,
|
||||
ITenantPaymentRepository paymentRepository)
|
||||
: IRequestHandler<RecordPaymentCommand, PaymentDto>
|
||||
ITenantPaymentRepository paymentRepository,
|
||||
IIdGenerator idGenerator)
|
||||
: IRequestHandler<RecordPaymentCommand, PaymentRecordDto>
|
||||
{
|
||||
/// <summary>
|
||||
/// 处理记录支付请求。
|
||||
@@ -23,44 +25,57 @@ public sealed class RecordPaymentCommandHandler(
|
||||
/// <param name="request">记录支付命令。</param>
|
||||
/// <param name="cancellationToken">取消标记。</param>
|
||||
/// <returns>支付 DTO。</returns>
|
||||
public async Task<PaymentDto> Handle(RecordPaymentCommand request, CancellationToken cancellationToken)
|
||||
public async Task<PaymentRecordDto> Handle(RecordPaymentCommand request, CancellationToken cancellationToken)
|
||||
{
|
||||
// 1. 查询账单
|
||||
var bill = await billingRepository.FindByIdAsync(request.BillId, cancellationToken);
|
||||
if (bill is null)
|
||||
var billing = await billingRepository.FindByIdAsync(request.BillingId, cancellationToken);
|
||||
if (billing is null)
|
||||
{
|
||||
throw new BusinessException(ErrorCodes.NotFound, "账单不存在");
|
||||
}
|
||||
|
||||
// 2. 构建支付记录
|
||||
// 2. (空行后) 业务规则检查
|
||||
if (billing.Status == TenantBillingStatus.Paid)
|
||||
{
|
||||
throw new BusinessException(ErrorCodes.BusinessError, "已支付账单不允许重复收款");
|
||||
}
|
||||
|
||||
if (billing.Status == TenantBillingStatus.Cancelled)
|
||||
{
|
||||
throw new BusinessException(ErrorCodes.BusinessError, "已取消账单不允许收款");
|
||||
}
|
||||
|
||||
// 3. (空行后) 幂等校验:交易号唯一
|
||||
if (!string.IsNullOrWhiteSpace(request.TransactionNo))
|
||||
{
|
||||
var exists = await paymentRepository.GetByTransactionNoAsync(request.TransactionNo.Trim(), cancellationToken);
|
||||
if (exists is not null)
|
||||
{
|
||||
throw new BusinessException(ErrorCodes.Conflict, "交易号已存在,疑似重复提交");
|
||||
}
|
||||
}
|
||||
|
||||
// 4. (空行后) 构建支付记录(默认待审核)
|
||||
var now = DateTime.UtcNow;
|
||||
var payment = new TenantPayment
|
||||
{
|
||||
TenantId = bill.TenantId,
|
||||
BillingStatementId = request.BillId,
|
||||
Id = idGenerator.NextId(),
|
||||
TenantId = billing.TenantId,
|
||||
BillingStatementId = request.BillingId,
|
||||
Amount = request.Amount,
|
||||
Method = request.Method,
|
||||
Status = PaymentStatus.Success,
|
||||
TransactionNo = request.TransactionNo,
|
||||
Status = TenantPaymentStatus.Pending,
|
||||
TransactionNo = string.IsNullOrWhiteSpace(request.TransactionNo) ? null : request.TransactionNo.Trim(),
|
||||
ProofUrl = request.ProofUrl,
|
||||
PaidAt = DateTime.UtcNow,
|
||||
PaidAt = now,
|
||||
Notes = request.Notes
|
||||
};
|
||||
|
||||
// 3. 更新账单已付金额
|
||||
bill.AmountPaid += request.Amount;
|
||||
|
||||
// 4. 如果已付金额 >= 应付金额,标记为已支付
|
||||
if (bill.AmountPaid >= bill.AmountDue)
|
||||
{
|
||||
bill.Status = TenantBillingStatus.Paid;
|
||||
}
|
||||
|
||||
// 5. 持久化变更
|
||||
// 5. (空行后) 持久化变更
|
||||
await paymentRepository.AddAsync(payment, cancellationToken);
|
||||
await billingRepository.UpdateAsync(bill, cancellationToken);
|
||||
await paymentRepository.SaveChangesAsync(cancellationToken);
|
||||
|
||||
// 6. 返回 DTO
|
||||
return payment.ToDto();
|
||||
// 6. (空行后) 返回 DTO
|
||||
return payment.ToPaymentRecordDto();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,54 @@
|
||||
using MediatR;
|
||||
using TakeoutSaaS.Application.App.Billings.Commands;
|
||||
using TakeoutSaaS.Domain.Tenants.Enums;
|
||||
using TakeoutSaaS.Domain.Tenants.Repositories;
|
||||
using TakeoutSaaS.Shared.Abstractions.Constants;
|
||||
using TakeoutSaaS.Shared.Abstractions.Exceptions;
|
||||
|
||||
namespace TakeoutSaaS.Application.App.Billings.Handlers;
|
||||
|
||||
/// <summary>
|
||||
/// 更新账单状态命令处理器。
|
||||
/// </summary>
|
||||
public sealed class UpdateBillingStatusCommandHandler(
|
||||
ITenantBillingRepository billingRepository)
|
||||
: IRequestHandler<UpdateBillingStatusCommand, Unit>
|
||||
{
|
||||
/// <inheritdoc />
|
||||
public async Task<Unit> Handle(UpdateBillingStatusCommand request, CancellationToken cancellationToken)
|
||||
{
|
||||
// 1. 查询账单
|
||||
var billing = await billingRepository.FindByIdAsync(request.BillingId, cancellationToken);
|
||||
if (billing is null)
|
||||
{
|
||||
throw new BusinessException(ErrorCodes.NotFound, "账单不存在");
|
||||
}
|
||||
|
||||
// 2. (空行后) 状态转换规则校验
|
||||
if (billing.Status == TenantBillingStatus.Paid && request.NewStatus != TenantBillingStatus.Paid)
|
||||
{
|
||||
throw new BusinessException(ErrorCodes.BusinessError, "已支付账单不允许改为其他状态");
|
||||
}
|
||||
|
||||
if (billing.Status == TenantBillingStatus.Cancelled)
|
||||
{
|
||||
throw new BusinessException(ErrorCodes.BusinessError, "已取消账单不允许变更状态");
|
||||
}
|
||||
|
||||
// 3. (空行后) 更新状态与备注
|
||||
billing.Status = request.NewStatus;
|
||||
if (!string.IsNullOrWhiteSpace(request.Notes))
|
||||
{
|
||||
billing.Notes = string.IsNullOrWhiteSpace(billing.Notes)
|
||||
? $"[状态变更] {request.Notes}"
|
||||
: $"{billing.Notes}\n[状态变更] {request.Notes}";
|
||||
}
|
||||
|
||||
// 4. (空行后) 持久化
|
||||
await billingRepository.UpdateAsync(billing, cancellationToken);
|
||||
await billingRepository.SaveChangesAsync(cancellationToken);
|
||||
|
||||
return Unit.Value;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,73 @@
|
||||
using MediatR;
|
||||
using TakeoutSaaS.Application.App.Billings.Commands;
|
||||
using TakeoutSaaS.Application.App.Billings.Dto;
|
||||
using TakeoutSaaS.Domain.Tenants.Repositories;
|
||||
using TakeoutSaaS.Shared.Abstractions.Constants;
|
||||
using TakeoutSaaS.Shared.Abstractions.Exceptions;
|
||||
using TakeoutSaaS.Shared.Abstractions.Security;
|
||||
|
||||
namespace TakeoutSaaS.Application.App.Billings.Handlers;
|
||||
|
||||
/// <summary>
|
||||
/// 审核支付命令处理器。
|
||||
/// </summary>
|
||||
public sealed class VerifyPaymentCommandHandler(
|
||||
ITenantPaymentRepository paymentRepository,
|
||||
ITenantBillingRepository billingRepository,
|
||||
ICurrentUserAccessor currentUserAccessor)
|
||||
: IRequestHandler<VerifyPaymentCommand, PaymentRecordDto>
|
||||
{
|
||||
/// <inheritdoc />
|
||||
public async Task<PaymentRecordDto> Handle(VerifyPaymentCommand request, CancellationToken cancellationToken)
|
||||
{
|
||||
// 1. 校验操作者身份
|
||||
if (!currentUserAccessor.IsAuthenticated || currentUserAccessor.UserId <= 0)
|
||||
{
|
||||
throw new BusinessException(ErrorCodes.Unauthorized, "未登录或无效的操作者身份");
|
||||
}
|
||||
|
||||
// 2. (空行后) 查询支付记录
|
||||
var payment = await paymentRepository.FindByIdAsync(request.PaymentId, cancellationToken);
|
||||
if (payment is null)
|
||||
{
|
||||
throw new BusinessException(ErrorCodes.NotFound, "支付记录不存在");
|
||||
}
|
||||
|
||||
// 3. (空行后) 查询关联账单
|
||||
var billing = await billingRepository.FindByIdAsync(payment.BillingStatementId, cancellationToken);
|
||||
if (billing is null)
|
||||
{
|
||||
throw new BusinessException(ErrorCodes.NotFound, "关联账单不存在");
|
||||
}
|
||||
|
||||
// 4. (空行后) 归一化审核备注
|
||||
var normalizedNotes = string.IsNullOrWhiteSpace(request.Notes) ? null : request.Notes.Trim();
|
||||
|
||||
// 5. (空行后) 根据审核结果更新支付与账单状态
|
||||
if (request.Approved)
|
||||
{
|
||||
payment.Verify(currentUserAccessor.UserId);
|
||||
payment.Notes = normalizedNotes;
|
||||
|
||||
billing.MarkAsPaid(payment.Amount, payment.TransactionNo ?? string.Empty);
|
||||
}
|
||||
else
|
||||
{
|
||||
payment.Reject(currentUserAccessor.UserId, normalizedNotes ?? string.Empty);
|
||||
payment.Notes = normalizedNotes;
|
||||
}
|
||||
|
||||
// 6. (空行后) 持久化更新状态
|
||||
await paymentRepository.UpdateAsync(payment, cancellationToken);
|
||||
if (request.Approved)
|
||||
{
|
||||
await billingRepository.UpdateAsync(billing, cancellationToken);
|
||||
}
|
||||
|
||||
// 7. (空行后) 保存数据库更改
|
||||
await paymentRepository.SaveChangesAsync(cancellationToken);
|
||||
|
||||
// 8. (空行后) 返回 DTO
|
||||
return payment.ToPaymentRecordDto();
|
||||
}
|
||||
}
|
||||
@@ -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 [];
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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";
|
||||
}
|
||||
@@ -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; }
|
||||
}
|
||||
@@ -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; }
|
||||
}
|
||||
@@ -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; }
|
||||
}
|
||||
@@ -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; }
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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));
|
||||
}
|
||||
}
|
||||
@@ -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));
|
||||
}
|
||||
}
|
||||
@@ -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));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -20,6 +20,9 @@ public static class AppApplicationServiceCollectionExtensions
|
||||
{
|
||||
services.AddMediatR(cfg => cfg.RegisterServicesFromAssembly(Assembly.GetExecutingAssembly()));
|
||||
services.AddValidatorsFromAssembly(Assembly.GetExecutingAssembly());
|
||||
|
||||
// (空行后) 注册 AutoMapper Profile
|
||||
services.AddAutoMapper(Assembly.GetExecutingAssembly());
|
||||
services.AddTransient(typeof(IPipelineBehavior<,>), typeof(ValidationBehavior<,>));
|
||||
|
||||
return services;
|
||||
|
||||
@@ -9,6 +9,8 @@
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
|
||||
<PackageReference Include="Microsoft.AspNetCore.Http.Abstractions" Version="2.3.0" />
|
||||
<PackageReference Include="Microsoft.Extensions.Identity.Core" Version="10.0.0" />
|
||||
|
||||
@@ -0,0 +1,84 @@
|
||||
namespace TakeoutSaaS.Domain.Tenants.Entities;
|
||||
|
||||
/// <summary>
|
||||
/// 账单明细项(值对象)。
|
||||
/// 用于记录账单中的单项费用明细,如套餐费用、配额包费用等。
|
||||
/// </summary>
|
||||
public sealed class BillingLineItem
|
||||
{
|
||||
/// <summary>
|
||||
/// 明细项类型(如:套餐费、配额包、其他费用)。
|
||||
/// </summary>
|
||||
public string ItemType { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// 明细项描述。
|
||||
/// </summary>
|
||||
public string Description { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// 数量。
|
||||
/// </summary>
|
||||
public decimal Quantity { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 单价。
|
||||
/// </summary>
|
||||
public decimal UnitPrice { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 金额(数量 × 单价)。
|
||||
/// </summary>
|
||||
public decimal Amount { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 折扣率(0-1 之间,如 0.1 表示 10% 折扣)。
|
||||
/// </summary>
|
||||
public decimal DiscountRate { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 创建账单明细项。
|
||||
/// </summary>
|
||||
/// <param name="itemType">明细项类型。</param>
|
||||
/// <param name="description">描述。</param>
|
||||
/// <param name="quantity">数量。</param>
|
||||
/// <param name="unitPrice">单价。</param>
|
||||
/// <param name="discountRate">折扣率。</param>
|
||||
/// <returns>账单明细项实例。</returns>
|
||||
public static BillingLineItem Create(
|
||||
string itemType,
|
||||
string description,
|
||||
decimal quantity,
|
||||
decimal unitPrice,
|
||||
decimal discountRate = 0)
|
||||
{
|
||||
var amount = quantity * unitPrice * (1 - discountRate);
|
||||
return new BillingLineItem
|
||||
{
|
||||
ItemType = itemType,
|
||||
Description = description,
|
||||
Quantity = quantity,
|
||||
UnitPrice = unitPrice,
|
||||
Amount = amount,
|
||||
DiscountRate = discountRate
|
||||
};
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 计算折扣后的金额。
|
||||
/// </summary>
|
||||
/// <returns>折扣后金额。</returns>
|
||||
public decimal CalculateDiscountedAmount()
|
||||
{
|
||||
return Quantity * UnitPrice * (1 - DiscountRate);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 获取折扣金额。
|
||||
/// </summary>
|
||||
/// <returns>折扣金额。</returns>
|
||||
public decimal GetDiscountAmount()
|
||||
{
|
||||
return Quantity * UnitPrice * DiscountRate;
|
||||
}
|
||||
}
|
||||
@@ -13,6 +13,16 @@ public sealed class TenantBillingStatement : MultiTenantEntityBase
|
||||
/// </summary>
|
||||
public string StatementNo { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// 账单类型(订阅账单/配额包账单/手动账单/续费账单)。
|
||||
/// </summary>
|
||||
public BillingType BillingType { get; set; } = BillingType.Subscription;
|
||||
|
||||
/// <summary>
|
||||
/// 关联的订阅 ID(仅当 BillingType 为 Subscription 或 Renewal 时有值)。
|
||||
/// </summary>
|
||||
public long? SubscriptionId { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 账单周期开始时间。
|
||||
/// </summary>
|
||||
@@ -24,15 +34,30 @@ public sealed class TenantBillingStatement : MultiTenantEntityBase
|
||||
public DateTime PeriodEnd { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 应付金额。
|
||||
/// 应付金额(原始金额)。
|
||||
/// </summary>
|
||||
public decimal AmountDue { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 折扣金额。
|
||||
/// </summary>
|
||||
public decimal DiscountAmount { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 税费金额。
|
||||
/// </summary>
|
||||
public decimal TaxAmount { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 实付金额。
|
||||
/// </summary>
|
||||
public decimal AmountPaid { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 货币类型(默认 CNY)。
|
||||
/// </summary>
|
||||
public string Currency { get; set; } = "CNY";
|
||||
|
||||
/// <summary>
|
||||
/// 当前付款状态。
|
||||
/// </summary>
|
||||
@@ -43,8 +68,133 @@ public sealed class TenantBillingStatement : MultiTenantEntityBase
|
||||
/// </summary>
|
||||
public DateTime DueDate { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 提醒发送时间(续费提醒、逾期提醒等)。
|
||||
/// </summary>
|
||||
public DateTime? ReminderSentAt { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 逾期通知时间。
|
||||
/// </summary>
|
||||
public DateTime? OverdueNotifiedAt { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 账单明细 JSON,记录各项费用。
|
||||
/// </summary>
|
||||
public string? LineItemsJson { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 备注信息(如:人工备注、取消原因等)。
|
||||
/// </summary>
|
||||
public string? Notes { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 计算总金额(应付金额 - 折扣 + 税费)。
|
||||
/// </summary>
|
||||
/// <returns>总金额。</returns>
|
||||
public decimal CalculateTotalAmount()
|
||||
{
|
||||
return AmountDue - DiscountAmount + TaxAmount;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 标记为已支付(直接结清)。
|
||||
/// </summary>
|
||||
public void MarkAsPaid()
|
||||
{
|
||||
// 1. 计算剩余应付金额
|
||||
var remainingAmount = CalculateTotalAmount() - AmountPaid;
|
||||
|
||||
// 2. 若已结清则直接返回
|
||||
if (remainingAmount <= 0)
|
||||
{
|
||||
Status = TenantBillingStatus.Paid;
|
||||
return;
|
||||
}
|
||||
|
||||
// 3. 补足剩余金额并标记为已支付
|
||||
MarkAsPaid(remainingAmount, string.Empty);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 标记为已支付。
|
||||
/// </summary>
|
||||
/// <param name="amount">支付金额。</param>
|
||||
/// <param name="transactionNo">交易号。</param>
|
||||
public void MarkAsPaid(decimal amount, string transactionNo)
|
||||
{
|
||||
if (Status == TenantBillingStatus.Paid)
|
||||
{
|
||||
throw new InvalidOperationException("账单已经处于已支付状态,不能重复标记。");
|
||||
}
|
||||
|
||||
if (Status == TenantBillingStatus.Cancelled)
|
||||
{
|
||||
throw new InvalidOperationException("已取消的账单不能标记为已支付。");
|
||||
}
|
||||
|
||||
// 1. 累加支付金额
|
||||
AmountPaid += amount;
|
||||
|
||||
// 2. 如果实付金额大于等于应付总额,则标记为已支付
|
||||
if (AmountPaid >= CalculateTotalAmount())
|
||||
{
|
||||
Status = TenantBillingStatus.Paid;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 标记为逾期。
|
||||
/// </summary>
|
||||
public void MarkAsOverdue()
|
||||
{
|
||||
// 1. 仅待支付账单允许标记逾期
|
||||
if (Status != TenantBillingStatus.Pending)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
// 2. 未超过到期日则不处理
|
||||
if (DateTime.UtcNow <= DueDate)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
// 3. 标记为逾期(通知时间由外部流程在发送通知时写入)
|
||||
Status = TenantBillingStatus.Overdue;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 取消账单。
|
||||
/// </summary>
|
||||
public void Cancel()
|
||||
{
|
||||
Cancel(null);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 取消账单。
|
||||
/// </summary>
|
||||
/// <param name="reason">取消原因。</param>
|
||||
public void Cancel(string? reason)
|
||||
{
|
||||
if (Status == TenantBillingStatus.Paid)
|
||||
{
|
||||
throw new InvalidOperationException("已支付的账单不能取消。");
|
||||
}
|
||||
|
||||
if (Status == TenantBillingStatus.Cancelled)
|
||||
{
|
||||
throw new InvalidOperationException("账单已经处于取消状态。");
|
||||
}
|
||||
|
||||
// 1. 变更状态
|
||||
Status = TenantBillingStatus.Cancelled;
|
||||
|
||||
// 2. 记录取消原因(可选)
|
||||
if (!string.IsNullOrWhiteSpace(reason))
|
||||
{
|
||||
Notes = string.IsNullOrWhiteSpace(Notes) ? $"[取消原因] {reason}" : $"{Notes}\n[取消原因] {reason}";
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -21,12 +21,12 @@ public sealed class TenantPayment : MultiTenantEntityBase
|
||||
/// <summary>
|
||||
/// 支付方式。
|
||||
/// </summary>
|
||||
public PaymentMethod Method { get; set; }
|
||||
public TenantPaymentMethod Method { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 支付状态。
|
||||
/// </summary>
|
||||
public PaymentStatus Status { get; set; }
|
||||
public TenantPaymentStatus Status { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 交易号。
|
||||
@@ -43,8 +43,116 @@ public sealed class TenantPayment : MultiTenantEntityBase
|
||||
/// </summary>
|
||||
public DateTime? PaidAt { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 退款原因。
|
||||
/// </summary>
|
||||
public string? RefundReason { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 退款时间。
|
||||
/// </summary>
|
||||
public DateTime? RefundedAt { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 审核人 ID(管理员)。
|
||||
/// </summary>
|
||||
public long? VerifiedBy { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 审核时间。
|
||||
/// </summary>
|
||||
public DateTime? VerifiedAt { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 备注信息。
|
||||
/// </summary>
|
||||
public string? Notes { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 审核支付记录(确认支付有效性)。
|
||||
/// </summary>
|
||||
public void Verify()
|
||||
{
|
||||
if (Status != TenantPaymentStatus.Pending)
|
||||
{
|
||||
throw new InvalidOperationException("只有待审核的支付记录才能被审核。");
|
||||
}
|
||||
|
||||
if (VerifiedAt.HasValue)
|
||||
{
|
||||
throw new InvalidOperationException("该支付记录已经被审核过。");
|
||||
}
|
||||
|
||||
// 1. 标记为支付成功
|
||||
Status = TenantPaymentStatus.Success;
|
||||
|
||||
// 2. 写入审核时间与支付时间
|
||||
VerifiedAt = DateTime.UtcNow;
|
||||
PaidAt ??= DateTime.UtcNow;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 审核支付记录(确认支付有效性)。
|
||||
/// </summary>
|
||||
/// <param name="verifierId">审核人 ID。</param>
|
||||
public void Verify(long verifierId)
|
||||
{
|
||||
Verify();
|
||||
VerifiedBy = verifierId;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 退款。
|
||||
/// </summary>
|
||||
public void Refund()
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(RefundReason))
|
||||
{
|
||||
throw new InvalidOperationException("退款原因不能为空。");
|
||||
}
|
||||
|
||||
Refund(RefundReason);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 退款。
|
||||
/// </summary>
|
||||
/// <param name="reason">退款原因。</param>
|
||||
public void Refund(string reason)
|
||||
{
|
||||
if (Status == TenantPaymentStatus.Refunded)
|
||||
{
|
||||
throw new InvalidOperationException("该支付记录已经处于退款状态。");
|
||||
}
|
||||
|
||||
if (Status != TenantPaymentStatus.Success)
|
||||
{
|
||||
throw new InvalidOperationException("只有支付成功的记录才能退款。");
|
||||
}
|
||||
|
||||
// 1. 标记退款状态
|
||||
Status = TenantPaymentStatus.Refunded;
|
||||
|
||||
// 2. 写入退款原因与退款时间
|
||||
RefundReason = reason;
|
||||
RefundedAt = DateTime.UtcNow;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 拒绝支付(审核不通过)。
|
||||
/// </summary>
|
||||
/// <param name="verifierId">审核人 ID。</param>
|
||||
/// <param name="reason">拒绝原因。</param>
|
||||
public void Reject(long verifierId, string reason)
|
||||
{
|
||||
if (Status != TenantPaymentStatus.Pending)
|
||||
{
|
||||
throw new InvalidOperationException("只有待审核的支付记录才能被拒绝。");
|
||||
}
|
||||
|
||||
Status = TenantPaymentStatus.Failed;
|
||||
VerifiedBy = verifierId;
|
||||
VerifiedAt = DateTime.UtcNow;
|
||||
Notes = $"拒绝原因: {reason}";
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
27
src/Domain/TakeoutSaaS.Domain/Tenants/Enums/BillingType.cs
Normal file
27
src/Domain/TakeoutSaaS.Domain/Tenants/Enums/BillingType.cs
Normal 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
|
||||
}
|
||||
@@ -1,9 +1,9 @@
|
||||
namespace TakeoutSaaS.Domain.Tenants.Enums;
|
||||
|
||||
/// <summary>
|
||||
/// 支付方式。
|
||||
/// 租户支付方式。
|
||||
/// </summary>
|
||||
public enum PaymentMethod
|
||||
public enum TenantPaymentMethod
|
||||
{
|
||||
/// <summary>
|
||||
/// 线上支付。
|
||||
@@ -1,9 +1,9 @@
|
||||
namespace TakeoutSaaS.Domain.Tenants.Enums;
|
||||
|
||||
/// <summary>
|
||||
/// 支付状态。
|
||||
/// 租户支付状态。
|
||||
/// </summary>
|
||||
public enum PaymentStatus
|
||||
public enum TenantPaymentStatus
|
||||
{
|
||||
/// <summary>
|
||||
/// 待支付。
|
||||
@@ -42,6 +42,14 @@ public interface ITenantBillingRepository
|
||||
/// <returns>账单实体或 null。</returns>
|
||||
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>
|
||||
@@ -54,6 +62,39 @@ public interface ITenantBillingRepository
|
||||
DateTime periodStart,
|
||||
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>
|
||||
@@ -84,6 +125,8 @@ public interface ITenantBillingRepository
|
||||
/// <param name="status">账单状态筛选(可选)。</param>
|
||||
/// <param name="from">开始时间(UTC,可选)。</param>
|
||||
/// <param name="to">结束时间(UTC,可选)。</param>
|
||||
/// <param name="minAmount">最小应付金额筛选(包含,可选)。</param>
|
||||
/// <param name="maxAmount">最大应付金额筛选(包含,可选)。</param>
|
||||
/// <param name="keyword">关键词搜索(账单号或租户名)。</param>
|
||||
/// <param name="pageNumber">页码(从 1 开始)。</param>
|
||||
/// <param name="pageSize">页大小。</param>
|
||||
@@ -94,11 +137,29 @@ public interface ITenantBillingRepository
|
||||
TenantBillingStatus? status,
|
||||
DateTime? from,
|
||||
DateTime? to,
|
||||
decimal? minAmount,
|
||||
decimal? maxAmount,
|
||||
string? keyword,
|
||||
int pageNumber,
|
||||
int pageSize,
|
||||
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>
|
||||
/// 按 ID 获取账单(不限租户,管理员端使用)。
|
||||
/// </summary>
|
||||
@@ -107,3 +168,80 @@ public interface ITenantBillingRepository
|
||||
/// <returns>账单实体或 null。</returns>
|
||||
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; }
|
||||
}
|
||||
|
||||
@@ -15,6 +15,14 @@ public interface ITenantPaymentRepository
|
||||
/// <returns>支付记录集合。</returns>
|
||||
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>
|
||||
/// 按 ID 获取支付记录。
|
||||
/// </summary>
|
||||
@@ -23,6 +31,14 @@ public interface ITenantPaymentRepository
|
||||
/// <returns>支付记录实体或 null。</returns>
|
||||
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>
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
using TakeoutSaaS.Domain.Tenants.Entities;
|
||||
|
||||
namespace TakeoutSaaS.Domain.Tenants.Services;
|
||||
|
||||
/// <summary>
|
||||
/// 账单导出服务接口。
|
||||
/// </summary>
|
||||
public interface IBillingExportService
|
||||
{
|
||||
/// <summary>
|
||||
/// 导出为 Excel(XLSX)。
|
||||
/// </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);
|
||||
}
|
||||
@@ -8,9 +8,12 @@ using TakeoutSaaS.Domain.Payments.Repositories;
|
||||
using TakeoutSaaS.Domain.Products.Repositories;
|
||||
using TakeoutSaaS.Domain.Stores.Repositories;
|
||||
using TakeoutSaaS.Domain.Tenants.Repositories;
|
||||
using TakeoutSaaS.Domain.Tenants.Services;
|
||||
using TakeoutSaaS.Infrastructure.App.Options;
|
||||
using TakeoutSaaS.Infrastructure.App.Persistence;
|
||||
using TakeoutSaaS.Infrastructure.App.Persistence.Repositories;
|
||||
using TakeoutSaaS.Infrastructure.App.Repositories;
|
||||
using TakeoutSaaS.Infrastructure.App.Services;
|
||||
using TakeoutSaaS.Infrastructure.Common.Extensions;
|
||||
using TakeoutSaaS.Shared.Abstractions.Constants;
|
||||
|
||||
@@ -40,8 +43,8 @@ public static class AppServiceCollectionExtensions
|
||||
services.AddScoped<IPaymentRepository, EfPaymentRepository>();
|
||||
services.AddScoped<IDeliveryRepository, EfDeliveryRepository>();
|
||||
services.AddScoped<ITenantRepository, EfTenantRepository>();
|
||||
services.AddScoped<ITenantBillingRepository, EfTenantBillingRepository>();
|
||||
services.AddScoped<ITenantPaymentRepository, EfTenantPaymentRepository>();
|
||||
services.AddScoped<ITenantBillingRepository, TenantBillingRepository>();
|
||||
services.AddScoped<ITenantPaymentRepository, TenantPaymentRepository>();
|
||||
services.AddScoped<ITenantAnnouncementRepository, EfTenantAnnouncementRepository>();
|
||||
services.AddScoped<ITenantAnnouncementReadRepository, EfTenantAnnouncementReadRepository>();
|
||||
services.AddScoped<ITenantNotificationRepository, EfTenantNotificationRepository>();
|
||||
@@ -52,6 +55,10 @@ public static class AppServiceCollectionExtensions
|
||||
services.AddScoped<IStatisticsRepository, EfStatisticsRepository>();
|
||||
services.AddScoped<ISubscriptionRepository, EfSubscriptionRepository>();
|
||||
|
||||
// 1. 账单领域/导出服务
|
||||
services.AddScoped<IBillingDomainService, BillingDomainService>();
|
||||
services.AddScoped<IBillingExportService, BillingExportService>();
|
||||
|
||||
services.AddOptions<AppSeedOptions>()
|
||||
.Bind(configuration.GetSection(AppSeedOptions.SectionName))
|
||||
.ValidateDataAnnotations();
|
||||
|
||||
@@ -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");
|
||||
}
|
||||
}
|
||||
@@ -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");
|
||||
}
|
||||
}
|
||||
@@ -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:00(UTC)
|
||||
var monday = date.AddDays(-daysSinceMonday);
|
||||
return new DateTime(monday.Year, monday.Month, monday.Day, 0, 0, 0, DateTimeKind.Utc);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -24,6 +24,7 @@ using TakeoutSaaS.Infrastructure.Common.Persistence;
|
||||
using TakeoutSaaS.Shared.Abstractions.Ids;
|
||||
using TakeoutSaaS.Shared.Abstractions.Security;
|
||||
using TakeoutSaaS.Shared.Abstractions.Tenancy;
|
||||
using TakeoutSaaS.Infrastructure.App.Persistence.Configurations;
|
||||
|
||||
namespace TakeoutSaaS.Infrastructure.App.Persistence;
|
||||
|
||||
@@ -762,28 +763,12 @@ public sealed class TakeoutAppDbContext(
|
||||
|
||||
private static void ConfigureTenantBilling(EntityTypeBuilder<TenantBillingStatement> builder)
|
||||
{
|
||||
builder.ToTable("tenant_billing_statements");
|
||||
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();
|
||||
new TenantBillingStatementConfiguration().Configure(builder);
|
||||
}
|
||||
|
||||
private static void ConfigureTenantPayment(EntityTypeBuilder<TenantPayment> builder)
|
||||
{
|
||||
builder.ToTable("tenant_payments");
|
||||
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 });
|
||||
new TenantPaymentConfiguration().Configure(builder);
|
||||
}
|
||||
|
||||
private static void ConfigureTenantNotification(EntityTypeBuilder<TenantNotification> builder)
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -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: "应付金额(原始金额)。");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -6221,13 +6221,17 @@ namespace TakeoutSaaS.Infrastructure.Migrations
|
||||
b.Property<decimal>("AmountDue")
|
||||
.HasPrecision(18, 2)
|
||||
.HasColumnType("numeric(18,2)")
|
||||
.HasComment("应付金额。");
|
||||
.HasComment("应付金额(原始金额)。");
|
||||
|
||||
b.Property<decimal>("AmountPaid")
|
||||
.HasPrecision(18, 2)
|
||||
.HasColumnType("numeric(18,2)")
|
||||
.HasComment("实付金额。");
|
||||
|
||||
b.Property<int>("BillingType")
|
||||
.HasColumnType("integer")
|
||||
.HasComment("账单类型(订阅账单/配额包账单/手动账单/续费账单)。");
|
||||
|
||||
b.Property<DateTime>("CreatedAt")
|
||||
.HasColumnType("timestamp with time zone")
|
||||
.HasComment("创建时间(UTC)。");
|
||||
@@ -6236,6 +6240,14 @@ namespace TakeoutSaaS.Infrastructure.Migrations
|
||||
.HasColumnType("bigint")
|
||||
.HasComment("创建人用户标识,匿名或系统操作时为 null。");
|
||||
|
||||
b.Property<string>("Currency")
|
||||
.IsRequired()
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasMaxLength(8)
|
||||
.HasColumnType("character varying(8)")
|
||||
.HasDefaultValue("CNY")
|
||||
.HasComment("货币类型(默认 CNY)。");
|
||||
|
||||
b.Property<DateTime?>("DeletedAt")
|
||||
.HasColumnType("timestamp with time zone")
|
||||
.HasComment("软删除时间(UTC),未删除时为 null。");
|
||||
@@ -6244,6 +6256,11 @@ namespace TakeoutSaaS.Infrastructure.Migrations
|
||||
.HasColumnType("bigint")
|
||||
.HasComment("删除人用户标识(软删除),未删除时为 null。");
|
||||
|
||||
b.Property<decimal>("DiscountAmount")
|
||||
.HasPrecision(18, 2)
|
||||
.HasColumnType("numeric(18,2)")
|
||||
.HasComment("折扣金额。");
|
||||
|
||||
b.Property<DateTime>("DueDate")
|
||||
.HasColumnType("timestamp with time zone")
|
||||
.HasComment("到期日。");
|
||||
@@ -6252,6 +6269,15 @@ namespace TakeoutSaaS.Infrastructure.Migrations
|
||||
.HasColumnType("text")
|
||||
.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")
|
||||
.HasColumnType("timestamp with time zone")
|
||||
.HasComment("账单周期结束时间。");
|
||||
@@ -6260,6 +6286,10 @@ namespace TakeoutSaaS.Infrastructure.Migrations
|
||||
.HasColumnType("timestamp with time zone")
|
||||
.HasComment("账单周期开始时间。");
|
||||
|
||||
b.Property<DateTime?>("ReminderSentAt")
|
||||
.HasColumnType("timestamp with time zone")
|
||||
.HasComment("提醒发送时间(续费提醒、逾期提醒等)。");
|
||||
|
||||
b.Property<string>("StatementNo")
|
||||
.IsRequired()
|
||||
.HasMaxLength(64)
|
||||
@@ -6270,6 +6300,15 @@ namespace TakeoutSaaS.Infrastructure.Migrations
|
||||
.HasColumnType("integer")
|
||||
.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")
|
||||
.HasColumnType("bigint")
|
||||
.HasComment("所属租户 ID。");
|
||||
@@ -6284,9 +6323,19 @@ namespace TakeoutSaaS.Infrastructure.Migrations
|
||||
|
||||
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")
|
||||
.IsUnique();
|
||||
|
||||
b.HasIndex("TenantId", "Status", "DueDate")
|
||||
.HasDatabaseName("idx_billing_tenant_status_duedate");
|
||||
|
||||
b.ToTable("tenant_billing_statements", null, t =>
|
||||
{
|
||||
t.HasComment("租户账单,用于呈现周期性收费。");
|
||||
@@ -6555,6 +6604,15 @@ namespace TakeoutSaaS.Infrastructure.Migrations
|
||||
.HasColumnType("character varying(512)")
|
||||
.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")
|
||||
.HasColumnType("integer")
|
||||
.HasComment("支付状态。");
|
||||
@@ -6576,8 +6634,23 @@ namespace TakeoutSaaS.Infrastructure.Migrations
|
||||
.HasColumnType("bigint")
|
||||
.HasComment("最后更新人用户标识,匿名或系统操作时为 null。");
|
||||
|
||||
b.Property<DateTime?>("VerifiedAt")
|
||||
.HasColumnType("timestamp with time zone")
|
||||
.HasComment("审核时间。");
|
||||
|
||||
b.Property<long?>("VerifiedBy")
|
||||
.HasColumnType("bigint")
|
||||
.HasComment("审核人 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.ToTable("tenant_payments", null, t =>
|
||||
|
||||
Binary file not shown.
Reference in New Issue
Block a user