Compare commits

...

3 Commits

Author SHA1 Message Date
decfa4fa12 fix: resolve ProductBatchToolController xml doc warnings
All checks were successful
Build and Deploy TenantApi + SkuWorker / build-and-deploy (push) Successful in 2m6s
2026-03-03 08:38:42 +08:00
3b3bdcee71 feat: implement marketing punch card backend module 2026-03-02 21:43:09 +08:00
6588c85f27 feat(marketing): add new customer gift backend module 2026-03-02 15:58:06 +08:00
83 changed files with 27441 additions and 1 deletions

View File

@@ -0,0 +1,489 @@
namespace TakeoutSaaS.TenantApi.Contracts.Marketing;
/// <summary>
/// 新客有礼详情请求。
/// </summary>
public sealed class NewCustomerDetailRequest
{
/// <summary>
/// 操作门店 ID。
/// </summary>
public string StoreId { get; set; } = string.Empty;
/// <summary>
/// 邀请记录页码。
/// </summary>
public int RecordPage { get; set; } = 1;
/// <summary>
/// 邀请记录每页条数。
/// </summary>
public int RecordPageSize { get; set; } = 10;
}
/// <summary>
/// 新客有礼配置保存请求。
/// </summary>
public sealed class SaveNewCustomerSettingsRequest
{
/// <summary>
/// 操作门店 ID。
/// </summary>
public string StoreId { get; set; } = string.Empty;
/// <summary>
/// 是否开启新客礼包。
/// </summary>
public bool GiftEnabled { get; set; }
/// <summary>
/// 礼包类型coupon/direct
/// </summary>
public string GiftType { get; set; } = "coupon";
/// <summary>
/// 首单直减金额。
/// </summary>
public decimal? DirectReduceAmount { get; set; }
/// <summary>
/// 首单直减门槛金额。
/// </summary>
public decimal? DirectMinimumSpend { get; set; }
/// <summary>
/// 是否开启老带新分享。
/// </summary>
public bool InviteEnabled { get; set; }
/// <summary>
/// 分享渠道wechat_friend/moments/sms
/// </summary>
public List<string> ShareChannels { get; set; } = [];
/// <summary>
/// 新客礼包券列表。
/// </summary>
public List<NewCustomerSaveCouponRuleRequest> WelcomeCoupons { get; set; } = [];
/// <summary>
/// 邀请人奖励券列表。
/// </summary>
public List<NewCustomerSaveCouponRuleRequest> InviterCoupons { get; set; } = [];
/// <summary>
/// 被邀请人奖励券列表。
/// </summary>
public List<NewCustomerSaveCouponRuleRequest> InviteeCoupons { get; set; } = [];
}
/// <summary>
/// 新客邀请记录分页请求。
/// </summary>
public sealed class NewCustomerInviteRecordListRequest
{
/// <summary>
/// 操作门店 ID。
/// </summary>
public string StoreId { get; set; } = string.Empty;
/// <summary>
/// 页码。
/// </summary>
public int Page { get; set; } = 1;
/// <summary>
/// 每页条数。
/// </summary>
public int PageSize { get; set; } = 10;
}
/// <summary>
/// 写入新客邀请记录请求。
/// </summary>
public sealed class WriteNewCustomerInviteRecordRequest
{
/// <summary>
/// 操作门店 ID。
/// </summary>
public string StoreId { get; set; } = string.Empty;
/// <summary>
/// 邀请人展示名。
/// </summary>
public string InviterName { get; set; } = string.Empty;
/// <summary>
/// 被邀请人展示名。
/// </summary>
public string InviteeName { get; set; } = string.Empty;
/// <summary>
/// 邀请时间。
/// </summary>
public DateTime InviteTime { get; set; }
/// <summary>
/// 订单状态pending_order/ordered
/// </summary>
public string OrderStatus { get; set; } = "pending_order";
/// <summary>
/// 奖励状态pending/issued
/// </summary>
public string RewardStatus { get; set; } = "pending";
/// <summary>
/// 奖励发放时间。
/// </summary>
public DateTime? RewardIssuedAt { get; set; }
/// <summary>
/// 来源渠道。
/// </summary>
public string? SourceChannel { get; set; }
}
/// <summary>
/// 写入新客成长记录请求。
/// </summary>
public sealed class WriteNewCustomerGrowthRecordRequest
{
/// <summary>
/// 操作门店 ID。
/// </summary>
public string StoreId { get; set; } = string.Empty;
/// <summary>
/// 顾客业务唯一键。
/// </summary>
public string CustomerKey { get; set; } = string.Empty;
/// <summary>
/// 顾客展示名。
/// </summary>
public string? CustomerName { get; set; }
/// <summary>
/// 注册时间。
/// </summary>
public DateTime RegisteredAt { get; set; }
/// <summary>
/// 礼包领取时间。
/// </summary>
public DateTime? GiftClaimedAt { get; set; }
/// <summary>
/// 首单时间。
/// </summary>
public DateTime? FirstOrderAt { get; set; }
/// <summary>
/// 来源渠道。
/// </summary>
public string? SourceChannel { get; set; }
}
/// <summary>
/// 保存优惠券规则请求项。
/// </summary>
public sealed class NewCustomerSaveCouponRuleRequest
{
/// <summary>
/// 券类型amount_off/discount/free_shipping
/// </summary>
public string CouponType { get; set; } = "amount_off";
/// <summary>
/// 面值或折扣值。
/// </summary>
public decimal? Value { get; set; }
/// <summary>
/// 使用门槛金额。
/// </summary>
public decimal? MinimumSpend { get; set; }
/// <summary>
/// 有效期天数。
/// </summary>
public int ValidDays { get; set; }
}
/// <summary>
/// 新客有礼详情响应。
/// </summary>
public sealed class NewCustomerDetailResponse
{
/// <summary>
/// 配置详情。
/// </summary>
public NewCustomerSettingsResponse Settings { get; set; } = new();
/// <summary>
/// 统计数据。
/// </summary>
public NewCustomerStatsResponse Stats { get; set; } = new();
/// <summary>
/// 邀请记录分页。
/// </summary>
public NewCustomerInviteRecordListResultResponse InviteRecords { get; set; } = new();
}
/// <summary>
/// 新客有礼配置响应。
/// </summary>
public sealed class NewCustomerSettingsResponse
{
/// <summary>
/// 门店 ID。
/// </summary>
public string StoreId { get; set; } = string.Empty;
/// <summary>
/// 是否开启新客礼包。
/// </summary>
public bool GiftEnabled { get; set; }
/// <summary>
/// 礼包类型coupon/direct
/// </summary>
public string GiftType { get; set; } = "coupon";
/// <summary>
/// 首单直减金额。
/// </summary>
public decimal? DirectReduceAmount { get; set; }
/// <summary>
/// 首单直减门槛金额。
/// </summary>
public decimal? DirectMinimumSpend { get; set; }
/// <summary>
/// 是否开启老带新分享。
/// </summary>
public bool InviteEnabled { get; set; }
/// <summary>
/// 分享渠道wechat_friend/moments/sms
/// </summary>
public List<string> ShareChannels { get; set; } = [];
/// <summary>
/// 新客礼包券列表。
/// </summary>
public List<NewCustomerCouponRuleResponse> WelcomeCoupons { get; set; } = [];
/// <summary>
/// 邀请人奖励券列表。
/// </summary>
public List<NewCustomerCouponRuleResponse> InviterCoupons { get; set; } = [];
/// <summary>
/// 被邀请人奖励券列表。
/// </summary>
public List<NewCustomerCouponRuleResponse> InviteeCoupons { get; set; } = [];
/// <summary>
/// 更新时间yyyy-MM-dd HH:mm:ss
/// </summary>
public string UpdatedAt { get; set; } = string.Empty;
}
/// <summary>
/// 新客有礼统计响应。
/// </summary>
public sealed class NewCustomerStatsResponse
{
/// <summary>
/// 本月新客数。
/// </summary>
public int MonthlyNewCustomers { get; set; }
/// <summary>
/// 较上月增长人数。
/// </summary>
public int MonthlyGrowthCount { get; set; }
/// <summary>
/// 较上月增长百分比。
/// </summary>
public decimal MonthlyGrowthRatePercent { get; set; }
/// <summary>
/// 本月礼包领取率(百分比)。
/// </summary>
public decimal GiftClaimRate { get; set; }
/// <summary>
/// 本月礼包已领取人数。
/// </summary>
public int GiftClaimedCount { get; set; }
/// <summary>
/// 本月首单转化率(百分比)。
/// </summary>
public decimal FirstOrderConversionRate { get; set; }
/// <summary>
/// 本月首单完成人数。
/// </summary>
public int FirstOrderedCount { get; set; }
}
/// <summary>
/// 邀请记录分页结果响应。
/// </summary>
public sealed class NewCustomerInviteRecordListResultResponse
{
/// <summary>
/// 列表项。
/// </summary>
public List<NewCustomerInviteRecordResponse> 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 sealed class NewCustomerInviteRecordResponse
{
/// <summary>
/// 记录 ID。
/// </summary>
public string Id { get; set; } = string.Empty;
/// <summary>
/// 邀请人展示名。
/// </summary>
public string InviterName { get; set; } = string.Empty;
/// <summary>
/// 被邀请人展示名。
/// </summary>
public string InviteeName { get; set; } = string.Empty;
/// <summary>
/// 邀请时间yyyy-MM-dd HH:mm:ss
/// </summary>
public string InviteTime { get; set; } = string.Empty;
/// <summary>
/// 订单状态pending_order/ordered
/// </summary>
public string OrderStatus { get; set; } = "pending_order";
/// <summary>
/// 奖励状态pending/issued
/// </summary>
public string RewardStatus { get; set; } = "pending";
/// <summary>
/// 奖励发放时间yyyy-MM-dd HH:mm:ss
/// </summary>
public string? RewardIssuedAt { get; set; }
/// <summary>
/// 来源渠道。
/// </summary>
public string? SourceChannel { get; set; }
}
/// <summary>
/// 新客成长记录响应。
/// </summary>
public sealed class NewCustomerGrowthRecordResponse
{
/// <summary>
/// 记录 ID。
/// </summary>
public string Id { get; set; } = string.Empty;
/// <summary>
/// 顾客业务唯一键。
/// </summary>
public string CustomerKey { get; set; } = string.Empty;
/// <summary>
/// 顾客展示名。
/// </summary>
public string? CustomerName { get; set; }
/// <summary>
/// 注册时间yyyy-MM-dd HH:mm:ss
/// </summary>
public string RegisteredAt { get; set; } = string.Empty;
/// <summary>
/// 礼包领取时间yyyy-MM-dd HH:mm:ss
/// </summary>
public string? GiftClaimedAt { get; set; }
/// <summary>
/// 首单时间yyyy-MM-dd HH:mm:ss
/// </summary>
public string? FirstOrderAt { get; set; }
/// <summary>
/// 来源渠道。
/// </summary>
public string? SourceChannel { get; set; }
}
/// <summary>
/// 新客券规则响应。
/// </summary>
public sealed class NewCustomerCouponRuleResponse
{
/// <summary>
/// 规则 ID。
/// </summary>
public string Id { get; set; } = string.Empty;
/// <summary>
/// 场景welcome/inviter/invitee
/// </summary>
public string Scene { get; set; } = "welcome";
/// <summary>
/// 券类型amount_off/discount/free_shipping
/// </summary>
public string CouponType { get; set; } = "amount_off";
/// <summary>
/// 面值或折扣值。
/// </summary>
public decimal? Value { get; set; }
/// <summary>
/// 使用门槛金额。
/// </summary>
public decimal? MinimumSpend { get; set; }
/// <summary>
/// 有效期天数。
/// </summary>
public int ValidDays { get; set; }
/// <summary>
/// 排序值。
/// </summary>
public int SortOrder { get; set; }
}

View File

@@ -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; }
}

View File

