Compare commits
2 Commits
654b1ae3f7
...
3a94348cca
| Author | SHA1 | Date | |
|---|---|---|---|
| 3a94348cca | |||
| c032608a57 |
@@ -1,20 +1,25 @@
|
||||
using Asp.Versioning;
|
||||
using Asp.Versioning.ApiExplorer;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using TakeoutSaaS.Shared.Abstractions.Security;
|
||||
using TakeoutSaaS.Shared.Abstractions.Serialization;
|
||||
using TakeoutSaaS.Shared.Web.Filters;
|
||||
using Microsoft.AspNetCore.Cors.Infrastructure;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using OpenTelemetry.Metrics;
|
||||
using OpenTelemetry.Resources;
|
||||
using OpenTelemetry.Trace;
|
||||
using Serilog;
|
||||
using TakeoutSaaS.Application.App.Extensions;
|
||||
using TakeoutSaaS.Application.Dictionary.Extensions;
|
||||
using TakeoutSaaS.Application.Identity.Extensions;
|
||||
using TakeoutSaaS.Application.Messaging.Extensions;
|
||||
using TakeoutSaaS.Infrastructure.App.Extensions;
|
||||
using TakeoutSaaS.Infrastructure.Dictionary.Extensions;
|
||||
using TakeoutSaaS.Infrastructure.Identity.Extensions;
|
||||
using TakeoutSaaS.Module.Authorization.Extensions;
|
||||
using TakeoutSaaS.Module.Messaging.Extensions;
|
||||
using TakeoutSaaS.Module.Tenancy.Extensions;
|
||||
using TakeoutSaaS.Shared.Abstractions.Security;
|
||||
using TakeoutSaaS.Shared.Abstractions.Serialization;
|
||||
using TakeoutSaaS.Shared.Web.Extensions;
|
||||
using TakeoutSaaS.Shared.Web.Filters;
|
||||
using TakeoutSaaS.Shared.Web.Security;
|
||||
using TakeoutSaaS.Shared.Web.Swagger;
|
||||
|
||||
@@ -81,15 +86,29 @@ if (isDevelopment)
|
||||
}
|
||||
|
||||
// 5. 注册鉴权授权与权限策略
|
||||
builder.Services.AddAppApplication();
|
||||
builder.Services.AddAppInfrastructure(builder.Configuration);
|
||||
builder.Services.AddJwtAuthentication(builder.Configuration);
|
||||
builder.Services.AddTenantResolution(builder.Configuration);
|
||||
builder.Services.AddAuthorization();
|
||||
builder.Services.AddPermissionAuthorization();
|
||||
builder.Services.AddHealthChecks();
|
||||
|
||||
// 6. 配置 OpenTelemetry 采集
|
||||
// 6. 注册应用层与基础设施(仅租户侧所需)
|
||||
builder.Services.AddAppApplication();
|
||||
builder.Services.AddIdentityApplication(enableMiniSupport: false);
|
||||
builder.Services.AddAppInfrastructure(builder.Configuration);
|
||||
builder.Services.AddIdentityInfrastructure(builder.Configuration, enableMiniFeatures: false, enableAdminSeed: false);
|
||||
|
||||
// 7. 注册多租户解析(依赖 ITenantRepository,需在 Infrastructure 之后)
|
||||
builder.Services.AddTenantResolution(builder.Configuration);
|
||||
|
||||
// 8. 注册字典模块(系统参数、字典项、缓存等)
|
||||
builder.Services.AddDictionaryApplication();
|
||||
builder.Services.AddDictionaryInfrastructure(builder.Configuration);
|
||||
|
||||
// 9. 注册消息发布能力(未配置 RabbitMQ 时自动降级为 NoOp 实现)
|
||||
builder.Services.AddMessagingApplication();
|
||||
builder.Services.AddMessagingModule(builder.Configuration);
|
||||
|
||||
// 10. 配置 OpenTelemetry 采集
|
||||
var otelSection = builder.Configuration.GetSection("Otel");
|
||||
var otelEndpoint = otelSection.GetValue<string>("Endpoint");
|
||||
var useConsoleExporter = otelSection.GetValue<bool?>("UseConsoleExporter") ?? builder.Environment.IsDevelopment();
|
||||
@@ -105,7 +124,6 @@ builder.Services.AddOpenTelemetry()
|
||||
.AddAspNetCoreInstrumentation()
|
||||
.AddHttpClientInstrumentation()
|
||||
.AddEntityFrameworkCoreInstrumentation();
|
||||
// 1. (空行后) 配置 OTLP 导出
|
||||
if (!string.IsNullOrWhiteSpace(otelEndpoint))
|
||||
{
|
||||
tracing.AddOtlpExporter(exporter =>
|
||||
@@ -113,7 +131,6 @@ builder.Services.AddOpenTelemetry()
|
||||
exporter.Endpoint = new Uri(otelEndpoint);
|
||||
});
|
||||
}
|
||||
// 2. (空行后) 配置 Console 导出
|
||||
if (useConsoleExporter)
|
||||
{
|
||||
tracing.AddConsoleExporter();
|
||||
@@ -126,7 +143,6 @@ builder.Services.AddOpenTelemetry()
|
||||
.AddHttpClientInstrumentation()
|
||||
.AddRuntimeInstrumentation()
|
||||
.AddPrometheusExporter();
|
||||
// 1. (空行后) 配置 OTLP 导出
|
||||
if (!string.IsNullOrWhiteSpace(otelEndpoint))
|
||||
{
|
||||
metrics.AddOtlpExporter(exporter =>
|
||||
@@ -134,14 +150,13 @@ builder.Services.AddOpenTelemetry()
|
||||
exporter.Endpoint = new Uri(otelEndpoint);
|
||||
});
|
||||
}
|
||||
// 2. (空行后) 配置 Console 导出
|
||||
if (useConsoleExporter)
|
||||
{
|
||||
metrics.AddConsoleExporter();
|
||||
}
|
||||
});
|
||||
|
||||
// 7. 配置 CORS
|
||||
// 11. 配置 CORS
|
||||
var tenantOrigins = ResolveCorsOrigins(builder.Configuration, "Cors:Tenant");
|
||||
builder.Services.AddCors(options =>
|
||||
{
|
||||
@@ -151,21 +166,23 @@ builder.Services.AddCors(options =>
|
||||
});
|
||||
});
|
||||
|
||||
// 8. 构建应用并配置中间件管道
|
||||
// 12. 构建应用并配置中间件管道
|
||||
var app = builder.Build();
|
||||
app.UseCors("TenantApiCors");
|
||||
|
||||
// 1. (空行后) 通用 Web Core 中间件(异常、ProblemDetails、日志等)
|
||||
app.UseSharedWebCore();
|
||||
|
||||
// 2. (空行后) 执行认证并解析租户
|
||||
// 1. (空行后) 先完成身份认证,确保租户解析优先使用 Token Claim
|
||||
app.UseAuthentication();
|
||||
|
||||
// 2. (空行后) 解析并注入租户上下文(已认证请求不允许 Header 覆盖)
|
||||
app.UseTenantResolution();
|
||||
|
||||
// 3. (空行后) 执行授权
|
||||
// 3. (空行后) 通用 Web Core 中间件(异常、ProblemDetails、日志等)
|
||||
app.UseSharedWebCore();
|
||||
|
||||
// 4. (空行后) 执行授权
|
||||
app.UseAuthorization();
|
||||
|
||||
// 4. (空行后) 开发环境启用 Swagger
|
||||
// 5. (空行后) 开发环境启用 Swagger
|
||||
if (app.Environment.IsDevelopment())
|
||||
{
|
||||
app.UseSharedSwagger();
|
||||
@@ -175,7 +192,7 @@ app.MapPrometheusScrapingEndpoint();
|
||||
app.MapControllers();
|
||||
app.Run();
|
||||
|
||||
// 10. 解析配置中的 CORS 域名
|
||||
// 13. 解析配置中的 CORS 域名
|
||||
static string[] ResolveCorsOrigins(IConfiguration configuration, string sectionKey)
|
||||
{
|
||||
var origins = configuration.GetSection(sectionKey).Get<string[]>();
|
||||
@@ -185,7 +202,7 @@ static string[] ResolveCorsOrigins(IConfiguration configuration, string sectionK
|
||||
.ToArray() ?? [];
|
||||
}
|
||||
|
||||
// 10. 构建 CORS 策略
|
||||
// 14. 构建 CORS 策略
|
||||
static void ConfigureCorsPolicy(CorsPolicyBuilder policy, string[] origins)
|
||||
{
|
||||
if (origins.Length == 0)
|
||||
@@ -197,7 +214,7 @@ static void ConfigureCorsPolicy(CorsPolicyBuilder policy, string[] origins)
|
||||
policy.WithOrigins(origins)
|
||||
.AllowCredentials();
|
||||
}
|
||||
// 1. (空行后) 放行通用 Header 与 Method
|
||||
|
||||
policy
|
||||
.AllowAnyHeader()
|
||||
.AllowAnyMethod();
|
||||
|
||||
@@ -0,0 +1,200 @@
|
||||
using System.Text.Json;
|
||||
using TakeoutSaaS.Application.App.Billings.Dto;
|
||||
using TakeoutSaaS.Domain.Tenants.Entities;
|
||||
|
||||
namespace TakeoutSaaS.Application.App.Billings;
|
||||
|
||||
/// <summary>
|
||||
/// 账单 DTO 映射助手。
|
||||
/// </summary>
|
||||
internal static class BillingMapping
|
||||
{
|
||||
/// <summary>
|
||||
/// 将账单实体映射为账单 DTO(旧版)。
|
||||
/// </summary>
|
||||
/// <param name="bill">账单实体。</param>
|
||||
/// <param name="tenantName">租户名称。</param>
|
||||
/// <returns>账单 DTO。</returns>
|
||||
public static BillDto ToDto(this TenantBillingStatement bill, string? tenantName = null)
|
||||
=> new()
|
||||
{
|
||||
Id = bill.Id,
|
||||
TenantId = bill.TenantId,
|
||||
TenantName = tenantName,
|
||||
StatementNo = bill.StatementNo,
|
||||
PeriodStart = bill.PeriodStart,
|
||||
PeriodEnd = bill.PeriodEnd,
|
||||
AmountDue = bill.AmountDue,
|
||||
AmountPaid = bill.AmountPaid,
|
||||
Status = bill.Status,
|
||||
DueDate = bill.DueDate,
|
||||
CreatedAt = bill.CreatedAt
|
||||
};
|
||||
|
||||
/// <summary>
|
||||
/// 将账单实体映射为账单列表 DTO(新版)。
|
||||
/// </summary>
|
||||
/// <param name="billing">账单实体。</param>
|
||||
/// <param name="tenantName">租户名称。</param>
|
||||
/// <returns>账单列表 DTO。</returns>
|
||||
public static BillingListDto ToBillingListDto(this TenantBillingStatement billing, string? tenantName = null)
|
||||
=> new()
|
||||
{
|
||||
Id = billing.Id,
|
||||
TenantId = billing.TenantId,
|
||||
SubscriptionId = billing.SubscriptionId,
|
||||
TenantName = tenantName ?? string.Empty,
|
||||
StatementNo = billing.StatementNo,
|
||||
BillingType = billing.BillingType,
|
||||
Status = billing.Status,
|
||||
PeriodStart = billing.PeriodStart,
|
||||
PeriodEnd = billing.PeriodEnd,
|
||||
AmountDue = billing.AmountDue,
|
||||
AmountPaid = billing.AmountPaid,
|
||||
DiscountAmount = billing.DiscountAmount,
|
||||
TaxAmount = billing.TaxAmount,
|
||||
TotalAmount = billing.CalculateTotalAmount(),
|
||||
Currency = billing.Currency,
|
||||
DueDate = billing.DueDate,
|
||||
CreatedAt = billing.CreatedAt,
|
||||
UpdatedAt = billing.UpdatedAt,
|
||||
IsOverdue = billing.Status == TakeoutSaaS.Domain.Tenants.Enums.TenantBillingStatus.Overdue
|
||||
|| (billing.Status == TakeoutSaaS.Domain.Tenants.Enums.TenantBillingStatus.Pending && billing.DueDate < DateTime.UtcNow),
|
||||
OverdueDays = (billing.Status is TakeoutSaaS.Domain.Tenants.Enums.TenantBillingStatus.Pending
|
||||
or TakeoutSaaS.Domain.Tenants.Enums.TenantBillingStatus.Overdue)
|
||||
&& billing.DueDate < DateTime.UtcNow
|
||||
? (int)(DateTime.UtcNow - billing.DueDate).TotalDays
|
||||
: 0
|
||||
};
|
||||
|
||||
/// <summary>
|
||||
/// 将账单实体与支付记录映射为账单详情 DTO(旧版)。
|
||||
/// </summary>
|
||||
/// <param name="bill">账单实体。</param>
|
||||
/// <param name="payments">支付记录列表。</param>
|
||||
/// <param name="tenantName">租户名称。</param>
|
||||
/// <returns>账单详情 DTO。</returns>
|
||||
public static BillDetailDto ToDetailDto(
|
||||
this TenantBillingStatement bill,
|
||||
List<TenantPayment> payments,
|
||||
string? tenantName = null)
|
||||
=> new()
|
||||
{
|
||||
Id = bill.Id,
|
||||
TenantId = bill.TenantId,
|
||||
TenantName = tenantName,
|
||||
StatementNo = bill.StatementNo,
|
||||
PeriodStart = bill.PeriodStart,
|
||||
PeriodEnd = bill.PeriodEnd,
|
||||
AmountDue = bill.AmountDue,
|
||||
AmountPaid = bill.AmountPaid,
|
||||
Status = bill.Status,
|
||||
DueDate = bill.DueDate,
|
||||
LineItemsJson = bill.LineItemsJson,
|
||||
CreatedAt = bill.CreatedAt,
|
||||
Payments = payments.Select(p => p.ToDto()).ToList()
|
||||
};
|
||||
|
||||
/// <summary>
|
||||
/// 将账单实体与支付记录映射为账单详情 DTO(新版)。
|
||||
/// </summary>
|
||||
/// <param name="billing">账单实体。</param>
|
||||
/// <param name="payments">支付记录列表。</param>
|
||||
/// <param name="tenantName">租户名称。</param>
|
||||
/// <returns>账单详情 DTO。</returns>
|
||||
public static BillingDetailDto ToBillingDetailDto(
|
||||
this TenantBillingStatement billing,
|
||||
List<TenantPayment> payments,
|
||||
string? tenantName = null)
|
||||
{
|
||||
// 反序列化账单明细
|
||||
var lineItems = new List<BillingLineItemDto>();
|
||||
if (!string.IsNullOrWhiteSpace(billing.LineItemsJson))
|
||||
{
|
||||
try
|
||||
{
|
||||
lineItems = JsonSerializer.Deserialize<List<BillingLineItemDto>>(billing.LineItemsJson) ?? [];
|
||||
}
|
||||
catch
|
||||
{
|
||||
lineItems = [];
|
||||
}
|
||||
}
|
||||
|
||||
return new BillingDetailDto
|
||||
{
|
||||
Id = billing.Id,
|
||||
TenantId = billing.TenantId,
|
||||
TenantName = tenantName ?? string.Empty,
|
||||
SubscriptionId = billing.SubscriptionId,
|
||||
StatementNo = billing.StatementNo,
|
||||
BillingType = billing.BillingType,
|
||||
Status = billing.Status,
|
||||
PeriodStart = billing.PeriodStart,
|
||||
PeriodEnd = billing.PeriodEnd,
|
||||
AmountDue = billing.AmountDue,
|
||||
AmountPaid = billing.AmountPaid,
|
||||
DiscountAmount = billing.DiscountAmount,
|
||||
TaxAmount = billing.TaxAmount,
|
||||
TotalAmount = billing.CalculateTotalAmount(),
|
||||
Currency = billing.Currency,
|
||||
DueDate = billing.DueDate,
|
||||
ReminderSentAt = billing.ReminderSentAt,
|
||||
OverdueNotifiedAt = billing.OverdueNotifiedAt,
|
||||
LineItemsJson = billing.LineItemsJson,
|
||||
LineItems = lineItems,
|
||||
Payments = payments.Select(p => p.ToPaymentRecordDto()).ToList(),
|
||||
Notes = billing.Notes,
|
||||
CreatedAt = billing.CreatedAt,
|
||||
CreatedBy = billing.CreatedBy,
|
||||
UpdatedAt = billing.UpdatedAt,
|
||||
UpdatedBy = billing.UpdatedBy
|
||||
};
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 将支付记录实体映射为支付 DTO(旧版)。
|
||||
/// </summary>
|
||||
/// <param name="payment">支付记录实体。</param>
|
||||
/// <returns>支付 DTO。</returns>
|
||||
public static PaymentDto ToDto(this TenantPayment payment)
|
||||
=> new()
|
||||
{
|
||||
Id = payment.Id,
|
||||
BillingStatementId = payment.BillingStatementId,
|
||||
Amount = payment.Amount,
|
||||
Method = payment.Method,
|
||||
Status = payment.Status,
|
||||
TransactionNo = payment.TransactionNo,
|
||||
ProofUrl = payment.ProofUrl,
|
||||
PaidAt = payment.PaidAt,
|
||||
Notes = payment.Notes,
|
||||
CreatedAt = payment.CreatedAt
|
||||
};
|
||||
|
||||
/// <summary>
|
||||
/// 将支付记录实体映射为支付记录 DTO(新版)。
|
||||
/// </summary>
|
||||
/// <param name="payment">支付记录实体。</param>
|
||||
/// <returns>支付记录 DTO。</returns>
|
||||
public static PaymentRecordDto ToPaymentRecordDto(this TenantPayment payment)
|
||||
=> new()
|
||||
{
|
||||
Id = payment.Id,
|
||||
TenantId = payment.TenantId,
|
||||
BillingId = payment.BillingStatementId,
|
||||
Amount = payment.Amount,
|
||||
Method = payment.Method,
|
||||
Status = payment.Status,
|
||||
TransactionNo = payment.TransactionNo,
|
||||
ProofUrl = payment.ProofUrl,
|
||||
IsVerified = payment.VerifiedAt.HasValue,
|
||||
PaidAt = payment.PaidAt,
|
||||
VerifiedBy = payment.VerifiedBy,
|
||||
VerifiedAt = payment.VerifiedAt,
|
||||
RefundReason = payment.RefundReason,
|
||||
RefundedAt = payment.RefundedAt,
|
||||
Notes = payment.Notes,
|
||||
CreatedAt = payment.CreatedAt
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
using MediatR;
|
||||
using TakeoutSaaS.Domain.Tenants.Enums;
|
||||
|
||||
namespace TakeoutSaaS.Application.App.Billings.Commands;
|
||||
|
||||
/// <summary>
|
||||
/// 批量更新账单状态命令。
|
||||
/// </summary>
|
||||
public sealed record BatchUpdateStatusCommand : IRequest<int>
|
||||
{
|
||||
/// <summary>
|
||||
/// 账单 ID 列表(雪花算法)。
|
||||
/// </summary>
|
||||
public long[] BillingIds { get; init; } = [];
|
||||
|
||||
/// <summary>
|
||||
/// 新状态。
|
||||
/// </summary>
|
||||
public TenantBillingStatus NewStatus { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// 批量操作备注。
|
||||
/// </summary>
|
||||
public string? Notes { get; init; }
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
using MediatR;
|
||||
|
||||
namespace TakeoutSaaS.Application.App.Billings.Commands;
|
||||
|
||||
/// <summary>
|
||||
/// 取消账单命令。
|
||||
/// </summary>
|
||||
public sealed record CancelBillingCommand : IRequest<Unit>
|
||||
{
|
||||
/// <summary>
|
||||
/// 账单 ID(雪花算法)。
|
||||
/// </summary>
|
||||
public long BillingId { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// 取消原因。
|
||||
/// </summary>
|
||||
public string Reason { get; init; } = string.Empty;
|
||||
}
|
||||
@@ -0,0 +1,42 @@
|
||||
using MediatR;
|
||||
using TakeoutSaaS.Application.App.Billings.Dto;
|
||||
using TakeoutSaaS.Domain.Tenants.Enums;
|
||||
|
||||
namespace TakeoutSaaS.Application.App.Billings.Commands;
|
||||
|
||||
/// <summary>
|
||||
/// 一键确认收款命令(记录支付 + 立即审核通过)。
|
||||
/// </summary>
|
||||
public sealed record ConfirmPaymentCommand : IRequest<PaymentRecordDto>
|
||||
{
|
||||
/// <summary>
|
||||
/// 账单 ID(雪花算法)。
|
||||
/// </summary>
|
||||
public long BillingId { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// 支付金额。
|
||||
/// </summary>
|
||||
public decimal Amount { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// 支付方式。
|
||||
/// </summary>
|
||||
public TenantPaymentMethod Method { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// 交易号。
|
||||
/// </summary>
|
||||
public string? TransactionNo { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// 支付凭证 URL。
|
||||
/// </summary>
|
||||
public string? ProofUrl { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// 备注信息。
|
||||
/// </summary>
|
||||
public string? Notes { get; init; }
|
||||
}
|
||||
|
||||
@@ -0,0 +1,30 @@
|
||||
using MediatR;
|
||||
using TakeoutSaaS.Application.App.Billings.Dto;
|
||||
|
||||
namespace TakeoutSaaS.Application.App.Billings.Commands;
|
||||
|
||||
/// <summary>
|
||||
/// 创建账单命令。
|
||||
/// </summary>
|
||||
public sealed record CreateBillCommand : IRequest<BillDto>
|
||||
{
|
||||
/// <summary>
|
||||
/// 租户 ID(雪花算法)。
|
||||
/// </summary>
|
||||
public long TenantId { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// 应付金额。
|
||||
/// </summary>
|
||||
public decimal AmountDue { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// 到期日(UTC)。
|
||||
/// </summary>
|
||||
public DateTime DueDate { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// 备注信息。
|
||||
/// </summary>
|
||||
public string? Notes { get; init; }
|
||||
}
|
||||
@@ -0,0 +1,41 @@
|
||||
using MediatR;
|
||||
using TakeoutSaaS.Application.App.Billings.Dto;
|
||||
using TakeoutSaaS.Domain.Tenants.Enums;
|
||||
|
||||
namespace TakeoutSaaS.Application.App.Billings.Commands;
|
||||
|
||||
/// <summary>
|
||||
/// 创建账单命令。
|
||||
/// </summary>
|
||||
public sealed record CreateBillingCommand : IRequest<BillingDetailDto>
|
||||
{
|
||||
/// <summary>
|
||||
/// 租户 ID(雪花算法)。
|
||||
/// </summary>
|
||||
public long TenantId { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// 账单类型。
|
||||
/// </summary>
|
||||
public BillingType BillingType { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// 应付金额。
|
||||
/// </summary>
|
||||
public decimal AmountDue { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// 到期日(UTC)。
|
||||
/// </summary>
|
||||
public DateTime DueDate { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// 账单明细列表。
|
||||
/// </summary>
|
||||
public List<BillingLineItemDto> LineItems { get; init; } = [];
|
||||
|
||||
/// <summary>
|
||||
/// 备注。
|
||||
/// </summary>
|
||||
public string? Notes { get; init; }
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
using MediatR;
|
||||
using TakeoutSaaS.Application.App.Billings.Dto;
|
||||
|
||||
namespace TakeoutSaaS.Application.App.Billings.Commands;
|
||||
|
||||
/// <summary>
|
||||
/// 生成订阅账单命令(自动化场景)。
|
||||
/// </summary>
|
||||
public sealed record GenerateSubscriptionBillingCommand : IRequest<BillingDetailDto>
|
||||
{
|
||||
/// <summary>
|
||||
/// 订阅 ID(雪花算法)。
|
||||
/// </summary>
|
||||
public long SubscriptionId { get; init; }
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
using MediatR;
|
||||
|
||||
namespace TakeoutSaaS.Application.App.Billings.Commands;
|
||||
|
||||
/// <summary>
|
||||
/// 处理逾期账单命令(后台任务场景)。
|
||||
/// </summary>
|
||||
public sealed record ProcessOverdueBillingsCommand : IRequest<int>
|
||||
{
|
||||
}
|
||||
@@ -0,0 +1,41 @@
|
||||
using MediatR;
|
||||
using TakeoutSaaS.Application.App.Billings.Dto;
|
||||
using TakeoutSaaS.Domain.Tenants.Enums;
|
||||
|
||||
namespace TakeoutSaaS.Application.App.Billings.Commands;
|
||||
|
||||
/// <summary>
|
||||
/// 记录支付命令。
|
||||
/// </summary>
|
||||
public sealed record RecordPaymentCommand : IRequest<PaymentRecordDto>
|
||||
{
|
||||
/// <summary>
|
||||
/// 账单 ID(雪花算法)。
|
||||
/// </summary>
|
||||
public long BillingId { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// 支付金额。
|
||||
/// </summary>
|
||||
public decimal Amount { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// 支付方式。
|
||||
/// </summary>
|
||||
public TenantPaymentMethod Method { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// 交易号。
|
||||
/// </summary>
|
||||
public string? TransactionNo { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// 支付凭证 URL。
|
||||
/// </summary>
|
||||
public string? ProofUrl { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// 备注信息。
|
||||
/// </summary>
|
||||
public string? Notes { get; init; }
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
using MediatR;
|
||||
using TakeoutSaaS.Application.App.Billings.Dto;
|
||||
using TakeoutSaaS.Domain.Tenants.Enums;
|
||||
|
||||
namespace TakeoutSaaS.Application.App.Billings.Commands;
|
||||
|
||||
/// <summary>
|
||||
/// 更新账单状态命令。
|
||||
/// </summary>
|
||||
public sealed record UpdateBillStatusCommand : IRequest<BillDto?>
|
||||
{
|
||||
/// <summary>
|
||||
/// 账单 ID(雪花算法)。
|
||||
/// </summary>
|
||||
public long BillId { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// 新状态。
|
||||
/// </summary>
|
||||
public TenantBillingStatus Status { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// 备注信息。
|
||||
/// </summary>
|
||||
public string? Notes { get; init; }
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
using MediatR;
|
||||
using TakeoutSaaS.Domain.Tenants.Enums;
|
||||
|
||||
namespace TakeoutSaaS.Application.App.Billings.Commands;
|
||||
|
||||
/// <summary>
|
||||
/// 更新账单状态命令。
|
||||
/// </summary>
|
||||
public sealed record UpdateBillingStatusCommand : IRequest<Unit>
|
||||
{
|
||||
/// <summary>
|
||||
/// 账单 ID(雪花算法)。
|
||||
/// </summary>
|
||||
public long BillingId { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// 新状态。
|
||||
/// </summary>
|
||||
public TenantBillingStatus NewStatus { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// 备注。
|
||||
/// </summary>
|
||||
public string? Notes { get; init; }
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
using MediatR;
|
||||
using System.ComponentModel.DataAnnotations;
|
||||
using TakeoutSaaS.Application.App.Billings.Dto;
|
||||
|
||||
namespace TakeoutSaaS.Application.App.Billings.Commands;
|
||||
|
||||
/// <summary>
|
||||
/// 审核支付命令。
|
||||
/// </summary>
|
||||
public sealed record VerifyPaymentCommand : IRequest<PaymentRecordDto>
|
||||
{
|
||||
/// <summary>
|
||||
/// 支付记录 ID(雪花算法)。
|
||||
/// </summary>
|
||||
[Required]
|
||||
public long PaymentId { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// 是否通过审核。
|
||||
/// </summary>
|
||||
public bool Approved { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// 审核备注(可选)。
|
||||
/// </summary>
|
||||
[MaxLength(512)]
|
||||
public string? Notes { get; init; }
|
||||
}
|
||||
@@ -0,0 +1,78 @@
|
||||
using System.Text.Json.Serialization;
|
||||
using TakeoutSaaS.Domain.Tenants.Enums;
|
||||
using TakeoutSaaS.Shared.Abstractions.Serialization;
|
||||
|
||||
namespace TakeoutSaaS.Application.App.Billings.Dto;
|
||||
|
||||
/// <summary>
|
||||
/// 账单详情 DTO。
|
||||
/// </summary>
|
||||
public sealed record BillDetailDto
|
||||
{
|
||||
/// <summary>
|
||||
/// 账单 ID(雪花算法,序列化为字符串)。
|
||||
/// </summary>
|
||||
[JsonConverter(typeof(SnowflakeIdJsonConverter))]
|
||||
public long Id { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// 租户 ID(雪花算法,序列化为字符串)。
|
||||
/// </summary>
|
||||
[JsonConverter(typeof(SnowflakeIdJsonConverter))]
|
||||
public long TenantId { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// 租户名称。
|
||||
/// </summary>
|
||||
public string? TenantName { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// 账单编号。
|
||||
/// </summary>
|
||||
public string StatementNo { get; init; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// 计费周期开始时间(UTC)。
|
||||
/// </summary>
|
||||
public DateTime PeriodStart { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// 计费周期结束时间(UTC)。
|
||||
/// </summary>
|
||||
public DateTime PeriodEnd { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// 应付金额。
|
||||
/// </summary>
|
||||
public decimal AmountDue { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// 已付金额。
|
||||
/// </summary>
|
||||
public decimal AmountPaid { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// 账单状态。
|
||||
/// </summary>
|
||||
public TenantBillingStatus Status { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// 到期日(UTC)。
|
||||
/// </summary>
|
||||
public DateTime DueDate { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// 账单明细 JSON。
|
||||
/// </summary>
|
||||
public string? LineItemsJson { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// 创建时间(UTC)。
|
||||
/// </summary>
|
||||
public DateTime CreatedAt { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// 支付记录列表。
|
||||
/// </summary>
|
||||
public List<PaymentDto> Payments { get; init; } = new();
|
||||
}
|
||||
@@ -0,0 +1,68 @@
|
||||
using System.Text.Json.Serialization;
|
||||
using TakeoutSaaS.Domain.Tenants.Enums;
|
||||
using TakeoutSaaS.Shared.Abstractions.Serialization;
|
||||
|
||||
namespace TakeoutSaaS.Application.App.Billings.Dto;
|
||||
|
||||
/// <summary>
|
||||
/// 账单 DTO。
|
||||
/// </summary>
|
||||
public sealed record BillDto
|
||||
{
|
||||
/// <summary>
|
||||
/// 账单 ID(雪花算法,序列化为字符串)。
|
||||
/// </summary>
|
||||
[JsonConverter(typeof(SnowflakeIdJsonConverter))]
|
||||
public long Id { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// 租户 ID(雪花算法,序列化为字符串)。
|
||||
/// </summary>
|
||||
[JsonConverter(typeof(SnowflakeIdJsonConverter))]
|
||||
public long TenantId { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// 租户名称。
|
||||
/// </summary>
|
||||
public string? TenantName { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// 账单编号。
|
||||
/// </summary>
|
||||
public string StatementNo { get; init; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// 计费周期开始时间(UTC)。
|
||||
/// </summary>
|
||||
public DateTime PeriodStart { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// 计费周期结束时间(UTC)。
|
||||
/// </summary>
|
||||
public DateTime PeriodEnd { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// 应付金额。
|
||||
/// </summary>
|
||||
public decimal AmountDue { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// 已付金额。
|
||||
/// </summary>
|
||||
public decimal AmountPaid { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// 账单状态。
|
||||
/// </summary>
|
||||
public TenantBillingStatus Status { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// 到期日(UTC)。
|
||||
/// </summary>
|
||||
public DateTime DueDate { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// 创建时间(UTC)。
|
||||
/// </summary>
|
||||
public DateTime CreatedAt { get; init; }
|
||||
}
|
||||
@@ -0,0 +1,146 @@
|
||||
using System.Text.Json.Serialization;
|
||||
using TakeoutSaaS.Domain.Tenants.Enums;
|
||||
using TakeoutSaaS.Shared.Abstractions.Serialization;
|
||||
|
||||
namespace TakeoutSaaS.Application.App.Billings.Dto;
|
||||
|
||||
/// <summary>
|
||||
/// 账单详情 DTO(租户端)。
|
||||
/// </summary>
|
||||
public sealed record BillingDetailDto
|
||||
{
|
||||
/// <summary>
|
||||
/// 账单 ID。
|
||||
/// </summary>
|
||||
[JsonConverter(typeof(SnowflakeIdJsonConverter))]
|
||||
public long Id { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// 租户 ID。
|
||||
/// </summary>
|
||||
[JsonConverter(typeof(SnowflakeIdJsonConverter))]
|
||||
public long TenantId { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// 租户名称。
|
||||
/// </summary>
|
||||
public string TenantName { get; init; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// 账单编号。
|
||||
/// </summary>
|
||||
public string StatementNo { get; init; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// 计费周期开始时间(UTC)。
|
||||
/// </summary>
|
||||
public DateTime PeriodStart { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// 计费周期结束时间(UTC)。
|
||||
/// </summary>
|
||||
public DateTime PeriodEnd { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// 账单类型。
|
||||
/// </summary>
|
||||
public BillingType BillingType { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// 账单状态。
|
||||
/// </summary>
|
||||
public TenantBillingStatus Status { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// 应付金额。
|
||||
/// </summary>
|
||||
public decimal AmountDue { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// 已支付金额。
|
||||
/// </summary>
|
||||
public decimal AmountPaid { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// 折扣金额。
|
||||
/// </summary>
|
||||
public decimal DiscountAmount { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// 税费金额。
|
||||
/// </summary>
|
||||
public decimal TaxAmount { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// 总金额(应付金额 - 折扣 + 税费)。
|
||||
/// </summary>
|
||||
public decimal TotalAmount { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// 币种。
|
||||
/// </summary>
|
||||
public string Currency { get; init; } = "CNY";
|
||||
|
||||
/// <summary>
|
||||
/// 到期日。
|
||||
/// </summary>
|
||||
public DateTime DueDate { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// 订阅 ID(可选)。
|
||||
/// </summary>
|
||||
[JsonConverter(typeof(NullableSnowflakeIdJsonConverter))]
|
||||
public long? SubscriptionId { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// 账单明细 JSON(原始字符串)。
|
||||
/// </summary>
|
||||
public string? LineItemsJson { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// 账单明细行项目。
|
||||
/// </summary>
|
||||
public IReadOnlyList<BillingLineItemDto> LineItems { get; init; } = [];
|
||||
|
||||
/// <summary>
|
||||
/// 支付记录。
|
||||
/// </summary>
|
||||
public IReadOnlyList<PaymentRecordDto> Payments { get; init; } = [];
|
||||
|
||||
/// <summary>
|
||||
/// 提醒发送时间。
|
||||
/// </summary>
|
||||
public DateTime? ReminderSentAt { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// 逾期通知时间。
|
||||
/// </summary>
|
||||
public DateTime? OverdueNotifiedAt { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// 备注。
|
||||
/// </summary>
|
||||
public string? Notes { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// 创建时间。
|
||||
/// </summary>
|
||||
public DateTime CreatedAt { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// 创建人 ID。
|
||||
/// </summary>
|
||||
[JsonConverter(typeof(NullableSnowflakeIdJsonConverter))]
|
||||
public long? CreatedBy { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// 更新时间。
|
||||
/// </summary>
|
||||
public DateTime? UpdatedAt { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// 更新人 ID。
|
||||
/// </summary>
|
||||
[JsonConverter(typeof(NullableSnowflakeIdJsonConverter))]
|
||||
public long? UpdatedBy { get; init; }
|
||||
}
|
||||
@@ -0,0 +1,545 @@
|
||||
using System.Text.Json.Serialization;
|
||||
using TakeoutSaaS.Domain.Tenants.Enums;
|
||||
using TakeoutSaaS.Shared.Abstractions.Serialization;
|
||||
|
||||
namespace TakeoutSaaS.Application.App.Billings.Dto.Legacy;
|
||||
|
||||
/// <summary>
|
||||
/// 账单列表 DTO(用于列表展示)。
|
||||
/// </summary>
|
||||
public sealed record BillingListDto
|
||||
{
|
||||
/// <summary>
|
||||
/// 账单 ID(雪花算法,序列化为字符串)。
|
||||
/// </summary>
|
||||
[JsonConverter(typeof(SnowflakeIdJsonConverter))]
|
||||
public long Id { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// 租户 ID(雪花算法,序列化为字符串)。
|
||||
/// </summary>
|
||||
[JsonConverter(typeof(SnowflakeIdJsonConverter))]
|
||||
public long TenantId { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// 租户名称。
|
||||
/// </summary>
|
||||
public string TenantName { get; init; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// 关联订阅 ID(仅订阅/续费账单可能有值)。
|
||||
/// </summary>
|
||||
[JsonConverter(typeof(NullableSnowflakeIdJsonConverter))]
|
||||
public long? SubscriptionId { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// 账单编号。
|
||||
/// </summary>
|
||||
public string StatementNo { get; init; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// 账单类型。
|
||||
/// </summary>
|
||||
public BillingType BillingType { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// 计费周期开始时间(UTC)。
|
||||
/// </summary>
|
||||
public DateTime PeriodStart { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// 计费周期结束时间(UTC)。
|
||||
/// </summary>
|
||||
public DateTime PeriodEnd { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// 应付金额。
|
||||
/// </summary>
|
||||
public decimal AmountDue { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// 折扣金额。
|
||||
/// </summary>
|
||||
public decimal DiscountAmount { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// 税费金额。
|
||||
/// </summary>
|
||||
public decimal TaxAmount { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// 总金额(应付金额 - 折扣 + 税费)。
|
||||
/// </summary>
|
||||
public decimal TotalAmount { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// 已付金额。
|
||||
/// </summary>
|
||||
public decimal AmountPaid { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// 币种。
|
||||
/// </summary>
|
||||
public string Currency { get; init; } = "CNY";
|
||||
|
||||
/// <summary>
|
||||
/// 账单状态。
|
||||
/// </summary>
|
||||
public TenantBillingStatus Status { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// 到期日(UTC)。
|
||||
/// </summary>
|
||||
public DateTime DueDate { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// 是否已逾期(根据到期日与状态综合判断)。
|
||||
/// </summary>
|
||||
public bool IsOverdue { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// 逾期天数(未逾期为 0)。
|
||||
/// </summary>
|
||||
public int OverdueDays { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// 创建时间(UTC)。
|
||||
/// </summary>
|
||||
public DateTime CreatedAt { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// 更新时间(UTC)。
|
||||
/// </summary>
|
||||
public DateTime? UpdatedAt { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 账单详情 DTO(含明细项)。
|
||||
/// </summary>
|
||||
public sealed record BillingDetailDto
|
||||
{
|
||||
/// <summary>
|
||||
/// 账单 ID(雪花算法,序列化为字符串)。
|
||||
/// </summary>
|
||||
[JsonConverter(typeof(SnowflakeIdJsonConverter))]
|
||||
public long Id { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// 租户 ID(雪花算法,序列化为字符串)。
|
||||
/// </summary>
|
||||
[JsonConverter(typeof(SnowflakeIdJsonConverter))]
|
||||
public long TenantId { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// 租户名称。
|
||||
/// </summary>
|
||||
public string TenantName { get; init; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// 账单编号。
|
||||
/// </summary>
|
||||
public string StatementNo { get; init; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// 关联订阅 ID(仅订阅/续费账单可能有值)。
|
||||
/// </summary>
|
||||
[JsonConverter(typeof(NullableSnowflakeIdJsonConverter))]
|
||||
public long? SubscriptionId { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// 账单类型。
|
||||
/// </summary>
|
||||
public BillingType BillingType { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// 计费周期开始时间(UTC)。
|
||||
/// </summary>
|
||||
public DateTime PeriodStart { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// 计费周期结束时间(UTC)。
|
||||
/// </summary>
|
||||
public DateTime PeriodEnd { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// 应付金额。
|
||||
/// </summary>
|
||||
public decimal AmountDue { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// 折扣金额。
|
||||
/// </summary>
|
||||
public decimal DiscountAmount { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// 税费金额。
|
||||
/// </summary>
|
||||
public decimal TaxAmount { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// 总金额(应付金额 - 折扣 + 税费)。
|
||||
/// </summary>
|
||||
public decimal TotalAmount { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// 已付金额。
|
||||
/// </summary>
|
||||
public decimal AmountPaid { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// 币种。
|
||||
/// </summary>
|
||||
public string Currency { get; init; } = "CNY";
|
||||
|
||||
/// <summary>
|
||||
/// 账单状态。
|
||||
/// </summary>
|
||||
public TenantBillingStatus Status { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// 到期日(UTC)。
|
||||
/// </summary>
|
||||
public DateTime DueDate { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// 账单明细 JSON。
|
||||
/// </summary>
|
||||
public string? LineItemsJson { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// 账单明细列表(从 JSON 反序列化)。
|
||||
/// </summary>
|
||||
public IReadOnlyList<BillingLineItemDto> LineItems { get; init; } = [];
|
||||
|
||||
/// <summary>
|
||||
/// 支付记录列表。
|
||||
/// </summary>
|
||||
public IReadOnlyList<PaymentRecordDto> Payments { get; init; } = [];
|
||||
|
||||
/// <summary>
|
||||
/// 备注信息。
|
||||
/// </summary>
|
||||
public string? Notes { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// 创建时间(UTC)。
|
||||
/// </summary>
|
||||
public DateTime CreatedAt { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// 更新时间(UTC)。
|
||||
/// </summary>
|
||||
public DateTime? UpdatedAt { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 账单明细项 DTO。
|
||||
/// </summary>
|
||||
public sealed record BillingLineItemDto
|
||||
{
|
||||
/// <summary>
|
||||
/// 明细类型(如:套餐费、配额包费用、其他费用)。
|
||||
/// </summary>
|
||||
public string ItemType { get; init; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// 描述。
|
||||
/// </summary>
|
||||
public string Description { get; init; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// 数量。
|
||||
/// </summary>
|
||||
public decimal Quantity { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// 单价。
|
||||
/// </summary>
|
||||
public decimal UnitPrice { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// 金额(数量 × 单价)。
|
||||
/// </summary>
|
||||
public decimal Amount { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// 折扣率(0-1)。
|
||||
/// </summary>
|
||||
public decimal? DiscountRate { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 支付记录 DTO。
|
||||
/// </summary>
|
||||
public sealed record PaymentRecordDto
|
||||
{
|
||||
/// <summary>
|
||||
/// 支付记录 ID(雪花算法,序列化为字符串)。
|
||||
/// </summary>
|
||||
[JsonConverter(typeof(SnowflakeIdJsonConverter))]
|
||||
public long Id { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// 租户 ID(雪花算法,序列化为字符串)。
|
||||
/// </summary>
|
||||
[JsonConverter(typeof(SnowflakeIdJsonConverter))]
|
||||
public long TenantId { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// 账单 ID(雪花算法,序列化为字符串)。
|
||||
/// </summary>
|
||||
[JsonConverter(typeof(SnowflakeIdJsonConverter))]
|
||||
public long BillingId { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// 支付金额。
|
||||
/// </summary>
|
||||
public decimal Amount { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// 支付方式。
|
||||
/// </summary>
|
||||
public TenantPaymentMethod Method { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// 支付状态。
|
||||
/// </summary>
|
||||
public TenantPaymentStatus Status { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// 支付流水号。
|
||||
/// </summary>
|
||||
public string? TransactionNo { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// 支付凭证 URL。
|
||||
/// </summary>
|
||||
public string? ProofUrl { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// 备注。
|
||||
/// </summary>
|
||||
public string? Notes { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// 审核状态(待审核/已通过/已拒绝)。
|
||||
/// </summary>
|
||||
public bool IsVerified { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// 审核人 ID(雪花算法,序列化为字符串)。
|
||||
/// </summary>
|
||||
[JsonConverter(typeof(NullableSnowflakeIdJsonConverter))]
|
||||
public long? VerifiedBy { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// 审核时间(UTC)。
|
||||
/// </summary>
|
||||
public DateTime? VerifiedAt { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// 退款原因。
|
||||
/// </summary>
|
||||
public string? RefundReason { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// 退款时间(UTC)。
|
||||
/// </summary>
|
||||
public DateTime? RefundedAt { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// 支付时间(UTC)。
|
||||
/// </summary>
|
||||
public DateTime? PaidAt { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// 创建时间(UTC)。
|
||||
/// </summary>
|
||||
public DateTime CreatedAt { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 账单统计 DTO。
|
||||
/// </summary>
|
||||
public sealed record BillingStatisticsDto
|
||||
{
|
||||
/// <summary>
|
||||
/// 租户 ID(当前租户)。
|
||||
/// </summary>
|
||||
[JsonConverter(typeof(NullableSnowflakeIdJsonConverter))]
|
||||
public long? TenantId { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// 统计周期开始时间(UTC)。
|
||||
/// </summary>
|
||||
public DateTime StartDate { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// 统计周期结束时间(UTC)。
|
||||
/// </summary>
|
||||
public DateTime EndDate { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// 分组方式(Day/Week/Month)。
|
||||
/// </summary>
|
||||
public string GroupBy { get; init; } = "Day";
|
||||
|
||||
/// <summary>
|
||||
/// 总账单数量。
|
||||
/// </summary>
|
||||
public int TotalCount { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// 待付款账单数量。
|
||||
/// </summary>
|
||||
public int PendingCount { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// 已付款账单数量。
|
||||
/// </summary>
|
||||
public int PaidCount { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// 逾期账单数量。
|
||||
/// </summary>
|
||||
public int OverdueCount { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// 已取消账单数量。
|
||||
/// </summary>
|
||||
public int CancelledCount { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// 总应收金额(账单原始应付)。
|
||||
/// </summary>
|
||||
public decimal TotalAmountDue { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// 总实收金额。
|
||||
/// </summary>
|
||||
public decimal TotalAmountPaid { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// 总未收金额(总金额 - 实收)。
|
||||
/// </summary>
|
||||
public decimal TotalAmountUnpaid { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// 逾期未收金额。
|
||||
/// </summary>
|
||||
public decimal TotalOverdueAmount { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// 分组统计:应收金额趋势(Key 为分组起始日期 yyyy-MM-dd)。
|
||||
/// </summary>
|
||||
public Dictionary<string, decimal> AmountDueTrend { get; init; } = [];
|
||||
|
||||
/// <summary>
|
||||
/// 分组统计:实收金额趋势(Key 为分组起始日期 yyyy-MM-dd)。
|
||||
/// </summary>
|
||||
public Dictionary<string, decimal> AmountPaidTrend { get; init; } = [];
|
||||
|
||||
/// <summary>
|
||||
/// 分组统计:账单数量趋势(Key 为分组起始日期 yyyy-MM-dd)。
|
||||
/// </summary>
|
||||
public Dictionary<string, int> CountTrend { get; init; } = [];
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 账单导出 DTO。
|
||||
/// </summary>
|
||||
public sealed record BillingExportDto
|
||||
{
|
||||
/// <summary>
|
||||
/// 账单 ID(雪花算法,序列化为字符串)。
|
||||
/// </summary>
|
||||
[JsonConverter(typeof(SnowflakeIdJsonConverter))]
|
||||
public long Id { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// 租户 ID(雪花算法,序列化为字符串)。
|
||||
/// </summary>
|
||||
[JsonConverter(typeof(SnowflakeIdJsonConverter))]
|
||||
public long TenantId { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// 租户名称。
|
||||
/// </summary>
|
||||
public string TenantName { get; init; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// 账单编号。
|
||||
/// </summary>
|
||||
public string StatementNo { get; init; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// 关联订阅 ID(仅订阅/续费账单可能有值)。
|
||||
/// </summary>
|
||||
[JsonConverter(typeof(NullableSnowflakeIdJsonConverter))]
|
||||
public long? SubscriptionId { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// 账单类型。
|
||||
/// </summary>
|
||||
public BillingType BillingType { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// 计费周期开始时间(UTC)。
|
||||
/// </summary>
|
||||
public DateTime PeriodStart { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// 计费周期结束时间(UTC)。
|
||||
/// </summary>
|
||||
public DateTime PeriodEnd { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// 应付金额。
|
||||
/// </summary>
|
||||
public decimal AmountDue { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// 折扣金额。
|
||||
/// </summary>
|
||||
public decimal DiscountAmount { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// 税费金额。
|
||||
/// </summary>
|
||||
public decimal TaxAmount { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// 总金额。
|
||||
/// </summary>
|
||||
public decimal TotalAmount { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// 已付金额。
|
||||
/// </summary>
|
||||
public decimal AmountPaid { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// 账单状态。
|
||||
/// </summary>
|
||||
public TenantBillingStatus Status { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// 币种。
|
||||
/// </summary>
|
||||
public string Currency { get; init; } = "CNY";
|
||||
|
||||
/// <summary>
|
||||
/// 到期日(UTC)。
|
||||
/// </summary>
|
||||
public DateTime DueDate { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// 备注信息。
|
||||
/// </summary>
|
||||
public string? Notes { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// 账单明细列表。
|
||||
/// </summary>
|
||||
public List<BillingLineItemDto> LineItems { get; init; } = [];
|
||||
}
|
||||
@@ -0,0 +1,104 @@
|
||||
using System.Text.Json.Serialization;
|
||||
using TakeoutSaaS.Domain.Tenants.Enums;
|
||||
using TakeoutSaaS.Shared.Abstractions.Serialization;
|
||||
|
||||
namespace TakeoutSaaS.Application.App.Billings.Dto;
|
||||
|
||||
/// <summary>
|
||||
/// 账单导出 DTO。
|
||||
/// </summary>
|
||||
public sealed record BillingExportDto
|
||||
{
|
||||
/// <summary>
|
||||
/// 账单 ID。
|
||||
/// </summary>
|
||||
[JsonConverter(typeof(SnowflakeIdJsonConverter))]
|
||||
public long Id { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// 租户 ID。
|
||||
/// </summary>
|
||||
[JsonConverter(typeof(SnowflakeIdJsonConverter))]
|
||||
public long TenantId { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// 租户名称。
|
||||
/// </summary>
|
||||
public string TenantName { get; init; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// 账单编号。
|
||||
/// </summary>
|
||||
public string StatementNo { get; init; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// 订阅 ID(可选)。
|
||||
/// </summary>
|
||||
[JsonConverter(typeof(NullableSnowflakeIdJsonConverter))]
|
||||
public long? SubscriptionId { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// 账单类型。
|
||||
/// </summary>
|
||||
public BillingType BillingType { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// 账单状态。
|
||||
/// </summary>
|
||||
public TenantBillingStatus Status { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// 计费周期开始时间(UTC)。
|
||||
/// </summary>
|
||||
public DateTime PeriodStart { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// 计费周期结束时间(UTC)。
|
||||
/// </summary>
|
||||
public DateTime PeriodEnd { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// 应付金额。
|
||||
/// </summary>
|
||||
public decimal AmountDue { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// 折扣金额。
|
||||
/// </summary>
|
||||
public decimal DiscountAmount { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// 税费金额。
|
||||
/// </summary>
|
||||
public decimal TaxAmount { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// 总金额(应付金额 - 折扣 + 税费)。
|
||||
/// </summary>
|
||||
public decimal TotalAmount { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// 已支付金额。
|
||||
/// </summary>
|
||||
public decimal AmountPaid { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// 币种。
|
||||
/// </summary>
|
||||
public string Currency { get; init; } = "CNY";
|
||||
|
||||
/// <summary>
|
||||
/// 到期日(UTC)。
|
||||
/// </summary>
|
||||
public DateTime DueDate { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// 备注。
|
||||
/// </summary>
|
||||
public string? Notes { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// 账单明细。
|
||||
/// </summary>
|
||||
public IReadOnlyList<BillingLineItemDto> LineItems { get; init; } = [];
|
||||
}
|
||||
@@ -0,0 +1,37 @@
|
||||
namespace TakeoutSaaS.Application.App.Billings.Dto;
|
||||
|
||||
/// <summary>
|
||||
/// 账单明细行项目 DTO。
|
||||
/// </summary>
|
||||
public sealed record BillingLineItemDto
|
||||
{
|
||||
/// <summary>
|
||||
/// 明细类型(如:订阅费、配额包费用、其他费用)。
|
||||
/// </summary>
|
||||
public string ItemType { get; init; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// 描述。
|
||||
/// </summary>
|
||||
public string Description { get; init; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// 数量。
|
||||
/// </summary>
|
||||
public decimal Quantity { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// 单价。
|
||||
/// </summary>
|
||||
public decimal UnitPrice { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// 金额(数量 × 单价)。
|
||||
/// </summary>
|
||||
public decimal Amount { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// 折扣率(0-1,可选)。
|
||||
/// </summary>
|
||||
public decimal? DiscountRate { get; init; }
|
||||
}
|
||||
@@ -0,0 +1,114 @@
|
||||
using System.Text.Json.Serialization;
|
||||
using TakeoutSaaS.Domain.Tenants.Enums;
|
||||
using TakeoutSaaS.Shared.Abstractions.Serialization;
|
||||
|
||||
namespace TakeoutSaaS.Application.App.Billings.Dto;
|
||||
|
||||
/// <summary>
|
||||
/// 账单列表 DTO(租户端列表展示)。
|
||||
/// </summary>
|
||||
public sealed record BillingListDto
|
||||
{
|
||||
/// <summary>
|
||||
/// 账单 ID。
|
||||
/// </summary>
|
||||
[JsonConverter(typeof(SnowflakeIdJsonConverter))]
|
||||
public long Id { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// 租户 ID。
|
||||
/// </summary>
|
||||
[JsonConverter(typeof(SnowflakeIdJsonConverter))]
|
||||
public long TenantId { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// 租户名称。
|
||||
/// </summary>
|
||||
public string TenantName { get; init; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// 订阅 ID(可选)。
|
||||
/// </summary>
|
||||
[JsonConverter(typeof(NullableSnowflakeIdJsonConverter))]
|
||||
public long? SubscriptionId { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// 账单编号。
|
||||
/// </summary>
|
||||
public string StatementNo { get; init; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// 计费周期开始时间(UTC)。
|
||||
/// </summary>
|
||||
public DateTime PeriodStart { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// 计费周期结束时间(UTC)。
|
||||
/// </summary>
|
||||
public DateTime PeriodEnd { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// 账单类型。
|
||||
/// </summary>
|
||||
public BillingType BillingType { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// 账单状态。
|
||||
/// </summary>
|
||||
public TenantBillingStatus Status { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// 应付金额。
|
||||
/// </summary>
|
||||
public decimal AmountDue { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// 已支付金额。
|
||||
/// </summary>
|
||||
public decimal AmountPaid { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// 折扣金额。
|
||||
/// </summary>
|
||||
public decimal DiscountAmount { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// 税费金额。
|
||||
/// </summary>
|
||||
public decimal TaxAmount { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// 总金额(应付金额 - 折扣 + 税费)。
|
||||
/// </summary>
|
||||
public decimal TotalAmount { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// 币种。
|
||||
/// </summary>
|
||||
public string Currency { get; init; } = "CNY";
|
||||
|
||||
/// <summary>
|
||||
/// 到期日。
|
||||
/// </summary>
|
||||
public DateTime DueDate { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// 创建时间。
|
||||
/// </summary>
|
||||
public DateTime CreatedAt { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// 更新时间。
|
||||
/// </summary>
|
||||
public DateTime? UpdatedAt { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// 是否逾期。
|
||||
/// </summary>
|
||||
public bool IsOverdue { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// 逾期天数(未逾期为 0)。
|
||||
/// </summary>
|
||||
public int OverdueDays { get; init; }
|
||||
}
|
||||
@@ -0,0 +1,91 @@
|
||||
using System.Text.Json.Serialization;
|
||||
using TakeoutSaaS.Shared.Abstractions.Serialization;
|
||||
|
||||
namespace TakeoutSaaS.Application.App.Billings.Dto;
|
||||
|
||||
/// <summary>
|
||||
/// 账单统计数据 DTO。
|
||||
/// </summary>
|
||||
public sealed record BillingStatisticsDto
|
||||
{
|
||||
/// <summary>
|
||||
/// 租户 ID(当前租户)。
|
||||
/// </summary>
|
||||
[JsonConverter(typeof(NullableSnowflakeIdJsonConverter))]
|
||||
public long? TenantId { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// 统计开始时间(UTC)。
|
||||
/// </summary>
|
||||
public DateTime StartDate { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// 统计结束时间(UTC)。
|
||||
/// </summary>
|
||||
public DateTime EndDate { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// 分组方式(Day/Week/Month)。
|
||||
/// </summary>
|
||||
public string GroupBy { get; init; } = "Day";
|
||||
|
||||
/// <summary>
|
||||
/// 总账单数量。
|
||||
/// </summary>
|
||||
public int TotalCount { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// 待支付账单数量。
|
||||
/// </summary>
|
||||
public int PendingCount { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// 已支付账单数量。
|
||||
/// </summary>
|
||||
public int PaidCount { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// 逾期账单数量。
|
||||
/// </summary>
|
||||
public int OverdueCount { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// 已取消账单数量。
|
||||
/// </summary>
|
||||
public int CancelledCount { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// 总应收金额。
|
||||
/// </summary>
|
||||
public decimal TotalAmountDue { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// 已收金额。
|
||||
/// </summary>
|
||||
public decimal TotalAmountPaid { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// 未收金额。
|
||||
/// </summary>
|
||||
public decimal TotalAmountUnpaid { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// 逾期金额。
|
||||
/// </summary>
|
||||
public decimal TotalOverdueAmount { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// 应收金额趋势(Key 为日期桶字符串)。
|
||||
/// </summary>
|
||||
public IReadOnlyDictionary<string, decimal> AmountDueTrend { get; init; } = new Dictionary<string, decimal>();
|
||||
|
||||
/// <summary>
|
||||
/// 实收金额趋势(Key 为日期桶字符串)。
|
||||
/// </summary>
|
||||
public IReadOnlyDictionary<string, decimal> AmountPaidTrend { get; init; } = new Dictionary<string, decimal>();
|
||||
|
||||
/// <summary>
|
||||
/// 数量趋势(Key 为日期桶字符串)。
|
||||
/// </summary>
|
||||
public IReadOnlyDictionary<string, int> CountTrend { get; init; } = new Dictionary<string, int>();
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
namespace TakeoutSaaS.Application.App.Billings.Dto;
|
||||
|
||||
/// <summary>
|
||||
/// 账单趋势数据点 DTO。
|
||||
/// </summary>
|
||||
public sealed record BillingTrendPointDto
|
||||
{
|
||||
/// <summary>
|
||||
/// 分组时间点(Day/Week/Month 对齐后的时间)。
|
||||
/// </summary>
|
||||
public DateTime Period { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// 账单数量。
|
||||
/// </summary>
|
||||
public int Count { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// 应收金额。
|
||||
/// </summary>
|
||||
public decimal AmountDue { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// 实收金额。
|
||||
/// </summary>
|
||||
public decimal AmountPaid { get; init; }
|
||||
}
|
||||
@@ -0,0 +1,63 @@
|
||||
using System.Text.Json.Serialization;
|
||||
using TakeoutSaaS.Domain.Tenants.Enums;
|
||||
using TakeoutSaaS.Shared.Abstractions.Serialization;
|
||||
|
||||
namespace TakeoutSaaS.Application.App.Billings.Dto;
|
||||
|
||||
/// <summary>
|
||||
/// 支付记录 DTO。
|
||||
/// </summary>
|
||||
public sealed record PaymentDto
|
||||
{
|
||||
/// <summary>
|
||||
/// 支付记录 ID(雪花算法,序列化为字符串)。
|
||||
/// </summary>
|
||||
[JsonConverter(typeof(SnowflakeIdJsonConverter))]
|
||||
public long Id { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// 账单 ID(雪花算法,序列化为字符串)。
|
||||
/// </summary>
|
||||
[JsonConverter(typeof(SnowflakeIdJsonConverter))]
|
||||
public long BillingStatementId { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// 支付金额。
|
||||
/// </summary>
|
||||
public decimal Amount { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// 支付方式。
|
||||
/// </summary>
|
||||
public TenantPaymentMethod Method { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// 支付状态。
|
||||
/// </summary>
|
||||
public TenantPaymentStatus Status { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// 交易号。
|
||||
/// </summary>
|
||||
public string? TransactionNo { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// 支付凭证 URL。
|
||||
/// </summary>
|
||||
public string? ProofUrl { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// 支付时间。
|
||||
/// </summary>
|
||||
public DateTime? PaidAt { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// 备注信息。
|
||||
/// </summary>
|
||||
public string? Notes { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// 创建时间(UTC)。
|
||||
/// </summary>
|
||||
public DateTime CreatedAt { get; init; }
|
||||
}
|
||||
@@ -0,0 +1,95 @@
|
||||
using System.Text.Json.Serialization;
|
||||
using TakeoutSaaS.Domain.Tenants.Enums;
|
||||
using TakeoutSaaS.Shared.Abstractions.Serialization;
|
||||
|
||||
namespace TakeoutSaaS.Application.App.Billings.Dto;
|
||||
|
||||
/// <summary>
|
||||
/// 支付记录 DTO(租户端)。
|
||||
/// </summary>
|
||||
public sealed record PaymentRecordDto
|
||||
{
|
||||
/// <summary>
|
||||
/// 支付记录 ID。
|
||||
/// </summary>
|
||||
[JsonConverter(typeof(SnowflakeIdJsonConverter))]
|
||||
public long Id { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// 租户 ID。
|
||||
/// </summary>
|
||||
[JsonConverter(typeof(SnowflakeIdJsonConverter))]
|
||||
public long TenantId { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// 关联的账单 ID。
|
||||
/// </summary>
|
||||
[JsonConverter(typeof(SnowflakeIdJsonConverter))]
|
||||
public long BillingId { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// 支付金额。
|
||||
/// </summary>
|
||||
public decimal Amount { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// 支付方式。
|
||||
/// </summary>
|
||||
public TenantPaymentMethod Method { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// 支付状态。
|
||||
/// </summary>
|
||||
public TenantPaymentStatus Status { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// 交易号。
|
||||
/// </summary>
|
||||
public string? TransactionNo { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// 支付凭证 URL。
|
||||
/// </summary>
|
||||
public string? ProofUrl { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// 支付时间。
|
||||
/// </summary>
|
||||
public DateTime? PaidAt { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// 是否已审核。
|
||||
/// </summary>
|
||||
public bool IsVerified { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// 审核人 ID。
|
||||
/// </summary>
|
||||
[JsonConverter(typeof(NullableSnowflakeIdJsonConverter))]
|
||||
public long? VerifiedBy { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// 审核时间。
|
||||
/// </summary>
|
||||
public DateTime? VerifiedAt { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// 退款原因。
|
||||
/// </summary>
|
||||
public string? RefundReason { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// 退款时间。
|
||||
/// </summary>
|
||||
public DateTime? RefundedAt { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// 备注信息。
|
||||
/// </summary>
|
||||
public string? Notes { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// 创建时间(UTC)。
|
||||
/// </summary>
|
||||
public DateTime CreatedAt { get; init; }
|
||||
}
|
||||
@@ -0,0 +1,110 @@
|
||||
using MediatR;
|
||||
using TakeoutSaaS.Application.App.Billings.Commands;
|
||||
using TakeoutSaaS.Domain.Tenants.Repositories;
|
||||
using TakeoutSaaS.Shared.Abstractions.Constants;
|
||||
using TakeoutSaaS.Shared.Abstractions.Exceptions;
|
||||
using TakeoutSaaS.Shared.Abstractions.Tenancy;
|
||||
|
||||
namespace TakeoutSaaS.Application.App.Billings.Handlers;
|
||||
|
||||
/// <summary>
|
||||
/// 批量更新账单状态处理器。
|
||||
/// </summary>
|
||||
public sealed class BatchUpdateStatusCommandHandler(
|
||||
ITenantBillingRepository billingRepository,
|
||||
ITenantContextAccessor tenantContextAccessor)
|
||||
: IRequestHandler<BatchUpdateStatusCommand, int>
|
||||
{
|
||||
/// <summary>
|
||||
/// 处理批量更新账单状态请求。
|
||||
/// </summary>
|
||||
/// <param name="request">批量更新状态命令。</param>
|
||||
/// <param name="cancellationToken">取消标记。</param>
|
||||
/// <returns>成功更新的账单数量。</returns>
|
||||
public async Task<int> Handle(BatchUpdateStatusCommand request, CancellationToken cancellationToken)
|
||||
{
|
||||
// 1. 参数验证
|
||||
if (request.BillingIds.Length == 0)
|
||||
{
|
||||
throw new BusinessException(ErrorCodes.BadRequest, "账单 ID 列表不能为空");
|
||||
}
|
||||
|
||||
// 2. 查询所有账单
|
||||
var billings = await billingRepository.GetByIdsAsync(request.BillingIds, cancellationToken);
|
||||
if (billings.Count == 0)
|
||||
{
|
||||
throw new BusinessException(ErrorCodes.NotFound, "未找到任何匹配的账单");
|
||||
}
|
||||
|
||||
// 3. 批量更新状态(逐租户上下文执行,避免跨租户写入)
|
||||
var now = DateTime.UtcNow;
|
||||
var currentTenantId = tenantContextAccessor.Current?.TenantId ?? 0;
|
||||
if (currentTenantId != 0 && billings.Any(x => x.TenantId != currentTenantId))
|
||||
{
|
||||
throw new BusinessException(ErrorCodes.Forbidden, "禁止跨租户批量更新账单状态");
|
||||
}
|
||||
|
||||
var updatedTotal = 0;
|
||||
var grouped = billings.GroupBy(x => x.TenantId).ToList();
|
||||
foreach (var group in grouped)
|
||||
{
|
||||
using (currentTenantId == 0 ? tenantContextAccessor.EnterTenantScope(group.Key, "billing:batch-update") : null)
|
||||
{
|
||||
var updatedCount = 0;
|
||||
foreach (var billing in group)
|
||||
{
|
||||
// 业务规则检查:某些状态转换可能不允许
|
||||
if (!CanTransitionStatus(billing.Status, request.NewStatus))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
billing.Status = request.NewStatus;
|
||||
billing.UpdatedAt = now;
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(request.Notes))
|
||||
{
|
||||
billing.Notes = string.IsNullOrWhiteSpace(billing.Notes)
|
||||
? $"[批量操作] {request.Notes}"
|
||||
: $"{billing.Notes}\n[批量操作] {request.Notes}";
|
||||
}
|
||||
|
||||
await billingRepository.UpdateAsync(billing, cancellationToken);
|
||||
updatedCount++;
|
||||
}
|
||||
|
||||
if (updatedCount > 0)
|
||||
{
|
||||
await billingRepository.SaveChangesAsync(cancellationToken);
|
||||
}
|
||||
|
||||
updatedTotal += updatedCount;
|
||||
}
|
||||
}
|
||||
|
||||
return updatedTotal;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 检查状态转换是否允许。
|
||||
/// </summary>
|
||||
private static bool CanTransitionStatus(
|
||||
TakeoutSaaS.Domain.Tenants.Enums.TenantBillingStatus currentStatus,
|
||||
TakeoutSaaS.Domain.Tenants.Enums.TenantBillingStatus newStatus)
|
||||
{
|
||||
// 已支付的账单不能改为其他状态
|
||||
if (currentStatus == TakeoutSaaS.Domain.Tenants.Enums.TenantBillingStatus.Paid
|
||||
&& newStatus != TakeoutSaaS.Domain.Tenants.Enums.TenantBillingStatus.Paid)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
// 已取消的账单不能改为其他状态
|
||||
if (currentStatus == TakeoutSaaS.Domain.Tenants.Enums.TenantBillingStatus.Cancelled)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
using MediatR;
|
||||
using TakeoutSaaS.Application.App.Billings.Commands;
|
||||
using TakeoutSaaS.Domain.Tenants.Repositories;
|
||||
using TakeoutSaaS.Shared.Abstractions.Constants;
|
||||
using TakeoutSaaS.Shared.Abstractions.Exceptions;
|
||||
|
||||
namespace TakeoutSaaS.Application.App.Billings.Handlers;
|
||||
|
||||
/// <summary>
|
||||
/// 取消账单命令处理器。
|
||||
/// </summary>
|
||||
public sealed class CancelBillingCommandHandler(
|
||||
ITenantBillingRepository billingRepository)
|
||||
: IRequestHandler<CancelBillingCommand, Unit>
|
||||
{
|
||||
/// <inheritdoc />
|
||||
public async Task<Unit> Handle(CancelBillingCommand request, CancellationToken cancellationToken)
|
||||
{
|
||||
// 1. 查询账单
|
||||
var billing = await billingRepository.FindByIdAsync(request.BillingId, cancellationToken);
|
||||
if (billing is null)
|
||||
{
|
||||
throw new BusinessException(ErrorCodes.NotFound, "账单不存在");
|
||||
}
|
||||
|
||||
// 2. 取消账单(领域规则校验在实体方法内)
|
||||
billing.Cancel(request.Reason);
|
||||
|
||||
// 3. 持久化
|
||||
await billingRepository.UpdateAsync(billing, cancellationToken);
|
||||
await billingRepository.SaveChangesAsync(cancellationToken);
|
||||
|
||||
return Unit.Value;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,99 @@
|
||||
using MediatR;
|
||||
using TakeoutSaaS.Application.App.Billings.Commands;
|
||||
using TakeoutSaaS.Application.App.Billings.Dto;
|
||||
using TakeoutSaaS.Domain.Tenants.Entities;
|
||||
using TakeoutSaaS.Domain.Tenants.Enums;
|
||||
using TakeoutSaaS.Domain.Tenants.Repositories;
|
||||
using TakeoutSaaS.Shared.Abstractions.Constants;
|
||||
using TakeoutSaaS.Shared.Abstractions.Exceptions;
|
||||
using TakeoutSaaS.Shared.Abstractions.Ids;
|
||||
using TakeoutSaaS.Shared.Abstractions.Security;
|
||||
|
||||
namespace TakeoutSaaS.Application.App.Billings.Handlers;
|
||||
|
||||
/// <summary>
|
||||
/// 一键确认收款处理器(记录支付 + 立即审核通过 + 同步更新账单已收金额/状态)。
|
||||
/// </summary>
|
||||
public sealed class ConfirmPaymentCommandHandler(
|
||||
ITenantBillingRepository billingRepository,
|
||||
ITenantPaymentRepository paymentRepository,
|
||||
IIdGenerator idGenerator,
|
||||
ICurrentUserAccessor currentUserAccessor)
|
||||
: IRequestHandler<ConfirmPaymentCommand, PaymentRecordDto>
|
||||
{
|
||||
/// <inheritdoc />
|
||||
public async Task<PaymentRecordDto> Handle(ConfirmPaymentCommand request, CancellationToken cancellationToken)
|
||||
{
|
||||
// 1. 校验操作者身份(用于写入 VerifiedBy)
|
||||
if (!currentUserAccessor.IsAuthenticated || currentUserAccessor.UserId <= 0)
|
||||
{
|
||||
throw new BusinessException(ErrorCodes.Unauthorized, "未登录或无效的操作者身份");
|
||||
}
|
||||
|
||||
// 2. 查询账单
|
||||
var billing = await billingRepository.FindByIdAsync(request.BillingId, cancellationToken);
|
||||
if (billing is null)
|
||||
{
|
||||
throw new BusinessException(ErrorCodes.NotFound, "账单不存在");
|
||||
}
|
||||
|
||||
// 3. 业务规则检查
|
||||
if (billing.Status == TenantBillingStatus.Paid)
|
||||
{
|
||||
throw new BusinessException(ErrorCodes.BusinessError, "已支付账单不允许重复收款");
|
||||
}
|
||||
|
||||
if (billing.Status == TenantBillingStatus.Cancelled)
|
||||
{
|
||||
throw new BusinessException(ErrorCodes.BusinessError, "已取消账单不允许收款");
|
||||
}
|
||||
|
||||
// 4. 金额边界:不允许超过剩余应收(与前端校验保持一致)
|
||||
var totalAmount = billing.CalculateTotalAmount();
|
||||
var remainingAmount = totalAmount - billing.AmountPaid;
|
||||
if (request.Amount > remainingAmount)
|
||||
{
|
||||
throw new BusinessException(ErrorCodes.BadRequest, "支付金额不能超过剩余应收");
|
||||
}
|
||||
|
||||
// 5. 幂等校验:交易号唯一
|
||||
if (!string.IsNullOrWhiteSpace(request.TransactionNo))
|
||||
{
|
||||
var exists = await paymentRepository.GetByTransactionNoAsync(request.TransactionNo.Trim(), cancellationToken);
|
||||
if (exists is not null)
|
||||
{
|
||||
throw new BusinessException(ErrorCodes.Conflict, "交易号已存在,疑似重复提交");
|
||||
}
|
||||
}
|
||||
|
||||
// 6. 构建支付记录并立即审核通过
|
||||
var now = DateTime.UtcNow;
|
||||
var payment = new TenantPayment
|
||||
{
|
||||
Id = idGenerator.NextId(),
|
||||
TenantId = billing.TenantId,
|
||||
BillingStatementId = request.BillingId,
|
||||
Amount = request.Amount,
|
||||
Method = request.Method,
|
||||
Status = TenantPaymentStatus.Pending,
|
||||
TransactionNo = string.IsNullOrWhiteSpace(request.TransactionNo) ? null : request.TransactionNo.Trim(),
|
||||
ProofUrl = request.ProofUrl,
|
||||
PaidAt = now,
|
||||
Notes = request.Notes
|
||||
};
|
||||
|
||||
payment.Verify(currentUserAccessor.UserId);
|
||||
|
||||
// 7. 同步更新账单已收金额/状态(支持分次收款)
|
||||
billing.MarkAsPaid(payment.Amount, payment.TransactionNo ?? string.Empty);
|
||||
|
||||
// 8. 持久化变更(同一 DbContext 下单次 SaveChanges 可提交两张表)
|
||||
await paymentRepository.AddAsync(payment, cancellationToken);
|
||||
await billingRepository.UpdateAsync(billing, cancellationToken);
|
||||
await paymentRepository.SaveChangesAsync(cancellationToken);
|
||||
|
||||
// 9. 返回 DTO
|
||||
return payment.ToPaymentRecordDto();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,61 @@
|
||||
using MediatR;
|
||||
using TakeoutSaaS.Application.App.Billings.Commands;
|
||||
using TakeoutSaaS.Application.App.Billings.Dto;
|
||||
using TakeoutSaaS.Domain.Tenants.Entities;
|
||||
using TakeoutSaaS.Domain.Tenants.Enums;
|
||||
using TakeoutSaaS.Domain.Tenants.Repositories;
|
||||
using TakeoutSaaS.Shared.Abstractions.Constants;
|
||||
using TakeoutSaaS.Shared.Abstractions.Exceptions;
|
||||
using TakeoutSaaS.Shared.Abstractions.Ids;
|
||||
|
||||
namespace TakeoutSaaS.Application.App.Billings.Handlers;
|
||||
|
||||
/// <summary>
|
||||
/// 创建账单处理器。
|
||||
/// </summary>
|
||||
public sealed class CreateBillCommandHandler(
|
||||
ITenantBillingRepository billingRepository,
|
||||
ITenantRepository tenantRepository,
|
||||
IIdGenerator idGenerator)
|
||||
: IRequestHandler<CreateBillCommand, BillDto>
|
||||
{
|
||||
/// <summary>
|
||||
/// 处理创建账单请求。
|
||||
/// </summary>
|
||||
/// <param name="request">创建命令。</param>
|
||||
/// <param name="cancellationToken">取消标记。</param>
|
||||
/// <returns>账单 DTO。</returns>
|
||||
public async Task<BillDto> Handle(CreateBillCommand request, CancellationToken cancellationToken)
|
||||
{
|
||||
// 1. 验证租户存在
|
||||
var tenant = await tenantRepository.FindByIdAsync(request.TenantId, cancellationToken);
|
||||
if (tenant is null)
|
||||
{
|
||||
throw new BusinessException(ErrorCodes.NotFound, "租户不存在");
|
||||
}
|
||||
|
||||
// 2. 生成账单编号
|
||||
var statementNo = $"BILL-{DateTime.UtcNow:yyyyMMdd}-{idGenerator.NextId()}";
|
||||
|
||||
// 3. 构建账单实体
|
||||
var bill = new TenantBillingStatement
|
||||
{
|
||||
TenantId = request.TenantId,
|
||||
StatementNo = statementNo,
|
||||
PeriodStart = DateTime.UtcNow,
|
||||
PeriodEnd = DateTime.UtcNow,
|
||||
AmountDue = request.AmountDue,
|
||||
AmountPaid = 0,
|
||||
Status = TenantBillingStatus.Pending,
|
||||
DueDate = request.DueDate,
|
||||
LineItemsJson = request.Notes
|
||||
};
|
||||
|
||||
// 4. 持久化账单
|
||||
await billingRepository.AddAsync(bill, cancellationToken);
|
||||
await billingRepository.SaveChangesAsync(cancellationToken);
|
||||
|
||||
// 5. 返回 DTO
|
||||
return bill.ToDto(tenant.Name);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,65 @@
|
||||
using MediatR;
|
||||
using System.Text.Json;
|
||||
using TakeoutSaaS.Application.App.Billings.Commands;
|
||||
using TakeoutSaaS.Application.App.Billings.Dto;
|
||||
using TakeoutSaaS.Domain.Tenants.Entities;
|
||||
using TakeoutSaaS.Domain.Tenants.Enums;
|
||||
using TakeoutSaaS.Domain.Tenants.Repositories;
|
||||
using TakeoutSaaS.Shared.Abstractions.Constants;
|
||||
using TakeoutSaaS.Shared.Abstractions.Exceptions;
|
||||
using TakeoutSaaS.Shared.Abstractions.Ids;
|
||||
|
||||
namespace TakeoutSaaS.Application.App.Billings.Handlers;
|
||||
|
||||
/// <summary>
|
||||
/// 创建账单命令处理器。
|
||||
/// </summary>
|
||||
public sealed class CreateBillingCommandHandler(
|
||||
ITenantRepository tenantRepository,
|
||||
ITenantBillingRepository billingRepository,
|
||||
IIdGenerator idGenerator)
|
||||
: IRequestHandler<CreateBillingCommand, BillingDetailDto>
|
||||
{
|
||||
/// <inheritdoc />
|
||||
public async Task<BillingDetailDto> Handle(CreateBillingCommand request, CancellationToken cancellationToken)
|
||||
{
|
||||
// 1. 校验租户存在
|
||||
var tenant = await tenantRepository.FindByIdAsync(request.TenantId, cancellationToken);
|
||||
if (tenant is null)
|
||||
{
|
||||
throw new BusinessException(ErrorCodes.NotFound, "租户不存在");
|
||||
}
|
||||
|
||||
// 2. 构建账单实体
|
||||
var now = DateTime.UtcNow;
|
||||
var statementNo = $"BIL-{now:yyyyMMdd}-{idGenerator.NextId()}";
|
||||
var lineItemsJson = JsonSerializer.Serialize(request.LineItems);
|
||||
|
||||
var billing = new TenantBillingStatement
|
||||
{
|
||||
TenantId = request.TenantId,
|
||||
StatementNo = statementNo,
|
||||
BillingType = request.BillingType,
|
||||
SubscriptionId = null,
|
||||
PeriodStart = now,
|
||||
PeriodEnd = now,
|
||||
AmountDue = request.AmountDue,
|
||||
DiscountAmount = 0m,
|
||||
TaxAmount = 0m,
|
||||
AmountPaid = 0m,
|
||||
Currency = "CNY",
|
||||
Status = TenantBillingStatus.Pending,
|
||||
DueDate = request.DueDate,
|
||||
LineItemsJson = lineItemsJson,
|
||||
Notes = request.Notes
|
||||
};
|
||||
|
||||
// 3. 持久化账单
|
||||
await billingRepository.AddAsync(billing, cancellationToken);
|
||||
await billingRepository.SaveChangesAsync(cancellationToken);
|
||||
|
||||
// 4. 返回详情 DTO
|
||||
return billing.ToBillingDetailDto([], tenant.Name);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,44 @@
|
||||
using MediatR;
|
||||
using TakeoutSaaS.Application.App.Billings.Queries;
|
||||
using TakeoutSaaS.Domain.Tenants.Repositories;
|
||||
using TakeoutSaaS.Domain.Tenants.Services;
|
||||
using TakeoutSaaS.Shared.Abstractions.Constants;
|
||||
using TakeoutSaaS.Shared.Abstractions.Exceptions;
|
||||
|
||||
namespace TakeoutSaaS.Application.App.Billings.Handlers;
|
||||
|
||||
/// <summary>
|
||||
/// 导出账单处理器。
|
||||
/// </summary>
|
||||
public sealed class ExportBillingsQueryHandler(
|
||||
ITenantBillingRepository billingRepository,
|
||||
IBillingExportService exportService)
|
||||
: IRequestHandler<ExportBillingsQuery, byte[]>
|
||||
{
|
||||
/// <inheritdoc />
|
||||
public async Task<byte[]> Handle(ExportBillingsQuery request, CancellationToken cancellationToken)
|
||||
{
|
||||
// 1. 参数验证
|
||||
if (request.BillingIds.Length == 0)
|
||||
{
|
||||
throw new BusinessException(ErrorCodes.BadRequest, "账单 ID 列表不能为空");
|
||||
}
|
||||
|
||||
// 2. 查询账单数据
|
||||
var billings = await billingRepository.GetByIdsAsync(request.BillingIds, cancellationToken);
|
||||
if (billings.Count == 0)
|
||||
{
|
||||
throw new BusinessException(ErrorCodes.NotFound, "未找到任何匹配的账单");
|
||||
}
|
||||
|
||||
// 3. 根据格式导出
|
||||
var format = (request.Format ?? string.Empty).Trim().ToLowerInvariant();
|
||||
return format switch
|
||||
{
|
||||
"excel" or "xlsx" => await exportService.ExportToExcelAsync(billings, cancellationToken),
|
||||
"pdf" => await exportService.ExportToPdfAsync(billings, cancellationToken),
|
||||
"csv" => await exportService.ExportToCsvAsync(billings, cancellationToken),
|
||||
_ => throw new BusinessException(ErrorCodes.BadRequest, $"不支持的导出格式: {request.Format}")
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,102 @@
|
||||
using MediatR;
|
||||
using System.Text.Json;
|
||||
using TakeoutSaaS.Application.App.Billings.Commands;
|
||||
using TakeoutSaaS.Application.App.Billings.Dto;
|
||||
using TakeoutSaaS.Domain.Tenants.Entities;
|
||||
using TakeoutSaaS.Domain.Tenants.Enums;
|
||||
using TakeoutSaaS.Domain.Tenants.Repositories;
|
||||
using TakeoutSaaS.Shared.Abstractions.Constants;
|
||||
using TakeoutSaaS.Shared.Abstractions.Exceptions;
|
||||
using TakeoutSaaS.Shared.Abstractions.Ids;
|
||||
|
||||
namespace TakeoutSaaS.Application.App.Billings.Handlers;
|
||||
|
||||
/// <summary>
|
||||
/// 生成订阅账单命令处理器。
|
||||
/// </summary>
|
||||
public sealed class GenerateSubscriptionBillingCommandHandler(
|
||||
ISubscriptionRepository subscriptionRepository,
|
||||
ITenantBillingRepository billingRepository,
|
||||
IIdGenerator idGenerator)
|
||||
: IRequestHandler<GenerateSubscriptionBillingCommand, BillingDetailDto>
|
||||
{
|
||||
/// <inheritdoc />
|
||||
public async Task<BillingDetailDto> Handle(GenerateSubscriptionBillingCommand request, CancellationToken cancellationToken)
|
||||
{
|
||||
// 1. 查询订阅详情(含租户/套餐信息)
|
||||
var detail = await subscriptionRepository.GetDetailAsync(request.SubscriptionId, cancellationToken);
|
||||
if (detail is null)
|
||||
{
|
||||
throw new BusinessException(ErrorCodes.NotFound, "订阅不存在");
|
||||
}
|
||||
|
||||
// 2. 校验套餐价格信息
|
||||
var subscription = detail.Subscription;
|
||||
var package = detail.Package;
|
||||
if (package is null)
|
||||
{
|
||||
throw new BusinessException(ErrorCodes.BusinessError, "订阅未关联有效套餐,无法生成账单");
|
||||
}
|
||||
|
||||
// 3. 按订阅周期选择价格(简化规则:优先按年/按月)
|
||||
var billingPeriodDays = (subscription.EffectiveTo - subscription.EffectiveFrom).TotalDays;
|
||||
var amountDue = billingPeriodDays >= 300
|
||||
? package.YearlyPrice
|
||||
: package.MonthlyPrice;
|
||||
|
||||
if (!amountDue.HasValue)
|
||||
{
|
||||
throw new BusinessException(ErrorCodes.BusinessError, "套餐价格未配置,无法生成账单");
|
||||
}
|
||||
|
||||
// 4. 幂等校验:同一周期开始时间仅允许存在一张未取消账单
|
||||
var exists = await billingRepository.ExistsNotCancelledByPeriodStartAsync(subscription.TenantId, subscription.EffectiveFrom, cancellationToken);
|
||||
if (exists)
|
||||
{
|
||||
throw new BusinessException(ErrorCodes.Conflict, "该订阅周期的账单已存在");
|
||||
}
|
||||
|
||||
// 5. 构建账单实体
|
||||
var now = DateTime.UtcNow;
|
||||
var statementNo = $"BIL-{now:yyyyMMdd}-{idGenerator.NextId()}";
|
||||
var lineItems = new List<BillingLineItemDto>
|
||||
{
|
||||
new()
|
||||
{
|
||||
ItemType = "Subscription",
|
||||
Description = $"套餐 {package.Name} 订阅费用",
|
||||
Quantity = 1,
|
||||
UnitPrice = amountDue.Value,
|
||||
Amount = amountDue.Value,
|
||||
DiscountRate = null
|
||||
}
|
||||
};
|
||||
|
||||
var billing = new TenantBillingStatement
|
||||
{
|
||||
TenantId = subscription.TenantId,
|
||||
StatementNo = statementNo,
|
||||
BillingType = BillingType.Subscription,
|
||||
SubscriptionId = subscription.Id,
|
||||
PeriodStart = subscription.EffectiveFrom,
|
||||
PeriodEnd = subscription.EffectiveTo,
|
||||
AmountDue = amountDue.Value,
|
||||
DiscountAmount = 0m,
|
||||
TaxAmount = 0m,
|
||||
AmountPaid = 0m,
|
||||
Currency = "CNY",
|
||||
Status = TenantBillingStatus.Pending,
|
||||
DueDate = now.AddDays(7),
|
||||
LineItemsJson = JsonSerializer.Serialize(lineItems),
|
||||
Notes = subscription.Notes
|
||||
};
|
||||
|
||||
// 6. 持久化账单
|
||||
await billingRepository.AddAsync(billing, cancellationToken);
|
||||
await billingRepository.SaveChangesAsync(cancellationToken);
|
||||
|
||||
// 7. 返回详情 DTO
|
||||
return billing.ToBillingDetailDto([], detail.TenantName);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,41 @@
|
||||
using MediatR;
|
||||
using TakeoutSaaS.Application.App.Billings.Dto;
|
||||
using TakeoutSaaS.Application.App.Billings.Queries;
|
||||
using TakeoutSaaS.Domain.Tenants.Repositories;
|
||||
|
||||
namespace TakeoutSaaS.Application.App.Billings.Handlers;
|
||||
|
||||
/// <summary>
|
||||
/// 获取账单详情查询处理器。
|
||||
/// </summary>
|
||||
public sealed class GetBillDetailQueryHandler(
|
||||
ITenantBillingRepository billingRepository,
|
||||
ITenantPaymentRepository paymentRepository,
|
||||
ITenantRepository tenantRepository)
|
||||
: IRequestHandler<GetBillDetailQuery, BillDetailDto?>
|
||||
{
|
||||
/// <summary>
|
||||
/// 处理获取账单详情请求。
|
||||
/// </summary>
|
||||
/// <param name="request">查询请求。</param>
|
||||
/// <param name="cancellationToken">取消标记。</param>
|
||||
/// <returns>账单详情或 null。</returns>
|
||||
public async Task<BillDetailDto?> Handle(GetBillDetailQuery request, CancellationToken cancellationToken)
|
||||
{
|
||||
// 1. 查询账单
|
||||
var bill = await billingRepository.FindByIdAsync(request.BillId, cancellationToken);
|
||||
if (bill is null)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
// 2. 查询支付记录
|
||||
var payments = await paymentRepository.GetByBillingIdAsync(request.BillId, cancellationToken);
|
||||
|
||||
// 3. 查询租户名称
|
||||
var tenant = await tenantRepository.FindByIdAsync(bill.TenantId, cancellationToken);
|
||||
|
||||
// 4. 返回详情 DTO
|
||||
return bill.ToDetailDto(payments.ToList(), tenant?.Name);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,55 @@
|
||||
using MediatR;
|
||||
using TakeoutSaaS.Application.App.Billings.Dto;
|
||||
using TakeoutSaaS.Application.App.Billings.Queries;
|
||||
using TakeoutSaaS.Domain.Tenants.Repositories;
|
||||
using TakeoutSaaS.Shared.Abstractions.Results;
|
||||
|
||||
namespace TakeoutSaaS.Application.App.Billings.Handlers;
|
||||
|
||||
/// <summary>
|
||||
/// 获取账单列表查询处理器。
|
||||
/// </summary>
|
||||
public sealed class GetBillListQueryHandler(
|
||||
ITenantBillingRepository billingRepository,
|
||||
ITenantRepository tenantRepository)
|
||||
: IRequestHandler<GetBillListQuery, PagedResult<BillDto>>
|
||||
{
|
||||
/// <summary>
|
||||
/// 处理获取账单列表请求。
|
||||
/// </summary>
|
||||
/// <param name="request">查询请求。</param>
|
||||
/// <param name="cancellationToken">取消标记。</param>
|
||||
/// <returns>分页账单列表。</returns>
|
||||
public async Task<PagedResult<BillDto>> Handle(GetBillListQuery request, CancellationToken cancellationToken)
|
||||
{
|
||||
// 1. 分页查询账单
|
||||
var (bills, total) = await billingRepository.SearchPagedAsync(
|
||||
request.TenantId,
|
||||
request.Status,
|
||||
request.StartDate,
|
||||
request.EndDate,
|
||||
null,
|
||||
null,
|
||||
request.Keyword,
|
||||
request.PageNumber,
|
||||
request.PageSize,
|
||||
cancellationToken);
|
||||
|
||||
// 2. 无数据直接返回
|
||||
if (bills.Count == 0)
|
||||
{
|
||||
return new PagedResult<BillDto>([], request.PageNumber, request.PageSize, total);
|
||||
}
|
||||
|
||||
// 3. 批量查询租户信息
|
||||
var tenantIds = bills.Select(b => b.TenantId).Distinct().ToArray();
|
||||
var tenants = await tenantRepository.FindByIdsAsync(tenantIds, cancellationToken);
|
||||
var tenantDict = tenants.ToDictionary(t => t.Id, t => t.Name);
|
||||
|
||||
// 4. 映射 DTO
|
||||
var result = bills.Select(b => b.ToDto(tenantDict.GetValueOrDefault(b.TenantId))).ToList();
|
||||
|
||||
// 5. 返回分页结果
|
||||
return new PagedResult<BillDto>(result, request.PageNumber, request.PageSize, total);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,236 @@
|
||||
using MediatR;
|
||||
using System.Data;
|
||||
using System.Data.Common;
|
||||
using System.Text.Json;
|
||||
using TakeoutSaaS.Application.App.Billings.Dto;
|
||||
using TakeoutSaaS.Application.App.Billings.Queries;
|
||||
using TakeoutSaaS.Domain.Tenants.Enums;
|
||||
using TakeoutSaaS.Shared.Abstractions.Constants;
|
||||
using TakeoutSaaS.Shared.Abstractions.Data;
|
||||
using TakeoutSaaS.Shared.Abstractions.Exceptions;
|
||||
|
||||
namespace TakeoutSaaS.Application.App.Billings.Handlers;
|
||||
|
||||
/// <summary>
|
||||
/// 查询账单详情处理器。
|
||||
/// </summary>
|
||||
public sealed class GetBillingDetailQueryHandler(
|
||||
IDapperExecutor dapperExecutor)
|
||||
: IRequestHandler<GetBillingDetailQuery, BillingDetailDto>
|
||||
{
|
||||
/// <summary>
|
||||
/// 处理查询账单详情请求。
|
||||
/// </summary>
|
||||
/// <param name="request">查询命令。</param>
|
||||
/// <param name="cancellationToken">取消标记。</param>
|
||||
/// <returns>账单详情 DTO。</returns>
|
||||
public async Task<BillingDetailDto> Handle(GetBillingDetailQuery request, CancellationToken cancellationToken)
|
||||
{
|
||||
// 1. 查询账单 + 支付记录(同一连接,避免多次往返)
|
||||
return await dapperExecutor.QueryAsync(
|
||||
DatabaseConstants.AppDataSource,
|
||||
DatabaseConnectionRole.Read,
|
||||
async (connection, token) =>
|
||||
{
|
||||
// 1.1 查询账单
|
||||
await using var billCommand = CreateCommand(
|
||||
connection,
|
||||
BuildBillingSql(),
|
||||
[
|
||||
("billingId", request.BillingId)
|
||||
]);
|
||||
|
||||
await using var billReader = await billCommand.ExecuteReaderAsync(token);
|
||||
if (!await billReader.ReadAsync(token))
|
||||
{
|
||||
throw new BusinessException(ErrorCodes.NotFound, "账单不存在");
|
||||
}
|
||||
|
||||
// 1.2 读取账单行数据到内存(释放 Reader,避免同连接并发执行命令)
|
||||
var billingId = billReader.GetInt64(0);
|
||||
var tenantId = billReader.GetInt64(1);
|
||||
var tenantName = billReader.IsDBNull(2) ? string.Empty : billReader.GetString(2);
|
||||
long? subscriptionId = billReader.IsDBNull(3) ? null : billReader.GetInt64(3);
|
||||
var statementNo = billReader.GetString(4);
|
||||
var billingType = (BillingType)billReader.GetInt32(5);
|
||||
var status = (TenantBillingStatus)billReader.GetInt32(6);
|
||||
var periodStart = billReader.GetDateTime(7);
|
||||
var periodEnd = billReader.GetDateTime(8);
|
||||
var amountDue = billReader.GetDecimal(9);
|
||||
var discountAmount = billReader.GetDecimal(10);
|
||||
var taxAmount = billReader.GetDecimal(11);
|
||||
var amountPaid = billReader.GetDecimal(12);
|
||||
var currency = billReader.IsDBNull(13) ? "CNY" : billReader.GetString(13);
|
||||
var dueDate = billReader.GetDateTime(14);
|
||||
DateTime? reminderSentAt = billReader.IsDBNull(15) ? null : billReader.GetDateTime(15);
|
||||
DateTime? overdueNotifiedAt = billReader.IsDBNull(16) ? null : billReader.GetDateTime(16);
|
||||
var notes = billReader.IsDBNull(17) ? null : billReader.GetString(17);
|
||||
var lineItemsJson = billReader.IsDBNull(18) ? null : billReader.GetString(18);
|
||||
var createdAt = billReader.GetDateTime(19);
|
||||
long? createdBy = billReader.IsDBNull(20) ? null : billReader.GetInt64(20);
|
||||
DateTime? updatedAt = billReader.IsDBNull(21) ? null : billReader.GetDateTime(21);
|
||||
long? updatedBy = billReader.IsDBNull(22) ? null : billReader.GetInt64(22);
|
||||
|
||||
// 1.3 主动释放账单 Reader,确保后续查询不会触发 Npgsql 并发命令异常
|
||||
await billReader.DisposeAsync();
|
||||
|
||||
// 1.4 反序列化账单明细
|
||||
var lineItems = new List<BillingLineItemDto>();
|
||||
if (!string.IsNullOrWhiteSpace(lineItemsJson))
|
||||
{
|
||||
try
|
||||
{
|
||||
lineItems = JsonSerializer.Deserialize<List<BillingLineItemDto>>(lineItemsJson) ?? [];
|
||||
}
|
||||
catch
|
||||
{
|
||||
lineItems = [];
|
||||
}
|
||||
}
|
||||
|
||||
// 1.5 查询支付记录
|
||||
var payments = new List<PaymentRecordDto>();
|
||||
await using var paymentCommand = CreateCommand(
|
||||
connection,
|
||||
BuildPaymentsSql(),
|
||||
[
|
||||
("billingId", request.BillingId)
|
||||
]);
|
||||
|
||||
await using var paymentReader = await paymentCommand.ExecuteReaderAsync(token);
|
||||
while (await paymentReader.ReadAsync(token))
|
||||
{
|
||||
payments.Add(new PaymentRecordDto
|
||||
{
|
||||
Id = paymentReader.GetInt64(0),
|
||||
TenantId = paymentReader.GetInt64(1),
|
||||
BillingId = paymentReader.GetInt64(2),
|
||||
Amount = paymentReader.GetDecimal(3),
|
||||
Method = (TenantPaymentMethod)paymentReader.GetInt32(4),
|
||||
Status = (TenantPaymentStatus)paymentReader.GetInt32(5),
|
||||
TransactionNo = paymentReader.IsDBNull(6) ? null : paymentReader.GetString(6),
|
||||
ProofUrl = paymentReader.IsDBNull(7) ? null : paymentReader.GetString(7),
|
||||
Notes = paymentReader.IsDBNull(8) ? null : paymentReader.GetString(8),
|
||||
VerifiedBy = paymentReader.IsDBNull(9) ? null : paymentReader.GetInt64(9),
|
||||
VerifiedAt = paymentReader.IsDBNull(10) ? null : paymentReader.GetDateTime(10),
|
||||
RefundReason = paymentReader.IsDBNull(11) ? null : paymentReader.GetString(11),
|
||||
RefundedAt = paymentReader.IsDBNull(12) ? null : paymentReader.GetDateTime(12),
|
||||
PaidAt = paymentReader.IsDBNull(13) ? null : paymentReader.GetDateTime(13),
|
||||
IsVerified = !paymentReader.IsDBNull(10),
|
||||
CreatedAt = paymentReader.GetDateTime(14)
|
||||
});
|
||||
}
|
||||
|
||||
// 1.6 组装详情 DTO
|
||||
var totalAmount = amountDue - discountAmount + taxAmount;
|
||||
|
||||
return new BillingDetailDto
|
||||
{
|
||||
Id = billingId,
|
||||
TenantId = tenantId,
|
||||
TenantName = tenantName,
|
||||
SubscriptionId = subscriptionId,
|
||||
StatementNo = statementNo,
|
||||
BillingType = billingType,
|
||||
Status = status,
|
||||
PeriodStart = periodStart,
|
||||
PeriodEnd = periodEnd,
|
||||
AmountDue = amountDue,
|
||||
DiscountAmount = discountAmount,
|
||||
TaxAmount = taxAmount,
|
||||
TotalAmount = totalAmount,
|
||||
AmountPaid = amountPaid,
|
||||
Currency = currency,
|
||||
DueDate = dueDate,
|
||||
ReminderSentAt = reminderSentAt,
|
||||
OverdueNotifiedAt = overdueNotifiedAt,
|
||||
LineItemsJson = lineItemsJson,
|
||||
LineItems = lineItems,
|
||||
Payments = payments,
|
||||
Notes = notes,
|
||||
CreatedAt = createdAt,
|
||||
CreatedBy = createdBy,
|
||||
UpdatedAt = updatedAt,
|
||||
UpdatedBy = updatedBy
|
||||
};
|
||||
},
|
||||
cancellationToken);
|
||||
}
|
||||
|
||||
private static string BuildBillingSql()
|
||||
{
|
||||
return """
|
||||
select
|
||||
b."Id",
|
||||
b."TenantId",
|
||||
t."Name" as "TenantName",
|
||||
b."SubscriptionId",
|
||||
b."StatementNo",
|
||||
b."BillingType",
|
||||
b."Status",
|
||||
b."PeriodStart",
|
||||
b."PeriodEnd",
|
||||
b."AmountDue",
|
||||
b."DiscountAmount",
|
||||
b."TaxAmount",
|
||||
b."AmountPaid",
|
||||
b."Currency",
|
||||
b."DueDate",
|
||||
b."ReminderSentAt",
|
||||
b."OverdueNotifiedAt",
|
||||
b."Notes",
|
||||
b."LineItemsJson",
|
||||
b."CreatedAt",
|
||||
b."CreatedBy",
|
||||
b."UpdatedAt",
|
||||
b."UpdatedBy"
|
||||
from public.tenant_billing_statements b
|
||||
join public.tenants t on t."Id" = b."TenantId" and t."DeletedAt" is null
|
||||
where b."DeletedAt" is null
|
||||
and b."Id" = @billingId
|
||||
limit 1;
|
||||
""";
|
||||
}
|
||||
|
||||
private static string BuildPaymentsSql()
|
||||
{
|
||||
return """
|
||||
select
|
||||
p."Id",
|
||||
p."TenantId",
|
||||
p."BillingStatementId",
|
||||
p."Amount",
|
||||
p."Method",
|
||||
p."Status",
|
||||
p."TransactionNo",
|
||||
p."ProofUrl",
|
||||
p."Notes",
|
||||
p."VerifiedBy",
|
||||
p."VerifiedAt",
|
||||
p."RefundReason",
|
||||
p."RefundedAt",
|
||||
p."PaidAt",
|
||||
p."CreatedAt"
|
||||
from public.tenant_payments p
|
||||
where p."DeletedAt" is null
|
||||
and p."BillingStatementId" = @billingId
|
||||
order by p."CreatedAt" desc;
|
||||
""";
|
||||
}
|
||||
|
||||
private static DbCommand CreateCommand(IDbConnection connection, string sql, (string Name, object? Value)[] parameters)
|
||||
{
|
||||
var command = connection.CreateCommand();
|
||||
command.CommandText = sql;
|
||||
|
||||
foreach (var (name, value) in parameters)
|
||||
{
|
||||
var p = command.CreateParameter();
|
||||
p.ParameterName = name;
|
||||
p.Value = value ?? DBNull.Value;
|
||||
command.Parameters.Add(p);
|
||||
}
|
||||
|
||||
return (DbCommand)command;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,250 @@
|
||||
using MediatR;
|
||||
using System.Data;
|
||||
using System.Data.Common;
|
||||
using TakeoutSaaS.Application.App.Billings.Dto;
|
||||
using TakeoutSaaS.Application.App.Billings.Queries;
|
||||
using TakeoutSaaS.Domain.Tenants.Enums;
|
||||
using TakeoutSaaS.Shared.Abstractions.Constants;
|
||||
using TakeoutSaaS.Shared.Abstractions.Data;
|
||||
using TakeoutSaaS.Shared.Abstractions.Exceptions;
|
||||
using TakeoutSaaS.Shared.Abstractions.Results;
|
||||
using TakeoutSaaS.Shared.Abstractions.Tenancy;
|
||||
|
||||
namespace TakeoutSaaS.Application.App.Billings.Handlers;
|
||||
|
||||
/// <summary>
|
||||
/// 分页查询账单列表处理器。
|
||||
/// </summary>
|
||||
public sealed class GetBillingListQueryHandler(
|
||||
IDapperExecutor dapperExecutor,
|
||||
ITenantProvider tenantProvider)
|
||||
: IRequestHandler<GetBillingListQuery, PagedResult<BillingListDto>>
|
||||
{
|
||||
/// <summary>
|
||||
/// 处理分页查询账单列表请求。
|
||||
/// </summary>
|
||||
/// <param name="request">查询命令。</param>
|
||||
/// <param name="cancellationToken">取消标记。</param>
|
||||
/// <returns>分页账单列表 DTO。</returns>
|
||||
public async Task<PagedResult<BillingListDto>> Handle(GetBillingListQuery request, CancellationToken cancellationToken)
|
||||
{
|
||||
// 1. 校验租户上下文(租户端禁止跨租户)
|
||||
var currentTenantId = tenantProvider.GetCurrentTenantId();
|
||||
if (currentTenantId <= 0)
|
||||
{
|
||||
throw new BusinessException(ErrorCodes.BadRequest, "缺少租户标识");
|
||||
}
|
||||
|
||||
// 2. (空行后) 禁止跨租户查询
|
||||
if (request.TenantId.HasValue && request.TenantId.Value != currentTenantId)
|
||||
{
|
||||
throw new BusinessException(ErrorCodes.Forbidden, "禁止跨租户查询账单");
|
||||
}
|
||||
var tenantId = currentTenantId;
|
||||
|
||||
// 3. (空行后) 参数规范化
|
||||
var page = request.PageNumber <= 0 ? 1 : request.PageNumber;
|
||||
var pageSize = request.PageSize is <= 0 or > 200 ? 20 : request.PageSize;
|
||||
var keyword = string.IsNullOrWhiteSpace(request.Keyword) ? null : request.Keyword.Trim();
|
||||
var minAmount = request.MinAmount;
|
||||
var maxAmount = request.MaxAmount;
|
||||
var offset = (page - 1) * pageSize;
|
||||
|
||||
// 1.1 金额区间规范化(避免 min > max 导致结果为空)
|
||||
if (minAmount.HasValue && maxAmount.HasValue && minAmount.Value > maxAmount.Value)
|
||||
{
|
||||
(minAmount, maxAmount) = (maxAmount, minAmount);
|
||||
}
|
||||
|
||||
// 2. 排序白名单(防 SQL 注入)
|
||||
var orderBy = request.SortBy?.Trim() switch
|
||||
{
|
||||
"DueDate" => "b.\"DueDate\"",
|
||||
"AmountDue" => "b.\"AmountDue\"",
|
||||
"PeriodStart" => "b.\"PeriodStart\"",
|
||||
"PeriodEnd" => "b.\"PeriodEnd\"",
|
||||
"CreatedAt" => "b.\"CreatedAt\"",
|
||||
_ => "b.\"CreatedAt\""
|
||||
};
|
||||
|
||||
// 3. 查询总数 + 列表
|
||||
return await dapperExecutor.QueryAsync(
|
||||
DatabaseConstants.AppDataSource,
|
||||
DatabaseConnectionRole.Read,
|
||||
async (connection, token) =>
|
||||
{
|
||||
// 3.1 统计总数
|
||||
var total = await ExecuteScalarIntAsync(
|
||||
connection,
|
||||
BuildCountSql(),
|
||||
[
|
||||
("tenantId", tenantId),
|
||||
("status", request.Status.HasValue ? (int)request.Status.Value : null),
|
||||
("billingType", request.BillingType.HasValue ? (int)request.BillingType.Value : null),
|
||||
("startDate", request.StartDate),
|
||||
("endDate", request.EndDate),
|
||||
("minAmount", minAmount),
|
||||
("maxAmount", maxAmount),
|
||||
("keyword", keyword)
|
||||
],
|
||||
token);
|
||||
|
||||
// 3.2 查询列表
|
||||
var listSql = BuildListSql(orderBy, request.SortDesc);
|
||||
await using var listCommand = CreateCommand(
|
||||
connection,
|
||||
listSql,
|
||||
[
|
||||
("tenantId", tenantId),
|
||||
("status", request.Status.HasValue ? (int)request.Status.Value : null),
|
||||
("billingType", request.BillingType.HasValue ? (int)request.BillingType.Value : null),
|
||||
("startDate", request.StartDate),
|
||||
("endDate", request.EndDate),
|
||||
("minAmount", minAmount),
|
||||
("maxAmount", maxAmount),
|
||||
("keyword", keyword),
|
||||
("offset", offset),
|
||||
("limit", pageSize)
|
||||
]);
|
||||
|
||||
await using var reader = await listCommand.ExecuteReaderAsync(token);
|
||||
var now = DateTime.UtcNow;
|
||||
var items = new List<BillingListDto>();
|
||||
while (await reader.ReadAsync(token))
|
||||
{
|
||||
var dueDate = reader.GetDateTime(13);
|
||||
var status = (TenantBillingStatus)reader.GetInt32(12);
|
||||
var amountDue = reader.GetDecimal(8);
|
||||
var discountAmount = reader.GetDecimal(9);
|
||||
var taxAmount = reader.GetDecimal(10);
|
||||
var totalAmount = amountDue - discountAmount + taxAmount;
|
||||
|
||||
// 3.2.1 逾期辅助字段
|
||||
var isOverdue = status is TenantBillingStatus.Overdue
|
||||
|| (status is TenantBillingStatus.Pending && dueDate < now);
|
||||
var overdueDays = dueDate < now ? (int)(now - dueDate).TotalDays : 0;
|
||||
|
||||
items.Add(new BillingListDto
|
||||
{
|
||||
Id = reader.GetInt64(0),
|
||||
TenantId = reader.GetInt64(1),
|
||||
TenantName = reader.IsDBNull(2) ? string.Empty : reader.GetString(2),
|
||||
SubscriptionId = reader.IsDBNull(3) ? null : reader.GetInt64(3),
|
||||
StatementNo = reader.GetString(4),
|
||||
BillingType = (BillingType)reader.GetInt32(5),
|
||||
PeriodStart = reader.GetDateTime(6),
|
||||
PeriodEnd = reader.GetDateTime(7),
|
||||
AmountDue = amountDue,
|
||||
DiscountAmount = discountAmount,
|
||||
TaxAmount = taxAmount,
|
||||
TotalAmount = totalAmount,
|
||||
AmountPaid = reader.GetDecimal(11),
|
||||
Currency = reader.IsDBNull(14) ? "CNY" : reader.GetString(14),
|
||||
Status = status,
|
||||
DueDate = dueDate,
|
||||
IsOverdue = isOverdue,
|
||||
OverdueDays = overdueDays,
|
||||
CreatedAt = reader.GetDateTime(15),
|
||||
UpdatedAt = reader.IsDBNull(16) ? null : reader.GetDateTime(16)
|
||||
});
|
||||
}
|
||||
|
||||
// 3.3 返回分页
|
||||
return new PagedResult<BillingListDto>(items, page, pageSize, total);
|
||||
},
|
||||
cancellationToken);
|
||||
}
|
||||
|
||||
private static string BuildCountSql()
|
||||
{
|
||||
return """
|
||||
select count(*)
|
||||
from public.tenant_billing_statements b
|
||||
join public.tenants t on t."Id" = b."TenantId" and t."DeletedAt" is null
|
||||
where b."DeletedAt" is null
|
||||
and b."TenantId" = @tenantId
|
||||
and (@status::int is null or b."Status" = @status)
|
||||
and (@billingType::int is null or b."BillingType" = @billingType)
|
||||
and (@startDate::timestamp with time zone is null or b."PeriodStart" >= @startDate)
|
||||
and (@endDate::timestamp with time zone is null or b."PeriodEnd" <= @endDate)
|
||||
and (@minAmount::numeric is null or b."AmountDue" >= @minAmount)
|
||||
and (@maxAmount::numeric is null or b."AmountDue" <= @maxAmount)
|
||||
and (
|
||||
@keyword::text is null
|
||||
or b."StatementNo" ilike ('%' || @keyword::text || '%')
|
||||
or t."Name" ilike ('%' || @keyword::text || '%')
|
||||
);
|
||||
""";
|
||||
}
|
||||
|
||||
private static string BuildListSql(string orderBy, bool sortDesc)
|
||||
{
|
||||
var direction = sortDesc ? "desc" : "asc";
|
||||
|
||||
return $"""
|
||||
select
|
||||
b."Id",
|
||||
b."TenantId",
|
||||
t."Name" as "TenantName",
|
||||
b."SubscriptionId",
|
||||
b."StatementNo",
|
||||
b."BillingType",
|
||||
b."PeriodStart",
|
||||
b."PeriodEnd",
|
||||
b."AmountDue",
|
||||
b."DiscountAmount",
|
||||
b."TaxAmount",
|
||||
b."AmountPaid",
|
||||
b."Status",
|
||||
b."DueDate",
|
||||
b."Currency",
|
||||
b."CreatedAt",
|
||||
b."UpdatedAt"
|
||||
from public.tenant_billing_statements b
|
||||
join public.tenants t on t."Id" = b."TenantId" and t."DeletedAt" is null
|
||||
where b."DeletedAt" is null
|
||||
and b."TenantId" = @tenantId
|
||||
and (@status::int is null or b."Status" = @status)
|
||||
and (@billingType::int is null or b."BillingType" = @billingType)
|
||||
and (@startDate::timestamp with time zone is null or b."PeriodStart" >= @startDate)
|
||||
and (@endDate::timestamp with time zone is null or b."PeriodEnd" <= @endDate)
|
||||
and (@minAmount::numeric is null or b."AmountDue" >= @minAmount)
|
||||
and (@maxAmount::numeric is null or b."AmountDue" <= @maxAmount)
|
||||
and (
|
||||
@keyword::text is null
|
||||
or b."StatementNo" ilike ('%' || @keyword::text || '%')
|
||||
or t."Name" ilike ('%' || @keyword::text || '%')
|
||||
)
|
||||
order by {orderBy} {direction}
|
||||
offset @offset
|
||||
limit @limit;
|
||||
""";
|
||||
}
|
||||
|
||||
private static async Task<int> ExecuteScalarIntAsync(
|
||||
IDbConnection connection,
|
||||
string sql,
|
||||
(string Name, object? Value)[] parameters,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
await using var command = CreateCommand(connection, sql, parameters);
|
||||
var result = await command.ExecuteScalarAsync(cancellationToken);
|
||||
return result is null or DBNull ? 0 : Convert.ToInt32(result);
|
||||
}
|
||||
|
||||
private static DbCommand CreateCommand(IDbConnection connection, string sql, (string Name, object? Value)[] parameters)
|
||||
{
|
||||
var command = connection.CreateCommand();
|
||||
command.CommandText = sql;
|
||||
|
||||
foreach (var (name, value) in parameters)
|
||||
{
|
||||
var p = command.CreateParameter();
|
||||
p.ParameterName = name;
|
||||
p.Value = value ?? DBNull.Value;
|
||||
command.Parameters.Add(p);
|
||||
}
|
||||
|
||||
return (DbCommand)command;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,139 @@
|
||||
using MediatR;
|
||||
using System.Data;
|
||||
using System.Data.Common;
|
||||
using TakeoutSaaS.Application.App.Billings.Dto;
|
||||
using TakeoutSaaS.Application.App.Billings.Queries;
|
||||
using TakeoutSaaS.Shared.Abstractions.Constants;
|
||||
using TakeoutSaaS.Shared.Abstractions.Data;
|
||||
using TakeoutSaaS.Shared.Abstractions.Exceptions;
|
||||
using TakeoutSaaS.Domain.Tenants.Enums;
|
||||
|
||||
namespace TakeoutSaaS.Application.App.Billings.Handlers;
|
||||
|
||||
/// <summary>
|
||||
/// 查询账单支付记录处理器。
|
||||
/// </summary>
|
||||
public sealed class GetBillingPaymentsQueryHandler(
|
||||
IDapperExecutor dapperExecutor)
|
||||
: IRequestHandler<GetBillingPaymentsQuery, List<PaymentRecordDto>>
|
||||
{
|
||||
/// <summary>
|
||||
/// 处理查询账单支付记录请求。
|
||||
/// </summary>
|
||||
/// <param name="request">查询命令。</param>
|
||||
/// <param name="cancellationToken">取消标记。</param>
|
||||
/// <returns>支付记录列表 DTO。</returns>
|
||||
public async Task<List<PaymentRecordDto>> Handle(GetBillingPaymentsQuery request, CancellationToken cancellationToken)
|
||||
{
|
||||
// 1. 校验账单是否存在
|
||||
return await dapperExecutor.QueryAsync(
|
||||
DatabaseConstants.AppDataSource,
|
||||
DatabaseConnectionRole.Read,
|
||||
async (connection, token) =>
|
||||
{
|
||||
// 1.1 校验账单存在
|
||||
var exists = await ExecuteScalarIntAsync(
|
||||
connection,
|
||||
"""
|
||||
select 1
|
||||
from public.tenant_billing_statements b
|
||||
where b."DeletedAt" is null
|
||||
and b."Id" = @billingId
|
||||
limit 1;
|
||||
""",
|
||||
[
|
||||
("billingId", request.BillingId)
|
||||
],
|
||||
token);
|
||||
|
||||
if (exists == 0)
|
||||
{
|
||||
throw new BusinessException(ErrorCodes.NotFound, "账单不存在");
|
||||
}
|
||||
|
||||
// 1.2 查询支付记录
|
||||
await using var command = CreateCommand(
|
||||
connection,
|
||||
"""
|
||||
select
|
||||
p."Id",
|
||||
p."TenantId",
|
||||
p."BillingStatementId",
|
||||
p."Amount",
|
||||
p."Method",
|
||||
p."Status",
|
||||
p."TransactionNo",
|
||||
p."ProofUrl",
|
||||
p."Notes",
|
||||
p."VerifiedBy",
|
||||
p."VerifiedAt",
|
||||
p."RefundReason",
|
||||
p."RefundedAt",
|
||||
p."PaidAt",
|
||||
p."CreatedAt"
|
||||
from public.tenant_payments p
|
||||
where p."DeletedAt" is null
|
||||
and p."BillingStatementId" = @billingId
|
||||
order by p."CreatedAt" desc;
|
||||
""",
|
||||
[
|
||||
("billingId", request.BillingId)
|
||||
]);
|
||||
|
||||
await using var reader = await command.ExecuteReaderAsync(token);
|
||||
var results = new List<PaymentRecordDto>();
|
||||
while (await reader.ReadAsync(token))
|
||||
{
|
||||
results.Add(new PaymentRecordDto
|
||||
{
|
||||
Id = reader.GetInt64(0),
|
||||
TenantId = reader.GetInt64(1),
|
||||
BillingId = reader.GetInt64(2),
|
||||
Amount = reader.GetDecimal(3),
|
||||
Method = (TenantPaymentMethod)reader.GetInt32(4),
|
||||
Status = (TenantPaymentStatus)reader.GetInt32(5),
|
||||
TransactionNo = reader.IsDBNull(6) ? null : reader.GetString(6),
|
||||
ProofUrl = reader.IsDBNull(7) ? null : reader.GetString(7),
|
||||
Notes = reader.IsDBNull(8) ? null : reader.GetString(8),
|
||||
VerifiedBy = reader.IsDBNull(9) ? null : reader.GetInt64(9),
|
||||
VerifiedAt = reader.IsDBNull(10) ? null : reader.GetDateTime(10),
|
||||
RefundReason = reader.IsDBNull(11) ? null : reader.GetString(11),
|
||||
RefundedAt = reader.IsDBNull(12) ? null : reader.GetDateTime(12),
|
||||
PaidAt = reader.IsDBNull(13) ? null : reader.GetDateTime(13),
|
||||
IsVerified = !reader.IsDBNull(10),
|
||||
CreatedAt = reader.GetDateTime(14)
|
||||
});
|
||||
}
|
||||
|
||||
return results;
|
||||
},
|
||||
cancellationToken);
|
||||
}
|
||||
|
||||
private static async Task<int> ExecuteScalarIntAsync(
|
||||
IDbConnection connection,
|
||||
string sql,
|
||||
(string Name, object? Value)[] parameters,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
await using var command = CreateCommand(connection, sql, parameters);
|
||||
var result = await command.ExecuteScalarAsync(cancellationToken);
|
||||
return result is null or DBNull ? 0 : Convert.ToInt32(result);
|
||||
}
|
||||
|
||||
private static DbCommand CreateCommand(IDbConnection connection, string sql, (string Name, object? Value)[] parameters)
|
||||
{
|
||||
var command = connection.CreateCommand();
|
||||
command.CommandText = sql;
|
||||
|
||||
foreach (var (name, value) in parameters)
|
||||
{
|
||||
var p = command.CreateParameter();
|
||||
p.ParameterName = name;
|
||||
p.Value = value ?? DBNull.Value;
|
||||
command.Parameters.Add(p);
|
||||
}
|
||||
|
||||
return (DbCommand)command;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,204 @@
|
||||
using MediatR;
|
||||
using System.Data;
|
||||
using System.Data.Common;
|
||||
using TakeoutSaaS.Application.App.Billings.Dto;
|
||||
using TakeoutSaaS.Application.App.Billings.Queries;
|
||||
using TakeoutSaaS.Domain.Tenants.Enums;
|
||||
using TakeoutSaaS.Shared.Abstractions.Constants;
|
||||
using TakeoutSaaS.Shared.Abstractions.Data;
|
||||
using TakeoutSaaS.Shared.Abstractions.Exceptions;
|
||||
using TakeoutSaaS.Shared.Abstractions.Tenancy;
|
||||
|
||||
namespace TakeoutSaaS.Application.App.Billings.Handlers;
|
||||
|
||||
/// <summary>
|
||||
/// 查询账单统计数据处理器。
|
||||
/// </summary>
|
||||
public sealed class GetBillingStatisticsQueryHandler(
|
||||
IDapperExecutor dapperExecutor,
|
||||
ITenantProvider tenantProvider)
|
||||
: IRequestHandler<GetBillingStatisticsQuery, BillingStatisticsDto>
|
||||
{
|
||||
/// <summary>
|
||||
/// 处理查询账单统计数据请求。
|
||||
/// </summary>
|
||||
/// <param name="request">查询命令。</param>
|
||||
/// <param name="cancellationToken">取消标记。</param>
|
||||
/// <returns>账单统计数据 DTO。</returns>
|
||||
public async Task<BillingStatisticsDto> Handle(GetBillingStatisticsQuery request, CancellationToken cancellationToken)
|
||||
{
|
||||
// 1. 校验租户上下文(租户端禁止跨租户)
|
||||
var currentTenantId = tenantProvider.GetCurrentTenantId();
|
||||
if (currentTenantId <= 0)
|
||||
{
|
||||
throw new BusinessException(ErrorCodes.BadRequest, "缺少租户标识");
|
||||
}
|
||||
|
||||
// 2. (空行后) 禁止跨租户统计
|
||||
if (request.TenantId.HasValue && request.TenantId.Value != currentTenantId)
|
||||
{
|
||||
throw new BusinessException(ErrorCodes.Forbidden, "禁止跨租户统计账单");
|
||||
}
|
||||
var tenantId = currentTenantId;
|
||||
|
||||
// 3. (空行后) 参数规范化
|
||||
var startDate = request.StartDate ?? DateTime.UtcNow.AddMonths(-1);
|
||||
var endDate = request.EndDate ?? DateTime.UtcNow;
|
||||
var groupBy = NormalizeGroupBy(request.GroupBy);
|
||||
|
||||
// 2. 查询统计数据(总览 + 趋势)
|
||||
return await dapperExecutor.QueryAsync(
|
||||
DatabaseConstants.AppDataSource,
|
||||
DatabaseConnectionRole.Read,
|
||||
async (connection, token) =>
|
||||
{
|
||||
// 2.1 总览统计
|
||||
await using var summaryCommand = CreateCommand(
|
||||
connection,
|
||||
BuildSummarySql(),
|
||||
[
|
||||
("tenantId", tenantId),
|
||||
("startDate", startDate),
|
||||
("endDate", endDate),
|
||||
("now", DateTime.UtcNow)
|
||||
]);
|
||||
|
||||
await using var summaryReader = await summaryCommand.ExecuteReaderAsync(token);
|
||||
await summaryReader.ReadAsync(token);
|
||||
|
||||
var totalCount = summaryReader.IsDBNull(0) ? 0 : summaryReader.GetInt32(0);
|
||||
var pendingCount = summaryReader.IsDBNull(1) ? 0 : summaryReader.GetInt32(1);
|
||||
var paidCount = summaryReader.IsDBNull(2) ? 0 : summaryReader.GetInt32(2);
|
||||
var overdueCount = summaryReader.IsDBNull(3) ? 0 : summaryReader.GetInt32(3);
|
||||
var cancelledCount = summaryReader.IsDBNull(4) ? 0 : summaryReader.GetInt32(4);
|
||||
var totalAmountDue = summaryReader.IsDBNull(5) ? 0m : summaryReader.GetDecimal(5);
|
||||
var totalAmountPaid = summaryReader.IsDBNull(6) ? 0m : summaryReader.GetDecimal(6);
|
||||
var totalAmountUnpaid = summaryReader.IsDBNull(7) ? 0m : summaryReader.GetDecimal(7);
|
||||
var totalOverdueAmount = summaryReader.IsDBNull(8) ? 0m : summaryReader.GetDecimal(8);
|
||||
|
||||
// 2.2 趋势数据
|
||||
await using var trendCommand = CreateCommand(
|
||||
connection,
|
||||
BuildTrendSql(groupBy),
|
||||
[
|
||||
("tenantId", tenantId),
|
||||
("startDate", startDate),
|
||||
("endDate", endDate)
|
||||
]);
|
||||
|
||||
await using var trendReader = await trendCommand.ExecuteReaderAsync(token);
|
||||
var amountDueTrend = new Dictionary<string, decimal>();
|
||||
var amountPaidTrend = new Dictionary<string, decimal>();
|
||||
var countTrend = new Dictionary<string, int>();
|
||||
while (await trendReader.ReadAsync(token))
|
||||
{
|
||||
var bucket = trendReader.GetDateTime(0);
|
||||
var key = bucket.ToString("yyyy-MM-dd");
|
||||
|
||||
amountDueTrend[key] = trendReader.IsDBNull(1) ? 0m : trendReader.GetDecimal(1);
|
||||
amountPaidTrend[key] = trendReader.IsDBNull(2) ? 0m : trendReader.GetDecimal(2);
|
||||
countTrend[key] = trendReader.IsDBNull(3) ? 0 : trendReader.GetInt32(3);
|
||||
}
|
||||
|
||||
// 2.3 组装 DTO
|
||||
return new BillingStatisticsDto
|
||||
{
|
||||
TenantId = tenantId,
|
||||
StartDate = startDate,
|
||||
EndDate = endDate,
|
||||
GroupBy = groupBy,
|
||||
TotalCount = totalCount,
|
||||
PendingCount = pendingCount,
|
||||
PaidCount = paidCount,
|
||||
OverdueCount = overdueCount,
|
||||
CancelledCount = cancelledCount,
|
||||
TotalAmountDue = totalAmountDue,
|
||||
TotalAmountPaid = totalAmountPaid,
|
||||
TotalAmountUnpaid = totalAmountUnpaid,
|
||||
TotalOverdueAmount = totalOverdueAmount,
|
||||
AmountDueTrend = amountDueTrend,
|
||||
AmountPaidTrend = amountPaidTrend,
|
||||
CountTrend = countTrend
|
||||
};
|
||||
},
|
||||
cancellationToken);
|
||||
}
|
||||
|
||||
private static string NormalizeGroupBy(string? groupBy)
|
||||
{
|
||||
return groupBy?.Trim() switch
|
||||
{
|
||||
"Week" => "Week",
|
||||
"Month" => "Month",
|
||||
_ => "Day"
|
||||
};
|
||||
}
|
||||
|
||||
private static string BuildSummarySql()
|
||||
{
|
||||
return """
|
||||
select
|
||||
count(*)::int as "TotalCount",
|
||||
coalesce(sum(case when b."Status" = 0 then 1 else 0 end), 0)::int as "PendingCount",
|
||||
coalesce(sum(case when b."Status" = 1 then 1 else 0 end), 0)::int as "PaidCount",
|
||||
coalesce(sum(case when b."Status" = 2 then 1 else 0 end), 0)::int as "OverdueCount",
|
||||
coalesce(sum(case when b."Status" = 3 then 1 else 0 end), 0)::int as "CancelledCount",
|
||||
coalesce(sum(b."AmountDue"), 0)::numeric as "TotalAmountDue",
|
||||
coalesce(sum(b."AmountPaid"), 0)::numeric as "TotalAmountPaid",
|
||||
coalesce(sum((b."AmountDue" - b."DiscountAmount" + b."TaxAmount") - b."AmountPaid"), 0)::numeric as "TotalAmountUnpaid",
|
||||
coalesce(sum(
|
||||
case
|
||||
when b."Status" in (0, 2) and b."DueDate" < @now
|
||||
then (b."AmountDue" - b."DiscountAmount" + b."TaxAmount") - b."AmountPaid"
|
||||
else 0
|
||||
end
|
||||
), 0)::numeric as "TotalOverdueAmount"
|
||||
from public.tenant_billing_statements b
|
||||
where b."DeletedAt" is null
|
||||
and b."TenantId" = @tenantId
|
||||
and b."PeriodStart" >= @startDate
|
||||
and b."PeriodEnd" <= @endDate;
|
||||
""";
|
||||
}
|
||||
|
||||
private static string BuildTrendSql(string groupBy)
|
||||
{
|
||||
var dateTrunc = groupBy switch
|
||||
{
|
||||
"Week" => "week",
|
||||
"Month" => "month",
|
||||
_ => "day"
|
||||
};
|
||||
|
||||
return $"""
|
||||
select
|
||||
date_trunc('{dateTrunc}', b."PeriodStart") as "Bucket",
|
||||
coalesce(sum(b."AmountDue"), 0)::numeric as "AmountDue",
|
||||
coalesce(sum(b."AmountPaid"), 0)::numeric as "AmountPaid",
|
||||
count(*)::int as "Count"
|
||||
from public.tenant_billing_statements b
|
||||
where b."DeletedAt" is null
|
||||
and b."TenantId" = @tenantId
|
||||
and b."PeriodStart" >= @startDate
|
||||
and b."PeriodEnd" <= @endDate
|
||||
group by 1
|
||||
order by 1 asc;
|
||||
""";
|
||||
}
|
||||
|
||||
private static DbCommand CreateCommand(IDbConnection connection, string sql, (string Name, object? Value)[] parameters)
|
||||
{
|
||||
var command = connection.CreateCommand();
|
||||
command.CommandText = sql;
|
||||
|
||||
foreach (var (name, value) in parameters)
|
||||
{
|
||||
var p = command.CreateParameter();
|
||||
p.ParameterName = name;
|
||||
p.Value = value ?? DBNull.Value;
|
||||
command.Parameters.Add(p);
|
||||
}
|
||||
|
||||
return (DbCommand)command;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,172 @@
|
||||
using MediatR;
|
||||
using System.Data;
|
||||
using System.Data.Common;
|
||||
using TakeoutSaaS.Application.App.Billings.Dto;
|
||||
using TakeoutSaaS.Application.App.Billings.Queries;
|
||||
using TakeoutSaaS.Domain.Tenants.Enums;
|
||||
using TakeoutSaaS.Shared.Abstractions.Constants;
|
||||
using TakeoutSaaS.Shared.Abstractions.Data;
|
||||
using TakeoutSaaS.Shared.Abstractions.Results;
|
||||
|
||||
namespace TakeoutSaaS.Application.App.Billings.Handlers;
|
||||
|
||||
/// <summary>
|
||||
/// 查询逾期账单列表处理器。
|
||||
/// </summary>
|
||||
public sealed class GetOverdueBillingsQueryHandler(
|
||||
IDapperExecutor dapperExecutor)
|
||||
: IRequestHandler<GetOverdueBillingsQuery, PagedResult<BillingListDto>>
|
||||
{
|
||||
/// <summary>
|
||||
/// 处理查询逾期账单列表请求。
|
||||
/// </summary>
|
||||
/// <param name="request">查询命令。</param>
|
||||
/// <param name="cancellationToken">取消标记。</param>
|
||||
/// <returns>分页逾期账单列表 DTO。</returns>
|
||||
public async Task<PagedResult<BillingListDto>> Handle(GetOverdueBillingsQuery request, CancellationToken cancellationToken)
|
||||
{
|
||||
// 1. 参数规范化
|
||||
var page = request.PageNumber <= 0 ? 1 : request.PageNumber;
|
||||
var pageSize = request.PageSize is <= 0 or > 200 ? 20 : request.PageSize;
|
||||
var offset = (page - 1) * pageSize;
|
||||
var now = DateTime.UtcNow;
|
||||
|
||||
// 2. 查询总数 + 列表
|
||||
return await dapperExecutor.QueryAsync(
|
||||
DatabaseConstants.AppDataSource,
|
||||
DatabaseConnectionRole.Read,
|
||||
async (connection, token) =>
|
||||
{
|
||||
// 2.1 统计总数
|
||||
var total = await ExecuteScalarIntAsync(
|
||||
connection,
|
||||
BuildCountSql(),
|
||||
[
|
||||
("now", now)
|
||||
],
|
||||
token);
|
||||
|
||||
// 2.2 查询列表
|
||||
await using var listCommand = CreateCommand(
|
||||
connection,
|
||||
BuildListSql(),
|
||||
[
|
||||
("now", now),
|
||||
("offset", offset),
|
||||
("limit", pageSize)
|
||||
]);
|
||||
|
||||
await using var reader = await listCommand.ExecuteReaderAsync(token);
|
||||
var items = new List<BillingListDto>();
|
||||
while (await reader.ReadAsync(token))
|
||||
{
|
||||
var dueDate = reader.GetDateTime(13);
|
||||
var status = (TenantBillingStatus)reader.GetInt32(12);
|
||||
var amountDue = reader.GetDecimal(8);
|
||||
var discountAmount = reader.GetDecimal(9);
|
||||
var taxAmount = reader.GetDecimal(10);
|
||||
var totalAmount = amountDue - discountAmount + taxAmount;
|
||||
var overdueDays = dueDate < now ? (int)(now - dueDate).TotalDays : 0;
|
||||
|
||||
items.Add(new BillingListDto
|
||||
{
|
||||
Id = reader.GetInt64(0),
|
||||
TenantId = reader.GetInt64(1),
|
||||
TenantName = reader.IsDBNull(2) ? string.Empty : reader.GetString(2),
|
||||
SubscriptionId = reader.IsDBNull(3) ? null : reader.GetInt64(3),
|
||||
StatementNo = reader.GetString(4),
|
||||
BillingType = (BillingType)reader.GetInt32(5),
|
||||
PeriodStart = reader.GetDateTime(6),
|
||||
PeriodEnd = reader.GetDateTime(7),
|
||||
AmountDue = amountDue,
|
||||
DiscountAmount = discountAmount,
|
||||
TaxAmount = taxAmount,
|
||||
TotalAmount = totalAmount,
|
||||
AmountPaid = reader.GetDecimal(11),
|
||||
Status = status,
|
||||
DueDate = dueDate,
|
||||
Currency = reader.IsDBNull(14) ? "CNY" : reader.GetString(14),
|
||||
IsOverdue = true,
|
||||
OverdueDays = overdueDays,
|
||||
CreatedAt = reader.GetDateTime(15),
|
||||
UpdatedAt = reader.IsDBNull(16) ? null : reader.GetDateTime(16)
|
||||
});
|
||||
}
|
||||
|
||||
// 2.3 返回分页
|
||||
return new PagedResult<BillingListDto>(items, page, pageSize, total);
|
||||
},
|
||||
cancellationToken);
|
||||
}
|
||||
|
||||
private static string BuildCountSql()
|
||||
{
|
||||
return """
|
||||
select count(*)
|
||||
from public.tenant_billing_statements b
|
||||
join public.tenants t on t."Id" = b."TenantId" and t."DeletedAt" is null
|
||||
where b."DeletedAt" is null
|
||||
and b."DueDate" < @now
|
||||
and b."Status" in (0, 2);
|
||||
""";
|
||||
}
|
||||
|
||||
private static string BuildListSql()
|
||||
{
|
||||
return """
|
||||
select
|
||||
b."Id",
|
||||
b."TenantId",
|
||||
t."Name" as "TenantName",
|
||||
b."SubscriptionId",
|
||||
b."StatementNo",
|
||||
b."BillingType",
|
||||
b."PeriodStart",
|
||||
b."PeriodEnd",
|
||||
b."AmountDue",
|
||||
b."DiscountAmount",
|
||||
b."TaxAmount",
|
||||
b."AmountPaid",
|
||||
b."Status",
|
||||
b."DueDate",
|
||||
b."Currency",
|
||||
b."CreatedAt",
|
||||
b."UpdatedAt"
|
||||
from public.tenant_billing_statements b
|
||||
join public.tenants t on t."Id" = b."TenantId" and t."DeletedAt" is null
|
||||
where b."DeletedAt" is null
|
||||
and b."DueDate" < @now
|
||||
and b."Status" in (0, 2)
|
||||
order by b."DueDate" asc
|
||||
offset @offset
|
||||
limit @limit;
|
||||
""";
|
||||
}
|
||||
|
||||
private static async Task<int> ExecuteScalarIntAsync(
|
||||
IDbConnection connection,
|
||||
string sql,
|
||||
(string Name, object? Value)[] parameters,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
await using var command = CreateCommand(connection, sql, parameters);
|
||||
var result = await command.ExecuteScalarAsync(cancellationToken);
|
||||
return result is null or DBNull ? 0 : Convert.ToInt32(result);
|
||||
}
|
||||
|
||||
private static DbCommand CreateCommand(IDbConnection connection, string sql, (string Name, object? Value)[] parameters)
|
||||
{
|
||||
var command = connection.CreateCommand();
|
||||
command.CommandText = sql;
|
||||
|
||||
foreach (var (name, value) in parameters)
|
||||
{
|
||||
var p = command.CreateParameter();
|
||||
p.ParameterName = name;
|
||||
p.Value = value ?? DBNull.Value;
|
||||
command.Parameters.Add(p);
|
||||
}
|
||||
|
||||
return (DbCommand)command;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
using MediatR;
|
||||
using TakeoutSaaS.Application.App.Billings.Dto;
|
||||
using TakeoutSaaS.Application.App.Billings.Queries;
|
||||
using TakeoutSaaS.Domain.Tenants.Repositories;
|
||||
|
||||
namespace TakeoutSaaS.Application.App.Billings.Handlers;
|
||||
|
||||
/// <summary>
|
||||
/// 获取租户支付记录查询处理器。
|
||||
/// </summary>
|
||||
public sealed class GetTenantPaymentsQueryHandler(ITenantPaymentRepository paymentRepository)
|
||||
: IRequestHandler<GetTenantPaymentsQuery, List<PaymentDto>>
|
||||
{
|
||||
/// <summary>
|
||||
/// 处理获取支付记录请求。
|
||||
/// </summary>
|
||||
/// <param name="request">查询请求。</param>
|
||||
/// <param name="cancellationToken">取消标记。</param>
|
||||
/// <returns>支付记录列表。</returns>
|
||||
public async Task<List<PaymentDto>> Handle(GetTenantPaymentsQuery request, CancellationToken cancellationToken)
|
||||
{
|
||||
// 1. 查询支付记录
|
||||
var payments = await paymentRepository.GetByBillingIdAsync(request.BillId, cancellationToken);
|
||||
|
||||
// 2. 映射并返回 DTO
|
||||
return payments.Select(p => p.ToDto()).ToList();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
using MediatR;
|
||||
using TakeoutSaaS.Application.App.Billings.Commands;
|
||||
using TakeoutSaaS.Domain.Tenants.Services;
|
||||
|
||||
namespace TakeoutSaaS.Application.App.Billings.Handlers;
|
||||
|
||||
/// <summary>
|
||||
/// 处理逾期账单命令处理器(后台任务)。
|
||||
/// </summary>
|
||||
public sealed class ProcessOverdueBillingsCommandHandler(
|
||||
IBillingDomainService billingDomainService)
|
||||
: IRequestHandler<ProcessOverdueBillingsCommand, int>
|
||||
{
|
||||
/// <inheritdoc />
|
||||
public async Task<int> Handle(ProcessOverdueBillingsCommand request, CancellationToken cancellationToken)
|
||||
{
|
||||
// 1. 委托领域服务执行逾期账单处理(Pending && DueDate < Now -> Overdue)
|
||||
return await billingDomainService.ProcessOverdueBillingsAsync(cancellationToken);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,81 @@
|
||||
using MediatR;
|
||||
using TakeoutSaaS.Application.App.Billings.Commands;
|
||||
using TakeoutSaaS.Application.App.Billings.Dto;
|
||||
using TakeoutSaaS.Domain.Tenants.Entities;
|
||||
using TakeoutSaaS.Domain.Tenants.Enums;
|
||||
using TakeoutSaaS.Domain.Tenants.Repositories;
|
||||
using TakeoutSaaS.Shared.Abstractions.Constants;
|
||||
using TakeoutSaaS.Shared.Abstractions.Exceptions;
|
||||
using TakeoutSaaS.Shared.Abstractions.Ids;
|
||||
|
||||
namespace TakeoutSaaS.Application.App.Billings.Handlers;
|
||||
|
||||
/// <summary>
|
||||
/// 记录支付处理器。
|
||||
/// </summary>
|
||||
public sealed class RecordPaymentCommandHandler(
|
||||
ITenantBillingRepository billingRepository,
|
||||
ITenantPaymentRepository paymentRepository,
|
||||
IIdGenerator idGenerator)
|
||||
: IRequestHandler<RecordPaymentCommand, PaymentRecordDto>
|
||||
{
|
||||
/// <summary>
|
||||
/// 处理记录支付请求。
|
||||
/// </summary>
|
||||
/// <param name="request">记录支付命令。</param>
|
||||
/// <param name="cancellationToken">取消标记。</param>
|
||||
/// <returns>支付 DTO。</returns>
|
||||
public async Task<PaymentRecordDto> Handle(RecordPaymentCommand request, CancellationToken cancellationToken)
|
||||
{
|
||||
// 1. 查询账单
|
||||
var billing = await billingRepository.FindByIdAsync(request.BillingId, cancellationToken);
|
||||
if (billing is null)
|
||||
{
|
||||
throw new BusinessException(ErrorCodes.NotFound, "账单不存在");
|
||||
}
|
||||
|
||||
// 2. 业务规则检查
|
||||
if (billing.Status == TenantBillingStatus.Paid)
|
||||
{
|
||||
throw new BusinessException(ErrorCodes.BusinessError, "已支付账单不允许重复收款");
|
||||
}
|
||||
|
||||
if (billing.Status == TenantBillingStatus.Cancelled)
|
||||
{
|
||||
throw new BusinessException(ErrorCodes.BusinessError, "已取消账单不允许收款");
|
||||
}
|
||||
|
||||
// 3. 幂等校验:交易号唯一
|
||||
if (!string.IsNullOrWhiteSpace(request.TransactionNo))
|
||||
{
|
||||
var exists = await paymentRepository.GetByTransactionNoAsync(request.TransactionNo.Trim(), cancellationToken);
|
||||
if (exists is not null)
|
||||
{
|
||||
throw new BusinessException(ErrorCodes.Conflict, "交易号已存在,疑似重复提交");
|
||||
}
|
||||
}
|
||||
|
||||
// 4. 构建支付记录(默认待审核)
|
||||
var now = DateTime.UtcNow;
|
||||
var payment = new TenantPayment
|
||||
{
|
||||
Id = idGenerator.NextId(),
|
||||
TenantId = billing.TenantId,
|
||||
BillingStatementId = request.BillingId,
|
||||
Amount = request.Amount,
|
||||
Method = request.Method,
|
||||
Status = TenantPaymentStatus.Pending,
|
||||
TransactionNo = string.IsNullOrWhiteSpace(request.TransactionNo) ? null : request.TransactionNo.Trim(),
|
||||
ProofUrl = request.ProofUrl,
|
||||
PaidAt = now,
|
||||
Notes = request.Notes
|
||||
};
|
||||
|
||||
// 5. 持久化变更
|
||||
await paymentRepository.AddAsync(payment, cancellationToken);
|
||||
await paymentRepository.SaveChangesAsync(cancellationToken);
|
||||
|
||||
// 6. 返回 DTO
|
||||
return payment.ToPaymentRecordDto();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,50 @@
|
||||
using MediatR;
|
||||
using TakeoutSaaS.Application.App.Billings.Commands;
|
||||
using TakeoutSaaS.Application.App.Billings.Dto;
|
||||
using TakeoutSaaS.Domain.Tenants.Repositories;
|
||||
using TakeoutSaaS.Shared.Abstractions.Constants;
|
||||
using TakeoutSaaS.Shared.Abstractions.Exceptions;
|
||||
|
||||
namespace TakeoutSaaS.Application.App.Billings.Handlers;
|
||||
|
||||
/// <summary>
|
||||
/// 更新账单状态处理器。
|
||||
/// </summary>
|
||||
public sealed class UpdateBillStatusCommandHandler(
|
||||
ITenantBillingRepository billingRepository,
|
||||
ITenantRepository tenantRepository)
|
||||
: IRequestHandler<UpdateBillStatusCommand, BillDto?>
|
||||
{
|
||||
/// <summary>
|
||||
/// 处理更新账单状态请求。
|
||||
/// </summary>
|
||||
/// <param name="request">更新命令。</param>
|
||||
/// <param name="cancellationToken">取消标记。</param>
|
||||
/// <returns>账单 DTO 或 null。</returns>
|
||||
public async Task<BillDto?> Handle(UpdateBillStatusCommand request, CancellationToken cancellationToken)
|
||||
{
|
||||
// 1. 查询账单
|
||||
var bill = await billingRepository.FindByIdAsync(request.BillId, cancellationToken);
|
||||
if (bill is null)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
// 2. 更新状态
|
||||
bill.Status = request.Status;
|
||||
if (!string.IsNullOrWhiteSpace(request.Notes))
|
||||
{
|
||||
bill.LineItemsJson = request.Notes;
|
||||
}
|
||||
|
||||
// 3. 持久化变更
|
||||
await billingRepository.UpdateAsync(bill, cancellationToken);
|
||||
await billingRepository.SaveChangesAsync(cancellationToken);
|
||||
|
||||
// 4. 查询租户名称
|
||||
var tenant = await tenantRepository.FindByIdAsync(bill.TenantId, cancellationToken);
|
||||
|
||||
// 5. 返回 DTO
|
||||
return bill.ToDto(tenant?.Name);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,54 @@
|
||||
using MediatR;
|
||||
using TakeoutSaaS.Application.App.Billings.Commands;
|
||||
using TakeoutSaaS.Domain.Tenants.Enums;
|
||||
using TakeoutSaaS.Domain.Tenants.Repositories;
|
||||
using TakeoutSaaS.Shared.Abstractions.Constants;
|
||||
using TakeoutSaaS.Shared.Abstractions.Exceptions;
|
||||
|
||||
namespace TakeoutSaaS.Application.App.Billings.Handlers;
|
||||
|
||||
/// <summary>
|
||||
/// 更新账单状态命令处理器。
|
||||
/// </summary>
|
||||
public sealed class UpdateBillingStatusCommandHandler(
|
||||
ITenantBillingRepository billingRepository)
|
||||
: IRequestHandler<UpdateBillingStatusCommand, Unit>
|
||||
{
|
||||
/// <inheritdoc />
|
||||
public async Task<Unit> Handle(UpdateBillingStatusCommand request, CancellationToken cancellationToken)
|
||||
{
|
||||
// 1. 查询账单
|
||||
var billing = await billingRepository.FindByIdAsync(request.BillingId, cancellationToken);
|
||||
if (billing is null)
|
||||
{
|
||||
throw new BusinessException(ErrorCodes.NotFound, "账单不存在");
|
||||
}
|
||||
|
||||
// 2. 状态转换规则校验
|
||||
if (billing.Status == TenantBillingStatus.Paid && request.NewStatus != TenantBillingStatus.Paid)
|
||||
{
|
||||
throw new BusinessException(ErrorCodes.BusinessError, "已支付账单不允许改为其他状态");
|
||||
}
|
||||
|
||||
if (billing.Status == TenantBillingStatus.Cancelled)
|
||||
{
|
||||
throw new BusinessException(ErrorCodes.BusinessError, "已取消账单不允许变更状态");
|
||||
}
|
||||
|
||||
// 3. 更新状态与备注
|
||||
billing.Status = request.NewStatus;
|
||||
if (!string.IsNullOrWhiteSpace(request.Notes))
|
||||
{
|
||||
billing.Notes = string.IsNullOrWhiteSpace(billing.Notes)
|
||||
? $"[状态变更] {request.Notes}"
|
||||
: $"{billing.Notes}\n[状态变更] {request.Notes}";
|
||||
}
|
||||
|
||||
// 4. 持久化
|
||||
await billingRepository.UpdateAsync(billing, cancellationToken);
|
||||
await billingRepository.SaveChangesAsync(cancellationToken);
|
||||
|
||||
return Unit.Value;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,73 @@
|
||||
using MediatR;
|
||||
using TakeoutSaaS.Application.App.Billings.Commands;
|
||||
using TakeoutSaaS.Application.App.Billings.Dto;
|
||||
using TakeoutSaaS.Domain.Tenants.Repositories;
|
||||
using TakeoutSaaS.Shared.Abstractions.Constants;
|
||||
using TakeoutSaaS.Shared.Abstractions.Exceptions;
|
||||
using TakeoutSaaS.Shared.Abstractions.Security;
|
||||
|
||||
namespace TakeoutSaaS.Application.App.Billings.Handlers;
|
||||
|
||||
/// <summary>
|
||||
/// 审核支付命令处理器。
|
||||
/// </summary>
|
||||
public sealed class VerifyPaymentCommandHandler(
|
||||
ITenantPaymentRepository paymentRepository,
|
||||
ITenantBillingRepository billingRepository,
|
||||
ICurrentUserAccessor currentUserAccessor)
|
||||
: IRequestHandler<VerifyPaymentCommand, PaymentRecordDto>
|
||||
{
|
||||
/// <inheritdoc />
|
||||
public async Task<PaymentRecordDto> Handle(VerifyPaymentCommand request, CancellationToken cancellationToken)
|
||||
{
|
||||
// 1. 校验操作者身份
|
||||
if (!currentUserAccessor.IsAuthenticated || currentUserAccessor.UserId <= 0)
|
||||
{
|
||||
throw new BusinessException(ErrorCodes.Unauthorized, "未登录或无效的操作者身份");
|
||||
}
|
||||
|
||||
// 2. 查询支付记录
|
||||
var payment = await paymentRepository.FindByIdAsync(request.PaymentId, cancellationToken);
|
||||
if (payment is null)
|
||||
{
|
||||
throw new BusinessException(ErrorCodes.NotFound, "支付记录不存在");
|
||||
}
|
||||
|
||||
// 3. 查询关联账单
|
||||
var billing = await billingRepository.FindByIdAsync(payment.BillingStatementId, cancellationToken);
|
||||
if (billing is null)
|
||||
{
|
||||
throw new BusinessException(ErrorCodes.NotFound, "关联账单不存在");
|
||||
}
|
||||
|
||||
// 4. 归一化审核备注
|
||||
var normalizedNotes = string.IsNullOrWhiteSpace(request.Notes) ? null : request.Notes.Trim();
|
||||
|
||||
// 5. 根据审核结果更新支付与账单状态
|
||||
if (request.Approved)
|
||||
{
|
||||
payment.Verify(currentUserAccessor.UserId);
|
||||
payment.Notes = normalizedNotes;
|
||||
|
||||
billing.MarkAsPaid(payment.Amount, payment.TransactionNo ?? string.Empty);
|
||||
}
|
||||
else
|
||||
{
|
||||
payment.Reject(currentUserAccessor.UserId, normalizedNotes ?? string.Empty);
|
||||
payment.Notes = normalizedNotes;
|
||||
}
|
||||
|
||||
// 6. 持久化更新状态
|
||||
await paymentRepository.UpdateAsync(payment, cancellationToken);
|
||||
if (request.Approved)
|
||||
{
|
||||
await billingRepository.UpdateAsync(billing, cancellationToken);
|
||||
}
|
||||
|
||||
// 7. 保存数据库更改
|
||||
await paymentRepository.SaveChangesAsync(cancellationToken);
|
||||
|
||||
// 8. 返回 DTO
|
||||
return payment.ToPaymentRecordDto();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
using MediatR;
|
||||
|
||||
namespace TakeoutSaaS.Application.App.Billings.Queries;
|
||||
|
||||
/// <summary>
|
||||
/// 导出账单(Excel/PDF/CSV)。
|
||||
/// </summary>
|
||||
public sealed record ExportBillingsQuery : IRequest<byte[]>
|
||||
{
|
||||
/// <summary>
|
||||
/// 要导出的账单 ID 列表。
|
||||
/// </summary>
|
||||
public long[] BillingIds { get; init; } = [];
|
||||
|
||||
/// <summary>
|
||||
/// 导出格式(Excel/Pdf/Csv)。
|
||||
/// </summary>
|
||||
public string Format { get; init; } = "Excel";
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
using MediatR;
|
||||
using TakeoutSaaS.Application.App.Billings.Dto;
|
||||
|
||||
namespace TakeoutSaaS.Application.App.Billings.Queries;
|
||||
|
||||
/// <summary>
|
||||
/// 获取账单详情查询。
|
||||
/// </summary>
|
||||
public sealed record GetBillDetailQuery : IRequest<BillDetailDto?>
|
||||
{
|
||||
/// <summary>
|
||||
/// 账单 ID(雪花算法)。
|
||||
/// </summary>
|
||||
public long BillId { get; init; }
|
||||
}
|
||||
@@ -0,0 +1,47 @@
|
||||
using MediatR;
|
||||
using TakeoutSaaS.Application.App.Billings.Dto;
|
||||
using TakeoutSaaS.Domain.Tenants.Enums;
|
||||
using TakeoutSaaS.Shared.Abstractions.Results;
|
||||
|
||||
namespace TakeoutSaaS.Application.App.Billings.Queries;
|
||||
|
||||
/// <summary>
|
||||
/// 获取账单列表查询。
|
||||
/// </summary>
|
||||
public sealed record GetBillListQuery : IRequest<PagedResult<BillDto>>
|
||||
{
|
||||
/// <summary>
|
||||
/// 页码(从 1 开始)。
|
||||
/// </summary>
|
||||
public int PageNumber { get; init; } = 1;
|
||||
|
||||
/// <summary>
|
||||
/// 页大小。
|
||||
/// </summary>
|
||||
public int PageSize { get; init; } = 20;
|
||||
|
||||
/// <summary>
|
||||
/// 租户 ID 筛选(可选)。
|
||||
/// </summary>
|
||||
public long? TenantId { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// 状态筛选(可选)。
|
||||
/// </summary>
|
||||
public TenantBillingStatus? Status { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// 开始日期筛选(可选)。
|
||||
/// </summary>
|
||||
public DateTime? StartDate { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// 结束日期筛选(可选)。
|
||||
/// </summary>
|
||||
public DateTime? EndDate { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// 搜索关键词(账单号或租户名)。
|
||||
/// </summary>
|
||||
public string? Keyword { get; init; }
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
using MediatR;
|
||||
using TakeoutSaaS.Application.App.Billings.Dto;
|
||||
|
||||
namespace TakeoutSaaS.Application.App.Billings.Queries;
|
||||
|
||||
/// <summary>
|
||||
/// 查询账单详情(含明细项)。
|
||||
/// </summary>
|
||||
public sealed record GetBillingDetailQuery : IRequest<BillingDetailDto>
|
||||
{
|
||||
/// <summary>
|
||||
/// 账单 ID(雪花算法)。
|
||||
/// </summary>
|
||||
public long BillingId { get; init; }
|
||||
}
|
||||
@@ -0,0 +1,72 @@
|
||||
using MediatR;
|
||||
using TakeoutSaaS.Application.App.Billings.Dto;
|
||||
using TakeoutSaaS.Domain.Tenants.Enums;
|
||||
using TakeoutSaaS.Shared.Abstractions.Results;
|
||||
|
||||
namespace TakeoutSaaS.Application.App.Billings.Queries;
|
||||
|
||||
/// <summary>
|
||||
/// 分页查询账单列表。
|
||||
/// </summary>
|
||||
public sealed record GetBillingListQuery : IRequest<PagedResult<BillingListDto>>
|
||||
{
|
||||
/// <summary>
|
||||
/// 租户 ID(可选,默认当前租户;禁止跨租户)。
|
||||
/// </summary>
|
||||
public long? TenantId { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// 账单状态筛选。
|
||||
/// </summary>
|
||||
public TenantBillingStatus? Status { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// 账单类型筛选。
|
||||
/// </summary>
|
||||
public BillingType? BillingType { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// 账单起始时间(UTC)筛选。
|
||||
/// </summary>
|
||||
public DateTime? StartDate { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// 账单结束时间(UTC)筛选。
|
||||
/// </summary>
|
||||
public DateTime? EndDate { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// 关键词搜索(账单编号)。
|
||||
/// </summary>
|
||||
public string? Keyword { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// 最小应付金额筛选(包含)。
|
||||
/// </summary>
|
||||
public decimal? MinAmount { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// 最大应付金额筛选(包含)。
|
||||
/// </summary>
|
||||
public decimal? MaxAmount { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// 页码(从 1 开始)。
|
||||
/// </summary>
|
||||
public int PageNumber { get; init; } = 1;
|
||||
|
||||
/// <summary>
|
||||
/// 每页条数。
|
||||
/// </summary>
|
||||
public int PageSize { get; init; } = 20;
|
||||
|
||||
/// <summary>
|
||||
/// 排序字段(DueDate/CreatedAt/AmountDue)。
|
||||
/// </summary>
|
||||
public string? SortBy { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// 是否降序排序。
|
||||
/// </summary>
|
||||
public bool SortDesc { get; init; }
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
using MediatR;
|
||||
using TakeoutSaaS.Application.App.Billings.Dto;
|
||||
|
||||
namespace TakeoutSaaS.Application.App.Billings.Queries;
|
||||
|
||||
/// <summary>
|
||||
/// 查询账单的支付记录。
|
||||
/// </summary>
|
||||
public sealed record GetBillingPaymentsQuery : IRequest<List<PaymentRecordDto>>
|
||||
{
|
||||
/// <summary>
|
||||
/// 账单 ID(雪花算法)。
|
||||
/// </summary>
|
||||
public long BillingId { get; init; }
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
using MediatR;
|
||||
using TakeoutSaaS.Application.App.Billings.Dto;
|
||||
|
||||
namespace TakeoutSaaS.Application.App.Billings.Queries;
|
||||
|
||||
/// <summary>
|
||||
/// 查询账单统计数据。
|
||||
/// </summary>
|
||||
public sealed record GetBillingStatisticsQuery : IRequest<BillingStatisticsDto>
|
||||
{
|
||||
/// <summary>
|
||||
/// 租户 ID(可选,默认当前租户;禁止跨租户)。
|
||||
/// </summary>
|
||||
public long? TenantId { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// 统计开始时间(UTC)。
|
||||
/// </summary>
|
||||
public DateTime? StartDate { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// 统计结束时间(UTC)。
|
||||
/// </summary>
|
||||
public DateTime? EndDate { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// 分组方式(Day/Week/Month)。
|
||||
/// </summary>
|
||||
public string? GroupBy { get; init; }
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
using MediatR;
|
||||
using TakeoutSaaS.Application.App.Billings.Dto;
|
||||
using TakeoutSaaS.Shared.Abstractions.Results;
|
||||
|
||||
namespace TakeoutSaaS.Application.App.Billings.Queries;
|
||||
|
||||
/// <summary>
|
||||
/// 查询逾期账单列表。
|
||||
/// </summary>
|
||||
public sealed record GetOverdueBillingsQuery : IRequest<PagedResult<BillingListDto>>
|
||||
{
|
||||
/// <summary>
|
||||
/// 页码(从 1 开始)。
|
||||
/// </summary>
|
||||
public int PageNumber { get; init; } = 1;
|
||||
|
||||
/// <summary>
|
||||
/// 每页条数。
|
||||
/// </summary>
|
||||
public int PageSize { get; init; } = 20;
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
using MediatR;
|
||||
using TakeoutSaaS.Application.App.Billings.Dto;
|
||||
|
||||
namespace TakeoutSaaS.Application.App.Billings.Queries;
|
||||
|
||||
/// <summary>
|
||||
/// 获取租户支付记录查询。
|
||||
/// </summary>
|
||||
public sealed record GetTenantPaymentsQuery : IRequest<List<PaymentDto>>
|
||||
{
|
||||
/// <summary>
|
||||
/// 账单 ID(雪花算法)。
|
||||
/// </summary>
|
||||
public long BillId { get; init; }
|
||||
}
|
||||
@@ -0,0 +1,50 @@
|
||||
using FluentValidation;
|
||||
using TakeoutSaaS.Application.App.Billings.Commands;
|
||||
|
||||
namespace TakeoutSaaS.Application.App.Billings.Validators;
|
||||
|
||||
/// <summary>
|
||||
/// 一键确认收款命令验证器。
|
||||
/// </summary>
|
||||
public sealed class ConfirmPaymentCommandValidator : AbstractValidator<ConfirmPaymentCommand>
|
||||
{
|
||||
public ConfirmPaymentCommandValidator()
|
||||
{
|
||||
// 1. 账单 ID 必填
|
||||
RuleFor(x => x.BillingId)
|
||||
.GreaterThan(0)
|
||||
.WithMessage("账单 ID 必须大于 0");
|
||||
|
||||
// 2. 支付金额必须大于 0
|
||||
RuleFor(x => x.Amount)
|
||||
.GreaterThan(0)
|
||||
.WithMessage("支付金额必须大于 0")
|
||||
.LessThanOrEqualTo(1_000_000_000)
|
||||
.WithMessage("支付金额不能超过 10 亿");
|
||||
|
||||
// 3. 支付方式必填
|
||||
RuleFor(x => x.Method)
|
||||
.IsInEnum()
|
||||
.WithMessage("支付方式无效");
|
||||
|
||||
// 4. 交易号必填
|
||||
RuleFor(x => x.TransactionNo)
|
||||
.NotEmpty()
|
||||
.WithMessage("交易号不能为空")
|
||||
.MaximumLength(64)
|
||||
.WithMessage("交易号不能超过 64 个字符");
|
||||
|
||||
// 5. 支付凭证 URL(可选)
|
||||
RuleFor(x => x.ProofUrl)
|
||||
.MaximumLength(500)
|
||||
.WithMessage("支付凭证 URL 不能超过 500 个字符")
|
||||
.When(x => !string.IsNullOrWhiteSpace(x.ProofUrl));
|
||||
|
||||
// 6. 备注(可选)
|
||||
RuleFor(x => x.Notes)
|
||||
.MaximumLength(500)
|
||||
.WithMessage("备注不能超过 500 个字符")
|
||||
.When(x => !string.IsNullOrWhiteSpace(x.Notes));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,73 @@
|
||||
using FluentValidation;
|
||||
using TakeoutSaaS.Application.App.Billings.Commands;
|
||||
|
||||
namespace TakeoutSaaS.Application.App.Billings.Validators;
|
||||
|
||||
/// <summary>
|
||||
/// 创建账单命令验证器。
|
||||
/// </summary>
|
||||
public sealed class CreateBillingCommandValidator : AbstractValidator<CreateBillingCommand>
|
||||
{
|
||||
public CreateBillingCommandValidator()
|
||||
{
|
||||
// 1. 租户 ID 必填
|
||||
RuleFor(x => x.TenantId)
|
||||
.GreaterThan(0)
|
||||
.WithMessage("租户 ID 必须大于 0");
|
||||
|
||||
// 2. 账单类型必填
|
||||
RuleFor(x => x.BillingType)
|
||||
.IsInEnum()
|
||||
.WithMessage("账单类型无效");
|
||||
|
||||
// 3. 应付金额必须大于 0
|
||||
RuleFor(x => x.AmountDue)
|
||||
.GreaterThan(0)
|
||||
.WithMessage("应付金额必须大于 0");
|
||||
|
||||
// 4. 到期日必须是未来时间
|
||||
RuleFor(x => x.DueDate)
|
||||
.GreaterThan(DateTime.UtcNow)
|
||||
.WithMessage("到期日必须是未来时间");
|
||||
|
||||
// 5. 账单明细至少包含一项
|
||||
RuleFor(x => x.LineItems)
|
||||
.NotEmpty()
|
||||
.WithMessage("账单明细不能为空");
|
||||
|
||||
// 6. 账单明细项验证
|
||||
RuleForEach(x => x.LineItems)
|
||||
.ChildRules(lineItem =>
|
||||
{
|
||||
lineItem.RuleFor(x => x.ItemType)
|
||||
.NotEmpty()
|
||||
.WithMessage("账单明细类型不能为空")
|
||||
.MaximumLength(50)
|
||||
.WithMessage("账单明细类型不能超过 50 个字符");
|
||||
|
||||
lineItem.RuleFor(x => x.Description)
|
||||
.NotEmpty()
|
||||
.WithMessage("账单明细描述不能为空")
|
||||
.MaximumLength(200)
|
||||
.WithMessage("账单明细描述不能超过 200 个字符");
|
||||
|
||||
lineItem.RuleFor(x => x.Quantity)
|
||||
.GreaterThan(0)
|
||||
.WithMessage("账单明细数量必须大于 0");
|
||||
|
||||
lineItem.RuleFor(x => x.UnitPrice)
|
||||
.GreaterThanOrEqualTo(0)
|
||||
.WithMessage("账单明细单价不能为负数");
|
||||
|
||||
lineItem.RuleFor(x => x.Amount)
|
||||
.GreaterThanOrEqualTo(0)
|
||||
.WithMessage("账单明细金额不能为负数");
|
||||
});
|
||||
|
||||
// 7. 备注长度限制(可选)
|
||||
RuleFor(x => x.Notes)
|
||||
.MaximumLength(500)
|
||||
.WithMessage("备注不能超过 500 个字符")
|
||||
.When(x => !string.IsNullOrWhiteSpace(x.Notes));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,49 @@
|
||||
using FluentValidation;
|
||||
using TakeoutSaaS.Application.App.Billings.Commands;
|
||||
|
||||
namespace TakeoutSaaS.Application.App.Billings.Validators;
|
||||
|
||||
/// <summary>
|
||||
/// 记录支付命令验证器。
|
||||
/// </summary>
|
||||
public sealed class RecordPaymentCommandValidator : AbstractValidator<RecordPaymentCommand>
|
||||
{
|
||||
public RecordPaymentCommandValidator()
|
||||
{
|
||||
// 1. 账单 ID 必填
|
||||
RuleFor(x => x.BillingId)
|
||||
.GreaterThan(0)
|
||||
.WithMessage("账单 ID 必须大于 0");
|
||||
|
||||
// 2. 支付金额必须大于 0
|
||||
RuleFor(x => x.Amount)
|
||||
.GreaterThan(0)
|
||||
.WithMessage("支付金额必须大于 0")
|
||||
.LessThanOrEqualTo(1_000_000_000)
|
||||
.WithMessage("支付金额不能超过 10 亿");
|
||||
|
||||
// 3. 支付方式必填
|
||||
RuleFor(x => x.Method)
|
||||
.IsInEnum()
|
||||
.WithMessage("支付方式无效");
|
||||
|
||||
// 4. 交易号必填
|
||||
RuleFor(x => x.TransactionNo)
|
||||
.NotEmpty()
|
||||
.WithMessage("交易号不能为空")
|
||||
.MaximumLength(64)
|
||||
.WithMessage("交易号不能超过 64 个字符");
|
||||
|
||||
// 5. 支付凭证 URL(可选)
|
||||
RuleFor(x => x.ProofUrl)
|
||||
.MaximumLength(500)
|
||||
.WithMessage("支付凭证 URL 不能超过 500 个字符")
|
||||
.When(x => !string.IsNullOrWhiteSpace(x.ProofUrl));
|
||||
|
||||
// 6. 备注(可选)
|
||||
RuleFor(x => x.Notes)
|
||||
.MaximumLength(500)
|
||||
.WithMessage("备注不能超过 500 个字符")
|
||||
.When(x => !string.IsNullOrWhiteSpace(x.Notes));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
using FluentValidation;
|
||||
using TakeoutSaaS.Application.App.Billings.Commands;
|
||||
|
||||
namespace TakeoutSaaS.Application.App.Billings.Validators;
|
||||
|
||||
/// <summary>
|
||||
/// 更新账单状态命令验证器。
|
||||
/// </summary>
|
||||
public sealed class UpdateBillingStatusCommandValidator : AbstractValidator<UpdateBillingStatusCommand>
|
||||
{
|
||||
public UpdateBillingStatusCommandValidator()
|
||||
{
|
||||
// 1. 账单 ID 必填
|
||||
RuleFor(x => x.BillingId)
|
||||
.GreaterThan(0)
|
||||
.WithMessage("账单 ID 必须大于 0");
|
||||
|
||||
// 2. 状态枚举校验
|
||||
RuleFor(x => x.NewStatus)
|
||||
.IsInEnum()
|
||||
.WithMessage("新状态无效");
|
||||
|
||||
// 3. 备注长度限制(可选)
|
||||
RuleFor(x => x.Notes)
|
||||
.MaximumLength(500)
|
||||
.WithMessage("备注不能超过 500 个字符")
|
||||
.When(x => !string.IsNullOrWhiteSpace(x.Notes));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,66 @@
|
||||
using MediatR;
|
||||
using TakeoutSaaS.Application.App.Deliveries.Dto;
|
||||
using TakeoutSaaS.Domain.Deliveries.Enums;
|
||||
|
||||
namespace TakeoutSaaS.Application.App.Deliveries.Commands;
|
||||
|
||||
/// <summary>
|
||||
/// 创建配送单命令。
|
||||
/// </summary>
|
||||
public sealed class CreateDeliveryOrderCommand : IRequest<DeliveryOrderDto>
|
||||
{
|
||||
/// <summary>
|
||||
/// 订单 ID。
|
||||
/// </summary>
|
||||
public long OrderId { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 服务商。
|
||||
/// </summary>
|
||||
public DeliveryProvider Provider { get; set; } = DeliveryProvider.InHouse;
|
||||
|
||||
/// <summary>
|
||||
/// 第三方单号。
|
||||
/// </summary>
|
||||
public string? ProviderOrderId { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 状态。
|
||||
/// </summary>
|
||||
public DeliveryStatus Status { get; set; } = DeliveryStatus.Pending;
|
||||
|
||||
/// <summary>
|
||||
/// 配送费。
|
||||
/// </summary>
|
||||
public decimal? DeliveryFee { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 骑手姓名。
|
||||
/// </summary>
|
||||
public string? CourierName { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 骑手电话。
|
||||
/// </summary>
|
||||
public string? CourierPhone { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 下发时间。
|
||||
/// </summary>
|
||||
public DateTime? DispatchedAt { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 取餐时间。
|
||||
/// </summary>
|
||||
public DateTime? PickedUpAt { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 完成时间。
|
||||
/// </summary>
|
||||
public DateTime? DeliveredAt { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 异常原因。
|
||||
/// </summary>
|
||||
public string? FailureReason { get; set; }
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
using MediatR;
|
||||
|
||||
namespace TakeoutSaaS.Application.App.Deliveries.Commands;
|
||||
|
||||
/// <summary>
|
||||
/// 删除配送单命令。
|
||||
/// </summary>
|
||||
public sealed class DeleteDeliveryOrderCommand : IRequest<bool>
|
||||
{
|
||||
/// <summary>
|
||||
/// 配送单 ID。
|
||||
/// </summary>
|
||||
public long DeliveryOrderId { get; set; }
|
||||
}
|
||||
@@ -0,0 +1,71 @@
|
||||
using MediatR;
|
||||
using TakeoutSaaS.Application.App.Deliveries.Dto;
|
||||
using TakeoutSaaS.Domain.Deliveries.Enums;
|
||||
|
||||
namespace TakeoutSaaS.Application.App.Deliveries.Commands;
|
||||
|
||||
/// <summary>
|
||||
/// 更新配送单命令。
|
||||
/// </summary>
|
||||
public sealed record UpdateDeliveryOrderCommand : IRequest<DeliveryOrderDto?>
|
||||
{
|
||||
/// <summary>
|
||||
/// 配送单 ID。
|
||||
/// </summary>
|
||||
public long DeliveryOrderId { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// 订单 ID。
|
||||
/// </summary>
|
||||
public long OrderId { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// 服务商。
|
||||
/// </summary>
|
||||
public DeliveryProvider Provider { get; init; } = DeliveryProvider.InHouse;
|
||||
|
||||
/// <summary>
|
||||
/// 第三方单号。
|
||||
/// </summary>
|
||||
public string? ProviderOrderId { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// 状态。
|
||||
/// </summary>
|
||||
public DeliveryStatus Status { get; init; } = DeliveryStatus.Pending;
|
||||
|
||||
/// <summary>
|
||||
/// 配送费。
|
||||
/// </summary>
|
||||
public decimal? DeliveryFee { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// 骑手姓名。
|
||||
/// </summary>
|
||||
public string? CourierName { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// 骑手电话。
|
||||
/// </summary>
|
||||
public string? CourierPhone { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// 下发时间。
|
||||
/// </summary>
|
||||
public DateTime? DispatchedAt { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// 取餐时间。
|
||||
/// </summary>
|
||||
public DateTime? PickedUpAt { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// 完成时间。
|
||||
/// </summary>
|
||||
public DateTime? DeliveredAt { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// 异常原因。
|
||||
/// </summary>
|
||||
public string? FailureReason { get; init; }
|
||||
}
|
||||
@@ -0,0 +1,43 @@
|
||||
using System.Text.Json.Serialization;
|
||||
using TakeoutSaaS.Domain.Deliveries.Enums;
|
||||
using TakeoutSaaS.Shared.Abstractions.Serialization;
|
||||
|
||||
namespace TakeoutSaaS.Application.App.Deliveries.Dto;
|
||||
|
||||
/// <summary>
|
||||
/// 配送事件 DTO。
|
||||
/// </summary>
|
||||
public sealed class DeliveryEventDto
|
||||
{
|
||||
/// <summary>
|
||||
/// 事件 ID。
|
||||
/// </summary>
|
||||
[JsonConverter(typeof(SnowflakeIdJsonConverter))]
|
||||
public long Id { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// 配送单 ID。
|
||||
/// </summary>
|
||||
[JsonConverter(typeof(SnowflakeIdJsonConverter))]
|
||||
public long DeliveryOrderId { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// 事件类型。
|
||||
/// </summary>
|
||||
public DeliveryEventType EventType { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// 描述。
|
||||
/// </summary>
|
||||
public string? Message { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// 事件时间。
|
||||
/// </summary>
|
||||
public DateTime OccurredAt { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// 原始载荷。
|
||||
/// </summary>
|
||||
public string? Payload { get; init; }
|
||||
}
|
||||
@@ -0,0 +1,89 @@
|
||||
using System.Text.Json.Serialization;
|
||||
using TakeoutSaaS.Domain.Deliveries.Enums;
|
||||
using TakeoutSaaS.Shared.Abstractions.Serialization;
|
||||
|
||||
namespace TakeoutSaaS.Application.App.Deliveries.Dto;
|
||||
|
||||
/// <summary>
|
||||
/// 配送单 DTO。
|
||||
/// </summary>
|
||||
public sealed class DeliveryOrderDto
|
||||
{
|
||||
/// <summary>
|
||||
/// 配送单 ID。
|
||||
/// </summary>
|
||||
[JsonConverter(typeof(SnowflakeIdJsonConverter))]
|
||||
public long Id { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// 租户 ID。
|
||||
/// </summary>
|
||||
[JsonConverter(typeof(SnowflakeIdJsonConverter))]
|
||||
public long TenantId { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// 订单 ID。
|
||||
/// </summary>
|
||||
[JsonConverter(typeof(SnowflakeIdJsonConverter))]
|
||||
public long OrderId { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// 配送服务商。
|
||||
/// </summary>
|
||||
public DeliveryProvider Provider { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// 第三方配送单号。
|
||||
/// </summary>
|
||||
public string? ProviderOrderId { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// 状态。
|
||||
/// </summary>
|
||||
public DeliveryStatus Status { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// 配送费。
|
||||
/// </summary>
|
||||
public decimal? DeliveryFee { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// 骑手姓名。
|
||||
/// </summary>
|
||||
public string? CourierName { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// 骑手电话。
|
||||
/// </summary>
|
||||
public string? CourierPhone { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// 下发时间。
|
||||
/// </summary>
|
||||
public DateTime? DispatchedAt { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// 取餐时间。
|
||||
/// </summary>
|
||||
public DateTime? PickedUpAt { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// 完成时间。
|
||||
/// </summary>
|
||||
public DateTime? DeliveredAt { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// 异常原因。
|
||||
/// </summary>
|
||||
public string? FailureReason { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// 事件列表。
|
||||
/// </summary>
|
||||
public IReadOnlyList<DeliveryEventDto> Events { get; init; } = Array.Empty<DeliveryEventDto>();
|
||||
|
||||
/// <summary>
|
||||
/// 创建时间。
|
||||
/// </summary>
|
||||
public DateTime CreatedAt { get; init; }
|
||||
}
|
||||
@@ -0,0 +1,72 @@
|
||||
using MediatR;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using TakeoutSaaS.Application.App.Deliveries.Commands;
|
||||
using TakeoutSaaS.Application.App.Deliveries.Dto;
|
||||
using TakeoutSaaS.Domain.Deliveries.Entities;
|
||||
using TakeoutSaaS.Domain.Deliveries.Repositories;
|
||||
|
||||
namespace TakeoutSaaS.Application.App.Deliveries.Handlers;
|
||||
|
||||
/// <summary>
|
||||
/// 创建配送单命令处理器。
|
||||
/// </summary>
|
||||
public sealed class CreateDeliveryOrderCommandHandler(IDeliveryRepository deliveryRepository, ILogger<CreateDeliveryOrderCommandHandler> logger)
|
||||
: IRequestHandler<CreateDeliveryOrderCommand, DeliveryOrderDto>
|
||||
{
|
||||
/// <inheritdoc />
|
||||
public async Task<DeliveryOrderDto> Handle(CreateDeliveryOrderCommand request, CancellationToken cancellationToken)
|
||||
{
|
||||
// 1. 构建配送单实体
|
||||
var deliveryOrder = new DeliveryOrder
|
||||
{
|
||||
OrderId = request.OrderId,
|
||||
Provider = request.Provider,
|
||||
ProviderOrderId = request.ProviderOrderId?.Trim(),
|
||||
Status = request.Status,
|
||||
DeliveryFee = request.DeliveryFee,
|
||||
CourierName = request.CourierName?.Trim(),
|
||||
CourierPhone = request.CourierPhone?.Trim(),
|
||||
DispatchedAt = request.DispatchedAt,
|
||||
PickedUpAt = request.PickedUpAt,
|
||||
DeliveredAt = request.DeliveredAt,
|
||||
FailureReason = request.FailureReason?.Trim()
|
||||
};
|
||||
|
||||
// 2. 持久化配送单
|
||||
await deliveryRepository.AddDeliveryOrderAsync(deliveryOrder, cancellationToken);
|
||||
await deliveryRepository.SaveChangesAsync(cancellationToken);
|
||||
|
||||
// 3. 记录日志
|
||||
logger.LogInformation("创建配送单 {DeliveryOrderId} 对应订单 {OrderId}", deliveryOrder.Id, deliveryOrder.OrderId);
|
||||
|
||||
// 4. 映射 DTO 返回
|
||||
return MapToDto(deliveryOrder, []);
|
||||
}
|
||||
|
||||
private static DeliveryOrderDto MapToDto(DeliveryOrder deliveryOrder, IReadOnlyList<DeliveryEvent> events) => new()
|
||||
{
|
||||
Id = deliveryOrder.Id,
|
||||
TenantId = deliveryOrder.TenantId,
|
||||
OrderId = deliveryOrder.OrderId,
|
||||
Provider = deliveryOrder.Provider,
|
||||
ProviderOrderId = deliveryOrder.ProviderOrderId,
|
||||
Status = deliveryOrder.Status,
|
||||
DeliveryFee = deliveryOrder.DeliveryFee,
|
||||
CourierName = deliveryOrder.CourierName,
|
||||
CourierPhone = deliveryOrder.CourierPhone,
|
||||
DispatchedAt = deliveryOrder.DispatchedAt,
|
||||
PickedUpAt = deliveryOrder.PickedUpAt,
|
||||
DeliveredAt = deliveryOrder.DeliveredAt,
|
||||
FailureReason = deliveryOrder.FailureReason,
|
||||
CreatedAt = deliveryOrder.CreatedAt,
|
||||
Events = events.Select(x => new DeliveryEventDto
|
||||
{
|
||||
Id = x.Id,
|
||||
DeliveryOrderId = x.DeliveryOrderId,
|
||||
EventType = x.EventType,
|
||||
Message = x.Message,
|
||||
OccurredAt = x.OccurredAt,
|
||||
Payload = x.Payload
|
||||
}).ToList()
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
using MediatR;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using TakeoutSaaS.Application.App.Deliveries.Commands;
|
||||
using TakeoutSaaS.Domain.Deliveries.Repositories;
|
||||
using TakeoutSaaS.Shared.Abstractions.Tenancy;
|
||||
|
||||
namespace TakeoutSaaS.Application.App.Deliveries.Handlers;
|
||||
|
||||
/// <summary>
|
||||
/// 删除配送单命令处理器。
|
||||
/// </summary>
|
||||
public sealed class DeleteDeliveryOrderCommandHandler(
|
||||
IDeliveryRepository deliveryRepository,
|
||||
ITenantProvider tenantProvider,
|
||||
ILogger<DeleteDeliveryOrderCommandHandler> logger)
|
||||
: IRequestHandler<DeleteDeliveryOrderCommand, bool>
|
||||
{
|
||||
/// <inheritdoc />
|
||||
public async Task<bool> Handle(DeleteDeliveryOrderCommand request, CancellationToken cancellationToken)
|
||||
{
|
||||
// 1. 获取租户并定位配送单
|
||||
var tenantId = tenantProvider.GetCurrentTenantId();
|
||||
var existing = await deliveryRepository.FindByIdAsync(request.DeliveryOrderId, tenantId, cancellationToken);
|
||||
if (existing == null)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
// 2. 删除并保存
|
||||
await deliveryRepository.DeleteDeliveryOrderAsync(request.DeliveryOrderId, tenantId, cancellationToken);
|
||||
await deliveryRepository.SaveChangesAsync(cancellationToken);
|
||||
|
||||
// 3. 记录删除日志
|
||||
logger.LogInformation("删除配送单 {DeliveryOrderId}", request.DeliveryOrderId);
|
||||
|
||||
return true;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,64 @@
|
||||
using MediatR;
|
||||
using TakeoutSaaS.Application.App.Deliveries.Dto;
|
||||
using TakeoutSaaS.Application.App.Deliveries.Queries;
|
||||
using TakeoutSaaS.Domain.Deliveries.Entities;
|
||||
using TakeoutSaaS.Domain.Deliveries.Repositories;
|
||||
using TakeoutSaaS.Shared.Abstractions.Tenancy;
|
||||
|
||||
namespace TakeoutSaaS.Application.App.Deliveries.Handlers;
|
||||
|
||||
/// <summary>
|
||||
/// 配送单详情查询处理器。
|
||||
/// </summary>
|
||||
public sealed class GetDeliveryOrderByIdQueryHandler(
|
||||
IDeliveryRepository deliveryRepository,
|
||||
ITenantProvider tenantProvider)
|
||||
: IRequestHandler<GetDeliveryOrderByIdQuery, DeliveryOrderDto?>
|
||||
{
|
||||
/// <inheritdoc />
|
||||
public async Task<DeliveryOrderDto?> Handle(GetDeliveryOrderByIdQuery request, CancellationToken cancellationToken)
|
||||
{
|
||||
// 1. 读取当前租户标识
|
||||
var tenantId = tenantProvider.GetCurrentTenantId();
|
||||
|
||||
// 2. 查询配送单主体
|
||||
var order = await deliveryRepository.FindByIdAsync(request.DeliveryOrderId, tenantId, cancellationToken);
|
||||
if (order == null)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
// 3. 查询配送事件明细
|
||||
var events = await deliveryRepository.GetEventsAsync(order.Id, tenantId, cancellationToken);
|
||||
|
||||
// 4. 映射为 DTO 返回
|
||||
return MapToDto(order, events);
|
||||
}
|
||||
|
||||
private static DeliveryOrderDto MapToDto(DeliveryOrder deliveryOrder, IReadOnlyList<DeliveryEvent> events) => new()
|
||||
{
|
||||
Id = deliveryOrder.Id,
|
||||
TenantId = deliveryOrder.TenantId,
|
||||
OrderId = deliveryOrder.OrderId,
|
||||
Provider = deliveryOrder.Provider,
|
||||
ProviderOrderId = deliveryOrder.ProviderOrderId,
|
||||
Status = deliveryOrder.Status,
|
||||
DeliveryFee = deliveryOrder.DeliveryFee,
|
||||
CourierName = deliveryOrder.CourierName,
|
||||
CourierPhone = deliveryOrder.CourierPhone,
|
||||
DispatchedAt = deliveryOrder.DispatchedAt,
|
||||
PickedUpAt = deliveryOrder.PickedUpAt,
|
||||
DeliveredAt = deliveryOrder.DeliveredAt,
|
||||
FailureReason = deliveryOrder.FailureReason,
|
||||
CreatedAt = deliveryOrder.CreatedAt,
|
||||
Events = events.Select(x => new DeliveryEventDto
|
||||
{
|
||||
Id = x.Id,
|
||||
DeliveryOrderId = x.DeliveryOrderId,
|
||||
EventType = x.EventType,
|
||||
Message = x.Message,
|
||||
OccurredAt = x.OccurredAt,
|
||||
Payload = x.Payload
|
||||
}).ToList()
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,71 @@
|
||||
using MediatR;
|
||||
using TakeoutSaaS.Application.App.Deliveries.Dto;
|
||||
using TakeoutSaaS.Application.App.Deliveries.Queries;
|
||||
using TakeoutSaaS.Domain.Deliveries.Repositories;
|
||||
using TakeoutSaaS.Shared.Abstractions.Results;
|
||||
using TakeoutSaaS.Shared.Abstractions.Tenancy;
|
||||
|
||||
namespace TakeoutSaaS.Application.App.Deliveries.Handlers;
|
||||
|
||||
/// <summary>
|
||||
/// 配送单列表查询处理器。
|
||||
/// </summary>
|
||||
public sealed class SearchDeliveryOrdersQueryHandler(
|
||||
IDeliveryRepository deliveryRepository,
|
||||
ITenantProvider tenantProvider)
|
||||
: IRequestHandler<SearchDeliveryOrdersQuery, PagedResult<DeliveryOrderDto>>
|
||||
{
|
||||
/// <inheritdoc />
|
||||
public async Task<PagedResult<DeliveryOrderDto>> Handle(SearchDeliveryOrdersQuery request, CancellationToken cancellationToken)
|
||||
{
|
||||
// 1. 获取当前租户标识
|
||||
var tenantId = tenantProvider.GetCurrentTenantId();
|
||||
|
||||
// 2. 查询配送单列表(租户隔离)
|
||||
var orders = await deliveryRepository.SearchAsync(tenantId, request.Status, request.OrderId, cancellationToken);
|
||||
|
||||
// 3. 本地排序
|
||||
var sorted = ApplySorting(orders, request.SortBy, request.SortDescending);
|
||||
|
||||
// 4. 本地分页
|
||||
var paged = sorted
|
||||
.Skip((request.Page - 1) * request.PageSize)
|
||||
.Take(request.PageSize)
|
||||
.ToList();
|
||||
|
||||
// 5. 映射 DTO
|
||||
var items = paged.Select(order => new DeliveryOrderDto
|
||||
{
|
||||
Id = order.Id,
|
||||
TenantId = order.TenantId,
|
||||
OrderId = order.OrderId,
|
||||
Provider = order.Provider,
|
||||
ProviderOrderId = order.ProviderOrderId,
|
||||
Status = order.Status,
|
||||
DeliveryFee = order.DeliveryFee,
|
||||
CourierName = order.CourierName,
|
||||
CourierPhone = order.CourierPhone,
|
||||
DispatchedAt = order.DispatchedAt,
|
||||
PickedUpAt = order.PickedUpAt,
|
||||
DeliveredAt = order.DeliveredAt,
|
||||
FailureReason = order.FailureReason,
|
||||
CreatedAt = order.CreatedAt
|
||||
}).ToList();
|
||||
|
||||
// 6. 返回分页结果
|
||||
return new PagedResult<DeliveryOrderDto>(items, request.Page, request.PageSize, orders.Count);
|
||||
}
|
||||
|
||||
private static IOrderedEnumerable<Domain.Deliveries.Entities.DeliveryOrder> ApplySorting(
|
||||
IReadOnlyCollection<Domain.Deliveries.Entities.DeliveryOrder> orders,
|
||||
string? sortBy,
|
||||
bool sortDescending)
|
||||
{
|
||||
return sortBy?.ToLowerInvariant() switch
|
||||
{
|
||||
"status" => sortDescending ? orders.OrderByDescending(x => x.Status) : orders.OrderBy(x => x.Status),
|
||||
"provider" => sortDescending ? orders.OrderByDescending(x => x.Provider) : orders.OrderBy(x => x.Provider),
|
||||
_ => sortDescending ? orders.OrderByDescending(x => x.CreatedAt) : orders.OrderBy(x => x.CreatedAt)
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,84 @@
|
||||
using MediatR;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using TakeoutSaaS.Application.App.Deliveries.Commands;
|
||||
using TakeoutSaaS.Application.App.Deliveries.Dto;
|
||||
using TakeoutSaaS.Domain.Deliveries.Entities;
|
||||
using TakeoutSaaS.Domain.Deliveries.Repositories;
|
||||
using TakeoutSaaS.Shared.Abstractions.Tenancy;
|
||||
|
||||
namespace TakeoutSaaS.Application.App.Deliveries.Handlers;
|
||||
|
||||
/// <summary>
|
||||
/// 更新配送单命令处理器。
|
||||
/// </summary>
|
||||
public sealed class UpdateDeliveryOrderCommandHandler(
|
||||
IDeliveryRepository deliveryRepository,
|
||||
ITenantProvider tenantProvider,
|
||||
ILogger<UpdateDeliveryOrderCommandHandler> logger)
|
||||
: IRequestHandler<UpdateDeliveryOrderCommand, DeliveryOrderDto?>
|
||||
{
|
||||
/// <inheritdoc />
|
||||
public async Task<DeliveryOrderDto?> Handle(UpdateDeliveryOrderCommand request, CancellationToken cancellationToken)
|
||||
{
|
||||
// 1. 获取当前租户标识
|
||||
var tenantId = tenantProvider.GetCurrentTenantId();
|
||||
|
||||
// 2. 查询目标配送单
|
||||
var existing = await deliveryRepository.FindByIdAsync(request.DeliveryOrderId, tenantId, cancellationToken);
|
||||
if (existing == null)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
// 3. 更新字段
|
||||
existing.OrderId = request.OrderId;
|
||||
existing.Provider = request.Provider;
|
||||
existing.ProviderOrderId = request.ProviderOrderId?.Trim();
|
||||
existing.Status = request.Status;
|
||||
existing.DeliveryFee = request.DeliveryFee;
|
||||
existing.CourierName = request.CourierName?.Trim();
|
||||
existing.CourierPhone = request.CourierPhone?.Trim();
|
||||
existing.DispatchedAt = request.DispatchedAt;
|
||||
existing.PickedUpAt = request.PickedUpAt;
|
||||
existing.DeliveredAt = request.DeliveredAt;
|
||||
existing.FailureReason = request.FailureReason?.Trim();
|
||||
|
||||
// 4. 持久化变更
|
||||
await deliveryRepository.UpdateDeliveryOrderAsync(existing, cancellationToken);
|
||||
await deliveryRepository.SaveChangesAsync(cancellationToken);
|
||||
|
||||
// 5. 记录更新日志
|
||||
logger.LogInformation("更新配送单 {DeliveryOrderId}", existing.Id);
|
||||
|
||||
// 6. 查询事件并返回映射结果
|
||||
var events = await deliveryRepository.GetEventsAsync(existing.Id, tenantId, cancellationToken);
|
||||
return MapToDto(existing, events);
|
||||
}
|
||||
|
||||
private static DeliveryOrderDto MapToDto(DeliveryOrder deliveryOrder, IReadOnlyList<DeliveryEvent> events) => new()
|
||||
{
|
||||
Id = deliveryOrder.Id,
|
||||
TenantId = deliveryOrder.TenantId,
|
||||
OrderId = deliveryOrder.OrderId,
|
||||
Provider = deliveryOrder.Provider,
|
||||
ProviderOrderId = deliveryOrder.ProviderOrderId,
|
||||
Status = deliveryOrder.Status,
|
||||
DeliveryFee = deliveryOrder.DeliveryFee,
|
||||
CourierName = deliveryOrder.CourierName,
|
||||
CourierPhone = deliveryOrder.CourierPhone,
|
||||
DispatchedAt = deliveryOrder.DispatchedAt,
|
||||
PickedUpAt = deliveryOrder.PickedUpAt,
|
||||
DeliveredAt = deliveryOrder.DeliveredAt,
|
||||
FailureReason = deliveryOrder.FailureReason,
|
||||
CreatedAt = deliveryOrder.CreatedAt,
|
||||
Events = events.Select(x => new DeliveryEventDto
|
||||
{
|
||||
Id = x.Id,
|
||||
DeliveryOrderId = x.DeliveryOrderId,
|
||||
EventType = x.EventType,
|
||||
Message = x.Message,
|
||||
OccurredAt = x.OccurredAt,
|
||||
Payload = x.Payload
|
||||
}).ToList()
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
using MediatR;
|
||||
using TakeoutSaaS.Application.App.Deliveries.Dto;
|
||||
|
||||
namespace TakeoutSaaS.Application.App.Deliveries.Queries;
|
||||
|
||||
/// <summary>
|
||||
/// 配送单详情查询。
|
||||
/// </summary>
|
||||
public sealed class GetDeliveryOrderByIdQuery : IRequest<DeliveryOrderDto?>
|
||||
{
|
||||
/// <summary>
|
||||
/// 配送单 ID。
|
||||
/// </summary>
|
||||
public long DeliveryOrderId { get; init; }
|
||||
}
|
||||
@@ -0,0 +1,42 @@
|
||||
using MediatR;
|
||||
using TakeoutSaaS.Application.App.Deliveries.Dto;
|
||||
using TakeoutSaaS.Domain.Deliveries.Enums;
|
||||
using TakeoutSaaS.Shared.Abstractions.Results;
|
||||
|
||||
namespace TakeoutSaaS.Application.App.Deliveries.Queries;
|
||||
|
||||
/// <summary>
|
||||
/// 配送单列表查询。
|
||||
/// </summary>
|
||||
public sealed class SearchDeliveryOrdersQuery : IRequest<PagedResult<DeliveryOrderDto>>
|
||||
{
|
||||
/// <summary>
|
||||
/// 订单 ID(可选)。
|
||||
/// </summary>
|
||||
public long? OrderId { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// 配送状态。
|
||||
/// </summary>
|
||||
public DeliveryStatus? Status { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// 页码。
|
||||
/// </summary>
|
||||
public int Page { get; init; } = 1;
|
||||
|
||||
/// <summary>
|
||||
/// 每页条数。
|
||||
/// </summary>
|
||||
public int PageSize { get; init; } = 20;
|
||||
|
||||
/// <summary>
|
||||
/// 排序字段(createdAt/status/provider)。
|
||||
/// </summary>
|
||||
public string? SortBy { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// 是否倒序。
|
||||
/// </summary>
|
||||
public bool SortDescending { get; init; } = true;
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
using FluentValidation;
|
||||
using TakeoutSaaS.Application.App.Deliveries.Commands;
|
||||
|
||||
namespace TakeoutSaaS.Application.App.Deliveries.Validators;
|
||||
|
||||
/// <summary>
|
||||
/// 创建配送单命令验证器。
|
||||
/// </summary>
|
||||
public sealed class CreateDeliveryOrderCommandValidator : AbstractValidator<CreateDeliveryOrderCommand>
|
||||
{
|
||||
/// <summary>
|
||||
/// 初始化验证规则。
|
||||
/// </summary>
|
||||
public CreateDeliveryOrderCommandValidator()
|
||||
{
|
||||
RuleFor(x => x.OrderId).GreaterThan(0);
|
||||
RuleFor(x => x.ProviderOrderId).MaximumLength(64);
|
||||
RuleFor(x => x.CourierName).MaximumLength(64);
|
||||
RuleFor(x => x.CourierPhone).MaximumLength(32);
|
||||
RuleFor(x => x.FailureReason).MaximumLength(256);
|
||||
RuleFor(x => x.DeliveryFee).GreaterThanOrEqualTo(0).When(x => x.DeliveryFee.HasValue);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
using FluentValidation;
|
||||
using TakeoutSaaS.Application.App.Deliveries.Queries;
|
||||
|
||||
namespace TakeoutSaaS.Application.App.Deliveries.Validators;
|
||||
|
||||
/// <summary>
|
||||
/// 配送单列表查询验证器。
|
||||
/// </summary>
|
||||
public sealed class SearchDeliveryOrdersQueryValidator : AbstractValidator<SearchDeliveryOrdersQuery>
|
||||
{
|
||||
/// <summary>
|
||||
/// 初始化验证规则。
|
||||
/// </summary>
|
||||
public SearchDeliveryOrdersQueryValidator()
|
||||
{
|
||||
RuleFor(x => x.Page).GreaterThan(0);
|
||||
RuleFor(x => x.PageSize).InclusiveBetween(1, 200);
|
||||
RuleFor(x => x.SortBy).MaximumLength(64);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
using FluentValidation;
|
||||
using TakeoutSaaS.Application.App.Deliveries.Commands;
|
||||
|
||||
namespace TakeoutSaaS.Application.App.Deliveries.Validators;
|
||||
|
||||
/// <summary>
|
||||
/// 更新配送单命令验证器。
|
||||
/// </summary>
|
||||
public sealed class UpdateDeliveryOrderCommandValidator : AbstractValidator<UpdateDeliveryOrderCommand>
|
||||
{
|
||||
/// <summary>
|
||||
/// 初始化验证规则。
|
||||
/// </summary>
|
||||
public UpdateDeliveryOrderCommandValidator()
|
||||
{
|
||||
RuleFor(x => x.DeliveryOrderId).GreaterThan(0);
|
||||
RuleFor(x => x.OrderId).GreaterThan(0);
|
||||
RuleFor(x => x.ProviderOrderId).MaximumLength(64);
|
||||
RuleFor(x => x.CourierName).MaximumLength(64);
|
||||
RuleFor(x => x.CourierPhone).MaximumLength(32);
|
||||
RuleFor(x => x.FailureReason).MaximumLength(256);
|
||||
RuleFor(x => x.DeliveryFee).GreaterThanOrEqualTo(0).When(x => x.DeliveryFee.HasValue);
|
||||
}
|
||||
}
|
||||
@@ -3,6 +3,8 @@ using MediatR;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using System.Reflection;
|
||||
using TakeoutSaaS.Application.App.Common.Behaviors;
|
||||
using TakeoutSaaS.Application.App.Personal.Services;
|
||||
using TakeoutSaaS.Application.App.Personal.Validators;
|
||||
using TakeoutSaaS.Application.App.Stores.Services;
|
||||
|
||||
namespace TakeoutSaaS.Application.App.Extensions;
|
||||
@@ -13,23 +15,26 @@ namespace TakeoutSaaS.Application.App.Extensions;
|
||||
public static class AppApplicationServiceCollectionExtensions
|
||||
{
|
||||
/// <summary>
|
||||
/// 注册业务应用层(MediatR、验证器、管道行为)。
|
||||
/// 注册业务应用层(MediatR 处理器等)。
|
||||
/// </summary>
|
||||
/// <param name="services">服务集合。</param>
|
||||
/// <returns>服务集合。</returns>
|
||||
public static IServiceCollection AddAppApplication(this IServiceCollection services)
|
||||
{
|
||||
// 1. 注册 MediatR 处理器
|
||||
services.AddMediatR(cfg => cfg.RegisterServicesFromAssembly(Assembly.GetExecutingAssembly()));
|
||||
|
||||
// 2. 注册 FluentValidation 验证器
|
||||
services.AddValidatorsFromAssembly(Assembly.GetExecutingAssembly());
|
||||
|
||||
// 3. 注册统一验证管道
|
||||
services.AddTransient(typeof(IPipelineBehavior<,>), typeof(ValidationBehavior<,>));
|
||||
|
||||
// 4. 注册门店模块上下文服务
|
||||
// 1. 注册个人中心基础服务
|
||||
services.AddScoped<PersonalContextService>();
|
||||
services.AddSingleton<PersonalMaskingService>();
|
||||
services.AddSingleton<PersonalDateRangeValidator>();
|
||||
services.AddScoped<PersonalModuleStatusService>();
|
||||
services.AddScoped<PersonalAuditService>();
|
||||
|
||||
// 2. 注册门店模块上下文服务
|
||||
services.AddScoped<StoreContextService>();
|
||||
|
||||
return services;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,46 @@
|
||||
using MediatR;
|
||||
using TakeoutSaaS.Application.App.Inventory.Dto;
|
||||
using TakeoutSaaS.Domain.Inventory.Enums;
|
||||
|
||||
namespace TakeoutSaaS.Application.App.Inventory.Commands;
|
||||
|
||||
/// <summary>
|
||||
/// 库存调整命令。
|
||||
/// </summary>
|
||||
public sealed record AdjustInventoryCommand : IRequest<InventoryItemDto>
|
||||
{
|
||||
/// <summary>
|
||||
/// 门店 ID。
|
||||
/// </summary>
|
||||
public long StoreId { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// SKU ID。
|
||||
/// </summary>
|
||||
public long ProductSkuId { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// 调整数量,正数入库,负数出库。
|
||||
/// </summary>
|
||||
public int QuantityDelta { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// 调整类型。
|
||||
/// </summary>
|
||||
public InventoryAdjustmentType AdjustmentType { get; init; } = InventoryAdjustmentType.Manual;
|
||||
|
||||
/// <summary>
|
||||
/// 原因说明。
|
||||
/// </summary>
|
||||
public string? Reason { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// 安全库存阈值(可选)。
|
||||
/// </summary>
|
||||
public int? SafetyStock { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// 是否售罄标记。
|
||||
/// </summary>
|
||||
public bool? IsSoldOut { get; init; }
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
using MediatR;
|
||||
using TakeoutSaaS.Application.App.Inventory.Dto;
|
||||
|
||||
namespace TakeoutSaaS.Application.App.Inventory.Commands;
|
||||
|
||||
/// <summary>
|
||||
/// 扣减库存命令(履约/支付成功)。
|
||||
/// </summary>
|
||||
public sealed record DeductInventoryCommand : IRequest<InventoryItemDto>
|
||||
{
|
||||
/// <summary>
|
||||
/// 门店 ID。
|
||||
/// </summary>
|
||||
public long StoreId { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// SKU ID。
|
||||
/// </summary>
|
||||
public long ProductSkuId { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// 扣减数量。
|
||||
/// </summary>
|
||||
public int Quantity { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// 是否预售锁定转扣减。
|
||||
/// </summary>
|
||||
public bool IsPresaleOrder { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// 幂等键(与锁定请求一致可避免重复扣减)。
|
||||
/// </summary>
|
||||
public string? IdempotencyKey { get; init; }
|
||||
}
|
||||
@@ -0,0 +1,41 @@
|
||||
using System;
|
||||
using MediatR;
|
||||
using TakeoutSaaS.Application.App.Inventory.Dto;
|
||||
|
||||
namespace TakeoutSaaS.Application.App.Inventory.Commands;
|
||||
|
||||
/// <summary>
|
||||
/// 锁定库存命令。
|
||||
/// </summary>
|
||||
public sealed record LockInventoryCommand : IRequest<InventoryItemDto>
|
||||
{
|
||||
/// <summary>
|
||||
/// 门店 ID。
|
||||
/// </summary>
|
||||
public long StoreId { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// SKU ID。
|
||||
/// </summary>
|
||||
public long ProductSkuId { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// 锁定数量。
|
||||
/// </summary>
|
||||
public int Quantity { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// 是否按预售逻辑锁定。
|
||||
/// </summary>
|
||||
public bool IsPresaleOrder { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// 锁定过期时间(UTC),超时可释放。
|
||||
/// </summary>
|
||||
public DateTime? ExpiresAt { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// 幂等键(同一键重复调用返回同一结果)。
|
||||
/// </summary>
|
||||
public string IdempotencyKey { get; init; } = string.Empty;
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
using MediatR;
|
||||
|
||||
namespace TakeoutSaaS.Application.App.Inventory.Commands;
|
||||
|
||||
/// <summary>
|
||||
/// 释放过期库存锁定命令。
|
||||
/// </summary>
|
||||
public sealed record ReleaseExpiredInventoryLocksCommand : IRequest<int>;
|
||||
@@ -0,0 +1,35 @@
|
||||
using MediatR;
|
||||
using TakeoutSaaS.Application.App.Inventory.Dto;
|
||||
|
||||
namespace TakeoutSaaS.Application.App.Inventory.Commands;
|
||||
|
||||
/// <summary>
|
||||
/// 释放库存命令。
|
||||
/// </summary>
|
||||
public sealed record ReleaseInventoryCommand : IRequest<InventoryItemDto>
|
||||
{
|
||||
/// <summary>
|
||||
/// 门店 ID。
|
||||
/// </summary>
|
||||
public long StoreId { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// SKU ID。
|
||||
/// </summary>
|
||||
public long ProductSkuId { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// 释放数量。
|
||||
/// </summary>
|
||||
public int Quantity { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// 是否预售锁定释放。
|
||||
/// </summary>
|
||||
public bool IsPresaleOrder { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// 幂等键(与锁定请求一致可避免重复释放)。
|
||||
/// </summary>
|
||||
public string? IdempotencyKey { get; init; }
|
||||
}
|
||||
@@ -0,0 +1,45 @@
|
||||
using MediatR;
|
||||
using TakeoutSaaS.Application.App.Inventory.Dto;
|
||||
|
||||
namespace TakeoutSaaS.Application.App.Inventory.Commands;
|
||||
|
||||
/// <summary>
|
||||
/// 新增或更新库存批次命令。
|
||||
/// </summary>
|
||||
public sealed record UpsertInventoryBatchCommand : IRequest<InventoryBatchDto>
|
||||
{
|
||||
/// <summary>
|
||||
/// 门店 ID。
|
||||
/// </summary>
|
||||
public long StoreId { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// SKU ID。
|
||||
/// </summary>
|
||||
public long ProductSkuId { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// 批次号。
|
||||
/// </summary>
|
||||
public string BatchNumber { get; init; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// 生产日期。
|
||||
/// </summary>
|
||||
public DateTime? ProductionDate { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// 过期日期。
|
||||
/// </summary>
|
||||
public DateTime? ExpireDate { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// 入库数量。
|
||||
/// </summary>
|
||||
public int Quantity { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// 剩余数量。
|
||||
/// </summary>
|
||||
public int RemainingQuantity { get; init; }
|
||||
}
|
||||
@@ -0,0 +1,53 @@
|
||||
using System.Text.Json.Serialization;
|
||||
using TakeoutSaaS.Shared.Abstractions.Serialization;
|
||||
|
||||
namespace TakeoutSaaS.Application.App.Inventory.Dto;
|
||||
|
||||
/// <summary>
|
||||
/// 库存批次 DTO。
|
||||
/// </summary>
|
||||
public sealed record InventoryBatchDto
|
||||
{
|
||||
/// <summary>
|
||||
/// 批次 ID。
|
||||
/// </summary>
|
||||
[JsonConverter(typeof(SnowflakeIdJsonConverter))]
|
||||
public long Id { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// 门店 ID。
|
||||
/// </summary>
|
||||
[JsonConverter(typeof(SnowflakeIdJsonConverter))]
|
||||
public long StoreId { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// SKU ID。
|
||||
/// </summary>
|
||||
[JsonConverter(typeof(SnowflakeIdJsonConverter))]
|
||||
public long ProductSkuId { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// 批次号。
|
||||
/// </summary>
|
||||
public string BatchNumber { get; init; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// 生产日期。
|
||||
/// </summary>
|
||||
public DateTime? ProductionDate { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// 过期日期。
|
||||
/// </summary>
|
||||
public DateTime? ExpireDate { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// 入库数量。
|
||||
/// </summary>
|
||||
public int Quantity { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// 剩余数量。
|
||||
/// </summary>
|
||||
public int RemainingQuantity { get; init; }
|
||||
}
|
||||
@@ -0,0 +1,99 @@
|
||||
using System.Text.Json.Serialization;
|
||||
using TakeoutSaaS.Shared.Abstractions.Serialization;
|
||||
|
||||
namespace TakeoutSaaS.Application.App.Inventory.Dto;
|
||||
|
||||
/// <summary>
|
||||
/// 库存项 DTO。
|
||||
/// </summary>
|
||||
public sealed record InventoryItemDto
|
||||
{
|
||||
/// <summary>
|
||||
/// 库存记录 ID。
|
||||
/// </summary>
|
||||
[JsonConverter(typeof(SnowflakeIdJsonConverter))]
|
||||
public long Id { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// 租户 ID。
|
||||
/// </summary>
|
||||
[JsonConverter(typeof(SnowflakeIdJsonConverter))]
|
||||
public long TenantId { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// 门店 ID。
|
||||
/// </summary>
|
||||
[JsonConverter(typeof(SnowflakeIdJsonConverter))]
|
||||
public long StoreId { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// SKU ID。
|
||||
/// </summary>
|
||||
[JsonConverter(typeof(SnowflakeIdJsonConverter))]
|
||||
public long ProductSkuId { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// 批次号。
|
||||
/// </summary>
|
||||
public string? BatchNumber { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// 可用库存。
|
||||
/// </summary>
|
||||
public int QuantityOnHand { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// 已锁定库存。
|
||||
/// </summary>
|
||||
public int QuantityReserved { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// 安全库存。
|
||||
/// </summary>
|
||||
public int? SafetyStock { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// 储位。
|
||||
/// </summary>
|
||||
public string? Location { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// 过期日期。
|
||||
/// </summary>
|
||||
public DateTime? ExpireDate { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// 是否预售。
|
||||
/// </summary>
|
||||
public bool IsPresale { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// 预售开始时间。
|
||||
/// </summary>
|
||||
public DateTime? PresaleStartTime { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// 预售结束时间。
|
||||
/// </summary>
|
||||
public DateTime? PresaleEndTime { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// 预售上限。
|
||||
/// </summary>
|
||||
public int? PresaleCapacity { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// 已锁定预售量。
|
||||
/// </summary>
|
||||
public int PresaleLocked { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// 限购数量。
|
||||
/// </summary>
|
||||
public int? MaxQuantityPerOrder { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// 是否售罄。
|
||||
/// </summary>
|
||||
public bool IsSoldOut { get; init; }
|
||||
}
|
||||
@@ -0,0 +1,85 @@
|
||||
using MediatR;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using TakeoutSaaS.Application.App.Inventory.Commands;
|
||||
using TakeoutSaaS.Application.App.Inventory.Dto;
|
||||
using TakeoutSaaS.Domain.Inventory.Entities;
|
||||
using TakeoutSaaS.Domain.Inventory.Repositories;
|
||||
using TakeoutSaaS.Shared.Abstractions.Constants;
|
||||
using TakeoutSaaS.Shared.Abstractions.Exceptions;
|
||||
using TakeoutSaaS.Shared.Abstractions.Tenancy;
|
||||
|
||||
namespace TakeoutSaaS.Application.App.Inventory.Handlers;
|
||||
|
||||
/// <summary>
|
||||
/// 库存调整处理器。
|
||||
/// </summary>
|
||||
public sealed class AdjustInventoryCommandHandler(
|
||||
IInventoryRepository inventoryRepository,
|
||||
ITenantProvider tenantProvider,
|
||||
ILogger<AdjustInventoryCommandHandler> logger)
|
||||
: IRequestHandler<AdjustInventoryCommand, InventoryItemDto>
|
||||
{
|
||||
/// <inheritdoc />
|
||||
public async Task<InventoryItemDto> Handle(AdjustInventoryCommand request, CancellationToken cancellationToken)
|
||||
{
|
||||
// 1. 读取库存
|
||||
var tenantId = tenantProvider.GetCurrentTenantId();
|
||||
var item = await inventoryRepository.GetForUpdateAsync(tenantId, request.StoreId, request.ProductSkuId, cancellationToken);
|
||||
// 2. 初始化或校验存在性
|
||||
if (item is null)
|
||||
{
|
||||
if (request.QuantityDelta < 0)
|
||||
{
|
||||
throw new BusinessException(ErrorCodes.NotFound, "库存不存在,无法扣减");
|
||||
}
|
||||
|
||||
// 初始化库存记录
|
||||
item = new InventoryItem
|
||||
{
|
||||
TenantId = tenantId,
|
||||
StoreId = request.StoreId,
|
||||
ProductSkuId = request.ProductSkuId,
|
||||
QuantityOnHand = request.QuantityDelta,
|
||||
QuantityReserved = 0,
|
||||
SafetyStock = request.SafetyStock,
|
||||
IsSoldOut = false
|
||||
};
|
||||
await inventoryRepository.AddItemAsync(item, cancellationToken);
|
||||
}
|
||||
|
||||
// 3. 应用调整
|
||||
var newQuantity = item.QuantityOnHand + request.QuantityDelta;
|
||||
if (newQuantity < 0)
|
||||
{
|
||||
throw new BusinessException(ErrorCodes.Conflict, "库存不足,无法扣减");
|
||||
}
|
||||
|
||||
item.QuantityOnHand = newQuantity;
|
||||
item.SafetyStock = request.SafetyStock ?? item.SafetyStock;
|
||||
item.IsSoldOut = request.IsSoldOut ?? IsSoldOut(item);
|
||||
|
||||
// 4. 写入调整记录
|
||||
var adjustment = new InventoryAdjustment
|
||||
{
|
||||
TenantId = tenantId,
|
||||
InventoryItemId = item.Id,
|
||||
AdjustmentType = request.AdjustmentType,
|
||||
Quantity = request.QuantityDelta,
|
||||
Reason = request.Reason,
|
||||
OperatorId = null,
|
||||
OccurredAt = DateTime.UtcNow
|
||||
};
|
||||
await inventoryRepository.AddAdjustmentAsync(adjustment, cancellationToken);
|
||||
await inventoryRepository.SaveChangesAsync(cancellationToken);
|
||||
logger.LogInformation("调整库存 SKU {ProductSkuId} 门店 {StoreId} 变更 {Delta}", request.ProductSkuId, request.StoreId, request.QuantityDelta);
|
||||
return InventoryMapping.ToDto(item);
|
||||
}
|
||||
|
||||
// 辅助:售罄判定
|
||||
private static bool IsSoldOut(InventoryItem item)
|
||||
{
|
||||
var available = item.QuantityOnHand - item.QuantityReserved - item.PresaleLocked;
|
||||
var safety = item.SafetyStock ?? 0;
|
||||
return available <= safety || item.QuantityOnHand <= 0;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,110 @@
|
||||
using MediatR;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using TakeoutSaaS.Application.App.Inventory.Commands;
|
||||
using TakeoutSaaS.Application.App.Inventory.Dto;
|
||||
using TakeoutSaaS.Domain.Inventory.Repositories;
|
||||
using TakeoutSaaS.Shared.Abstractions.Constants;
|
||||
using TakeoutSaaS.Shared.Abstractions.Exceptions;
|
||||
using TakeoutSaaS.Shared.Abstractions.Tenancy;
|
||||
|
||||
namespace TakeoutSaaS.Application.App.Inventory.Handlers;
|
||||
|
||||
/// <summary>
|
||||
/// 库存扣减处理器。
|
||||
/// </summary>
|
||||
public sealed class DeductInventoryCommandHandler(
|
||||
IInventoryRepository inventoryRepository,
|
||||
ITenantProvider tenantProvider,
|
||||
ILogger<DeductInventoryCommandHandler> logger)
|
||||
: IRequestHandler<DeductInventoryCommand, InventoryItemDto>
|
||||
{
|
||||
/// <inheritdoc />
|
||||
public async Task<InventoryItemDto> Handle(DeductInventoryCommand request, CancellationToken cancellationToken)
|
||||
{
|
||||
// 1. 读取库存
|
||||
var tenantId = tenantProvider.GetCurrentTenantId();
|
||||
var item = await inventoryRepository.GetForUpdateAsync(tenantId, request.StoreId, request.ProductSkuId, cancellationToken);
|
||||
if (item is null)
|
||||
{
|
||||
throw new BusinessException(ErrorCodes.NotFound, "库存不存在");
|
||||
}
|
||||
|
||||
// 1.1 幂等:若锁记录已扣减/释放则直接返回
|
||||
if (!string.IsNullOrWhiteSpace(request.IdempotencyKey))
|
||||
{
|
||||
var lockRecord = await inventoryRepository.FindLockByKeyAsync(tenantId, request.IdempotencyKey, cancellationToken);
|
||||
if (lockRecord is not null)
|
||||
{
|
||||
if (lockRecord.Status == Domain.Inventory.Enums.InventoryLockStatus.Deducted)
|
||||
{
|
||||
return InventoryMapping.ToDto(item);
|
||||
}
|
||||
|
||||
if (lockRecord.Status == Domain.Inventory.Enums.InventoryLockStatus.Locked)
|
||||
{
|
||||
request = request with { Quantity = lockRecord.Quantity, IsPresaleOrder = lockRecord.IsPresale };
|
||||
await inventoryRepository.MarkLockStatusAsync(lockRecord, Domain.Inventory.Enums.InventoryLockStatus.Deducted, cancellationToken);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 2. 计算扣减来源
|
||||
var isPresale = request.IsPresaleOrder || item.IsPresale;
|
||||
if (isPresale)
|
||||
{
|
||||
if (item.PresaleLocked < request.Quantity)
|
||||
{
|
||||
throw new BusinessException(ErrorCodes.Conflict, "预售锁定不足,无法扣减");
|
||||
}
|
||||
|
||||
item.PresaleLocked -= request.Quantity;
|
||||
}
|
||||
else
|
||||
{
|
||||
if (item.QuantityReserved < request.Quantity)
|
||||
{
|
||||
throw new BusinessException(ErrorCodes.Conflict, "锁定库存不足,无法扣减");
|
||||
}
|
||||
|
||||
item.QuantityReserved -= request.Quantity;
|
||||
}
|
||||
|
||||
var remaining = item.QuantityOnHand - request.Quantity;
|
||||
if (remaining < 0)
|
||||
{
|
||||
throw new BusinessException(ErrorCodes.Conflict, "可用库存不足,无法扣减");
|
||||
}
|
||||
|
||||
// 3. 扣减可用量并按批次消耗
|
||||
item.QuantityOnHand = remaining;
|
||||
// 3.1 批次扣减(非预售)
|
||||
if (!isPresale)
|
||||
{
|
||||
var batches = await inventoryRepository.GetBatchesForConsumeAsync(tenantId, request.StoreId, request.ProductSkuId, item.BatchConsumeStrategy, cancellationToken);
|
||||
var need = request.Quantity;
|
||||
foreach (var batch in batches)
|
||||
{
|
||||
if (need <= 0)
|
||||
{
|
||||
break;
|
||||
}
|
||||
|
||||
var take = Math.Min(batch.RemainingQuantity, need);
|
||||
batch.RemainingQuantity -= take;
|
||||
need -= take;
|
||||
await inventoryRepository.UpdateBatchAsync(batch, cancellationToken);
|
||||
}
|
||||
|
||||
if (need > 0)
|
||||
{
|
||||
throw new BusinessException(ErrorCodes.Conflict, "批次数量不足,无法扣减");
|
||||
}
|
||||
}
|
||||
|
||||
item.IsSoldOut = item.QuantityOnHand - item.QuantityReserved - item.PresaleLocked <= (item.SafetyStock ?? 0);
|
||||
await inventoryRepository.UpdateItemAsync(item, cancellationToken);
|
||||
await inventoryRepository.SaveChangesAsync(cancellationToken);
|
||||
logger.LogInformation("扣减库存 门店 {StoreId} SKU {ProductSkuId} 数量 {Quantity}", request.StoreId, request.ProductSkuId, request.Quantity);
|
||||
return InventoryMapping.ToDto(item);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
using MediatR;
|
||||
using TakeoutSaaS.Application.App.Inventory.Dto;
|
||||
using TakeoutSaaS.Application.App.Inventory.Queries;
|
||||
using TakeoutSaaS.Domain.Inventory.Repositories;
|
||||
using TakeoutSaaS.Shared.Abstractions.Tenancy;
|
||||
|
||||
namespace TakeoutSaaS.Application.App.Inventory.Handlers;
|
||||
|
||||
/// <summary>
|
||||
/// 库存批次查询处理器。
|
||||
/// </summary>
|
||||
public sealed class GetInventoryBatchesQueryHandler(
|
||||
IInventoryRepository inventoryRepository,
|
||||
ITenantProvider tenantProvider)
|
||||
: IRequestHandler<GetInventoryBatchesQuery, IReadOnlyList<InventoryBatchDto>>
|
||||
{
|
||||
/// <inheritdoc />
|
||||
public async Task<IReadOnlyList<InventoryBatchDto>> Handle(GetInventoryBatchesQuery request, CancellationToken cancellationToken)
|
||||
{
|
||||
// 1. 读取批次
|
||||
var tenantId = tenantProvider.GetCurrentTenantId();
|
||||
var batches = await inventoryRepository.GetBatchesAsync(tenantId, request.StoreId, request.ProductSkuId, cancellationToken);
|
||||
// 2. 映射
|
||||
return batches.Select(InventoryMapping.ToDto).ToList();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
using MediatR;
|
||||
using TakeoutSaaS.Application.App.Inventory.Dto;
|
||||
using TakeoutSaaS.Application.App.Inventory.Queries;
|
||||
using TakeoutSaaS.Domain.Inventory.Repositories;
|
||||
using TakeoutSaaS.Shared.Abstractions.Tenancy;
|
||||
|
||||
namespace TakeoutSaaS.Application.App.Inventory.Handlers;
|
||||
|
||||
/// <summary>
|
||||
/// 查询库存处理器。
|
||||
/// </summary>
|
||||
public sealed class GetInventoryItemQueryHandler(
|
||||
IInventoryRepository inventoryRepository,
|
||||
ITenantProvider tenantProvider)
|
||||
: IRequestHandler<GetInventoryItemQuery, InventoryItemDto?>
|
||||
{
|
||||
/// <inheritdoc />
|
||||
public async Task<InventoryItemDto?> Handle(GetInventoryItemQuery request, CancellationToken cancellationToken)
|
||||
{
|
||||
// 1. 读取库存
|
||||
var tenantId = tenantProvider.GetCurrentTenantId();
|
||||
var item = await inventoryRepository.FindBySkuAsync(tenantId, request.StoreId, request.ProductSkuId, cancellationToken);
|
||||
// 2. 返回 DTO
|
||||
return item is null ? null : InventoryMapping.ToDto(item);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,92 @@
|
||||
using MediatR;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using TakeoutSaaS.Application.App.Inventory.Commands;
|
||||
using TakeoutSaaS.Application.App.Inventory.Dto;
|
||||
using TakeoutSaaS.Domain.Inventory.Repositories;
|
||||
using TakeoutSaaS.Shared.Abstractions.Constants;
|
||||
using TakeoutSaaS.Shared.Abstractions.Exceptions;
|
||||
using TakeoutSaaS.Shared.Abstractions.Tenancy;
|
||||
|
||||
namespace TakeoutSaaS.Application.App.Inventory.Handlers;
|
||||
|
||||
/// <summary>
|
||||
/// 库存锁定处理器。
|
||||
/// </summary>
|
||||
public sealed class LockInventoryCommandHandler(
|
||||
IInventoryRepository inventoryRepository,
|
||||
ITenantProvider tenantProvider,
|
||||
ILogger<LockInventoryCommandHandler> logger)
|
||||
: IRequestHandler<LockInventoryCommand, InventoryItemDto>
|
||||
{
|
||||
/// <inheritdoc />
|
||||
public async Task<InventoryItemDto> Handle(LockInventoryCommand request, CancellationToken cancellationToken)
|
||||
{
|
||||
// 1. 读取库存
|
||||
var tenantId = tenantProvider.GetCurrentTenantId();
|
||||
var item = await inventoryRepository.GetForUpdateAsync(tenantId, request.StoreId, request.ProductSkuId, cancellationToken);
|
||||
if (item is null)
|
||||
{
|
||||
throw new BusinessException(ErrorCodes.NotFound, "库存不存在");
|
||||
}
|
||||
|
||||
// 1.1 幂等处理
|
||||
var existingLock = await inventoryRepository.FindLockByKeyAsync(tenantId, request.IdempotencyKey, cancellationToken);
|
||||
if (existingLock is not null)
|
||||
{
|
||||
return InventoryMapping.ToDto(item);
|
||||
}
|
||||
|
||||
// 2. 校验可用量
|
||||
var now = DateTime.UtcNow;
|
||||
var isPresale = request.IsPresaleOrder || item.IsPresale;
|
||||
if (isPresale)
|
||||
{
|
||||
if (item.PresaleStartTime.HasValue && now < item.PresaleStartTime.Value)
|
||||
{
|
||||
throw new BusinessException(ErrorCodes.Conflict, "预售尚未开始");
|
||||
}
|
||||
|
||||
if (item.PresaleEndTime.HasValue && now > item.PresaleEndTime.Value)
|
||||
{
|
||||
throw new BusinessException(ErrorCodes.Conflict, "预售已结束");
|
||||
}
|
||||
}
|
||||
|
||||
var available = isPresale
|
||||
? (item.PresaleCapacity ?? item.QuantityOnHand) - item.PresaleLocked
|
||||
: item.QuantityOnHand - item.QuantityReserved;
|
||||
if (available < request.Quantity)
|
||||
{
|
||||
throw new BusinessException(ErrorCodes.Conflict, "库存不足,无法锁定");
|
||||
}
|
||||
|
||||
// 3. 执行锁定
|
||||
if (isPresale)
|
||||
{
|
||||
item.PresaleLocked += request.Quantity;
|
||||
}
|
||||
else
|
||||
{
|
||||
item.QuantityReserved += request.Quantity;
|
||||
}
|
||||
|
||||
item.IsSoldOut = item.QuantityOnHand - item.QuantityReserved - item.PresaleLocked <= (item.SafetyStock ?? 0);
|
||||
await inventoryRepository.UpdateItemAsync(item, cancellationToken);
|
||||
var lockRecord = new Domain.Inventory.Entities.InventoryLockRecord
|
||||
{
|
||||
TenantId = tenantId,
|
||||
StoreId = request.StoreId,
|
||||
ProductSkuId = request.ProductSkuId,
|
||||
Quantity = request.Quantity,
|
||||
IsPresale = isPresale,
|
||||
IdempotencyKey = request.IdempotencyKey,
|
||||
ExpiresAt = request.ExpiresAt,
|
||||
Status = Domain.Inventory.Enums.InventoryLockStatus.Locked
|
||||
};
|
||||
|
||||
await inventoryRepository.AddLockAsync(lockRecord, cancellationToken);
|
||||
await inventoryRepository.SaveChangesAsync(cancellationToken);
|
||||
logger.LogInformation("锁定库存 门店 {StoreId} SKU {ProductSkuId} 数量 {Quantity}", request.StoreId, request.ProductSkuId, request.Quantity);
|
||||
return InventoryMapping.ToDto(item);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,60 @@
|
||||
using MediatR;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using TakeoutSaaS.Application.App.Inventory.Commands;
|
||||
using TakeoutSaaS.Domain.Inventory.Enums;
|
||||
using TakeoutSaaS.Domain.Inventory.Repositories;
|
||||
using TakeoutSaaS.Shared.Abstractions.Tenancy;
|
||||
|
||||
namespace TakeoutSaaS.Application.App.Inventory.Handlers;
|
||||
|
||||
/// <summary>
|
||||
/// 释放过期锁定处理器。
|
||||
/// </summary>
|
||||
public sealed class ReleaseExpiredInventoryLocksCommandHandler(
|
||||
IInventoryRepository inventoryRepository,
|
||||
ITenantProvider tenantProvider,
|
||||
ILogger<ReleaseExpiredInventoryLocksCommandHandler> logger)
|
||||
: IRequestHandler<ReleaseExpiredInventoryLocksCommand, int>
|
||||
{
|
||||
/// <inheritdoc />
|
||||
public async Task<int> Handle(ReleaseExpiredInventoryLocksCommand request, CancellationToken cancellationToken)
|
||||
{
|
||||
// 1. 查询过期锁
|
||||
var tenantId = tenantProvider.GetCurrentTenantId();
|
||||
var now = DateTime.UtcNow;
|
||||
var expiredLocks = await inventoryRepository.FindExpiredLocksAsync(tenantId, now, cancellationToken);
|
||||
if (expiredLocks.Count == 0)
|
||||
{
|
||||
return 0;
|
||||
}
|
||||
|
||||
// 2. 释放锁对应库存
|
||||
var affected = 0;
|
||||
foreach (var lockRecord in expiredLocks)
|
||||
{
|
||||
var item = await inventoryRepository.GetForUpdateAsync(tenantId, lockRecord.StoreId, lockRecord.ProductSkuId, cancellationToken);
|
||||
if (item is null)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
if (lockRecord.IsPresale)
|
||||
{
|
||||
item.PresaleLocked = Math.Max(0, item.PresaleLocked - lockRecord.Quantity);
|
||||
}
|
||||
else
|
||||
{
|
||||
item.QuantityReserved = Math.Max(0, item.QuantityReserved - lockRecord.Quantity);
|
||||
}
|
||||
|
||||
item.IsSoldOut = item.QuantityOnHand - item.QuantityReserved - item.PresaleLocked <= (item.SafetyStock ?? 0);
|
||||
await inventoryRepository.UpdateItemAsync(item, cancellationToken);
|
||||
await inventoryRepository.MarkLockStatusAsync(lockRecord, InventoryLockStatus.Released, cancellationToken);
|
||||
affected++;
|
||||
}
|
||||
|
||||
await inventoryRepository.SaveChangesAsync(cancellationToken);
|
||||
logger.LogInformation("释放过期库存锁定 {Count} 条", affected);
|
||||
return affected;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,76 @@
|
||||
using MediatR;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using TakeoutSaaS.Application.App.Inventory.Commands;
|
||||
using TakeoutSaaS.Application.App.Inventory.Dto;
|
||||
using TakeoutSaaS.Domain.Inventory.Repositories;
|
||||
using TakeoutSaaS.Shared.Abstractions.Constants;
|
||||
using TakeoutSaaS.Shared.Abstractions.Exceptions;
|
||||
using TakeoutSaaS.Shared.Abstractions.Tenancy;
|
||||
|
||||
namespace TakeoutSaaS.Application.App.Inventory.Handlers;
|
||||
|
||||
/// <summary>
|
||||
/// 库存释放处理器。
|
||||
/// </summary>
|
||||
public sealed class ReleaseInventoryCommandHandler(
|
||||
IInventoryRepository inventoryRepository,
|
||||
ITenantProvider tenantProvider,
|
||||
ILogger<ReleaseInventoryCommandHandler> logger)
|
||||
: IRequestHandler<ReleaseInventoryCommand, InventoryItemDto>
|
||||
{
|
||||
/// <inheritdoc />
|
||||
public async Task<InventoryItemDto> Handle(ReleaseInventoryCommand request, CancellationToken cancellationToken)
|
||||
{
|
||||
// 1. 读取库存
|
||||
var tenantId = tenantProvider.GetCurrentTenantId();
|
||||
var item = await inventoryRepository.GetForUpdateAsync(tenantId, request.StoreId, request.ProductSkuId, cancellationToken);
|
||||
if (item is null)
|
||||
{
|
||||
throw new BusinessException(ErrorCodes.NotFound, "库存不存在");
|
||||
}
|
||||
|
||||
// 1.1 幂等处理:若提供键且锁记录不存在,直接视为已释放
|
||||
if (!string.IsNullOrWhiteSpace(request.IdempotencyKey))
|
||||
{
|
||||
var lockRecord = await inventoryRepository.FindLockByKeyAsync(tenantId, request.IdempotencyKey, cancellationToken);
|
||||
if (lockRecord is not null)
|
||||
{
|
||||
if (lockRecord.Status != Domain.Inventory.Enums.InventoryLockStatus.Locked)
|
||||
{
|
||||
return InventoryMapping.ToDto(item);
|
||||
}
|
||||
|
||||
// 将数量同步为锁记录数,避免重复释放不一致
|
||||
request = request with { Quantity = lockRecord.Quantity };
|
||||
await inventoryRepository.MarkLockStatusAsync(lockRecord, Domain.Inventory.Enums.InventoryLockStatus.Released, cancellationToken);
|
||||
}
|
||||
}
|
||||
|
||||
// 2. 计算释放
|
||||
var isPresale = request.IsPresaleOrder || item.IsPresale;
|
||||
if (isPresale)
|
||||
{
|
||||
if (item.PresaleLocked < request.Quantity)
|
||||
{
|
||||
throw new BusinessException(ErrorCodes.Conflict, "预售锁定不足");
|
||||
}
|
||||
|
||||
item.PresaleLocked -= request.Quantity;
|
||||
}
|
||||
else
|
||||
{
|
||||
if (item.QuantityReserved < request.Quantity)
|
||||
{
|
||||
throw new BusinessException(ErrorCodes.Conflict, "锁定库存不足");
|
||||
}
|
||||
|
||||
item.QuantityReserved -= request.Quantity;
|
||||
}
|
||||
|
||||
item.IsSoldOut = item.QuantityOnHand - item.QuantityReserved - item.PresaleLocked <= (item.SafetyStock ?? 0);
|
||||
await inventoryRepository.UpdateItemAsync(item, cancellationToken);
|
||||
await inventoryRepository.SaveChangesAsync(cancellationToken);
|
||||
logger.LogInformation("释放库存 门店 {StoreId} SKU {ProductSkuId} 数量 {Quantity}", request.StoreId, request.ProductSkuId, request.Quantity);
|
||||
return InventoryMapping.ToDto(item);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,57 @@
|
||||
using MediatR;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using TakeoutSaaS.Application.App.Inventory.Commands;
|
||||
using TakeoutSaaS.Application.App.Inventory.Dto;
|
||||
using TakeoutSaaS.Domain.Inventory.Entities;
|
||||
using TakeoutSaaS.Domain.Inventory.Repositories;
|
||||
using TakeoutSaaS.Shared.Abstractions.Constants;
|
||||
using TakeoutSaaS.Shared.Abstractions.Exceptions;
|
||||
using TakeoutSaaS.Shared.Abstractions.Tenancy;
|
||||
|
||||
namespace TakeoutSaaS.Application.App.Inventory.Handlers;
|
||||
|
||||
/// <summary>
|
||||
/// 批次维护处理器。
|
||||
/// </summary>
|
||||
public sealed class UpsertInventoryBatchCommandHandler(
|
||||
IInventoryRepository inventoryRepository,
|
||||
ITenantProvider tenantProvider,
|
||||
ILogger<UpsertInventoryBatchCommandHandler> logger)
|
||||
: IRequestHandler<UpsertInventoryBatchCommand, InventoryBatchDto>
|
||||
{
|
||||
/// <inheritdoc />
|
||||
public async Task<InventoryBatchDto> Handle(UpsertInventoryBatchCommand request, CancellationToken cancellationToken)
|
||||
{
|
||||
// 1. 读取批次
|
||||
var tenantId = tenantProvider.GetCurrentTenantId();
|
||||
var batch = await inventoryRepository.GetBatchForUpdateAsync(tenantId, request.StoreId, request.ProductSkuId, request.BatchNumber, cancellationToken);
|
||||
// 2. 创建或更新
|
||||
if (batch is null)
|
||||
{
|
||||
batch = new InventoryBatch
|
||||
{
|
||||
TenantId = tenantId,
|
||||
StoreId = request.StoreId,
|
||||
ProductSkuId = request.ProductSkuId,
|
||||
BatchNumber = request.BatchNumber,
|
||||
ProductionDate = request.ProductionDate,
|
||||
ExpireDate = request.ExpireDate,
|
||||
Quantity = request.Quantity,
|
||||
RemainingQuantity = request.RemainingQuantity
|
||||
};
|
||||
await inventoryRepository.AddBatchAsync(batch, cancellationToken);
|
||||
}
|
||||
else
|
||||
{
|
||||
batch.ProductionDate = request.ProductionDate;
|
||||
batch.ExpireDate = request.ExpireDate;
|
||||
batch.Quantity = request.Quantity;
|
||||
batch.RemainingQuantity = request.RemainingQuantity;
|
||||
await inventoryRepository.UpdateBatchAsync(batch, cancellationToken);
|
||||
}
|
||||
|
||||
await inventoryRepository.SaveChangesAsync(cancellationToken);
|
||||
logger.LogInformation("维护批次 门店 {StoreId} SKU {ProductSkuId} 批次 {BatchNumber}", request.StoreId, request.ProductSkuId, request.BatchNumber);
|
||||
return InventoryMapping.ToDto(batch);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,49 @@
|
||||
using TakeoutSaaS.Application.App.Inventory.Dto;
|
||||
using TakeoutSaaS.Domain.Inventory.Entities;
|
||||
|
||||
namespace TakeoutSaaS.Application.App.Inventory;
|
||||
|
||||
/// <summary>
|
||||
/// 库存映射辅助。
|
||||
/// </summary>
|
||||
public static class InventoryMapping
|
||||
{
|
||||
/// <summary>
|
||||
/// 映射库存 DTO。
|
||||
/// </summary>
|
||||
public static InventoryItemDto ToDto(InventoryItem item) => new()
|
||||
{
|
||||
Id = item.Id,
|
||||
TenantId = item.TenantId,
|
||||
StoreId = item.StoreId,
|
||||
ProductSkuId = item.ProductSkuId,
|
||||
BatchNumber = item.BatchNumber,
|
||||
QuantityOnHand = item.QuantityOnHand,
|
||||
QuantityReserved = item.QuantityReserved,
|
||||
SafetyStock = item.SafetyStock,
|
||||
Location = item.Location,
|
||||
ExpireDate = item.ExpireDate,
|
||||
IsPresale = item.IsPresale,
|
||||
PresaleStartTime = item.PresaleStartTime,
|
||||
PresaleEndTime = item.PresaleEndTime,
|
||||
PresaleCapacity = item.PresaleCapacity,
|
||||
PresaleLocked = item.PresaleLocked,
|
||||
MaxQuantityPerOrder = item.MaxQuantityPerOrder,
|
||||
IsSoldOut = item.IsSoldOut
|
||||
};
|
||||
|
||||
/// <summary>
|
||||
/// 映射批次 DTO。
|
||||
/// </summary>
|
||||
public static InventoryBatchDto ToDto(InventoryBatch batch) => new()
|
||||
{
|
||||
Id = batch.Id,
|
||||
StoreId = batch.StoreId,
|
||||
ProductSkuId = batch.ProductSkuId,
|
||||
BatchNumber = batch.BatchNumber,
|
||||
ProductionDate = batch.ProductionDate,
|
||||
ExpireDate = batch.ExpireDate,
|
||||
Quantity = batch.Quantity,
|
||||
RemainingQuantity = batch.RemainingQuantity
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
using MediatR;
|
||||
using TakeoutSaaS.Application.App.Inventory.Dto;
|
||||
|
||||
namespace TakeoutSaaS.Application.App.Inventory.Queries;
|
||||
|
||||
/// <summary>
|
||||
/// 查询库存批次列表。
|
||||
/// </summary>
|
||||
public sealed record GetInventoryBatchesQuery : IRequest<IReadOnlyList<InventoryBatchDto>>
|
||||
{
|
||||
/// <summary>
|
||||
/// 门店 ID。
|
||||
/// </summary>
|
||||
public long StoreId { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// SKU ID。
|
||||
/// </summary>
|
||||
public long ProductSkuId { get; init; }
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
using MediatR;
|
||||
using TakeoutSaaS.Application.App.Inventory.Dto;
|
||||
|
||||
namespace TakeoutSaaS.Application.App.Inventory.Queries;
|
||||
|
||||
/// <summary>
|
||||
/// 按门店与 SKU 查询库存。
|
||||
/// </summary>
|
||||
public sealed record GetInventoryItemQuery : IRequest<InventoryItemDto?>
|
||||
{
|
||||
/// <summary>
|
||||
/// 门店 ID。
|
||||
/// </summary>
|
||||
public long StoreId { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// SKU ID。
|
||||
/// </summary>
|
||||
public long ProductSkuId { get; init; }
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
using FluentValidation;
|
||||
using TakeoutSaaS.Application.App.Inventory.Commands;
|
||||
|
||||
namespace TakeoutSaaS.Application.App.Inventory.Validators;
|
||||
|
||||
/// <summary>
|
||||
/// 库存调整命令验证器。
|
||||
/// </summary>
|
||||
public sealed class AdjustInventoryCommandValidator : AbstractValidator<AdjustInventoryCommand>
|
||||
{
|
||||
/// <summary>
|
||||
/// 初始化验证规则。
|
||||
/// </summary>
|
||||
public AdjustInventoryCommandValidator()
|
||||
{
|
||||
RuleFor(x => x.StoreId).GreaterThan(0);
|
||||
RuleFor(x => x.ProductSkuId).GreaterThan(0);
|
||||
RuleFor(x => x.QuantityDelta).NotEqual(0);
|
||||
RuleFor(x => x.SafetyStock).GreaterThanOrEqualTo(0).When(x => x.SafetyStock.HasValue);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
using FluentValidation;
|
||||
using TakeoutSaaS.Application.App.Inventory.Commands;
|
||||
|
||||
namespace TakeoutSaaS.Application.App.Inventory.Validators;
|
||||
|
||||
/// <summary>
|
||||
/// 扣减库存命令验证器。
|
||||
/// </summary>
|
||||
public sealed class DeductInventoryCommandValidator : AbstractValidator<DeductInventoryCommand>
|
||||
{
|
||||
/// <summary>
|
||||
/// 初始化验证规则。
|
||||
/// </summary>
|
||||
public DeductInventoryCommandValidator()
|
||||
{
|
||||
RuleFor(x => x.StoreId).GreaterThan(0);
|
||||
RuleFor(x => x.ProductSkuId).GreaterThan(0);
|
||||
RuleFor(x => x.Quantity).GreaterThan(0);
|
||||
RuleFor(x => x.IdempotencyKey).MaximumLength(128);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
using FluentValidation;
|
||||
using TakeoutSaaS.Application.App.Inventory.Commands;
|
||||
|
||||
namespace TakeoutSaaS.Application.App.Inventory.Validators;
|
||||
|
||||
/// <summary>
|
||||
/// 库存锁定命令验证器。
|
||||
/// </summary>
|
||||
public sealed class LockInventoryCommandValidator : AbstractValidator<LockInventoryCommand>
|
||||
{
|
||||
/// <summary>
|
||||
/// 初始化验证规则。
|
||||
/// </summary>
|
||||
public LockInventoryCommandValidator()
|
||||
{
|
||||
RuleFor(x => x.StoreId).GreaterThan(0);
|
||||
RuleFor(x => x.ProductSkuId).GreaterThan(0);
|
||||
RuleFor(x => x.Quantity).GreaterThan(0);
|
||||
RuleFor(x => x.IdempotencyKey).NotEmpty().MaximumLength(128);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
using FluentValidation;
|
||||
using TakeoutSaaS.Application.App.Inventory.Commands;
|
||||
|
||||
namespace TakeoutSaaS.Application.App.Inventory.Validators;
|
||||
|
||||
/// <summary>
|
||||
/// 释放库存命令验证器。
|
||||
/// </summary>
|
||||
public sealed class ReleaseInventoryCommandValidator : AbstractValidator<ReleaseInventoryCommand>
|
||||
{
|
||||
/// <summary>
|
||||
/// 初始化验证规则。
|
||||
/// </summary>
|
||||
public ReleaseInventoryCommandValidator()
|
||||
{
|
||||
RuleFor(x => x.StoreId).GreaterThan(0);
|
||||
RuleFor(x => x.ProductSkuId).GreaterThan(0);
|
||||
RuleFor(x => x.Quantity).GreaterThan(0);
|
||||
RuleFor(x => x.IdempotencyKey).MaximumLength(128);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
using FluentValidation;
|
||||
using TakeoutSaaS.Application.App.Inventory.Commands;
|
||||
|
||||
namespace TakeoutSaaS.Application.App.Inventory.Validators;
|
||||
|
||||
/// <summary>
|
||||
/// 批次维护命令验证器。
|
||||
/// </summary>
|
||||
public sealed class UpsertInventoryBatchCommandValidator : AbstractValidator<UpsertInventoryBatchCommand>
|
||||
{
|
||||
/// <summary>
|
||||
/// 初始化验证规则。
|
||||
/// </summary>
|
||||
public UpsertInventoryBatchCommandValidator()
|
||||
{
|
||||
RuleFor(x => x.StoreId).GreaterThan(0);
|
||||
RuleFor(x => x.ProductSkuId).GreaterThan(0);
|
||||
RuleFor(x => x.BatchNumber).NotEmpty().MaximumLength(64);
|
||||
RuleFor(x => x.Quantity).GreaterThan(0);
|
||||
RuleFor(x => x.RemainingQuantity).GreaterThanOrEqualTo(0);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
using MediatR;
|
||||
using System.ComponentModel.DataAnnotations;
|
||||
using TakeoutSaaS.Application.App.Merchants.Dto;
|
||||
using TakeoutSaaS.Domain.Merchants.Enums;
|
||||
|
||||
namespace TakeoutSaaS.Application.App.Merchants.Commands;
|
||||
|
||||
/// <summary>
|
||||
/// 新增商户证照。
|
||||
/// </summary>
|
||||
public sealed record AddMerchantDocumentCommand(
|
||||
[property: Required] long MerchantId,
|
||||
[property: Required] MerchantDocumentType DocumentType,
|
||||
[property: Required, MaxLength(512)] string FileUrl,
|
||||
[property: MaxLength(64)] string? DocumentNumber,
|
||||
DateTime? IssuedAt,
|
||||
DateTime? ExpiresAt) : IRequest<MerchantDocumentDto>;
|
||||
@@ -0,0 +1,13 @@
|
||||
using MediatR;
|
||||
using System.ComponentModel.DataAnnotations;
|
||||
using TakeoutSaaS.Application.App.Merchants.Dto;
|
||||
|
||||
namespace TakeoutSaaS.Application.App.Merchants.Commands;
|
||||
|
||||
/// <summary>
|
||||
/// 新增商户类目。
|
||||
/// </summary>
|
||||
public sealed record CreateMerchantCategoryCommand(
|
||||
[property: Required, MaxLength(64)] string Name,
|
||||
int? DisplayOrder,
|
||||
bool IsActive = true) : IRequest<MerchantCategoryDto>;
|
||||
@@ -0,0 +1,53 @@
|
||||
using MediatR;
|
||||
using System.ComponentModel.DataAnnotations;
|
||||
using TakeoutSaaS.Application.App.Merchants.Dto;
|
||||
using TakeoutSaaS.Domain.Merchants.Enums;
|
||||
|
||||
namespace TakeoutSaaS.Application.App.Merchants.Commands;
|
||||
|
||||
/// <summary>
|
||||
/// 创建商户命令。
|
||||
/// </summary>
|
||||
public sealed class CreateMerchantCommand : IRequest<MerchantDto>
|
||||
{
|
||||
/// <summary>
|
||||
/// 品牌名称。
|
||||
/// </summary>
|
||||
[Required, MaxLength(128)]
|
||||
public string BrandName { get; init; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// 品牌简称。
|
||||
/// </summary>
|
||||
[MaxLength(64)]
|
||||
public string? BrandAlias { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// 品牌 Logo。
|
||||
/// </summary>
|
||||
[MaxLength(256)]
|
||||
public string? LogoUrl { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// 品类。
|
||||
/// </summary>
|
||||
[MaxLength(64)]
|
||||
public string? Category { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// 联系电话。
|
||||
/// </summary>
|
||||
[Required, MaxLength(32)]
|
||||
public string ContactPhone { get; init; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// 联系邮箱。
|
||||
/// </summary>
|
||||
[MaxLength(128)]
|
||||
public string? ContactEmail { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// 状态,可用于直接设为审核通过等场景。
|
||||
/// </summary>
|
||||
public MerchantStatus Status { get; init; } = MerchantStatus.Pending;
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user