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

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -24,6 +24,7 @@ using TakeoutSaaS.Infrastructure.Common.Persistence;
using TakeoutSaaS.Shared.Abstractions.Ids;
using TakeoutSaaS.Shared.Abstractions.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)

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -6221,13 +6221,17 @@ namespace TakeoutSaaS.Infrastructure.Migrations
b.Property<decimal>("AmountDue")
.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 =>