@@ -0,0 +1,297 @@
using System.Globalization;
using MediatR;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
using TakeoutSaaS.Application.App.Coupons.NewCustomer.Commands;
using TakeoutSaaS.Application.App.Coupons.NewCustomer.Dto;
using TakeoutSaaS.Application.App.Coupons.NewCustomer.Queries;
using TakeoutSaaS.Application.App.Stores.Services;
using TakeoutSaaS.Infrastructure.App.Persistence;
using TakeoutSaaS.Module.Authorization.Attributes;
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/new-customer")]
public sealed class MarketingNewCustomerController(
IMediator mediator,
TakeoutAppDbContext dbContext,
StoreContextService storeContextService)
: BaseApiController
{
private const string ViewPermission = "tenant:marketing:new-customer:view";
private const string ManagePermission = "tenant:marketing:new-customer:manage";
/// <summary>
/// 获取新客有礼详情。
/// </summary>
[HttpGet("detail")]
[PermissionAuthorize(ViewPermission, ManagePermission)]
[ProducesResponseType(typeof(ApiResponse<NewCustomerDetailResponse>), StatusCodes.Status200OK)]
public async Task<ApiResponse<NewCustomerDetailResponse>> Detail(
[FromQuery] NewCustomerDetailRequest request,
CancellationToken cancellationToken)
{
// 1. 解析并校验门店权限
var storeId = StoreApiHelpers.ParseRequiredSnowflake(request.StoreId, nameof(request.StoreId));
await EnsureStoreAccessibleAsync(storeId, cancellationToken);
// 2. 查询应用层详情
var result = await mediator.Send(new GetNewCustomerDetailQuery
{
StoreId = storeId,
RecordPage = request.RecordPage,
RecordPageSize = request.RecordPageSize
}, cancellationToken);
// 3. 返回响应
return ApiResponse<NewCustomerDetailResponse>.Ok(MapDetail(result));
}
/// <summary>
/// 保存新客有礼配置。
/// </summary>
[HttpPost("save")]
[PermissionAuthorize(ManagePermission)]
[ProducesResponseType(typeof(ApiResponse<NewCustomerSettingsResponse>), StatusCodes.Status200OK)]
public async Task<ApiResponse<NewCustomerSettingsResponse>> Save(
[FromBody] SaveNewCustomerSettingsRequest request,
CancellationToken cancellationToken)
{
// 1. 解析并校验门店权限
var storeId = StoreApiHelpers.ParseRequiredSnowflake(request.StoreId, nameof(request.StoreId));
await EnsureStoreAccessibleAsync(storeId, cancellationToken);
// 2. 调用应用层保存
var result = await mediator.Send(new SaveNewCustomerSettingsCommand
{
StoreId = storeId,
GiftEnabled = request.GiftEnabled,
GiftType = request.GiftType,
DirectReduceAmount = request.DirectReduceAmount,
DirectMinimumSpend = request.DirectMinimumSpend,
InviteEnabled = request.InviteEnabled,
ShareChannels = request.ShareChannels,
WelcomeCoupons = request.WelcomeCoupons.Select(MapSaveCouponRule).ToList(),
InviterCoupons = request.InviterCoupons.Select(MapSaveCouponRule).ToList(),
InviteeCoupons = request.InviteeCoupons.Select(MapSaveCouponRule).ToList()
}, cancellationToken);
// 3. 返回响应
return ApiResponse<NewCustomerSettingsResponse>.Ok(MapSettings(result));
}
/// <summary>
/// 获取新客邀请记录分页。
/// </summary>
[HttpGet("invite-record/list")]
[PermissionAuthorize(ViewPermission, ManagePermission)]
[ProducesResponseType(typeof(ApiResponse<NewCustomerInviteRecordListResultResponse>), StatusCodes.Status200OK)]
public async Task<ApiResponse<NewCustomerInviteRecordListResultResponse>> InviteRecordList(
[FromQuery] NewCustomerInviteRecordListRequest request,
CancellationToken cancellationToken)
{
// 1. 解析并校验门店权限
var storeId = StoreApiHelpers.ParseRequiredSnowflake(request.StoreId, nameof(request.StoreId));
await EnsureStoreAccessibleAsync(storeId, cancellationToken);
// 2. 查询应用层分页
var result = await mediator.Send(new GetNewCustomerInviteRecordListQuery
{
StoreId = storeId,
Page = request.Page,
PageSize = request.PageSize
}, cancellationToken);
// 3. 返回响应
return ApiResponse<NewCustomerInviteRecordListResultResponse>.Ok(MapInviteRecordList(result));
}
/// <summary>
/// 写入新客邀请记录。
/// </summary>
[HttpPost("invite-record/write")]
[PermissionAuthorize(ManagePermission)]
[ProducesResponseType(typeof(ApiResponse<NewCustomerInviteRecordResponse>), StatusCodes.Status200OK)]
public async Task<ApiResponse<NewCustomerInviteRecordResponse>> WriteInviteRecord(
[FromBody] WriteNewCustomerInviteRecordRequest request,
CancellationToken cancellationToken)
{
// 1. 解析并校验门店权限
var storeId = StoreApiHelpers.ParseRequiredSnowflake(request.StoreId, nameof(request.StoreId));
await EnsureStoreAccessibleAsync(storeId, cancellationToken);
// 2. 调用应用层写入
var result = await mediator.Send(new WriteNewCustomerInviteRecordCommand
{
StoreId = storeId,
InviterName = request.InviterName,
InviteeName = request.InviteeName,
InviteTime = request.InviteTime,
OrderStatus = request.OrderStatus,
RewardStatus = request.RewardStatus,
RewardIssuedAt = request.RewardIssuedAt,
SourceChannel = request.SourceChannel
}, cancellationToken);
// 3. 返回响应
return ApiResponse<NewCustomerInviteRecordResponse>.Ok(MapInviteRecord(result));
}
/// <summary>
/// 写入新客成长记录。
/// </summary>
[HttpPost("growth-record/write")]
[PermissionAuthorize(ManagePermission)]
[ProducesResponseType(typeof(ApiResponse<NewCustomerGrowthRecordResponse>), StatusCodes.Status200OK)]
public async Task<ApiResponse<NewCustomerGrowthRecordResponse>> WriteGrowthRecord(
[FromBody] WriteNewCustomerGrowthRecordRequest request,
CancellationToken cancellationToken)
{
// 1. 解析并校验门店权限
var storeId = StoreApiHelpers.ParseRequiredSnowflake(request.StoreId, nameof(request.StoreId));
await EnsureStoreAccessibleAsync(storeId, cancellationToken);
// 2. 调用应用层写入
var result = await mediator.Send(new WriteNewCustomerGrowthRecordCommand
{
StoreId = storeId,
CustomerKey = request.CustomerKey,
CustomerName = request.CustomerName,
RegisteredAt = request.RegisteredAt,
GiftClaimedAt = request.GiftClaimedAt,
FirstOrderAt = request.FirstOrderAt,
SourceChannel = request.SourceChannel
}, cancellationToken);
// 3. 返回响应
return ApiResponse<NewCustomerGrowthRecordResponse>.Ok(MapGrowthRecord(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 NewCustomerSaveCouponRuleInputDto MapSaveCouponRule(NewCustomerSaveCouponRuleRequest source)
{
return new NewCustomerSaveCouponRuleInputDto
{
CouponType = source.CouponType,
Value = source.Value,
MinimumSpend = source.MinimumSpend,
ValidDays = source.ValidDays
};
}
private static NewCustomerDetailResponse MapDetail(NewCustomerDetailDto source)
{
return new NewCustomerDetailResponse
{
Settings = MapSettings(source.Settings),
Stats = new NewCustomerStatsResponse
{
MonthlyNewCustomers = source.Stats.MonthlyNewCustomers,
MonthlyGrowthCount = source.Stats.MonthlyGrowthCount,
MonthlyGrowthRatePercent = source.Stats.MonthlyGrowthRatePercent,
GiftClaimRate = source.Stats.GiftClaimRate,
GiftClaimedCount = source.Stats.GiftClaimedCount,
FirstOrderConversionRate = source.Stats.FirstOrderConversionRate,
FirstOrderedCount = source.Stats.FirstOrderedCount
},
InviteRecords = MapInviteRecordList(source.InviteRecords)
};
}
private static NewCustomerSettingsResponse MapSettings(NewCustomerSettingsDto source)
{
return new NewCustomerSettingsResponse
{
StoreId = source.StoreId.ToString(),
GiftEnabled = source.GiftEnabled,
GiftType = source.GiftType,
DirectReduceAmount = source.DirectReduceAmount,
DirectMinimumSpend = source.DirectMinimumSpend,
InviteEnabled = source.InviteEnabled,
ShareChannels = source.ShareChannels.ToList(),
WelcomeCoupons = source.WelcomeCoupons.Select(MapCouponRule).ToList(),
InviterCoupons = source.InviterCoupons.Select(MapCouponRule).ToList(),
InviteeCoupons = source.InviteeCoupons.Select(MapCouponRule).ToList(),
UpdatedAt = ToDateTime(source.UpdatedAt)
};
}
private static NewCustomerCouponRuleResponse MapCouponRule(NewCustomerCouponRuleDto source)
{
return new NewCustomerCouponRuleResponse
{
Id = source.Id.ToString(),
Scene = source.Scene,
CouponType = source.CouponType,
Value = source.Value,
MinimumSpend = source.MinimumSpend,
ValidDays = source.ValidDays,
SortOrder = source.SortOrder
};
}
private static NewCustomerInviteRecordListResultResponse MapInviteRecordList(
NewCustomerInviteRecordListResultDto source)
{
return new NewCustomerInviteRecordListResultResponse
{
Items = source.Items.Select(MapInviteRecord).ToList(),
Page = source.Page,
PageSize = source.PageSize,
TotalCount = source.TotalCount
};
}
private static NewCustomerInviteRecordResponse MapInviteRecord(NewCustomerInviteRecordDto source)
{
return new NewCustomerInviteRecordResponse
{
Id = source.Id.ToString(),
InviterName = source.InviterName,
InviteeName = source.InviteeName,
InviteTime = ToDateTime(source.InviteTime),
OrderStatus = source.OrderStatus,
RewardStatus = source.RewardStatus,
RewardIssuedAt = source.RewardIssuedAt.HasValue
? ToDateTime(source.RewardIssuedAt.Value)
: null,
SourceChannel = source.SourceChannel
};
}
private static NewCustomerGrowthRecordResponse MapGrowthRecord(NewCustomerGrowthRecordDto source)
{
return new NewCustomerGrowthRecordResponse
{
Id = source.Id.ToString(),
CustomerKey = source.CustomerKey,
CustomerName = source.CustomerName,
RegisteredAt = ToDateTime(source.RegisteredAt),
GiftClaimedAt = source.GiftClaimedAt.HasValue
? ToDateTime(source.GiftClaimedAt.Value)
: null,
FirstOrderAt = source.FirstOrderAt.HasValue
? ToDateTime(source.FirstOrderAt.Value)
: null,
SourceChannel = source.SourceChannel
};
}
private static string ToDateTime(DateTime value)
{
return value.ToString("yyyy-MM-dd HH:mm:ss", CultureInfo.InvariantCulture);
}
}

View File

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

View File

@@ -16,6 +16,12 @@ using TakeoutSaaS.TenantApi.Contracts.Product;
namespace TakeoutSaaS.TenantApi.Controllers;
/// <summary>
/// 提供商品批量工具能力,包括批量调价、上下架、移类、跨店同步、导入与导出。
/// </summary>
/// <param name="dbContext">应用数据库上下文。</param>
/// <param name="storeContextService">门店上下文服务。</param>
/// <param name="idGenerator">雪花 ID 生成器。</param>
[ApiVersion("1.0")]
[Authorize]
[Route("api/tenant/v{version:apiVersion}/product/batch")]
@@ -39,6 +45,12 @@ public sealed class ProductBatchToolController(
"状态"
];
/// <summary>
/// 预览批量调价结果。
/// </summary>
/// <param name="request">调价预览请求。</param>
/// <param name="cancellationToken">取消令牌。</param>
/// <returns>调价预览结果。</returns>
[HttpPost("price-adjust/preview")]
[ProducesResponseType(typeof(ApiResponse<BatchPricePreviewResponse>), StatusCodes.Status200OK)]
public async Task<ApiResponse<BatchPricePreviewResponse>> PreviewPriceAdjust(
@@ -81,6 +93,12 @@ public sealed class ProductBatchToolController(
});
}
/// <summary>
/// 执行批量调价。
/// </summary>
/// <param name="request">调价请求。</param>
/// <param name="cancellationToken">取消令牌。</param>
/// <returns>批量工具执行结果。</returns>
[HttpPost("price-adjust")]
[ProducesResponseType(typeof(ApiResponse<BatchToolResultResponse>), StatusCodes.Status200OK)]
public async Task<ApiResponse<BatchToolResultResponse>> PriceAdjust(
@@ -123,6 +141,12 @@ public sealed class ProductBatchToolController(
});
}
/// <summary>
/// 批量切换商品上下架状态。
/// </summary>
/// <param name="request">上下架切换请求。</param>
/// <param name="cancellationToken">取消令牌。</param>
/// <returns>批量工具执行结果。</returns>
[HttpPost("sale-switch")]
[ProducesResponseType(typeof(ApiResponse<BatchToolResultResponse>), StatusCodes.Status200OK)]
public async Task<ApiResponse<BatchToolResultResponse>> SaleSwitch(
@@ -186,6 +210,12 @@ public sealed class ProductBatchToolController(
});
}
/// <summary>
/// 批量移动商品分类。
/// </summary>
/// <param name="request">移类请求。</param>
/// <param name="cancellationToken">取消令牌。</param>
/// <returns>批量工具执行结果。</returns>
[HttpPost("move-category")]
[ProducesResponseType(typeof(ApiResponse<BatchToolResultResponse>), StatusCodes.Status200OK)]
public async Task<ApiResponse<BatchToolResultResponse>> MoveCategory(
@@ -256,6 +286,12 @@ public sealed class ProductBatchToolController(
});
}
/// <summary>
/// 将源门店商品批量同步到目标门店。
/// </summary>
/// <param name="request">跨店同步请求。</param>
/// <param name="cancellationToken">取消令牌。</param>
/// <returns>批量工具执行结果。</returns>
[HttpPost("store-sync")]
[ProducesResponseType(typeof(ApiResponse<BatchToolResultResponse>), StatusCodes.Status200OK)]
public async Task<ApiResponse<BatchToolResultResponse>> SyncStore(
@@ -479,6 +515,12 @@ public sealed class ProductBatchToolController(
});
}
/// <summary>
/// 下载商品批量导入模板。
/// </summary>
/// <param name="storeId">门店 ID。</param>
/// <param name="cancellationToken">取消令牌。</param>
/// <returns>导入模板文件。</returns>
[HttpGet("import/template")]
[ProducesResponseType(typeof(ApiResponse<BatchExcelFileResponse>), StatusCodes.Status200OK)]
public async Task<ApiResponse<BatchExcelFileResponse>> DownloadImportTemplate(
@@ -519,6 +561,12 @@ public sealed class ProductBatchToolController(
});
}
/// <summary>
/// 批量导入商品。
/// </summary>
/// <param name="request">导入请求。</param>
/// <param name="cancellationToken">取消令牌。</param>
/// <returns>导入结果。</returns>
[HttpPost("import")]
[Consumes("multipart/form-data")]
[ProducesResponseType(typeof(ApiResponse<BatchImportResultResponse>), StatusCodes.Status200OK)]
@@ -858,6 +906,12 @@ public sealed class ProductBatchToolController(
});
}
/// <summary>
/// 按范围导出商品 Excel。
/// </summary>
/// <param name="request">导出请求。</param>
/// <param name="cancellationToken">取消令牌。</param>
/// <returns>导出文件。</returns>
[HttpPost("export")]
[ProducesResponseType(typeof(ApiResponse<BatchExcelFileResponse>), StatusCodes.Status200OK)]
public async Task<ApiResponse<BatchExcelFileResponse>> Export(

View File

@@ -0,0 +1,60 @@
using MediatR;
using TakeoutSaaS.Application.App.Coupons.NewCustomer.Dto;
namespace TakeoutSaaS.Application.App.Coupons.NewCustomer.Commands;
/// <summary>
/// 保存新客有礼配置。
/// </summary>
public sealed class SaveNewCustomerSettingsCommand : IRequest<NewCustomerSettingsDto>
{
/// <summary>
/// 门店 ID。
/// </summary>
public long StoreId { get; init; }
/// <summary>
/// 是否开启新客礼包。
/// </summary>
public bool GiftEnabled { get; init; }
/// <summary>
/// 礼包类型coupon/direct
/// </summary>
public string GiftType { get; init; } = "coupon";
/// <summary>
/// 首单直减金额。
/// </summary>
public decimal? DirectReduceAmount { get; init; }
/// <summary>
/// 首单直减门槛金额。
/// </summary>
public decimal? DirectMinimumSpend { get; init; }
/// <summary>
/// 是否开启邀请分享。
/// </summary>
public bool InviteEnabled { get; init; }
/// <summary>
/// 分享渠道。
/// </summary>
public IReadOnlyCollection<string> ShareChannels { get; init; } = [];
/// <summary>
/// 新客礼包券。
/// </summary>
public IReadOnlyCollection<NewCustomerSaveCouponRuleInputDto> WelcomeCoupons { get; init; } = [];
/// <summary>
/// 邀请人奖励券。
/// </summary>
public IReadOnlyCollection<NewCustomerSaveCouponRuleInputDto> InviterCoupons { get; init; } = [];
/// <summary>
/// 被邀请人奖励券。
/// </summary>
public IReadOnlyCollection<NewCustomerSaveCouponRuleInputDto> InviteeCoupons { get; init; } = [];
}

View File

@@ -0,0 +1,45 @@
using MediatR;
using TakeoutSaaS.Application.App.Coupons.NewCustomer.Dto;
namespace TakeoutSaaS.Application.App.Coupons.NewCustomer.Commands;
/// <summary>
/// 写入新客成长记录。
/// </summary>
public sealed class WriteNewCustomerGrowthRecordCommand : IRequest<NewCustomerGrowthRecordDto>
{
/// <summary>
/// 门店 ID。
/// </summary>
public long StoreId { get; init; }
/// <summary>
/// 顾客业务唯一键。
/// </summary>
public string CustomerKey { get; init; } = string.Empty;
/// <summary>
/// 顾客展示名。
/// </summary>
public string? CustomerName { get; init; }
/// <summary>
/// 注册时间。
/// </summary>
public DateTime RegisteredAt { get; init; }
/// <summary>
/// 礼包领取时间。
/// </summary>
public DateTime? GiftClaimedAt { get; init; }
/// <summary>
/// 首单时间。
/// </summary>
public DateTime? FirstOrderAt { get; init; }
/// <summary>
/// 来源渠道。
/// </summary>
public string? SourceChannel { get; init; }
}

View File

@@ -0,0 +1,50 @@
using MediatR;
using TakeoutSaaS.Application.App.Coupons.NewCustomer.Dto;
namespace TakeoutSaaS.Application.App.Coupons.NewCustomer.Commands;
/// <summary>
/// 写入新客邀请记录。
/// </summary>
public sealed class WriteNewCustomerInviteRecordCommand : IRequest<NewCustomerInviteRecordDto>
{
/// <summary>
/// 门店 ID。
/// </summary>
public long StoreId { get; init; }
/// <summary>
/// 邀请人展示名。
/// </summary>
public string InviterName { get; init; } = string.Empty;
/// <summary>
/// 被邀请人展示名。
/// </summary>
public string InviteeName { get; init; } = string.Empty;
/// <summary>
/// 邀请时间。
/// </summary>
public DateTime InviteTime { get; init; }
/// <summary>
/// 订单状态pending_order/ordered
/// </summary>
public string OrderStatus { get; init; } = "pending_order";
/// <summary>
/// 奖励状态pending/issued
/// </summary>
public string RewardStatus { get; init; } = "pending";
/// <summary>
/// 奖励发放时间。
/// </summary>
public DateTime? RewardIssuedAt { get; init; }
/// <summary>
/// 来源渠道。
/// </summary>
public string? SourceChannel { get; init; }
}

View File

@@ -0,0 +1,42 @@
namespace TakeoutSaaS.Application.App.Coupons.NewCustomer.Dto;
/// <summary>
/// 新客有礼券规则 DTO。
/// </summary>
public sealed class NewCustomerCouponRuleDto
{
/// <summary>
/// 规则 ID。
/// </summary>
public long Id { get; init; }
/// <summary>
/// 场景welcome/inviter/invitee
/// </summary>
public string Scene { get; init; } = "welcome";
/// <summary>
/// 券类型amount_off/discount/free_shipping
/// </summary>
public string CouponType { get; init; } = "amount_off";
/// <summary>
/// 面值或折扣值。
/// </summary>
public decimal? Value { get; init; }
/// <summary>
/// 使用门槛。
/// </summary>
public decimal? MinimumSpend { get; init; }
/// <summary>
/// 有效期天数。
/// </summary>
public int ValidDays { get; init; }
/// <summary>
/// 排序值。
/// </summary>
public int SortOrder { get; init; }
}

View File

@@ -0,0 +1,22 @@
namespace TakeoutSaaS.Application.App.Coupons.NewCustomer.Dto;
/// <summary>
/// 新客有礼详情 DTO。
/// </summary>
public sealed class NewCustomerDetailDto
{
/// <summary>
/// 配置详情。
/// </summary>
public NewCustomerSettingsDto Settings { get; init; } = new();
/// <summary>
/// 统计数据。
/// </summary>
public NewCustomerStatsDto Stats { get; init; } = new();
/// <summary>
/// 邀请记录分页结果。
/// </summary>
public NewCustomerInviteRecordListResultDto InviteRecords { get; init; } = new();
}

View File

@@ -0,0 +1,42 @@
namespace TakeoutSaaS.Application.App.Coupons.NewCustomer.Dto;
/// <summary>
/// 新客成长记录 DTO。
/// </summary>
public sealed class NewCustomerGrowthRecordDto
{
/// <summary>
/// 记录 ID。
/// </summary>
public long Id { get; init; }
/// <summary>
/// 顾客业务键。
/// </summary>
public string CustomerKey { get; init; } = string.Empty;
/// <summary>
/// 顾客展示名。
/// </summary>
public string? CustomerName { get; init; }
/// <summary>
/// 注册时间。
/// </summary>
public DateTime RegisteredAt { get; init; }
/// <summary>
/// 礼包领取时间。
/// </summary>
public DateTime? GiftClaimedAt { get; init; }
/// <summary>
/// 首单时间。
/// </summary>
public DateTime? FirstOrderAt { get; init; }
/// <summary>
/// 渠道来源。
/// </summary>
public string? SourceChannel { get; init; }
}

View File

@@ -0,0 +1,47 @@
namespace TakeoutSaaS.Application.App.Coupons.NewCustomer.Dto;
/// <summary>
/// 新客邀请记录 DTO。
/// </summary>
public sealed class NewCustomerInviteRecordDto
{
/// <summary>
/// 记录 ID。
/// </summary>
public long Id { get; init; }
/// <summary>
/// 邀请人展示名。
/// </summary>
public string InviterName { get; init; } = string.Empty;
/// <summary>
/// 被邀请人展示名。
/// </summary>
public string InviteeName { get; init; } = string.Empty;
/// <summary>
/// 邀请时间。
/// </summary>
public DateTime InviteTime { get; init; }
/// <summary>
/// 状态pending_order/ordered
/// </summary>
public string OrderStatus { get; init; } = "pending_order";
/// <summary>
/// 奖励发放状态pending/issued
/// </summary>
public string RewardStatus { get; init; } = "pending";
/// <summary>
/// 奖励发放时间。
/// </summary>
public DateTime? RewardIssuedAt { get; init; }
/// <summary>
/// 渠道来源。
/// </summary>
public string? SourceChannel { get; init; }
}

View File

@@ -0,0 +1,27 @@
namespace TakeoutSaaS.Application.App.Coupons.NewCustomer.Dto;
/// <summary>
/// 新客邀请记录分页结果 DTO。
/// </summary>
public sealed class NewCustomerInviteRecordListResultDto
{
/// <summary>
/// 列表项。
/// </summary>
public IReadOnlyList<NewCustomerInviteRecordDto> Items { get; init; } = [];
/// <summary>
/// 页码。
/// </summary>
public int Page { get; init; }
/// <summary>
/// 每页条数。
/// </summary>
public int PageSize { get; init; }
/// <summary>
/// 总条数。
/// </summary>
public int TotalCount { get; init; }
}

View File

@@ -0,0 +1,27 @@
namespace TakeoutSaaS.Application.App.Coupons.NewCustomer.Dto;
/// <summary>
/// 保存新客有礼券规则输入 DTO。
/// </summary>
public sealed class NewCustomerSaveCouponRuleInputDto
{
/// <summary>
/// 券类型amount_off/discount/free_shipping
/// </summary>
public string CouponType { get; init; } = "amount_off";
/// <summary>
/// 面值或折扣值。
/// </summary>
public decimal? Value { get; init; }
/// <summary>
/// 使用门槛金额。
/// </summary>
public decimal? MinimumSpend { get; init; }
/// <summary>
/// 有效期天数。
/// </summary>
public int ValidDays { get; init; }
}

View File

@@ -0,0 +1,62 @@
namespace TakeoutSaaS.Application.App.Coupons.NewCustomer.Dto;
/// <summary>
/// 新客有礼配置 DTO。
/// </summary>
public sealed class NewCustomerSettingsDto
{
/// <summary>
/// 门店 ID。
/// </summary>
public long StoreId { get; init; }
/// <summary>
/// 是否开启新客礼包。
/// </summary>
public bool GiftEnabled { get; init; }
/// <summary>
/// 礼包类型coupon/direct
/// </summary>
public string GiftType { get; init; } = "coupon";
/// <summary>
/// 首单直减金额。
/// </summary>
public decimal? DirectReduceAmount { get; init; }
/// <summary>
/// 首单直减门槛金额。
/// </summary>
public decimal? DirectMinimumSpend { get; init; }
/// <summary>
/// 是否开启老带新分享。
/// </summary>
public bool InviteEnabled { get; init; }
/// <summary>
/// 分享渠道。
/// </summary>
public IReadOnlyList<string> ShareChannels { get; init; } = [];
/// <summary>
/// 新客礼包券列表。
/// </summary>
public IReadOnlyList<NewCustomerCouponRuleDto> WelcomeCoupons { get; init; } = [];
/// <summary>
/// 邀请人奖励券列表。
/// </summary>
public IReadOnlyList<NewCustomerCouponRuleDto> InviterCoupons { get; init; } = [];
/// <summary>
/// 被邀请人奖励券列表。
/// </summary>
public IReadOnlyList<NewCustomerCouponRuleDto> InviteeCoupons { get; init; } = [];
/// <summary>
/// 更新时间。
/// </summary>
public DateTime UpdatedAt { get; init; }
}

View File

@@ -0,0 +1,42 @@
namespace TakeoutSaaS.Application.App.Coupons.NewCustomer.Dto;
/// <summary>
/// 新客有礼统计 DTO。
/// </summary>
public sealed class NewCustomerStatsDto
{
/// <summary>
/// 本月新客数。
/// </summary>
public int MonthlyNewCustomers { get; init; }
/// <summary>
/// 较上月增长人数。
/// </summary>
public int MonthlyGrowthCount { get; init; }
/// <summary>
/// 较上月增长百分比。
/// </summary>
public decimal MonthlyGrowthRatePercent { get; init; }
/// <summary>
/// 本月礼包领取率。
/// </summary>
public decimal GiftClaimRate { get; init; }
/// <summary>
/// 本月礼包已领取人数。
/// </summary>
public int GiftClaimedCount { get; init; }
/// <summary>
/// 本月首单转化率。
/// </summary>
public decimal FirstOrderConversionRate { get; init; }
/// <summary>
/// 本月首单完成人数。
/// </summary>
public int FirstOrderedCount { get; init; }
}

View File

@@ -0,0 +1,152 @@
using MediatR;
using TakeoutSaaS.Application.App.Coupons.NewCustomer.Dto;
using TakeoutSaaS.Application.App.Coupons.NewCustomer.Queries;
using TakeoutSaaS.Domain.Coupons.Entities;
using TakeoutSaaS.Domain.Coupons.Enums;
using TakeoutSaaS.Domain.Coupons.Repositories;
using TakeoutSaaS.Shared.Abstractions.Tenancy;
namespace TakeoutSaaS.Application.App.Coupons.NewCustomer.Handlers;
/// <summary>
/// 查询新客有礼详情处理器。
/// </summary>
public sealed class GetNewCustomerDetailQueryHandler(
INewCustomerGiftRepository repository,
ITenantProvider tenantProvider)
: IRequestHandler<GetNewCustomerDetailQuery, NewCustomerDetailDto>
{
/// <inheritdoc />
public async Task<NewCustomerDetailDto> Handle(GetNewCustomerDetailQuery request, CancellationToken cancellationToken)
{
var tenantId = tenantProvider.GetCurrentTenantId();
var normalizedPage = Math.Max(1, request.RecordPage);
var normalizedPageSize = Math.Clamp(request.RecordPageSize, 1, 200);
var setting = await repository.FindSettingByStoreIdAsync(tenantId, request.StoreId, cancellationToken);
var rules = await repository.GetCouponRulesByStoreIdAsync(tenantId, request.StoreId, cancellationToken);
var settingsDto = BuildSettingsDto(request.StoreId, setting, rules);
var nowUtc = DateTime.UtcNow;
var currentMonthStart = NewCustomerMapping.StartOfMonthUtc(nowUtc);
var nextMonthStart = currentMonthStart.AddMonths(1);
var previousMonthStart = currentMonthStart.AddMonths(-1);
var currentMonthNewCustomerCount = await repository.CountRegisteredCustomersAsync(
tenantId,
request.StoreId,
currentMonthStart,
nextMonthStart,
cancellationToken);
var previousMonthNewCustomerCount = await repository.CountRegisteredCustomersAsync(
tenantId,
request.StoreId,
previousMonthStart,
currentMonthStart,
cancellationToken);
var currentMonthGiftClaimedCount = await repository.CountGiftClaimedCustomersAsync(
tenantId,
request.StoreId,
currentMonthStart,
nextMonthStart,
cancellationToken);
var currentMonthFirstOrderedCount = await repository.CountFirstOrderedCustomersAsync(
tenantId,
request.StoreId,
currentMonthStart,
nextMonthStart,
cancellationToken);
var stats = new NewCustomerStatsDto
{
MonthlyNewCustomers = currentMonthNewCustomerCount,
MonthlyGrowthCount = currentMonthNewCustomerCount - previousMonthNewCustomerCount,
MonthlyGrowthRatePercent = NewCustomerMapping.ToGrowthRatePercent(
currentMonthNewCustomerCount,
previousMonthNewCustomerCount),
GiftClaimRate = NewCustomerMapping.ToRatePercent(
currentMonthGiftClaimedCount,
currentMonthNewCustomerCount),
GiftClaimedCount = currentMonthGiftClaimedCount,
FirstOrderConversionRate = NewCustomerMapping.ToRatePercent(
currentMonthFirstOrderedCount,
currentMonthNewCustomerCount),
FirstOrderedCount = currentMonthFirstOrderedCount
};
var (records, totalCount) = await repository.GetInviteRecordsAsync(
tenantId,
request.StoreId,
normalizedPage,
normalizedPageSize,
cancellationToken);
return new NewCustomerDetailDto
{
Settings = settingsDto,
Stats = stats,
InviteRecords = new NewCustomerInviteRecordListResultDto
{
Items = records.Select(NewCustomerMapping.ToInviteRecordDto).ToList(),
Page = normalizedPage,
PageSize = normalizedPageSize,
TotalCount = totalCount
}
};
}
private static NewCustomerSettingsDto BuildSettingsDto(
long storeId,
NewCustomerGiftSetting? setting,
IReadOnlyList<NewCustomerCouponRule> rules)
{
var welcomeCoupons = rules
.Where(item => item.Scene == NewCustomerCouponScene.Welcome)
.Select(NewCustomerMapping.ToCouponRuleDto)
.ToList();
var inviterCoupons = rules
.Where(item => item.Scene == NewCustomerCouponScene.InviterReward)
.Select(NewCustomerMapping.ToCouponRuleDto)
.ToList();
var inviteeCoupons = rules
.Where(item => item.Scene == NewCustomerCouponScene.InviteeReward)
.Select(NewCustomerMapping.ToCouponRuleDto)
.ToList();
if (setting is null)
{
return new NewCustomerSettingsDto
{
StoreId = storeId,
GiftEnabled = true,
GiftType = "coupon",
InviteEnabled = true,
ShareChannels = ["wechat_friend", "moments"],
WelcomeCoupons = welcomeCoupons,
InviterCoupons = inviterCoupons,
InviteeCoupons = inviteeCoupons,
UpdatedAt = DateTime.UtcNow
};
}
return new NewCustomerSettingsDto
{
StoreId = storeId,
GiftEnabled = setting.GiftEnabled,
GiftType = NewCustomerMapping.ToGiftTypeText(setting.GiftType),
DirectReduceAmount = setting.DirectReduceAmount,
DirectMinimumSpend = setting.DirectMinimumSpend,
InviteEnabled = setting.InviteEnabled,
ShareChannels = NewCustomerMapping.DeserializeShareChannels(setting.ShareChannelsJson),
WelcomeCoupons = welcomeCoupons,
InviterCoupons = inviterCoupons,
InviteeCoupons = inviteeCoupons,
UpdatedAt = setting.UpdatedAt ?? setting.CreatedAt
};
}
}

View File

@@ -0,0 +1,41 @@
using MediatR;
using TakeoutSaaS.Application.App.Coupons.NewCustomer.Dto;
using TakeoutSaaS.Application.App.Coupons.NewCustomer.Queries;
using TakeoutSaaS.Domain.Coupons.Repositories;
using TakeoutSaaS.Shared.Abstractions.Tenancy;
namespace TakeoutSaaS.Application.App.Coupons.NewCustomer.Handlers;
/// <summary>
/// 查询新客邀请记录分页处理器。
/// </summary>
public sealed class GetNewCustomerInviteRecordListQueryHandler(
INewCustomerGiftRepository repository,
ITenantProvider tenantProvider)
: IRequestHandler<GetNewCustomerInviteRecordListQuery, NewCustomerInviteRecordListResultDto>
{
/// <inheritdoc />
public async Task<NewCustomerInviteRecordListResultDto> Handle(
GetNewCustomerInviteRecordListQuery request,
CancellationToken cancellationToken)
{
var tenantId = tenantProvider.GetCurrentTenantId();
var normalizedPage = Math.Max(1, request.Page);
var normalizedPageSize = Math.Clamp(request.PageSize, 1, 200);
var (items, totalCount) = await repository.GetInviteRecordsAsync(
tenantId,
request.StoreId,
normalizedPage,
normalizedPageSize,
cancellationToken);
return new NewCustomerInviteRecordListResultDto
{
Items = items.Select(NewCustomerMapping.ToInviteRecordDto).ToList(),
Page = normalizedPage,
PageSize = normalizedPageSize,
TotalCount = totalCount
};
}
}

View File

@@ -0,0 +1,129 @@
using MediatR;
using TakeoutSaaS.Application.App.Coupons.NewCustomer.Commands;
using TakeoutSaaS.Application.App.Coupons.NewCustomer.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.NewCustomer.Handlers;
/// <summary>
/// 保存新客有礼配置处理器。
/// </summary>
public sealed class SaveNewCustomerSettingsCommandHandler(
INewCustomerGiftRepository repository,
ITenantProvider tenantProvider)
: IRequestHandler<SaveNewCustomerSettingsCommand, NewCustomerSettingsDto>
{
/// <inheritdoc />
public async Task<NewCustomerSettingsDto> Handle(
SaveNewCustomerSettingsCommand request,
CancellationToken cancellationToken)
{
if (request.StoreId <= 0)
{
throw new BusinessException(ErrorCodes.BadRequest, "storeId 参数不合法");
}
var tenantId = tenantProvider.GetCurrentTenantId();
var giftType = NewCustomerMapping.ParseGiftType(request.GiftType);
var shareChannels = NewCustomerMapping.NormalizeShareChannels(request.ShareChannels);
var welcomeRules = NewCustomerMapping.NormalizeCouponRulesForSave(
request.StoreId,
NewCustomerCouponScene.Welcome,
request.WelcomeCoupons);
var inviterRules = NewCustomerMapping.NormalizeCouponRulesForSave(
request.StoreId,
NewCustomerCouponScene.InviterReward,
request.InviterCoupons);
var inviteeRules = NewCustomerMapping.NormalizeCouponRulesForSave(
request.StoreId,
NewCustomerCouponScene.InviteeReward,
request.InviteeCoupons);
if (giftType == NewCustomerGiftType.Coupon && welcomeRules.Count == 0)
{
throw new BusinessException(ErrorCodes.BadRequest, "优惠券包至少需要一张券");
}
if (giftType == NewCustomerGiftType.Direct)
{
if (!request.DirectReduceAmount.HasValue || request.DirectReduceAmount.Value <= 0m)
{
throw new BusinessException(ErrorCodes.BadRequest, "directReduceAmount 必须大于 0");
}
if (!request.DirectMinimumSpend.HasValue || request.DirectMinimumSpend.Value < 0m)
{
throw new BusinessException(ErrorCodes.BadRequest, "directMinimumSpend 不能小于 0");
}
}
if (request.InviteEnabled && (inviterRules.Count == 0 || inviteeRules.Count == 0))
{
throw new BusinessException(ErrorCodes.BadRequest, "开启邀请后必须配置邀请人和被邀请人奖励券");
}
var setting = await repository.FindSettingByStoreIdAsync(tenantId, request.StoreId, cancellationToken);
var isNewSetting = setting is null;
if (setting is null)
{
setting = new NewCustomerGiftSetting
{
StoreId = request.StoreId
};
await repository.AddSettingAsync(setting, cancellationToken);
}
setting.GiftEnabled = request.GiftEnabled;
setting.GiftType = giftType;
setting.DirectReduceAmount = giftType == NewCustomerGiftType.Direct
? decimal.Round(request.DirectReduceAmount!.Value, 2, MidpointRounding.AwayFromZero)
: null;
setting.DirectMinimumSpend = giftType == NewCustomerGiftType.Direct
? decimal.Round(request.DirectMinimumSpend!.Value, 2, MidpointRounding.AwayFromZero)
: null;
setting.InviteEnabled = request.InviteEnabled;
setting.ShareChannelsJson = NewCustomerMapping.SerializeShareChannels(shareChannels);
if (!isNewSetting)
{
await repository.UpdateSettingAsync(setting, cancellationToken);
}
var allRules = new List<NewCustomerCouponRule>(welcomeRules.Count + inviterRules.Count + inviteeRules.Count);
allRules.AddRange(welcomeRules);
allRules.AddRange(inviterRules);
allRules.AddRange(inviteeRules);
await repository.ReplaceCouponRulesAsync(
tenantId,
request.StoreId,
allRules,
cancellationToken);
await repository.SaveChangesAsync(cancellationToken);
return new NewCustomerSettingsDto
{
StoreId = request.StoreId,
GiftEnabled = setting.GiftEnabled,
GiftType = NewCustomerMapping.ToGiftTypeText(setting.GiftType),
DirectReduceAmount = setting.DirectReduceAmount,
DirectMinimumSpend = setting.DirectMinimumSpend,
InviteEnabled = setting.InviteEnabled,
ShareChannels = shareChannels,
WelcomeCoupons = welcomeRules.Select(NewCustomerMapping.ToCouponRuleDto).ToList(),
InviterCoupons = inviterRules.Select(NewCustomerMapping.ToCouponRuleDto).ToList(),
InviteeCoupons = inviteeRules.Select(NewCustomerMapping.ToCouponRuleDto).ToList(),
UpdatedAt = DateTime.UtcNow
};
}
}

View File

@@ -0,0 +1,101 @@
using MediatR;
using TakeoutSaaS.Application.App.Coupons.NewCustomer.Commands;
using TakeoutSaaS.Application.App.Coupons.NewCustomer.Dto;
using TakeoutSaaS.Domain.Coupons.Entities;
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.NewCustomer.Handlers;
/// <summary>
/// 写入新客成长记录处理器。
/// </summary>
public sealed class WriteNewCustomerGrowthRecordCommandHandler(
INewCustomerGiftRepository repository,
ITenantProvider tenantProvider)
: IRequestHandler<WriteNewCustomerGrowthRecordCommand, NewCustomerGrowthRecordDto>
{
/// <inheritdoc />
public async Task<NewCustomerGrowthRecordDto> Handle(
WriteNewCustomerGrowthRecordCommand request,
CancellationToken cancellationToken)
{
if (request.StoreId <= 0)
{
throw new BusinessException(ErrorCodes.BadRequest, "storeId 参数不合法");
}
var tenantId = tenantProvider.GetCurrentTenantId();
var customerKey = NewCustomerMapping.NormalizeCustomerKey(request.CustomerKey);
var customerName = NewCustomerMapping.NormalizeOptionalText(request.CustomerName, "customerName", 64);
var sourceChannel = NewCustomerMapping.NormalizeOptionalText(request.SourceChannel, "sourceChannel", 32);
var registeredAt = NewCustomerMapping.NormalizeUtc(request.RegisteredAt);
DateTime? giftClaimedAt = request.GiftClaimedAt.HasValue
? NewCustomerMapping.NormalizeUtc(request.GiftClaimedAt.Value)
: null;
DateTime? firstOrderAt = request.FirstOrderAt.HasValue
? NewCustomerMapping.NormalizeUtc(request.FirstOrderAt.Value)
: null;
var entity = await repository.FindGrowthRecordByCustomerKeyAsync(
tenantId: tenantId,
storeId: request.StoreId,
customerKey: customerKey,
cancellationToken: cancellationToken);
if (entity is null)
{
entity = new NewCustomerGrowthRecord
{
StoreId = request.StoreId,
CustomerKey = customerKey,
CustomerName = customerName,
RegisteredAt = registeredAt,
GiftClaimedAt = giftClaimedAt,
FirstOrderAt = firstOrderAt,
SourceChannel = sourceChannel
};
await repository.AddGrowthRecordAsync(entity, cancellationToken);
await repository.SaveChangesAsync(cancellationToken);
return NewCustomerMapping.ToGrowthRecordDto(entity);
}
if (!string.IsNullOrWhiteSpace(customerName))
{
entity.CustomerName = customerName;
}
if (!string.IsNullOrWhiteSpace(sourceChannel))
{
entity.SourceChannel = sourceChannel;
}
entity.RegisteredAt = registeredAt < entity.RegisteredAt
? registeredAt
: entity.RegisteredAt;
entity.GiftClaimedAt = MergeNullableDate(entity.GiftClaimedAt, giftClaimedAt);
entity.FirstOrderAt = MergeNullableDate(entity.FirstOrderAt, firstOrderAt);
await repository.UpdateGrowthRecordAsync(entity, cancellationToken);
await repository.SaveChangesAsync(cancellationToken);
return NewCustomerMapping.ToGrowthRecordDto(entity);
}
private static DateTime? MergeNullableDate(DateTime? existing, DateTime? incoming)
{
if (!existing.HasValue)
{
return incoming;
}
if (!incoming.HasValue)
{
return existing;
}
return incoming.Value < existing.Value ? incoming : existing;
}
}

View File

@@ -0,0 +1,59 @@
using MediatR;
using TakeoutSaaS.Application.App.Coupons.NewCustomer.Commands;
using TakeoutSaaS.Application.App.Coupons.NewCustomer.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.NewCustomer.Handlers;
/// <summary>
/// 写入新客邀请记录处理器。
/// </summary>
public sealed class WriteNewCustomerInviteRecordCommandHandler(
INewCustomerGiftRepository repository)
: IRequestHandler<WriteNewCustomerInviteRecordCommand, NewCustomerInviteRecordDto>
{
/// <inheritdoc />
public async Task<NewCustomerInviteRecordDto> Handle(
WriteNewCustomerInviteRecordCommand request,
CancellationToken cancellationToken)
{
if (request.StoreId <= 0)
{
throw new BusinessException(ErrorCodes.BadRequest, "storeId 参数不合法");
}
var inviterName = NewCustomerMapping.NormalizeDisplayName(request.InviterName, "inviterName");
var inviteeName = NewCustomerMapping.NormalizeDisplayName(request.InviteeName, "inviteeName");
var orderStatus = NewCustomerMapping.ParseInviteOrderStatus(request.OrderStatus);
var rewardStatus = NewCustomerMapping.ParseInviteRewardStatus(request.RewardStatus);
var sourceChannel = NewCustomerMapping.NormalizeOptionalText(request.SourceChannel, "sourceChannel", 32);
var inviteTime = NewCustomerMapping.NormalizeUtc(request.InviteTime);
DateTime? rewardIssuedAt = rewardStatus == NewCustomerInviteRewardStatus.Issued
? request.RewardIssuedAt.HasValue
? NewCustomerMapping.NormalizeUtc(request.RewardIssuedAt.Value)
: DateTime.UtcNow
: null;
var entity = new NewCustomerInviteRecord
{
StoreId = request.StoreId,
InviterName = inviterName,
InviteeName = inviteeName,
InviteTime = inviteTime,
OrderStatus = orderStatus,
RewardStatus = rewardStatus,
RewardIssuedAt = rewardIssuedAt,
SourceChannel = sourceChannel
};
await repository.AddInviteRecordAsync(entity, cancellationToken);
await repository.SaveChangesAsync(cancellationToken);
return NewCustomerMapping.ToInviteRecordDto(entity);
}
}

View File

@@ -0,0 +1,394 @@
using System.Globalization;
using System.Text.Json;
using TakeoutSaaS.Application.App.Coupons.NewCustomer.Dto;
using TakeoutSaaS.Domain.Coupons.Entities;
using TakeoutSaaS.Domain.Coupons.Enums;
using TakeoutSaaS.Shared.Abstractions.Constants;
using TakeoutSaaS.Shared.Abstractions.Exceptions;
namespace TakeoutSaaS.Application.App.Coupons.NewCustomer;
/// <summary>
/// 新客有礼映射与规则校验。
/// </summary>
internal static class NewCustomerMapping
{
private static readonly HashSet<string> AllowedShareChannels =
[
"wechat_friend",
"moments",
"sms"
];
private static readonly JsonSerializerOptions JsonOptions = new(JsonSerializerDefaults.Web)
{
PropertyNameCaseInsensitive = true
};
public static NewCustomerGiftType ParseGiftType(string? value)
{
var normalized = (value ?? string.Empty).Trim().ToLowerInvariant();
return normalized switch
{
"coupon" => NewCustomerGiftType.Coupon,
"direct" => NewCustomerGiftType.Direct,
_ => throw new BusinessException(ErrorCodes.BadRequest, "giftType 参数不合法")
};
}
public static string ToGiftTypeText(NewCustomerGiftType value)
{
return value switch
{
NewCustomerGiftType.Coupon => "coupon",
NewCustomerGiftType.Direct => "direct",
_ => "coupon"
};
}
public static NewCustomerCouponScene ParseCouponScene(string? value)
{
var normalized = (value ?? string.Empty).Trim().ToLowerInvariant();
return normalized switch
{
"welcome" => NewCustomerCouponScene.Welcome,
"inviter" => NewCustomerCouponScene.InviterReward,
"invitee" => NewCustomerCouponScene.InviteeReward,
_ => throw new BusinessException(ErrorCodes.BadRequest, "scene 参数不合法")
};
}
public static string ToCouponSceneText(NewCustomerCouponScene value)
{
return value switch
{
NewCustomerCouponScene.Welcome => "welcome",
NewCustomerCouponScene.InviterReward => "inviter",
NewCustomerCouponScene.InviteeReward => "invitee",
_ => "welcome"
};
}
public static NewCustomerCouponType ParseCouponType(string? value)
{
var normalized = (value ?? string.Empty).Trim().ToLowerInvariant();
return normalized switch
{
"amount_off" => NewCustomerCouponType.AmountOff,
"discount" => NewCustomerCouponType.Discount,
"free_shipping" => NewCustomerCouponType.FreeShipping,
_ => throw new BusinessException(ErrorCodes.BadRequest, "couponType 参数不合法")
};
}
public static string ToCouponTypeText(NewCustomerCouponType value)
{
return value switch
{
NewCustomerCouponType.AmountOff => "amount_off",
NewCustomerCouponType.Discount => "discount",
NewCustomerCouponType.FreeShipping => "free_shipping",
_ => "amount_off"
};
}
public static NewCustomerInviteOrderStatus ParseInviteOrderStatus(string? value)
{
var normalized = (value ?? string.Empty).Trim().ToLowerInvariant();
return normalized switch
{
"pending_order" => NewCustomerInviteOrderStatus.PendingOrder,
"ordered" => NewCustomerInviteOrderStatus.Ordered,
_ => throw new BusinessException(ErrorCodes.BadRequest, "orderStatus 参数不合法")
};
}
public static string ToInviteOrderStatusText(NewCustomerInviteOrderStatus value)
{
return value switch
{
NewCustomerInviteOrderStatus.PendingOrder => "pending_order",
NewCustomerInviteOrderStatus.Ordered => "ordered",
_ => "pending_order"
};
}
public static NewCustomerInviteRewardStatus ParseInviteRewardStatus(string? value)
{
var normalized = (value ?? string.Empty).Trim().ToLowerInvariant();
return normalized switch
{
"pending" => NewCustomerInviteRewardStatus.Pending,
"issued" => NewCustomerInviteRewardStatus.Issued,
_ => throw new BusinessException(ErrorCodes.BadRequest, "rewardStatus 参数不合法")
};
}
public static string ToInviteRewardStatusText(NewCustomerInviteRewardStatus value)
{
return value switch
{
NewCustomerInviteRewardStatus.Pending => "pending",
NewCustomerInviteRewardStatus.Issued => "issued",
_ => "pending"
};
}
public static DateTime NormalizeUtc(DateTime value)
{
return value.Kind switch
{
DateTimeKind.Utc => value,
DateTimeKind.Local => value.ToUniversalTime(),
_ => DateTime.SpecifyKind(value, DateTimeKind.Utc)
};
}
public static DateTime StartOfMonthUtc(DateTime nowUtc)
{
var utc = NormalizeUtc(nowUtc);
return new DateTime(utc.Year, utc.Month, 1, 0, 0, 0, DateTimeKind.Utc);
}
public static decimal ToRatePercent(int numerator, int denominator)
{
if (denominator <= 0 || numerator <= 0)
{
return 0m;
}
return decimal.Round(numerator * 100m / denominator, 1, MidpointRounding.AwayFromZero);
}
public static decimal ToGrowthRatePercent(int currentValue, int previousValue)
{
if (previousValue <= 0)
{
return currentValue > 0 ? 100m : 0m;
}
return decimal.Round(
(currentValue - previousValue) * 100m / previousValue,
1,
MidpointRounding.AwayFromZero);
}
public static string SerializeShareChannels(IReadOnlyCollection<string> channels)
{
return JsonSerializer.Serialize(channels, JsonOptions);
}
public static IReadOnlyList<string> DeserializeShareChannels(string? payload)
{
if (string.IsNullOrWhiteSpace(payload))
{
return [];
}
var values = JsonSerializer.Deserialize<List<string>>(payload, JsonOptions) ?? [];
return NormalizeShareChannels(values);
}
public static IReadOnlyList<string> NormalizeShareChannels(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, "shareChannels 不能为空");
}
if (normalized.Any(item => !AllowedShareChannels.Contains(item)))
{
throw new BusinessException(ErrorCodes.BadRequest, "shareChannels 存在非法值");
}
return normalized;
}
public static IReadOnlyList<NewCustomerCouponRule> NormalizeCouponRulesForSave(
long storeId,
NewCustomerCouponScene scene,
IReadOnlyCollection<NewCustomerSaveCouponRuleInputDto>? values)
{
var rules = (values ?? [])
.Select((item, index) => NormalizeCouponRuleForSave(storeId, scene, item, index + 1))
.ToList();
return rules;
}
public static NewCustomerCouponRuleDto ToCouponRuleDto(NewCustomerCouponRule source)
{
return new NewCustomerCouponRuleDto
{
Id = source.Id,
Scene = ToCouponSceneText(source.Scene),
CouponType = ToCouponTypeText(source.CouponType),
Value = source.Value,
MinimumSpend = source.MinimumSpend,
ValidDays = source.ValidDays,
SortOrder = source.SortOrder
};
}
public static NewCustomerInviteRecordDto ToInviteRecordDto(NewCustomerInviteRecord source)
{
return new NewCustomerInviteRecordDto
{
Id = source.Id,
InviterName = source.InviterName,
InviteeName = source.InviteeName,
InviteTime = source.InviteTime,
OrderStatus = ToInviteOrderStatusText(source.OrderStatus),
RewardStatus = ToInviteRewardStatusText(source.RewardStatus),
RewardIssuedAt = source.RewardIssuedAt,
SourceChannel = source.SourceChannel
};
}
public static NewCustomerGrowthRecordDto ToGrowthRecordDto(NewCustomerGrowthRecord source)
{
return new NewCustomerGrowthRecordDto
{
Id = source.Id,
CustomerKey = source.CustomerKey,
CustomerName = source.CustomerName,
RegisteredAt = source.RegisteredAt,
GiftClaimedAt = source.GiftClaimedAt,
FirstOrderAt = source.FirstOrderAt,
SourceChannel = source.SourceChannel
};
}
public static string NormalizeDisplayName(string? value, string fieldName)
{
var normalized = (value ?? string.Empty).Trim();
if (string.IsNullOrWhiteSpace(normalized))
{
throw new BusinessException(ErrorCodes.BadRequest, $"{fieldName} 不能为空");
}
if (normalized.Length > 64)
{
throw new BusinessException(ErrorCodes.BadRequest, $"{fieldName} 长度不能超过 64");
}
return normalized;
}
public static string NormalizeCustomerKey(string? value)
{
var normalized = (value ?? string.Empty).Trim();
if (string.IsNullOrWhiteSpace(normalized))
{
throw new BusinessException(ErrorCodes.BadRequest, "customerKey 不能为空");
}
if (normalized.Length > 64)
{
throw new BusinessException(ErrorCodes.BadRequest, "customerKey 长度不能超过 64");
}
return normalized;
}
public static string? NormalizeOptionalText(string? value, string fieldName, int maxLength)
{
var normalized = (value ?? string.Empty).Trim();
if (normalized.Length == 0)
{
return null;
}
if (normalized.Length > maxLength)
{
throw new BusinessException(ErrorCodes.BadRequest, $"{fieldName} 长度不能超过 {maxLength}");
}
return normalized;
}
private static NewCustomerCouponRule NormalizeCouponRuleForSave(
long storeId,
NewCustomerCouponScene scene,
NewCustomerSaveCouponRuleInputDto value,
int sortOrder)
{
ArgumentNullException.ThrowIfNull(value);
var couponType = ParseCouponType(value.CouponType);
var minimumSpend = NormalizeNonNegativeMoney(value.MinimumSpend, "minimumSpend");
decimal? normalizedValue = couponType switch
{
NewCustomerCouponType.AmountOff => NormalizePositiveMoney(value.Value, "value"),
NewCustomerCouponType.Discount => NormalizeDiscount(value.Value),
NewCustomerCouponType.FreeShipping => null,
_ => throw new BusinessException(ErrorCodes.BadRequest, "couponType 参数不合法")
};
if (couponType == NewCustomerCouponType.AmountOff &&
minimumSpend.HasValue &&
normalizedValue.HasValue &&
normalizedValue.Value >= minimumSpend.Value &&
minimumSpend.Value > 0m)
{
throw new BusinessException(ErrorCodes.BadRequest, "满减券 value 必须小于 minimumSpend");
}
if (value.ValidDays is < 1 or > 365)
{
throw new BusinessException(ErrorCodes.BadRequest, "validDays 必须在 1-365 之间");
}
return new NewCustomerCouponRule
{
StoreId = storeId,
Scene = scene,
CouponType = couponType,
Value = normalizedValue,
MinimumSpend = minimumSpend,
ValidDays = value.ValidDays,
SortOrder = sortOrder
};
}
private static decimal? NormalizeNonNegativeMoney(decimal? value, string fieldName)
{
if (!value.HasValue)
{
return null;
}
if (value.Value < 0m)
{
throw new BusinessException(ErrorCodes.BadRequest, $"{fieldName} 不能小于 0");
}
return decimal.Round(value.Value, 2, MidpointRounding.AwayFromZero);
}
private static decimal NormalizePositiveMoney(decimal? value, string fieldName)
{
if (!value.HasValue || value.Value <= 0m)
{
throw new BusinessException(ErrorCodes.BadRequest, $"{fieldName} 必须大于 0");
}
return decimal.Round(value.Value, 2, MidpointRounding.AwayFromZero);
}
private static decimal NormalizeDiscount(decimal? value)
{
if (!value.HasValue || value.Value <= 0m || value.Value >= 10m)
{
throw new BusinessException(ErrorCodes.BadRequest, "折扣券 value 必须在 0-10 之间");
}
return decimal.Round(value.Value, 2, MidpointRounding.AwayFromZero);
}
}

