feat: implement marketing punch card backend module

This commit is contained in:
2026-03-02 21:43:09 +08:00
parent 6588c85f27
commit 3b3bdcee71
48 changed files with 14863 additions and 1 deletions

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