feat: 完成账单管理模块后端功能开发及API优化

核心功能:
- 账单CRUD操作(创建、查询、详情、更新状态、删除)
- 支付记录管理(创建支付、审核支付)
- 批量操作支持(批量更新账单状态)
- 统计分析功能(账单统计、逾期账单查询)
- 导出功能(Excel/PDF/CSV)

API端点 (16个):
- GET /api/admin/v1/billings - 账单列表(分页、筛选、排序)
- POST /api/admin/v1/billings - 创建账单
- GET /api/admin/v1/billings/{id} - 账单详情
- DELETE /api/admin/v1/billings/{id} - 删除账单
- PUT /api/admin/v1/billings/{id}/status - 更新状态
- POST /api/admin/v1/billings/batch/status - 批量更新
- GET /api/admin/v1/billings/{id}/payments - 支付记录
- POST /api/admin/v1/billings/{id}/payments - 创建支付
- PUT /api/admin/v1/billings/payments/{paymentId}/verify - 审核支付
- GET /api/admin/v1/billings/statistics - 统计数据
- GET /api/admin/v1/billings/overdue - 逾期账单
- POST /api/admin/v1/billings/export - 导出账单

架构优化:
- 采用CQRS模式分离读写(MediatR + Dapper + EF Core)
- 完整的领域模型设计(TenantBillingStatement, TenantPayment等)
- FluentValidation请求验证
- 状态机管理账单和支付状态流转

API设计优化 (三项改进):
1. 导出API响应Content-Type改为application/octet-stream
2. 支付审核API添加Approved和Notes可选参数,支持通过/拒绝
3. 移除TenantBillings API中重复的TenantId参数

数据库变更:
- 新增账单相关表及关系
- 支持Snowflake ID主键
- 完整的审计字段支持

🤖 Generated with Claude Code

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
This commit is contained in:
2025-12-18 11:24:44 +08:00
parent 98f49ea7ad
commit 4b53862ded
73 changed files with 12688 additions and 305 deletions

View File

@@ -0,0 +1,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;
}
}