View File

@@ -0,0 +1,25 @@
using MediatR;
using TakeoutSaaS.Application.App.Coupons.NewCustomer.Dto;
namespace TakeoutSaaS.Application.App.Coupons.NewCustomer.Queries;
/// <summary>
/// 查询新客有礼详情。
/// </summary>
public sealed class GetNewCustomerDetailQuery : IRequest<NewCustomerDetailDto>
{
/// <summary>
/// 操作门店 ID。
/// </summary>
public long StoreId { get; init; }
/// <summary>
/// 邀请记录页码。
/// </summary>
public int RecordPage { get; init; } = 1;
/// <summary>
/// 邀请记录每页条数。
/// </summary>
public int RecordPageSize { get; init; } = 10;
}

View File

@@ -0,0 +1,25 @@
using MediatR;
using TakeoutSaaS.Application.App.Coupons.NewCustomer.Dto;
namespace TakeoutSaaS.Application.App.Coupons.NewCustomer.Queries;
/// <summary>
/// 查询新客邀请记录分页。
/// </summary>
public sealed class GetNewCustomerInviteRecordListQuery : IRequest<NewCustomerInviteRecordListResultDto>
{
/// <summary>
/// 门店 ID。
/// </summary>
public long StoreId { get; init; }
/// <summary>
/// 页码。
/// </summary>
public int Page { get; init; } = 1;
/// <summary>
/// 每页条数。
/// </summary>
public int PageSize { get; init; } = 10;
}

