✨ 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,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;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user