feat: implement marketing punch card backend module
This commit is contained in:
Submodule TakeoutSaaS.Docs updated: 0941503124...315fec77b6
@@ -0,0 +1,809 @@
|
||||
namespace TakeoutSaaS.TenantApi.Contracts.Marketing;
|
||||
|
||||
/// <summary>
|
||||
/// 次卡列表查询请求。
|
||||
/// </summary>
|
||||
public sealed class PunchCardListRequest
|
||||
{
|
||||
/// <summary>
|
||||
/// 门店 ID。
|
||||
/// </summary>
|
||||
public string StoreId { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// 名称关键字。
|
||||
/// </summary>
|
||||
public string? Keyword { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 状态筛选(enabled/disabled)。
|
||||
/// </summary>
|
||||
public string? Status { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 页码。
|
||||
/// </summary>
|
||||
public int Page { get; set; } = 1;
|
||||
|
||||
/// <summary>
|
||||
/// 每页条数。
|
||||
/// </summary>
|
||||
public int PageSize { get; set; } = 4;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 次卡详情请求。
|
||||
/// </summary>
|
||||
public sealed class PunchCardDetailRequest
|
||||
{
|
||||
/// <summary>
|
||||
/// 门店 ID。
|
||||
/// </summary>
|
||||
public string StoreId { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// 次卡 ID。
|
||||
/// </summary>
|
||||
public string PunchCardId { get; set; } = string.Empty;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 保存次卡请求。
|
||||
/// </summary>
|
||||
public sealed class SavePunchCardRequest
|
||||
{
|
||||
/// <summary>
|
||||
/// 门店 ID。
|
||||
/// </summary>
|
||||
public string StoreId { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// 次卡 ID(编辑时传)。
|
||||
/// </summary>
|
||||
public string? Id { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 次卡名称。
|
||||
/// </summary>
|
||||
public string Name { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// 封面图地址。
|
||||
/// </summary>
|
||||
public string? CoverImageUrl { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 售价。
|
||||
/// </summary>
|
||||
public decimal SalePrice { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 原价。
|
||||
/// </summary>
|
||||
public decimal? OriginalPrice { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 总次数。
|
||||
/// </summary>
|
||||
public int TotalTimes { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 有效期类型(days/range)。
|
||||
/// </summary>
|
||||
public string ValidityType { get; set; } = "days";
|
||||
|
||||
/// <summary>
|
||||
/// 固定天数。
|
||||
/// </summary>
|
||||
public int? ValidityDays { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 固定开始日期(yyyy-MM-dd)。
|
||||
/// </summary>
|
||||
public string? ValidFrom { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 固定结束日期(yyyy-MM-dd)。
|
||||
/// </summary>
|
||||
public string? ValidTo { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 范围类型(all/category/tag/product)。
|
||||
/// </summary>
|
||||
public string ScopeType { get; set; } = "all";
|
||||
|
||||
/// <summary>
|
||||
/// 指定分类 ID。
|
||||
/// </summary>
|
||||
public List<string> ScopeCategoryIds { get; set; } = [];
|
||||
|
||||
/// <summary>
|
||||
/// 指定标签 ID。
|
||||
/// </summary>
|
||||
public List<string> ScopeTagIds { get; set; } = [];
|
||||
|
||||
/// <summary>
|
||||
/// 指定商品 ID。
|
||||
/// </summary>
|
||||
public List<string> ScopeProductIds { get; set; } = [];
|
||||
|
||||
/// <summary>
|
||||
/// 使用模式(free/cap)。
|
||||
/// </summary>
|
||||
public string UsageMode { get; set; } = "free";
|
||||
|
||||
/// <summary>
|
||||
/// 单次上限金额。
|
||||
/// </summary>
|
||||
public decimal? UsageCapAmount { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 每日限用次数。
|
||||
/// </summary>
|
||||
public int? DailyLimit { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 每单限用次数。
|
||||
/// </summary>
|
||||
public int? PerOrderLimit { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 每人限购。
|
||||
/// </summary>
|
||||
public int? PerUserPurchaseLimit { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 是否允许转赠。
|
||||
/// </summary>
|
||||
public bool AllowTransfer { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 过期策略(invalidate/refund)。
|
||||
/// </summary>
|
||||
public string ExpireStrategy { get; set; } = "invalidate";
|
||||
|
||||
/// <summary>
|
||||
/// 次卡描述。
|
||||
/// </summary>
|
||||
public string? Description { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 通知渠道(in_app/sms)。
|
||||
/// </summary>
|
||||
public List<string> NotifyChannels { get; set; } = [];
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 次卡状态修改请求。
|
||||
/// </summary>
|
||||
public sealed class ChangePunchCardStatusRequest
|
||||
{
|
||||
/// <summary>
|
||||
/// 门店 ID。
|
||||
/// </summary>
|
||||
public string StoreId { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// 次卡 ID。
|
||||
/// </summary>
|
||||
public string PunchCardId { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// 状态(enabled/disabled)。
|
||||
/// </summary>
|
||||
public string Status { get; set; } = "disabled";
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 次卡删除请求。
|
||||
/// </summary>
|
||||
public sealed class DeletePunchCardRequest
|
||||
{
|
||||
/// <summary>
|
||||
/// 门店 ID。
|
||||
/// </summary>
|
||||
public string StoreId { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// 次卡 ID。
|
||||
/// </summary>
|
||||
public string PunchCardId { get; set; } = string.Empty;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 次卡使用记录查询请求。
|
||||
/// </summary>
|
||||
public sealed class PunchCardUsageRecordListRequest
|
||||
{
|
||||
/// <summary>
|
||||
/// 门店 ID。
|
||||
/// </summary>
|
||||
public string StoreId { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// 次卡模板 ID。
|
||||
/// </summary>
|
||||
public string? PunchCardId { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 状态筛选(normal/used_up/expired)。
|
||||
/// </summary>
|
||||
public string? Status { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 关键字(会员/商品)。
|
||||
/// </summary>
|
||||
public string? Keyword { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 页码。
|
||||
/// </summary>
|
||||
public int Page { get; set; } = 1;
|
||||
|
||||
/// <summary>
|
||||
/// 每页条数。
|
||||
/// </summary>
|
||||
public int PageSize { get; set; } = 10;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 次卡使用记录导出请求。
|
||||
/// </summary>
|
||||
public sealed class ExportPunchCardUsageRecordRequest
|
||||
{
|
||||
/// <summary>
|
||||
/// 门店 ID。
|
||||
/// </summary>
|
||||
public string StoreId { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// 次卡模板 ID。
|
||||
/// </summary>
|
||||
public string? PunchCardId { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 状态筛选(normal/used_up/expired)。
|
||||
/// </summary>
|
||||
public string? Status { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 关键字(会员/商品)。
|
||||
/// </summary>
|
||||
public string? Keyword { get; set; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 写入次卡使用记录请求。
|
||||
/// </summary>
|
||||
public sealed class WritePunchCardUsageRecordRequest
|
||||
{
|
||||
/// <summary>
|
||||
/// 门店 ID。
|
||||
/// </summary>
|
||||
public string StoreId { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// 次卡模板 ID。
|
||||
/// </summary>
|
||||
public string PunchCardId { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// 次卡实例 ID(可空)。
|
||||
/// </summary>
|
||||
public string? PunchCardInstanceId { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 次卡实例编号(可空)。
|
||||
/// </summary>
|
||||
public string? PunchCardInstanceNo { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 会员名称。
|
||||
/// </summary>
|
||||
public string? MemberName { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 会员手机号(脱敏)。
|
||||
/// </summary>
|
||||
public string? MemberPhoneMasked { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 兑换商品。
|
||||
/// </summary>
|
||||
public string ProductName { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// 使用时间。
|
||||
/// </summary>
|
||||
public DateTime? UsedAt { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 本次使用次数。
|
||||
/// </summary>
|
||||
public int UsedTimes { get; set; } = 1;
|
||||
|
||||
/// <summary>
|
||||
/// 超额补差金额。
|
||||
/// </summary>
|
||||
public decimal? ExtraPayAmount { get; set; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 次卡模板统计。
|
||||
/// </summary>
|
||||
public sealed class PunchCardStatsResponse
|
||||
{
|
||||
/// <summary>
|
||||
/// 在售次卡数量。
|
||||
/// </summary>
|
||||
public int OnSaleCount { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 累计售出数量。
|
||||
/// </summary>
|
||||
public int TotalSoldCount { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 累计收入。
|
||||
/// </summary>
|
||||
public decimal TotalRevenueAmount { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 使用中数量。
|
||||
/// </summary>
|
||||
public int ActiveInUseCount { get; set; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 次卡列表项。
|
||||
/// </summary>
|
||||
public sealed class PunchCardListItemResponse
|
||||
{
|
||||
/// <summary>
|
||||
/// 次卡 ID。
|
||||
/// </summary>
|
||||
public string Id { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// 次卡名称。
|
||||
/// </summary>
|
||||
public string Name { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// 封面图。
|
||||
/// </summary>
|
||||
public string? CoverImageUrl { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 售价。
|
||||
/// </summary>
|
||||
public decimal SalePrice { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 原价。
|
||||
/// </summary>
|
||||
public decimal? OriginalPrice { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 总次数。
|
||||
/// </summary>
|
||||
public int TotalTimes { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 有效期展示。
|
||||
/// </summary>
|
||||
public string ValiditySummary { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// 适用范围类型。
|
||||
/// </summary>
|
||||
public string ScopeType { get; set; } = "all";
|
||||
|
||||
/// <summary>
|
||||
/// 使用模式。
|
||||
/// </summary>
|
||||
public string UsageMode { get; set; } = "free";
|
||||
|
||||
/// <summary>
|
||||
/// 单次上限金额。
|
||||
/// </summary>
|
||||
public decimal? UsageCapAmount { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 每日限用。
|
||||
/// </summary>
|
||||
public int? DailyLimit { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 状态(enabled/disabled)。
|
||||
/// </summary>
|
||||
public string Status { get; set; } = "enabled";
|
||||
|
||||
/// <summary>
|
||||
/// 是否弱化展示。
|
||||
/// </summary>
|
||||
public bool IsDimmed { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 已售数量。
|
||||
/// </summary>
|
||||
public int SoldCount { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 使用中数量。
|
||||
/// </summary>
|
||||
public int ActiveCount { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 累计收入。
|
||||
/// </summary>
|
||||
public decimal RevenueAmount { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 更新时间(yyyy-MM-dd HH:mm:ss)。
|
||||
/// </summary>
|
||||
public string UpdatedAt { get; set; } = string.Empty;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 次卡列表结果。
|
||||
/// </summary>
|
||||
public sealed class PunchCardListResultResponse
|
||||
{
|
||||
/// <summary>
|
||||
/// 列表。
|
||||
/// </summary>
|
||||
public List<PunchCardListItemResponse> Items { get; set; } = [];
|
||||
|
||||
/// <summary>
|
||||
/// 当前页。
|
||||
/// </summary>
|
||||
public int Page { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 每页条数。
|
||||
/// </summary>
|
||||
public int PageSize { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 总条数。
|
||||
/// </summary>
|
||||
public int TotalCount { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 统计。
|
||||
/// </summary>
|
||||
public PunchCardStatsResponse Stats { get; set; } = new();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 次卡范围。
|
||||
/// </summary>
|
||||
public sealed class PunchCardScopeResponse
|
||||
{
|
||||
/// <summary>
|
||||
/// 范围类型(all/category/tag/product)。
|
||||
/// </summary>
|
||||
public string ScopeType { get; set; } = "all";
|
||||
|
||||
/// <summary>
|
||||
/// 分类 ID。
|
||||
/// </summary>
|
||||
public List<string> CategoryIds { get; set; } = [];
|
||||
|
||||
/// <summary>
|
||||
/// 标签 ID。
|
||||
/// </summary>
|
||||
public List<string> TagIds { get; set; } = [];
|
||||
|
||||
/// <summary>
|
||||
/// 商品 ID。
|
||||
/// </summary>
|
||||
public List<string> ProductIds { get; set; } = [];
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 次卡详情。
|
||||
/// </summary>
|
||||
public sealed class PunchCardDetailResponse
|
||||
{
|
||||
/// <summary>
|
||||
/// 次卡 ID。
|
||||
/// </summary>
|
||||
public string Id { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// 门店 ID。
|
||||
/// </summary>
|
||||
public string StoreId { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// 次卡名称。
|
||||
/// </summary>
|
||||
public string Name { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// 封面图。
|
||||
/// </summary>
|
||||
public string? CoverImageUrl { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 售价。
|
||||
/// </summary>
|
||||
public decimal SalePrice { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 原价。
|
||||
/// </summary>
|
||||
public decimal? OriginalPrice { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 总次数。
|
||||
/// </summary>
|
||||
public int TotalTimes { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 有效期类型(days/range)。
|
||||
/// </summary>
|
||||
public string ValidityType { get; set; } = "days";
|
||||
|
||||
/// <summary>
|
||||
/// 固定天数。
|
||||
/// </summary>
|
||||
public int? ValidityDays { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 固定开始日期(yyyy-MM-dd)。
|
||||
/// </summary>
|
||||
public string? ValidFrom { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 固定结束日期(yyyy-MM-dd)。
|
||||
/// </summary>
|
||||
public string? ValidTo { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 适用范围。
|
||||
/// </summary>
|
||||
public PunchCardScopeResponse Scope { get; set; } = new();
|
||||
|
||||
/// <summary>
|
||||
/// 使用模式(free/cap)。
|
||||
/// </summary>
|
||||
public string UsageMode { get; set; } = "free";
|
||||
|
||||
/// <summary>
|
||||
/// 单次上限金额。
|
||||
/// </summary>
|
||||
public decimal? UsageCapAmount { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 每日限用。
|
||||
/// </summary>
|
||||
public int? DailyLimit { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 每单限用。
|
||||
/// </summary>
|
||||
public int? PerOrderLimit { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 每人限购。
|
||||
/// </summary>
|
||||
public int? PerUserPurchaseLimit { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 是否允许转赠。
|
||||
/// </summary>
|
||||
public bool AllowTransfer { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 过期策略(invalidate/refund)。
|
||||
/// </summary>
|
||||
public string ExpireStrategy { get; set; } = "invalidate";
|
||||
|
||||
/// <summary>
|
||||
/// 描述。
|
||||
/// </summary>
|
||||
public string? Description { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 通知渠道。
|
||||
/// </summary>
|
||||
public List<string> NotifyChannels { get; set; } = [];
|
||||
|
||||
/// <summary>
|
||||
/// 状态(enabled/disabled)。
|
||||
/// </summary>
|
||||
public string Status { get; set; } = "enabled";
|
||||
|
||||
/// <summary>
|
||||
/// 已售数量。
|
||||
/// </summary>
|
||||
public int SoldCount { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 使用中数量。
|
||||
/// </summary>
|
||||
public int ActiveCount { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 累计收入。
|
||||
/// </summary>
|
||||
public decimal RevenueAmount { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 更新时间(yyyy-MM-dd HH:mm:ss)。
|
||||
/// </summary>
|
||||
public string UpdatedAt { get; set; } = string.Empty;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 次卡下拉选项。
|
||||
/// </summary>
|
||||
public sealed class PunchCardTemplateOptionResponse
|
||||
{
|
||||
/// <summary>
|
||||
/// 次卡 ID。
|
||||
/// </summary>
|
||||
public string TemplateId { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// 次卡名称。
|
||||
/// </summary>
|
||||
public string Name { get; set; } = string.Empty;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 使用记录统计。
|
||||
/// </summary>
|
||||
public sealed class PunchCardUsageStatsResponse
|
||||
{
|
||||
/// <summary>
|
||||
/// 今日使用次数。
|
||||
/// </summary>
|
||||
public int TodayUsedCount { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 本月使用次数。
|
||||
/// </summary>
|
||||
public int MonthUsedCount { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 7 天内即将过期数量。
|
||||
/// </summary>
|
||||
public int ExpiringSoonCount { get; set; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 次卡使用记录项。
|
||||
/// </summary>
|
||||
public sealed class PunchCardUsageRecordResponse
|
||||
{
|
||||
/// <summary>
|
||||
/// 使用记录 ID。
|
||||
/// </summary>
|
||||
public string Id { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// 使用单号。
|
||||
/// </summary>
|
||||
public string RecordNo { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// 次卡模板 ID。
|
||||
/// </summary>
|
||||
public string PunchCardId { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// 次卡名称。
|
||||
/// </summary>
|
||||
public string PunchCardName { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// 次卡实例 ID。
|
||||
/// </summary>
|
||||
public string PunchCardInstanceId { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// 会员名称。
|
||||
/// </summary>
|
||||
public string MemberName { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// 会员手机号(脱敏)。
|
||||
/// </summary>
|
||||
public string MemberPhoneMasked { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// 兑换商品。
|
||||
/// </summary>
|
||||
public string ProductName { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// 使用时间(yyyy-MM-dd HH:mm:ss)。
|
||||
/// </summary>
|
||||
public string UsedAt { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// 本次使用次数。
|
||||
/// </summary>
|
||||
public int UsedTimes { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 剩余次数。
|
||||
/// </summary>
|
||||
public int RemainingTimesAfterUse { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 总次数。
|
||||
/// </summary>
|
||||
public int TotalTimes { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 状态(normal/almost_used_up/used_up/expired)。
|
||||
/// </summary>
|
||||
public string DisplayStatus { get; set; } = "normal";
|
||||
|
||||
/// <summary>
|
||||
/// 超额补差金额。
|
||||
/// </summary>
|
||||
public decimal? ExtraPayAmount { get; set; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 使用记录分页结果。
|
||||
/// </summary>
|
||||
public sealed class PunchCardUsageRecordListResultResponse
|
||||
{
|
||||
/// <summary>
|
||||
/// 列表。
|
||||
/// </summary>
|
||||
public List<PunchCardUsageRecordResponse> Items { get; set; } = [];
|
||||
|
||||
/// <summary>
|
||||
/// 页码。
|
||||
/// </summary>
|
||||
public int Page { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 每页条数。
|
||||
/// </summary>
|
||||
public int PageSize { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 总条数。
|
||||
/// </summary>
|
||||
public int TotalCount { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 统计。
|
||||
/// </summary>
|
||||
public PunchCardUsageStatsResponse Stats { get; set; } = new();
|
||||
|
||||
/// <summary>
|
||||
/// 次卡筛选项。
|
||||
/// </summary>
|
||||
public List<PunchCardTemplateOptionResponse> TemplateOptions { get; set; } = [];
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 使用记录导出回执。
|
||||
/// </summary>
|
||||
public sealed class PunchCardUsageRecordExportResponse
|
||||
{
|
||||
/// <summary>
|
||||
/// 文件名。
|
||||
/// </summary>
|
||||
public string FileName { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// Base64 文件内容。
|
||||
/// </summary>
|
||||
public string FileContentBase64 { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// 导出总条数。
|
||||
/// </summary>
|
||||
public int TotalCount { get; set; }
|
||||
}
|
||||
@@ -0,0 +1,402 @@
|
||||
using System.Globalization;
|
||||
using MediatR;
|
||||
using Microsoft.AspNetCore.Authorization;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using TakeoutSaaS.Application.App.Coupons.PunchCard.Commands;
|
||||
using TakeoutSaaS.Application.App.Coupons.PunchCard.Dto;
|
||||
using TakeoutSaaS.Application.App.Coupons.PunchCard.Queries;
|
||||
using TakeoutSaaS.Application.App.Stores.Services;
|
||||
using TakeoutSaaS.Infrastructure.App.Persistence;
|
||||
using TakeoutSaaS.Module.Authorization.Attributes;
|
||||
using TakeoutSaaS.Shared.Abstractions.Constants;
|
||||
using TakeoutSaaS.Shared.Abstractions.Results;
|
||||
using TakeoutSaaS.Shared.Web.Api;
|
||||
using TakeoutSaaS.TenantApi.Contracts.Marketing;
|
||||
|
||||
namespace TakeoutSaaS.TenantApi.Controllers;
|
||||
|
||||
/// <summary>
|
||||
/// 营销中心次卡管理。
|
||||
/// </summary>
|
||||
[ApiVersion("1.0")]
|
||||
[Authorize]
|
||||
[Route("api/tenant/v{version:apiVersion}/marketing/punch-card")]
|
||||
public sealed class MarketingPunchCardController(
|
||||
IMediator mediator,
|
||||
TakeoutAppDbContext dbContext,
|
||||
StoreContextService storeContextService)
|
||||
: BaseApiController
|
||||
{
|
||||
private const string ViewPermission = "tenant:marketing:punch-card:view";
|
||||
private const string ManagePermission = "tenant:marketing:punch-card:manage";
|
||||
|
||||
/// <summary>
|
||||
/// 获取次卡列表。
|
||||
/// </summary>
|
||||
[HttpGet("list")]
|
||||
[PermissionAuthorize(ViewPermission, ManagePermission)]
|
||||
[ProducesResponseType(typeof(ApiResponse<PunchCardListResultResponse>), StatusCodes.Status200OK)]
|
||||
public async Task<ApiResponse<PunchCardListResultResponse>> List(
|
||||
[FromQuery] PunchCardListRequest request,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var storeId = StoreApiHelpers.ParseRequiredSnowflake(request.StoreId, nameof(request.StoreId));
|
||||
await EnsureStoreAccessibleAsync(storeId, cancellationToken);
|
||||
|
||||
var result = await mediator.Send(new GetPunchCardTemplateListQuery
|
||||
{
|
||||
StoreId = storeId,
|
||||
Keyword = request.Keyword,
|
||||
Status = request.Status,
|
||||
Page = request.Page,
|
||||
PageSize = request.PageSize
|
||||
}, cancellationToken);
|
||||
|
||||
return ApiResponse<PunchCardListResultResponse>.Ok(new PunchCardListResultResponse
|
||||
{
|
||||
Items = result.Items.Select(MapListItem).ToList(),
|
||||
Page = result.Page,
|
||||
PageSize = result.PageSize,
|
||||
TotalCount = result.TotalCount,
|
||||
Stats = MapTemplateStats(result.Stats)
|
||||
});
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 获取次卡详情。
|
||||
/// </summary>
|
||||
[HttpGet("detail")]
|
||||
[PermissionAuthorize(ViewPermission, ManagePermission)]
|
||||
[ProducesResponseType(typeof(ApiResponse<PunchCardDetailResponse>), StatusCodes.Status200OK)]
|
||||
public async Task<ApiResponse<PunchCardDetailResponse>> Detail(
|
||||
[FromQuery] PunchCardDetailRequest request,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var storeId = StoreApiHelpers.ParseRequiredSnowflake(request.StoreId, nameof(request.StoreId));
|
||||
await EnsureStoreAccessibleAsync(storeId, cancellationToken);
|
||||
|
||||
var result = await mediator.Send(new GetPunchCardTemplateDetailQuery
|
||||
{
|
||||
StoreId = storeId,
|
||||
TemplateId = StoreApiHelpers.ParseRequiredSnowflake(request.PunchCardId, nameof(request.PunchCardId))
|
||||
}, cancellationToken);
|
||||
|
||||
if (result is null)
|
||||
{
|
||||
return ApiResponse<PunchCardDetailResponse>.Error(ErrorCodes.NotFound, "次卡不存在");
|
||||
}
|
||||
|
||||
return ApiResponse<PunchCardDetailResponse>.Ok(MapDetail(result));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 保存次卡。
|
||||
/// </summary>
|
||||
[HttpPost("save")]
|
||||
[PermissionAuthorize(ManagePermission)]
|
||||
[ProducesResponseType(typeof(ApiResponse<PunchCardDetailResponse>), StatusCodes.Status200OK)]
|
||||
public async Task<ApiResponse<PunchCardDetailResponse>> Save(
|
||||
[FromBody] SavePunchCardRequest request,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var storeId = StoreApiHelpers.ParseRequiredSnowflake(request.StoreId, nameof(request.StoreId));
|
||||
await EnsureStoreAccessibleAsync(storeId, cancellationToken);
|
||||
|
||||
var result = await mediator.Send(new SavePunchCardTemplateCommand
|
||||
{
|
||||
StoreId = storeId,
|
||||
TemplateId = StoreApiHelpers.ParseSnowflakeOrNull(request.Id),
|
||||
Name = request.Name,
|
||||
CoverImageUrl = request.CoverImageUrl,
|
||||
SalePrice = request.SalePrice,
|
||||
OriginalPrice = request.OriginalPrice,
|
||||
TotalTimes = request.TotalTimes,
|
||||
ValidityType = request.ValidityType,
|
||||
ValidityDays = request.ValidityDays,
|
||||
ValidFrom = ParseDateOrNull(request.ValidFrom, nameof(request.ValidFrom)),
|
||||
ValidTo = ParseDateOrNull(request.ValidTo, nameof(request.ValidTo)),
|
||||
ScopeType = request.ScopeType,
|
||||
ScopeCategoryIds = StoreApiHelpers.ParseSnowflakeList(request.ScopeCategoryIds),
|
||||
ScopeTagIds = StoreApiHelpers.ParseSnowflakeList(request.ScopeTagIds),
|
||||
ScopeProductIds = StoreApiHelpers.ParseSnowflakeList(request.ScopeProductIds),
|
||||
UsageMode = request.UsageMode,
|
||||
UsageCapAmount = request.UsageCapAmount,
|
||||
DailyLimit = request.DailyLimit,
|
||||
PerOrderLimit = request.PerOrderLimit,
|
||||
PerUserPurchaseLimit = request.PerUserPurchaseLimit,
|
||||
AllowTransfer = request.AllowTransfer,
|
||||
ExpireStrategy = request.ExpireStrategy,
|
||||
Description = request.Description,
|
||||
NotifyChannels = request.NotifyChannels
|
||||
}, cancellationToken);
|
||||
|
||||
return ApiResponse<PunchCardDetailResponse>.Ok(MapDetail(result));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 修改次卡状态。
|
||||
/// </summary>
|
||||
[HttpPost("status")]
|
||||
[PermissionAuthorize(ManagePermission)]
|
||||
[ProducesResponseType(typeof(ApiResponse<PunchCardDetailResponse>), StatusCodes.Status200OK)]
|
||||
public async Task<ApiResponse<PunchCardDetailResponse>> ChangeStatus(
|
||||
[FromBody] ChangePunchCardStatusRequest request,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var storeId = StoreApiHelpers.ParseRequiredSnowflake(request.StoreId, nameof(request.StoreId));
|
||||
await EnsureStoreAccessibleAsync(storeId, cancellationToken);
|
||||
|
||||
var result = await mediator.Send(new ChangePunchCardTemplateStatusCommand
|
||||
{
|
||||
StoreId = storeId,
|
||||
TemplateId = StoreApiHelpers.ParseRequiredSnowflake(request.PunchCardId, nameof(request.PunchCardId)),
|
||||
Status = request.Status
|
||||
}, cancellationToken);
|
||||
|
||||
return ApiResponse<PunchCardDetailResponse>.Ok(MapDetail(result));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 删除次卡。
|
||||
/// </summary>
|
||||
[HttpPost("delete")]
|
||||
[PermissionAuthorize(ManagePermission)]
|
||||
[ProducesResponseType(typeof(ApiResponse<object>), StatusCodes.Status200OK)]
|
||||
public async Task<ApiResponse<object>> Delete(
|
||||
[FromBody] DeletePunchCardRequest request,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var storeId = StoreApiHelpers.ParseRequiredSnowflake(request.StoreId, nameof(request.StoreId));
|
||||
await EnsureStoreAccessibleAsync(storeId, cancellationToken);
|
||||
|
||||
await mediator.Send(new DeletePunchCardTemplateCommand
|
||||
{
|
||||
StoreId = storeId,
|
||||
TemplateId = StoreApiHelpers.ParseRequiredSnowflake(request.PunchCardId, nameof(request.PunchCardId))
|
||||
}, cancellationToken);
|
||||
|
||||
return ApiResponse<object>.Ok(null);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 获取次卡使用记录。
|
||||
/// </summary>
|
||||
[HttpGet("usage-record/list")]
|
||||
[PermissionAuthorize(ViewPermission, ManagePermission)]
|
||||
[ProducesResponseType(typeof(ApiResponse<PunchCardUsageRecordListResultResponse>), StatusCodes.Status200OK)]
|
||||
public async Task<ApiResponse<PunchCardUsageRecordListResultResponse>> UsageRecordList(
|
||||
[FromQuery] PunchCardUsageRecordListRequest request,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var storeId = StoreApiHelpers.ParseRequiredSnowflake(request.StoreId, nameof(request.StoreId));
|
||||
await EnsureStoreAccessibleAsync(storeId, cancellationToken);
|
||||
|
||||
var result = await mediator.Send(new GetPunchCardUsageRecordListQuery
|
||||
{
|
||||
StoreId = storeId,
|
||||
TemplateId = StoreApiHelpers.ParseSnowflakeOrNull(request.PunchCardId),
|
||||
Status = request.Status,
|
||||
Keyword = request.Keyword,
|
||||
Page = request.Page,
|
||||
PageSize = request.PageSize
|
||||
}, cancellationToken);
|
||||
|
||||
return ApiResponse<PunchCardUsageRecordListResultResponse>.Ok(new PunchCardUsageRecordListResultResponse
|
||||
{
|
||||
Items = result.Items.Select(MapUsageRecord).ToList(),
|
||||
Page = result.Page,
|
||||
PageSize = result.PageSize,
|
||||
TotalCount = result.TotalCount,
|
||||
Stats = new PunchCardUsageStatsResponse
|
||||
{
|
||||
TodayUsedCount = result.Stats.TodayUsedCount,
|
||||
MonthUsedCount = result.Stats.MonthUsedCount,
|
||||
ExpiringSoonCount = result.Stats.ExpiringSoonCount
|
||||
},
|
||||
TemplateOptions = result.TemplateOptions.Select(item => new PunchCardTemplateOptionResponse
|
||||
{
|
||||
TemplateId = item.TemplateId.ToString(),
|
||||
Name = item.Name
|
||||
}).ToList()
|
||||
});
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 导出次卡使用记录。
|
||||
/// </summary>
|
||||
[HttpGet("usage-record/export")]
|
||||
[PermissionAuthorize(ManagePermission)]
|
||||
[ProducesResponseType(typeof(ApiResponse<PunchCardUsageRecordExportResponse>), StatusCodes.Status200OK)]
|
||||
public async Task<ApiResponse<PunchCardUsageRecordExportResponse>> ExportUsageRecord(
|
||||
[FromQuery] ExportPunchCardUsageRecordRequest request,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var storeId = StoreApiHelpers.ParseRequiredSnowflake(request.StoreId, nameof(request.StoreId));
|
||||
await EnsureStoreAccessibleAsync(storeId, cancellationToken);
|
||||
|
||||
var result = await mediator.Send(new ExportPunchCardUsageRecordCsvQuery
|
||||
{
|
||||
StoreId = storeId,
|
||||
TemplateId = StoreApiHelpers.ParseSnowflakeOrNull(request.PunchCardId),
|
||||
Status = request.Status,
|
||||
Keyword = request.Keyword
|
||||
}, cancellationToken);
|
||||
|
||||
return ApiResponse<PunchCardUsageRecordExportResponse>.Ok(new PunchCardUsageRecordExportResponse
|
||||
{
|
||||
FileName = result.FileName,
|
||||
FileContentBase64 = result.FileContentBase64,
|
||||
TotalCount = result.TotalCount
|
||||
});
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 写入次卡使用记录。
|
||||
/// </summary>
|
||||
[HttpPost("usage-record/write")]
|
||||
[PermissionAuthorize(ManagePermission)]
|
||||
[ProducesResponseType(typeof(ApiResponse<PunchCardUsageRecordResponse>), StatusCodes.Status200OK)]
|
||||
public async Task<ApiResponse<PunchCardUsageRecordResponse>> WriteUsageRecord(
|
||||
[FromBody] WritePunchCardUsageRecordRequest request,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var storeId = StoreApiHelpers.ParseRequiredSnowflake(request.StoreId, nameof(request.StoreId));
|
||||
await EnsureStoreAccessibleAsync(storeId, cancellationToken);
|
||||
|
||||
var result = await mediator.Send(new WritePunchCardUsageRecordCommand
|
||||
{
|
||||
StoreId = storeId,
|
||||
TemplateId = StoreApiHelpers.ParseRequiredSnowflake(request.PunchCardId, nameof(request.PunchCardId)),
|
||||
InstanceId = StoreApiHelpers.ParseSnowflakeOrNull(request.PunchCardInstanceId),
|
||||
InstanceNo = request.PunchCardInstanceNo,
|
||||
MemberName = request.MemberName,
|
||||
MemberPhoneMasked = request.MemberPhoneMasked,
|
||||
ProductName = request.ProductName,
|
||||
UsedAt = request.UsedAt,
|
||||
UsedTimes = request.UsedTimes,
|
||||
ExtraPayAmount = request.ExtraPayAmount
|
||||
}, cancellationToken);
|
||||
|
||||
return ApiResponse<PunchCardUsageRecordResponse>.Ok(MapUsageRecord(result));
|
||||
}
|
||||
|
||||
private async Task EnsureStoreAccessibleAsync(long storeId, CancellationToken cancellationToken)
|
||||
{
|
||||
var (tenantId, merchantId) = StoreApiHelpers.GetTenantMerchantContext(storeContextService);
|
||||
await StoreApiHelpers.EnsureStoreAccessibleAsync(dbContext, tenantId, merchantId, storeId, cancellationToken);
|
||||
}
|
||||
|
||||
private static DateTime? ParseDateOrNull(string? value, string fieldName)
|
||||
{
|
||||
return string.IsNullOrWhiteSpace(value)
|
||||
? null
|
||||
: StoreApiHelpers.ParseDateOnly(value, fieldName);
|
||||
}
|
||||
|
||||
private static PunchCardListItemResponse MapListItem(PunchCardListItemDto source)
|
||||
{
|
||||
return new PunchCardListItemResponse
|
||||
{
|
||||
Id = source.Id.ToString(),
|
||||
Name = source.Name,
|
||||
CoverImageUrl = source.CoverImageUrl,
|
||||
SalePrice = source.SalePrice,
|
||||
OriginalPrice = source.OriginalPrice,
|
||||
TotalTimes = source.TotalTimes,
|
||||
ValiditySummary = source.ValiditySummary,
|
||||
ScopeType = source.ScopeType,
|
||||
UsageMode = source.UsageMode,
|
||||
UsageCapAmount = source.UsageCapAmount,
|
||||
DailyLimit = source.DailyLimit,
|
||||
Status = source.Status,
|
||||
IsDimmed = source.IsDimmed,
|
||||
SoldCount = source.SoldCount,
|
||||
ActiveCount = source.ActiveCount,
|
||||
RevenueAmount = source.RevenueAmount,
|
||||
UpdatedAt = ToDateTime(source.UpdatedAt)
|
||||
};
|
||||
}
|
||||
|
||||
private static PunchCardStatsResponse MapTemplateStats(PunchCardStatsDto source)
|
||||
{
|
||||
return new PunchCardStatsResponse
|
||||
{
|
||||
OnSaleCount = source.OnSaleCount,
|
||||
TotalSoldCount = source.TotalSoldCount,
|
||||
TotalRevenueAmount = source.TotalRevenueAmount,
|
||||
ActiveInUseCount = source.ActiveInUseCount
|
||||
};
|
||||
}
|
||||
|
||||
private static PunchCardDetailResponse MapDetail(PunchCardDetailDto source)
|
||||
{
|
||||
return new PunchCardDetailResponse
|
||||
{
|
||||
Id = source.Id.ToString(),
|
||||
StoreId = source.StoreId.ToString(),
|
||||
Name = source.Name,
|
||||
CoverImageUrl = source.CoverImageUrl,
|
||||
SalePrice = source.SalePrice,
|
||||
OriginalPrice = source.OriginalPrice,
|
||||
TotalTimes = source.TotalTimes,
|
||||
ValidityType = source.ValidityType,
|
||||
ValidityDays = source.ValidityDays,
|
||||
ValidFrom = ToDateOnly(source.ValidFrom),
|
||||
ValidTo = ToDateOnly(source.ValidTo),
|
||||
Scope = new PunchCardScopeResponse
|
||||
{
|
||||
ScopeType = source.Scope.ScopeType,
|
||||
CategoryIds = source.Scope.CategoryIds.Select(item => item.ToString()).ToList(),
|
||||
TagIds = source.Scope.TagIds.Select(item => item.ToString()).ToList(),
|
||||
ProductIds = source.Scope.ProductIds.Select(item => item.ToString()).ToList()
|
||||
},
|
||||
UsageMode = source.UsageMode,
|
||||
UsageCapAmount = source.UsageCapAmount,
|
||||
DailyLimit = source.DailyLimit,
|
||||
PerOrderLimit = source.PerOrderLimit,
|
||||
PerUserPurchaseLimit = source.PerUserPurchaseLimit,
|
||||
AllowTransfer = source.AllowTransfer,
|
||||
ExpireStrategy = source.ExpireStrategy,
|
||||
Description = source.Description,
|
||||
NotifyChannels = source.NotifyChannels.ToList(),
|
||||
Status = source.Status,
|
||||
SoldCount = source.SoldCount,
|
||||
ActiveCount = source.ActiveCount,
|
||||
RevenueAmount = source.RevenueAmount,
|
||||
UpdatedAt = ToDateTime(source.UpdatedAt)
|
||||
};
|
||||
}
|
||||
|
||||
private static PunchCardUsageRecordResponse MapUsageRecord(PunchCardUsageRecordDto source)
|
||||
{
|
||||
return new PunchCardUsageRecordResponse
|
||||
{
|
||||
Id = source.Id.ToString(),
|
||||
RecordNo = source.RecordNo,
|
||||
PunchCardId = source.PunchCardTemplateId.ToString(),
|
||||
PunchCardName = source.PunchCardName,
|
||||
PunchCardInstanceId = source.PunchCardInstanceId.ToString(),
|
||||
MemberName = source.MemberName,
|
||||
MemberPhoneMasked = source.MemberPhoneMasked,
|
||||
ProductName = source.ProductName,
|
||||
UsedAt = ToDateTime(source.UsedAt),
|
||||
UsedTimes = source.UsedTimes,
|
||||
RemainingTimesAfterUse = source.RemainingTimesAfterUse,
|
||||
TotalTimes = source.TotalTimes,
|
||||
DisplayStatus = source.DisplayStatus,
|
||||
ExtraPayAmount = source.ExtraPayAmount
|
||||
};
|
||||
}
|
||||
|
||||
private static string? ToDateOnly(DateTime? value)
|
||||
{
|
||||
return value.HasValue
|
||||
? value.Value.ToString("yyyy-MM-dd", CultureInfo.InvariantCulture)
|
||||
: null;
|
||||
}
|
||||
|
||||
private static string ToDateTime(DateTime value)
|
||||
{
|
||||
return value.ToString("yyyy-MM-dd HH:mm:ss", CultureInfo.InvariantCulture);
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -0,0 +1,65 @@
|
||||
using TakeoutSaaS.Domain.Coupons.Enums;
|
||||
using TakeoutSaaS.Shared.Abstractions.Entities;
|
||||
|
||||
namespace TakeoutSaaS.Domain.Coupons.Entities;
|
||||
|
||||
/// <summary>
|
||||
/// 次卡实例(顾客购买后生成)。
|
||||
/// </summary>
|
||||
public sealed class PunchCardInstance : MultiTenantEntityBase
|
||||
{
|
||||
/// <summary>
|
||||
/// 门店 ID。
|
||||
/// </summary>
|
||||
public long StoreId { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 次卡模板 ID。
|
||||
/// </summary>
|
||||
public long PunchCardTemplateId { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 实例编号(业务唯一)。
|
||||
/// </summary>
|
||||
public string InstanceNo { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// 会员名称。
|
||||
/// </summary>
|
||||
public string MemberName { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// 会员手机号(脱敏)。
|
||||
/// </summary>
|
||||
public string MemberPhoneMasked { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// 购买时间(UTC)。
|
||||
/// </summary>
|
||||
public DateTime PurchasedAt { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 过期时间(UTC,可空)。
|
||||
/// </summary>
|
||||
public DateTime? ExpiresAt { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 总次数。
|
||||
/// </summary>
|
||||
public int TotalTimes { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 剩余次数。
|
||||
/// </summary>
|
||||
public int RemainingTimes { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 实付金额。
|
||||
/// </summary>
|
||||
public decimal PaidAmount { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 实例状态。
|
||||
/// </summary>
|
||||
public PunchCardInstanceStatus Status { get; set; } = PunchCardInstanceStatus.Active;
|
||||
}
|
||||
@@ -0,0 +1,130 @@
|
||||
using TakeoutSaaS.Domain.Coupons.Enums;
|
||||
using TakeoutSaaS.Shared.Abstractions.Entities;
|
||||
|
||||
namespace TakeoutSaaS.Domain.Coupons.Entities;
|
||||
|
||||
/// <summary>
|
||||
/// 次卡模板配置。
|
||||
/// </summary>
|
||||
public sealed class PunchCardTemplate : MultiTenantEntityBase
|
||||
{
|
||||
/// <summary>
|
||||
/// 门店 ID。
|
||||
/// </summary>
|
||||
public long StoreId { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 次卡名称。
|
||||
/// </summary>
|
||||
public string Name { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// 封面图片地址。
|
||||
/// </summary>
|
||||
public string? CoverImageUrl { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 售价。
|
||||
/// </summary>
|
||||
public decimal SalePrice { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 原价。
|
||||
/// </summary>
|
||||
public decimal? OriginalPrice { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 总次数。
|
||||
/// </summary>
|
||||
public int TotalTimes { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 有效期类型。
|
||||
/// </summary>
|
||||
public PunchCardValidityType ValidityType { get; set; } = PunchCardValidityType.Days;
|
||||
|
||||
/// <summary>
|
||||
/// 固定天数(ValidityType=Days 时有效)。
|
||||
/// </summary>
|
||||
public int? ValidityDays { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 固定开始日期(UTC,ValidityType=DateRange 时有效)。
|
||||
/// </summary>
|
||||
public DateTime? ValidFrom { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 固定结束日期(UTC,ValidityType=DateRange 时有效)。
|
||||
/// </summary>
|
||||
public DateTime? ValidTo { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 适用范围类型。
|
||||
/// </summary>
|
||||
public PunchCardScopeType ScopeType { get; set; } = PunchCardScopeType.All;
|
||||
|
||||
/// <summary>
|
||||
/// 指定分类 ID JSON。
|
||||
/// </summary>
|
||||
public string ScopeCategoryIdsJson { get; set; } = "[]";
|
||||
|
||||
/// <summary>
|
||||
/// 指定标签 ID JSON。
|
||||
/// </summary>
|
||||
public string ScopeTagIdsJson { get; set; } = "[]";
|
||||
|
||||
/// <summary>
|
||||
/// 指定商品 ID JSON。
|
||||
/// </summary>
|
||||
public string ScopeProductIdsJson { get; set; } = "[]";
|
||||
|
||||
/// <summary>
|
||||
/// 使用模式。
|
||||
/// </summary>
|
||||
public PunchCardUsageMode UsageMode { get; set; } = PunchCardUsageMode.Free;
|
||||
|
||||
/// <summary>
|
||||
/// 金额上限(UsageMode=Cap 时有效)。
|
||||
/// </summary>
|
||||
public decimal? UsageCapAmount { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 每日限用次数。
|
||||
/// </summary>
|
||||
public int? DailyLimit { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 每单限用次数。
|
||||
/// </summary>
|
||||
public int? PerOrderLimit { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 每人限购张数。
|
||||
/// </summary>
|
||||
public int? PerUserPurchaseLimit { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 是否允许转赠。
|
||||
/// </summary>
|
||||
public bool AllowTransfer { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 过期策略。
|
||||
/// </summary>
|
||||
public PunchCardExpireStrategy ExpireStrategy { get; set; } = PunchCardExpireStrategy.Invalidate;
|
||||
|
||||
/// <summary>
|
||||
/// 次卡描述。
|
||||
/// </summary>
|
||||
public string? Description { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 购买通知渠道 JSON。
|
||||
/// </summary>
|
||||
public string NotifyChannelsJson { get; set; } = "[]";
|
||||
|
||||
/// <summary>
|
||||
/// 次卡状态。
|
||||
/// </summary>
|
||||
public PunchCardStatus Status { get; set; } = PunchCardStatus.Enabled;
|
||||
}
|
||||
@@ -0,0 +1,60 @@
|
||||
using TakeoutSaaS.Domain.Coupons.Enums;
|
||||
using TakeoutSaaS.Shared.Abstractions.Entities;
|
||||
|
||||
namespace TakeoutSaaS.Domain.Coupons.Entities;
|
||||
|
||||
/// <summary>
|
||||
/// 次卡使用记录。
|
||||
/// </summary>
|
||||
public sealed class PunchCardUsageRecord : MultiTenantEntityBase
|
||||
{
|
||||
/// <summary>
|
||||
/// 门店 ID。
|
||||
/// </summary>
|
||||
public long StoreId { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 次卡模板 ID。
|
||||
/// </summary>
|
||||
public long PunchCardTemplateId { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 次卡实例 ID。
|
||||
/// </summary>
|
||||
public long PunchCardInstanceId { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 使用单号。
|
||||
/// </summary>
|
||||
public string RecordNo { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// 兑换商品名称。
|
||||
/// </summary>
|
||||
public string ProductName { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// 使用时间(UTC)。
|
||||
/// </summary>
|
||||
public DateTime UsedAt { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 本次使用次数。
|
||||
/// </summary>
|
||||
public int UsedTimes { get; set; } = 1;
|
||||
|
||||
/// <summary>
|
||||
/// 使用后剩余次数。
|
||||
/// </summary>
|
||||
public int RemainingTimesAfterUse { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 本次记录状态。
|
||||
/// </summary>
|
||||
public PunchCardUsageRecordStatus StatusAfterUse { get; set; } = PunchCardUsageRecordStatus.Normal;
|
||||
|
||||
/// <summary>
|
||||
/// 超额补差金额。
|
||||
/// </summary>
|
||||
public decimal? ExtraPayAmount { get; set; }
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
namespace TakeoutSaaS.Domain.Coupons.Enums;
|
||||
|
||||
/// <summary>
|
||||
/// 次卡过期策略。
|
||||
/// </summary>
|
||||
public enum PunchCardExpireStrategy
|
||||
{
|
||||
/// <summary>
|
||||
/// 剩余次数作废。
|
||||
/// </summary>
|
||||
Invalidate = 0,
|
||||
|
||||
/// <summary>
|
||||
/// 可申请退款。
|
||||
/// </summary>
|
||||
Refund = 1
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
namespace TakeoutSaaS.Domain.Coupons.Enums;
|
||||
|
||||
/// <summary>
|
||||
/// 次卡实例状态。
|
||||
/// </summary>
|
||||
public enum PunchCardInstanceStatus
|
||||
{
|
||||
/// <summary>
|
||||
/// 使用中。
|
||||
/// </summary>
|
||||
Active = 0,
|
||||
|
||||
/// <summary>
|
||||
/// 已用完。
|
||||
/// </summary>
|
||||
UsedUp = 1,
|
||||
|
||||
/// <summary>
|
||||
/// 已过期。
|
||||
/// </summary>
|
||||
Expired = 2,
|
||||
|
||||
/// <summary>
|
||||
/// 已退款。
|
||||
/// </summary>
|
||||
Refunded = 3
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
namespace TakeoutSaaS.Domain.Coupons.Enums;
|
||||
|
||||
/// <summary>
|
||||
/// 次卡适用范围类型。
|
||||
/// </summary>
|
||||
public enum PunchCardScopeType
|
||||
{
|
||||
/// <summary>
|
||||
/// 全部商品。
|
||||
/// </summary>
|
||||
All = 0,
|
||||
|
||||
/// <summary>
|
||||
/// 指定分类。
|
||||
/// </summary>
|
||||
Category = 1,
|
||||
|
||||
/// <summary>
|
||||
/// 指定标签。
|
||||
/// </summary>
|
||||
Tag = 2,
|
||||
|
||||
/// <summary>
|
||||
/// 指定商品。
|
||||
/// </summary>
|
||||
Product = 3
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
namespace TakeoutSaaS.Domain.Coupons.Enums;
|
||||
|
||||
/// <summary>
|
||||
/// 次卡状态。
|
||||
/// </summary>
|
||||
public enum PunchCardStatus
|
||||
{
|
||||
/// <summary>
|
||||
/// 已下架。
|
||||
/// </summary>
|
||||
Disabled = 0,
|
||||
|
||||
/// <summary>
|
||||
/// 已上架。
|
||||
/// </summary>
|
||||
Enabled = 1
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
namespace TakeoutSaaS.Domain.Coupons.Enums;
|
||||
|
||||
/// <summary>
|
||||
/// 次卡使用模式。
|
||||
/// </summary>
|
||||
public enum PunchCardUsageMode
|
||||
{
|
||||
/// <summary>
|
||||
/// 完全免费。
|
||||
/// </summary>
|
||||
Free = 0,
|
||||
|
||||
/// <summary>
|
||||
/// 金额上限。
|
||||
/// </summary>
|
||||
Cap = 1
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
namespace TakeoutSaaS.Domain.Coupons.Enums;
|
||||
|
||||
/// <summary>
|
||||
/// 次卡使用记录状态。
|
||||
/// </summary>
|
||||
public enum PunchCardUsageRecordStatus
|
||||
{
|
||||
/// <summary>
|
||||
/// 正常使用。
|
||||
/// </summary>
|
||||
Normal = 0,
|
||||
|
||||
/// <summary>
|
||||
/// 即将用完。
|
||||
/// </summary>
|
||||
AlmostUsedUp = 1,
|
||||
|
||||
/// <summary>
|
||||
/// 已用完。
|
||||
/// </summary>
|
||||
UsedUp = 2,
|
||||
|
||||
/// <summary>
|
||||
/// 已过期。
|
||||
/// </summary>
|
||||
Expired = 3
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
namespace TakeoutSaaS.Domain.Coupons.Enums;
|
||||
|
||||
/// <summary>
|
||||
/// 次卡有效期类型。
|
||||
/// </summary>
|
||||
public enum PunchCardValidityType
|
||||
{
|
||||
/// <summary>
|
||||
/// 购买后固定天数。
|
||||
/// </summary>
|
||||
Days = 0,
|
||||
|
||||
/// <summary>
|
||||
/// 固定日期区间。
|
||||
/// </summary>
|
||||
DateRange = 1
|
||||
}
|
||||
@@ -0,0 +1,247 @@
|
||||
using TakeoutSaaS.Domain.Coupons.Entities;
|
||||
using TakeoutSaaS.Domain.Coupons.Enums;
|
||||
|
||||
namespace TakeoutSaaS.Domain.Coupons.Repositories;
|
||||
|
||||
/// <summary>
|
||||
/// 次卡管理仓储契约。
|
||||
/// </summary>
|
||||
public interface IPunchCardRepository
|
||||
{
|
||||
/// <summary>
|
||||
/// 查询次卡模板分页。
|
||||
/// </summary>
|
||||
Task<(IReadOnlyList<PunchCardTemplate> Items, int TotalCount)> SearchTemplatesAsync(
|
||||
long tenantId,
|
||||
long storeId,
|
||||
string? keyword,
|
||||
PunchCardStatus? status,
|
||||
int page,
|
||||
int pageSize,
|
||||
CancellationToken cancellationToken = default);
|
||||
|
||||
/// <summary>
|
||||
/// 读取指定次卡模板。
|
||||
/// </summary>
|
||||
Task<PunchCardTemplate?> FindTemplateByIdAsync(
|
||||
long tenantId,
|
||||
long storeId,
|
||||
long templateId,
|
||||
CancellationToken cancellationToken = default);
|
||||
|
||||
/// <summary>
|
||||
/// 按标识批量读取次卡模板。
|
||||
/// </summary>
|
||||
Task<IReadOnlyList<PunchCardTemplate>> GetTemplatesByIdsAsync(
|
||||
long tenantId,
|
||||
long storeId,
|
||||
IReadOnlyCollection<long> templateIds,
|
||||
CancellationToken cancellationToken = default);
|
||||
|
||||
/// <summary>
|
||||
/// 按模板批量统计售卖与在用信息。
|
||||
/// </summary>
|
||||
Task<Dictionary<long, PunchCardTemplateAggregateSnapshot>> GetTemplateAggregateByTemplateIdsAsync(
|
||||
long tenantId,
|
||||
long storeId,
|
||||
IReadOnlyCollection<long> templateIds,
|
||||
CancellationToken cancellationToken = default);
|
||||
|
||||
/// <summary>
|
||||
/// 查询页面统计。
|
||||
/// </summary>
|
||||
Task<PunchCardTemplateStatsSnapshot> GetTemplateStatsAsync(
|
||||
long tenantId,
|
||||
long storeId,
|
||||
CancellationToken cancellationToken = default);
|
||||
|
||||
/// <summary>
|
||||
/// 新增次卡模板。
|
||||
/// </summary>
|
||||
Task AddTemplateAsync(PunchCardTemplate entity, CancellationToken cancellationToken = default);
|
||||
|
||||
/// <summary>
|
||||
/// 更新次卡模板。
|
||||
/// </summary>
|
||||
Task UpdateTemplateAsync(PunchCardTemplate entity, CancellationToken cancellationToken = default);
|
||||
|
||||
/// <summary>
|
||||
/// 删除次卡模板。
|
||||
/// </summary>
|
||||
Task DeleteTemplateAsync(PunchCardTemplate entity, CancellationToken cancellationToken = default);
|
||||
|
||||
/// <summary>
|
||||
/// 查询次卡实例。
|
||||
/// </summary>
|
||||
Task<PunchCardInstance?> FindInstanceByNoAsync(
|
||||
long tenantId,
|
||||
long storeId,
|
||||
string instanceNo,
|
||||
CancellationToken cancellationToken = default);
|
||||
|
||||
/// <summary>
|
||||
/// 查询次卡实例。
|
||||
/// </summary>
|
||||
Task<PunchCardInstance?> FindInstanceByIdAsync(
|
||||
long tenantId,
|
||||
long storeId,
|
||||
long instanceId,
|
||||
CancellationToken cancellationToken = default);
|
||||
|
||||
/// <summary>
|
||||
/// 按标识批量读取次卡实例。
|
||||
/// </summary>
|
||||
Task<IReadOnlyList<PunchCardInstance>> GetInstancesByIdsAsync(
|
||||
long tenantId,
|
||||
long storeId,
|
||||
IReadOnlyCollection<long> instanceIds,
|
||||
CancellationToken cancellationToken = default);
|
||||
|
||||
/// <summary>
|
||||
/// 新增次卡实例。
|
||||
/// </summary>
|
||||
Task AddInstanceAsync(PunchCardInstance entity, CancellationToken cancellationToken = default);
|
||||
|
||||
/// <summary>
|
||||
/// 更新次卡实例。
|
||||
/// </summary>
|
||||
Task UpdateInstanceAsync(PunchCardInstance entity, CancellationToken cancellationToken = default);
|
||||
|
||||
/// <summary>
|
||||
/// 查询使用记录分页。
|
||||
/// </summary>
|
||||
Task<(IReadOnlyList<PunchCardUsageRecord> Items, int TotalCount)> SearchUsageRecordsAsync(
|
||||
long tenantId,
|
||||
long storeId,
|
||||
long? templateId,
|
||||
string? keyword,
|
||||
PunchCardUsageRecordFilterStatus? status,
|
||||
int page,
|
||||
int pageSize,
|
||||
CancellationToken cancellationToken = default);
|
||||
|
||||
/// <summary>
|
||||
/// 查询导出使用记录(不分页)。
|
||||
/// </summary>
|
||||
Task<IReadOnlyList<PunchCardUsageRecord>> ListUsageRecordsForExportAsync(
|
||||
long tenantId,
|
||||
long storeId,
|
||||
long? templateId,
|
||||
string? keyword,
|
||||
PunchCardUsageRecordFilterStatus? status,
|
||||
CancellationToken cancellationToken = default);
|
||||
|
||||
/// <summary>
|
||||
/// 查询使用记录统计。
|
||||
/// </summary>
|
||||
Task<PunchCardUsageStatsSnapshot> GetUsageStatsAsync(
|
||||
long tenantId,
|
||||
long storeId,
|
||||
long? templateId,
|
||||
DateTime nowUtc,
|
||||
CancellationToken cancellationToken = default);
|
||||
|
||||
/// <summary>
|
||||
/// 新增使用记录。
|
||||
/// </summary>
|
||||
Task AddUsageRecordAsync(PunchCardUsageRecord entity, CancellationToken cancellationToken = default);
|
||||
|
||||
/// <summary>
|
||||
/// 持久化变更。
|
||||
/// </summary>
|
||||
Task SaveChangesAsync(CancellationToken cancellationToken = default);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 次卡模板聚合快照。
|
||||
/// </summary>
|
||||
public sealed record PunchCardTemplateAggregateSnapshot
|
||||
{
|
||||
/// <summary>
|
||||
/// 次卡模板标识。
|
||||
/// </summary>
|
||||
public required long TemplateId { 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 sealed record PunchCardTemplateStatsSnapshot
|
||||
{
|
||||
/// <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; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 使用记录筛选状态。
|
||||
/// </summary>
|
||||
public enum PunchCardUsageRecordFilterStatus
|
||||
{
|
||||
/// <summary>
|
||||
/// 正常(包含即将用完)。
|
||||
/// </summary>
|
||||
Normal = 0,
|
||||
|
||||
/// <summary>
|
||||
/// 已用完。
|
||||
/// </summary>
|
||||
UsedUp = 1,
|
||||
|
||||
/// <summary>
|
||||
/// 已过期。
|
||||
/// </summary>
|
||||
Expired = 2
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 使用记录统计快照。
|
||||
/// </summary>
|
||||
public sealed record PunchCardUsageStatsSnapshot
|
||||
{
|
||||
/// <summary>
|
||||
/// 今日使用次数。
|
||||
/// </summary>
|
||||
public int TodayUsedCount { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// 本月使用次数。
|
||||
/// </summary>
|
||||
public int MonthUsedCount { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// 7 天内即将过期数量。
|
||||
/// </summary>
|
||||
public int ExpiringSoonCount { get; init; }
|
||||
}
|
||||
@@ -48,6 +48,7 @@ public static class AppServiceCollectionExtensions
|
||||
services.AddScoped<ICouponRepository, EfCouponRepository>();
|
||||
services.AddScoped<INewCustomerGiftRepository, EfNewCustomerGiftRepository>();
|
||||
services.AddScoped<IPromotionCampaignRepository, EfPromotionCampaignRepository>();
|
||||
services.AddScoped<IPunchCardRepository, EfPunchCardRepository>();
|
||||
services.AddScoped<IOrderRepository, EfOrderRepository>();
|
||||
services.AddScoped<IPaymentRepository, EfPaymentRepository>();
|
||||
services.AddScoped<IDeliveryRepository, EfDeliveryRepository>();
|
||||
|
||||
@@ -370,6 +370,18 @@ public sealed class TakeoutAppDbContext(
|
||||
/// </summary>
|
||||
public DbSet<NewCustomerGrowthRecord> NewCustomerGrowthRecords => Set<NewCustomerGrowthRecord>();
|
||||
/// <summary>
|
||||
/// 次卡模板。
|
||||
/// </summary>
|
||||
public DbSet<PunchCardTemplate> PunchCardTemplates => Set<PunchCardTemplate>();
|
||||
/// <summary>
|
||||
/// 次卡实例。
|
||||
/// </summary>
|
||||
public DbSet<PunchCardInstance> PunchCardInstances => Set<PunchCardInstance>();
|
||||
/// <summary>
|
||||
/// 次卡使用记录。
|
||||
/// </summary>
|
||||
public DbSet<PunchCardUsageRecord> PunchCardUsageRecords => Set<PunchCardUsageRecord>();
|
||||
/// <summary>
|
||||
/// 会员档案。
|
||||
/// </summary>
|
||||
public DbSet<MemberProfile> MemberProfiles => Set<MemberProfile>();
|
||||
@@ -540,6 +552,9 @@ public sealed class TakeoutAppDbContext(
|
||||
ConfigureNewCustomerCouponRule(modelBuilder.Entity<NewCustomerCouponRule>());
|
||||
ConfigureNewCustomerInviteRecord(modelBuilder.Entity<NewCustomerInviteRecord>());
|
||||
ConfigureNewCustomerGrowthRecord(modelBuilder.Entity<NewCustomerGrowthRecord>());
|
||||
ConfigurePunchCardTemplate(modelBuilder.Entity<PunchCardTemplate>());
|
||||
ConfigurePunchCardInstance(modelBuilder.Entity<PunchCardInstance>());
|
||||
ConfigurePunchCardUsageRecord(modelBuilder.Entity<PunchCardUsageRecord>());
|
||||
ConfigureMemberProfile(modelBuilder.Entity<MemberProfile>());
|
||||
ConfigureMemberTier(modelBuilder.Entity<MemberTier>());
|
||||
ConfigureMemberPointLedger(modelBuilder.Entity<MemberPointLedger>());
|
||||
@@ -1692,6 +1707,77 @@ public sealed class TakeoutAppDbContext(
|
||||
builder.HasIndex(x => new { x.TenantId, x.StoreId, x.RegisteredAt });
|
||||
}
|
||||
|
||||
private static void ConfigurePunchCardTemplate(EntityTypeBuilder<PunchCardTemplate> builder)
|
||||
{
|
||||
builder.ToTable("punch_card_templates");
|
||||
builder.HasKey(x => x.Id);
|
||||
builder.Property(x => x.StoreId).IsRequired();
|
||||
builder.Property(x => x.Name).HasMaxLength(64).IsRequired();
|
||||
builder.Property(x => x.CoverImageUrl).HasMaxLength(512);
|
||||
builder.Property(x => x.SalePrice).HasPrecision(18, 2);
|
||||
builder.Property(x => x.OriginalPrice).HasPrecision(18, 2);
|
||||
builder.Property(x => x.TotalTimes).IsRequired();
|
||||
builder.Property(x => x.ValidityType).HasConversion<int>();
|
||||
builder.Property(x => x.ValidityDays);
|
||||
builder.Property(x => x.ValidFrom);
|
||||
builder.Property(x => x.ValidTo);
|
||||
builder.Property(x => x.ScopeType).HasConversion<int>();
|
||||
builder.Property(x => x.ScopeCategoryIdsJson).HasColumnType("text").IsRequired();
|
||||
builder.Property(x => x.ScopeTagIdsJson).HasColumnType("text").IsRequired();
|
||||
builder.Property(x => x.ScopeProductIdsJson).HasColumnType("text").IsRequired();
|
||||
builder.Property(x => x.UsageMode).HasConversion<int>();
|
||||
builder.Property(x => x.UsageCapAmount).HasPrecision(18, 2);
|
||||
builder.Property(x => x.DailyLimit);
|
||||
builder.Property(x => x.PerOrderLimit);
|
||||
builder.Property(x => x.PerUserPurchaseLimit);
|
||||
builder.Property(x => x.AllowTransfer).IsRequired();
|
||||
builder.Property(x => x.ExpireStrategy).HasConversion<int>();
|
||||
builder.Property(x => x.Description).HasMaxLength(512);
|
||||
builder.Property(x => x.NotifyChannelsJson).HasColumnType("text").IsRequired();
|
||||
builder.Property(x => x.Status).HasConversion<int>();
|
||||
builder.HasIndex(x => new { x.TenantId, x.StoreId, x.Name }).IsUnique();
|
||||
builder.HasIndex(x => new { x.TenantId, x.StoreId, x.Status });
|
||||
}
|
||||
|
||||
private static void ConfigurePunchCardInstance(EntityTypeBuilder<PunchCardInstance> builder)
|
||||
{
|
||||
builder.ToTable("punch_card_instances");
|
||||
builder.HasKey(x => x.Id);
|
||||
builder.Property(x => x.StoreId).IsRequired();
|
||||
builder.Property(x => x.PunchCardTemplateId).IsRequired();
|
||||
builder.Property(x => x.InstanceNo).HasMaxLength(32).IsRequired();
|
||||
builder.Property(x => x.MemberName).HasMaxLength(64).IsRequired();
|
||||
builder.Property(x => x.MemberPhoneMasked).HasMaxLength(32).IsRequired();
|
||||
builder.Property(x => x.PurchasedAt).IsRequired();
|
||||
builder.Property(x => x.ExpiresAt);
|
||||
builder.Property(x => x.TotalTimes).IsRequired();
|
||||
builder.Property(x => x.RemainingTimes).IsRequired();
|
||||
builder.Property(x => x.PaidAmount).HasPrecision(18, 2);
|
||||
builder.Property(x => x.Status).HasConversion<int>();
|
||||
builder.HasIndex(x => new { x.TenantId, x.StoreId, x.InstanceNo }).IsUnique();
|
||||
builder.HasIndex(x => new { x.TenantId, x.StoreId, x.PunchCardTemplateId });
|
||||
builder.HasIndex(x => new { x.TenantId, x.StoreId, x.Status, x.ExpiresAt });
|
||||
}
|
||||
|
||||
private static void ConfigurePunchCardUsageRecord(EntityTypeBuilder<PunchCardUsageRecord> builder)
|
||||
{
|
||||
builder.ToTable("punch_card_usage_records");
|
||||
builder.HasKey(x => x.Id);
|
||||
builder.Property(x => x.StoreId).IsRequired();
|
||||
builder.Property(x => x.PunchCardTemplateId).IsRequired();
|
||||
builder.Property(x => x.PunchCardInstanceId).IsRequired();
|
||||
builder.Property(x => x.RecordNo).HasMaxLength(32).IsRequired();
|
||||
builder.Property(x => x.ProductName).HasMaxLength(128).IsRequired();
|
||||
builder.Property(x => x.UsedAt).IsRequired();
|
||||
builder.Property(x => x.UsedTimes).IsRequired();
|
||||
builder.Property(x => x.RemainingTimesAfterUse).IsRequired();
|
||||
builder.Property(x => x.StatusAfterUse).HasConversion<int>();
|
||||
builder.Property(x => x.ExtraPayAmount).HasPrecision(18, 2);
|
||||
builder.HasIndex(x => new { x.TenantId, x.StoreId, x.RecordNo }).IsUnique();
|
||||
builder.HasIndex(x => new { x.TenantId, x.StoreId, x.PunchCardTemplateId, x.UsedAt });
|
||||
builder.HasIndex(x => new { x.TenantId, x.StoreId, x.PunchCardInstanceId, x.UsedAt });
|
||||
}
|
||||
|
||||
private static void ConfigureMemberProfile(EntityTypeBuilder<MemberProfile> builder)
|
||||
{
|
||||
builder.ToTable("member_profiles");
|
||||
|
||||
@@ -0,0 +1,469 @@
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using TakeoutSaaS.Domain.Coupons.Entities;
|
||||
using TakeoutSaaS.Domain.Coupons.Enums;
|
||||
using TakeoutSaaS.Domain.Coupons.Repositories;
|
||||
using TakeoutSaaS.Infrastructure.App.Persistence;
|
||||
|
||||
namespace TakeoutSaaS.Infrastructure.App.Repositories;
|
||||
|
||||
/// <summary>
|
||||
/// 次卡仓储 EF Core 实现。
|
||||
/// </summary>
|
||||
public sealed class EfPunchCardRepository(TakeoutAppDbContext context) : IPunchCardRepository
|
||||
{
|
||||
/// <inheritdoc />
|
||||
public async Task<(IReadOnlyList<PunchCardTemplate> Items, int TotalCount)> SearchTemplatesAsync(
|
||||
long tenantId,
|
||||
long storeId,
|
||||
string? keyword,
|
||||
PunchCardStatus? status,
|
||||
int page,
|
||||
int pageSize,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
var normalizedPage = Math.Max(1, page);
|
||||
var normalizedPageSize = Math.Clamp(pageSize, 1, 200);
|
||||
|
||||
var query = context.PunchCardTemplates
|
||||
.AsNoTracking()
|
||||
.Where(item => item.TenantId == tenantId && item.StoreId == storeId);
|
||||
|
||||
if (status.HasValue)
|
||||
{
|
||||
query = query.Where(item => item.Status == status.Value);
|
||||
}
|
||||
|
||||
var normalizedKeyword = (keyword ?? string.Empty).Trim();
|
||||
if (!string.IsNullOrWhiteSpace(normalizedKeyword))
|
||||
{
|
||||
var keywordLike = $"%{normalizedKeyword}%";
|
||||
query = query.Where(item => EF.Functions.ILike(item.Name, keywordLike));
|
||||
}
|
||||
|
||||
var totalCount = await query.CountAsync(cancellationToken);
|
||||
if (totalCount == 0)
|
||||
{
|
||||
return ([], 0);
|
||||
}
|
||||
|
||||
var items = await query
|
||||
.OrderByDescending(item => item.UpdatedAt ?? item.CreatedAt)
|
||||
.ThenByDescending(item => item.Id)
|
||||
.Skip((normalizedPage - 1) * normalizedPageSize)
|
||||
.Take(normalizedPageSize)
|
||||
.ToListAsync(cancellationToken);
|
||||
|
||||
return (items, totalCount);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public Task<PunchCardTemplate?> FindTemplateByIdAsync(
|
||||
long tenantId,
|
||||
long storeId,
|
||||
long templateId,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
return context.PunchCardTemplates
|
||||
.Where(item =>
|
||||
item.TenantId == tenantId &&
|
||||
item.StoreId == storeId &&
|
||||
item.Id == templateId)
|
||||
.FirstOrDefaultAsync(cancellationToken);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task<IReadOnlyList<PunchCardTemplate>> GetTemplatesByIdsAsync(
|
||||
long tenantId,
|
||||
long storeId,
|
||||
IReadOnlyCollection<long> templateIds,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
if (templateIds.Count == 0)
|
||||
{
|
||||
return [];
|
||||
}
|
||||
|
||||
return await context.PunchCardTemplates
|
||||
.AsNoTracking()
|
||||
.Where(item =>
|
||||
item.TenantId == tenantId &&
|
||||
item.StoreId == storeId &&
|
||||
templateIds.Contains(item.Id))
|
||||
.ToListAsync(cancellationToken);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task<Dictionary<long, PunchCardTemplateAggregateSnapshot>> GetTemplateAggregateByTemplateIdsAsync(
|
||||
long tenantId,
|
||||
long storeId,
|
||||
IReadOnlyCollection<long> templateIds,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
if (templateIds.Count == 0)
|
||||
{
|
||||
return [];
|
||||
}
|
||||
|
||||
var nowUtc = DateTime.UtcNow;
|
||||
var aggregates = await context.PunchCardInstances
|
||||
.AsNoTracking()
|
||||
.Where(item =>
|
||||
item.TenantId == tenantId &&
|
||||
item.StoreId == storeId &&
|
||||
templateIds.Contains(item.PunchCardTemplateId))
|
||||
.GroupBy(item => item.PunchCardTemplateId)
|
||||
.Select(group => new PunchCardTemplateAggregateSnapshot
|
||||
{
|
||||
TemplateId = group.Key,
|
||||
SoldCount = group.Count(),
|
||||
ActiveCount = group.Count(item =>
|
||||
item.Status == PunchCardInstanceStatus.Active &&
|
||||
item.RemainingTimes > 0 &&
|
||||
(!item.ExpiresAt.HasValue || item.ExpiresAt.Value >= nowUtc)),
|
||||
RevenueAmount = group.Sum(item => item.PaidAmount)
|
||||
})
|
||||
.ToListAsync(cancellationToken);
|
||||
|
||||
return aggregates.ToDictionary(item => item.TemplateId, item => item);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task<PunchCardTemplateStatsSnapshot> GetTemplateStatsAsync(
|
||||
long tenantId,
|
||||
long storeId,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
var onSaleCount = await context.PunchCardTemplates
|
||||
.AsNoTracking()
|
||||
.Where(item =>
|
||||
item.TenantId == tenantId &&
|
||||
item.StoreId == storeId &&
|
||||
item.Status == PunchCardStatus.Enabled)
|
||||
.CountAsync(cancellationToken);
|
||||
|
||||
var summary = await context.PunchCardInstances
|
||||
.AsNoTracking()
|
||||
.Where(item => item.TenantId == tenantId && item.StoreId == storeId)
|
||||
.GroupBy(_ => 1)
|
||||
.Select(group => new
|
||||
{
|
||||
TotalSoldCount = group.Count(),
|
||||
TotalRevenueAmount = group.Sum(item => item.PaidAmount)
|
||||
})
|
||||
.FirstOrDefaultAsync(cancellationToken);
|
||||
|
||||
var nowUtc = DateTime.UtcNow;
|
||||
var activeInUseCount = await context.PunchCardInstances
|
||||
.AsNoTracking()
|
||||
.Where(item =>
|
||||
item.TenantId == tenantId &&
|
||||
item.StoreId == storeId &&
|
||||
item.Status == PunchCardInstanceStatus.Active &&
|
||||
item.RemainingTimes > 0 &&
|
||||
(!item.ExpiresAt.HasValue || item.ExpiresAt.Value >= nowUtc))
|
||||
.CountAsync(cancellationToken);
|
||||
|
||||
return new PunchCardTemplateStatsSnapshot
|
||||
{
|
||||
OnSaleCount = onSaleCount,
|
||||
TotalSoldCount = summary?.TotalSoldCount ?? 0,
|
||||
TotalRevenueAmount = summary?.TotalRevenueAmount ?? 0m,
|
||||
ActiveInUseCount = activeInUseCount
|
||||
};
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public Task AddTemplateAsync(PunchCardTemplate entity, CancellationToken cancellationToken = default)
|
||||
{
|
||||
return context.PunchCardTemplates.AddAsync(entity, cancellationToken).AsTask();
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public Task UpdateTemplateAsync(PunchCardTemplate entity, CancellationToken cancellationToken = default)
|
||||
{
|
||||
context.PunchCardTemplates.Update(entity);
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public Task DeleteTemplateAsync(PunchCardTemplate entity, CancellationToken cancellationToken = default)
|
||||
{
|
||||
context.PunchCardTemplates.Remove(entity);
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public Task<PunchCardInstance?> FindInstanceByNoAsync(
|
||||
long tenantId,
|
||||
long storeId,
|
||||
string instanceNo,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
return context.PunchCardInstances
|
||||
.Where(item =>
|
||||
item.TenantId == tenantId &&
|
||||
item.StoreId == storeId &&
|
||||
item.InstanceNo == instanceNo)
|
||||
.FirstOrDefaultAsync(cancellationToken);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public Task<PunchCardInstance?> FindInstanceByIdAsync(
|
||||
long tenantId,
|
||||
long storeId,
|
||||
long instanceId,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
return context.PunchCardInstances
|
||||
.Where(item =>
|
||||
item.TenantId == tenantId &&
|
||||
item.StoreId == storeId &&
|
||||
item.Id == instanceId)
|
||||
.FirstOrDefaultAsync(cancellationToken);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task<IReadOnlyList<PunchCardInstance>> GetInstancesByIdsAsync(
|
||||
long tenantId,
|
||||
long storeId,
|
||||
IReadOnlyCollection<long> instanceIds,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
if (instanceIds.Count == 0)
|
||||
{
|
||||
return [];
|
||||
}
|
||||
|
||||
return await context.PunchCardInstances
|
||||
.AsNoTracking()
|
||||
.Where(item =>
|
||||
item.TenantId == tenantId &&
|
||||
item.StoreId == storeId &&
|
||||
instanceIds.Contains(item.Id))
|
||||
.ToListAsync(cancellationToken);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public Task AddInstanceAsync(PunchCardInstance entity, CancellationToken cancellationToken = default)
|
||||
{
|
||||
return context.PunchCardInstances.AddAsync(entity, cancellationToken).AsTask();
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public Task UpdateInstanceAsync(PunchCardInstance entity, CancellationToken cancellationToken = default)
|
||||
{
|
||||
context.PunchCardInstances.Update(entity);
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task<(IReadOnlyList<PunchCardUsageRecord> Items, int TotalCount)> SearchUsageRecordsAsync(
|
||||
long tenantId,
|
||||
long storeId,
|
||||
long? templateId,
|
||||
string? keyword,
|
||||
PunchCardUsageRecordFilterStatus? status,
|
||||
int page,
|
||||
int pageSize,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
var normalizedPage = Math.Max(1, page);
|
||||
var normalizedPageSize = Math.Clamp(pageSize, 1, 500);
|
||||
var query = BuildUsageRecordQuery(
|
||||
tenantId,
|
||||
storeId,
|
||||
templateId,
|
||||
keyword,
|
||||
status,
|
||||
DateTime.UtcNow);
|
||||
|
||||
var totalCount = await query.CountAsync(cancellationToken);
|
||||
if (totalCount == 0)
|
||||
{
|
||||
return ([], 0);
|
||||
}
|
||||
|
||||
var items = await query
|
||||
.OrderByDescending(item => item.UsedAt)
|
||||
.ThenByDescending(item => item.Id)
|
||||
.Skip((normalizedPage - 1) * normalizedPageSize)
|
||||
.Take(normalizedPageSize)
|
||||
.ToListAsync(cancellationToken);
|
||||
|
||||
return (items, totalCount);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task<IReadOnlyList<PunchCardUsageRecord>> ListUsageRecordsForExportAsync(
|
||||
long tenantId,
|
||||
long storeId,
|
||||
long? templateId,
|
||||
string? keyword,
|
||||
PunchCardUsageRecordFilterStatus? status,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
return await BuildUsageRecordQuery(
|
||||
tenantId,
|
||||
storeId,
|
||||
templateId,
|
||||
keyword,
|
||||
status,
|
||||
DateTime.UtcNow)
|
||||
.OrderByDescending(item => item.UsedAt)
|
||||
.ThenByDescending(item => item.Id)
|
||||
.Take(20_000)
|
||||
.ToListAsync(cancellationToken);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task<PunchCardUsageStatsSnapshot> GetUsageStatsAsync(
|
||||
long tenantId,
|
||||
long storeId,
|
||||
long? templateId,
|
||||
DateTime nowUtc,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
var utcNow = NormalizeUtc(nowUtc);
|
||||
var todayStart = utcNow.Date;
|
||||
var monthStart = new DateTime(utcNow.Year, utcNow.Month, 1, 0, 0, 0, DateTimeKind.Utc);
|
||||
var soonEnd = todayStart.AddDays(7);
|
||||
|
||||
var usageQuery = context.PunchCardUsageRecords
|
||||
.AsNoTracking()
|
||||
.Where(item => item.TenantId == tenantId && item.StoreId == storeId);
|
||||
|
||||
if (templateId.HasValue)
|
||||
{
|
||||
usageQuery = usageQuery.Where(item => item.PunchCardTemplateId == templateId.Value);
|
||||
}
|
||||
|
||||
var todayUsedCount = await usageQuery
|
||||
.Where(item => item.UsedAt >= todayStart && item.UsedAt < todayStart.AddDays(1))
|
||||
.CountAsync(cancellationToken);
|
||||
|
||||
var monthUsedCount = await usageQuery
|
||||
.Where(item => item.UsedAt >= monthStart && item.UsedAt < monthStart.AddMonths(1))
|
||||
.CountAsync(cancellationToken);
|
||||
|
||||
var instanceQuery = context.PunchCardInstances
|
||||
.AsNoTracking()
|
||||
.Where(item =>
|
||||
item.TenantId == tenantId &&
|
||||
item.StoreId == storeId &&
|
||||
item.Status == PunchCardInstanceStatus.Active &&
|
||||
item.RemainingTimes > 0 &&
|
||||
item.ExpiresAt.HasValue &&
|
||||
item.ExpiresAt.Value >= todayStart &&
|
||||
item.ExpiresAt.Value < soonEnd);
|
||||
|
||||
if (templateId.HasValue)
|
||||
{
|
||||
instanceQuery = instanceQuery.Where(item => item.PunchCardTemplateId == templateId.Value);
|
||||
}
|
||||
|
||||
var expiringSoonCount = await instanceQuery.CountAsync(cancellationToken);
|
||||
|
||||
return new PunchCardUsageStatsSnapshot
|
||||
{
|
||||
TodayUsedCount = todayUsedCount,
|
||||
MonthUsedCount = monthUsedCount,
|
||||
ExpiringSoonCount = expiringSoonCount
|
||||
};
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public Task AddUsageRecordAsync(PunchCardUsageRecord entity, CancellationToken cancellationToken = default)
|
||||
{
|
||||
return context.PunchCardUsageRecords.AddAsync(entity, cancellationToken).AsTask();
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public Task SaveChangesAsync(CancellationToken cancellationToken = default)
|
||||
{
|
||||
return context.SaveChangesAsync(cancellationToken);
|
||||
}
|
||||
|
||||
private IQueryable<PunchCardUsageRecord> BuildUsageRecordQuery(
|
||||
long tenantId,
|
||||
long storeId,
|
||||
long? templateId,
|
||||
string? keyword,
|
||||
PunchCardUsageRecordFilterStatus? status,
|
||||
DateTime nowUtc)
|
||||
{
|
||||
var query = context.PunchCardUsageRecords
|
||||
.AsNoTracking()
|
||||
.Where(item => item.TenantId == tenantId && item.StoreId == storeId);
|
||||
|
||||
if (templateId.HasValue)
|
||||
{
|
||||
query = query.Where(item => item.PunchCardTemplateId == templateId.Value);
|
||||
}
|
||||
|
||||
var instanceQuery = context.PunchCardInstances
|
||||
.AsNoTracking()
|
||||
.Where(item => item.TenantId == tenantId && item.StoreId == storeId);
|
||||
|
||||
if (templateId.HasValue)
|
||||
{
|
||||
instanceQuery = instanceQuery.Where(item => item.PunchCardTemplateId == templateId.Value);
|
||||
}
|
||||
|
||||
var utcNow = NormalizeUtc(nowUtc);
|
||||
if (status.HasValue)
|
||||
{
|
||||
instanceQuery = status.Value switch
|
||||
{
|
||||
PunchCardUsageRecordFilterStatus.Normal => instanceQuery.Where(item =>
|
||||
item.Status == PunchCardInstanceStatus.Active &&
|
||||
item.RemainingTimes > 0 &&
|
||||
(!item.ExpiresAt.HasValue || item.ExpiresAt.Value >= utcNow)),
|
||||
PunchCardUsageRecordFilterStatus.UsedUp => instanceQuery.Where(item =>
|
||||
item.Status == PunchCardInstanceStatus.UsedUp ||
|
||||
item.RemainingTimes <= 0),
|
||||
PunchCardUsageRecordFilterStatus.Expired => instanceQuery.Where(item =>
|
||||
(item.Status == PunchCardInstanceStatus.Expired ||
|
||||
(item.ExpiresAt.HasValue && item.ExpiresAt.Value < utcNow)) &&
|
||||
item.Status != PunchCardInstanceStatus.Refunded),
|
||||
_ => instanceQuery
|
||||
};
|
||||
}
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(keyword))
|
||||
{
|
||||
var normalizedKeyword = $"%{keyword.Trim()}%";
|
||||
var matchedInstanceIds = context.PunchCardInstances
|
||||
.AsNoTracking()
|
||||
.Where(item =>
|
||||
item.TenantId == tenantId &&
|
||||
item.StoreId == storeId &&
|
||||
(EF.Functions.ILike(item.MemberName, normalizedKeyword) ||
|
||||
EF.Functions.ILike(item.MemberPhoneMasked, normalizedKeyword) ||
|
||||
EF.Functions.ILike(item.InstanceNo, normalizedKeyword)))
|
||||
.Select(item => item.Id);
|
||||
|
||||
query = query.Where(item =>
|
||||
EF.Functions.ILike(item.RecordNo, normalizedKeyword) ||
|
||||
EF.Functions.ILike(item.ProductName, normalizedKeyword) ||
|
||||
matchedInstanceIds.Contains(item.PunchCardInstanceId));
|
||||
}
|
||||
|
||||
if (status.HasValue)
|
||||
{
|
||||
var filteredInstanceIds = instanceQuery.Select(item => item.Id);
|
||||
query = query.Where(item => filteredInstanceIds.Contains(item.PunchCardInstanceId));
|
||||
}
|
||||
|
||||
return query;
|
||||
}
|
||||
|
||||
private static DateTime NormalizeUtc(DateTime value)
|
||||
{
|
||||
return value.Kind switch
|
||||
{
|
||||
DateTimeKind.Utc => value,
|
||||
DateTimeKind.Local => value.ToUniversalTime(),
|
||||
_ => DateTime.SpecifyKind(value, DateTimeKind.Utc)
|
||||
};
|
||||
}
|
||||
}
|
||||
9547
src/Infrastructure/TakeoutSaaS.Infrastructure/Migrations/20260302125930_AddPunchCardModule.Designer.cs
generated
Normal file
9547
src/Infrastructure/TakeoutSaaS.Infrastructure/Migrations/20260302125930_AddPunchCardModule.Designer.cs
generated
Normal file
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,177 @@
|
||||
using System;
|
||||
using Microsoft.EntityFrameworkCore.Migrations;
|
||||
using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata;
|
||||
|
||||
#nullable disable
|
||||
|
||||
namespace TakeoutSaaS.Infrastructure.Migrations
|
||||
{
|
||||
/// <inheritdoc />
|
||||
public partial class AddPunchCardModule : Migration
|
||||
{
|
||||
/// <inheritdoc />
|
||||
protected override void Up(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.CreateTable(
|
||||
name: "punch_card_instances",
|
||||
columns: table => new
|
||||
{
|
||||
Id = table.Column<long>(type: "bigint", nullable: false, comment: "实体唯一标识。")
|
||||
.Annotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.IdentityByDefaultColumn),
|
||||
StoreId = table.Column<long>(type: "bigint", nullable: false, comment: "门店 ID。"),
|
||||
PunchCardTemplateId = table.Column<long>(type: "bigint", nullable: false, comment: "次卡模板 ID。"),
|
||||
InstanceNo = table.Column<string>(type: "character varying(32)", maxLength: 32, nullable: false, comment: "实例编号(业务唯一)。"),
|
||||
MemberName = table.Column<string>(type: "character varying(64)", maxLength: 64, nullable: false, comment: "会员名称。"),
|
||||
MemberPhoneMasked = table.Column<string>(type: "character varying(32)", maxLength: 32, nullable: false, comment: "会员手机号(脱敏)。"),
|
||||
PurchasedAt = table.Column<DateTime>(type: "timestamp with time zone", nullable: false, comment: "购买时间(UTC)。"),
|
||||
ExpiresAt = table.Column<DateTime>(type: "timestamp with time zone", nullable: true, comment: "过期时间(UTC,可空)。"),
|
||||
TotalTimes = table.Column<int>(type: "integer", nullable: false, comment: "总次数。"),
|
||||
RemainingTimes = table.Column<int>(type: "integer", nullable: false, comment: "剩余次数。"),
|
||||
PaidAmount = table.Column<decimal>(type: "numeric(18,2)", precision: 18, scale: 2, nullable: false, comment: "实付金额。"),
|
||||
Status = table.Column<int>(type: "integer", nullable: false, comment: "实例状态。"),
|
||||
CreatedAt = table.Column<DateTime>(type: "timestamp with time zone", nullable: false, comment: "创建时间(UTC)。"),
|
||||
UpdatedAt = table.Column<DateTime>(type: "timestamp with time zone", nullable: true, comment: "最近一次更新时间(UTC),从未更新时为 null。"),
|
||||
DeletedAt = table.Column<DateTime>(type: "timestamp with time zone", nullable: true, comment: "软删除时间(UTC),未删除时为 null。"),
|
||||
CreatedBy = table.Column<long>(type: "bigint", nullable: true, comment: "创建人用户标识,匿名或系统操作时为 null。"),
|
||||
UpdatedBy = table.Column<long>(type: "bigint", nullable: true, comment: "最后更新人用户标识,匿名或系统操作时为 null。"),
|
||||
DeletedBy = table.Column<long>(type: "bigint", nullable: true, comment: "删除人用户标识(软删除),未删除时为 null。"),
|
||||
TenantId = table.Column<long>(type: "bigint", nullable: false, comment: "所属租户 ID。")
|
||||
},
|
||||
constraints: table =>
|
||||
{
|
||||
table.PrimaryKey("PK_punch_card_instances", x => x.Id);
|
||||
},
|
||||
comment: "次卡实例(顾客购买后生成)。");
|
||||
|
||||
migrationBuilder.CreateTable(
|
||||
name: "punch_card_templates",
|
||||
columns: table => new
|
||||
{
|
||||
Id = table.Column<long>(type: "bigint", nullable: false, comment: "实体唯一标识。")
|
||||
.Annotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.IdentityByDefaultColumn),
|
||||
StoreId = table.Column<long>(type: "bigint", nullable: false, comment: "门店 ID。"),
|
||||
Name = table.Column<string>(type: "character varying(64)", maxLength: 64, nullable: false, comment: "次卡名称。"),
|
||||
CoverImageUrl = table.Column<string>(type: "character varying(512)", maxLength: 512, nullable: true, comment: "封面图片地址。"),
|
||||
SalePrice = table.Column<decimal>(type: "numeric(18,2)", precision: 18, scale: 2, nullable: false, comment: "售价。"),
|
||||
OriginalPrice = table.Column<decimal>(type: "numeric(18,2)", precision: 18, scale: 2, nullable: true, comment: "原价。"),
|
||||
TotalTimes = table.Column<int>(type: "integer", nullable: false, comment: "总次数。"),
|
||||
ValidityType = table.Column<int>(type: "integer", nullable: false, comment: "有效期类型。"),
|
||||
ValidityDays = table.Column<int>(type: "integer", nullable: true, comment: "固定天数(ValidityType=Days 时有效)。"),
|
||||
ValidFrom = table.Column<DateTime>(type: "timestamp with time zone", nullable: true, comment: "固定开始日期(UTC,ValidityType=DateRange 时有效)。"),
|
||||
ValidTo = table.Column<DateTime>(type: "timestamp with time zone", nullable: true, comment: "固定结束日期(UTC,ValidityType=DateRange 时有效)。"),
|
||||
ScopeType = table.Column<int>(type: "integer", nullable: false, comment: "适用范围类型。"),
|
||||
ScopeCategoryIdsJson = table.Column<string>(type: "text", nullable: false, comment: "指定分类 ID JSON。"),
|
||||
ScopeTagIdsJson = table.Column<string>(type: "text", nullable: false, comment: "指定标签 ID JSON。"),
|
||||
ScopeProductIdsJson = table.Column<string>(type: "text", nullable: false, comment: "指定商品 ID JSON。"),
|
||||
UsageMode = table.Column<int>(type: "integer", nullable: false, comment: "使用模式。"),
|
||||
UsageCapAmount = table.Column<decimal>(type: "numeric(18,2)", precision: 18, scale: 2, nullable: true, comment: "金额上限(UsageMode=Cap 时有效)。"),
|
||||
DailyLimit = table.Column<int>(type: "integer", nullable: true, comment: "每日限用次数。"),
|
||||
PerOrderLimit = table.Column<int>(type: "integer", nullable: true, comment: "每单限用次数。"),
|
||||
PerUserPurchaseLimit = table.Column<int>(type: "integer", nullable: true, comment: "每人限购张数。"),
|
||||
AllowTransfer = table.Column<bool>(type: "boolean", nullable: false, comment: "是否允许转赠。"),
|
||||
ExpireStrategy = table.Column<int>(type: "integer", nullable: false, comment: "过期策略。"),
|
||||
Description = table.Column<string>(type: "character varying(512)", maxLength: 512, nullable: true, comment: "次卡描述。"),
|
||||
NotifyChannelsJson = table.Column<string>(type: "text", nullable: false, comment: "购买通知渠道 JSON。"),
|
||||
Status = table.Column<int>(type: "integer", nullable: false, comment: "次卡状态。"),
|
||||
CreatedAt = table.Column<DateTime>(type: "timestamp with time zone", nullable: false, comment: "创建时间(UTC)。"),
|
||||
UpdatedAt = table.Column<DateTime>(type: "timestamp with time zone", nullable: true, comment: "最近一次更新时间(UTC),从未更新时为 null。"),
|
||||
DeletedAt = table.Column<DateTime>(type: "timestamp with time zone", nullable: true, comment: "软删除时间(UTC),未删除时为 null。"),
|
||||
CreatedBy = table.Column<long>(type: "bigint", nullable: true, comment: "创建人用户标识,匿名或系统操作时为 null。"),
|
||||
UpdatedBy = table.Column<long>(type: "bigint", nullable: true, comment: "最后更新人用户标识,匿名或系统操作时为 null。"),
|
||||
DeletedBy = table.Column<long>(type: "bigint", nullable: true, comment: "删除人用户标识(软删除),未删除时为 null。"),
|
||||
TenantId = table.Column<long>(type: "bigint", nullable: false, comment: "所属租户 ID。")
|
||||
},
|
||||
constraints: table =>
|
||||
{
|
||||
table.PrimaryKey("PK_punch_card_templates", x => x.Id);
|
||||
},
|
||||
comment: "次卡模板配置。");
|
||||
|
||||
migrationBuilder.CreateTable(
|
||||
name: "punch_card_usage_records",
|
||||
columns: table => new
|
||||
{
|
||||
Id = table.Column<long>(type: "bigint", nullable: false, comment: "实体唯一标识。")
|
||||
.Annotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.IdentityByDefaultColumn),
|
||||
StoreId = table.Column<long>(type: "bigint", nullable: false, comment: "门店 ID。"),
|
||||
PunchCardTemplateId = table.Column<long>(type: "bigint", nullable: false, comment: "次卡模板 ID。"),
|
||||
PunchCardInstanceId = table.Column<long>(type: "bigint", nullable: false, comment: "次卡实例 ID。"),
|
||||
RecordNo = table.Column<string>(type: "character varying(32)", maxLength: 32, nullable: false, comment: "使用单号。"),
|
||||
ProductName = table.Column<string>(type: "character varying(128)", maxLength: 128, nullable: false, comment: "兑换商品名称。"),
|
||||
UsedAt = table.Column<DateTime>(type: "timestamp with time zone", nullable: false, comment: "使用时间(UTC)。"),
|
||||
UsedTimes = table.Column<int>(type: "integer", nullable: false, comment: "本次使用次数。"),
|
||||
RemainingTimesAfterUse = table.Column<int>(type: "integer", nullable: false, comment: "使用后剩余次数。"),
|
||||
StatusAfterUse = table.Column<int>(type: "integer", nullable: false, comment: "本次记录状态。"),
|
||||
ExtraPayAmount = table.Column<decimal>(type: "numeric(18,2)", precision: 18, scale: 2, nullable: true, comment: "超额补差金额。"),
|
||||
CreatedAt = table.Column<DateTime>(type: "timestamp with time zone", nullable: false, comment: "创建时间(UTC)。"),
|
||||
UpdatedAt = table.Column<DateTime>(type: "timestamp with time zone", nullable: true, comment: "最近一次更新时间(UTC),从未更新时为 null。"),
|
||||
DeletedAt = table.Column<DateTime>(type: "timestamp with time zone", nullable: true, comment: "软删除时间(UTC),未删除时为 null。"),
|
||||
CreatedBy = table.Column<long>(type: "bigint", nullable: true, comment: "创建人用户标识,匿名或系统操作时为 null。"),
|
||||
UpdatedBy = table.Column<long>(type: "bigint", nullable: true, comment: "最后更新人用户标识,匿名或系统操作时为 null。"),
|
||||
DeletedBy = table.Column<long>(type: "bigint", nullable: true, comment: "删除人用户标识(软删除),未删除时为 null。"),
|
||||
TenantId = table.Column<long>(type: "bigint", nullable: false, comment: "所属租户 ID。")
|
||||
},
|
||||
constraints: table =>
|
||||
{
|
||||
table.PrimaryKey("PK_punch_card_usage_records", x => x.Id);
|
||||
},
|
||||
comment: "次卡使用记录。");
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "IX_punch_card_instances_TenantId_StoreId_InstanceNo",
|
||||
table: "punch_card_instances",
|
||||
columns: new[] { "TenantId", "StoreId", "InstanceNo" },
|
||||
unique: true);
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "IX_punch_card_instances_TenantId_StoreId_PunchCardTemplateId",
|
||||
table: "punch_card_instances",
|
||||
columns: new[] { "TenantId", "StoreId", "PunchCardTemplateId" });
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "IX_punch_card_instances_TenantId_StoreId_Status_ExpiresAt",
|
||||
table: "punch_card_instances",
|
||||
columns: new[] { "TenantId", "StoreId", "Status", "ExpiresAt" });
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "IX_punch_card_templates_TenantId_StoreId_Name",
|
||||
table: "punch_card_templates",
|
||||
columns: new[] { "TenantId", "StoreId", "Name" },
|
||||
unique: true);
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "IX_punch_card_templates_TenantId_StoreId_Status",
|
||||
table: "punch_card_templates",
|
||||
columns: new[] { "TenantId", "StoreId", "Status" });
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "IX_punch_card_usage_records_TenantId_StoreId_PunchCardInstance~",
|
||||
table: "punch_card_usage_records",
|
||||
columns: new[] { "TenantId", "StoreId", "PunchCardInstanceId", "UsedAt" });
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "IX_punch_card_usage_records_TenantId_StoreId_PunchCardTemplate~",
|
||||
table: "punch_card_usage_records",
|
||||
columns: new[] { "TenantId", "StoreId", "PunchCardTemplateId", "UsedAt" });
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "IX_punch_card_usage_records_TenantId_StoreId_RecordNo",
|
||||
table: "punch_card_usage_records",
|
||||
columns: new[] { "TenantId", "StoreId", "RecordNo" },
|
||||
unique: true);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
protected override void Down(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.DropTable(
|
||||
name: "punch_card_instances");
|
||||
|
||||
migrationBuilder.DropTable(
|
||||
name: "punch_card_templates");
|
||||
|
||||
migrationBuilder.DropTable(
|
||||
name: "punch_card_usage_records");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1012,6 +1012,363 @@ namespace TakeoutSaaS.Infrastructure.Migrations
|
||||
});
|
||||
});
|
||||
|
||||
modelBuilder.Entity("TakeoutSaaS.Domain.Coupons.Entities.PunchCardInstance", b =>
|
||||
{
|
||||
b.Property<long>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("bigint")
|
||||
.HasComment("实体唯一标识。");
|
||||
|
||||
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<long>("Id"));
|
||||
|
||||
b.Property<DateTime>("CreatedAt")
|
||||
.HasColumnType("timestamp with time zone")
|
||||
.HasComment("创建时间(UTC)。");
|
||||
|
||||
b.Property<long?>("CreatedBy")
|
||||
.HasColumnType("bigint")
|
||||
.HasComment("创建人用户标识,匿名或系统操作时为 null。");
|
||||
|
||||
b.Property<DateTime?>("DeletedAt")
|
||||
.HasColumnType("timestamp with time zone")
|
||||
.HasComment("软删除时间(UTC),未删除时为 null。");
|
||||
|
||||
b.Property<long?>("DeletedBy")
|
||||
.HasColumnType("bigint")
|
||||
.HasComment("删除人用户标识(软删除),未删除时为 null。");
|
||||
|
||||
b.Property<DateTime?>("ExpiresAt")
|
||||
.HasColumnType("timestamp with time zone")
|
||||
.HasComment("过期时间(UTC,可空)。");
|
||||
|
||||
b.Property<string>("InstanceNo")
|
||||
.IsRequired()
|
||||
.HasMaxLength(32)
|
||||
.HasColumnType("character varying(32)")
|
||||
.HasComment("实例编号(业务唯一)。");
|
||||
|
||||
b.Property<string>("MemberName")
|
||||
.IsRequired()
|
||||
.HasMaxLength(64)
|
||||
.HasColumnType("character varying(64)")
|
||||
.HasComment("会员名称。");
|
||||
|
||||
b.Property<string>("MemberPhoneMasked")
|
||||
.IsRequired()
|
||||
.HasMaxLength(32)
|
||||
.HasColumnType("character varying(32)")
|
||||
.HasComment("会员手机号(脱敏)。");
|
||||
|
||||
b.Property<decimal>("PaidAmount")
|
||||
.HasPrecision(18, 2)
|
||||
.HasColumnType("numeric(18,2)")
|
||||
.HasComment("实付金额。");
|
||||
|
||||
b.Property<long>("PunchCardTemplateId")
|
||||
.HasColumnType("bigint")
|
||||
.HasComment("次卡模板 ID。");
|
||||
|
||||
b.Property<DateTime>("PurchasedAt")
|
||||
.HasColumnType("timestamp with time zone")
|
||||
.HasComment("购买时间(UTC)。");
|
||||
|
||||
b.Property<int>("RemainingTimes")
|
||||
.HasColumnType("integer")
|
||||
.HasComment("剩余次数。");
|
||||
|
||||
b.Property<int>("Status")
|
||||
.HasColumnType("integer")
|
||||
.HasComment("实例状态。");
|
||||
|
||||
b.Property<long>("StoreId")
|
||||
.HasColumnType("bigint")
|
||||
.HasComment("门店 ID。");
|
||||
|
||||
b.Property<long>("TenantId")
|
||||
.HasColumnType("bigint")
|
||||
.HasComment("所属租户 ID。");
|
||||
|
||||
b.Property<int>("TotalTimes")
|
||||
.HasColumnType("integer")
|
||||
.HasComment("总次数。");
|
||||
|
||||
b.Property<DateTime?>("UpdatedAt")
|
||||
.HasColumnType("timestamp with time zone")
|
||||
.HasComment("最近一次更新时间(UTC),从未更新时为 null。");
|
||||
|
||||
b.Property<long?>("UpdatedBy")
|
||||
.HasColumnType("bigint")
|
||||
.HasComment("最后更新人用户标识,匿名或系统操作时为 null。");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("TenantId", "StoreId", "InstanceNo")
|
||||
.IsUnique();
|
||||
|
||||
b.HasIndex("TenantId", "StoreId", "PunchCardTemplateId");
|
||||
|
||||
b.HasIndex("TenantId", "StoreId", "Status", "ExpiresAt");
|
||||
|
||||
b.ToTable("punch_card_instances", null, t =>
|
||||
{
|
||||
t.HasComment("次卡实例(顾客购买后生成)。");
|
||||
});
|
||||
});
|
||||
|
||||
modelBuilder.Entity("TakeoutSaaS.Domain.Coupons.Entities.PunchCardTemplate", b =>
|
||||
{
|
||||
b.Property<long>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("bigint")
|
||||
.HasComment("实体唯一标识。");
|
||||
|
||||
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<long>("Id"));
|
||||
|
||||
b.Property<bool>("AllowTransfer")
|
||||
.HasColumnType("boolean")
|
||||
.HasComment("是否允许转赠。");
|
||||
|
||||
b.Property<string>("CoverImageUrl")
|
||||
.HasMaxLength(512)
|
||||
.HasColumnType("character varying(512)")
|
||||
.HasComment("封面图片地址。");
|
||||
|
||||
b.Property<DateTime>("CreatedAt")
|
||||
.HasColumnType("timestamp with time zone")
|
||||
.HasComment("创建时间(UTC)。");
|
||||
|
||||
b.Property<long?>("CreatedBy")
|
||||
.HasColumnType("bigint")
|
||||
.HasComment("创建人用户标识,匿名或系统操作时为 null。");
|
||||
|
||||
b.Property<int?>("DailyLimit")
|
||||
.HasColumnType("integer")
|
||||
.HasComment("每日限用次数。");
|
||||
|
||||
b.Property<DateTime?>("DeletedAt")
|
||||
.HasColumnType("timestamp with time zone")
|
||||
.HasComment("软删除时间(UTC),未删除时为 null。");
|
||||
|
||||
b.Property<long?>("DeletedBy")
|
||||
.HasColumnType("bigint")
|
||||
.HasComment("删除人用户标识(软删除),未删除时为 null。");
|
||||
|
||||
b.Property<string>("Description")
|
||||
.HasMaxLength(512)
|
||||
.HasColumnType("character varying(512)")
|
||||
.HasComment("次卡描述。");
|
||||
|
||||
b.Property<int>("ExpireStrategy")
|
||||
.HasColumnType("integer")
|
||||
.HasComment("过期策略。");
|
||||
|
||||
b.Property<string>("Name")
|
||||
.IsRequired()
|
||||
.HasMaxLength(64)
|
||||
.HasColumnType("character varying(64)")
|
||||
.HasComment("次卡名称。");
|
||||
|
||||
b.Property<string>("NotifyChannelsJson")
|
||||
.IsRequired()
|
||||
.HasColumnType("text")
|
||||
.HasComment("购买通知渠道 JSON。");
|
||||
|
||||
b.Property<decimal?>("OriginalPrice")
|
||||
.HasPrecision(18, 2)
|
||||
.HasColumnType("numeric(18,2)")
|
||||
.HasComment("原价。");
|
||||
|
||||
b.Property<int?>("PerOrderLimit")
|
||||
.HasColumnType("integer")
|
||||
.HasComment("每单限用次数。");
|
||||
|
||||
b.Property<int?>("PerUserPurchaseLimit")
|
||||
.HasColumnType("integer")
|
||||
.HasComment("每人限购张数。");
|
||||
|
||||
b.Property<decimal>("SalePrice")
|
||||
.HasPrecision(18, 2)
|
||||
.HasColumnType("numeric(18,2)")
|
||||
.HasComment("售价。");
|
||||
|
||||
b.Property<string>("ScopeCategoryIdsJson")
|
||||
.IsRequired()
|
||||
.HasColumnType("text")
|
||||
.HasComment("指定分类 ID JSON。");
|
||||
|
||||
b.Property<string>("ScopeProductIdsJson")
|
||||
.IsRequired()
|
||||
.HasColumnType("text")
|
||||
.HasComment("指定商品 ID JSON。");
|
||||
|
||||
b.Property<string>("ScopeTagIdsJson")
|
||||
.IsRequired()
|
||||
.HasColumnType("text")
|
||||
.HasComment("指定标签 ID JSON。");
|
||||
|
||||
b.Property<int>("ScopeType")
|
||||
.HasColumnType("integer")
|
||||
.HasComment("适用范围类型。");
|
||||
|
||||
b.Property<int>("Status")
|
||||
.HasColumnType("integer")
|
||||
.HasComment("次卡状态。");
|
||||
|
||||
b.Property<long>("StoreId")
|
||||
.HasColumnType("bigint")
|
||||
.HasComment("门店 ID。");
|
||||
|
||||
b.Property<long>("TenantId")
|
||||
.HasColumnType("bigint")
|
||||
.HasComment("所属租户 ID。");
|
||||
|
||||
b.Property<int>("TotalTimes")
|
||||
.HasColumnType("integer")
|
||||
.HasComment("总次数。");
|
||||
|
||||
b.Property<DateTime?>("UpdatedAt")
|
||||
.HasColumnType("timestamp with time zone")
|
||||
.HasComment("最近一次更新时间(UTC),从未更新时为 null。");
|
||||
|
||||
b.Property<long?>("UpdatedBy")
|
||||
.HasColumnType("bigint")
|
||||
.HasComment("最后更新人用户标识,匿名或系统操作时为 null。");
|
||||
|
||||
b.Property<decimal?>("UsageCapAmount")
|
||||
.HasPrecision(18, 2)
|
||||
.HasColumnType("numeric(18,2)")
|
||||
.HasComment("金额上限(UsageMode=Cap 时有效)。");
|
||||
|
||||
b.Property<int>("UsageMode")
|
||||
.HasColumnType("integer")
|
||||
.HasComment("使用模式。");
|
||||
|
||||
b.Property<DateTime?>("ValidFrom")
|
||||
.HasColumnType("timestamp with time zone")
|
||||
.HasComment("固定开始日期(UTC,ValidityType=DateRange 时有效)。");
|
||||
|
||||
b.Property<DateTime?>("ValidTo")
|
||||
.HasColumnType("timestamp with time zone")
|
||||
.HasComment("固定结束日期(UTC,ValidityType=DateRange 时有效)。");
|
||||
|
||||
b.Property<int?>("ValidityDays")
|
||||
.HasColumnType("integer")
|
||||
.HasComment("固定天数(ValidityType=Days 时有效)。");
|
||||
|
||||
b.Property<int>("ValidityType")
|
||||
.HasColumnType("integer")
|
||||
.HasComment("有效期类型。");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("TenantId", "StoreId", "Name")
|
||||
.IsUnique();
|
||||
|
||||
b.HasIndex("TenantId", "StoreId", "Status");
|
||||
|
||||
b.ToTable("punch_card_templates", null, t =>
|
||||
{
|
||||
t.HasComment("次卡模板配置。");
|
||||
});
|
||||
});
|
||||
|
||||
modelBuilder.Entity("TakeoutSaaS.Domain.Coupons.Entities.PunchCardUsageRecord", b =>
|
||||
{
|
||||
b.Property<long>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("bigint")
|
||||
.HasComment("实体唯一标识。");
|
||||
|
||||
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<long>("Id"));
|
||||
|
||||
b.Property<DateTime>("CreatedAt")
|
||||
.HasColumnType("timestamp with time zone")
|
||||
.HasComment("创建时间(UTC)。");
|
||||
|
||||
b.Property<long?>("CreatedBy")
|
||||
.HasColumnType("bigint")
|
||||
.HasComment("创建人用户标识,匿名或系统操作时为 null。");
|
||||
|
||||
b.Property<DateTime?>("DeletedAt")
|
||||
.HasColumnType("timestamp with time zone")
|
||||
.HasComment("软删除时间(UTC),未删除时为 null。");
|
||||
|
||||
b.Property<long?>("DeletedBy")
|
||||
.HasColumnType("bigint")
|
||||
.HasComment("删除人用户标识(软删除),未删除时为 null。");
|
||||
|
||||
b.Property<decimal?>("ExtraPayAmount")
|
||||
.HasPrecision(18, 2)
|
||||
.HasColumnType("numeric(18,2)")
|
||||
.HasComment("超额补差金额。");
|
||||
|
||||
b.Property<string>("ProductName")
|
||||
.IsRequired()
|
||||
.HasMaxLength(128)
|
||||
.HasColumnType("character varying(128)")
|
||||
.HasComment("兑换商品名称。");
|
||||
|
||||
b.Property<long>("PunchCardInstanceId")
|
||||
.HasColumnType("bigint")
|
||||
.HasComment("次卡实例 ID。");
|
||||
|
||||
b.Property<long>("PunchCardTemplateId")
|
||||
.HasColumnType("bigint")
|
||||
.HasComment("次卡模板 ID。");
|
||||
|
||||
b.Property<string>("RecordNo")
|
||||
.IsRequired()
|
||||
.HasMaxLength(32)
|
||||
.HasColumnType("character varying(32)")
|
||||
.HasComment("使用单号。");
|
||||
|
||||
b.Property<int>("RemainingTimesAfterUse")
|
||||
.HasColumnType("integer")
|
||||
.HasComment("使用后剩余次数。");
|
||||
|
||||
b.Property<int>("StatusAfterUse")
|
||||
.HasColumnType("integer")
|
||||
.HasComment("本次记录状态。");
|
||||
|
||||
b.Property<long>("StoreId")
|
||||
.HasColumnType("bigint")
|
||||
.HasComment("门店 ID。");
|
||||
|
||||
b.Property<long>("TenantId")
|
||||
.HasColumnType("bigint")
|
||||
.HasComment("所属租户 ID。");
|
||||
|
||||
b.Property<DateTime?>("UpdatedAt")
|
||||
.HasColumnType("timestamp with time zone")
|
||||
.HasComment("最近一次更新时间(UTC),从未更新时为 null。");
|
||||
|
||||
b.Property<long?>("UpdatedBy")
|
||||
.HasColumnType("bigint")
|
||||
.HasComment("最后更新人用户标识,匿名或系统操作时为 null。");
|
||||
|
||||
b.Property<DateTime>("UsedAt")
|
||||
.HasColumnType("timestamp with time zone")
|
||||
.HasComment("使用时间(UTC)。");
|
||||
|
||||
b.Property<int>("UsedTimes")
|
||||
.HasColumnType("integer")
|
||||
.HasComment("本次使用次数。");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("TenantId", "StoreId", "RecordNo")
|
||||
.IsUnique();
|
||||
|
||||
b.HasIndex("TenantId", "StoreId", "PunchCardInstanceId", "UsedAt");
|
||||
|
||||
b.HasIndex("TenantId", "StoreId", "PunchCardTemplateId", "UsedAt");
|
||||
|
||||
b.ToTable("punch_card_usage_records", null, t =>
|
||||
{
|
||||
t.HasComment("次卡使用记录。");
|
||||
});
|
||||
});
|
||||
|
||||
modelBuilder.Entity("TakeoutSaaS.Domain.CustomerService.Entities.ChatMessage", b =>
|
||||
{
|
||||
b.Property<long>("Id")
|
||||
|
||||
Reference in New Issue
Block a user