View File

@@ -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";
}

View File

@@ -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; }
}

View File

@@ -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; } = [];
}

View File

@@ -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; }
}

View File

@@ -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; }
}

View File

@@ -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; }
}

View File

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

View File

@@ -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; } = [];
}

View File

@@ -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; }
}

View File

@@ -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;
}

View File

@@ -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; }
}

View File

@@ -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; }
}

View File

@@ -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; } = [];
}

View File

@@ -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; }
}

View File

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

View File

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

View File

@@ -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}\"";
}
}

View File

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

View File

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

View File

@@ -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
};
}
}

View File

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

View File

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

View File

@@ -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)}";
}
}

View File

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

View File

@@ -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; }
}

View File

@@ -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; }
}

View File

@@ -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;
}

View File

@@ -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;
}

View File

@@ -0,0 +1,45 @@
using TakeoutSaaS.Domain.Coupons.Enums;
using TakeoutSaaS.Shared.Abstractions.Entities;
namespace TakeoutSaaS.Domain.Coupons.Entities;
/// <summary>
/// 新客有礼券规则。
/// </summary>
public sealed class NewCustomerCouponRule : MultiTenantEntityBase
{
/// <summary>
/// 门店 ID。
/// </summary>
public long StoreId { get; set; }
/// <summary>
/// 券规则场景。
/// </summary>
public NewCustomerCouponScene Scene { get; set; } = NewCustomerCouponScene.Welcome;
/// <summary>
/// 券类型。
/// </summary>
public NewCustomerCouponType CouponType { get; set; } = NewCustomerCouponType.AmountOff;
/// <summary>
/// 面值或折扣值。
/// </summary>
public decimal? Value { get; set; }
/// <summary>
/// 使用门槛。
/// </summary>
public decimal? MinimumSpend { get; set; }
/// <summary>
/// 有效期天数。
/// </summary>
public int ValidDays { get; set; }
/// <summary>
/// 排序值(同场景内递增)。
/// </summary>
public int SortOrder { get; set; }
}

