feat: implement marketing punch card backend module
This commit is contained in:
@@ -0,0 +1,25 @@
|
||||
using MediatR;
|
||||
using TakeoutSaaS.Application.App.Coupons.PunchCard.Dto;
|
||||
|
||||
namespace TakeoutSaaS.Application.App.Coupons.PunchCard.Commands;
|
||||
|
||||
/// <summary>
|
||||
/// 修改次卡模板状态命令。
|
||||
/// </summary>
|
||||
public sealed class ChangePunchCardTemplateStatusCommand : IRequest<PunchCardDetailDto>
|
||||
{
|
||||
/// <summary>
|
||||
/// 操作门店 ID。
|
||||
/// </summary>
|
||||
public long StoreId { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// 次卡模板 ID。
|
||||
/// </summary>
|
||||
public long TemplateId { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// 状态(enabled/disabled)。
|
||||
/// </summary>
|
||||
public string Status { get; init; } = "disabled";
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
using MediatR;
|
||||
|
||||
namespace TakeoutSaaS.Application.App.Coupons.PunchCard.Commands;
|
||||
|
||||
/// <summary>
|
||||
/// 删除次卡模板命令。
|
||||
/// </summary>
|
||||
public sealed class DeletePunchCardTemplateCommand : IRequest
|
||||
{
|
||||
/// <summary>
|
||||
/// 操作门店 ID。
|
||||
/// </summary>
|
||||
public long StoreId { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// 次卡模板 ID。
|
||||
/// </summary>
|
||||
public long TemplateId { get; init; }
|
||||
}
|
||||
@@ -0,0 +1,130 @@
|
||||
using MediatR;
|
||||
using TakeoutSaaS.Application.App.Coupons.PunchCard.Dto;
|
||||
|
||||
namespace TakeoutSaaS.Application.App.Coupons.PunchCard.Commands;
|
||||
|
||||
/// <summary>
|
||||
/// 保存次卡模板命令。
|
||||
/// </summary>
|
||||
public sealed class SavePunchCardTemplateCommand : IRequest<PunchCardDetailDto>
|
||||
{
|
||||
/// <summary>
|
||||
/// 操作门店 ID。
|
||||
/// </summary>
|
||||
public long StoreId { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// 次卡模板 ID(编辑时传)。
|
||||
/// </summary>
|
||||
public long? TemplateId { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// 次卡名称。
|
||||
/// </summary>
|
||||
public string Name { get; init; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// 封面图。
|
||||
/// </summary>
|
||||
public string? CoverImageUrl { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// 售价。
|
||||
/// </summary>
|
||||
public decimal SalePrice { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// 原价。
|
||||
/// </summary>
|
||||
public decimal? OriginalPrice { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// 总次数。
|
||||
/// </summary>
|
||||
public int TotalTimes { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// 有效期类型(days/range)。
|
||||
/// </summary>
|
||||
public string ValidityType { get; init; } = "days";
|
||||
|
||||
/// <summary>
|
||||
/// 固定天数。
|
||||
/// </summary>
|
||||
public int? ValidityDays { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// 固定开始日期。
|
||||
/// </summary>
|
||||
public DateTime? ValidFrom { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// 固定结束日期。
|
||||
/// </summary>
|
||||
public DateTime? ValidTo { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// 范围类型(all/category/tag/product)。
|
||||
/// </summary>
|
||||
public string ScopeType { get; init; } = "all";
|
||||
|
||||
/// <summary>
|
||||
/// 指定分类 ID。
|
||||
/// </summary>
|
||||
public IReadOnlyCollection<long> ScopeCategoryIds { get; init; } = [];
|
||||
|
||||
/// <summary>
|
||||
/// 指定标签 ID。
|
||||
/// </summary>
|
||||
public IReadOnlyCollection<long> ScopeTagIds { get; init; } = [];
|
||||
|
||||
/// <summary>
|
||||
/// 指定商品 ID。
|
||||
/// </summary>
|
||||
public IReadOnlyCollection<long> ScopeProductIds { get; init; } = [];
|
||||
|
||||
/// <summary>
|
||||
/// 使用模式(free/cap)。
|
||||
/// </summary>
|
||||
public string UsageMode { get; init; } = "free";
|
||||
|
||||
/// <summary>
|
||||
/// 单次上限金额。
|
||||
/// </summary>
|
||||
public decimal? UsageCapAmount { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// 每日限用次数。
|
||||
/// </summary>
|
||||
public int? DailyLimit { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// 每单限用次数。
|
||||
/// </summary>
|
||||
public int? PerOrderLimit { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// 每人限购张数。
|
||||
/// </summary>
|
||||
public int? PerUserPurchaseLimit { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// 是否允许转赠。
|
||||
/// </summary>
|
||||
public bool AllowTransfer { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// 过期策略(invalidate/refund)。
|
||||
/// </summary>
|
||||
public string ExpireStrategy { get; init; } = "invalidate";
|
||||
|
||||
/// <summary>
|
||||
/// 次卡说明。
|
||||
/// </summary>
|
||||
public string? Description { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// 通知渠道(in_app/sms)。
|
||||
/// </summary>
|
||||
public IReadOnlyCollection<string> NotifyChannels { get; init; } = [];
|
||||
}
|
||||
@@ -0,0 +1,60 @@
|
||||
using MediatR;
|
||||
using TakeoutSaaS.Application.App.Coupons.PunchCard.Dto;
|
||||
|
||||
namespace TakeoutSaaS.Application.App.Coupons.PunchCard.Commands;
|
||||
|
||||
/// <summary>
|
||||
/// 写入次卡使用记录命令。
|
||||
/// </summary>
|
||||
public sealed class WritePunchCardUsageRecordCommand : IRequest<PunchCardUsageRecordDto>
|
||||
{
|
||||
/// <summary>
|
||||
/// 操作门店 ID。
|
||||
/// </summary>
|
||||
public long StoreId { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// 次卡模板 ID。
|
||||
/// </summary>
|
||||
public long TemplateId { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// 次卡实例 ID(可空)。
|
||||
/// </summary>
|
||||
public long? InstanceId { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// 次卡实例编号(可空)。
|
||||
/// </summary>
|
||||
public string? InstanceNo { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// 会员名称(当未指定实例时用于创建实例)。
|
||||
/// </summary>
|
||||
public string? MemberName { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// 会员手机号(脱敏,当未指定实例时用于创建实例)。
|
||||
/// </summary>
|
||||
public string? MemberPhoneMasked { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// 兑换商品名称。
|
||||
/// </summary>
|
||||
public string ProductName { get; init; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// 使用时间(可空,空则取当前 UTC)。
|
||||
/// </summary>
|
||||
public DateTime? UsedAt { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// 本次使用次数。
|
||||
/// </summary>
|
||||
public int UsedTimes { get; init; } = 1;
|
||||
|
||||
/// <summary>
|
||||
/// 超额补差金额。
|
||||
/// </summary>
|
||||
public decimal? ExtraPayAmount { get; init; }
|
||||
}
|
||||
@@ -0,0 +1,137 @@
|
||||
namespace TakeoutSaaS.Application.App.Coupons.PunchCard.Dto;
|
||||
|
||||
/// <summary>
|
||||
/// 次卡详情。
|
||||
/// </summary>
|
||||
public sealed class PunchCardDetailDto
|
||||
{
|
||||
/// <summary>
|
||||
/// 次卡 ID。
|
||||
/// </summary>
|
||||
public long Id { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// 门店 ID。
|
||||
/// </summary>
|
||||
public long StoreId { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// 次卡名称。
|
||||
/// </summary>
|
||||
public string Name { get; init; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// 封面图。
|
||||
/// </summary>
|
||||
public string? CoverImageUrl { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// 售价。
|
||||
/// </summary>
|
||||
public decimal SalePrice { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// 原价。
|
||||
/// </summary>
|
||||
public decimal? OriginalPrice { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// 总次数。
|
||||
/// </summary>
|
||||
public int TotalTimes { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// 有效期类型(days/range)。
|
||||
/// </summary>
|
||||
public string ValidityType { get; init; } = "days";
|
||||
|
||||
/// <summary>
|
||||
/// 固定天数。
|
||||
/// </summary>
|
||||
public int? ValidityDays { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// 固定开始日期(UTC)。
|
||||
/// </summary>
|
||||
public DateTime? ValidFrom { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// 固定结束日期(UTC)。
|
||||
/// </summary>
|
||||
public DateTime? ValidTo { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// 适用范围。
|
||||
/// </summary>
|
||||
public PunchCardScopeDto Scope { get; init; } = new();
|
||||
|
||||
/// <summary>
|
||||
/// 使用模式(free/cap)。
|
||||
/// </summary>
|
||||
public string UsageMode { get; init; } = "free";
|
||||
|
||||
/// <summary>
|
||||
/// 金额上限。
|
||||
/// </summary>
|
||||
public decimal? UsageCapAmount { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// 每日限用。
|
||||
/// </summary>
|
||||
public int? DailyLimit { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// 每单限用。
|
||||
/// </summary>
|
||||
public int? PerOrderLimit { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// 每人限购。
|
||||
/// </summary>
|
||||
public int? PerUserPurchaseLimit { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// 是否允许转赠。
|
||||
/// </summary>
|
||||
public bool AllowTransfer { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// 过期策略(invalidate/refund)。
|
||||
/// </summary>
|
||||
public string ExpireStrategy { get; init; } = "invalidate";
|
||||
|
||||
/// <summary>
|
||||
/// 描述。
|
||||
/// </summary>
|
||||
public string? Description { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// 通知渠道(in_app/sms)。
|
||||
/// </summary>
|
||||
public IReadOnlyList<string> NotifyChannels { get; init; } = [];
|
||||
|
||||
/// <summary>
|
||||
/// 状态(enabled/disabled)。
|
||||
/// </summary>
|
||||
public string Status { get; init; } = "enabled";
|
||||
|
||||
/// <summary>
|
||||
/// 已售数量。
|
||||
/// </summary>
|
||||
public int SoldCount { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// 使用中数量。
|
||||
/// </summary>
|
||||
public int ActiveCount { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// 累计收入。
|
||||
/// </summary>
|
||||
public decimal RevenueAmount { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// 更新时间。
|
||||
/// </summary>
|
||||
public DateTime UpdatedAt { get; init; }
|
||||
}
|
||||
@@ -0,0 +1,92 @@
|
||||
namespace TakeoutSaaS.Application.App.Coupons.PunchCard.Dto;
|
||||
|
||||
/// <summary>
|
||||
/// 次卡列表项。
|
||||
/// </summary>
|
||||
public sealed class PunchCardListItemDto
|
||||
{
|
||||
/// <summary>
|
||||
/// 次卡 ID。
|
||||
/// </summary>
|
||||
public long Id { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// 次卡名称。
|
||||
/// </summary>
|
||||
public string Name { get; init; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// 封面图。
|
||||
/// </summary>
|
||||
public string? CoverImageUrl { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// 售价。
|
||||
/// </summary>
|
||||
public decimal SalePrice { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// 原价。
|
||||
/// </summary>
|
||||
public decimal? OriginalPrice { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// 总次数。
|
||||
/// </summary>
|
||||
public int TotalTimes { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// 有效期展示文案。
|
||||
/// </summary>
|
||||
public string ValiditySummary { get; init; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// 适用范围类型(all/category/tag/product)。
|
||||
/// </summary>
|
||||
public string ScopeType { get; init; } = "all";
|
||||
|
||||
/// <summary>
|
||||
/// 使用模式(free/cap)。
|
||||
/// </summary>
|
||||
public string UsageMode { get; init; } = "free";
|
||||
|
||||
/// <summary>
|
||||
/// 单次使用上限金额。
|
||||
/// </summary>
|
||||
public decimal? UsageCapAmount { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// 每日限用次数。
|
||||
/// </summary>
|
||||
public int? DailyLimit { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// 状态(enabled/disabled)。
|
||||
/// </summary>
|
||||
public string Status { get; init; } = "enabled";
|
||||
|
||||
/// <summary>
|
||||
/// 是否弱化展示。
|
||||
/// </summary>
|
||||
public bool IsDimmed { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// 已售数量。
|
||||
/// </summary>
|
||||
public int SoldCount { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// 使用中数量。
|
||||
/// </summary>
|
||||
public int ActiveCount { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// 累计收入。
|
||||
/// </summary>
|
||||
public decimal RevenueAmount { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// 更新时间。
|
||||
/// </summary>
|
||||
public DateTime UpdatedAt { get; init; }
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
namespace TakeoutSaaS.Application.App.Coupons.PunchCard.Dto;
|
||||
|
||||
/// <summary>
|
||||
/// 次卡模板列表结果。
|
||||
/// </summary>
|
||||
public sealed class PunchCardListResultDto
|
||||
{
|
||||
/// <summary>
|
||||
/// 列表项。
|
||||
/// </summary>
|
||||
public IReadOnlyList<PunchCardListItemDto> Items { get; init; } = [];
|
||||
|
||||
/// <summary>
|
||||
/// 当前页。
|
||||
/// </summary>
|
||||
public int Page { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// 每页条数。
|
||||
/// </summary>
|
||||
public int PageSize { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// 总条数。
|
||||
/// </summary>
|
||||
public int TotalCount { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// 统计数据。
|
||||
/// </summary>
|
||||
public PunchCardStatsDto Stats { get; init; } = new();
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
namespace TakeoutSaaS.Application.App.Coupons.PunchCard.Dto;
|
||||
|
||||
/// <summary>
|
||||
/// 次卡范围规则。
|
||||
/// </summary>
|
||||
public sealed class PunchCardScopeDto
|
||||
{
|
||||
/// <summary>
|
||||
/// 范围类型(all/category/tag/product)。
|
||||
/// </summary>
|
||||
public string ScopeType { get; init; } = "all";
|
||||
|
||||
/// <summary>
|
||||
/// 指定分类 ID。
|
||||
/// </summary>
|
||||
public IReadOnlyList<long> CategoryIds { get; init; } = [];
|
||||
|
||||
/// <summary>
|
||||
/// 指定标签 ID。
|
||||
/// </summary>
|
||||
public IReadOnlyList<long> TagIds { get; init; } = [];
|
||||
|
||||
/// <summary>
|
||||
/// 指定商品 ID。
|
||||
/// </summary>
|
||||
public IReadOnlyList<long> ProductIds { get; init; } = [];
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
namespace TakeoutSaaS.Application.App.Coupons.PunchCard.Dto;
|
||||
|
||||
/// <summary>
|
||||
/// 次卡模板统计。
|
||||
/// </summary>
|
||||
public sealed class PunchCardStatsDto
|
||||
{
|
||||
/// <summary>
|
||||
/// 在售次卡数量。
|
||||
/// </summary>
|
||||
public int OnSaleCount { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// 累计售出数量。
|
||||
/// </summary>
|
||||
public int TotalSoldCount { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// 累计收入。
|
||||
/// </summary>
|
||||
public decimal TotalRevenueAmount { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// 使用中数量。
|
||||
/// </summary>
|
||||
public int ActiveInUseCount { get; init; }
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
namespace TakeoutSaaS.Application.App.Coupons.PunchCard.Dto;
|
||||
|
||||
/// <summary>
|
||||
/// 次卡过滤选项。
|
||||
/// </summary>
|
||||
public sealed class PunchCardTemplateOptionDto
|
||||
{
|
||||
/// <summary>
|
||||
/// 次卡模板 ID。
|
||||
/// </summary>
|
||||
public long TemplateId { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// 次卡名称。
|
||||
/// </summary>
|
||||
public string Name { get; init; } = string.Empty;
|
||||
}
|
||||
@@ -0,0 +1,77 @@
|
||||
namespace TakeoutSaaS.Application.App.Coupons.PunchCard.Dto;
|
||||
|
||||
/// <summary>
|
||||
/// 次卡使用记录项。
|
||||
/// </summary>
|
||||
public sealed class PunchCardUsageRecordDto
|
||||
{
|
||||
/// <summary>
|
||||
/// 使用记录 ID。
|
||||
/// </summary>
|
||||
public long Id { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// 使用单号。
|
||||
/// </summary>
|
||||
public string RecordNo { get; init; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// 次卡模板 ID。
|
||||
/// </summary>
|
||||
public long PunchCardTemplateId { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// 次卡名称。
|
||||
/// </summary>
|
||||
public string PunchCardName { get; init; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// 次卡实例 ID。
|
||||
/// </summary>
|
||||
public long PunchCardInstanceId { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// 会员名称。
|
||||
/// </summary>
|
||||
public string MemberName { get; init; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// 会员手机号(脱敏)。
|
||||
/// </summary>
|
||||
public string MemberPhoneMasked { get; init; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// 兑换商品名称。
|
||||
/// </summary>
|
||||
public string ProductName { get; init; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// 使用时间。
|
||||
/// </summary>
|
||||
public DateTime UsedAt { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// 本次使用次数。
|
||||
/// </summary>
|
||||
public int UsedTimes { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// 使用后剩余次数。
|
||||
/// </summary>
|
||||
public int RemainingTimesAfterUse { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// 总次数。
|
||||
/// </summary>
|
||||
public int TotalTimes { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// 状态(normal/almost_used_up/used_up/expired)。
|
||||
/// </summary>
|
||||
public string DisplayStatus { get; init; } = "normal";
|
||||
|
||||
/// <summary>
|
||||
/// 超额补差金额。
|
||||
/// </summary>
|
||||
public decimal? ExtraPayAmount { get; init; }
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
namespace TakeoutSaaS.Application.App.Coupons.PunchCard.Dto;
|
||||
|
||||
/// <summary>
|
||||
/// 次卡使用记录导出结果。
|
||||
/// </summary>
|
||||
public sealed class PunchCardUsageRecordExportDto
|
||||
{
|
||||
/// <summary>
|
||||
/// 文件名。
|
||||
/// </summary>
|
||||
public string FileName { get; init; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// 文件内容(Base64)。
|
||||
/// </summary>
|
||||
public string FileContentBase64 { get; init; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// 导出总条数。
|
||||
/// </summary>
|
||||
public int TotalCount { get; init; }
|
||||
}
|
||||
@@ -0,0 +1,37 @@
|
||||
namespace TakeoutSaaS.Application.App.Coupons.PunchCard.Dto;
|
||||
|
||||
/// <summary>
|
||||
/// 次卡使用记录分页结果。
|
||||
/// </summary>
|
||||
public sealed class PunchCardUsageRecordListResultDto
|
||||
{
|
||||
/// <summary>
|
||||
/// 列表数据。
|
||||
/// </summary>
|
||||
public IReadOnlyList<PunchCardUsageRecordDto> Items { get; init; } = [];
|
||||
|
||||
/// <summary>
|
||||
/// 页码。
|
||||
/// </summary>
|
||||
public int Page { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// 每页条数。
|
||||
/// </summary>
|
||||
public int PageSize { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// 总条数。
|
||||
/// </summary>
|
||||
public int TotalCount { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// 统计数据。
|
||||
/// </summary>
|
||||
public PunchCardUsageStatsDto Stats { get; init; } = new();
|
||||
|
||||
/// <summary>
|
||||
/// 次卡筛选选项。
|
||||
/// </summary>
|
||||
public IReadOnlyList<PunchCardTemplateOptionDto> TemplateOptions { get; init; } = [];
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
namespace TakeoutSaaS.Application.App.Coupons.PunchCard.Dto;
|
||||
|
||||
/// <summary>
|
||||
/// 次卡使用记录统计。
|
||||
/// </summary>
|
||||
public sealed class PunchCardUsageStatsDto
|
||||
{
|
||||
/// <summary>
|
||||
/// 今日使用次数。
|
||||
/// </summary>
|
||||
public int TodayUsedCount { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// 本月使用次数。
|
||||
/// </summary>
|
||||
public int MonthUsedCount { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// 7 天内即将过期数量。
|
||||
/// </summary>
|
||||
public int ExpiringSoonCount { get; init; }
|
||||
}
|
||||
@@ -0,0 +1,51 @@
|
||||
using MediatR;
|
||||
using TakeoutSaaS.Application.App.Coupons.PunchCard.Commands;
|
||||
using TakeoutSaaS.Application.App.Coupons.PunchCard.Dto;
|
||||
using TakeoutSaaS.Domain.Coupons.Repositories;
|
||||
using TakeoutSaaS.Shared.Abstractions.Constants;
|
||||
using TakeoutSaaS.Shared.Abstractions.Exceptions;
|
||||
using TakeoutSaaS.Shared.Abstractions.Tenancy;
|
||||
|
||||
namespace TakeoutSaaS.Application.App.Coupons.PunchCard.Handlers;
|
||||
|
||||
/// <summary>
|
||||
/// 次卡状态变更处理器。
|
||||
/// </summary>
|
||||
public sealed class ChangePunchCardTemplateStatusCommandHandler(
|
||||
IPunchCardRepository repository,
|
||||
ITenantProvider tenantProvider)
|
||||
: IRequestHandler<ChangePunchCardTemplateStatusCommand, PunchCardDetailDto>
|
||||
{
|
||||
/// <inheritdoc />
|
||||
public async Task<PunchCardDetailDto> Handle(
|
||||
ChangePunchCardTemplateStatusCommand request,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var tenantId = tenantProvider.GetCurrentTenantId();
|
||||
var normalizedStatus = PunchCardMapping.ParseTemplateStatus(request.Status);
|
||||
|
||||
var entity = await repository.FindTemplateByIdAsync(
|
||||
tenantId,
|
||||
request.StoreId,
|
||||
request.TemplateId,
|
||||
cancellationToken)
|
||||
?? throw new BusinessException(ErrorCodes.NotFound, "次卡不存在");
|
||||
|
||||
entity.Status = normalizedStatus;
|
||||
|
||||
await repository.UpdateTemplateAsync(entity, cancellationToken);
|
||||
await repository.SaveChangesAsync(cancellationToken);
|
||||
|
||||
var aggregateMap = await repository.GetTemplateAggregateByTemplateIdsAsync(
|
||||
tenantId,
|
||||
request.StoreId,
|
||||
[entity.Id],
|
||||
cancellationToken);
|
||||
|
||||
var aggregate = aggregateMap.TryGetValue(entity.Id, out var value)
|
||||
? value
|
||||
: PunchCardDtoFactory.EmptyAggregate(entity.Id);
|
||||
|
||||
return PunchCardDtoFactory.ToDetailDto(entity, aggregate);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,43 @@
|
||||
using MediatR;
|
||||
using TakeoutSaaS.Application.App.Coupons.PunchCard.Commands;
|
||||
using TakeoutSaaS.Domain.Coupons.Repositories;
|
||||
using TakeoutSaaS.Shared.Abstractions.Constants;
|
||||
using TakeoutSaaS.Shared.Abstractions.Exceptions;
|
||||
using TakeoutSaaS.Shared.Abstractions.Tenancy;
|
||||
|
||||
namespace TakeoutSaaS.Application.App.Coupons.PunchCard.Handlers;
|
||||
|
||||
/// <summary>
|
||||
/// 删除次卡模板处理器。
|
||||
/// </summary>
|
||||
public sealed class DeletePunchCardTemplateCommandHandler(
|
||||
IPunchCardRepository repository,
|
||||
ITenantProvider tenantProvider)
|
||||
: IRequestHandler<DeletePunchCardTemplateCommand>
|
||||
{
|
||||
/// <inheritdoc />
|
||||
public async Task Handle(DeletePunchCardTemplateCommand request, CancellationToken cancellationToken)
|
||||
{
|
||||
var tenantId = tenantProvider.GetCurrentTenantId();
|
||||
var entity = await repository.FindTemplateByIdAsync(
|
||||
tenantId,
|
||||
request.StoreId,
|
||||
request.TemplateId,
|
||||
cancellationToken)
|
||||
?? throw new BusinessException(ErrorCodes.NotFound, "次卡不存在");
|
||||
|
||||
var aggregate = await repository.GetTemplateAggregateByTemplateIdsAsync(
|
||||
tenantId,
|
||||
request.StoreId,
|
||||
[entity.Id],
|
||||
cancellationToken);
|
||||
|
||||
if (aggregate.TryGetValue(entity.Id, out var snapshot) && snapshot.SoldCount > 0)
|
||||
{
|
||||
throw new BusinessException(ErrorCodes.BadRequest, "已售出的次卡不可删除");
|
||||
}
|
||||
|
||||
await repository.DeleteTemplateAsync(entity, cancellationToken);
|
||||
await repository.SaveChangesAsync(cancellationToken);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,128 @@
|
||||
using System.Text;
|
||||
using MediatR;
|
||||
using TakeoutSaaS.Application.App.Coupons.PunchCard.Dto;
|
||||
using TakeoutSaaS.Application.App.Coupons.PunchCard.Queries;
|
||||
using TakeoutSaaS.Domain.Coupons.Repositories;
|
||||
using TakeoutSaaS.Shared.Abstractions.Tenancy;
|
||||
|
||||
namespace TakeoutSaaS.Application.App.Coupons.PunchCard.Handlers;
|
||||
|
||||
/// <summary>
|
||||
/// 导出次卡使用记录处理器。
|
||||
/// </summary>
|
||||
public sealed class ExportPunchCardUsageRecordCsvQueryHandler(
|
||||
IPunchCardRepository repository,
|
||||
ITenantProvider tenantProvider)
|
||||
: IRequestHandler<ExportPunchCardUsageRecordCsvQuery, PunchCardUsageRecordExportDto>
|
||||
{
|
||||
/// <inheritdoc />
|
||||
public async Task<PunchCardUsageRecordExportDto> Handle(
|
||||
ExportPunchCardUsageRecordCsvQuery request,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var tenantId = tenantProvider.GetCurrentTenantId();
|
||||
var normalizedStatus = PunchCardMapping.ParseUsageStatusFilter(request.Status);
|
||||
|
||||
var records = await repository.ListUsageRecordsForExportAsync(
|
||||
tenantId,
|
||||
request.StoreId,
|
||||
request.TemplateId,
|
||||
request.Keyword,
|
||||
normalizedStatus,
|
||||
cancellationToken);
|
||||
|
||||
if (records.Count == 0)
|
||||
{
|
||||
return new PunchCardUsageRecordExportDto
|
||||
{
|
||||
FileName = $"次卡使用记录_{DateTime.UtcNow:yyyyMMddHHmmss}.csv",
|
||||
FileContentBase64 = Convert.ToBase64String(Encoding.UTF8.GetBytes("\uFEFF使用单号,会员,手机号,次卡,兑换商品,使用时间,剩余次数,总次数,状态\n")),
|
||||
TotalCount = 0
|
||||
};
|
||||
}
|
||||
|
||||
var instanceIds = records.Select(item => item.PunchCardInstanceId).Distinct().ToList();
|
||||
var instances = await repository.GetInstancesByIdsAsync(
|
||||
tenantId,
|
||||
request.StoreId,
|
||||
instanceIds,
|
||||
cancellationToken);
|
||||
|
||||
var instanceMap = instances.ToDictionary(item => item.Id, item => item);
|
||||
|
||||
var templateIds = records.Select(item => item.PunchCardTemplateId)
|
||||
.Concat(instances.Select(item => item.PunchCardTemplateId))
|
||||
.Distinct()
|
||||
.ToList();
|
||||
|
||||
var templates = await repository.GetTemplatesByIdsAsync(
|
||||
tenantId,
|
||||
request.StoreId,
|
||||
templateIds,
|
||||
cancellationToken);
|
||||
|
||||
var templateMap = templates.ToDictionary(item => item.Id, item => item);
|
||||
|
||||
var csv = BuildCsv(records, instanceMap, templateMap);
|
||||
var bytes = Encoding.UTF8.GetBytes($"\uFEFF{csv}");
|
||||
|
||||
return new PunchCardUsageRecordExportDto
|
||||
{
|
||||
FileName = $"次卡使用记录_{DateTime.UtcNow:yyyyMMddHHmmss}.csv",
|
||||
FileContentBase64 = Convert.ToBase64String(bytes),
|
||||
TotalCount = records.Count
|
||||
};
|
||||
}
|
||||
|
||||
private static string BuildCsv(
|
||||
IReadOnlyCollection<Domain.Coupons.Entities.PunchCardUsageRecord> records,
|
||||
IReadOnlyDictionary<long, Domain.Coupons.Entities.PunchCardInstance> instanceMap,
|
||||
IReadOnlyDictionary<long, Domain.Coupons.Entities.PunchCardTemplate> templateMap)
|
||||
{
|
||||
var lines = new List<string>
|
||||
{
|
||||
"使用单号,会员,手机号,次卡,兑换商品,使用时间,剩余次数,总次数,状态"
|
||||
};
|
||||
|
||||
var nowUtc = DateTime.UtcNow;
|
||||
foreach (var record in records)
|
||||
{
|
||||
instanceMap.TryGetValue(record.PunchCardInstanceId, out var instance);
|
||||
templateMap.TryGetValue(record.PunchCardTemplateId, out var template);
|
||||
|
||||
var dto = PunchCardDtoFactory.ToUsageRecordDto(record, instance, template, nowUtc);
|
||||
var statusText = ResolveStatusText(dto.DisplayStatus);
|
||||
|
||||
lines.Add(string.Join(",",
|
||||
Escape(dto.RecordNo),
|
||||
Escape(dto.MemberName),
|
||||
Escape(dto.MemberPhoneMasked),
|
||||
Escape(dto.PunchCardName),
|
||||
Escape(dto.ProductName),
|
||||
Escape(dto.UsedAt.ToString("yyyy-MM-dd HH:mm:ss")),
|
||||
dto.RemainingTimesAfterUse,
|
||||
dto.TotalTimes,
|
||||
Escape(statusText)));
|
||||
}
|
||||
|
||||
return string.Join('\n', lines);
|
||||
}
|
||||
|
||||
private static string ResolveStatusText(string value)
|
||||
{
|
||||
return value switch
|
||||
{
|
||||
"normal" => "正常使用",
|
||||
"almost_used_up" => "即将用完",
|
||||
"used_up" => "已用完",
|
||||
"expired" => "已过期",
|
||||
_ => "正常使用"
|
||||
};
|
||||
}
|
||||
|
||||
private static string Escape(string value)
|
||||
{
|
||||
var text = value.Replace("\"", "\"\"");
|
||||
return $"\"{text}\"";
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,47 @@
|
||||
using MediatR;
|
||||
using TakeoutSaaS.Application.App.Coupons.PunchCard.Dto;
|
||||
using TakeoutSaaS.Application.App.Coupons.PunchCard.Queries;
|
||||
using TakeoutSaaS.Domain.Coupons.Repositories;
|
||||
using TakeoutSaaS.Shared.Abstractions.Tenancy;
|
||||
|
||||
namespace TakeoutSaaS.Application.App.Coupons.PunchCard.Handlers;
|
||||
|
||||
/// <summary>
|
||||
/// 次卡模板详情查询处理器。
|
||||
/// </summary>
|
||||
public sealed class GetPunchCardTemplateDetailQueryHandler(
|
||||
IPunchCardRepository repository,
|
||||
ITenantProvider tenantProvider)
|
||||
: IRequestHandler<GetPunchCardTemplateDetailQuery, PunchCardDetailDto?>
|
||||
{
|
||||
/// <inheritdoc />
|
||||
public async Task<PunchCardDetailDto?> Handle(
|
||||
GetPunchCardTemplateDetailQuery request,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var tenantId = tenantProvider.GetCurrentTenantId();
|
||||
|
||||
var template = await repository.FindTemplateByIdAsync(
|
||||
tenantId,
|
||||
request.StoreId,
|
||||
request.TemplateId,
|
||||
cancellationToken);
|
||||
|
||||
if (template is null)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
var aggregate = await repository.GetTemplateAggregateByTemplateIdsAsync(
|
||||
tenantId,
|
||||
request.StoreId,
|
||||
[request.TemplateId],
|
||||
cancellationToken);
|
||||
|
||||
var snapshot = aggregate.TryGetValue(template.Id, out var value)
|
||||
? value
|
||||
: PunchCardDtoFactory.EmptyAggregate(template.Id);
|
||||
|
||||
return PunchCardDtoFactory.ToDetailDto(template, snapshot);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,67 @@
|
||||
using MediatR;
|
||||
using TakeoutSaaS.Application.App.Coupons.PunchCard.Dto;
|
||||
using TakeoutSaaS.Application.App.Coupons.PunchCard.Queries;
|
||||
using TakeoutSaaS.Domain.Coupons.Repositories;
|
||||
using TakeoutSaaS.Shared.Abstractions.Tenancy;
|
||||
|
||||
namespace TakeoutSaaS.Application.App.Coupons.PunchCard.Handlers;
|
||||
|
||||
/// <summary>
|
||||
/// 次卡模板列表查询处理器。
|
||||
/// </summary>
|
||||
public sealed class GetPunchCardTemplateListQueryHandler(
|
||||
IPunchCardRepository repository,
|
||||
ITenantProvider tenantProvider)
|
||||
: IRequestHandler<GetPunchCardTemplateListQuery, PunchCardListResultDto>
|
||||
{
|
||||
/// <inheritdoc />
|
||||
public async Task<PunchCardListResultDto> Handle(
|
||||
GetPunchCardTemplateListQuery request,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var tenantId = tenantProvider.GetCurrentTenantId();
|
||||
var page = Math.Max(1, request.Page);
|
||||
var pageSize = Math.Clamp(request.PageSize, 1, 200);
|
||||
var status = PunchCardMapping.ParseTemplateStatusFilter(request.Status);
|
||||
|
||||
var (items, totalCount) = await repository.SearchTemplatesAsync(
|
||||
tenantId,
|
||||
request.StoreId,
|
||||
request.Keyword,
|
||||
status,
|
||||
page,
|
||||
pageSize,
|
||||
cancellationToken);
|
||||
|
||||
var templateIds = items.Select(item => item.Id).ToList();
|
||||
var aggregates = await repository.GetTemplateAggregateByTemplateIdsAsync(
|
||||
tenantId,
|
||||
request.StoreId,
|
||||
templateIds,
|
||||
cancellationToken);
|
||||
|
||||
var mappedItems = items
|
||||
.Select(item =>
|
||||
{
|
||||
var aggregate = aggregates.TryGetValue(item.Id, out var value)
|
||||
? value
|
||||
: PunchCardDtoFactory.EmptyAggregate(item.Id);
|
||||
return PunchCardDtoFactory.ToListItemDto(item, aggregate);
|
||||
})
|
||||
.ToList();
|
||||
|
||||
var statsSnapshot = await repository.GetTemplateStatsAsync(
|
||||
tenantId,
|
||||
request.StoreId,
|
||||
cancellationToken);
|
||||
|
||||
return new PunchCardListResultDto
|
||||
{
|
||||
Items = mappedItems,
|
||||
Page = page,
|
||||
PageSize = pageSize,
|
||||
TotalCount = totalCount,
|
||||
Stats = PunchCardDtoFactory.ToStatsDto(statsSnapshot)
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,104 @@
|
||||
using MediatR;
|
||||
using TakeoutSaaS.Application.App.Coupons.PunchCard.Dto;
|
||||
using TakeoutSaaS.Application.App.Coupons.PunchCard.Queries;
|
||||
using TakeoutSaaS.Domain.Coupons.Repositories;
|
||||
using TakeoutSaaS.Shared.Abstractions.Tenancy;
|
||||
|
||||
namespace TakeoutSaaS.Application.App.Coupons.PunchCard.Handlers;
|
||||
|
||||
/// <summary>
|
||||
/// 次卡使用记录列表查询处理器。
|
||||
/// </summary>
|
||||
public sealed class GetPunchCardUsageRecordListQueryHandler(
|
||||
IPunchCardRepository repository,
|
||||
ITenantProvider tenantProvider)
|
||||
: IRequestHandler<GetPunchCardUsageRecordListQuery, PunchCardUsageRecordListResultDto>
|
||||
{
|
||||
/// <inheritdoc />
|
||||
public async Task<PunchCardUsageRecordListResultDto> Handle(
|
||||
GetPunchCardUsageRecordListQuery request,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var tenantId = tenantProvider.GetCurrentTenantId();
|
||||
var page = Math.Max(1, request.Page);
|
||||
var pageSize = Math.Clamp(request.PageSize, 1, 500);
|
||||
var normalizedStatus = PunchCardMapping.ParseUsageStatusFilter(request.Status);
|
||||
|
||||
var (records, totalCount) = await repository.SearchUsageRecordsAsync(
|
||||
tenantId,
|
||||
request.StoreId,
|
||||
request.TemplateId,
|
||||
request.Keyword,
|
||||
normalizedStatus,
|
||||
page,
|
||||
pageSize,
|
||||
cancellationToken);
|
||||
|
||||
var instanceIds = records.Select(item => item.PunchCardInstanceId).Distinct().ToList();
|
||||
var instances = await repository.GetInstancesByIdsAsync(
|
||||
tenantId,
|
||||
request.StoreId,
|
||||
instanceIds,
|
||||
cancellationToken);
|
||||
|
||||
var instanceMap = instances.ToDictionary(item => item.Id, item => item);
|
||||
|
||||
var templateIds = records.Select(item => item.PunchCardTemplateId)
|
||||
.Concat(instances.Select(item => item.PunchCardTemplateId))
|
||||
.Distinct()
|
||||
.ToList();
|
||||
|
||||
var templates = await repository.GetTemplatesByIdsAsync(
|
||||
tenantId,
|
||||
request.StoreId,
|
||||
templateIds,
|
||||
cancellationToken);
|
||||
|
||||
var templateMap = templates.ToDictionary(item => item.Id, item => item);
|
||||
|
||||
var nowUtc = DateTime.UtcNow;
|
||||
var mappedRecords = records
|
||||
.Select(record =>
|
||||
{
|
||||
instanceMap.TryGetValue(record.PunchCardInstanceId, out var instance);
|
||||
templateMap.TryGetValue(record.PunchCardTemplateId, out var template);
|
||||
return PunchCardDtoFactory.ToUsageRecordDto(record, instance, template, nowUtc);
|
||||
})
|
||||
.ToList();
|
||||
|
||||
var usageStats = await repository.GetUsageStatsAsync(
|
||||
tenantId,
|
||||
request.StoreId,
|
||||
request.TemplateId,
|
||||
nowUtc,
|
||||
cancellationToken);
|
||||
|
||||
var (templateRows, _) = await repository.SearchTemplatesAsync(
|
||||
tenantId,
|
||||
request.StoreId,
|
||||
null,
|
||||
null,
|
||||
1,
|
||||
500,
|
||||
cancellationToken);
|
||||
|
||||
var templateOptions = templateRows
|
||||
.OrderBy(item => item.Name, StringComparer.Ordinal)
|
||||
.Select(item => new PunchCardTemplateOptionDto
|
||||
{
|
||||
TemplateId = item.Id,
|
||||
Name = item.Name
|
||||
})
|
||||
.ToList();
|
||||
|
||||
return new PunchCardUsageRecordListResultDto
|
||||
{
|
||||
Items = mappedRecords,
|
||||
Page = page,
|
||||
PageSize = pageSize,
|
||||
TotalCount = totalCount,
|
||||
Stats = PunchCardDtoFactory.ToUsageStatsDto(usageStats),
|
||||
TemplateOptions = templateOptions
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,158 @@
|
||||
using MediatR;
|
||||
using TakeoutSaaS.Application.App.Coupons.PunchCard.Commands;
|
||||
using TakeoutSaaS.Application.App.Coupons.PunchCard.Dto;
|
||||
using TakeoutSaaS.Domain.Coupons.Enums;
|
||||
using TakeoutSaaS.Domain.Coupons.Repositories;
|
||||
using TakeoutSaaS.Shared.Abstractions.Constants;
|
||||
using TakeoutSaaS.Shared.Abstractions.Exceptions;
|
||||
using TakeoutSaaS.Shared.Abstractions.Tenancy;
|
||||
|
||||
namespace TakeoutSaaS.Application.App.Coupons.PunchCard.Handlers;
|
||||
|
||||
/// <summary>
|
||||
/// 次卡模板保存处理器。
|
||||
/// </summary>
|
||||
public sealed class SavePunchCardTemplateCommandHandler(
|
||||
IPunchCardRepository repository,
|
||||
ITenantProvider tenantProvider)
|
||||
: IRequestHandler<SavePunchCardTemplateCommand, PunchCardDetailDto>
|
||||
{
|
||||
/// <inheritdoc />
|
||||
public async Task<PunchCardDetailDto> Handle(
|
||||
SavePunchCardTemplateCommand request,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var tenantId = tenantProvider.GetCurrentTenantId();
|
||||
|
||||
var normalizedName = PunchCardMapping.NormalizeName(request.Name);
|
||||
var normalizedCoverImageUrl = PunchCardMapping.NormalizeOptionalCoverUrl(request.CoverImageUrl);
|
||||
var normalizedSalePrice = PunchCardMapping.NormalizeAmount(request.SalePrice, "salePrice", false);
|
||||
var normalizedOriginalPrice = PunchCardMapping.NormalizeOptionalAmount(request.OriginalPrice, "originalPrice", true);
|
||||
var normalizedTotalTimes = PunchCardMapping.NormalizeRequiredPositiveInt(request.TotalTimes, "totalTimes", 10_000);
|
||||
|
||||
if (normalizedOriginalPrice.HasValue && normalizedOriginalPrice.Value < normalizedSalePrice)
|
||||
{
|
||||
throw new BusinessException(ErrorCodes.BadRequest, "originalPrice 不能小于 salePrice");
|
||||
}
|
||||
|
||||
var validityType = PunchCardMapping.ParseValidityType(request.ValidityType);
|
||||
var (normalizedValidityDays, normalizedValidFrom, normalizedValidTo) = PunchCardMapping.NormalizeValidity(
|
||||
validityType,
|
||||
request.ValidityDays,
|
||||
request.ValidFrom,
|
||||
request.ValidTo);
|
||||
|
||||
var scopeType = PunchCardMapping.ParseScopeType(request.ScopeType);
|
||||
var (normalizedCategoryIds, normalizedTagIds, normalizedProductIds) = PunchCardMapping.NormalizeScopeIds(
|
||||
scopeType,
|
||||
request.ScopeCategoryIds,
|
||||
request.ScopeTagIds,
|
||||
request.ScopeProductIds);
|
||||
|
||||
var usageMode = PunchCardMapping.ParseUsageMode(request.UsageMode);
|
||||
var normalizedUsageCapAmount = usageMode switch
|
||||
{
|
||||
PunchCardUsageMode.Free => null,
|
||||
PunchCardUsageMode.Cap => PunchCardMapping.NormalizeOptionalAmount(request.UsageCapAmount, "usageCapAmount", false),
|
||||
_ => null
|
||||
};
|
||||
|
||||
if (usageMode == PunchCardUsageMode.Cap && !normalizedUsageCapAmount.HasValue)
|
||||
{
|
||||
throw new BusinessException(ErrorCodes.BadRequest, "usageCapAmount 不能为空");
|
||||
}
|
||||
|
||||
var normalizedDailyLimit = PunchCardMapping.NormalizeOptionalLimit(request.DailyLimit, "dailyLimit", normalizedTotalTimes);
|
||||
var normalizedPerOrderLimit = PunchCardMapping.NormalizeOptionalLimit(request.PerOrderLimit, "perOrderLimit", normalizedTotalTimes);
|
||||
var normalizedPerUserPurchaseLimit = PunchCardMapping.NormalizeOptionalLimit(request.PerUserPurchaseLimit, "perUserPurchaseLimit", 1000);
|
||||
|
||||
var expireStrategy = PunchCardMapping.ParseExpireStrategy(request.ExpireStrategy);
|
||||
var normalizedDescription = PunchCardMapping.NormalizeOptionalDescription(request.Description);
|
||||
var normalizedNotifyChannelsJson = PunchCardMapping.SerializeNotifyChannels(request.NotifyChannels);
|
||||
|
||||
var normalizedCategoryIdsJson = PunchCardMapping.SerializeSnowflakeIds(normalizedCategoryIds);
|
||||
var normalizedTagIdsJson = PunchCardMapping.SerializeSnowflakeIds(normalizedTagIds);
|
||||
var normalizedProductIdsJson = PunchCardMapping.SerializeSnowflakeIds(normalizedProductIds);
|
||||
|
||||
if (!request.TemplateId.HasValue)
|
||||
{
|
||||
var newEntity = PunchCardDtoFactory.CreateTemplateEntity(
|
||||
request,
|
||||
normalizedName,
|
||||
normalizedCoverImageUrl,
|
||||
normalizedSalePrice,
|
||||
normalizedOriginalPrice,
|
||||
normalizedTotalTimes,
|
||||
validityType,
|
||||
normalizedValidityDays,
|
||||
normalizedValidFrom,
|
||||
normalizedValidTo,
|
||||
scopeType,
|
||||
normalizedCategoryIdsJson,
|
||||
normalizedTagIdsJson,
|
||||
normalizedProductIdsJson,
|
||||
usageMode,
|
||||
normalizedUsageCapAmount,
|
||||
normalizedDailyLimit,
|
||||
normalizedPerOrderLimit,
|
||||
normalizedPerUserPurchaseLimit,
|
||||
expireStrategy,
|
||||
normalizedDescription,
|
||||
normalizedNotifyChannelsJson);
|
||||
|
||||
await repository.AddTemplateAsync(newEntity, cancellationToken);
|
||||
await repository.SaveChangesAsync(cancellationToken);
|
||||
|
||||
return PunchCardDtoFactory.ToDetailDto(
|
||||
newEntity,
|
||||
PunchCardDtoFactory.EmptyAggregate(newEntity.Id));
|
||||
}
|
||||
|
||||
var entity = await repository.FindTemplateByIdAsync(
|
||||
tenantId,
|
||||
request.StoreId,
|
||||
request.TemplateId.Value,
|
||||
cancellationToken)
|
||||
?? throw new BusinessException(ErrorCodes.NotFound, "次卡不存在");
|
||||
|
||||
entity.Name = normalizedName;
|
||||
entity.CoverImageUrl = string.IsNullOrWhiteSpace(normalizedCoverImageUrl)
|
||||
? null
|
||||
: normalizedCoverImageUrl;
|
||||
entity.SalePrice = normalizedSalePrice;
|
||||
entity.OriginalPrice = normalizedOriginalPrice;
|
||||
entity.TotalTimes = normalizedTotalTimes;
|
||||
entity.ValidityType = validityType;
|
||||
entity.ValidityDays = normalizedValidityDays;
|
||||
entity.ValidFrom = normalizedValidFrom;
|
||||
entity.ValidTo = normalizedValidTo;
|
||||
entity.ScopeType = scopeType;
|
||||
entity.ScopeCategoryIdsJson = normalizedCategoryIdsJson;
|
||||
entity.ScopeTagIdsJson = normalizedTagIdsJson;
|
||||
entity.ScopeProductIdsJson = normalizedProductIdsJson;
|
||||
entity.UsageMode = usageMode;
|
||||
entity.UsageCapAmount = normalizedUsageCapAmount;
|
||||
entity.DailyLimit = normalizedDailyLimit;
|
||||
entity.PerOrderLimit = normalizedPerOrderLimit;
|
||||
entity.PerUserPurchaseLimit = normalizedPerUserPurchaseLimit;
|
||||
entity.AllowTransfer = request.AllowTransfer;
|
||||
entity.ExpireStrategy = expireStrategy;
|
||||
entity.Description = normalizedDescription;
|
||||
entity.NotifyChannelsJson = normalizedNotifyChannelsJson;
|
||||
|
||||
await repository.UpdateTemplateAsync(entity, cancellationToken);
|
||||
await repository.SaveChangesAsync(cancellationToken);
|
||||
|
||||
var aggregateMap = await repository.GetTemplateAggregateByTemplateIdsAsync(
|
||||
tenantId,
|
||||
request.StoreId,
|
||||
[entity.Id],
|
||||
cancellationToken);
|
||||
|
||||
var aggregate = aggregateMap.TryGetValue(entity.Id, out var value)
|
||||
? value
|
||||
: PunchCardDtoFactory.EmptyAggregate(entity.Id);
|
||||
|
||||
return PunchCardDtoFactory.ToDetailDto(entity, aggregate);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,160 @@
|
||||
using MediatR;
|
||||
using TakeoutSaaS.Application.App.Coupons.PunchCard.Commands;
|
||||
using TakeoutSaaS.Application.App.Coupons.PunchCard.Dto;
|
||||
using TakeoutSaaS.Domain.Coupons.Entities;
|
||||
using TakeoutSaaS.Domain.Coupons.Enums;
|
||||
using TakeoutSaaS.Domain.Coupons.Repositories;
|
||||
using TakeoutSaaS.Shared.Abstractions.Constants;
|
||||
using TakeoutSaaS.Shared.Abstractions.Exceptions;
|
||||
using TakeoutSaaS.Shared.Abstractions.Tenancy;
|
||||
|
||||
namespace TakeoutSaaS.Application.App.Coupons.PunchCard.Handlers;
|
||||
|
||||
/// <summary>
|
||||
/// 写入次卡使用记录处理器。
|
||||
/// </summary>
|
||||
public sealed class WritePunchCardUsageRecordCommandHandler(
|
||||
IPunchCardRepository repository,
|
||||
ITenantProvider tenantProvider)
|
||||
: IRequestHandler<WritePunchCardUsageRecordCommand, PunchCardUsageRecordDto>
|
||||
{
|
||||
/// <inheritdoc />
|
||||
public async Task<PunchCardUsageRecordDto> Handle(
|
||||
WritePunchCardUsageRecordCommand request,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var tenantId = tenantProvider.GetCurrentTenantId();
|
||||
var usedAt = request.UsedAt.HasValue
|
||||
? PunchCardMapping.NormalizeUtc(request.UsedAt.Value)
|
||||
: DateTime.UtcNow;
|
||||
|
||||
var template = await repository.FindTemplateByIdAsync(
|
||||
tenantId,
|
||||
request.StoreId,
|
||||
request.TemplateId,
|
||||
cancellationToken)
|
||||
?? throw new BusinessException(ErrorCodes.NotFound, "次卡不存在");
|
||||
|
||||
if (template.Status != PunchCardStatus.Enabled)
|
||||
{
|
||||
throw new BusinessException(ErrorCodes.BadRequest, "次卡已下架,无法使用");
|
||||
}
|
||||
|
||||
var productName = PunchCardMapping.NormalizeProductName(request.ProductName);
|
||||
var usedTimes = PunchCardMapping.NormalizeRequiredPositiveInt(request.UsedTimes, "usedTimes", template.TotalTimes);
|
||||
var extraPayAmount = PunchCardMapping.NormalizeOptionalAmount(request.ExtraPayAmount, "extraPayAmount", true);
|
||||
|
||||
PunchCardInstance? instance = null;
|
||||
if (request.InstanceId.HasValue && request.InstanceId.Value > 0)
|
||||
{
|
||||
instance = await repository.FindInstanceByIdAsync(
|
||||
tenantId,
|
||||
request.StoreId,
|
||||
request.InstanceId.Value,
|
||||
cancellationToken);
|
||||
}
|
||||
else if (!string.IsNullOrWhiteSpace(request.InstanceNo))
|
||||
{
|
||||
var normalizedInstanceNo = PunchCardMapping.NormalizeInstanceNo(request.InstanceNo);
|
||||
instance = await repository.FindInstanceByNoAsync(
|
||||
tenantId,
|
||||
request.StoreId,
|
||||
normalizedInstanceNo,
|
||||
cancellationToken);
|
||||
}
|
||||
|
||||
if (instance is not null && instance.PunchCardTemplateId != template.Id)
|
||||
{
|
||||
throw new BusinessException(ErrorCodes.BadRequest, "次卡实例与模板不匹配");
|
||||
}
|
||||
|
||||
var isNewInstance = false;
|
||||
if (instance is null)
|
||||
{
|
||||
var memberName = PunchCardMapping.NormalizeMemberName(request.MemberName);
|
||||
var memberPhoneMasked = PunchCardMapping.NormalizeMemberPhoneMasked(request.MemberPhoneMasked);
|
||||
var purchasedAt = usedAt;
|
||||
|
||||
instance = new PunchCardInstance
|
||||
{
|
||||
StoreId = request.StoreId,
|
||||
PunchCardTemplateId = template.Id,
|
||||
InstanceNo = PunchCardDtoFactory.GenerateInstanceNo(usedAt),
|
||||
MemberName = memberName,
|
||||
MemberPhoneMasked = memberPhoneMasked,
|
||||
PurchasedAt = purchasedAt,
|
||||
ExpiresAt = PunchCardMapping.ResolveInstanceExpireAt(template, purchasedAt),
|
||||
TotalTimes = template.TotalTimes,
|
||||
RemainingTimes = template.TotalTimes,
|
||||
PaidAmount = template.SalePrice,
|
||||
Status = PunchCardInstanceStatus.Active
|
||||
};
|
||||
isNewInstance = true;
|
||||
}
|
||||
|
||||
if (PunchCardMapping.IsInstanceExpired(instance, usedAt))
|
||||
{
|
||||
throw new BusinessException(ErrorCodes.BadRequest, "次卡已过期");
|
||||
}
|
||||
|
||||
if (instance.Status == PunchCardInstanceStatus.Refunded)
|
||||
{
|
||||
throw new BusinessException(ErrorCodes.BadRequest, "次卡已退款");
|
||||
}
|
||||
|
||||
if (instance.RemainingTimes <= 0 || instance.Status == PunchCardInstanceStatus.UsedUp)
|
||||
{
|
||||
throw new BusinessException(ErrorCodes.BadRequest, "次卡已用完");
|
||||
}
|
||||
|
||||
if (template.PerOrderLimit.HasValue && usedTimes > template.PerOrderLimit.Value)
|
||||
{
|
||||
throw new BusinessException(ErrorCodes.BadRequest, "超出每单限用次数");
|
||||
}
|
||||
|
||||
if (usedTimes > instance.RemainingTimes)
|
||||
{
|
||||
throw new BusinessException(ErrorCodes.BadRequest, "超出次卡剩余次数");
|
||||
}
|
||||
|
||||
var remainingTimes = instance.RemainingTimes - usedTimes;
|
||||
var statusAfterUse = PunchCardMapping.ResolveUsageRecordStatus(instance, remainingTimes, usedAt);
|
||||
|
||||
instance.RemainingTimes = remainingTimes;
|
||||
instance.Status = statusAfterUse switch
|
||||
{
|
||||
PunchCardUsageRecordStatus.UsedUp => PunchCardInstanceStatus.UsedUp,
|
||||
PunchCardUsageRecordStatus.Expired => PunchCardInstanceStatus.Expired,
|
||||
_ => PunchCardInstanceStatus.Active
|
||||
};
|
||||
|
||||
if (isNewInstance)
|
||||
{
|
||||
await repository.AddInstanceAsync(instance, cancellationToken);
|
||||
await repository.SaveChangesAsync(cancellationToken);
|
||||
}
|
||||
else
|
||||
{
|
||||
await repository.UpdateInstanceAsync(instance, cancellationToken);
|
||||
}
|
||||
|
||||
var record = new PunchCardUsageRecord
|
||||
{
|
||||
StoreId = request.StoreId,
|
||||
PunchCardTemplateId = template.Id,
|
||||
PunchCardInstanceId = instance.Id,
|
||||
RecordNo = PunchCardDtoFactory.GenerateRecordNo(usedAt),
|
||||
ProductName = productName,
|
||||
UsedAt = usedAt,
|
||||
UsedTimes = usedTimes,
|
||||
RemainingTimesAfterUse = remainingTimes,
|
||||
StatusAfterUse = statusAfterUse,
|
||||
ExtraPayAmount = extraPayAmount
|
||||
};
|
||||
|
||||
await repository.AddUsageRecordAsync(record, cancellationToken);
|
||||
await repository.SaveChangesAsync(cancellationToken);
|
||||
|
||||
return PunchCardDtoFactory.ToUsageRecordDto(record, instance, template, usedAt);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,210 @@
|
||||
using TakeoutSaaS.Application.App.Coupons.PunchCard.Commands;
|
||||
using TakeoutSaaS.Application.App.Coupons.PunchCard.Dto;
|
||||
using TakeoutSaaS.Domain.Coupons.Entities;
|
||||
using TakeoutSaaS.Domain.Coupons.Enums;
|
||||
using TakeoutSaaS.Domain.Coupons.Repositories;
|
||||
|
||||
namespace TakeoutSaaS.Application.App.Coupons.PunchCard;
|
||||
|
||||
/// <summary>
|
||||
/// 次卡 DTO 构造器。
|
||||
/// </summary>
|
||||
internal static class PunchCardDtoFactory
|
||||
{
|
||||
public static PunchCardTemplateAggregateSnapshot EmptyAggregate(long templateId)
|
||||
{
|
||||
return new PunchCardTemplateAggregateSnapshot
|
||||
{
|
||||
TemplateId = templateId,
|
||||
SoldCount = 0,
|
||||
ActiveCount = 0,
|
||||
RevenueAmount = 0m
|
||||
};
|
||||
}
|
||||
|
||||
public static PunchCardListItemDto ToListItemDto(
|
||||
PunchCardTemplate template,
|
||||
PunchCardTemplateAggregateSnapshot aggregate)
|
||||
{
|
||||
return new PunchCardListItemDto
|
||||
{
|
||||
Id = template.Id,
|
||||
Name = template.Name,
|
||||
CoverImageUrl = template.CoverImageUrl,
|
||||
SalePrice = template.SalePrice,
|
||||
OriginalPrice = template.OriginalPrice,
|
||||
TotalTimes = template.TotalTimes,
|
||||
ValiditySummary = PunchCardMapping.BuildValiditySummary(template),
|
||||
ScopeType = PunchCardMapping.ToScopeTypeText(template.ScopeType),
|
||||
UsageMode = PunchCardMapping.ToUsageModeText(template.UsageMode),
|
||||
UsageCapAmount = template.UsageCapAmount,
|
||||
DailyLimit = template.DailyLimit,
|
||||
Status = PunchCardMapping.ToTemplateStatusText(template.Status),
|
||||
IsDimmed = template.Status == PunchCardStatus.Disabled,
|
||||
SoldCount = aggregate.SoldCount,
|
||||
ActiveCount = aggregate.ActiveCount,
|
||||
RevenueAmount = decimal.Round(aggregate.RevenueAmount, 2, MidpointRounding.AwayFromZero),
|
||||
UpdatedAt = template.UpdatedAt ?? template.CreatedAt
|
||||
};
|
||||
}
|
||||
|
||||
public static PunchCardDetailDto ToDetailDto(
|
||||
PunchCardTemplate template,
|
||||
PunchCardTemplateAggregateSnapshot aggregate)
|
||||
{
|
||||
return new PunchCardDetailDto
|
||||
{
|
||||
Id = template.Id,
|
||||
StoreId = template.StoreId,
|
||||
Name = template.Name,
|
||||
CoverImageUrl = template.CoverImageUrl,
|
||||
SalePrice = template.SalePrice,
|
||||
OriginalPrice = template.OriginalPrice,
|
||||
TotalTimes = template.TotalTimes,
|
||||
ValidityType = PunchCardMapping.ToValidityTypeText(template.ValidityType),
|
||||
ValidityDays = template.ValidityDays,
|
||||
ValidFrom = template.ValidFrom,
|
||||
ValidTo = template.ValidTo,
|
||||
Scope = new PunchCardScopeDto
|
||||
{
|
||||
ScopeType = PunchCardMapping.ToScopeTypeText(template.ScopeType),
|
||||
CategoryIds = PunchCardMapping.DeserializeSnowflakeIds(template.ScopeCategoryIdsJson),
|
||||
TagIds = PunchCardMapping.DeserializeSnowflakeIds(template.ScopeTagIdsJson),
|
||||
ProductIds = PunchCardMapping.DeserializeSnowflakeIds(template.ScopeProductIdsJson)
|
||||
},
|
||||
UsageMode = PunchCardMapping.ToUsageModeText(template.UsageMode),
|
||||
UsageCapAmount = template.UsageCapAmount,
|
||||
DailyLimit = template.DailyLimit,
|
||||
PerOrderLimit = template.PerOrderLimit,
|
||||
PerUserPurchaseLimit = template.PerUserPurchaseLimit,
|
||||
AllowTransfer = template.AllowTransfer,
|
||||
ExpireStrategy = PunchCardMapping.ToExpireStrategyText(template.ExpireStrategy),
|
||||
Description = template.Description,
|
||||
NotifyChannels = PunchCardMapping.DeserializeNotifyChannels(template.NotifyChannelsJson),
|
||||
Status = PunchCardMapping.ToTemplateStatusText(template.Status),
|
||||
SoldCount = aggregate.SoldCount,
|
||||
ActiveCount = aggregate.ActiveCount,
|
||||
RevenueAmount = decimal.Round(aggregate.RevenueAmount, 2, MidpointRounding.AwayFromZero),
|
||||
UpdatedAt = template.UpdatedAt ?? template.CreatedAt
|
||||
};
|
||||
}
|
||||
|
||||
public static PunchCardStatsDto ToStatsDto(PunchCardTemplateStatsSnapshot source)
|
||||
{
|
||||
return new PunchCardStatsDto
|
||||
{
|
||||
OnSaleCount = source.OnSaleCount,
|
||||
TotalSoldCount = source.TotalSoldCount,
|
||||
TotalRevenueAmount = decimal.Round(source.TotalRevenueAmount, 2, MidpointRounding.AwayFromZero),
|
||||
ActiveInUseCount = source.ActiveInUseCount
|
||||
};
|
||||
}
|
||||
|
||||
public static PunchCardUsageStatsDto ToUsageStatsDto(PunchCardUsageStatsSnapshot source)
|
||||
{
|
||||
return new PunchCardUsageStatsDto
|
||||
{
|
||||
TodayUsedCount = source.TodayUsedCount,
|
||||
MonthUsedCount = source.MonthUsedCount,
|
||||
ExpiringSoonCount = source.ExpiringSoonCount
|
||||
};
|
||||
}
|
||||
|
||||
public static PunchCardUsageRecordDto ToUsageRecordDto(
|
||||
PunchCardUsageRecord record,
|
||||
PunchCardInstance? instance,
|
||||
PunchCardTemplate? template,
|
||||
DateTime nowUtc)
|
||||
{
|
||||
var resolvedTotalTimes = instance?.TotalTimes ?? template?.TotalTimes ?? 0;
|
||||
var status = record.StatusAfterUse;
|
||||
|
||||
if (instance is not null)
|
||||
{
|
||||
status = PunchCardMapping.ResolveUsageRecordStatus(instance, record.RemainingTimesAfterUse, nowUtc);
|
||||
}
|
||||
|
||||
return new PunchCardUsageRecordDto
|
||||
{
|
||||
Id = record.Id,
|
||||
RecordNo = record.RecordNo,
|
||||
PunchCardTemplateId = record.PunchCardTemplateId,
|
||||
PunchCardName = template?.Name ?? string.Empty,
|
||||
PunchCardInstanceId = record.PunchCardInstanceId,
|
||||
MemberName = instance?.MemberName ?? string.Empty,
|
||||
MemberPhoneMasked = instance?.MemberPhoneMasked ?? string.Empty,
|
||||
ProductName = record.ProductName,
|
||||
UsedAt = record.UsedAt,
|
||||
UsedTimes = record.UsedTimes,
|
||||
RemainingTimesAfterUse = record.RemainingTimesAfterUse,
|
||||
TotalTimes = resolvedTotalTimes,
|
||||
DisplayStatus = PunchCardMapping.ToUsageDisplayStatusText(status),
|
||||
ExtraPayAmount = record.ExtraPayAmount
|
||||
};
|
||||
}
|
||||
|
||||
public static PunchCardTemplate CreateTemplateEntity(
|
||||
SavePunchCardTemplateCommand request,
|
||||
string normalizedName,
|
||||
string normalizedCoverImageUrl,
|
||||
decimal normalizedSalePrice,
|
||||
decimal? normalizedOriginalPrice,
|
||||
int normalizedTotalTimes,
|
||||
PunchCardValidityType validityType,
|
||||
int? normalizedValidityDays,
|
||||
DateTime? normalizedValidFrom,
|
||||
DateTime? normalizedValidTo,
|
||||
PunchCardScopeType scopeType,
|
||||
string normalizedCategoryIdsJson,
|
||||
string normalizedTagIdsJson,
|
||||
string normalizedProductIdsJson,
|
||||
PunchCardUsageMode usageMode,
|
||||
decimal? normalizedUsageCapAmount,
|
||||
int? normalizedDailyLimit,
|
||||
int? normalizedPerOrderLimit,
|
||||
int? normalizedPerUserPurchaseLimit,
|
||||
PunchCardExpireStrategy expireStrategy,
|
||||
string? normalizedDescription,
|
||||
string normalizedNotifyChannelsJson)
|
||||
{
|
||||
return new PunchCardTemplate
|
||||
{
|
||||
StoreId = request.StoreId,
|
||||
Name = normalizedName,
|
||||
CoverImageUrl = string.IsNullOrWhiteSpace(normalizedCoverImageUrl)
|
||||
? null
|
||||
: normalizedCoverImageUrl,
|
||||
SalePrice = normalizedSalePrice,
|
||||
OriginalPrice = normalizedOriginalPrice,
|
||||
TotalTimes = normalizedTotalTimes,
|
||||
ValidityType = validityType,
|
||||
ValidityDays = normalizedValidityDays,
|
||||
ValidFrom = normalizedValidFrom,
|
||||
ValidTo = normalizedValidTo,
|
||||
ScopeType = scopeType,
|
||||
ScopeCategoryIdsJson = normalizedCategoryIdsJson,
|
||||
ScopeTagIdsJson = normalizedTagIdsJson,
|
||||
ScopeProductIdsJson = normalizedProductIdsJson,
|
||||
UsageMode = usageMode,
|
||||
UsageCapAmount = normalizedUsageCapAmount,
|
||||
DailyLimit = normalizedDailyLimit,
|
||||
PerOrderLimit = normalizedPerOrderLimit,
|
||||
PerUserPurchaseLimit = normalizedPerUserPurchaseLimit,
|
||||
AllowTransfer = request.AllowTransfer,
|
||||
ExpireStrategy = expireStrategy,
|
||||
Description = normalizedDescription,
|
||||
NotifyChannelsJson = normalizedNotifyChannelsJson,
|
||||
Status = PunchCardStatus.Enabled
|
||||
};
|
||||
}
|
||||
|
||||
public static string GenerateInstanceNo(DateTime nowUtc)
|
||||
{
|
||||
return $"PKI{nowUtc:yyyyMMddHHmmssfff}{Random.Shared.Next(1000, 9999)}";
|
||||
}
|
||||
|
||||
public static string GenerateRecordNo(DateTime nowUtc)
|
||||
{
|
||||
return $"PK{nowUtc:yyyyMMddHHmmssfff}{Random.Shared.Next(1000, 9999)}";
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,546 @@
|
||||
using System.Text.Json;
|
||||
using TakeoutSaaS.Application.App.Coupons.PunchCard.Dto;
|
||||
using TakeoutSaaS.Domain.Coupons.Entities;
|
||||
using TakeoutSaaS.Domain.Coupons.Enums;
|
||||
using TakeoutSaaS.Domain.Coupons.Repositories;
|
||||
using TakeoutSaaS.Shared.Abstractions.Constants;
|
||||
using TakeoutSaaS.Shared.Abstractions.Exceptions;
|
||||
|
||||
namespace TakeoutSaaS.Application.App.Coupons.PunchCard;
|
||||
|
||||
/// <summary>
|
||||
/// 次卡模块映射与标准化。
|
||||
/// </summary>
|
||||
internal static class PunchCardMapping
|
||||
{
|
||||
private static readonly HashSet<string> AllowedNotifyChannels =
|
||||
[
|
||||
"in_app",
|
||||
"sms"
|
||||
];
|
||||
|
||||
private static readonly JsonSerializerOptions JsonOptions = new(JsonSerializerDefaults.Web)
|
||||
{
|
||||
PropertyNameCaseInsensitive = true
|
||||
};
|
||||
|
||||
public static PunchCardStatus? ParseTemplateStatusFilter(string? value)
|
||||
{
|
||||
var normalized = (value ?? string.Empty).Trim().ToLowerInvariant();
|
||||
return normalized switch
|
||||
{
|
||||
"" => null,
|
||||
"enabled" => PunchCardStatus.Enabled,
|
||||
"disabled" => PunchCardStatus.Disabled,
|
||||
_ => throw new BusinessException(ErrorCodes.BadRequest, "status 参数不合法")
|
||||
};
|
||||
}
|
||||
|
||||
public static PunchCardStatus ParseTemplateStatus(string? value)
|
||||
{
|
||||
var normalized = (value ?? string.Empty).Trim().ToLowerInvariant();
|
||||
return normalized switch
|
||||
{
|
||||
"enabled" => PunchCardStatus.Enabled,
|
||||
"disabled" => PunchCardStatus.Disabled,
|
||||
_ => throw new BusinessException(ErrorCodes.BadRequest, "status 参数不合法")
|
||||
};
|
||||
}
|
||||
|
||||
public static string ToTemplateStatusText(PunchCardStatus value)
|
||||
{
|
||||
return value switch
|
||||
{
|
||||
PunchCardStatus.Enabled => "enabled",
|
||||
PunchCardStatus.Disabled => "disabled",
|
||||
_ => "disabled"
|
||||
};
|
||||
}
|
||||
|
||||
public static PunchCardValidityType ParseValidityType(string? value)
|
||||
{
|
||||
var normalized = (value ?? string.Empty).Trim().ToLowerInvariant();
|
||||
return normalized switch
|
||||
{
|
||||
"days" => PunchCardValidityType.Days,
|
||||
"range" => PunchCardValidityType.DateRange,
|
||||
_ => throw new BusinessException(ErrorCodes.BadRequest, "validityType 参数不合法")
|
||||
};
|
||||
}
|
||||
|
||||
public static string ToValidityTypeText(PunchCardValidityType value)
|
||||
{
|
||||
return value switch
|
||||
{
|
||||
PunchCardValidityType.Days => "days",
|
||||
PunchCardValidityType.DateRange => "range",
|
||||
_ => "days"
|
||||
};
|
||||
}
|
||||
|
||||
public static PunchCardScopeType ParseScopeType(string? value)
|
||||
{
|
||||
var normalized = (value ?? string.Empty).Trim().ToLowerInvariant();
|
||||
return normalized switch
|
||||
{
|
||||
"all" => PunchCardScopeType.All,
|
||||
"category" => PunchCardScopeType.Category,
|
||||
"tag" => PunchCardScopeType.Tag,
|
||||
"product" => PunchCardScopeType.Product,
|
||||
_ => throw new BusinessException(ErrorCodes.BadRequest, "scopeType 参数不合法")
|
||||
};
|
||||
}
|
||||
|
||||
public static string ToScopeTypeText(PunchCardScopeType value)
|
||||
{
|
||||
return value switch
|
||||
{
|
||||
PunchCardScopeType.All => "all",
|
||||
PunchCardScopeType.Category => "category",
|
||||
PunchCardScopeType.Tag => "tag",
|
||||
PunchCardScopeType.Product => "product",
|
||||
_ => "all"
|
||||
};
|
||||
}
|
||||
|
||||
public static PunchCardUsageMode ParseUsageMode(string? value)
|
||||
{
|
||||
var normalized = (value ?? string.Empty).Trim().ToLowerInvariant();
|
||||
return normalized switch
|
||||
{
|
||||
"free" => PunchCardUsageMode.Free,
|
||||
"cap" => PunchCardUsageMode.Cap,
|
||||
_ => throw new BusinessException(ErrorCodes.BadRequest, "usageMode 参数不合法")
|
||||
};
|
||||
}
|
||||
|
||||
public static string ToUsageModeText(PunchCardUsageMode value)
|
||||
{
|
||||
return value switch
|
||||
{
|
||||
PunchCardUsageMode.Free => "free",
|
||||
PunchCardUsageMode.Cap => "cap",
|
||||
_ => "free"
|
||||
};
|
||||
}
|
||||
|
||||
public static PunchCardExpireStrategy ParseExpireStrategy(string? value)
|
||||
{
|
||||
var normalized = (value ?? string.Empty).Trim().ToLowerInvariant();
|
||||
return normalized switch
|
||||
{
|
||||
"invalidate" => PunchCardExpireStrategy.Invalidate,
|
||||
"refund" => PunchCardExpireStrategy.Refund,
|
||||
_ => throw new BusinessException(ErrorCodes.BadRequest, "expireStrategy 参数不合法")
|
||||
};
|
||||
}
|
||||
|
||||
public static string ToExpireStrategyText(PunchCardExpireStrategy value)
|
||||
{
|
||||
return value switch
|
||||
{
|
||||
PunchCardExpireStrategy.Invalidate => "invalidate",
|
||||
PunchCardExpireStrategy.Refund => "refund",
|
||||
_ => "invalidate"
|
||||
};
|
||||
}
|
||||
|
||||
public static PunchCardUsageRecordFilterStatus? ParseUsageStatusFilter(string? value)
|
||||
{
|
||||
var normalized = (value ?? string.Empty).Trim().ToLowerInvariant();
|
||||
return normalized switch
|
||||
{
|
||||
"" => null,
|
||||
"normal" => PunchCardUsageRecordFilterStatus.Normal,
|
||||
"used_up" => PunchCardUsageRecordFilterStatus.UsedUp,
|
||||
"expired" => PunchCardUsageRecordFilterStatus.Expired,
|
||||
_ => throw new BusinessException(ErrorCodes.BadRequest, "status 参数不合法")
|
||||
};
|
||||
}
|
||||
|
||||
public static string ToUsageDisplayStatusText(PunchCardUsageRecordStatus value)
|
||||
{
|
||||
return value switch
|
||||
{
|
||||
PunchCardUsageRecordStatus.Normal => "normal",
|
||||
PunchCardUsageRecordStatus.AlmostUsedUp => "almost_used_up",
|
||||
PunchCardUsageRecordStatus.UsedUp => "used_up",
|
||||
PunchCardUsageRecordStatus.Expired => "expired",
|
||||
_ => "normal"
|
||||
};
|
||||
}
|
||||
|
||||
public static DateTime NormalizeUtc(DateTime value)
|
||||
{
|
||||
return value.Kind switch
|
||||
{
|
||||
DateTimeKind.Utc => value,
|
||||
DateTimeKind.Local => value.ToUniversalTime(),
|
||||
_ => DateTime.SpecifyKind(value, DateTimeKind.Utc)
|
||||
};
|
||||
}
|
||||
|
||||
public static string NormalizeName(string? value)
|
||||
{
|
||||
var normalized = (value ?? string.Empty).Trim();
|
||||
if (string.IsNullOrWhiteSpace(normalized))
|
||||
{
|
||||
throw new BusinessException(ErrorCodes.BadRequest, "name 不能为空");
|
||||
}
|
||||
|
||||
if (normalized.Length > 64)
|
||||
{
|
||||
throw new BusinessException(ErrorCodes.BadRequest, "name 长度不能超过 64");
|
||||
}
|
||||
|
||||
return normalized;
|
||||
}
|
||||
|
||||
public static string NormalizeOptionalCoverUrl(string? value)
|
||||
{
|
||||
var normalized = (value ?? string.Empty).Trim();
|
||||
if (string.IsNullOrWhiteSpace(normalized))
|
||||
{
|
||||
return string.Empty;
|
||||
}
|
||||
|
||||
if (normalized.Length > 512)
|
||||
{
|
||||
throw new BusinessException(ErrorCodes.BadRequest, "coverImageUrl 长度不能超过 512");
|
||||
}
|
||||
|
||||
return normalized;
|
||||
}
|
||||
|
||||
public static string? NormalizeOptionalDescription(string? value)
|
||||
{
|
||||
var normalized = (value ?? string.Empty).Trim();
|
||||
if (normalized.Length == 0)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
if (normalized.Length > 512)
|
||||
{
|
||||
throw new BusinessException(ErrorCodes.BadRequest, "description 长度不能超过 512");
|
||||
}
|
||||
|
||||
return normalized;
|
||||
}
|
||||
|
||||
public static string NormalizeInstanceNo(string? value)
|
||||
{
|
||||
var normalized = (value ?? string.Empty).Trim();
|
||||
if (string.IsNullOrWhiteSpace(normalized))
|
||||
{
|
||||
throw new BusinessException(ErrorCodes.BadRequest, "punchCardInstanceNo 不能为空");
|
||||
}
|
||||
|
||||
if (normalized.Length > 32)
|
||||
{
|
||||
throw new BusinessException(ErrorCodes.BadRequest, "punchCardInstanceNo 长度不能超过 32");
|
||||
}
|
||||
|
||||
return normalized;
|
||||
}
|
||||
|
||||
public static string NormalizeMemberName(string? value)
|
||||
{
|
||||
var normalized = (value ?? string.Empty).Trim();
|
||||
if (string.IsNullOrWhiteSpace(normalized))
|
||||
{
|
||||
throw new BusinessException(ErrorCodes.BadRequest, "memberName 不能为空");
|
||||
}
|
||||
|
||||
if (normalized.Length > 64)
|
||||
{
|
||||
throw new BusinessException(ErrorCodes.BadRequest, "memberName 长度不能超过 64");
|
||||
}
|
||||
|
||||
return normalized;
|
||||
}
|
||||
|
||||
public static string NormalizeMemberPhoneMasked(string? value)
|
||||
{
|
||||
var normalized = (value ?? string.Empty).Trim();
|
||||
if (string.IsNullOrWhiteSpace(normalized))
|
||||
{
|
||||
throw new BusinessException(ErrorCodes.BadRequest, "memberPhoneMasked 不能为空");
|
||||
}
|
||||
|
||||
if (normalized.Length > 32)
|
||||
{
|
||||
throw new BusinessException(ErrorCodes.BadRequest, "memberPhoneMasked 长度不能超过 32");
|
||||
}
|
||||
|
||||
return normalized;
|
||||
}
|
||||
|
||||
public static string NormalizeProductName(string? value)
|
||||
{
|
||||
var normalized = (value ?? string.Empty).Trim();
|
||||
if (string.IsNullOrWhiteSpace(normalized))
|
||||
{
|
||||
throw new BusinessException(ErrorCodes.BadRequest, "productName 不能为空");
|
||||
}
|
||||
|
||||
if (normalized.Length > 128)
|
||||
{
|
||||
throw new BusinessException(ErrorCodes.BadRequest, "productName 长度不能超过 128");
|
||||
}
|
||||
|
||||
return normalized;
|
||||
}
|
||||
|
||||
public static decimal NormalizeAmount(decimal value, string fieldName, bool allowZero = false)
|
||||
{
|
||||
if (value < 0 || (!allowZero && value <= 0))
|
||||
{
|
||||
throw new BusinessException(ErrorCodes.BadRequest, $"{fieldName} 参数不合法");
|
||||
}
|
||||
|
||||
return decimal.Round(value, 2, MidpointRounding.AwayFromZero);
|
||||
}
|
||||
|
||||
public static decimal? NormalizeOptionalAmount(decimal? value, string fieldName, bool allowZero = true)
|
||||
{
|
||||
if (!value.HasValue)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
if (value.Value < 0 || (!allowZero && value.Value <= 0))
|
||||
{
|
||||
throw new BusinessException(ErrorCodes.BadRequest, $"{fieldName} 参数不合法");
|
||||
}
|
||||
|
||||
return decimal.Round(value.Value, 2, MidpointRounding.AwayFromZero);
|
||||
}
|
||||
|
||||
public static int NormalizeRequiredPositiveInt(int value, string fieldName, int max = 100_000)
|
||||
{
|
||||
if (value <= 0 || value > max)
|
||||
{
|
||||
throw new BusinessException(ErrorCodes.BadRequest, $"{fieldName} 参数不合法");
|
||||
}
|
||||
|
||||
return value;
|
||||
}
|
||||
|
||||
public static int? NormalizeOptionalLimit(int? value, string fieldName, int max = 100_000)
|
||||
{
|
||||
if (!value.HasValue || value.Value <= 0)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
if (value.Value > max)
|
||||
{
|
||||
throw new BusinessException(ErrorCodes.BadRequest, $"{fieldName} 参数不合法");
|
||||
}
|
||||
|
||||
return value;
|
||||
}
|
||||
|
||||
public static (int? ValidityDays, DateTime? ValidFrom, DateTime? ValidTo) NormalizeValidity(
|
||||
PunchCardValidityType validityType,
|
||||
int? validityDays,
|
||||
DateTime? validFrom,
|
||||
DateTime? validTo)
|
||||
{
|
||||
return validityType switch
|
||||
{
|
||||
PunchCardValidityType.Days =>
|
||||
(
|
||||
NormalizeRequiredPositiveInt(validityDays ?? 0, "validityDays", 3650),
|
||||
null,
|
||||
null
|
||||
),
|
||||
PunchCardValidityType.DateRange => NormalizeRange(validFrom, validTo),
|
||||
_ => throw new BusinessException(ErrorCodes.BadRequest, "validityType 参数不合法")
|
||||
};
|
||||
}
|
||||
|
||||
public static (IReadOnlyList<long> CategoryIds, IReadOnlyList<long> TagIds, IReadOnlyList<long> ProductIds) NormalizeScopeIds(
|
||||
PunchCardScopeType scopeType,
|
||||
IReadOnlyCollection<long>? categoryIds,
|
||||
IReadOnlyCollection<long>? tagIds,
|
||||
IReadOnlyCollection<long>? productIds)
|
||||
{
|
||||
var normalizedCategoryIds = NormalizeSnowflakeIds(categoryIds, "scopeCategoryIds", false);
|
||||
var normalizedTagIds = NormalizeSnowflakeIds(tagIds, "scopeTagIds", false);
|
||||
var normalizedProductIds = NormalizeSnowflakeIds(productIds, "scopeProductIds", false);
|
||||
|
||||
return scopeType switch
|
||||
{
|
||||
PunchCardScopeType.All => ([], [], []),
|
||||
PunchCardScopeType.Category =>
|
||||
normalizedCategoryIds.Count == 0
|
||||
? throw new BusinessException(ErrorCodes.BadRequest, "scopeCategoryIds 不能为空")
|
||||
: (normalizedCategoryIds, [], []),
|
||||
PunchCardScopeType.Tag =>
|
||||
normalizedTagIds.Count == 0
|
||||
? throw new BusinessException(ErrorCodes.BadRequest, "scopeTagIds 不能为空")
|
||||
: ([], normalizedTagIds, []),
|
||||
PunchCardScopeType.Product =>
|
||||
normalizedProductIds.Count == 0
|
||||
? throw new BusinessException(ErrorCodes.BadRequest, "scopeProductIds 不能为空")
|
||||
: ([], [], normalizedProductIds),
|
||||
_ => throw new BusinessException(ErrorCodes.BadRequest, "scopeType 参数不合法")
|
||||
};
|
||||
}
|
||||
|
||||
public static IReadOnlyList<string> NormalizeNotifyChannels(IEnumerable<string>? values)
|
||||
{
|
||||
var normalized = (values ?? [])
|
||||
.Select(item => (item ?? string.Empty).Trim().ToLowerInvariant())
|
||||
.Where(item => !string.IsNullOrWhiteSpace(item))
|
||||
.Distinct()
|
||||
.ToList();
|
||||
|
||||
if (normalized.Count == 0)
|
||||
{
|
||||
throw new BusinessException(ErrorCodes.BadRequest, "notifyChannels 不能为空");
|
||||
}
|
||||
|
||||
if (normalized.Any(item => !AllowedNotifyChannels.Contains(item)))
|
||||
{
|
||||
throw new BusinessException(ErrorCodes.BadRequest, "notifyChannels 存在非法值");
|
||||
}
|
||||
|
||||
return normalized;
|
||||
}
|
||||
|
||||
public static IReadOnlyList<string> DeserializeNotifyChannels(string? payload)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(payload))
|
||||
{
|
||||
return [];
|
||||
}
|
||||
|
||||
var values = JsonSerializer.Deserialize<List<string>>(payload, JsonOptions) ?? [];
|
||||
return values
|
||||
.Select(item => (item ?? string.Empty).Trim().ToLowerInvariant())
|
||||
.Where(item => AllowedNotifyChannels.Contains(item))
|
||||
.Distinct()
|
||||
.ToList();
|
||||
}
|
||||
|
||||
public static string SerializeNotifyChannels(IEnumerable<string>? values)
|
||||
{
|
||||
return JsonSerializer.Serialize(NormalizeNotifyChannels(values), JsonOptions);
|
||||
}
|
||||
|
||||
public static IReadOnlyList<long> DeserializeSnowflakeIds(string? payload)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(payload))
|
||||
{
|
||||
return [];
|
||||
}
|
||||
|
||||
var values = JsonSerializer.Deserialize<List<long>>(payload, JsonOptions) ?? [];
|
||||
return values
|
||||
.Where(id => id > 0)
|
||||
.Distinct()
|
||||
.OrderBy(id => id)
|
||||
.ToList();
|
||||
}
|
||||
|
||||
public static string SerializeSnowflakeIds(IEnumerable<long>? values)
|
||||
{
|
||||
return JsonSerializer.Serialize(NormalizeSnowflakeIds(values, "ids", false), JsonOptions);
|
||||
}
|
||||
|
||||
public static string BuildValiditySummary(PunchCardTemplate template)
|
||||
{
|
||||
return template.ValidityType switch
|
||||
{
|
||||
PunchCardValidityType.Days => $"{template.ValidityDays ?? 0}天有效",
|
||||
PunchCardValidityType.DateRange when template.ValidFrom.HasValue && template.ValidTo.HasValue =>
|
||||
$"{template.ValidFrom.Value:yyyy-MM-dd} 至 {template.ValidTo.Value:yyyy-MM-dd}",
|
||||
_ => "-"
|
||||
};
|
||||
}
|
||||
|
||||
public static DateTime ResolveInstanceExpireAt(PunchCardTemplate template, DateTime purchasedAtUtc)
|
||||
{
|
||||
var purchasedAt = NormalizeUtc(purchasedAtUtc);
|
||||
|
||||
return template.ValidityType switch
|
||||
{
|
||||
PunchCardValidityType.Days => purchasedAt.Date.AddDays(template.ValidityDays ?? 0).AddTicks(-1),
|
||||
PunchCardValidityType.DateRange => template.ValidTo ?? purchasedAt.Date.AddTicks(-1),
|
||||
_ => purchasedAt.Date.AddTicks(-1)
|
||||
};
|
||||
}
|
||||
|
||||
public static bool IsInstanceExpired(PunchCardInstance instance, DateTime nowUtc)
|
||||
{
|
||||
var utcNow = NormalizeUtc(nowUtc);
|
||||
if (instance.Status == PunchCardInstanceStatus.Expired)
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
return instance.ExpiresAt.HasValue && instance.ExpiresAt.Value < utcNow;
|
||||
}
|
||||
|
||||
public static PunchCardUsageRecordStatus ResolveUsageRecordStatus(
|
||||
PunchCardInstance instance,
|
||||
int remainingTimes,
|
||||
DateTime usedAtUtc)
|
||||
{
|
||||
if (IsInstanceExpired(instance, usedAtUtc))
|
||||
{
|
||||
return PunchCardUsageRecordStatus.Expired;
|
||||
}
|
||||
|
||||
if (remainingTimes <= 0)
|
||||
{
|
||||
return PunchCardUsageRecordStatus.UsedUp;
|
||||
}
|
||||
|
||||
return remainingTimes <= 2
|
||||
? PunchCardUsageRecordStatus.AlmostUsedUp
|
||||
: PunchCardUsageRecordStatus.Normal;
|
||||
}
|
||||
|
||||
private static IReadOnlyList<long> NormalizeSnowflakeIds(
|
||||
IEnumerable<long>? values,
|
||||
string fieldName,
|
||||
bool required)
|
||||
{
|
||||
var normalized = (values ?? [])
|
||||
.Where(id => id > 0)
|
||||
.Distinct()
|
||||
.OrderBy(id => id)
|
||||
.ToList();
|
||||
|
||||
if (required && normalized.Count == 0)
|
||||
{
|
||||
throw new BusinessException(ErrorCodes.BadRequest, $"{fieldName} 不能为空");
|
||||
}
|
||||
|
||||
return normalized;
|
||||
}
|
||||
|
||||
private static (int? ValidityDays, DateTime? ValidFrom, DateTime? ValidTo) NormalizeRange(
|
||||
DateTime? validFrom,
|
||||
DateTime? validTo)
|
||||
{
|
||||
if (!validFrom.HasValue || !validTo.HasValue)
|
||||
{
|
||||
throw new BusinessException(ErrorCodes.BadRequest, "validFrom / validTo 不能为空");
|
||||
}
|
||||
|
||||
var normalizedFrom = NormalizeUtc(validFrom.Value).Date;
|
||||
var normalizedTo = NormalizeUtc(validTo.Value).Date.AddDays(1).AddTicks(-1);
|
||||
|
||||
if (normalizedFrom > normalizedTo)
|
||||
{
|
||||
throw new BusinessException(ErrorCodes.BadRequest, "validFrom 不能晚于 validTo");
|
||||
}
|
||||
|
||||
return (null, normalizedFrom, normalizedTo);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
using MediatR;
|
||||
using TakeoutSaaS.Application.App.Coupons.PunchCard.Dto;
|
||||
|
||||
namespace TakeoutSaaS.Application.App.Coupons.PunchCard.Queries;
|
||||
|
||||
/// <summary>
|
||||
/// 导出次卡使用记录 CSV。
|
||||
/// </summary>
|
||||
public sealed class ExportPunchCardUsageRecordCsvQuery : IRequest<PunchCardUsageRecordExportDto>
|
||||
{
|
||||
/// <summary>
|
||||
/// 操作门店 ID。
|
||||
/// </summary>
|
||||
public long StoreId { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// 次卡模板筛选 ID(可空)。
|
||||
/// </summary>
|
||||
public long? TemplateId { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// 状态筛选(normal/used_up/expired)。
|
||||
/// </summary>
|
||||
public string? Status { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// 关键字(会员/商品)。
|
||||
/// </summary>
|
||||
public string? Keyword { get; init; }
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
using MediatR;
|
||||
using TakeoutSaaS.Application.App.Coupons.PunchCard.Dto;
|
||||
|
||||
namespace TakeoutSaaS.Application.App.Coupons.PunchCard.Queries;
|
||||
|
||||
/// <summary>
|
||||
/// 查询次卡模板详情。
|
||||
/// </summary>
|
||||
public sealed class GetPunchCardTemplateDetailQuery : IRequest<PunchCardDetailDto?>
|
||||
{
|
||||
/// <summary>
|
||||
/// 操作门店 ID。
|
||||
/// </summary>
|
||||
public long StoreId { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// 次卡模板 ID。
|
||||
/// </summary>
|
||||
public long TemplateId { get; init; }
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
using MediatR;
|
||||
using TakeoutSaaS.Application.App.Coupons.PunchCard.Dto;
|
||||
|
||||
namespace TakeoutSaaS.Application.App.Coupons.PunchCard.Queries;
|
||||
|
||||
/// <summary>
|
||||
/// 查询次卡模板列表。
|
||||
/// </summary>
|
||||
public sealed class GetPunchCardTemplateListQuery : IRequest<PunchCardListResultDto>
|
||||
{
|
||||
/// <summary>
|
||||
/// 操作门店 ID。
|
||||
/// </summary>
|
||||
public long StoreId { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// 名称关键字。
|
||||
/// </summary>
|
||||
public string? Keyword { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// 状态筛选(enabled/disabled)。
|
||||
/// </summary>
|
||||
public string? Status { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// 页码。
|
||||
/// </summary>
|
||||
public int Page { get; init; } = 1;
|
||||
|
||||
/// <summary>
|
||||
/// 每页条数。
|
||||
/// </summary>
|
||||
public int PageSize { get; init; } = 4;
|
||||
}
|
||||
@@ -0,0 +1,40 @@
|
||||
using MediatR;
|
||||
using TakeoutSaaS.Application.App.Coupons.PunchCard.Dto;
|
||||
|
||||
namespace TakeoutSaaS.Application.App.Coupons.PunchCard.Queries;
|
||||
|
||||
/// <summary>
|
||||
/// 查询次卡使用记录列表。
|
||||
/// </summary>
|
||||
public sealed class GetPunchCardUsageRecordListQuery : IRequest<PunchCardUsageRecordListResultDto>
|
||||
{
|
||||
/// <summary>
|
||||
/// 操作门店 ID。
|
||||
/// </summary>
|
||||
public long StoreId { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// 次卡模板筛选 ID(可空)。
|
||||
/// </summary>
|
||||
public long? TemplateId { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// 状态筛选(normal/used_up/expired)。
|
||||
/// </summary>
|
||||
public string? Status { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// 关键字(会员/商品)。
|
||||
/// </summary>
|
||||
public string? Keyword { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// 页码。
|
||||
/// </summary>
|
||||
public int Page { get; init; } = 1;
|
||||
|
||||
/// <summary>
|
||||
/// 每页条数。
|
||||
/// </summary>
|
||||
public int PageSize { get; init; } = 10;
|
||||
}
|
||||
Reference in New Issue
Block a user