✨ 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:
@@ -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