View File

@@ -0,0 +1,45 @@
using TakeoutSaaS.Domain.Coupons.Enums;
using TakeoutSaaS.Shared.Abstractions.Entities;
namespace TakeoutSaaS.Domain.Coupons.Entities;
/// <summary>
/// 新客有礼门店配置。
/// </summary>
public sealed class NewCustomerGiftSetting : MultiTenantEntityBase
{
/// <summary>
/// 门店 ID。
/// </summary>
public long StoreId { get; set; }
/// <summary>
/// 是否开启新客礼包。
/// </summary>
public bool GiftEnabled { get; set; }
/// <summary>
/// 礼包类型。
/// </summary>
public NewCustomerGiftType GiftType { get; set; } = NewCustomerGiftType.Coupon;
/// <summary>
/// 首单直减金额。
/// </summary>
public decimal? DirectReduceAmount { get; set; }
/// <summary>
/// 首单直减门槛金额。
/// </summary>
public decimal? DirectMinimumSpend { get; set; }
/// <summary>
/// 是否开启老带新分享。
/// </summary>
public bool InviteEnabled { get; set; }
/// <summary>
/// 分享渠道JSON
/// </summary>
public string ShareChannelsJson { get; set; } = "[]";
}

View File

@@ -0,0 +1,44 @@
using TakeoutSaaS.Shared.Abstractions.Entities;
namespace TakeoutSaaS.Domain.Coupons.Entities;
/// <summary>
/// 新客成长记录。
/// </summary>
public sealed class NewCustomerGrowthRecord : MultiTenantEntityBase
{
/// <summary>
/// 门店 ID。
/// </summary>
public long StoreId { get; set; }
/// <summary>
/// 顾客业务唯一键。
/// </summary>
public string CustomerKey { get; set; } = string.Empty;
/// <summary>
/// 顾客展示名。
/// </summary>
public string? CustomerName { get; set; }
/// <summary>
/// 注册时间。
/// </summary>
public DateTime RegisteredAt { get; set; }
/// <summary>
/// 礼包领取时间。
/// </summary>
public DateTime? GiftClaimedAt { get; set; }
/// <summary>
/// 首单时间。
/// </summary>
public DateTime? FirstOrderAt { get; set; }
/// <summary>
/// 渠道来源。
/// </summary>
public string? SourceChannel { get; set; }
}

View File

@@ -0,0 +1,50 @@
using TakeoutSaaS.Domain.Coupons.Enums;
using TakeoutSaaS.Shared.Abstractions.Entities;
namespace TakeoutSaaS.Domain.Coupons.Entities;
/// <summary>
/// 新客邀请记录。
/// </summary>
public sealed class NewCustomerInviteRecord : MultiTenantEntityBase
{
/// <summary>
/// 门店 ID。
/// </summary>
public long StoreId { get; set; }
/// <summary>
/// 邀请人展示名。
/// </summary>
public string InviterName { get; set; } = string.Empty;
/// <summary>
/// 被邀请人展示名。
/// </summary>
public string InviteeName { get; set; } = string.Empty;
/// <summary>
/// 邀请时间。
/// </summary>
public DateTime InviteTime { get; set; }
/// <summary>
/// 订单状态。
/// </summary>
public NewCustomerInviteOrderStatus OrderStatus { get; set; } = NewCustomerInviteOrderStatus.PendingOrder;
/// <summary>
/// 奖励状态。
/// </summary>
public NewCustomerInviteRewardStatus RewardStatus { get; set; } = NewCustomerInviteRewardStatus.Pending;
/// <summary>
/// 奖励发放时间。
/// </summary>
public DateTime? RewardIssuedAt { get; set; }
/// <summary>
/// 邀请来源渠道。
/// </summary>
public string? SourceChannel { get; set; }
}

View File

@@ -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;
}

View File

@@ -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>
/// 固定开始日期UTCValidityType=DateRange 时有效)。
/// </summary>
public DateTime? ValidFrom { get; set; }
/// <summary>
/// 固定结束日期UTCValidityType=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;
}

View File

@@ -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; }
}

View File

@@ -0,0 +1,22 @@
namespace TakeoutSaaS.Domain.Coupons.Enums;
/// <summary>
/// 新客有礼券规则场景。
/// </summary>
public enum NewCustomerCouponScene
{
/// <summary>
/// 新客礼包。
/// </summary>
Welcome = 1,
/// <summary>
/// 邀请人奖励。
/// </summary>
InviterReward = 2,
/// <summary>
/// 被邀请人奖励。
/// </summary>
InviteeReward = 3
}

View File

@@ -0,0 +1,22 @@
namespace TakeoutSaaS.Domain.Coupons.Enums;
/// <summary>
/// 新客有礼券类型。
/// </summary>
public enum NewCustomerCouponType
{
/// <summary>
/// 满减券。
/// </summary>
AmountOff = 1,
/// <summary>
/// 折扣券。
/// </summary>
Discount = 2,
/// <summary>
/// 免配送费券。
/// </summary>
FreeShipping = 3
}

View File

@@ -0,0 +1,17 @@
namespace TakeoutSaaS.Domain.Coupons.Enums;
/// <summary>
/// 新客礼包类型。
/// </summary>
public enum NewCustomerGiftType
{
/// <summary>
/// 优惠券包。
/// </summary>
Coupon = 1,
/// <summary>
/// 首单直减。
/// </summary>
Direct = 2
}

View File

@@ -0,0 +1,17 @@
namespace TakeoutSaaS.Domain.Coupons.Enums;
/// <summary>
/// 邀请记录订单状态。
/// </summary>
public enum NewCustomerInviteOrderStatus
{
/// <summary>
/// 待下单。
/// </summary>
PendingOrder = 1,
/// <summary>
/// 已下单。
/// </summary>
Ordered = 2
}

View File

@@ -0,0 +1,17 @@
namespace TakeoutSaaS.Domain.Coupons.Enums;
/// <summary>
/// 邀请奖励发放状态。
/// </summary>
public enum NewCustomerInviteRewardStatus
{
/// <summary>
/// 待触发。
/// </summary>
Pending = 1,
/// <summary>
/// 已发放。
/// </summary>
Issued = 2
}

View File

@@ -0,0 +1,17 @@
namespace TakeoutSaaS.Domain.Coupons.Enums;
/// <summary>
/// 次卡过期策略。
/// </summary>
public enum PunchCardExpireStrategy
{
/// <summary>
/// 剩余次数作废。
/// </summary>
Invalidate = 0,
/// <summary>
/// 可申请退款。
/// </summary>
Refund = 1
}

View File

@@ -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
}

View File

@@ -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
}

View File

@@ -0,0 +1,17 @@
namespace TakeoutSaaS.Domain.Coupons.Enums;
/// <summary>
/// 次卡状态。
/// </summary>
public enum PunchCardStatus
{
/// <summary>
/// 已下架。
/// </summary>
Disabled = 0,
/// <summary>
/// 已上架。
/// </summary>
Enabled = 1
}

View File

@@ -0,0 +1,17 @@
namespace TakeoutSaaS.Domain.Coupons.Enums;
/// <summary>
/// 次卡使用模式。
/// </summary>
public enum PunchCardUsageMode
{
/// <summary>
/// 完全免费。
/// </summary>
Free = 0,
/// <summary>
/// 金额上限。
/// </summary>
Cap = 1
}

View File

@@ -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
}

View File

@@ -0,0 +1,17 @@
namespace TakeoutSaaS.Domain.Coupons.Enums;
/// <summary>
/// 次卡有效期类型。
/// </summary>
public enum PunchCardValidityType
{
/// <summary>
/// 购买后固定天数。
/// </summary>
Days = 0,
/// <summary>
/// 固定日期区间。
/// </summary>
DateRange = 1
}

View File

@@ -0,0 +1,113 @@
using TakeoutSaaS.Domain.Coupons.Entities;
namespace TakeoutSaaS.Domain.Coupons.Repositories;
/// <summary>
/// 新客有礼仓储契约。
/// </summary>
public interface INewCustomerGiftRepository
{
/// <summary>
/// 查询门店配置。
/// </summary>
Task<NewCustomerGiftSetting?> FindSettingByStoreIdAsync(
long tenantId,
long storeId,
CancellationToken cancellationToken = default);
/// <summary>
/// 新增门店配置。
/// </summary>
Task AddSettingAsync(NewCustomerGiftSetting entity, CancellationToken cancellationToken = default);
/// <summary>
/// 更新门店配置。
/// </summary>
Task UpdateSettingAsync(NewCustomerGiftSetting entity, CancellationToken cancellationToken = default);
/// <summary>
/// 查询门店全部券规则。
/// </summary>
Task<IReadOnlyList<NewCustomerCouponRule>> GetCouponRulesByStoreIdAsync(
long tenantId,
long storeId,
CancellationToken cancellationToken = default);
/// <summary>
/// 替换门店券规则集合。
/// </summary>
Task ReplaceCouponRulesAsync(
long tenantId,
long storeId,
IReadOnlyCollection<NewCustomerCouponRule> entities,
CancellationToken cancellationToken = default);
/// <summary>
/// 分页查询邀请记录。
/// </summary>
Task<(IReadOnlyList<NewCustomerInviteRecord> Items, int TotalCount)> GetInviteRecordsAsync(
long tenantId,
long storeId,
int page,
int pageSize,
CancellationToken cancellationToken = default);
/// <summary>
/// 新增邀请记录。
/// </summary>
Task AddInviteRecordAsync(NewCustomerInviteRecord entity, CancellationToken cancellationToken = default);
/// <summary>
/// 按业务键查询成长记录。
/// </summary>
Task<NewCustomerGrowthRecord?> FindGrowthRecordByCustomerKeyAsync(
long tenantId,
long storeId,
string customerKey,
CancellationToken cancellationToken = default);
/// <summary>
/// 新增成长记录。
/// </summary>
Task AddGrowthRecordAsync(NewCustomerGrowthRecord entity, CancellationToken cancellationToken = default);
/// <summary>
/// 更新成长记录。
/// </summary>
Task UpdateGrowthRecordAsync(NewCustomerGrowthRecord entity, CancellationToken cancellationToken = default);
/// <summary>
/// 统计时间范围内注册新客数。
/// </summary>
Task<int> CountRegisteredCustomersAsync(
long tenantId,
long storeId,
DateTime startAt,
DateTime endAt,
CancellationToken cancellationToken = default);
/// <summary>
/// 统计时间范围内礼包已领取数。
/// </summary>
Task<int> CountGiftClaimedCustomersAsync(
long tenantId,
long storeId,
DateTime startAt,
DateTime endAt,
CancellationToken cancellationToken = default);
/// <summary>
/// 统计时间范围内首单完成数。
/// </summary>
Task<int> CountFirstOrderedCustomersAsync(
long tenantId,
long storeId,
DateTime startAt,
DateTime endAt,
CancellationToken cancellationToken = default);
/// <summary>
/// 持久化变更。
/// </summary>
Task SaveChangesAsync(CancellationToken cancellationToken = default);
}

View File

@@ -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; }
}

View File

@@ -46,7 +46,9 @@ public static class AppServiceCollectionExtensions
services.AddScoped<IStoreRepository, EfStoreRepository>();
services.AddScoped<IProductRepository, EfProductRepository>();
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>();

View File

@@ -354,6 +354,34 @@ public sealed class TakeoutAppDbContext(
/// </summary>
public DbSet<PromotionCampaign> PromotionCampaigns => Set<PromotionCampaign>();
/// <summary>
/// 新客有礼配置。
/// </summary>
public DbSet<NewCustomerGiftSetting> NewCustomerGiftSettings => Set<NewCustomerGiftSetting>();
/// <summary>
/// 新客有礼券规则。
/// </summary>
public DbSet<NewCustomerCouponRule> NewCustomerCouponRules => Set<NewCustomerCouponRule>();
/// <summary>
/// 新客邀请记录。
/// </summary>
public DbSet<NewCustomerInviteRecord> NewCustomerInviteRecords => Set<NewCustomerInviteRecord>();
/// <summary>
/// 新客成长记录。
/// </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>();
@@ -520,6 +548,13 @@ public sealed class TakeoutAppDbContext(
ConfigureCouponTemplate(modelBuilder.Entity<CouponTemplate>());
ConfigureCoupon(modelBuilder.Entity<Coupon>());
ConfigurePromotionCampaign(modelBuilder.Entity<PromotionCampaign>());
ConfigureNewCustomerGiftSetting(modelBuilder.Entity<NewCustomerGiftSetting>());
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>());
@@ -1619,6 +1654,130 @@ public sealed class TakeoutAppDbContext(
builder.Property(x => x.BannerUrl).HasMaxLength(512);
}
private static void ConfigureNewCustomerGiftSetting(EntityTypeBuilder<NewCustomerGiftSetting> builder)
{
builder.ToTable("new_customer_gift_settings");
builder.HasKey(x => x.Id);
builder.Property(x => x.StoreId).IsRequired();
builder.Property(x => x.GiftType).HasConversion<int>();
builder.Property(x => x.DirectReduceAmount).HasPrecision(18, 2);
builder.Property(x => x.DirectMinimumSpend).HasPrecision(18, 2);
builder.Property(x => x.ShareChannelsJson).HasColumnType("text").IsRequired();
builder.HasIndex(x => new { x.TenantId, x.StoreId }).IsUnique();
}
private static void ConfigureNewCustomerCouponRule(EntityTypeBuilder<NewCustomerCouponRule> builder)
{
builder.ToTable("new_customer_coupon_rules");
builder.HasKey(x => x.Id);
builder.Property(x => x.StoreId).IsRequired();
builder.Property(x => x.Scene).HasConversion<int>();
builder.Property(x => x.CouponType).HasConversion<int>();
builder.Property(x => x.Value).HasPrecision(18, 2);
builder.Property(x => x.MinimumSpend).HasPrecision(18, 2);
builder.Property(x => x.ValidDays).IsRequired();
builder.Property(x => x.SortOrder).IsRequired();
builder.HasIndex(x => new { x.TenantId, x.StoreId, x.Scene, x.SortOrder });
}
private static void ConfigureNewCustomerInviteRecord(EntityTypeBuilder<NewCustomerInviteRecord> builder)
{
builder.ToTable("new_customer_invite_records");
builder.HasKey(x => x.Id);
builder.Property(x => x.StoreId).IsRequired();
builder.Property(x => x.InviterName).HasMaxLength(64).IsRequired();
builder.Property(x => x.InviteeName).HasMaxLength(64).IsRequired();
builder.Property(x => x.InviteTime).IsRequired();
builder.Property(x => x.OrderStatus).HasConversion<int>();
builder.Property(x => x.RewardStatus).HasConversion<int>();
builder.Property(x => x.SourceChannel).HasMaxLength(32);
builder.HasIndex(x => new { x.TenantId, x.StoreId, x.InviteTime });
}
private static void ConfigureNewCustomerGrowthRecord(EntityTypeBuilder<NewCustomerGrowthRecord> builder)
{
builder.ToTable("new_customer_growth_records");
builder.HasKey(x => x.Id);
builder.Property(x => x.StoreId).IsRequired();
builder.Property(x => x.CustomerKey).HasMaxLength(64).IsRequired();
builder.Property(x => x.CustomerName).HasMaxLength(64);
builder.Property(x => x.RegisteredAt).IsRequired();
builder.Property(x => x.SourceChannel).HasMaxLength(32);
builder.HasIndex(x => new { x.TenantId, x.StoreId, x.CustomerKey }).IsUnique();
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");

View File

@@ -0,0 +1,200 @@
using Microsoft.EntityFrameworkCore;
using TakeoutSaaS.Domain.Coupons.Entities;
using TakeoutSaaS.Domain.Coupons.Repositories;
using TakeoutSaaS.Infrastructure.App.Persistence;
namespace TakeoutSaaS.Infrastructure.App.Repositories;
/// <summary>
/// 新客有礼仓储 EF Core 实现。
/// </summary>
public sealed class EfNewCustomerGiftRepository(TakeoutAppDbContext context) : INewCustomerGiftRepository
{
/// <inheritdoc />
public Task<NewCustomerGiftSetting?> FindSettingByStoreIdAsync(
long tenantId,
long storeId,
CancellationToken cancellationToken = default)
{
return context.NewCustomerGiftSettings
.Where(item => item.TenantId == tenantId && item.StoreId == storeId)
.FirstOrDefaultAsync(cancellationToken);
}
/// <inheritdoc />
public Task AddSettingAsync(NewCustomerGiftSetting entity, CancellationToken cancellationToken = default)
{
return context.NewCustomerGiftSettings.AddAsync(entity, cancellationToken).AsTask();
}
/// <inheritdoc />
public Task UpdateSettingAsync(NewCustomerGiftSetting entity, CancellationToken cancellationToken = default)
{
context.NewCustomerGiftSettings.Update(entity);
return Task.CompletedTask;
}
/// <inheritdoc />
public async Task<IReadOnlyList<NewCustomerCouponRule>> GetCouponRulesByStoreIdAsync(
long tenantId,
long storeId,
CancellationToken cancellationToken = default)
{
return await context.NewCustomerCouponRules
.AsNoTracking()
.Where(item => item.TenantId == tenantId && item.StoreId == storeId)
.OrderBy(item => item.Scene)
.ThenBy(item => item.SortOrder)
.ThenBy(item => item.Id)
.ToListAsync(cancellationToken);
}
/// <inheritdoc />
public async Task ReplaceCouponRulesAsync(
long tenantId,
long storeId,
IReadOnlyCollection<NewCustomerCouponRule> entities,
CancellationToken cancellationToken = default)
{
var existing = await context.NewCustomerCouponRules
.Where(item => item.TenantId == tenantId && item.StoreId == storeId)
.ToListAsync(cancellationToken);
if (existing.Count > 0)
{
context.NewCustomerCouponRules.RemoveRange(existing);
}
if (entities.Count > 0)
{
await context.NewCustomerCouponRules.AddRangeAsync(entities, cancellationToken);
}
}
/// <inheritdoc />
public async Task<(IReadOnlyList<NewCustomerInviteRecord> Items, int TotalCount)> GetInviteRecordsAsync(
long tenantId,
long storeId,
int page,
int pageSize,
CancellationToken cancellationToken = default)
{
var normalizedPage = Math.Max(1, page);
var normalizedPageSize = Math.Clamp(pageSize, 1, 200);
var query = context.NewCustomerInviteRecords
.AsNoTracking()
.Where(item => item.TenantId == tenantId && item.StoreId == storeId);
var totalCount = await query.CountAsync(cancellationToken);
if (totalCount == 0)
{
return ([], 0);
}
var items = await query
.OrderByDescending(item => item.InviteTime)
.ThenByDescending(item => item.Id)
.Skip((normalizedPage - 1) * normalizedPageSize)
.Take(normalizedPageSize)
.ToListAsync(cancellationToken);
return (items, totalCount);
}
/// <inheritdoc />
public Task AddInviteRecordAsync(NewCustomerInviteRecord entity, CancellationToken cancellationToken = default)
{
return context.NewCustomerInviteRecords.AddAsync(entity, cancellationToken).AsTask();
}
/// <inheritdoc />
public Task<NewCustomerGrowthRecord?> FindGrowthRecordByCustomerKeyAsync(
long tenantId,
long storeId,
string customerKey,
CancellationToken cancellationToken = default)
{
return context.NewCustomerGrowthRecords
.Where(item =>
item.TenantId == tenantId &&
item.StoreId == storeId &&
item.CustomerKey == customerKey)
.FirstOrDefaultAsync(cancellationToken);
}
/// <inheritdoc />
public Task AddGrowthRecordAsync(NewCustomerGrowthRecord entity, CancellationToken cancellationToken = default)
{
return context.NewCustomerGrowthRecords.AddAsync(entity, cancellationToken).AsTask();
}
/// <inheritdoc />
public Task UpdateGrowthRecordAsync(NewCustomerGrowthRecord entity, CancellationToken cancellationToken = default)
{
context.NewCustomerGrowthRecords.Update(entity);
return Task.CompletedTask;
}
/// <inheritdoc />
public Task<int> CountRegisteredCustomersAsync(
long tenantId,
long storeId,
DateTime startAt,
DateTime endAt,
CancellationToken cancellationToken = default)
{
return context.NewCustomerGrowthRecords
.AsNoTracking()
.Where(item =>
item.TenantId == tenantId &&
item.StoreId == storeId &&
item.RegisteredAt >= startAt &&
item.RegisteredAt < endAt)
.CountAsync(cancellationToken);
}
/// <inheritdoc />
public Task<int> CountGiftClaimedCustomersAsync(
long tenantId,
long storeId,
DateTime startAt,
DateTime endAt,
CancellationToken cancellationToken = default)
{
return context.NewCustomerGrowthRecords
.AsNoTracking()
.Where(item =>
item.TenantId == tenantId &&
item.StoreId == storeId &&
item.RegisteredAt >= startAt &&
item.RegisteredAt < endAt &&
item.GiftClaimedAt != null)
.CountAsync(cancellationToken);
}
/// <inheritdoc />
public Task<int> CountFirstOrderedCustomersAsync(
long tenantId,
long storeId,
DateTime startAt,
DateTime endAt,
CancellationToken cancellationToken = default)
{
return context.NewCustomerGrowthRecords
.AsNoTracking()
.Where(item =>
item.TenantId == tenantId &&
item.StoreId == storeId &&
item.RegisteredAt >= startAt &&
item.RegisteredAt < endAt &&
item.FirstOrderAt != null)
.CountAsync(cancellationToken);
}
/// <inheritdoc />
public Task SaveChangesAsync(CancellationToken cancellationToken = default)
{
return context.SaveChangesAsync(cancellationToken);
}
}

View File

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

View File

@@ -0,0 +1,168 @@
using System;
using Microsoft.EntityFrameworkCore.Migrations;
using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata;
#nullable disable
namespace TakeoutSaaS.Infrastructure.Migrations
{
/// <inheritdoc />
public partial class AddNewCustomerGiftModule : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.CreateTable(
name: "new_customer_coupon_rules",
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。"),
Scene = table.Column<int>(type: "integer", nullable: false, comment: "券规则场景。"),
CouponType = table.Column<int>(type: "integer", nullable: false, comment: "券类型。"),
Value = table.Column<decimal>(type: "numeric(18,2)", precision: 18, scale: 2, nullable: true, comment: "面值或折扣值。"),
MinimumSpend = table.Column<decimal>(type: "numeric(18,2)", precision: 18, scale: 2, nullable: true, comment: "使用门槛。"),
ValidDays = table.Column<int>(type: "integer", nullable: false, comment: "有效期天数。"),
SortOrder = 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_new_customer_coupon_rules", x => x.Id);
},
comment: "新客有礼券规则。");
migrationBuilder.CreateTable(
name: "new_customer_gift_settings",
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。"),
GiftEnabled = table.Column<bool>(type: "boolean", nullable: false, comment: "是否开启新客礼包。"),
GiftType = table.Column<int>(type: "integer", nullable: false, comment: "礼包类型。"),
DirectReduceAmount = table.Column<decimal>(type: "numeric(18,2)", precision: 18, scale: 2, nullable: true, comment: "首单直减金额。"),
DirectMinimumSpend = table.Column<decimal>(type: "numeric(18,2)", precision: 18, scale: 2, nullable: true, comment: "首单直减门槛金额。"),
InviteEnabled = table.Column<bool>(type: "boolean", nullable: false, comment: "是否开启老带新分享。"),
ShareChannelsJson = table.Column<string>(type: "text", nullable: false, comment: "分享渠道JSON。"),
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_new_customer_gift_settings", x => x.Id);
},
comment: "新客有礼门店配置。");
migrationBuilder.CreateTable(
name: "new_customer_growth_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。"),
CustomerKey = table.Column<string>(type: "character varying(64)", maxLength: 64, nullable: false, comment: "顾客业务唯一键。"),
CustomerName = table.Column<string>(type: "character varying(64)", maxLength: 64, nullable: true, comment: "顾客展示名。"),
RegisteredAt = table.Column<DateTime>(type: "timestamp with time zone", nullable: false, comment: "注册时间。"),
GiftClaimedAt = table.Column<DateTime>(type: "timestamp with time zone", nullable: true, comment: "礼包领取时间。"),
FirstOrderAt = table.Column<DateTime>(type: "timestamp with time zone", nullable: true, comment: "首单时间。"),
SourceChannel = table.Column<string>(type: "character varying(32)", maxLength: 32, 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_new_customer_growth_records", x => x.Id);
},
comment: "新客成长记录。");
migrationBuilder.CreateTable(
name: "new_customer_invite_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。"),
InviterName = table.Column<string>(type: "character varying(64)", maxLength: 64, nullable: false, comment: "邀请人展示名。"),
InviteeName = table.Column<string>(type: "character varying(64)", maxLength: 64, nullable: false, comment: "被邀请人展示名。"),
InviteTime = table.Column<DateTime>(type: "timestamp with time zone", nullable: false, comment: "邀请时间。"),
OrderStatus = table.Column<int>(type: "integer", nullable: false, comment: "订单状态。"),
RewardStatus = table.Column<int>(type: "integer", nullable: false, comment: "奖励状态。"),
RewardIssuedAt = table.Column<DateTime>(type: "timestamp with time zone", nullable: true, comment: "奖励发放时间。"),
SourceChannel = table.Column<string>(type: "character varying(32)", maxLength: 32, 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_new_customer_invite_records", x => x.Id);
},
comment: "新客邀请记录。");
migrationBuilder.CreateIndex(
name: "IX_new_customer_coupon_rules_TenantId_StoreId_Scene_SortOrder",
table: "new_customer_coupon_rules",
columns: new[] { "TenantId", "StoreId", "Scene", "SortOrder" });
migrationBuilder.CreateIndex(
name: "IX_new_customer_gift_settings_TenantId_StoreId",
table: "new_customer_gift_settings",
columns: new[] { "TenantId", "StoreId" },
unique: true);
migrationBuilder.CreateIndex(
name: "IX_new_customer_growth_records_TenantId_StoreId_CustomerKey",
table: "new_customer_growth_records",
columns: new[] { "TenantId", "StoreId", "CustomerKey" },
unique: true);
migrationBuilder.CreateIndex(
name: "IX_new_customer_growth_records_TenantId_StoreId_RegisteredAt",
table: "new_customer_growth_records",
columns: new[] { "TenantId", "StoreId", "RegisteredAt" });
migrationBuilder.CreateIndex(
name: "IX_new_customer_invite_records_TenantId_StoreId_InviteTime",
table: "new_customer_invite_records",
columns: new[] { "TenantId", "StoreId", "InviteTime" });
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropTable(
name: "new_customer_coupon_rules");
migrationBuilder.DropTable(
name: "new_customer_gift_settings");
migrationBuilder.DropTable(
name: "new_customer_growth_records");
migrationBuilder.DropTable(
name: "new_customer_invite_records");
}
}
}

View File

@@ -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: "固定开始日期UTCValidityType=DateRange 时有效)。"),
ValidTo = table.Column<DateTime>(type: "timestamp with time zone", nullable: true, comment: "固定结束日期UTCValidityType=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");
}
}
}

View File

@@ -604,6 +604,328 @@ namespace TakeoutSaaS.Infrastructure.Migrations
});
});
modelBuilder.Entity("TakeoutSaaS.Domain.Coupons.Entities.NewCustomerCouponRule", b =>
{
b.Property<long>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("bigint")
.HasComment("实体唯一标识。");
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<long>("Id"));
b.Property<int>("CouponType")
.HasColumnType("integer")
.HasComment("券类型。");
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?>("MinimumSpend")
.HasPrecision(18, 2)
.HasColumnType("numeric(18,2)")
.HasComment("使用门槛。");
b.Property<int>("Scene")
.HasColumnType("integer")
.HasComment("券规则场景。");
b.Property<int>("SortOrder")
.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<int>("ValidDays")
.HasColumnType("integer")
.HasComment("有效期天数。");
b.Property<decimal?>("Value")
.HasPrecision(18, 2)
.HasColumnType("numeric(18,2)")
.HasComment("面值或折扣值。");
b.HasKey("Id");
b.HasIndex("TenantId", "StoreId", "Scene", "SortOrder");
b.ToTable("new_customer_coupon_rules", null, t =>
{
t.HasComment("新客有礼券规则。");
});
});
modelBuilder.Entity("TakeoutSaaS.Domain.Coupons.Entities.NewCustomerGiftSetting", 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?>("DirectMinimumSpend")
.HasPrecision(18, 2)
.HasColumnType("numeric(18,2)")
.HasComment("首单直减门槛金额。");
b.Property<decimal?>("DirectReduceAmount")
.HasPrecision(18, 2)
.HasColumnType("numeric(18,2)")
.HasComment("首单直减金额。");
b.Property<bool>("GiftEnabled")
.HasColumnType("boolean")
.HasComment("是否开启新客礼包。");
b.Property<int>("GiftType")
.HasColumnType("integer")
.HasComment("礼包类型。");
b.Property<bool>("InviteEnabled")
.HasColumnType("boolean")
.HasComment("是否开启老带新分享。");
b.Property<string>("ShareChannelsJson")
.IsRequired()
.HasColumnType("text")
.HasComment("分享渠道JSON。");
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.HasKey("Id");
b.HasIndex("TenantId", "StoreId")
.IsUnique();
b.ToTable("new_customer_gift_settings", null, t =>
{
t.HasComment("新客有礼门店配置。");
});
});
modelBuilder.Entity("TakeoutSaaS.Domain.Coupons.Entities.NewCustomerGrowthRecord", 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<string>("CustomerKey")
.IsRequired()
.HasMaxLength(64)
.HasColumnType("character varying(64)")
.HasComment("顾客业务唯一键。");
b.Property<string>("CustomerName")
.HasMaxLength(64)
.HasColumnType("character varying(64)")
.HasComment("顾客展示名。");
b.Property<DateTime?>("DeletedAt")
.HasColumnType("timestamp with time zone")
.HasComment("软删除时间UTC未删除时为 null。");
b.Property<long?>("DeletedBy")
.HasColumnType("bigint")
.HasComment("删除人用户标识(软删除),未删除时为 null。");
b.Property<DateTime?>("FirstOrderAt")
.HasColumnType("timestamp with time zone")
.HasComment("首单时间。");
b.Property<DateTime?>("GiftClaimedAt")
.HasColumnType("timestamp with time zone")
.HasComment("礼包领取时间。");
b.Property<DateTime>("RegisteredAt")
.HasColumnType("timestamp with time zone")
.HasComment("注册时间。");
b.Property<string>("SourceChannel")
.HasMaxLength(32)
.HasColumnType("character varying(32)")
.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.HasKey("Id");
b.HasIndex("TenantId", "StoreId", "CustomerKey")
.IsUnique();
b.HasIndex("TenantId", "StoreId", "RegisteredAt");
b.ToTable("new_customer_growth_records", null, t =>
{
t.HasComment("新客成长记录。");
});
});
modelBuilder.Entity("TakeoutSaaS.Domain.Coupons.Entities.NewCustomerInviteRecord", 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>("InviteTime")
.HasColumnType("timestamp with time zone")
.HasComment("邀请时间。");
b.Property<string>("InviteeName")
.IsRequired()
.HasMaxLength(64)
.HasColumnType("character varying(64)")
.HasComment("被邀请人展示名。");
b.Property<string>("InviterName")
.IsRequired()
.HasMaxLength(64)
.HasColumnType("character varying(64)")
.HasComment("邀请人展示名。");
b.Property<int>("OrderStatus")
.HasColumnType("integer")
.HasComment("订单状态。");
b.Property<DateTime?>("RewardIssuedAt")
.HasColumnType("timestamp with time zone")
.HasComment("奖励发放时间。");
b.Property<int>("RewardStatus")
.HasColumnType("integer")
.HasComment("奖励状态。");
b.Property<string>("SourceChannel")
.HasMaxLength(32)
.HasColumnType("character varying(32)")
.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.HasKey("Id");
b.HasIndex("TenantId", "StoreId", "InviteTime");
b.ToTable("new_customer_invite_records", null, t =>
{
t.HasComment("新客邀请记录。");
});
});
modelBuilder.Entity("TakeoutSaaS.Domain.Coupons.Entities.PromotionCampaign", b =>
{
b.Property<long>("Id")
@@ -690,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("固定开始日期UTCValidityType=DateRange 时有效)。");
b.Property<DateTime?>("ValidTo")
.HasColumnType("timestamp with time zone")
.HasComment("固定结束日期UTCValidityType=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")