Compare commits
3 Commits
a993b81aeb
...
d96ca4971a
| Author | SHA1 | Date | |
|---|---|---|---|
| d96ca4971a | |||
| c2821202c7 | |||
| 26afffd874 |
Submodule TakeoutSaaS.Docs updated: 7486bf272e...9006c8a589
@@ -0,0 +1,521 @@
|
|||||||
|
namespace TakeoutSaaS.TenantApi.Contracts.Customer;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 客户分析总览请求。
|
||||||
|
/// </summary>
|
||||||
|
public sealed class CustomerAnalysisOverviewRequest
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// 门店 ID(可选,未传表示当前商户全部可见门店)。
|
||||||
|
/// </summary>
|
||||||
|
public string? StoreId { get; set; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 统计周期(7d/30d/90d/365d)。
|
||||||
|
/// </summary>
|
||||||
|
public string? Period { get; set; }
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 客群明细筛选请求。
|
||||||
|
/// </summary>
|
||||||
|
public class CustomerAnalysisSegmentFilterRequest
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// 门店 ID(可选,未传表示当前商户全部可见门店)。
|
||||||
|
/// </summary>
|
||||||
|
public string? StoreId { get; set; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 统计周期(7d/30d/90d/365d)。
|
||||||
|
/// </summary>
|
||||||
|
public string? Period { get; set; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 分群编码。
|
||||||
|
/// </summary>
|
||||||
|
public string SegmentCode { get; set; } = "all";
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 关键词(姓名/手机号)。
|
||||||
|
/// </summary>
|
||||||
|
public string? Keyword { get; set; }
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 客群明细分页请求。
|
||||||
|
/// </summary>
|
||||||
|
public sealed class CustomerAnalysisSegmentListRequest : CustomerAnalysisSegmentFilterRequest
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// 页码。
|
||||||
|
/// </summary>
|
||||||
|
public int Page { get; set; } = 1;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 每页条数。
|
||||||
|
/// </summary>
|
||||||
|
public int PageSize { get; set; } = 10;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 会员详情请求。
|
||||||
|
/// </summary>
|
||||||
|
public sealed class CustomerMemberDetailRequest
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// 门店 ID(可选,未传表示当前商户全部可见门店)。
|
||||||
|
/// </summary>
|
||||||
|
public string? StoreId { get; set; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 客户标识(手机号归一化)。
|
||||||
|
/// </summary>
|
||||||
|
public string CustomerKey { get; set; } = string.Empty;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 客户分析导出请求。
|
||||||
|
/// </summary>
|
||||||
|
public sealed class CustomerAnalysisExportRequest
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// 门店 ID(可选,未传表示当前商户全部可见门店)。
|
||||||
|
/// </summary>
|
||||||
|
public string? StoreId { get; set; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 统计周期(7d/30d/90d/365d)。
|
||||||
|
/// </summary>
|
||||||
|
public string? Period { get; set; }
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 客户分析趋势点响应。
|
||||||
|
/// </summary>
|
||||||
|
public sealed class CustomerAnalysisTrendPointResponse
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// 维度标签。
|
||||||
|
/// </summary>
|
||||||
|
public string Label { get; set; } = string.Empty;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 数量值。
|
||||||
|
/// </summary>
|
||||||
|
public int Value { get; set; }
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 新老客构成项响应。
|
||||||
|
/// </summary>
|
||||||
|
public sealed class CustomerAnalysisCompositionItemResponse
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// 分群编码。
|
||||||
|
/// </summary>
|
||||||
|
public string SegmentCode { get; set; } = string.Empty;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 分群名称。
|
||||||
|
/// </summary>
|
||||||
|
public string Label { get; set; } = string.Empty;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 人数。
|
||||||
|
/// </summary>
|
||||||
|
public int Count { get; set; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 占比(百分比)。
|
||||||
|
/// </summary>
|
||||||
|
public decimal Percent { get; set; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 色调。
|
||||||
|
/// </summary>
|
||||||
|
public string Tone { get; set; } = "blue";
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 客单价分布项响应。
|
||||||
|
/// </summary>
|
||||||
|
public sealed class CustomerAnalysisAmountDistributionItemResponse
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// 分群编码。
|
||||||
|
/// </summary>
|
||||||
|
public string SegmentCode { get; set; } = string.Empty;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 区间标签。
|
||||||
|
/// </summary>
|
||||||
|
public string Label { get; set; } = string.Empty;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 人数。
|
||||||
|
/// </summary>
|
||||||
|
public int Count { get; set; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 占比(百分比)。
|
||||||
|
/// </summary>
|
||||||
|
public decimal Percent { get; set; }
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// RFM 分层单元响应。
|
||||||
|
/// </summary>
|
||||||
|
public sealed class CustomerAnalysisRfmCellResponse
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// 分群编码。
|
||||||
|
/// </summary>
|
||||||
|
public string SegmentCode { get; set; } = string.Empty;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 标签。
|
||||||
|
/// </summary>
|
||||||
|
public string Label { get; set; } = string.Empty;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 人数。
|
||||||
|
/// </summary>
|
||||||
|
public int Count { get; set; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 温度(hot/warm/cool/cold)。
|
||||||
|
/// </summary>
|
||||||
|
public string Tone { get; set; } = "cold";
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// RFM 分层行响应。
|
||||||
|
/// </summary>
|
||||||
|
public sealed class CustomerAnalysisRfmRowResponse
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// 行标签。
|
||||||
|
/// </summary>
|
||||||
|
public string Label { get; set; } = string.Empty;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 单元格集合。
|
||||||
|
/// </summary>
|
||||||
|
public List<CustomerAnalysisRfmCellResponse> Cells { get; set; } = [];
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 高价值客户响应。
|
||||||
|
/// </summary>
|
||||||
|
public sealed class CustomerAnalysisTopCustomerResponse
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// 排名。
|
||||||
|
/// </summary>
|
||||||
|
public int Rank { get; set; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 客户标识。
|
||||||
|
/// </summary>
|
||||||
|
public string CustomerKey { get; set; } = string.Empty;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 客户名称。
|
||||||
|
/// </summary>
|
||||||
|
public string Name { get; set; } = string.Empty;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 手机号(脱敏)。
|
||||||
|
/// </summary>
|
||||||
|
public string PhoneMasked { get; set; } = string.Empty;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 累计消费。
|
||||||
|
/// </summary>
|
||||||
|
public decimal TotalAmount { get; set; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 下单次数。
|
||||||
|
/// </summary>
|
||||||
|
public int OrderCount { get; set; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 客单价。
|
||||||
|
/// </summary>
|
||||||
|
public decimal AverageAmount { get; set; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 最近下单时间(yyyy-MM-dd)。
|
||||||
|
/// </summary>
|
||||||
|
public string LastOrderAt { get; set; } = string.Empty;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 客户标签。
|
||||||
|
/// </summary>
|
||||||
|
public List<CustomerTagResponse> Tags { get; set; } = [];
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 客户分析总览响应。
|
||||||
|
/// </summary>
|
||||||
|
public sealed class CustomerAnalysisOverviewResponse
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// 统计周期编码。
|
||||||
|
/// </summary>
|
||||||
|
public string PeriodCode { get; set; } = "30d";
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 统计周期天数。
|
||||||
|
/// </summary>
|
||||||
|
public int PeriodDays { get; set; } = 30;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 客户总数。
|
||||||
|
/// </summary>
|
||||||
|
public int TotalCustomers { get; set; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 周期新增客户数。
|
||||||
|
/// </summary>
|
||||||
|
public int NewCustomers { get; set; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 新增较上一周期增长百分比。
|
||||||
|
/// </summary>
|
||||||
|
public decimal GrowthRatePercent { get; set; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 周期内日均新增客户。
|
||||||
|
/// </summary>
|
||||||
|
public decimal NewCustomersDailyAverage { get; set; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 活跃客户数。
|
||||||
|
/// </summary>
|
||||||
|
public int ActiveCustomers { get; set; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 活跃率(百分比)。
|
||||||
|
/// </summary>
|
||||||
|
public decimal ActiveRatePercent { get; set; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 平均客户价值。
|
||||||
|
/// </summary>
|
||||||
|
public decimal AverageLifetimeValue { get; set; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 客户增长趋势。
|
||||||
|
/// </summary>
|
||||||
|
public List<CustomerAnalysisTrendPointResponse> GrowthTrend { get; set; } = [];
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 新老客占比。
|
||||||
|
/// </summary>
|
||||||
|
public List<CustomerAnalysisCompositionItemResponse> Composition { get; set; } = [];
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 客单价分布。
|
||||||
|
/// </summary>
|
||||||
|
public List<CustomerAnalysisAmountDistributionItemResponse> AmountDistribution { get; set; } = [];
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// RFM 分层。
|
||||||
|
/// </summary>
|
||||||
|
public List<CustomerAnalysisRfmRowResponse> RfmRows { get; set; } = [];
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 高价值客户 Top10。
|
||||||
|
/// </summary>
|
||||||
|
public List<CustomerAnalysisTopCustomerResponse> TopCustomers { get; set; } = [];
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 客群明细行响应。
|
||||||
|
/// </summary>
|
||||||
|
public sealed class CustomerAnalysisSegmentListItemResponse
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// 客户标识。
|
||||||
|
/// </summary>
|
||||||
|
public string CustomerKey { get; set; } = string.Empty;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 客户名称。
|
||||||
|
/// </summary>
|
||||||
|
public string Name { get; set; } = string.Empty;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 手机号(脱敏)。
|
||||||
|
/// </summary>
|
||||||
|
public string PhoneMasked { get; set; } = string.Empty;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 头像文案。
|
||||||
|
/// </summary>
|
||||||
|
public string AvatarText { get; set; } = string.Empty;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 头像颜色。
|
||||||
|
/// </summary>
|
||||||
|
public string AvatarColor { get; set; } = string.Empty;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 客户标签。
|
||||||
|
/// </summary>
|
||||||
|
public List<CustomerTagResponse> Tags { get; set; } = [];
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 是否会员。
|
||||||
|
/// </summary>
|
||||||
|
public bool IsMember { get; set; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 会员等级。
|
||||||
|
/// </summary>
|
||||||
|
public string MemberTierName { get; set; } = string.Empty;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 累计消费。
|
||||||
|
/// </summary>
|
||||||
|
public decimal TotalAmount { get; set; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 下单次数。
|
||||||
|
/// </summary>
|
||||||
|
public int OrderCount { get; set; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 客单价。
|
||||||
|
/// </summary>
|
||||||
|
public decimal AverageAmount { get; set; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 注册时间(yyyy-MM-dd)。
|
||||||
|
/// </summary>
|
||||||
|
public string RegisteredAt { get; set; } = string.Empty;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 最近下单时间(yyyy-MM-dd)。
|
||||||
|
/// </summary>
|
||||||
|
public string LastOrderAt { get; set; } = string.Empty;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 是否弱化显示。
|
||||||
|
/// </summary>
|
||||||
|
public bool IsDimmed { get; set; }
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 客群明细分页响应。
|
||||||
|
/// </summary>
|
||||||
|
public sealed class CustomerAnalysisSegmentListResultResponse
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// 分群编码。
|
||||||
|
/// </summary>
|
||||||
|
public string SegmentCode { get; set; } = string.Empty;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 分群标题。
|
||||||
|
/// </summary>
|
||||||
|
public string SegmentTitle { get; set; } = string.Empty;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 分群说明。
|
||||||
|
/// </summary>
|
||||||
|
public string SegmentDescription { get; set; } = string.Empty;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 列表项。
|
||||||
|
/// </summary>
|
||||||
|
public List<CustomerAnalysisSegmentListItemResponse> 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 CustomerMemberDetailResponse
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// 客户标识。
|
||||||
|
/// </summary>
|
||||||
|
public string CustomerKey { get; set; } = string.Empty;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 客户名称。
|
||||||
|
/// </summary>
|
||||||
|
public string Name { get; set; } = string.Empty;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 手机号(脱敏)。
|
||||||
|
/// </summary>
|
||||||
|
public string PhoneMasked { get; set; } = string.Empty;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 来源。
|
||||||
|
/// </summary>
|
||||||
|
public string Source { get; set; } = string.Empty;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 注册时间(yyyy-MM-dd)。
|
||||||
|
/// </summary>
|
||||||
|
public string RegisteredAt { get; set; } = string.Empty;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 最近下单时间(yyyy-MM-dd)。
|
||||||
|
/// </summary>
|
||||||
|
public string LastOrderAt { get; set; } = string.Empty;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 会员摘要。
|
||||||
|
/// </summary>
|
||||||
|
public CustomerMemberSummaryResponse Member { get; set; } = new();
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 客户标签。
|
||||||
|
/// </summary>
|
||||||
|
public List<CustomerTagResponse> Tags { get; set; } = [];
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 累计下单次数。
|
||||||
|
/// </summary>
|
||||||
|
public int TotalOrders { get; set; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 累计消费。
|
||||||
|
/// </summary>
|
||||||
|
public decimal TotalAmount { get; set; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 客单价。
|
||||||
|
/// </summary>
|
||||||
|
public decimal AverageAmount { get; set; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 复购率(百分比)。
|
||||||
|
/// </summary>
|
||||||
|
public decimal RepurchaseRatePercent { get; set; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 最近订单。
|
||||||
|
/// </summary>
|
||||||
|
public List<CustomerRecentOrderResponse> RecentOrders { get; set; } = [];
|
||||||
|
}
|
||||||
@@ -0,0 +1,346 @@
|
|||||||
|
namespace TakeoutSaaS.TenantApi.Contracts.Member;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 会员列表筛选请求。
|
||||||
|
/// </summary>
|
||||||
|
public class MemberListFilterRequest
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// 门店 ID(可选,未传表示当前商户全部可见门店)。
|
||||||
|
/// </summary>
|
||||||
|
public string? StoreId { get; set; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 关键词(姓名/手机号)。
|
||||||
|
/// </summary>
|
||||||
|
public string? Keyword { get; set; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 等级标识。
|
||||||
|
/// </summary>
|
||||||
|
public string? TierId { get; set; }
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 会员列表分页请求。
|
||||||
|
/// </summary>
|
||||||
|
public sealed class MemberListRequest : MemberListFilterRequest
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// 页码。
|
||||||
|
/// </summary>
|
||||||
|
public int Page { get; set; } = 1;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 每页条数。
|
||||||
|
/// </summary>
|
||||||
|
public int PageSize { get; set; } = 10;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 会员详情请求。
|
||||||
|
/// </summary>
|
||||||
|
public sealed class MemberDetailRequest
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// 门店 ID(可选,未传表示当前商户全部可见门店)。
|
||||||
|
/// </summary>
|
||||||
|
public string? StoreId { get; set; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 会员标识。
|
||||||
|
/// </summary>
|
||||||
|
public string MemberId { get; set; } = string.Empty;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 保存会员标签请求。
|
||||||
|
/// </summary>
|
||||||
|
public sealed class SaveMemberTagsRequest
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// 门店 ID(可选,未传表示当前商户全部可见门店)。
|
||||||
|
/// </summary>
|
||||||
|
public string? StoreId { get; set; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 会员标识。
|
||||||
|
/// </summary>
|
||||||
|
public string MemberId { get; set; } = string.Empty;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 标签集合。
|
||||||
|
/// </summary>
|
||||||
|
public List<string> Tags { get; set; } = [];
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 会员列表行响应。
|
||||||
|
/// </summary>
|
||||||
|
public sealed class MemberListItemResponse
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// 会员标识。
|
||||||
|
/// </summary>
|
||||||
|
public string MemberId { get; set; } = string.Empty;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 会员名称。
|
||||||
|
/// </summary>
|
||||||
|
public string Name { get; set; } = string.Empty;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 头像文案。
|
||||||
|
/// </summary>
|
||||||
|
public string AvatarText { get; set; } = string.Empty;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 头像颜色。
|
||||||
|
/// </summary>
|
||||||
|
public string AvatarColor { get; set; } = string.Empty;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 手机号(脱敏)。
|
||||||
|
/// </summary>
|
||||||
|
public string MobileMasked { get; set; } = string.Empty;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 会员等级标识。
|
||||||
|
/// </summary>
|
||||||
|
public string? TierId { get; set; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 会员等级名称。
|
||||||
|
/// </summary>
|
||||||
|
public string TierName { get; set; } = string.Empty;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 等级主题色。
|
||||||
|
/// </summary>
|
||||||
|
public string TierColorHex { get; set; } = string.Empty;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 累计消费。
|
||||||
|
/// </summary>
|
||||||
|
public decimal TotalAmount { get; set; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 消费次数。
|
||||||
|
/// </summary>
|
||||||
|
public int OrderCount { get; set; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 最近消费时间(yyyy-MM-dd)。
|
||||||
|
/// </summary>
|
||||||
|
public string LastOrderAt { get; set; } = string.Empty;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 储值余额。
|
||||||
|
/// </summary>
|
||||||
|
public decimal StoredBalance { get; set; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 积分余额。
|
||||||
|
/// </summary>
|
||||||
|
public int PointsBalance { get; set; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 是否沉睡会员。
|
||||||
|
/// </summary>
|
||||||
|
public bool IsDormant { get; set; }
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 会员列表响应。
|
||||||
|
/// </summary>
|
||||||
|
public sealed class MemberListResultResponse
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// 列表项。
|
||||||
|
/// </summary>
|
||||||
|
public List<MemberListItemResponse> Items { get; set; } = [];
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 总数。
|
||||||
|
/// </summary>
|
||||||
|
public int Total { get; set; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 页码。
|
||||||
|
/// </summary>
|
||||||
|
public int Page { get; set; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 每页条数。
|
||||||
|
/// </summary>
|
||||||
|
public int PageSize { get; set; }
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 会员列表统计响应。
|
||||||
|
/// </summary>
|
||||||
|
public sealed class MemberListStatsResponse
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// 会员总数。
|
||||||
|
/// </summary>
|
||||||
|
public int TotalMembers { get; set; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 本月新增会员数。
|
||||||
|
/// </summary>
|
||||||
|
public int MonthlyNewMembers { get; set; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 活跃会员数。
|
||||||
|
/// </summary>
|
||||||
|
public int ActiveMembers { get; set; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 沉睡会员数。
|
||||||
|
/// </summary>
|
||||||
|
public int DormantMembers { get; set; }
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 会员最近订单响应。
|
||||||
|
/// </summary>
|
||||||
|
public sealed class MemberRecentOrderResponse
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// 下单日期(yyyy-MM-dd)。
|
||||||
|
/// </summary>
|
||||||
|
public string OrderedAt { get; set; } = string.Empty;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 订单号。
|
||||||
|
/// </summary>
|
||||||
|
public string OrderNo { get; set; } = string.Empty;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 订单金额。
|
||||||
|
/// </summary>
|
||||||
|
public decimal Amount { get; set; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 订单状态文案。
|
||||||
|
/// </summary>
|
||||||
|
public string StatusText { get; set; } = string.Empty;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 会员详情响应。
|
||||||
|
/// </summary>
|
||||||
|
public sealed class MemberDetailResponse
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// 会员标识。
|
||||||
|
/// </summary>
|
||||||
|
public string MemberId { get; set; } = string.Empty;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 会员名称。
|
||||||
|
/// </summary>
|
||||||
|
public string Name { get; set; } = string.Empty;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 头像文案。
|
||||||
|
/// </summary>
|
||||||
|
public string AvatarText { get; set; } = string.Empty;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 头像颜色。
|
||||||
|
/// </summary>
|
||||||
|
public string AvatarColor { get; set; } = string.Empty;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 手机号(脱敏)。
|
||||||
|
/// </summary>
|
||||||
|
public string MobileMasked { get; set; } = string.Empty;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 注册时间(yyyy-MM-dd)。
|
||||||
|
/// </summary>
|
||||||
|
public string JoinedAt { get; set; } = string.Empty;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 会员等级标识。
|
||||||
|
/// </summary>
|
||||||
|
public string? TierId { get; set; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 会员等级名称。
|
||||||
|
/// </summary>
|
||||||
|
public string TierName { get; set; } = string.Empty;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 等级主题色。
|
||||||
|
/// </summary>
|
||||||
|
public string TierColorHex { get; set; } = string.Empty;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 累计消费。
|
||||||
|
/// </summary>
|
||||||
|
public decimal TotalAmount { get; set; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 消费次数。
|
||||||
|
/// </summary>
|
||||||
|
public int OrderCount { get; set; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 平均客单价。
|
||||||
|
/// </summary>
|
||||||
|
public decimal AverageAmount { get; set; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 储值余额。
|
||||||
|
/// </summary>
|
||||||
|
public decimal StoredBalance { get; set; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 储值实充余额。
|
||||||
|
/// </summary>
|
||||||
|
public decimal StoredRechargeBalance { get; set; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 储值赠金余额。
|
||||||
|
/// </summary>
|
||||||
|
public decimal StoredGiftBalance { get; set; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 积分余额。
|
||||||
|
/// </summary>
|
||||||
|
public int PointsBalance { get; set; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 会员标签。
|
||||||
|
/// </summary>
|
||||||
|
public List<string> Tags { get; set; } = [];
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 最近订单。
|
||||||
|
/// </summary>
|
||||||
|
public List<MemberRecentOrderResponse> RecentOrders { get; set; } = [];
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 会员导出响应。
|
||||||
|
/// </summary>
|
||||||
|
public sealed class MemberExportResponse
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// 文件名。
|
||||||
|
/// </summary>
|
||||||
|
public string FileName { get; set; } = string.Empty;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 文件内容 Base64。
|
||||||
|
/// </summary>
|
||||||
|
public string FileContentBase64 { get; set; } = string.Empty;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 导出总数。
|
||||||
|
/// </summary>
|
||||||
|
public int TotalCount { get; set; }
|
||||||
|
}
|
||||||
@@ -0,0 +1,427 @@
|
|||||||
|
namespace TakeoutSaaS.TenantApi.Contracts.Member;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 会员等级列表项响应。
|
||||||
|
/// </summary>
|
||||||
|
public sealed class MemberTierListItemResponse
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// 等级标识。
|
||||||
|
/// </summary>
|
||||||
|
public string TierId { get; set; } = string.Empty;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 排序序号。
|
||||||
|
/// </summary>
|
||||||
|
public int SortOrder { get; set; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 等级名称。
|
||||||
|
/// </summary>
|
||||||
|
public string Name { get; set; } = string.Empty;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 图标键。
|
||||||
|
/// </summary>
|
||||||
|
public string IconKey { get; set; } = string.Empty;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 主题色。
|
||||||
|
/// </summary>
|
||||||
|
public string ColorHex { get; set; } = string.Empty;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 升级条件文案。
|
||||||
|
/// </summary>
|
||||||
|
public string ConditionText { get; set; } = string.Empty;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 权益摘要。
|
||||||
|
/// </summary>
|
||||||
|
public List<string> Perks { get; set; } = [];
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 等级会员数。
|
||||||
|
/// </summary>
|
||||||
|
public int MemberCount { get; set; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 是否默认等级。
|
||||||
|
/// </summary>
|
||||||
|
public bool IsDefault { get; set; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 是否可删除。
|
||||||
|
/// </summary>
|
||||||
|
public bool CanDelete { get; set; }
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 等级详情查询请求。
|
||||||
|
/// </summary>
|
||||||
|
public sealed class MemberTierDetailRequest
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// 等级标识。
|
||||||
|
/// </summary>
|
||||||
|
public string? TierId { get; set; }
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 等级规则响应。
|
||||||
|
/// </summary>
|
||||||
|
public sealed class MemberTierRuleResponse
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// 升级规则类型。
|
||||||
|
/// </summary>
|
||||||
|
public string UpgradeRuleType { get; set; } = "none";
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 升级累计消费门槛。
|
||||||
|
/// </summary>
|
||||||
|
public decimal? UpgradeAmountThreshold { get; set; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 升级消费次数门槛。
|
||||||
|
/// </summary>
|
||||||
|
public int? UpgradeOrderCountThreshold { get; set; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 降级观察窗口天数。
|
||||||
|
/// </summary>
|
||||||
|
public int DowngradeWindowDays { get; set; }
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 折扣权益响应。
|
||||||
|
/// </summary>
|
||||||
|
public sealed class MemberTierDiscountBenefitResponse
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// 是否启用。
|
||||||
|
/// </summary>
|
||||||
|
public bool Enabled { get; set; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 折扣值。
|
||||||
|
/// </summary>
|
||||||
|
public decimal? DiscountRate { get; set; }
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 积分倍率权益响应。
|
||||||
|
/// </summary>
|
||||||
|
public sealed class MemberTierPointMultiplierBenefitResponse
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// 是否启用。
|
||||||
|
/// </summary>
|
||||||
|
public bool Enabled { get; set; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 倍率。
|
||||||
|
/// </summary>
|
||||||
|
public decimal? Multiplier { get; set; }
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 生日特权响应。
|
||||||
|
/// </summary>
|
||||||
|
public sealed class MemberTierBirthdayBenefitResponse
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// 是否启用。
|
||||||
|
/// </summary>
|
||||||
|
public bool Enabled { get; set; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 是否双倍积分。
|
||||||
|
/// </summary>
|
||||||
|
public bool DoublePointsEnabled { get; set; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 券模板 ID。
|
||||||
|
/// </summary>
|
||||||
|
public List<string> CouponTemplateIds { get; set; } = [];
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 每月赠券响应。
|
||||||
|
/// </summary>
|
||||||
|
public sealed class MemberTierMonthlyCouponBenefitResponse
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// 是否启用。
|
||||||
|
/// </summary>
|
||||||
|
public bool Enabled { get; set; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 每月发放日。
|
||||||
|
/// </summary>
|
||||||
|
public int GrantDay { get; set; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 券模板 ID。
|
||||||
|
/// </summary>
|
||||||
|
public List<string> CouponTemplateIds { get; set; } = [];
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 免配送费权益响应。
|
||||||
|
/// </summary>
|
||||||
|
public sealed class MemberTierFreeDeliveryBenefitResponse
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// 是否启用。
|
||||||
|
/// </summary>
|
||||||
|
public bool Enabled { get; set; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 每月免配送费次数。
|
||||||
|
/// </summary>
|
||||||
|
public int MonthlyFreeTimes { get; set; }
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 等级权益响应。
|
||||||
|
/// </summary>
|
||||||
|
public sealed class MemberTierBenefitsResponse
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// 折扣权益。
|
||||||
|
/// </summary>
|
||||||
|
public MemberTierDiscountBenefitResponse Discount { get; set; } = new();
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 积分倍率权益。
|
||||||
|
/// </summary>
|
||||||
|
public MemberTierPointMultiplierBenefitResponse PointMultiplier { get; set; } = new();
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 生日特权。
|
||||||
|
/// </summary>
|
||||||
|
public MemberTierBirthdayBenefitResponse Birthday { get; set; } = new();
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 每月赠券。
|
||||||
|
/// </summary>
|
||||||
|
public MemberTierMonthlyCouponBenefitResponse MonthlyCoupon { get; set; } = new();
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 免配送费权益。
|
||||||
|
/// </summary>
|
||||||
|
public MemberTierFreeDeliveryBenefitResponse FreeDelivery { get; set; } = new();
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 优先配送。
|
||||||
|
/// </summary>
|
||||||
|
public bool PriorityDeliveryEnabled { get; set; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 专属客服。
|
||||||
|
/// </summary>
|
||||||
|
public bool ExclusiveServiceEnabled { get; set; }
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 等级详情响应。
|
||||||
|
/// </summary>
|
||||||
|
public sealed class MemberTierDetailResponse
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// 等级标识。
|
||||||
|
/// </summary>
|
||||||
|
public string? TierId { get; set; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 排序序号。
|
||||||
|
/// </summary>
|
||||||
|
public int SortOrder { get; set; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 等级名称。
|
||||||
|
/// </summary>
|
||||||
|
public string Name { get; set; } = string.Empty;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 图标键。
|
||||||
|
/// </summary>
|
||||||
|
public string IconKey { get; set; } = string.Empty;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 主题色。
|
||||||
|
/// </summary>
|
||||||
|
public string ColorHex { get; set; } = string.Empty;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 是否默认等级。
|
||||||
|
/// </summary>
|
||||||
|
public bool IsDefault { get; set; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 升降级规则。
|
||||||
|
/// </summary>
|
||||||
|
public MemberTierRuleResponse Rule { get; set; } = new();
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 等级权益。
|
||||||
|
/// </summary>
|
||||||
|
public MemberTierBenefitsResponse Benefits { get; set; } = new();
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 是否可删除。
|
||||||
|
/// </summary>
|
||||||
|
public bool CanDelete { get; set; }
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 保存等级请求。
|
||||||
|
/// </summary>
|
||||||
|
public sealed class SaveMemberTierRequest
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// 等级标识(为空时新增)。
|
||||||
|
/// </summary>
|
||||||
|
public string? TierId { get; set; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 排序序号。
|
||||||
|
/// </summary>
|
||||||
|
public int SortOrder { get; set; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 等级名称。
|
||||||
|
/// </summary>
|
||||||
|
public string Name { get; set; } = string.Empty;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 图标键。
|
||||||
|
/// </summary>
|
||||||
|
public string IconKey { get; set; } = "user";
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 主题色。
|
||||||
|
/// </summary>
|
||||||
|
public string ColorHex { get; set; } = "#999999";
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 是否默认等级。
|
||||||
|
/// </summary>
|
||||||
|
public bool IsDefault { get; set; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 升降级规则。
|
||||||
|
/// </summary>
|
||||||
|
public MemberTierRuleResponse Rule { get; set; } = new();
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 等级权益。
|
||||||
|
/// </summary>
|
||||||
|
public MemberTierBenefitsResponse Benefits { get; set; } = new();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 删除等级请求。
|
||||||
|
/// </summary>
|
||||||
|
public sealed class DeleteMemberTierRequest
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// 等级标识。
|
||||||
|
/// </summary>
|
||||||
|
public string TierId { get; set; } = string.Empty;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 会员日配置响应。
|
||||||
|
/// </summary>
|
||||||
|
public sealed class MemberDaySettingResponse
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// 是否启用会员日。
|
||||||
|
/// </summary>
|
||||||
|
public bool IsEnabled { get; set; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 周几(1-7,对应周一到周日)。
|
||||||
|
/// </summary>
|
||||||
|
public int Weekday { get; set; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 会员日额外折扣。
|
||||||
|
/// </summary>
|
||||||
|
public decimal ExtraDiscountRate { get; set; }
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 保存会员日配置请求。
|
||||||
|
/// </summary>
|
||||||
|
public sealed class SaveMemberDaySettingRequest
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// 是否启用会员日。
|
||||||
|
/// </summary>
|
||||||
|
public bool IsEnabled { get; set; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 周几(1-7,对应周一到周日)。
|
||||||
|
/// </summary>
|
||||||
|
public int Weekday { get; set; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 会员日额外折扣。
|
||||||
|
/// </summary>
|
||||||
|
public decimal ExtraDiscountRate { get; set; }
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 优惠券选择器请求。
|
||||||
|
/// </summary>
|
||||||
|
public sealed class MemberCouponPickerRequest
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// 门店 ID(可选)。
|
||||||
|
/// </summary>
|
||||||
|
public string? StoreId { get; set; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 关键词。
|
||||||
|
/// </summary>
|
||||||
|
public string? Keyword { get; set; }
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 优惠券选择器项响应。
|
||||||
|
/// </summary>
|
||||||
|
public sealed class MemberCouponPickerItemResponse
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// 券模板标识。
|
||||||
|
/// </summary>
|
||||||
|
public string CouponTemplateId { get; set; } = string.Empty;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 券名称。
|
||||||
|
/// </summary>
|
||||||
|
public string Name { get; set; } = string.Empty;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 券类型。
|
||||||
|
/// </summary>
|
||||||
|
public string CouponType { get; set; } = string.Empty;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 面值或折扣值。
|
||||||
|
/// </summary>
|
||||||
|
public decimal Value { get; set; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 最低消费门槛。
|
||||||
|
/// </summary>
|
||||||
|
public decimal? MinimumSpend { get; set; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 展示文案。
|
||||||
|
/// </summary>
|
||||||
|
public string DisplayText { get; set; } = string.Empty;
|
||||||
|
}
|
||||||
@@ -0,0 +1,535 @@
|
|||||||
|
using System.Globalization;
|
||||||
|
using MediatR;
|
||||||
|
using Microsoft.AspNetCore.Authorization;
|
||||||
|
using Microsoft.AspNetCore.Mvc;
|
||||||
|
using Microsoft.EntityFrameworkCore;
|
||||||
|
using TakeoutSaaS.Application.App.Customers.Dto;
|
||||||
|
using TakeoutSaaS.Application.App.Customers.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.Exceptions;
|
||||||
|
using TakeoutSaaS.Shared.Abstractions.Results;
|
||||||
|
using TakeoutSaaS.Shared.Web.Api;
|
||||||
|
using TakeoutSaaS.TenantApi.Contracts.Customer;
|
||||||
|
|
||||||
|
namespace TakeoutSaaS.TenantApi.Controllers;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 客户分析。
|
||||||
|
/// </summary>
|
||||||
|
[ApiVersion("1.0")]
|
||||||
|
[Authorize]
|
||||||
|
[Route("api/tenant/v{version:apiVersion}/customer/analysis")]
|
||||||
|
public sealed class CustomerAnalysisController(
|
||||||
|
IMediator mediator,
|
||||||
|
TakeoutAppDbContext dbContext,
|
||||||
|
StoreContextService storeContextService)
|
||||||
|
: BaseApiController
|
||||||
|
{
|
||||||
|
private const string ViewPermission = "tenant:customer:analysis:view";
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 获取客户分析总览。
|
||||||
|
/// </summary>
|
||||||
|
[HttpGet("overview")]
|
||||||
|
[PermissionAuthorize(ViewPermission)]
|
||||||
|
[ProducesResponseType(typeof(ApiResponse<CustomerAnalysisOverviewResponse>), StatusCodes.Status200OK)]
|
||||||
|
public async Task<ApiResponse<CustomerAnalysisOverviewResponse>> Overview(
|
||||||
|
[FromQuery] CustomerAnalysisOverviewRequest request,
|
||||||
|
CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
var visibleStoreIds = await ResolveVisibleStoreIdsAsync(request.StoreId, cancellationToken);
|
||||||
|
var (periodCode, periodDays) = ParsePeriod(request.Period);
|
||||||
|
|
||||||
|
var result = await mediator.Send(new GetCustomerAnalysisOverviewQuery
|
||||||
|
{
|
||||||
|
VisibleStoreIds = visibleStoreIds,
|
||||||
|
PeriodCode = periodCode,
|
||||||
|
PeriodDays = periodDays
|
||||||
|
}, cancellationToken);
|
||||||
|
|
||||||
|
return ApiResponse<CustomerAnalysisOverviewResponse>.Ok(MapOverview(result));
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 获取客群明细。
|
||||||
|
/// </summary>
|
||||||
|
[HttpGet("segment/list")]
|
||||||
|
[PermissionAuthorize(ViewPermission)]
|
||||||
|
[ProducesResponseType(typeof(ApiResponse<CustomerAnalysisSegmentListResultResponse>), StatusCodes.Status200OK)]
|
||||||
|
public async Task<ApiResponse<CustomerAnalysisSegmentListResultResponse>> SegmentList(
|
||||||
|
[FromQuery] CustomerAnalysisSegmentListRequest request,
|
||||||
|
CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
var visibleStoreIds = await ResolveVisibleStoreIdsAsync(request.StoreId, cancellationToken);
|
||||||
|
var (periodCode, periodDays) = ParsePeriod(request.Period);
|
||||||
|
|
||||||
|
var result = await mediator.Send(new GetCustomerAnalysisSegmentListQuery
|
||||||
|
{
|
||||||
|
VisibleStoreIds = visibleStoreIds,
|
||||||
|
PeriodCode = periodCode,
|
||||||
|
PeriodDays = periodDays,
|
||||||
|
SegmentCode = request.SegmentCode,
|
||||||
|
Keyword = request.Keyword,
|
||||||
|
Page = Math.Max(1, request.Page),
|
||||||
|
PageSize = Math.Clamp(request.PageSize, 1, 200)
|
||||||
|
}, cancellationToken);
|
||||||
|
|
||||||
|
return ApiResponse<CustomerAnalysisSegmentListResultResponse>.Ok(MapSegmentList(result));
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 获取客户详情(分析页二级抽屉)。
|
||||||
|
/// </summary>
|
||||||
|
[HttpGet("detail")]
|
||||||
|
[PermissionAuthorize(ViewPermission)]
|
||||||
|
[ProducesResponseType(typeof(ApiResponse<CustomerDetailResponse>), StatusCodes.Status200OK)]
|
||||||
|
public async Task<ApiResponse<CustomerDetailResponse>> Detail(
|
||||||
|
[FromQuery] CustomerDetailRequest request,
|
||||||
|
CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
var customerKey = NormalizePhone(request.CustomerKey);
|
||||||
|
if (string.IsNullOrWhiteSpace(customerKey))
|
||||||
|
{
|
||||||
|
return ApiResponse<CustomerDetailResponse>.Error(ErrorCodes.BadRequest, "customerKey 非法");
|
||||||
|
}
|
||||||
|
|
||||||
|
var visibleStoreIds = await ResolveVisibleStoreIdsAsync(request.StoreId, cancellationToken);
|
||||||
|
var result = await mediator.Send(new GetCustomerDetailQuery
|
||||||
|
{
|
||||||
|
VisibleStoreIds = visibleStoreIds,
|
||||||
|
CustomerKey = customerKey
|
||||||
|
}, cancellationToken);
|
||||||
|
|
||||||
|
if (result is null)
|
||||||
|
{
|
||||||
|
return ApiResponse<CustomerDetailResponse>.Error(ErrorCodes.NotFound, "客户不存在");
|
||||||
|
}
|
||||||
|
|
||||||
|
return ApiResponse<CustomerDetailResponse>.Ok(MapDetail(result));
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 获取客户完整画像(分析页二级抽屉)。
|
||||||
|
/// </summary>
|
||||||
|
[HttpGet("profile")]
|
||||||
|
[PermissionAuthorize(ViewPermission)]
|
||||||
|
[ProducesResponseType(typeof(ApiResponse<CustomerProfileResponse>), StatusCodes.Status200OK)]
|
||||||
|
public async Task<ApiResponse<CustomerProfileResponse>> Profile(
|
||||||
|
[FromQuery] CustomerProfileRequest request,
|
||||||
|
CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
var customerKey = NormalizePhone(request.CustomerKey);
|
||||||
|
if (string.IsNullOrWhiteSpace(customerKey))
|
||||||
|
{
|
||||||
|
return ApiResponse<CustomerProfileResponse>.Error(ErrorCodes.BadRequest, "customerKey 非法");
|
||||||
|
}
|
||||||
|
|
||||||
|
var visibleStoreIds = await ResolveVisibleStoreIdsAsync(request.StoreId, cancellationToken);
|
||||||
|
var result = await mediator.Send(new GetCustomerProfileQuery
|
||||||
|
{
|
||||||
|
VisibleStoreIds = visibleStoreIds,
|
||||||
|
CustomerKey = customerKey
|
||||||
|
}, cancellationToken);
|
||||||
|
|
||||||
|
if (result is null)
|
||||||
|
{
|
||||||
|
return ApiResponse<CustomerProfileResponse>.Error(ErrorCodes.NotFound, "客户不存在");
|
||||||
|
}
|
||||||
|
|
||||||
|
return ApiResponse<CustomerProfileResponse>.Ok(MapProfile(result));
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 获取会员详情。
|
||||||
|
/// </summary>
|
||||||
|
[HttpGet("member/detail")]
|
||||||
|
[PermissionAuthorize(ViewPermission)]
|
||||||
|
[ProducesResponseType(typeof(ApiResponse<CustomerMemberDetailResponse>), StatusCodes.Status200OK)]
|
||||||
|
public async Task<ApiResponse<CustomerMemberDetailResponse>> MemberDetail(
|
||||||
|
[FromQuery] CustomerMemberDetailRequest request,
|
||||||
|
CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
var customerKey = NormalizePhone(request.CustomerKey);
|
||||||
|
if (string.IsNullOrWhiteSpace(customerKey))
|
||||||
|
{
|
||||||
|
return ApiResponse<CustomerMemberDetailResponse>.Error(ErrorCodes.BadRequest, "customerKey 非法");
|
||||||
|
}
|
||||||
|
|
||||||
|
var visibleStoreIds = await ResolveVisibleStoreIdsAsync(request.StoreId, cancellationToken);
|
||||||
|
|
||||||
|
var result = await mediator.Send(new GetCustomerMemberDetailQuery
|
||||||
|
{
|
||||||
|
VisibleStoreIds = visibleStoreIds,
|
||||||
|
CustomerKey = customerKey
|
||||||
|
}, cancellationToken);
|
||||||
|
|
||||||
|
if (result is null)
|
||||||
|
{
|
||||||
|
return ApiResponse<CustomerMemberDetailResponse>.Error(ErrorCodes.NotFound, "客户不存在");
|
||||||
|
}
|
||||||
|
|
||||||
|
return ApiResponse<CustomerMemberDetailResponse>.Ok(MapMemberDetail(result));
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 导出客户分析报表。
|
||||||
|
/// </summary>
|
||||||
|
[HttpGet("export")]
|
||||||
|
[PermissionAuthorize(ViewPermission)]
|
||||||
|
[ProducesResponseType(typeof(ApiResponse<CustomerExportResponse>), StatusCodes.Status200OK)]
|
||||||
|
public async Task<ApiResponse<CustomerExportResponse>> Export(
|
||||||
|
[FromQuery] CustomerAnalysisExportRequest request,
|
||||||
|
CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
var visibleStoreIds = await ResolveVisibleStoreIdsAsync(request.StoreId, cancellationToken);
|
||||||
|
var (periodCode, periodDays) = ParsePeriod(request.Period);
|
||||||
|
|
||||||
|
var result = await mediator.Send(new ExportCustomerAnalysisCsvQuery
|
||||||
|
{
|
||||||
|
VisibleStoreIds = visibleStoreIds,
|
||||||
|
PeriodCode = periodCode,
|
||||||
|
PeriodDays = periodDays
|
||||||
|
}, cancellationToken);
|
||||||
|
|
||||||
|
return ApiResponse<CustomerExportResponse>.Ok(new CustomerExportResponse
|
||||||
|
{
|
||||||
|
FileName = result.FileName,
|
||||||
|
FileContentBase64 = result.FileContentBase64,
|
||||||
|
TotalCount = result.TotalCount
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task<IReadOnlyCollection<long>> ResolveVisibleStoreIdsAsync(
|
||||||
|
string? storeId,
|
||||||
|
CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
var (tenantId, merchantId) = StoreApiHelpers.GetTenantMerchantContext(storeContextService);
|
||||||
|
|
||||||
|
if (!string.IsNullOrWhiteSpace(storeId))
|
||||||
|
{
|
||||||
|
var parsedStoreId = StoreApiHelpers.ParseRequiredSnowflake(storeId, nameof(storeId));
|
||||||
|
await StoreApiHelpers.EnsureStoreAccessibleAsync(
|
||||||
|
dbContext,
|
||||||
|
tenantId,
|
||||||
|
merchantId,
|
||||||
|
parsedStoreId,
|
||||||
|
cancellationToken);
|
||||||
|
return [parsedStoreId];
|
||||||
|
}
|
||||||
|
|
||||||
|
var allStoreIds = await dbContext.Stores
|
||||||
|
.AsNoTracking()
|
||||||
|
.Where(item => item.TenantId == tenantId && item.MerchantId == merchantId)
|
||||||
|
.Select(item => item.Id)
|
||||||
|
.OrderBy(item => item)
|
||||||
|
.ToListAsync(cancellationToken);
|
||||||
|
|
||||||
|
if (allStoreIds.Count == 0)
|
||||||
|
{
|
||||||
|
throw new BusinessException(ErrorCodes.BadRequest, "当前商户下不存在可用门店");
|
||||||
|
}
|
||||||
|
|
||||||
|
return allStoreIds;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static (string PeriodCode, int PeriodDays) ParsePeriod(string? period)
|
||||||
|
{
|
||||||
|
var normalized = (period ?? string.Empty).Trim().ToLowerInvariant();
|
||||||
|
if (string.IsNullOrWhiteSpace(normalized))
|
||||||
|
{
|
||||||
|
return ("30d", 30);
|
||||||
|
}
|
||||||
|
|
||||||
|
return normalized switch
|
||||||
|
{
|
||||||
|
"7" or "7d" => ("7d", 7),
|
||||||
|
"30" or "30d" => ("30d", 30),
|
||||||
|
"90" or "90d" => ("90d", 90),
|
||||||
|
"365" or "365d" or "1y" or "1year" => ("365d", 365),
|
||||||
|
_ => throw new BusinessException(ErrorCodes.BadRequest, "period 参数不合法")
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
private static string NormalizePhone(string? value)
|
||||||
|
{
|
||||||
|
if (string.IsNullOrWhiteSpace(value))
|
||||||
|
{
|
||||||
|
return string.Empty;
|
||||||
|
}
|
||||||
|
|
||||||
|
var chars = value.Where(char.IsDigit).ToArray();
|
||||||
|
return chars.Length == 0 ? string.Empty : new string(chars);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static CustomerAnalysisOverviewResponse MapOverview(CustomerAnalysisOverviewDto source)
|
||||||
|
{
|
||||||
|
return new CustomerAnalysisOverviewResponse
|
||||||
|
{
|
||||||
|
PeriodCode = source.PeriodCode,
|
||||||
|
PeriodDays = source.PeriodDays,
|
||||||
|
TotalCustomers = source.TotalCustomers,
|
||||||
|
NewCustomers = source.NewCustomers,
|
||||||
|
GrowthRatePercent = source.GrowthRatePercent,
|
||||||
|
NewCustomersDailyAverage = source.NewCustomersDailyAverage,
|
||||||
|
ActiveCustomers = source.ActiveCustomers,
|
||||||
|
ActiveRatePercent = source.ActiveRatePercent,
|
||||||
|
AverageLifetimeValue = source.AverageLifetimeValue,
|
||||||
|
GrowthTrend = source.GrowthTrend.Select(MapTrendPoint).ToList(),
|
||||||
|
Composition = source.Composition.Select(MapCompositionItem).ToList(),
|
||||||
|
AmountDistribution = source.AmountDistribution.Select(MapAmountDistributionItem).ToList(),
|
||||||
|
RfmRows = source.RfmRows.Select(MapRfmRow).ToList(),
|
||||||
|
TopCustomers = source.TopCustomers.Select(MapTopCustomer).ToList()
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
private static CustomerAnalysisTrendPointResponse MapTrendPoint(CustomerAnalysisTrendPointDto source)
|
||||||
|
{
|
||||||
|
return new CustomerAnalysisTrendPointResponse
|
||||||
|
{
|
||||||
|
Label = source.Label,
|
||||||
|
Value = source.Value
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
private static CustomerAnalysisCompositionItemResponse MapCompositionItem(CustomerAnalysisCompositionItemDto source)
|
||||||
|
{
|
||||||
|
return new CustomerAnalysisCompositionItemResponse
|
||||||
|
{
|
||||||
|
SegmentCode = source.SegmentCode,
|
||||||
|
Label = source.Label,
|
||||||
|
Count = source.Count,
|
||||||
|
Percent = source.Percent,
|
||||||
|
Tone = source.Tone
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
private static CustomerAnalysisAmountDistributionItemResponse MapAmountDistributionItem(
|
||||||
|
CustomerAnalysisAmountDistributionItemDto source)
|
||||||
|
{
|
||||||
|
return new CustomerAnalysisAmountDistributionItemResponse
|
||||||
|
{
|
||||||
|
SegmentCode = source.SegmentCode,
|
||||||
|
Label = source.Label,
|
||||||
|
Count = source.Count,
|
||||||
|
Percent = source.Percent
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
private static CustomerAnalysisRfmRowResponse MapRfmRow(CustomerAnalysisRfmRowDto source)
|
||||||
|
{
|
||||||
|
return new CustomerAnalysisRfmRowResponse
|
||||||
|
{
|
||||||
|
Label = source.Label,
|
||||||
|
Cells = source.Cells.Select(MapRfmCell).ToList()
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
private static CustomerAnalysisRfmCellResponse MapRfmCell(CustomerAnalysisRfmCellDto source)
|
||||||
|
{
|
||||||
|
return new CustomerAnalysisRfmCellResponse
|
||||||
|
{
|
||||||
|
SegmentCode = source.SegmentCode,
|
||||||
|
Label = source.Label,
|
||||||
|
Count = source.Count,
|
||||||
|
Tone = source.Tone
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
private static CustomerAnalysisTopCustomerResponse MapTopCustomer(CustomerAnalysisTopCustomerDto source)
|
||||||
|
{
|
||||||
|
return new CustomerAnalysisTopCustomerResponse
|
||||||
|
{
|
||||||
|
Rank = source.Rank,
|
||||||
|
CustomerKey = source.CustomerKey,
|
||||||
|
Name = source.Name,
|
||||||
|
PhoneMasked = source.PhoneMasked,
|
||||||
|
TotalAmount = source.TotalAmount,
|
||||||
|
OrderCount = source.OrderCount,
|
||||||
|
AverageAmount = source.AverageAmount,
|
||||||
|
LastOrderAt = ToDateOnly(source.LastOrderAt),
|
||||||
|
Tags = source.Tags.Select(MapTag).ToList()
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
private static CustomerDetailResponse MapDetail(CustomerDetailDto source)
|
||||||
|
{
|
||||||
|
return new CustomerDetailResponse
|
||||||
|
{
|
||||||
|
CustomerKey = source.CustomerKey,
|
||||||
|
Name = source.Name,
|
||||||
|
PhoneMasked = source.PhoneMasked,
|
||||||
|
RegisteredAt = ToDateOnly(source.RegisteredAt),
|
||||||
|
FirstOrderAt = ToDateOnly(source.FirstOrderAt),
|
||||||
|
Source = source.Source,
|
||||||
|
Tags = source.Tags.Select(MapTag).ToList(),
|
||||||
|
Member = MapMember(source.Member),
|
||||||
|
TotalOrders = source.TotalOrders,
|
||||||
|
TotalAmount = source.TotalAmount,
|
||||||
|
AverageAmount = source.AverageAmount,
|
||||||
|
RepurchaseRatePercent = source.RepurchaseRatePercent,
|
||||||
|
Preference = MapPreference(source.Preference),
|
||||||
|
TopProducts = source.TopProducts.Select(MapTopProduct).ToList(),
|
||||||
|
Trend = source.Trend.Select(MapTrend).ToList(),
|
||||||
|
RecentOrders = source.RecentOrders.Select(MapRecentOrder).ToList()
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
private static CustomerProfileResponse MapProfile(CustomerProfileDto source)
|
||||||
|
{
|
||||||
|
return new CustomerProfileResponse
|
||||||
|
{
|
||||||
|
CustomerKey = source.CustomerKey,
|
||||||
|
Name = source.Name,
|
||||||
|
PhoneMasked = source.PhoneMasked,
|
||||||
|
RegisteredAt = ToDateOnly(source.RegisteredAt),
|
||||||
|
FirstOrderAt = ToDateOnly(source.FirstOrderAt),
|
||||||
|
Source = source.Source,
|
||||||
|
Tags = source.Tags.Select(MapTag).ToList(),
|
||||||
|
Member = MapMember(source.Member),
|
||||||
|
TotalOrders = source.TotalOrders,
|
||||||
|
TotalAmount = source.TotalAmount,
|
||||||
|
AverageAmount = source.AverageAmount,
|
||||||
|
RepurchaseRatePercent = source.RepurchaseRatePercent,
|
||||||
|
AverageOrderIntervalDays = source.AverageOrderIntervalDays,
|
||||||
|
Preference = MapPreference(source.Preference),
|
||||||
|
TopProducts = source.TopProducts.Select(MapTopProduct).ToList(),
|
||||||
|
Trend = source.Trend.Select(MapTrend).ToList(),
|
||||||
|
RecentOrders = source.RecentOrders.Select(MapRecentOrder).ToList()
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
private static CustomerAnalysisSegmentListResultResponse MapSegmentList(CustomerAnalysisSegmentListResultDto source)
|
||||||
|
{
|
||||||
|
return new CustomerAnalysisSegmentListResultResponse
|
||||||
|
{
|
||||||
|
SegmentCode = source.SegmentCode,
|
||||||
|
SegmentTitle = source.SegmentTitle,
|
||||||
|
SegmentDescription = source.SegmentDescription,
|
||||||
|
Items = source.Items.Select(MapSegmentListItem).ToList(),
|
||||||
|
Page = source.Page,
|
||||||
|
PageSize = source.PageSize,
|
||||||
|
TotalCount = source.TotalCount
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
private static CustomerAnalysisSegmentListItemResponse MapSegmentListItem(CustomerAnalysisSegmentListItemDto source)
|
||||||
|
{
|
||||||
|
return new CustomerAnalysisSegmentListItemResponse
|
||||||
|
{
|
||||||
|
CustomerKey = source.CustomerKey,
|
||||||
|
Name = source.Name,
|
||||||
|
PhoneMasked = source.PhoneMasked,
|
||||||
|
AvatarText = source.AvatarText,
|
||||||
|
AvatarColor = source.AvatarColor,
|
||||||
|
Tags = source.Tags.Select(MapTag).ToList(),
|
||||||
|
IsMember = source.IsMember,
|
||||||
|
MemberTierName = source.MemberTierName,
|
||||||
|
TotalAmount = source.TotalAmount,
|
||||||
|
OrderCount = source.OrderCount,
|
||||||
|
AverageAmount = source.AverageAmount,
|
||||||
|
RegisteredAt = ToDateOnly(source.RegisteredAt),
|
||||||
|
LastOrderAt = ToDateOnly(source.LastOrderAt),
|
||||||
|
IsDimmed = source.IsDimmed
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
private static CustomerMemberDetailResponse MapMemberDetail(CustomerMemberDetailDto source)
|
||||||
|
{
|
||||||
|
return new CustomerMemberDetailResponse
|
||||||
|
{
|
||||||
|
CustomerKey = source.CustomerKey,
|
||||||
|
Name = source.Name,
|
||||||
|
PhoneMasked = source.PhoneMasked,
|
||||||
|
Source = source.Source,
|
||||||
|
RegisteredAt = ToDateOnly(source.RegisteredAt),
|
||||||
|
LastOrderAt = ToDateOnly(source.LastOrderAt),
|
||||||
|
Member = MapMember(source.Member),
|
||||||
|
Tags = source.Tags.Select(MapTag).ToList(),
|
||||||
|
TotalOrders = source.TotalOrders,
|
||||||
|
TotalAmount = source.TotalAmount,
|
||||||
|
AverageAmount = source.AverageAmount,
|
||||||
|
RepurchaseRatePercent = source.RepurchaseRatePercent,
|
||||||
|
RecentOrders = source.RecentOrders.Select(MapRecentOrder).ToList()
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
private static CustomerTagResponse MapTag(CustomerTagDto source)
|
||||||
|
{
|
||||||
|
return new CustomerTagResponse
|
||||||
|
{
|
||||||
|
Code = source.Code,
|
||||||
|
Label = source.Label,
|
||||||
|
Tone = source.Tone
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
private static CustomerMemberSummaryResponse MapMember(CustomerMemberSummaryDto source)
|
||||||
|
{
|
||||||
|
return new CustomerMemberSummaryResponse
|
||||||
|
{
|
||||||
|
IsMember = source.IsMember,
|
||||||
|
TierName = source.TierName,
|
||||||
|
PointsBalance = source.PointsBalance,
|
||||||
|
GrowthValue = source.GrowthValue,
|
||||||
|
JoinedAt = source.JoinedAt.HasValue ? ToDateOnly(source.JoinedAt.Value) : string.Empty
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
private static CustomerPreferenceResponse MapPreference(CustomerPreferenceDto source)
|
||||||
|
{
|
||||||
|
return new CustomerPreferenceResponse
|
||||||
|
{
|
||||||
|
PreferredCategories = source.PreferredCategories.ToList(),
|
||||||
|
PreferredOrderPeaks = source.PreferredOrderPeaks,
|
||||||
|
PreferredDelivery = source.PreferredDelivery,
|
||||||
|
PreferredPaymentMethod = source.PreferredPaymentMethod,
|
||||||
|
AverageDeliveryDistance = source.AverageDeliveryDistance
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
private static CustomerTopProductResponse MapTopProduct(CustomerTopProductDto source)
|
||||||
|
{
|
||||||
|
return new CustomerTopProductResponse
|
||||||
|
{
|
||||||
|
Rank = source.Rank,
|
||||||
|
ProductName = source.ProductName,
|
||||||
|
Count = source.Count,
|
||||||
|
ProportionPercent = source.ProportionPercent
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
private static CustomerTrendPointResponse MapTrend(CustomerTrendPointDto source)
|
||||||
|
{
|
||||||
|
return new CustomerTrendPointResponse
|
||||||
|
{
|
||||||
|
Label = source.Label,
|
||||||
|
Amount = source.Amount
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
private static CustomerRecentOrderResponse MapRecentOrder(CustomerRecentOrderDto source)
|
||||||
|
{
|
||||||
|
return new CustomerRecentOrderResponse
|
||||||
|
{
|
||||||
|
OrderNo = source.OrderNo,
|
||||||
|
Amount = source.Amount,
|
||||||
|
ItemsSummary = source.ItemsSummary,
|
||||||
|
DeliveryType = source.DeliveryType,
|
||||||
|
Status = source.Status,
|
||||||
|
OrderedAt = ToDateTime(source.OrderedAt)
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
private static string ToDateOnly(DateTime value)
|
||||||
|
{
|
||||||
|
return value.ToString("yyyy-MM-dd", CultureInfo.InvariantCulture);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static string ToDateTime(DateTime value)
|
||||||
|
{
|
||||||
|
return value.ToString("yyyy-MM-dd HH:mm:ss", CultureInfo.InvariantCulture);
|
||||||
|
}
|
||||||
|
}
|
||||||
251
src/Api/TakeoutSaaS.TenantApi/Controllers/MemberController.cs
Normal file
251
src/Api/TakeoutSaaS.TenantApi/Controllers/MemberController.cs
Normal file
@@ -0,0 +1,251 @@
|
|||||||
|
using System.Globalization;
|
||||||
|
using MediatR;
|
||||||
|
using Microsoft.AspNetCore.Authorization;
|
||||||
|
using Microsoft.AspNetCore.Mvc;
|
||||||
|
using Microsoft.EntityFrameworkCore;
|
||||||
|
using TakeoutSaaS.Application.App.Members.Commands;
|
||||||
|
using TakeoutSaaS.Application.App.Members.Dto;
|
||||||
|
using TakeoutSaaS.Application.App.Members.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.Exceptions;
|
||||||
|
using TakeoutSaaS.Shared.Abstractions.Results;
|
||||||
|
using TakeoutSaaS.Shared.Web.Api;
|
||||||
|
using TakeoutSaaS.TenantApi.Contracts.Member;
|
||||||
|
|
||||||
|
namespace TakeoutSaaS.TenantApi.Controllers;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 会员管理列表与详情。
|
||||||
|
/// </summary>
|
||||||
|
[ApiVersion("1.0")]
|
||||||
|
[Authorize]
|
||||||
|
[Route("api/tenant/v{version:apiVersion}/member/list")]
|
||||||
|
public sealed class MemberController(
|
||||||
|
IMediator mediator,
|
||||||
|
TakeoutAppDbContext dbContext,
|
||||||
|
StoreContextService storeContextService)
|
||||||
|
: BaseApiController
|
||||||
|
{
|
||||||
|
private const string ViewPermission = "tenant:member:view";
|
||||||
|
private const string ManagePermission = "tenant:member:manage";
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 获取会员列表。
|
||||||
|
/// </summary>
|
||||||
|
[HttpGet("list")]
|
||||||
|
[PermissionAuthorize(ViewPermission, ManagePermission)]
|
||||||
|
[ProducesResponseType(typeof(ApiResponse<MemberListResultResponse>), StatusCodes.Status200OK)]
|
||||||
|
public async Task<ApiResponse<MemberListResultResponse>> List(
|
||||||
|
[FromQuery] MemberListRequest request,
|
||||||
|
CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
var visibleStoreIds = await ResolveVisibleStoreIdsAsync(request.StoreId, cancellationToken);
|
||||||
|
|
||||||
|
var result = await mediator.Send(new SearchMemberListQuery
|
||||||
|
{
|
||||||
|
VisibleStoreIds = visibleStoreIds,
|
||||||
|
Keyword = request.Keyword,
|
||||||
|
TierId = StoreApiHelpers.ParseSnowflakeOrNull(request.TierId),
|
||||||
|
Page = Math.Max(1, request.Page),
|
||||||
|
PageSize = Math.Clamp(request.PageSize, 1, 200)
|
||||||
|
}, cancellationToken);
|
||||||
|
|
||||||
|
return ApiResponse<MemberListResultResponse>.Ok(new MemberListResultResponse
|
||||||
|
{
|
||||||
|
Items = result.Items.Select(MapListItem).ToList(),
|
||||||
|
Total = result.TotalCount,
|
||||||
|
Page = result.Page,
|
||||||
|
PageSize = result.PageSize
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 获取会员列表统计。
|
||||||
|
/// </summary>
|
||||||
|
[HttpGet("stats")]
|
||||||
|
[PermissionAuthorize(ViewPermission, ManagePermission)]
|
||||||
|
[ProducesResponseType(typeof(ApiResponse<MemberListStatsResponse>), StatusCodes.Status200OK)]
|
||||||
|
public async Task<ApiResponse<MemberListStatsResponse>> Stats(
|
||||||
|
[FromQuery] MemberListFilterRequest request,
|
||||||
|
CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
var visibleStoreIds = await ResolveVisibleStoreIdsAsync(request.StoreId, cancellationToken);
|
||||||
|
|
||||||
|
var result = await mediator.Send(new GetMemberListStatsQuery
|
||||||
|
{
|
||||||
|
VisibleStoreIds = visibleStoreIds,
|
||||||
|
Keyword = request.Keyword,
|
||||||
|
TierId = StoreApiHelpers.ParseSnowflakeOrNull(request.TierId)
|
||||||
|
}, cancellationToken);
|
||||||
|
|
||||||
|
return ApiResponse<MemberListStatsResponse>.Ok(new MemberListStatsResponse
|
||||||
|
{
|
||||||
|
TotalMembers = result.TotalMembers,
|
||||||
|
MonthlyNewMembers = result.MonthlyNewMembers,
|
||||||
|
ActiveMembers = result.ActiveMembers,
|
||||||
|
DormantMembers = result.DormantMembers
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 获取会员详情。
|
||||||
|
/// </summary>
|
||||||
|
[HttpGet("detail")]
|
||||||
|
[PermissionAuthorize(ViewPermission, ManagePermission)]
|
||||||
|
[ProducesResponseType(typeof(ApiResponse<MemberDetailResponse>), StatusCodes.Status200OK)]
|
||||||
|
public async Task<ApiResponse<MemberDetailResponse>> Detail(
|
||||||
|
[FromQuery] MemberDetailRequest request,
|
||||||
|
CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
var visibleStoreIds = await ResolveVisibleStoreIdsAsync(request.StoreId, cancellationToken);
|
||||||
|
|
||||||
|
var result = await mediator.Send(new GetMemberDetailQuery
|
||||||
|
{
|
||||||
|
VisibleStoreIds = visibleStoreIds,
|
||||||
|
MemberId = StoreApiHelpers.ParseRequiredSnowflake(request.MemberId, nameof(request.MemberId))
|
||||||
|
}, cancellationToken);
|
||||||
|
|
||||||
|
if (result is null)
|
||||||
|
{
|
||||||
|
return ApiResponse<MemberDetailResponse>.Error(ErrorCodes.NotFound, "会员不存在");
|
||||||
|
}
|
||||||
|
|
||||||
|
return ApiResponse<MemberDetailResponse>.Ok(MapDetail(result));
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 保存会员标签。
|
||||||
|
/// </summary>
|
||||||
|
[HttpPost("tags")]
|
||||||
|
[PermissionAuthorize(ManagePermission)]
|
||||||
|
[ProducesResponseType(typeof(ApiResponse<object>), StatusCodes.Status200OK)]
|
||||||
|
public async Task<ApiResponse<object>> SaveTags(
|
||||||
|
[FromBody] SaveMemberTagsRequest request,
|
||||||
|
CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
_ = await ResolveVisibleStoreIdsAsync(request.StoreId, cancellationToken);
|
||||||
|
|
||||||
|
await mediator.Send(new SaveMemberTagsCommand
|
||||||
|
{
|
||||||
|
MemberId = StoreApiHelpers.ParseRequiredSnowflake(request.MemberId, nameof(request.MemberId)),
|
||||||
|
Tags = (request.Tags ?? []).ToList()
|
||||||
|
}, cancellationToken);
|
||||||
|
|
||||||
|
return ApiResponse<object>.Ok(null);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 导出会员列表 CSV。
|
||||||
|
/// </summary>
|
||||||
|
[HttpGet("export")]
|
||||||
|
[PermissionAuthorize(ManagePermission)]
|
||||||
|
[ProducesResponseType(typeof(ApiResponse<MemberExportResponse>), StatusCodes.Status200OK)]
|
||||||
|
public async Task<ApiResponse<MemberExportResponse>> Export(
|
||||||
|
[FromQuery] MemberListFilterRequest request,
|
||||||
|
CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
var visibleStoreIds = await ResolveVisibleStoreIdsAsync(request.StoreId, cancellationToken);
|
||||||
|
|
||||||
|
var result = await mediator.Send(new ExportMemberCsvQuery
|
||||||
|
{
|
||||||
|
VisibleStoreIds = visibleStoreIds,
|
||||||
|
Keyword = request.Keyword,
|
||||||
|
TierId = StoreApiHelpers.ParseSnowflakeOrNull(request.TierId)
|
||||||
|
}, cancellationToken);
|
||||||
|
|
||||||
|
return ApiResponse<MemberExportResponse>.Ok(new MemberExportResponse
|
||||||
|
{
|
||||||
|
FileName = result.FileName,
|
||||||
|
FileContentBase64 = result.FileContentBase64,
|
||||||
|
TotalCount = result.TotalCount
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task<IReadOnlyCollection<long>> ResolveVisibleStoreIdsAsync(
|
||||||
|
string? storeId,
|
||||||
|
CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
var (tenantId, merchantId) = StoreApiHelpers.GetTenantMerchantContext(storeContextService);
|
||||||
|
|
||||||
|
if (!string.IsNullOrWhiteSpace(storeId))
|
||||||
|
{
|
||||||
|
var parsedStoreId = StoreApiHelpers.ParseRequiredSnowflake(storeId, nameof(storeId));
|
||||||
|
await StoreApiHelpers.EnsureStoreAccessibleAsync(
|
||||||
|
dbContext,
|
||||||
|
tenantId,
|
||||||
|
merchantId,
|
||||||
|
parsedStoreId,
|
||||||
|
cancellationToken);
|
||||||
|
return [parsedStoreId];
|
||||||
|
}
|
||||||
|
|
||||||
|
var allStoreIds = await dbContext.Stores
|
||||||
|
.AsNoTracking()
|
||||||
|
.Where(item => item.TenantId == tenantId && item.MerchantId == merchantId)
|
||||||
|
.Select(item => item.Id)
|
||||||
|
.OrderBy(item => item)
|
||||||
|
.ToListAsync(cancellationToken);
|
||||||
|
|
||||||
|
if (allStoreIds.Count == 0)
|
||||||
|
{
|
||||||
|
throw new BusinessException(ErrorCodes.BadRequest, "当前商户下不存在可用门店");
|
||||||
|
}
|
||||||
|
|
||||||
|
return allStoreIds;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static MemberListItemResponse MapListItem(MemberListItemDto source)
|
||||||
|
{
|
||||||
|
return new MemberListItemResponse
|
||||||
|
{
|
||||||
|
MemberId = source.MemberId.ToString(),
|
||||||
|
Name = source.Name,
|
||||||
|
AvatarText = source.AvatarText,
|
||||||
|
AvatarColor = source.AvatarColor,
|
||||||
|
MobileMasked = source.MobileMasked,
|
||||||
|
TierId = source.TierId?.ToString(),
|
||||||
|
TierName = source.TierName,
|
||||||
|
TierColorHex = source.TierColorHex,
|
||||||
|
TotalAmount = source.TotalAmount,
|
||||||
|
OrderCount = source.OrderCount,
|
||||||
|
LastOrderAt = source.LastOrderAt.ToString("yyyy-MM-dd", CultureInfo.InvariantCulture),
|
||||||
|
StoredBalance = source.StoredBalance,
|
||||||
|
PointsBalance = source.PointsBalance,
|
||||||
|
IsDormant = source.IsDormant
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
private static MemberDetailResponse MapDetail(MemberDetailDto source)
|
||||||
|
{
|
||||||
|
return new MemberDetailResponse
|
||||||
|
{
|
||||||
|
MemberId = source.MemberId.ToString(),
|
||||||
|
Name = source.Name,
|
||||||
|
AvatarText = source.AvatarText,
|
||||||
|
AvatarColor = source.AvatarColor,
|
||||||
|
MobileMasked = source.MobileMasked,
|
||||||
|
JoinedAt = source.JoinedAt.ToString("yyyy-MM-dd", CultureInfo.InvariantCulture),
|
||||||
|
TierId = source.TierId?.ToString(),
|
||||||
|
TierName = source.TierName,
|
||||||
|
TierColorHex = source.TierColorHex,
|
||||||
|
TotalAmount = source.TotalAmount,
|
||||||
|
OrderCount = source.OrderCount,
|
||||||
|
AverageAmount = source.AverageAmount,
|
||||||
|
StoredBalance = source.StoredBalance,
|
||||||
|
StoredRechargeBalance = source.StoredRechargeBalance,
|
||||||
|
StoredGiftBalance = source.StoredGiftBalance,
|
||||||
|
PointsBalance = source.PointsBalance,
|
||||||
|
Tags = source.Tags.ToList(),
|
||||||
|
RecentOrders = source.RecentOrders.Select(item => new MemberRecentOrderResponse
|
||||||
|
{
|
||||||
|
OrderedAt = item.OrderedAt.ToString("yyyy-MM-dd", CultureInfo.InvariantCulture),
|
||||||
|
OrderNo = item.OrderNo,
|
||||||
|
Amount = item.Amount,
|
||||||
|
StatusText = item.StatusText
|
||||||
|
}).ToList()
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,330 @@
|
|||||||
|
using MediatR;
|
||||||
|
using Microsoft.AspNetCore.Authorization;
|
||||||
|
using Microsoft.AspNetCore.Mvc;
|
||||||
|
using Microsoft.EntityFrameworkCore;
|
||||||
|
using TakeoutSaaS.Application.App.Members.Commands;
|
||||||
|
using TakeoutSaaS.Application.App.Members.Dto;
|
||||||
|
using TakeoutSaaS.Application.App.Members.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.Exceptions;
|
||||||
|
using TakeoutSaaS.Shared.Abstractions.Results;
|
||||||
|
using TakeoutSaaS.Shared.Web.Api;
|
||||||
|
using TakeoutSaaS.TenantApi.Contracts.Member;
|
||||||
|
|
||||||
|
namespace TakeoutSaaS.TenantApi.Controllers;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 会员等级体系与会员日配置。
|
||||||
|
/// </summary>
|
||||||
|
[ApiVersion("1.0")]
|
||||||
|
[Authorize]
|
||||||
|
[Route("api/tenant/v{version:apiVersion}/member/tier")]
|
||||||
|
public sealed class MemberTierController(
|
||||||
|
IMediator mediator,
|
||||||
|
TakeoutAppDbContext dbContext,
|
||||||
|
StoreContextService storeContextService)
|
||||||
|
: BaseApiController
|
||||||
|
{
|
||||||
|
private const string ViewPermission = "tenant:member:view";
|
||||||
|
private const string ManagePermission = "tenant:member:manage";
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 获取会员等级列表。
|
||||||
|
/// </summary>
|
||||||
|
[HttpGet("list")]
|
||||||
|
[PermissionAuthorize(ViewPermission, ManagePermission)]
|
||||||
|
[ProducesResponseType(typeof(ApiResponse<List<MemberTierListItemResponse>>), StatusCodes.Status200OK)]
|
||||||
|
public async Task<ApiResponse<List<MemberTierListItemResponse>>> List(CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
var result = await mediator.Send(new GetMemberTierListQuery(), cancellationToken);
|
||||||
|
return ApiResponse<List<MemberTierListItemResponse>>.Ok(result.Select(MapTierListItem).ToList());
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 获取会员等级详情。
|
||||||
|
/// </summary>
|
||||||
|
[HttpGet("detail")]
|
||||||
|
[PermissionAuthorize(ViewPermission, ManagePermission)]
|
||||||
|
[ProducesResponseType(typeof(ApiResponse<MemberTierDetailResponse>), StatusCodes.Status200OK)]
|
||||||
|
public async Task<ApiResponse<MemberTierDetailResponse>> Detail(
|
||||||
|
[FromQuery] MemberTierDetailRequest request,
|
||||||
|
CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
var result = await mediator.Send(new GetMemberTierDetailQuery
|
||||||
|
{
|
||||||
|
TierId = StoreApiHelpers.ParseSnowflakeOrNull(request.TierId)
|
||||||
|
}, cancellationToken);
|
||||||
|
|
||||||
|
return ApiResponse<MemberTierDetailResponse>.Ok(MapTierDetail(result));
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 保存会员等级。
|
||||||
|
/// </summary>
|
||||||
|
[HttpPost("save")]
|
||||||
|
[PermissionAuthorize(ManagePermission)]
|
||||||
|
[ProducesResponseType(typeof(ApiResponse<MemberTierDetailResponse>), StatusCodes.Status200OK)]
|
||||||
|
public async Task<ApiResponse<MemberTierDetailResponse>> Save(
|
||||||
|
[FromBody] SaveMemberTierRequest request,
|
||||||
|
CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
var result = await mediator.Send(new SaveMemberTierCommand
|
||||||
|
{
|
||||||
|
TierId = StoreApiHelpers.ParseSnowflakeOrNull(request.TierId),
|
||||||
|
SortOrder = request.SortOrder,
|
||||||
|
Name = request.Name,
|
||||||
|
IconKey = request.IconKey,
|
||||||
|
ColorHex = request.ColorHex,
|
||||||
|
IsDefault = request.IsDefault,
|
||||||
|
Rule = new MemberTierRuleDto
|
||||||
|
{
|
||||||
|
UpgradeRuleType = request.Rule.UpgradeRuleType,
|
||||||
|
UpgradeAmountThreshold = request.Rule.UpgradeAmountThreshold,
|
||||||
|
UpgradeOrderCountThreshold = request.Rule.UpgradeOrderCountThreshold,
|
||||||
|
DowngradeWindowDays = request.Rule.DowngradeWindowDays
|
||||||
|
},
|
||||||
|
Benefits = MapBenefits(request.Benefits)
|
||||||
|
}, cancellationToken);
|
||||||
|
|
||||||
|
return ApiResponse<MemberTierDetailResponse>.Ok(MapTierDetail(result));
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 删除会员等级。
|
||||||
|
/// </summary>
|
||||||
|
[HttpPost("delete")]
|
||||||
|
[PermissionAuthorize(ManagePermission)]
|
||||||
|
[ProducesResponseType(typeof(ApiResponse<object>), StatusCodes.Status200OK)]
|
||||||
|
public async Task<ApiResponse<object>> Delete(
|
||||||
|
[FromBody] DeleteMemberTierRequest request,
|
||||||
|
CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
await mediator.Send(new DeleteMemberTierCommand
|
||||||
|
{
|
||||||
|
TierId = StoreApiHelpers.ParseRequiredSnowflake(request.TierId, nameof(request.TierId))
|
||||||
|
}, cancellationToken);
|
||||||
|
|
||||||
|
return ApiResponse<object>.Ok(null);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 获取会员日配置。
|
||||||
|
/// </summary>
|
||||||
|
[HttpGet("day-setting")]
|
||||||
|
[PermissionAuthorize(ViewPermission, ManagePermission)]
|
||||||
|
[ProducesResponseType(typeof(ApiResponse<MemberDaySettingResponse>), StatusCodes.Status200OK)]
|
||||||
|
public async Task<ApiResponse<MemberDaySettingResponse>> GetDaySetting(CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
var result = await mediator.Send(new GetMemberDaySettingQuery(), cancellationToken);
|
||||||
|
return ApiResponse<MemberDaySettingResponse>.Ok(new MemberDaySettingResponse
|
||||||
|
{
|
||||||
|
IsEnabled = result.IsEnabled,
|
||||||
|
Weekday = result.Weekday,
|
||||||
|
ExtraDiscountRate = result.ExtraDiscountRate
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 保存会员日配置。
|
||||||
|
/// </summary>
|
||||||
|
[HttpPost("day-setting")]
|
||||||
|
[PermissionAuthorize(ManagePermission)]
|
||||||
|
[ProducesResponseType(typeof(ApiResponse<MemberDaySettingResponse>), StatusCodes.Status200OK)]
|
||||||
|
public async Task<ApiResponse<MemberDaySettingResponse>> SaveDaySetting(
|
||||||
|
[FromBody] SaveMemberDaySettingRequest request,
|
||||||
|
CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
var result = await mediator.Send(new SaveMemberDaySettingCommand
|
||||||
|
{
|
||||||
|
IsEnabled = request.IsEnabled,
|
||||||
|
Weekday = request.Weekday,
|
||||||
|
ExtraDiscountRate = request.ExtraDiscountRate
|
||||||
|
}, cancellationToken);
|
||||||
|
|
||||||
|
return ApiResponse<MemberDaySettingResponse>.Ok(new MemberDaySettingResponse
|
||||||
|
{
|
||||||
|
IsEnabled = result.IsEnabled,
|
||||||
|
Weekday = result.Weekday,
|
||||||
|
ExtraDiscountRate = result.ExtraDiscountRate
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 查询可选优惠券列表。
|
||||||
|
/// </summary>
|
||||||
|
[HttpGet("coupon-picker")]
|
||||||
|
[PermissionAuthorize(ViewPermission, ManagePermission)]
|
||||||
|
[ProducesResponseType(typeof(ApiResponse<List<MemberCouponPickerItemResponse>>), StatusCodes.Status200OK)]
|
||||||
|
public async Task<ApiResponse<List<MemberCouponPickerItemResponse>>> CouponPicker(
|
||||||
|
[FromQuery] MemberCouponPickerRequest request,
|
||||||
|
CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
var visibleStoreIds = await ResolveVisibleStoreIdsAsync(request.StoreId, cancellationToken);
|
||||||
|
|
||||||
|
var result = await mediator.Send(new SearchMemberCouponPickerQuery
|
||||||
|
{
|
||||||
|
VisibleStoreIds = visibleStoreIds,
|
||||||
|
Keyword = request.Keyword
|
||||||
|
}, cancellationToken);
|
||||||
|
|
||||||
|
return ApiResponse<List<MemberCouponPickerItemResponse>>.Ok(result.Select(item => new MemberCouponPickerItemResponse
|
||||||
|
{
|
||||||
|
CouponTemplateId = item.CouponTemplateId.ToString(),
|
||||||
|
Name = item.Name,
|
||||||
|
CouponType = item.CouponType,
|
||||||
|
Value = item.Value,
|
||||||
|
MinimumSpend = item.MinimumSpend,
|
||||||
|
DisplayText = item.DisplayText
|
||||||
|
}).ToList());
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task<IReadOnlyCollection<long>> ResolveVisibleStoreIdsAsync(
|
||||||
|
string? storeId,
|
||||||
|
CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
var (tenantId, merchantId) = StoreApiHelpers.GetTenantMerchantContext(storeContextService);
|
||||||
|
|
||||||
|
if (!string.IsNullOrWhiteSpace(storeId))
|
||||||
|
{
|
||||||
|
var parsedStoreId = StoreApiHelpers.ParseRequiredSnowflake(storeId, nameof(storeId));
|
||||||
|
await StoreApiHelpers.EnsureStoreAccessibleAsync(
|
||||||
|
dbContext,
|
||||||
|
tenantId,
|
||||||
|
merchantId,
|
||||||
|
parsedStoreId,
|
||||||
|
cancellationToken);
|
||||||
|
return [parsedStoreId];
|
||||||
|
}
|
||||||
|
|
||||||
|
var allStoreIds = await dbContext.Stores
|
||||||
|
.AsNoTracking()
|
||||||
|
.Where(item => item.TenantId == tenantId && item.MerchantId == merchantId)
|
||||||
|
.Select(item => item.Id)
|
||||||
|
.OrderBy(item => item)
|
||||||
|
.ToListAsync(cancellationToken);
|
||||||
|
|
||||||
|
if (allStoreIds.Count == 0)
|
||||||
|
{
|
||||||
|
throw new BusinessException(ErrorCodes.BadRequest, "当前商户下不存在可用门店");
|
||||||
|
}
|
||||||
|
|
||||||
|
return allStoreIds;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static MemberTierListItemResponse MapTierListItem(MemberTierListItemDto source)
|
||||||
|
{
|
||||||
|
return new MemberTierListItemResponse
|
||||||
|
{
|
||||||
|
TierId = source.TierId.ToString(),
|
||||||
|
SortOrder = source.SortOrder,
|
||||||
|
Name = source.Name,
|
||||||
|
IconKey = source.IconKey,
|
||||||
|
ColorHex = source.ColorHex,
|
||||||
|
ConditionText = source.ConditionText,
|
||||||
|
Perks = source.Perks.ToList(),
|
||||||
|
MemberCount = source.MemberCount,
|
||||||
|
IsDefault = source.IsDefault,
|
||||||
|
CanDelete = source.CanDelete
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
private static MemberTierDetailResponse MapTierDetail(MemberTierDetailDto source)
|
||||||
|
{
|
||||||
|
return new MemberTierDetailResponse
|
||||||
|
{
|
||||||
|
TierId = source.TierId?.ToString(),
|
||||||
|
SortOrder = source.SortOrder,
|
||||||
|
Name = source.Name,
|
||||||
|
IconKey = source.IconKey,
|
||||||
|
ColorHex = source.ColorHex,
|
||||||
|
IsDefault = source.IsDefault,
|
||||||
|
Rule = new MemberTierRuleResponse
|
||||||
|
{
|
||||||
|
UpgradeRuleType = source.Rule.UpgradeRuleType,
|
||||||
|
UpgradeAmountThreshold = source.Rule.UpgradeAmountThreshold,
|
||||||
|
UpgradeOrderCountThreshold = source.Rule.UpgradeOrderCountThreshold,
|
||||||
|
DowngradeWindowDays = source.Rule.DowngradeWindowDays
|
||||||
|
},
|
||||||
|
Benefits = new MemberTierBenefitsResponse
|
||||||
|
{
|
||||||
|
Discount = new MemberTierDiscountBenefitResponse
|
||||||
|
{
|
||||||
|
Enabled = source.Benefits.Discount.Enabled,
|
||||||
|
DiscountRate = source.Benefits.Discount.DiscountRate
|
||||||
|
},
|
||||||
|
PointMultiplier = new MemberTierPointMultiplierBenefitResponse
|
||||||
|
{
|
||||||
|
Enabled = source.Benefits.PointMultiplier.Enabled,
|
||||||
|
Multiplier = source.Benefits.PointMultiplier.Multiplier
|
||||||
|
},
|
||||||
|
Birthday = new MemberTierBirthdayBenefitResponse
|
||||||
|
{
|
||||||
|
Enabled = source.Benefits.Birthday.Enabled,
|
||||||
|
DoublePointsEnabled = source.Benefits.Birthday.DoublePointsEnabled,
|
||||||
|
CouponTemplateIds = source.Benefits.Birthday.CouponTemplateIds.Select(item => item.ToString()).ToList()
|
||||||
|
},
|
||||||
|
MonthlyCoupon = new MemberTierMonthlyCouponBenefitResponse
|
||||||
|
{
|
||||||
|
Enabled = source.Benefits.MonthlyCoupon.Enabled,
|
||||||
|
GrantDay = source.Benefits.MonthlyCoupon.GrantDay,
|
||||||
|
CouponTemplateIds = source.Benefits.MonthlyCoupon.CouponTemplateIds.Select(item => item.ToString()).ToList()
|
||||||
|
},
|
||||||
|
FreeDelivery = new MemberTierFreeDeliveryBenefitResponse
|
||||||
|
{
|
||||||
|
Enabled = source.Benefits.FreeDelivery.Enabled,
|
||||||
|
MonthlyFreeTimes = source.Benefits.FreeDelivery.MonthlyFreeTimes
|
||||||
|
},
|
||||||
|
PriorityDeliveryEnabled = source.Benefits.PriorityDeliveryEnabled,
|
||||||
|
ExclusiveServiceEnabled = source.Benefits.ExclusiveServiceEnabled
|
||||||
|
},
|
||||||
|
CanDelete = source.CanDelete
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
private static MemberTierBenefitsDto MapBenefits(MemberTierBenefitsResponse source)
|
||||||
|
{
|
||||||
|
return new MemberTierBenefitsDto
|
||||||
|
{
|
||||||
|
Discount = new MemberTierDiscountBenefitDto
|
||||||
|
{
|
||||||
|
Enabled = source.Discount.Enabled,
|
||||||
|
DiscountRate = source.Discount.DiscountRate
|
||||||
|
},
|
||||||
|
PointMultiplier = new MemberTierPointMultiplierBenefitDto
|
||||||
|
{
|
||||||
|
Enabled = source.PointMultiplier.Enabled,
|
||||||
|
Multiplier = source.PointMultiplier.Multiplier
|
||||||
|
},
|
||||||
|
Birthday = new MemberTierBirthdayBenefitDto
|
||||||
|
{
|
||||||
|
Enabled = source.Birthday.Enabled,
|
||||||
|
DoublePointsEnabled = source.Birthday.DoublePointsEnabled,
|
||||||
|
CouponTemplateIds = source.Birthday.CouponTemplateIds
|
||||||
|
.Select(StoreApiHelpers.ParseSnowflakeOrNull)
|
||||||
|
.Where(item => item.HasValue)
|
||||||
|
.Select(item => item!.Value)
|
||||||
|
.ToList()
|
||||||
|
},
|
||||||
|
MonthlyCoupon = new MemberTierMonthlyCouponBenefitDto
|
||||||
|
{
|
||||||
|
Enabled = source.MonthlyCoupon.Enabled,
|
||||||
|
GrantDay = source.MonthlyCoupon.GrantDay,
|
||||||
|
CouponTemplateIds = source.MonthlyCoupon.CouponTemplateIds
|
||||||
|
.Select(StoreApiHelpers.ParseSnowflakeOrNull)
|
||||||
|
.Where(item => item.HasValue)
|
||||||
|
.Select(item => item!.Value)
|
||||||
|
.ToList()
|
||||||
|
},
|
||||||
|
FreeDelivery = new MemberTierFreeDeliveryBenefitDto
|
||||||
|
{
|
||||||
|
Enabled = source.FreeDelivery.Enabled,
|
||||||
|
MonthlyFreeTimes = source.FreeDelivery.MonthlyFreeTimes
|
||||||
|
},
|
||||||
|
PriorityDeliveryEnabled = source.PriorityDeliveryEnabled,
|
||||||
|
ExclusiveServiceEnabled = source.ExclusiveServiceEnabled
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,431 @@
|
|||||||
|
namespace TakeoutSaaS.Application.App.Customers.Dto;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 客户分析增长趋势点 DTO。
|
||||||
|
/// </summary>
|
||||||
|
public sealed class CustomerAnalysisTrendPointDto
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// 维度标签。
|
||||||
|
/// </summary>
|
||||||
|
public string Label { get; init; } = string.Empty;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 数量值。
|
||||||
|
/// </summary>
|
||||||
|
public int Value { get; init; }
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 客户分析新老客构成项 DTO。
|
||||||
|
/// </summary>
|
||||||
|
public sealed class CustomerAnalysisCompositionItemDto
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// 分群编码。
|
||||||
|
/// </summary>
|
||||||
|
public string SegmentCode { get; init; } = string.Empty;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 分群名称。
|
||||||
|
/// </summary>
|
||||||
|
public string Label { get; init; } = string.Empty;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 分群人数。
|
||||||
|
/// </summary>
|
||||||
|
public int Count { get; init; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 分群占比(百分比)。
|
||||||
|
/// </summary>
|
||||||
|
public decimal Percent { get; init; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 色调(blue/green/orange/gray)。
|
||||||
|
/// </summary>
|
||||||
|
public string Tone { get; init; } = "blue";
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 客单价分布项 DTO。
|
||||||
|
/// </summary>
|
||||||
|
public sealed class CustomerAnalysisAmountDistributionItemDto
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// 分群编码。
|
||||||
|
/// </summary>
|
||||||
|
public string SegmentCode { get; init; } = string.Empty;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 区间标签。
|
||||||
|
/// </summary>
|
||||||
|
public string Label { get; init; } = string.Empty;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 人数。
|
||||||
|
/// </summary>
|
||||||
|
public int Count { get; init; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 占比(百分比)。
|
||||||
|
/// </summary>
|
||||||
|
public decimal Percent { get; init; }
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// RFM 分层单元 DTO。
|
||||||
|
/// </summary>
|
||||||
|
public sealed class CustomerAnalysisRfmCellDto
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// 分群编码。
|
||||||
|
/// </summary>
|
||||||
|
public string SegmentCode { get; init; } = string.Empty;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 分层标签。
|
||||||
|
/// </summary>
|
||||||
|
public string Label { get; init; } = string.Empty;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 人数。
|
||||||
|
/// </summary>
|
||||||
|
public int Count { get; init; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 温度(hot/warm/cool/cold)。
|
||||||
|
/// </summary>
|
||||||
|
public string Tone { get; init; } = "cold";
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// RFM 分层行 DTO。
|
||||||
|
/// </summary>
|
||||||
|
public sealed class CustomerAnalysisRfmRowDto
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// 行标签。
|
||||||
|
/// </summary>
|
||||||
|
public string Label { get; init; } = string.Empty;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 单元格集合。
|
||||||
|
/// </summary>
|
||||||
|
public IReadOnlyList<CustomerAnalysisRfmCellDto> Cells { get; init; } = [];
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 高价值客户 DTO。
|
||||||
|
/// </summary>
|
||||||
|
public sealed class CustomerAnalysisTopCustomerDto
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// 排名。
|
||||||
|
/// </summary>
|
||||||
|
public int Rank { get; init; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 客户标识。
|
||||||
|
/// </summary>
|
||||||
|
public string CustomerKey { get; init; } = string.Empty;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 客户名称。
|
||||||
|
/// </summary>
|
||||||
|
public string Name { get; init; } = string.Empty;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 手机号(脱敏)。
|
||||||
|
/// </summary>
|
||||||
|
public string PhoneMasked { get; init; } = string.Empty;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 累计消费。
|
||||||
|
/// </summary>
|
||||||
|
public decimal TotalAmount { get; init; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 下单次数。
|
||||||
|
/// </summary>
|
||||||
|
public int OrderCount { get; init; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 客单价。
|
||||||
|
/// </summary>
|
||||||
|
public decimal AverageAmount { get; init; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 最近下单时间。
|
||||||
|
/// </summary>
|
||||||
|
public DateTime LastOrderAt { get; init; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 客户标签。
|
||||||
|
/// </summary>
|
||||||
|
public IReadOnlyList<CustomerTagDto> Tags { get; init; } = [];
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 客户分析总览 DTO。
|
||||||
|
/// </summary>
|
||||||
|
public sealed class CustomerAnalysisOverviewDto
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// 统计周期编码。
|
||||||
|
/// </summary>
|
||||||
|
public string PeriodCode { get; init; } = "30d";
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 统计周期天数。
|
||||||
|
/// </summary>
|
||||||
|
public int PeriodDays { get; init; } = 30;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 客户总数。
|
||||||
|
/// </summary>
|
||||||
|
public int TotalCustomers { get; init; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 周期新增客户数。
|
||||||
|
/// </summary>
|
||||||
|
public int NewCustomers { get; init; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 新增较上一周期增长百分比。
|
||||||
|
/// </summary>
|
||||||
|
public decimal GrowthRatePercent { get; init; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 周期内日均新增客户。
|
||||||
|
/// </summary>
|
||||||
|
public decimal NewCustomersDailyAverage { get; init; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 活跃客户数。
|
||||||
|
/// </summary>
|
||||||
|
public int ActiveCustomers { get; init; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 活跃率(百分比)。
|
||||||
|
/// </summary>
|
||||||
|
public decimal ActiveRatePercent { get; init; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 平均客户价值(累计消费均值)。
|
||||||
|
/// </summary>
|
||||||
|
public decimal AverageLifetimeValue { get; init; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 客户增长趋势。
|
||||||
|
/// </summary>
|
||||||
|
public IReadOnlyList<CustomerAnalysisTrendPointDto> GrowthTrend { get; init; } = [];
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 新老客占比。
|
||||||
|
/// </summary>
|
||||||
|
public IReadOnlyList<CustomerAnalysisCompositionItemDto> Composition { get; init; } = [];
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 客单价分布。
|
||||||
|
/// </summary>
|
||||||
|
public IReadOnlyList<CustomerAnalysisAmountDistributionItemDto> AmountDistribution { get; init; } = [];
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// RFM 分层。
|
||||||
|
/// </summary>
|
||||||
|
public IReadOnlyList<CustomerAnalysisRfmRowDto> RfmRows { get; init; } = [];
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 高价值客户 Top10。
|
||||||
|
/// </summary>
|
||||||
|
public IReadOnlyList<CustomerAnalysisTopCustomerDto> TopCustomers { get; init; } = [];
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 客群明细行 DTO。
|
||||||
|
/// </summary>
|
||||||
|
public sealed class CustomerAnalysisSegmentListItemDto
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// 客户标识。
|
||||||
|
/// </summary>
|
||||||
|
public string CustomerKey { get; init; } = string.Empty;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 客户名称。
|
||||||
|
/// </summary>
|
||||||
|
public string Name { get; init; } = string.Empty;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 手机号(脱敏)。
|
||||||
|
/// </summary>
|
||||||
|
public string PhoneMasked { get; init; } = string.Empty;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 头像文案。
|
||||||
|
/// </summary>
|
||||||
|
public string AvatarText { get; init; } = string.Empty;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 头像颜色。
|
||||||
|
/// </summary>
|
||||||
|
public string AvatarColor { get; init; } = string.Empty;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 客户标签。
|
||||||
|
/// </summary>
|
||||||
|
public IReadOnlyList<CustomerTagDto> Tags { get; init; } = [];
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 是否会员。
|
||||||
|
/// </summary>
|
||||||
|
public bool IsMember { get; init; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 会员等级。
|
||||||
|
/// </summary>
|
||||||
|
public string MemberTierName { get; init; } = string.Empty;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 累计消费。
|
||||||
|
/// </summary>
|
||||||
|
public decimal TotalAmount { get; init; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 下单次数。
|
||||||
|
/// </summary>
|
||||||
|
public int OrderCount { get; init; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 客单价。
|
||||||
|
/// </summary>
|
||||||
|
public decimal AverageAmount { get; init; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 注册时间。
|
||||||
|
/// </summary>
|
||||||
|
public DateTime RegisteredAt { get; init; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 最近下单时间。
|
||||||
|
/// </summary>
|
||||||
|
public DateTime LastOrderAt { get; init; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 是否弱化显示。
|
||||||
|
/// </summary>
|
||||||
|
public bool IsDimmed { get; init; }
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 客群明细结果 DTO。
|
||||||
|
/// </summary>
|
||||||
|
public sealed class CustomerAnalysisSegmentListResultDto
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// 分群编码。
|
||||||
|
/// </summary>
|
||||||
|
public string SegmentCode { get; init; } = string.Empty;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 分群标题。
|
||||||
|
/// </summary>
|
||||||
|
public string SegmentTitle { get; init; } = string.Empty;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 分群说明。
|
||||||
|
/// </summary>
|
||||||
|
public string SegmentDescription { get; init; } = string.Empty;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 列表项。
|
||||||
|
/// </summary>
|
||||||
|
public IReadOnlyList<CustomerAnalysisSegmentListItemDto> 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>
|
||||||
|
/// 会员详情 DTO。
|
||||||
|
/// </summary>
|
||||||
|
public sealed class CustomerMemberDetailDto
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// 客户标识。
|
||||||
|
/// </summary>
|
||||||
|
public string CustomerKey { get; init; } = string.Empty;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 客户名称。
|
||||||
|
/// </summary>
|
||||||
|
public string Name { get; init; } = string.Empty;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 手机号(脱敏)。
|
||||||
|
/// </summary>
|
||||||
|
public string PhoneMasked { get; init; } = string.Empty;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 来源。
|
||||||
|
/// </summary>
|
||||||
|
public string Source { get; init; } = string.Empty;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 注册时间。
|
||||||
|
/// </summary>
|
||||||
|
public DateTime RegisteredAt { get; init; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 最近下单时间。
|
||||||
|
/// </summary>
|
||||||
|
public DateTime LastOrderAt { get; init; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 会员摘要。
|
||||||
|
/// </summary>
|
||||||
|
public CustomerMemberSummaryDto Member { get; init; } = new();
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 客户标签。
|
||||||
|
/// </summary>
|
||||||
|
public IReadOnlyList<CustomerTagDto> Tags { get; init; } = [];
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 累计下单次数。
|
||||||
|
/// </summary>
|
||||||
|
public int TotalOrders { get; init; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 累计消费。
|
||||||
|
/// </summary>
|
||||||
|
public decimal TotalAmount { get; init; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 客单价。
|
||||||
|
/// </summary>
|
||||||
|
public decimal AverageAmount { get; init; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 复购率。
|
||||||
|
/// </summary>
|
||||||
|
public decimal RepurchaseRatePercent { get; init; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 最近订单。
|
||||||
|
/// </summary>
|
||||||
|
public IReadOnlyList<CustomerRecentOrderDto> RecentOrders { get; init; } = [];
|
||||||
|
}
|
||||||
@@ -0,0 +1,478 @@
|
|||||||
|
using TakeoutSaaS.Application.App.Customers.Dto;
|
||||||
|
using TakeoutSaaS.Shared.Abstractions.Constants;
|
||||||
|
using TakeoutSaaS.Shared.Abstractions.Exceptions;
|
||||||
|
|
||||||
|
namespace TakeoutSaaS.Application.App.Customers.Handlers;
|
||||||
|
|
||||||
|
internal static class CustomerAnalysisSegmentSupport
|
||||||
|
{
|
||||||
|
internal const string SegmentAll = "all";
|
||||||
|
internal const string SegmentRepeatLoyal = "repeat_loyal";
|
||||||
|
internal const string SegmentActiveNew = "active_new";
|
||||||
|
internal const string SegmentActiveRecent = "active_recent";
|
||||||
|
internal const string SegmentDormant = "dormant";
|
||||||
|
internal const string SegmentChurn = "churn";
|
||||||
|
internal const string SegmentHighValueTop = "high_value_top";
|
||||||
|
|
||||||
|
private static readonly string[] CompositionSegmentOrder =
|
||||||
|
[
|
||||||
|
SegmentRepeatLoyal,
|
||||||
|
SegmentActiveNew,
|
||||||
|
SegmentDormant,
|
||||||
|
SegmentChurn
|
||||||
|
];
|
||||||
|
|
||||||
|
private static readonly (string SegmentCode, string Label, string Tone)[] CompositionDefinitions =
|
||||||
|
[
|
||||||
|
(SegmentRepeatLoyal, "老客户(复购2次+)", "blue"),
|
||||||
|
(SegmentActiveNew, "活跃新客", "green"),
|
||||||
|
(SegmentDormant, "沉睡客户", "orange"),
|
||||||
|
(SegmentChurn, "流失客户", "gray")
|
||||||
|
];
|
||||||
|
|
||||||
|
private static readonly AmountDistributionDefinition[] AmountDistributionDefinitions =
|
||||||
|
[
|
||||||
|
new("amount_0_30", "0 - 30元", 0m, 30m),
|
||||||
|
new("amount_30_60", "30 - 60元", 30m, 60m),
|
||||||
|
new("amount_60_100", "60 - 100元", 60m, 100m),
|
||||||
|
new("amount_100_150", "100 - 150元", 100m, 150m),
|
||||||
|
new("amount_150_plus", "150元以上", 150m, null)
|
||||||
|
];
|
||||||
|
|
||||||
|
private static readonly string[] RfmRowLabels =
|
||||||
|
[
|
||||||
|
"近期活跃",
|
||||||
|
"中期沉默",
|
||||||
|
"长期流失"
|
||||||
|
];
|
||||||
|
|
||||||
|
private static readonly string[] RfmColumnLabels =
|
||||||
|
[
|
||||||
|
"高频高额",
|
||||||
|
"高频低额",
|
||||||
|
"低频高额",
|
||||||
|
"低频低额"
|
||||||
|
];
|
||||||
|
|
||||||
|
private static readonly string[,] RfmCellLabels =
|
||||||
|
{
|
||||||
|
{ "重要价值", "潜力客户", "新客培育", "一般维护" },
|
||||||
|
{ "重要挽留", "一般发展", "一般保持", "低优先级" },
|
||||||
|
{ "重要召回", "即将流失", "基本流失", "已流失" }
|
||||||
|
};
|
||||||
|
|
||||||
|
private static readonly string[,] RfmCellTones =
|
||||||
|
{
|
||||||
|
{ "hot", "warm", "warm", "cool" },
|
||||||
|
{ "warm", "cool", "cool", "cold" },
|
||||||
|
{ "cool", "cold", "cold", "cold" }
|
||||||
|
};
|
||||||
|
|
||||||
|
internal static string NormalizeSegmentCode(string? segmentCode)
|
||||||
|
{
|
||||||
|
var normalized = (segmentCode ?? string.Empty).Trim().ToLowerInvariant();
|
||||||
|
return string.IsNullOrWhiteSpace(normalized) ? SegmentAll : normalized;
|
||||||
|
}
|
||||||
|
|
||||||
|
internal static SegmentMeta ResolveSegmentMeta(string normalizedSegmentCode)
|
||||||
|
{
|
||||||
|
var amountDefinition = ResolveAmountDistributionDefinition(normalizedSegmentCode);
|
||||||
|
if (amountDefinition is not null)
|
||||||
|
{
|
||||||
|
return new SegmentMeta(
|
||||||
|
normalizedSegmentCode,
|
||||||
|
$"客单价分布 · {amountDefinition.Label}",
|
||||||
|
$"筛选客单价位于 {amountDefinition.Label} 区间的客户");
|
||||||
|
}
|
||||||
|
|
||||||
|
if (TryParseRfmSegmentCode(normalizedSegmentCode, out var rowIndex, out var columnIndex))
|
||||||
|
{
|
||||||
|
return new SegmentMeta(
|
||||||
|
normalizedSegmentCode,
|
||||||
|
$"RFM分层 · {RfmRowLabels[rowIndex]} / {RfmColumnLabels[columnIndex]}",
|
||||||
|
$"当前客群标签:{RfmCellLabels[rowIndex, columnIndex]}");
|
||||||
|
}
|
||||||
|
|
||||||
|
return normalizedSegmentCode switch
|
||||||
|
{
|
||||||
|
SegmentAll => new SegmentMeta(SegmentAll, "全部客户", "当前门店下全部客户明细"),
|
||||||
|
SegmentRepeatLoyal => new SegmentMeta(SegmentRepeatLoyal, "老客户(复购2次+)", "非流失/沉睡且非新客的稳定复购客户"),
|
||||||
|
SegmentActiveNew => new SegmentMeta(SegmentActiveNew, "活跃新客", "统计周期内注册且有消费的客户"),
|
||||||
|
SegmentActiveRecent => new SegmentMeta(SegmentActiveRecent, "周期活跃客户", "统计周期内发生过消费行为的客户"),
|
||||||
|
SegmentDormant => new SegmentMeta(SegmentDormant, "沉睡客户", "31-60天未消费的客户"),
|
||||||
|
SegmentChurn => new SegmentMeta(SegmentChurn, "流失客户", "超过60天未消费的客户"),
|
||||||
|
SegmentHighValueTop => new SegmentMeta(SegmentHighValueTop, "高价值客户", "累计消费或消费能力达到高价值阈值的客户"),
|
||||||
|
_ => throw new BusinessException(ErrorCodes.BadRequest, "segmentCode 参数不合法")
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
internal static IReadOnlyList<CustomerAnalysisCompositionItemDto> BuildComposition(
|
||||||
|
IReadOnlyList<CustomerAggregate> customers,
|
||||||
|
DateTime nowUtc,
|
||||||
|
int periodDays)
|
||||||
|
{
|
||||||
|
if (customers.Count == 0)
|
||||||
|
{
|
||||||
|
return CompositionDefinitions
|
||||||
|
.Select(item => new CustomerAnalysisCompositionItemDto
|
||||||
|
{
|
||||||
|
SegmentCode = item.SegmentCode,
|
||||||
|
Label = item.Label,
|
||||||
|
Count = 0,
|
||||||
|
Percent = 0,
|
||||||
|
Tone = item.Tone
|
||||||
|
})
|
||||||
|
.ToList();
|
||||||
|
}
|
||||||
|
|
||||||
|
var counter = CompositionSegmentOrder.ToDictionary(item => item, _ => 0, StringComparer.Ordinal);
|
||||||
|
foreach (var customer in customers)
|
||||||
|
{
|
||||||
|
var segmentCode = ResolveCompositionSegmentCode(customer, nowUtc, periodDays);
|
||||||
|
counter[segmentCode] += 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
return CompositionDefinitions
|
||||||
|
.Select(item => new CustomerAnalysisCompositionItemDto
|
||||||
|
{
|
||||||
|
SegmentCode = item.SegmentCode,
|
||||||
|
Label = item.Label,
|
||||||
|
Count = counter[item.SegmentCode],
|
||||||
|
Percent = CustomerAnalyticsSupport.ToRatePercent(counter[item.SegmentCode], customers.Count),
|
||||||
|
Tone = item.Tone
|
||||||
|
})
|
||||||
|
.ToList();
|
||||||
|
}
|
||||||
|
|
||||||
|
internal static IReadOnlyList<CustomerAnalysisAmountDistributionItemDto> BuildAmountDistribution(
|
||||||
|
IReadOnlyList<CustomerAggregate> customers)
|
||||||
|
{
|
||||||
|
if (customers.Count == 0)
|
||||||
|
{
|
||||||
|
return AmountDistributionDefinitions
|
||||||
|
.Select(item => new CustomerAnalysisAmountDistributionItemDto
|
||||||
|
{
|
||||||
|
SegmentCode = item.SegmentCode,
|
||||||
|
Label = item.Label,
|
||||||
|
Count = 0,
|
||||||
|
Percent = 0
|
||||||
|
})
|
||||||
|
.ToList();
|
||||||
|
}
|
||||||
|
|
||||||
|
return AmountDistributionDefinitions
|
||||||
|
.Select(item =>
|
||||||
|
{
|
||||||
|
var count = customers.Count(customer => MatchAmountDistribution(customer.AverageAmount, item));
|
||||||
|
return new CustomerAnalysisAmountDistributionItemDto
|
||||||
|
{
|
||||||
|
SegmentCode = item.SegmentCode,
|
||||||
|
Label = item.Label,
|
||||||
|
Count = count,
|
||||||
|
Percent = CustomerAnalyticsSupport.ToRatePercent(count, customers.Count)
|
||||||
|
};
|
||||||
|
})
|
||||||
|
.ToList();
|
||||||
|
}
|
||||||
|
|
||||||
|
internal static IReadOnlyList<CustomerAnalysisRfmRowDto> BuildRfmRows(
|
||||||
|
IReadOnlyList<CustomerAggregate> customers,
|
||||||
|
DateTime nowUtc)
|
||||||
|
{
|
||||||
|
var counters = new int[3, 4];
|
||||||
|
foreach (var customer in customers)
|
||||||
|
{
|
||||||
|
var rowIndex = ResolveRfmRecencyRow(customer, nowUtc);
|
||||||
|
var columnIndex = ResolveRfmFrequencyAmountColumn(customer);
|
||||||
|
counters[rowIndex, columnIndex] += 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
var rows = new List<CustomerAnalysisRfmRowDto>(3);
|
||||||
|
for (var rowIndex = 0; rowIndex < 3; rowIndex += 1)
|
||||||
|
{
|
||||||
|
var cells = new List<CustomerAnalysisRfmCellDto>(4);
|
||||||
|
for (var columnIndex = 0; columnIndex < 4; columnIndex += 1)
|
||||||
|
{
|
||||||
|
cells.Add(new CustomerAnalysisRfmCellDto
|
||||||
|
{
|
||||||
|
SegmentCode = BuildRfmSegmentCode(rowIndex, columnIndex),
|
||||||
|
Label = RfmCellLabels[rowIndex, columnIndex],
|
||||||
|
Count = counters[rowIndex, columnIndex],
|
||||||
|
Tone = RfmCellTones[rowIndex, columnIndex]
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
rows.Add(new CustomerAnalysisRfmRowDto
|
||||||
|
{
|
||||||
|
Label = RfmRowLabels[rowIndex],
|
||||||
|
Cells = cells
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return rows;
|
||||||
|
}
|
||||||
|
|
||||||
|
internal static IReadOnlyList<CustomerAnalysisTrendPointDto> BuildGrowthTrend(
|
||||||
|
IReadOnlyList<CustomerAggregate> customers,
|
||||||
|
DateTime nowUtc,
|
||||||
|
int monthCount)
|
||||||
|
{
|
||||||
|
var normalizedMonthCount = Math.Clamp(monthCount, 1, 24);
|
||||||
|
var monthStart = new DateTime(nowUtc.Year, nowUtc.Month, 1, 0, 0, 0, DateTimeKind.Utc);
|
||||||
|
var windowStart = monthStart.AddMonths(-normalizedMonthCount + 1);
|
||||||
|
|
||||||
|
var countLookup = customers
|
||||||
|
.Where(item => item.RegisteredAt >= windowStart && item.RegisteredAt < monthStart.AddMonths(1))
|
||||||
|
.GroupBy(item => new DateTime(item.RegisteredAt.Year, item.RegisteredAt.Month, 1, 0, 0, 0, DateTimeKind.Utc))
|
||||||
|
.ToDictionary(group => group.Key, group => group.Count());
|
||||||
|
|
||||||
|
var trend = new List<CustomerAnalysisTrendPointDto>(normalizedMonthCount);
|
||||||
|
for (var index = 0; index < normalizedMonthCount; index += 1)
|
||||||
|
{
|
||||||
|
var currentMonth = windowStart.AddMonths(index);
|
||||||
|
countLookup.TryGetValue(currentMonth, out var value);
|
||||||
|
|
||||||
|
trend.Add(new CustomerAnalysisTrendPointDto
|
||||||
|
{
|
||||||
|
Label = $"{currentMonth.Month}月",
|
||||||
|
Value = value
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return trend;
|
||||||
|
}
|
||||||
|
|
||||||
|
internal static IReadOnlyList<CustomerAnalysisTopCustomerDto> BuildTopCustomers(
|
||||||
|
IReadOnlyList<CustomerAggregate> customers,
|
||||||
|
int takeCount)
|
||||||
|
{
|
||||||
|
var normalizedTakeCount = Math.Clamp(takeCount, 1, 200);
|
||||||
|
|
||||||
|
return customers
|
||||||
|
.OrderByDescending(item => item.TotalAmount)
|
||||||
|
.ThenByDescending(item => item.OrderCount)
|
||||||
|
.ThenByDescending(item => item.LastOrderAt)
|
||||||
|
.ThenBy(item => item.CustomerKey, StringComparer.Ordinal)
|
||||||
|
.Take(normalizedTakeCount)
|
||||||
|
.Select((item, index) => new CustomerAnalysisTopCustomerDto
|
||||||
|
{
|
||||||
|
Rank = index + 1,
|
||||||
|
CustomerKey = item.CustomerKey,
|
||||||
|
Name = item.Name,
|
||||||
|
PhoneMasked = item.PhoneMasked,
|
||||||
|
TotalAmount = item.TotalAmount,
|
||||||
|
OrderCount = item.OrderCount,
|
||||||
|
AverageAmount = item.AverageAmount,
|
||||||
|
LastOrderAt = item.LastOrderAt,
|
||||||
|
Tags = item.Tags
|
||||||
|
})
|
||||||
|
.ToList();
|
||||||
|
}
|
||||||
|
|
||||||
|
internal static IReadOnlyList<CustomerAggregate> FilterBySegment(
|
||||||
|
IReadOnlyList<CustomerAggregate> customers,
|
||||||
|
string normalizedSegmentCode,
|
||||||
|
DateTime nowUtc,
|
||||||
|
int periodDays)
|
||||||
|
{
|
||||||
|
if (normalizedSegmentCode == SegmentAll)
|
||||||
|
{
|
||||||
|
return customers;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (TryParseRfmSegmentCode(normalizedSegmentCode, out var rowIndex, out var columnIndex))
|
||||||
|
{
|
||||||
|
return customers
|
||||||
|
.Where(customer =>
|
||||||
|
ResolveRfmRecencyRow(customer, nowUtc) == rowIndex &&
|
||||||
|
ResolveRfmFrequencyAmountColumn(customer) == columnIndex)
|
||||||
|
.ToList();
|
||||||
|
}
|
||||||
|
|
||||||
|
var amountDefinition = ResolveAmountDistributionDefinition(normalizedSegmentCode);
|
||||||
|
if (amountDefinition is not null)
|
||||||
|
{
|
||||||
|
return customers
|
||||||
|
.Where(customer => MatchAmountDistribution(customer.AverageAmount, amountDefinition))
|
||||||
|
.ToList();
|
||||||
|
}
|
||||||
|
|
||||||
|
return normalizedSegmentCode switch
|
||||||
|
{
|
||||||
|
SegmentRepeatLoyal => customers
|
||||||
|
.Where(customer => string.Equals(
|
||||||
|
ResolveCompositionSegmentCode(customer, nowUtc, periodDays),
|
||||||
|
SegmentRepeatLoyal,
|
||||||
|
StringComparison.Ordinal))
|
||||||
|
.ToList(),
|
||||||
|
SegmentActiveNew => customers
|
||||||
|
.Where(customer => string.Equals(
|
||||||
|
ResolveCompositionSegmentCode(customer, nowUtc, periodDays),
|
||||||
|
SegmentActiveNew,
|
||||||
|
StringComparison.Ordinal))
|
||||||
|
.ToList(),
|
||||||
|
SegmentActiveRecent => customers
|
||||||
|
.Where(customer => customer.LastOrderAt >= nowUtc.AddDays(-Math.Clamp(periodDays, 7, 365)))
|
||||||
|
.ToList(),
|
||||||
|
SegmentDormant => customers
|
||||||
|
.Where(customer => string.Equals(
|
||||||
|
ResolveCompositionSegmentCode(customer, nowUtc, periodDays),
|
||||||
|
SegmentDormant,
|
||||||
|
StringComparison.Ordinal))
|
||||||
|
.ToList(),
|
||||||
|
SegmentChurn => customers
|
||||||
|
.Where(customer => string.Equals(
|
||||||
|
ResolveCompositionSegmentCode(customer, nowUtc, periodDays),
|
||||||
|
SegmentChurn,
|
||||||
|
StringComparison.Ordinal))
|
||||||
|
.ToList(),
|
||||||
|
SegmentHighValueTop => customers
|
||||||
|
.Where(customer =>
|
||||||
|
customer.Tags.Any(tag => string.Equals(tag.Code, CustomerAnalyticsSupport.TagHighValue, StringComparison.Ordinal)))
|
||||||
|
.ToList(),
|
||||||
|
_ => throw new BusinessException(ErrorCodes.BadRequest, "segmentCode 参数不合法")
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
internal static IReadOnlyList<CustomerAggregate> ApplyKeyword(
|
||||||
|
IReadOnlyList<CustomerAggregate> customers,
|
||||||
|
string? keyword)
|
||||||
|
{
|
||||||
|
var normalizedKeyword = (keyword ?? string.Empty).Trim();
|
||||||
|
if (string.IsNullOrWhiteSpace(normalizedKeyword))
|
||||||
|
{
|
||||||
|
return customers;
|
||||||
|
}
|
||||||
|
|
||||||
|
var keywordDigits = CustomerAnalyticsSupport.NormalizePhone(normalizedKeyword);
|
||||||
|
return customers
|
||||||
|
.Where(customer =>
|
||||||
|
{
|
||||||
|
var matchedByName = customer.Name.Contains(normalizedKeyword, StringComparison.OrdinalIgnoreCase);
|
||||||
|
var matchedByPhone = !string.IsNullOrWhiteSpace(keywordDigits) &&
|
||||||
|
customer.CustomerKey.Contains(keywordDigits, StringComparison.Ordinal);
|
||||||
|
return matchedByName || matchedByPhone;
|
||||||
|
})
|
||||||
|
.ToList();
|
||||||
|
}
|
||||||
|
|
||||||
|
internal static bool TryParseRfmSegmentCode(
|
||||||
|
string normalizedSegmentCode,
|
||||||
|
out int rowIndex,
|
||||||
|
out int columnIndex)
|
||||||
|
{
|
||||||
|
rowIndex = -1;
|
||||||
|
columnIndex = -1;
|
||||||
|
|
||||||
|
if (!normalizedSegmentCode.StartsWith("rfm_r", StringComparison.Ordinal) ||
|
||||||
|
normalizedSegmentCode.Length != 8)
|
||||||
|
{
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
var rowChar = normalizedSegmentCode[5];
|
||||||
|
var separator = normalizedSegmentCode[6];
|
||||||
|
var columnChar = normalizedSegmentCode[7];
|
||||||
|
|
||||||
|
if (separator != 'c' || !char.IsDigit(rowChar) || !char.IsDigit(columnChar))
|
||||||
|
{
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
rowIndex = rowChar - '1';
|
||||||
|
columnIndex = columnChar - '1';
|
||||||
|
|
||||||
|
return rowIndex is >= 0 and < 3 && columnIndex is >= 0 and < 4;
|
||||||
|
}
|
||||||
|
|
||||||
|
internal static string BuildRfmSegmentCode(int rowIndex, int columnIndex)
|
||||||
|
{
|
||||||
|
return $"rfm_r{rowIndex + 1}c{columnIndex + 1}";
|
||||||
|
}
|
||||||
|
|
||||||
|
private static string ResolveCompositionSegmentCode(
|
||||||
|
CustomerAggregate customer,
|
||||||
|
DateTime nowUtc,
|
||||||
|
int periodDays)
|
||||||
|
{
|
||||||
|
var silentDays = (nowUtc.Date - customer.LastOrderAt.Date).TotalDays;
|
||||||
|
if (silentDays > 60)
|
||||||
|
{
|
||||||
|
return SegmentChurn;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (silentDays > 30)
|
||||||
|
{
|
||||||
|
return SegmentDormant;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (customer.RegisteredAt >= nowUtc.AddDays(-Math.Clamp(periodDays, 7, 365)))
|
||||||
|
{
|
||||||
|
return SegmentActiveNew;
|
||||||
|
}
|
||||||
|
|
||||||
|
return SegmentRepeatLoyal;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static AmountDistributionDefinition? ResolveAmountDistributionDefinition(string normalizedSegmentCode)
|
||||||
|
{
|
||||||
|
return AmountDistributionDefinitions.FirstOrDefault(item =>
|
||||||
|
string.Equals(item.SegmentCode, normalizedSegmentCode, StringComparison.Ordinal));
|
||||||
|
}
|
||||||
|
|
||||||
|
private static bool MatchAmountDistribution(decimal amount, AmountDistributionDefinition definition)
|
||||||
|
{
|
||||||
|
if (amount < definition.MinInclusive)
|
||||||
|
{
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (definition.MaxExclusive.HasValue && amount >= definition.MaxExclusive.Value)
|
||||||
|
{
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static int ResolveRfmRecencyRow(CustomerAggregate customer, DateTime nowUtc)
|
||||||
|
{
|
||||||
|
var silentDays = (nowUtc.Date - customer.LastOrderAt.Date).TotalDays;
|
||||||
|
if (silentDays <= 30)
|
||||||
|
{
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (silentDays <= 60)
|
||||||
|
{
|
||||||
|
return 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
return 2;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static int ResolveRfmFrequencyAmountColumn(CustomerAggregate customer)
|
||||||
|
{
|
||||||
|
var highFrequency = customer.OrderCount >= 10;
|
||||||
|
var highAmount = customer.AverageAmount >= 100m;
|
||||||
|
|
||||||
|
return (highFrequency, highAmount) switch
|
||||||
|
{
|
||||||
|
(true, true) => 0,
|
||||||
|
(true, false) => 1,
|
||||||
|
(false, true) => 2,
|
||||||
|
_ => 3
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
private sealed record AmountDistributionDefinition(
|
||||||
|
string SegmentCode,
|
||||||
|
string Label,
|
||||||
|
decimal MinInclusive,
|
||||||
|
decimal? MaxExclusive);
|
||||||
|
}
|
||||||
|
|
||||||
|
internal sealed record SegmentMeta(
|
||||||
|
string SegmentCode,
|
||||||
|
string SegmentTitle,
|
||||||
|
string SegmentDescription);
|
||||||
@@ -0,0 +1,187 @@
|
|||||||
|
using System.Globalization;
|
||||||
|
using System.Text;
|
||||||
|
using MediatR;
|
||||||
|
using TakeoutSaaS.Application.App.Customers.Dto;
|
||||||
|
using TakeoutSaaS.Application.App.Customers.Queries;
|
||||||
|
using TakeoutSaaS.Domain.Orders.Repositories;
|
||||||
|
using TakeoutSaaS.Shared.Abstractions.Data;
|
||||||
|
using TakeoutSaaS.Shared.Abstractions.Tenancy;
|
||||||
|
|
||||||
|
namespace TakeoutSaaS.Application.App.Customers.Handlers;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 客户分析报表导出处理器。
|
||||||
|
/// </summary>
|
||||||
|
public sealed class ExportCustomerAnalysisCsvQueryHandler(
|
||||||
|
IOrderRepository orderRepository,
|
||||||
|
IDapperExecutor dapperExecutor,
|
||||||
|
ITenantProvider tenantProvider)
|
||||||
|
: IRequestHandler<ExportCustomerAnalysisCsvQuery, CustomerExportDto>
|
||||||
|
{
|
||||||
|
/// <inheritdoc />
|
||||||
|
public async Task<CustomerExportDto> Handle(
|
||||||
|
ExportCustomerAnalysisCsvQuery request,
|
||||||
|
CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
var periodCode = string.IsNullOrWhiteSpace(request.PeriodCode)
|
||||||
|
? "30d"
|
||||||
|
: request.PeriodCode.Trim().ToLowerInvariant();
|
||||||
|
var periodDays = Math.Clamp(request.PeriodDays, 7, 365);
|
||||||
|
|
||||||
|
if (request.VisibleStoreIds.Count == 0)
|
||||||
|
{
|
||||||
|
return BuildExport(
|
||||||
|
BuildCsv(periodCode, periodDays, [], DateTime.UtcNow),
|
||||||
|
0);
|
||||||
|
}
|
||||||
|
|
||||||
|
var customers = await CustomerAnalyticsSupport.LoadCustomersAsync(
|
||||||
|
orderRepository,
|
||||||
|
dapperExecutor,
|
||||||
|
tenantProvider,
|
||||||
|
request.VisibleStoreIds,
|
||||||
|
cancellationToken);
|
||||||
|
|
||||||
|
return BuildExport(
|
||||||
|
BuildCsv(periodCode, periodDays, customers, DateTime.UtcNow),
|
||||||
|
Math.Min(10, customers.Count));
|
||||||
|
}
|
||||||
|
|
||||||
|
private static CustomerExportDto BuildExport(string csv, int totalCount)
|
||||||
|
{
|
||||||
|
var bytes = Encoding.UTF8.GetPreamble().Concat(Encoding.UTF8.GetBytes(csv)).ToArray();
|
||||||
|
return new CustomerExportDto
|
||||||
|
{
|
||||||
|
FileName = $"客户分析报表_{DateTime.UtcNow:yyyyMMddHHmmss}.csv",
|
||||||
|
FileContentBase64 = Convert.ToBase64String(bytes),
|
||||||
|
TotalCount = totalCount
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
private static string BuildCsv(
|
||||||
|
string periodCode,
|
||||||
|
int periodDays,
|
||||||
|
IReadOnlyList<CustomerAggregate> customers,
|
||||||
|
DateTime nowUtc)
|
||||||
|
{
|
||||||
|
var currentStart = nowUtc.AddDays(-periodDays);
|
||||||
|
var previousStart = currentStart.AddDays(-periodDays);
|
||||||
|
|
||||||
|
var totalCustomers = customers.Count;
|
||||||
|
var newCustomers = customers.Count(item => item.RegisteredAt >= currentStart);
|
||||||
|
var previousNewCustomers = customers.Count(item =>
|
||||||
|
item.RegisteredAt >= previousStart &&
|
||||||
|
item.RegisteredAt < currentStart);
|
||||||
|
var activeCustomers = customers.Count(item => item.LastOrderAt >= currentStart);
|
||||||
|
var averageLifetimeValue = totalCustomers <= 0
|
||||||
|
? 0
|
||||||
|
: decimal.Round(
|
||||||
|
customers.Sum(item => item.TotalAmount) / totalCustomers,
|
||||||
|
2,
|
||||||
|
MidpointRounding.AwayFromZero);
|
||||||
|
|
||||||
|
var composition = CustomerAnalysisSegmentSupport.BuildComposition(customers, nowUtc, periodDays);
|
||||||
|
var amountDistribution = CustomerAnalysisSegmentSupport.BuildAmountDistribution(customers);
|
||||||
|
var rfmRows = CustomerAnalysisSegmentSupport.BuildRfmRows(customers, nowUtc);
|
||||||
|
var topCustomers = CustomerAnalysisSegmentSupport.BuildTopCustomers(customers, 10);
|
||||||
|
|
||||||
|
var sb = new StringBuilder();
|
||||||
|
sb.AppendLine($"统计周期,{Escape(ResolvePeriodLabel(periodCode, periodDays))}");
|
||||||
|
sb.AppendLine();
|
||||||
|
|
||||||
|
sb.AppendLine("核心指标");
|
||||||
|
sb.AppendLine("指标,值");
|
||||||
|
sb.AppendLine($"客户总数,{totalCustomers}");
|
||||||
|
sb.AppendLine($"周期新增,{newCustomers}");
|
||||||
|
sb.AppendLine($"新增增长率,{CustomerAnalyticsSupport.ToGrowthRatePercent(newCustomers, previousNewCustomers):0.#}%");
|
||||||
|
sb.AppendLine($"周期活跃客户,{activeCustomers}");
|
||||||
|
sb.AppendLine($"活跃率,{CustomerAnalyticsSupport.ToRatePercent(activeCustomers, totalCustomers):0.#}%");
|
||||||
|
sb.AppendLine($"平均客户价值,{averageLifetimeValue.ToString("0.00", CultureInfo.InvariantCulture)}");
|
||||||
|
sb.AppendLine();
|
||||||
|
|
||||||
|
sb.AppendLine("新老客占比");
|
||||||
|
sb.AppendLine("分层,人数,占比");
|
||||||
|
foreach (var item in composition)
|
||||||
|
{
|
||||||
|
sb.AppendLine($"{Escape(item.Label)},{item.Count},{item.Percent:0.#}%");
|
||||||
|
}
|
||||||
|
sb.AppendLine();
|
||||||
|
|
||||||
|
sb.AppendLine("客单价分布");
|
||||||
|
sb.AppendLine("区间,人数,占比");
|
||||||
|
foreach (var item in amountDistribution)
|
||||||
|
{
|
||||||
|
sb.AppendLine($"{Escape(item.Label)},{item.Count},{item.Percent:0.#}%");
|
||||||
|
}
|
||||||
|
sb.AppendLine();
|
||||||
|
|
||||||
|
sb.AppendLine("RFM客户分层");
|
||||||
|
sb.AppendLine("活跃度,分群,标签,人数");
|
||||||
|
for (var rowIndex = 0; rowIndex < rfmRows.Count; rowIndex += 1)
|
||||||
|
{
|
||||||
|
var row = rfmRows[rowIndex];
|
||||||
|
for (var columnIndex = 0; columnIndex < row.Cells.Count; columnIndex += 1)
|
||||||
|
{
|
||||||
|
var cell = row.Cells[columnIndex];
|
||||||
|
sb.AppendLine($"{Escape(row.Label)},{Escape(ResolveRfmColumnLabel(columnIndex))},{Escape(cell.Label)},{cell.Count}");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
sb.AppendLine();
|
||||||
|
|
||||||
|
sb.AppendLine("高价值客户TOP10");
|
||||||
|
sb.AppendLine("排名,客户,手机号,累计消费,下单次数,客单价,最近下单,标签");
|
||||||
|
foreach (var item in topCustomers)
|
||||||
|
{
|
||||||
|
var tags = item.Tags.Count == 0
|
||||||
|
? string.Empty
|
||||||
|
: string.Join('、', item.Tags.Select(tag => tag.Label));
|
||||||
|
|
||||||
|
sb.AppendLine(string.Join(',',
|
||||||
|
[
|
||||||
|
item.Rank.ToString(CultureInfo.InvariantCulture),
|
||||||
|
Escape(item.Name),
|
||||||
|
Escape(item.PhoneMasked),
|
||||||
|
Escape(item.TotalAmount.ToString("0.00", CultureInfo.InvariantCulture)),
|
||||||
|
Escape(item.OrderCount.ToString(CultureInfo.InvariantCulture)),
|
||||||
|
Escape(item.AverageAmount.ToString("0.00", CultureInfo.InvariantCulture)),
|
||||||
|
Escape(item.LastOrderAt.ToString("yyyy-MM-dd", CultureInfo.InvariantCulture)),
|
||||||
|
Escape(tags)
|
||||||
|
]));
|
||||||
|
}
|
||||||
|
|
||||||
|
return sb.ToString();
|
||||||
|
}
|
||||||
|
|
||||||
|
private static string ResolvePeriodLabel(string periodCode, int periodDays)
|
||||||
|
{
|
||||||
|
return periodCode switch
|
||||||
|
{
|
||||||
|
"7d" => "近7天",
|
||||||
|
"30d" => "近30天",
|
||||||
|
"90d" => "近90天",
|
||||||
|
"365d" => "近1年",
|
||||||
|
_ => $"近{periodDays}天"
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
private static string ResolveRfmColumnLabel(int columnIndex)
|
||||||
|
{
|
||||||
|
return columnIndex switch
|
||||||
|
{
|
||||||
|
0 => "高频高额",
|
||||||
|
1 => "高频低额",
|
||||||
|
2 => "低频高额",
|
||||||
|
_ => "低频低额"
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
private static string Escape(string input)
|
||||||
|
{
|
||||||
|
if (!input.Contains('"') && !input.Contains(',') && !input.Contains('\n') && !input.Contains('\r'))
|
||||||
|
{
|
||||||
|
return input;
|
||||||
|
}
|
||||||
|
|
||||||
|
return $"\"{input.Replace("\"", "\"\"")}\"";
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,94 @@
|
|||||||
|
using MediatR;
|
||||||
|
using TakeoutSaaS.Application.App.Customers.Dto;
|
||||||
|
using TakeoutSaaS.Application.App.Customers.Queries;
|
||||||
|
using TakeoutSaaS.Domain.Orders.Repositories;
|
||||||
|
using TakeoutSaaS.Shared.Abstractions.Data;
|
||||||
|
using TakeoutSaaS.Shared.Abstractions.Tenancy;
|
||||||
|
|
||||||
|
namespace TakeoutSaaS.Application.App.Customers.Handlers;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 客户分析总览查询处理器。
|
||||||
|
/// </summary>
|
||||||
|
public sealed class GetCustomerAnalysisOverviewQueryHandler(
|
||||||
|
IOrderRepository orderRepository,
|
||||||
|
IDapperExecutor dapperExecutor,
|
||||||
|
ITenantProvider tenantProvider)
|
||||||
|
: IRequestHandler<GetCustomerAnalysisOverviewQuery, CustomerAnalysisOverviewDto>
|
||||||
|
{
|
||||||
|
/// <inheritdoc />
|
||||||
|
public async Task<CustomerAnalysisOverviewDto> Handle(
|
||||||
|
GetCustomerAnalysisOverviewQuery request,
|
||||||
|
CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
var periodDays = Math.Clamp(request.PeriodDays, 7, 365);
|
||||||
|
var periodCode = string.IsNullOrWhiteSpace(request.PeriodCode)
|
||||||
|
? "30d"
|
||||||
|
: request.PeriodCode.Trim().ToLowerInvariant();
|
||||||
|
|
||||||
|
if (request.VisibleStoreIds.Count == 0)
|
||||||
|
{
|
||||||
|
return BuildEmptyOverview(periodCode, periodDays);
|
||||||
|
}
|
||||||
|
|
||||||
|
var customers = await CustomerAnalyticsSupport.LoadCustomersAsync(
|
||||||
|
orderRepository,
|
||||||
|
dapperExecutor,
|
||||||
|
tenantProvider,
|
||||||
|
request.VisibleStoreIds,
|
||||||
|
cancellationToken);
|
||||||
|
|
||||||
|
var nowUtc = DateTime.UtcNow;
|
||||||
|
var currentStart = nowUtc.AddDays(-periodDays);
|
||||||
|
var previousStart = currentStart.AddDays(-periodDays);
|
||||||
|
|
||||||
|
var totalCustomers = customers.Count;
|
||||||
|
var newCustomers = customers.Count(item => item.RegisteredAt >= currentStart);
|
||||||
|
var previousNewCustomers = customers.Count(item =>
|
||||||
|
item.RegisteredAt >= previousStart &&
|
||||||
|
item.RegisteredAt < currentStart);
|
||||||
|
var activeCustomers = customers.Count(item => item.LastOrderAt >= currentStart);
|
||||||
|
|
||||||
|
var averageLifetimeValue = totalCustomers <= 0
|
||||||
|
? 0
|
||||||
|
: decimal.Round(
|
||||||
|
customers.Sum(item => item.TotalAmount) / totalCustomers,
|
||||||
|
2,
|
||||||
|
MidpointRounding.AwayFromZero);
|
||||||
|
|
||||||
|
return new CustomerAnalysisOverviewDto
|
||||||
|
{
|
||||||
|
PeriodCode = periodCode,
|
||||||
|
PeriodDays = periodDays,
|
||||||
|
TotalCustomers = totalCustomers,
|
||||||
|
NewCustomers = newCustomers,
|
||||||
|
GrowthRatePercent = CustomerAnalyticsSupport.ToGrowthRatePercent(newCustomers, previousNewCustomers),
|
||||||
|
NewCustomersDailyAverage = decimal.Round(
|
||||||
|
newCustomers / Math.Max(1m, periodDays),
|
||||||
|
1,
|
||||||
|
MidpointRounding.AwayFromZero),
|
||||||
|
ActiveCustomers = activeCustomers,
|
||||||
|
ActiveRatePercent = CustomerAnalyticsSupport.ToRatePercent(activeCustomers, totalCustomers),
|
||||||
|
AverageLifetimeValue = averageLifetimeValue,
|
||||||
|
GrowthTrend = CustomerAnalysisSegmentSupport.BuildGrowthTrend(customers, nowUtc, 12),
|
||||||
|
Composition = CustomerAnalysisSegmentSupport.BuildComposition(customers, nowUtc, periodDays),
|
||||||
|
AmountDistribution = CustomerAnalysisSegmentSupport.BuildAmountDistribution(customers),
|
||||||
|
RfmRows = CustomerAnalysisSegmentSupport.BuildRfmRows(customers, nowUtc),
|
||||||
|
TopCustomers = CustomerAnalysisSegmentSupport.BuildTopCustomers(customers, 10)
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
private static CustomerAnalysisOverviewDto BuildEmptyOverview(string periodCode, int periodDays)
|
||||||
|
{
|
||||||
|
return new CustomerAnalysisOverviewDto
|
||||||
|
{
|
||||||
|
PeriodCode = periodCode,
|
||||||
|
PeriodDays = periodDays,
|
||||||
|
GrowthTrend = CustomerAnalysisSegmentSupport.BuildGrowthTrend([], DateTime.UtcNow, 12),
|
||||||
|
Composition = CustomerAnalysisSegmentSupport.BuildComposition([], DateTime.UtcNow, periodDays),
|
||||||
|
AmountDistribution = CustomerAnalysisSegmentSupport.BuildAmountDistribution([]),
|
||||||
|
RfmRows = CustomerAnalysisSegmentSupport.BuildRfmRows([], DateTime.UtcNow),
|
||||||
|
TopCustomers = []
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,102 @@
|
|||||||
|
using MediatR;
|
||||||
|
using TakeoutSaaS.Application.App.Customers.Dto;
|
||||||
|
using TakeoutSaaS.Application.App.Customers.Queries;
|
||||||
|
using TakeoutSaaS.Domain.Orders.Repositories;
|
||||||
|
using TakeoutSaaS.Shared.Abstractions.Data;
|
||||||
|
using TakeoutSaaS.Shared.Abstractions.Tenancy;
|
||||||
|
|
||||||
|
namespace TakeoutSaaS.Application.App.Customers.Handlers;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 客群明细查询处理器。
|
||||||
|
/// </summary>
|
||||||
|
public sealed class GetCustomerAnalysisSegmentListQueryHandler(
|
||||||
|
IOrderRepository orderRepository,
|
||||||
|
IDapperExecutor dapperExecutor,
|
||||||
|
ITenantProvider tenantProvider)
|
||||||
|
: IRequestHandler<GetCustomerAnalysisSegmentListQuery, CustomerAnalysisSegmentListResultDto>
|
||||||
|
{
|
||||||
|
/// <inheritdoc />
|
||||||
|
public async Task<CustomerAnalysisSegmentListResultDto> Handle(
|
||||||
|
GetCustomerAnalysisSegmentListQuery request,
|
||||||
|
CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
var page = Math.Max(1, request.Page);
|
||||||
|
var pageSize = Math.Clamp(request.PageSize, 1, 200);
|
||||||
|
var periodDays = Math.Clamp(request.PeriodDays, 7, 365);
|
||||||
|
var normalizedSegmentCode = CustomerAnalysisSegmentSupport.NormalizeSegmentCode(request.SegmentCode);
|
||||||
|
var segmentMeta = CustomerAnalysisSegmentSupport.ResolveSegmentMeta(normalizedSegmentCode);
|
||||||
|
|
||||||
|
if (request.VisibleStoreIds.Count == 0)
|
||||||
|
{
|
||||||
|
return new CustomerAnalysisSegmentListResultDto
|
||||||
|
{
|
||||||
|
SegmentCode = segmentMeta.SegmentCode,
|
||||||
|
SegmentTitle = segmentMeta.SegmentTitle,
|
||||||
|
SegmentDescription = segmentMeta.SegmentDescription,
|
||||||
|
Page = page,
|
||||||
|
PageSize = pageSize,
|
||||||
|
TotalCount = 0,
|
||||||
|
Items = []
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
var customers = await CustomerAnalyticsSupport.LoadCustomersAsync(
|
||||||
|
orderRepository,
|
||||||
|
dapperExecutor,
|
||||||
|
tenantProvider,
|
||||||
|
request.VisibleStoreIds,
|
||||||
|
cancellationToken);
|
||||||
|
|
||||||
|
var nowUtc = DateTime.UtcNow;
|
||||||
|
var segmentCustomers = CustomerAnalysisSegmentSupport.FilterBySegment(
|
||||||
|
customers,
|
||||||
|
normalizedSegmentCode,
|
||||||
|
nowUtc,
|
||||||
|
periodDays);
|
||||||
|
|
||||||
|
var keywordFiltered = CustomerAnalysisSegmentSupport.ApplyKeyword(
|
||||||
|
segmentCustomers,
|
||||||
|
request.Keyword);
|
||||||
|
|
||||||
|
var sortedCustomers = keywordFiltered
|
||||||
|
.OrderByDescending(item => item.TotalAmount)
|
||||||
|
.ThenByDescending(item => item.OrderCount)
|
||||||
|
.ThenByDescending(item => item.LastOrderAt)
|
||||||
|
.ThenBy(item => item.CustomerKey, StringComparer.Ordinal)
|
||||||
|
.ToList();
|
||||||
|
|
||||||
|
var pagedItems = sortedCustomers
|
||||||
|
.Skip((page - 1) * pageSize)
|
||||||
|
.Take(pageSize)
|
||||||
|
.Select(item => new CustomerAnalysisSegmentListItemDto
|
||||||
|
{
|
||||||
|
CustomerKey = item.CustomerKey,
|
||||||
|
Name = item.Name,
|
||||||
|
PhoneMasked = item.PhoneMasked,
|
||||||
|
AvatarText = item.AvatarText,
|
||||||
|
AvatarColor = item.AvatarColor,
|
||||||
|
Tags = item.Tags,
|
||||||
|
IsMember = item.Member.IsMember,
|
||||||
|
MemberTierName = item.Member.TierName,
|
||||||
|
TotalAmount = item.TotalAmount,
|
||||||
|
OrderCount = item.OrderCount,
|
||||||
|
AverageAmount = item.AverageAmount,
|
||||||
|
RegisteredAt = item.RegisteredAt,
|
||||||
|
LastOrderAt = item.LastOrderAt,
|
||||||
|
IsDimmed = item.IsDimmed
|
||||||
|
})
|
||||||
|
.ToList();
|
||||||
|
|
||||||
|
return new CustomerAnalysisSegmentListResultDto
|
||||||
|
{
|
||||||
|
SegmentCode = segmentMeta.SegmentCode,
|
||||||
|
SegmentTitle = segmentMeta.SegmentTitle,
|
||||||
|
SegmentDescription = segmentMeta.SegmentDescription,
|
||||||
|
Items = pagedItems,
|
||||||
|
Page = page,
|
||||||
|
PageSize = pageSize,
|
||||||
|
TotalCount = sortedCustomers.Count
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,77 @@
|
|||||||
|
using MediatR;
|
||||||
|
using TakeoutSaaS.Application.App.Customers.Dto;
|
||||||
|
using TakeoutSaaS.Application.App.Customers.Queries;
|
||||||
|
using TakeoutSaaS.Domain.Orders.Repositories;
|
||||||
|
using TakeoutSaaS.Shared.Abstractions.Data;
|
||||||
|
using TakeoutSaaS.Shared.Abstractions.Tenancy;
|
||||||
|
|
||||||
|
namespace TakeoutSaaS.Application.App.Customers.Handlers;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 会员详情查询处理器。
|
||||||
|
/// </summary>
|
||||||
|
public sealed class GetCustomerMemberDetailQueryHandler(
|
||||||
|
IOrderRepository orderRepository,
|
||||||
|
IDapperExecutor dapperExecutor,
|
||||||
|
ITenantProvider tenantProvider)
|
||||||
|
: IRequestHandler<GetCustomerMemberDetailQuery, CustomerMemberDetailDto?>
|
||||||
|
{
|
||||||
|
/// <inheritdoc />
|
||||||
|
public async Task<CustomerMemberDetailDto?> Handle(
|
||||||
|
GetCustomerMemberDetailQuery request,
|
||||||
|
CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
var customerKey = CustomerAnalyticsSupport.NormalizePhone(request.CustomerKey);
|
||||||
|
if (request.VisibleStoreIds.Count == 0 || string.IsNullOrWhiteSpace(customerKey))
|
||||||
|
{
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
var customers = await CustomerAnalyticsSupport.LoadCustomersAsync(
|
||||||
|
orderRepository,
|
||||||
|
dapperExecutor,
|
||||||
|
tenantProvider,
|
||||||
|
request.VisibleStoreIds,
|
||||||
|
cancellationToken);
|
||||||
|
|
||||||
|
var customer = customers.FirstOrDefault(item =>
|
||||||
|
string.Equals(item.CustomerKey, customerKey, StringComparison.Ordinal));
|
||||||
|
if (customer is null)
|
||||||
|
{
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
var tenantId = tenantProvider.GetCurrentTenantId();
|
||||||
|
if (tenantId <= 0)
|
||||||
|
{
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
var orderIds = customer.Orders
|
||||||
|
.Select(item => item.OrderId)
|
||||||
|
.ToList();
|
||||||
|
|
||||||
|
var itemLookup = await orderRepository.GetItemsByOrderIdsAsync(orderIds, tenantId, cancellationToken);
|
||||||
|
var recentOrders = CustomerAnalyticsSupport.BuildRecentOrders(customer.Orders, itemLookup, 5);
|
||||||
|
var repurchaseRatePercent = CustomerAnalyticsSupport.ToRatePercent(
|
||||||
|
Math.Max(0, customer.OrderCount - 1),
|
||||||
|
customer.OrderCount);
|
||||||
|
|
||||||
|
return new CustomerMemberDetailDto
|
||||||
|
{
|
||||||
|
CustomerKey = customer.CustomerKey,
|
||||||
|
Name = customer.Name,
|
||||||
|
PhoneMasked = customer.PhoneMasked,
|
||||||
|
Source = customer.Source,
|
||||||
|
RegisteredAt = customer.RegisteredAt,
|
||||||
|
LastOrderAt = customer.LastOrderAt,
|
||||||
|
Member = customer.Member,
|
||||||
|
Tags = customer.Tags,
|
||||||
|
TotalOrders = customer.OrderCount,
|
||||||
|
TotalAmount = customer.TotalAmount,
|
||||||
|
AverageAmount = customer.AverageAmount,
|
||||||
|
RepurchaseRatePercent = repurchaseRatePercent,
|
||||||
|
RecentOrders = recentOrders
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,25 @@
|
|||||||
|
using MediatR;
|
||||||
|
using TakeoutSaaS.Application.App.Customers.Dto;
|
||||||
|
|
||||||
|
namespace TakeoutSaaS.Application.App.Customers.Queries;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 客户分析报表导出查询。
|
||||||
|
/// </summary>
|
||||||
|
public sealed class ExportCustomerAnalysisCsvQuery : IRequest<CustomerExportDto>
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// 可见门店 ID 集合。
|
||||||
|
/// </summary>
|
||||||
|
public IReadOnlyCollection<long> VisibleStoreIds { get; init; } = [];
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 统计周期编码(7d/30d/90d/365d)。
|
||||||
|
/// </summary>
|
||||||
|
public string PeriodCode { get; init; } = "30d";
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 统计周期天数。
|
||||||
|
/// </summary>
|
||||||
|
public int PeriodDays { get; init; } = 30;
|
||||||
|
}
|
||||||
@@ -0,0 +1,25 @@
|
|||||||
|
using MediatR;
|
||||||
|
using TakeoutSaaS.Application.App.Customers.Dto;
|
||||||
|
|
||||||
|
namespace TakeoutSaaS.Application.App.Customers.Queries;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 客户分析总览查询。
|
||||||
|
/// </summary>
|
||||||
|
public sealed class GetCustomerAnalysisOverviewQuery : IRequest<CustomerAnalysisOverviewDto>
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// 可见门店 ID 集合。
|
||||||
|
/// </summary>
|
||||||
|
public IReadOnlyCollection<long> VisibleStoreIds { get; init; } = [];
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 统计周期编码(7d/30d/90d/365d)。
|
||||||
|
/// </summary>
|
||||||
|
public string PeriodCode { get; init; } = "30d";
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 统计周期天数。
|
||||||
|
/// </summary>
|
||||||
|
public int PeriodDays { get; init; } = 30;
|
||||||
|
}
|
||||||
@@ -0,0 +1,45 @@
|
|||||||
|
using MediatR;
|
||||||
|
using TakeoutSaaS.Application.App.Customers.Dto;
|
||||||
|
|
||||||
|
namespace TakeoutSaaS.Application.App.Customers.Queries;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 客群明细查询。
|
||||||
|
/// </summary>
|
||||||
|
public sealed class GetCustomerAnalysisSegmentListQuery : IRequest<CustomerAnalysisSegmentListResultDto>
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// 可见门店 ID 集合。
|
||||||
|
/// </summary>
|
||||||
|
public IReadOnlyCollection<long> VisibleStoreIds { get; init; } = [];
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 统计周期编码(7d/30d/90d/365d)。
|
||||||
|
/// </summary>
|
||||||
|
public string PeriodCode { get; init; } = "30d";
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 统计周期天数。
|
||||||
|
/// </summary>
|
||||||
|
public int PeriodDays { get; init; } = 30;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 分群编码。
|
||||||
|
/// </summary>
|
||||||
|
public string SegmentCode { get; init; } = "all";
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 关键词(姓名/手机号)。
|
||||||
|
/// </summary>
|
||||||
|
public string? Keyword { get; init; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 页码。
|
||||||
|
/// </summary>
|
||||||
|
public int Page { get; init; } = 1;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 每页条数。
|
||||||
|
/// </summary>
|
||||||
|
public int PageSize { get; init; } = 10;
|
||||||
|
}
|
||||||
@@ -0,0 +1,20 @@
|
|||||||
|
using MediatR;
|
||||||
|
using TakeoutSaaS.Application.App.Customers.Dto;
|
||||||
|
|
||||||
|
namespace TakeoutSaaS.Application.App.Customers.Queries;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 会员详情查询。
|
||||||
|
/// </summary>
|
||||||
|
public sealed class GetCustomerMemberDetailQuery : IRequest<CustomerMemberDetailDto?>
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// 可见门店 ID 集合。
|
||||||
|
/// </summary>
|
||||||
|
public IReadOnlyCollection<long> VisibleStoreIds { get; init; } = [];
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 客户标识(手机号归一化)。
|
||||||
|
/// </summary>
|
||||||
|
public string CustomerKey { get; init; } = string.Empty;
|
||||||
|
}
|
||||||
@@ -0,0 +1,14 @@
|
|||||||
|
using MediatR;
|
||||||
|
|
||||||
|
namespace TakeoutSaaS.Application.App.Members.Commands;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 删除会员等级命令。
|
||||||
|
/// </summary>
|
||||||
|
public sealed class DeleteMemberTierCommand : IRequest
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// 等级标识。
|
||||||
|
/// </summary>
|
||||||
|
public long TierId { get; init; }
|
||||||
|
}
|
||||||
@@ -0,0 +1,25 @@
|
|||||||
|
using MediatR;
|
||||||
|
using TakeoutSaaS.Application.App.Members.Dto;
|
||||||
|
|
||||||
|
namespace TakeoutSaaS.Application.App.Members.Commands;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 保存会员日配置命令。
|
||||||
|
/// </summary>
|
||||||
|
public sealed class SaveMemberDaySettingCommand : IRequest<MemberDaySettingDto>
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// 是否启用会员日。
|
||||||
|
/// </summary>
|
||||||
|
public bool IsEnabled { get; init; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 周几(1-7,对应周一到周日)。
|
||||||
|
/// </summary>
|
||||||
|
public int Weekday { get; init; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 会员日额外折扣(如 9 表示 9 折)。
|
||||||
|
/// </summary>
|
||||||
|
public decimal ExtraDiscountRate { get; init; }
|
||||||
|
}
|
||||||
@@ -0,0 +1,19 @@
|
|||||||
|
using MediatR;
|
||||||
|
|
||||||
|
namespace TakeoutSaaS.Application.App.Members.Commands;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 保存会员标签命令。
|
||||||
|
/// </summary>
|
||||||
|
public sealed class SaveMemberTagsCommand : IRequest
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// 会员标识。
|
||||||
|
/// </summary>
|
||||||
|
public long MemberId { get; init; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 标签集合。
|
||||||
|
/// </summary>
|
||||||
|
public IReadOnlyList<string> Tags { get; init; } = [];
|
||||||
|
}
|
||||||
@@ -0,0 +1,50 @@
|
|||||||
|
using MediatR;
|
||||||
|
using TakeoutSaaS.Application.App.Members.Dto;
|
||||||
|
|
||||||
|
namespace TakeoutSaaS.Application.App.Members.Commands;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 保存会员等级命令。
|
||||||
|
/// </summary>
|
||||||
|
public sealed class SaveMemberTierCommand : IRequest<MemberTierDetailDto>
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// 等级标识(为空时新增)。
|
||||||
|
/// </summary>
|
||||||
|
public long? TierId { get; init; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 排序序号。
|
||||||
|
/// </summary>
|
||||||
|
public int SortOrder { get; init; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 等级名称。
|
||||||
|
/// </summary>
|
||||||
|
public string Name { get; init; } = string.Empty;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 图标键。
|
||||||
|
/// </summary>
|
||||||
|
public string IconKey { get; init; } = "user";
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 主题色。
|
||||||
|
/// </summary>
|
||||||
|
public string ColorHex { get; init; } = "#999999";
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 是否默认等级。
|
||||||
|
/// </summary>
|
||||||
|
public bool IsDefault { get; init; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 升降级规则。
|
||||||
|
/// </summary>
|
||||||
|
public MemberTierRuleDto Rule { get; init; } = new();
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 等级权益。
|
||||||
|
/// </summary>
|
||||||
|
public MemberTierBenefitsDto Benefits { get; init; } = new();
|
||||||
|
}
|
||||||
@@ -0,0 +1,567 @@
|
|||||||
|
namespace TakeoutSaaS.Application.App.Members.Dto;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 会员列表行 DTO。
|
||||||
|
/// </summary>
|
||||||
|
public sealed class MemberListItemDto
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// 会员标识。
|
||||||
|
/// </summary>
|
||||||
|
public long MemberId { get; init; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 会员名称。
|
||||||
|
/// </summary>
|
||||||
|
public string Name { get; init; } = string.Empty;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 头像文案。
|
||||||
|
/// </summary>
|
||||||
|
public string AvatarText { get; init; } = string.Empty;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 头像颜色。
|
||||||
|
/// </summary>
|
||||||
|
public string AvatarColor { get; init; } = string.Empty;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 手机号(脱敏)。
|
||||||
|
/// </summary>
|
||||||
|
public string MobileMasked { get; init; } = string.Empty;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 会员等级标识。
|
||||||
|
/// </summary>
|
||||||
|
public long? TierId { get; init; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 会员等级名称。
|
||||||
|
/// </summary>
|
||||||
|
public string TierName { get; init; } = string.Empty;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 等级主题色。
|
||||||
|
/// </summary>
|
||||||
|
public string TierColorHex { get; init; } = string.Empty;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 累计消费。
|
||||||
|
/// </summary>
|
||||||
|
public decimal TotalAmount { get; init; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 消费次数。
|
||||||
|
/// </summary>
|
||||||
|
public int OrderCount { get; init; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 最近消费时间。
|
||||||
|
/// </summary>
|
||||||
|
public DateTime LastOrderAt { get; init; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 储值余额。
|
||||||
|
/// </summary>
|
||||||
|
public decimal StoredBalance { get; init; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 积分余额。
|
||||||
|
/// </summary>
|
||||||
|
public int PointsBalance { get; init; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 是否沉睡会员(用于弱化展示)。
|
||||||
|
/// </summary>
|
||||||
|
public bool IsDormant { get; init; }
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 会员列表统计 DTO。
|
||||||
|
/// </summary>
|
||||||
|
public sealed class MemberListStatsDto
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// 会员总数。
|
||||||
|
/// </summary>
|
||||||
|
public int TotalMembers { get; init; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 本月新增会员数。
|
||||||
|
/// </summary>
|
||||||
|
public int MonthlyNewMembers { get; init; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 活跃会员数(近 30 天有消费)。
|
||||||
|
/// </summary>
|
||||||
|
public int ActiveMembers { get; init; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 沉睡会员数(超过 60 天未消费)。
|
||||||
|
/// </summary>
|
||||||
|
public int DormantMembers { get; init; }
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 会员最近订单 DTO。
|
||||||
|
/// </summary>
|
||||||
|
public sealed class MemberRecentOrderDto
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// 日期。
|
||||||
|
/// </summary>
|
||||||
|
public DateTime OrderedAt { get; init; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 订单号。
|
||||||
|
/// </summary>
|
||||||
|
public string OrderNo { get; init; } = string.Empty;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 订单金额。
|
||||||
|
/// </summary>
|
||||||
|
public decimal Amount { get; init; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 订单状态文案。
|
||||||
|
/// </summary>
|
||||||
|
public string StatusText { get; init; } = string.Empty;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 会员详情 DTO。
|
||||||
|
/// </summary>
|
||||||
|
public sealed class MemberDetailDto
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// 会员标识。
|
||||||
|
/// </summary>
|
||||||
|
public long MemberId { get; init; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 会员名称。
|
||||||
|
/// </summary>
|
||||||
|
public string Name { get; init; } = string.Empty;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 头像文案。
|
||||||
|
/// </summary>
|
||||||
|
public string AvatarText { get; init; } = string.Empty;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 头像颜色。
|
||||||
|
/// </summary>
|
||||||
|
public string AvatarColor { get; init; } = string.Empty;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 手机号(脱敏)。
|
||||||
|
/// </summary>
|
||||||
|
public string MobileMasked { get; init; } = string.Empty;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 注册时间。
|
||||||
|
/// </summary>
|
||||||
|
public DateTime JoinedAt { get; init; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 会员等级标识。
|
||||||
|
/// </summary>
|
||||||
|
public long? TierId { get; init; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 会员等级名称。
|
||||||
|
/// </summary>
|
||||||
|
public string TierName { get; init; } = string.Empty;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 等级主题色。
|
||||||
|
/// </summary>
|
||||||
|
public string TierColorHex { get; init; } = string.Empty;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 累计消费。
|
||||||
|
/// </summary>
|
||||||
|
public decimal TotalAmount { get; init; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 消费次数。
|
||||||
|
/// </summary>
|
||||||
|
public int OrderCount { get; init; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 平均客单价。
|
||||||
|
/// </summary>
|
||||||
|
public decimal AverageAmount { get; init; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 储值余额。
|
||||||
|
/// </summary>
|
||||||
|
public decimal StoredBalance { get; init; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 储值实充余额。
|
||||||
|
/// </summary>
|
||||||
|
public decimal StoredRechargeBalance { get; init; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 储值赠金余额。
|
||||||
|
/// </summary>
|
||||||
|
public decimal StoredGiftBalance { get; init; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 积分余额。
|
||||||
|
/// </summary>
|
||||||
|
public int PointsBalance { get; init; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 会员标签。
|
||||||
|
/// </summary>
|
||||||
|
public IReadOnlyList<string> Tags { get; init; } = [];
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 最近订单。
|
||||||
|
/// </summary>
|
||||||
|
public IReadOnlyList<MemberRecentOrderDto> RecentOrders { get; init; } = [];
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 会员列表导出 DTO。
|
||||||
|
/// </summary>
|
||||||
|
public sealed class MemberExportDto
|
||||||
|
{
|
||||||
|
/// <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; }
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 会员等级列表项 DTO。
|
||||||
|
/// </summary>
|
||||||
|
public sealed class MemberTierListItemDto
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// 等级标识。
|
||||||
|
/// </summary>
|
||||||
|
public long TierId { get; init; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 排序序号。
|
||||||
|
/// </summary>
|
||||||
|
public int SortOrder { get; init; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 等级名称。
|
||||||
|
/// </summary>
|
||||||
|
public string Name { get; init; } = string.Empty;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 图标键。
|
||||||
|
/// </summary>
|
||||||
|
public string IconKey { get; init; } = string.Empty;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 主题色。
|
||||||
|
/// </summary>
|
||||||
|
public string ColorHex { get; init; } = string.Empty;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 升级条件文案。
|
||||||
|
/// </summary>
|
||||||
|
public string ConditionText { get; init; } = string.Empty;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 权益摘要。
|
||||||
|
/// </summary>
|
||||||
|
public IReadOnlyList<string> Perks { get; init; } = [];
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 等级会员数。
|
||||||
|
/// </summary>
|
||||||
|
public int MemberCount { get; init; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 是否默认等级。
|
||||||
|
/// </summary>
|
||||||
|
public bool IsDefault { get; init; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 是否可删除。
|
||||||
|
/// </summary>
|
||||||
|
public bool CanDelete { get; init; }
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 会员等级规则 DTO。
|
||||||
|
/// </summary>
|
||||||
|
public sealed class MemberTierRuleDto
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// 升级规则类型(none/amount/count/both)。
|
||||||
|
/// </summary>
|
||||||
|
public string UpgradeRuleType { get; init; } = "none";
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 升级累计消费门槛。
|
||||||
|
/// </summary>
|
||||||
|
public decimal? UpgradeAmountThreshold { get; init; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 升级消费次数门槛。
|
||||||
|
/// </summary>
|
||||||
|
public int? UpgradeOrderCountThreshold { get; init; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 降级观察窗口天数。
|
||||||
|
/// </summary>
|
||||||
|
public int DowngradeWindowDays { get; init; } = 90;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 会员等级权益 DTO。
|
||||||
|
/// </summary>
|
||||||
|
public sealed class MemberTierBenefitsDto
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// 折扣权益。
|
||||||
|
/// </summary>
|
||||||
|
public MemberTierDiscountBenefitDto Discount { get; init; } = new();
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 积分倍率权益。
|
||||||
|
/// </summary>
|
||||||
|
public MemberTierPointMultiplierBenefitDto PointMultiplier { get; init; } = new();
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 生日特权。
|
||||||
|
/// </summary>
|
||||||
|
public MemberTierBirthdayBenefitDto Birthday { get; init; } = new();
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 每月赠券。
|
||||||
|
/// </summary>
|
||||||
|
public MemberTierMonthlyCouponBenefitDto MonthlyCoupon { get; init; } = new();
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 免配送费权益。
|
||||||
|
/// </summary>
|
||||||
|
public MemberTierFreeDeliveryBenefitDto FreeDelivery { get; init; } = new();
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 优先配送。
|
||||||
|
/// </summary>
|
||||||
|
public bool PriorityDeliveryEnabled { get; init; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 专属客服。
|
||||||
|
/// </summary>
|
||||||
|
public bool ExclusiveServiceEnabled { get; init; }
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 折扣权益 DTO。
|
||||||
|
/// </summary>
|
||||||
|
public sealed class MemberTierDiscountBenefitDto
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// 是否启用。
|
||||||
|
/// </summary>
|
||||||
|
public bool Enabled { get; init; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 折扣值(如 9.5)。
|
||||||
|
/// </summary>
|
||||||
|
public decimal? DiscountRate { get; init; }
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 积分倍率权益 DTO。
|
||||||
|
/// </summary>
|
||||||
|
public sealed class MemberTierPointMultiplierBenefitDto
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// 是否启用。
|
||||||
|
/// </summary>
|
||||||
|
public bool Enabled { get; init; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 倍率(如 1.5)。
|
||||||
|
/// </summary>
|
||||||
|
public decimal? Multiplier { get; init; }
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 生日特权 DTO。
|
||||||
|
/// </summary>
|
||||||
|
public sealed class MemberTierBirthdayBenefitDto
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// 是否启用。
|
||||||
|
/// </summary>
|
||||||
|
public bool Enabled { get; init; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 是否双倍积分。
|
||||||
|
/// </summary>
|
||||||
|
public bool DoublePointsEnabled { get; init; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 生日赠券模板 ID。
|
||||||
|
/// </summary>
|
||||||
|
public IReadOnlyList<long> CouponTemplateIds { get; init; } = [];
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 每月赠券 DTO。
|
||||||
|
/// </summary>
|
||||||
|
public sealed class MemberTierMonthlyCouponBenefitDto
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// 是否启用。
|
||||||
|
/// </summary>
|
||||||
|
public bool Enabled { get; init; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 每月发放日(1-28)。
|
||||||
|
/// </summary>
|
||||||
|
public int GrantDay { get; init; } = 1;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 每月赠券模板 ID。
|
||||||
|
/// </summary>
|
||||||
|
public IReadOnlyList<long> CouponTemplateIds { get; init; } = [];
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 免配送费权益 DTO。
|
||||||
|
/// </summary>
|
||||||
|
public sealed class MemberTierFreeDeliveryBenefitDto
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// 是否启用。
|
||||||
|
/// </summary>
|
||||||
|
public bool Enabled { get; init; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 每月免配送费次数。
|
||||||
|
/// </summary>
|
||||||
|
public int MonthlyFreeTimes { get; init; }
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 会员等级详情 DTO。
|
||||||
|
/// </summary>
|
||||||
|
public sealed class MemberTierDetailDto
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// 等级标识。
|
||||||
|
/// </summary>
|
||||||
|
public long? TierId { get; init; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 排序序号。
|
||||||
|
/// </summary>
|
||||||
|
public int SortOrder { get; init; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 等级名称。
|
||||||
|
/// </summary>
|
||||||
|
public string Name { get; init; } = string.Empty;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 图标键。
|
||||||
|
/// </summary>
|
||||||
|
public string IconKey { get; init; } = string.Empty;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 主题色。
|
||||||
|
/// </summary>
|
||||||
|
public string ColorHex { get; init; } = string.Empty;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 是否默认等级。
|
||||||
|
/// </summary>
|
||||||
|
public bool IsDefault { get; init; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 升降级规则。
|
||||||
|
/// </summary>
|
||||||
|
public MemberTierRuleDto Rule { get; init; } = new();
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 等级权益。
|
||||||
|
/// </summary>
|
||||||
|
public MemberTierBenefitsDto Benefits { get; init; } = new();
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 是否可删除。
|
||||||
|
/// </summary>
|
||||||
|
public bool CanDelete { get; init; }
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 会员日配置 DTO。
|
||||||
|
/// </summary>
|
||||||
|
public sealed class MemberDaySettingDto
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// 是否启用会员日。
|
||||||
|
/// </summary>
|
||||||
|
public bool IsEnabled { get; init; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 周几(1-7,对应周一到周日)。
|
||||||
|
/// </summary>
|
||||||
|
public int Weekday { get; init; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 会员日额外折扣(如 9 表示 9 折)。
|
||||||
|
/// </summary>
|
||||||
|
public decimal ExtraDiscountRate { get; init; }
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 优惠券选择项 DTO。
|
||||||
|
/// </summary>
|
||||||
|
public sealed class MemberCouponPickerItemDto
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// 券模板标识。
|
||||||
|
/// </summary>
|
||||||
|
public long CouponTemplateId { get; init; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 券名称。
|
||||||
|
/// </summary>
|
||||||
|
public string Name { get; init; } = string.Empty;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 券类型。
|
||||||
|
/// </summary>
|
||||||
|
public string CouponType { get; init; } = string.Empty;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 面值或折扣值。
|
||||||
|
/// </summary>
|
||||||
|
public decimal Value { get; init; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 最低消费门槛。
|
||||||
|
/// </summary>
|
||||||
|
public decimal? MinimumSpend { get; init; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 展示文案。
|
||||||
|
/// </summary>
|
||||||
|
public string DisplayText { get; init; } = string.Empty;
|
||||||
|
}
|
||||||
@@ -0,0 +1,60 @@
|
|||||||
|
using MediatR;
|
||||||
|
using TakeoutSaaS.Application.App.Members.Commands;
|
||||||
|
using TakeoutSaaS.Domain.Membership.Repositories;
|
||||||
|
using TakeoutSaaS.Domain.Orders.Repositories;
|
||||||
|
using TakeoutSaaS.Shared.Abstractions.Constants;
|
||||||
|
using TakeoutSaaS.Shared.Abstractions.Exceptions;
|
||||||
|
using TakeoutSaaS.Shared.Abstractions.Tenancy;
|
||||||
|
|
||||||
|
namespace TakeoutSaaS.Application.App.Members.Handlers;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 删除会员等级命令处理器。
|
||||||
|
/// </summary>
|
||||||
|
public sealed class DeleteMemberTierCommandHandler(
|
||||||
|
IMemberRepository memberRepository,
|
||||||
|
IOrderRepository orderRepository,
|
||||||
|
ITenantProvider tenantProvider)
|
||||||
|
: IRequestHandler<DeleteMemberTierCommand>
|
||||||
|
{
|
||||||
|
/// <inheritdoc />
|
||||||
|
public async Task Handle(DeleteMemberTierCommand request, CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
await MemberCenterSupport.EnsureMemberCenterInitializedAsync(
|
||||||
|
memberRepository,
|
||||||
|
orderRepository,
|
||||||
|
tenantProvider,
|
||||||
|
cancellationToken);
|
||||||
|
|
||||||
|
var tenantId = tenantProvider.GetCurrentTenantId();
|
||||||
|
var tiers = await memberRepository.GetTiersAsync(tenantId, cancellationToken);
|
||||||
|
var tier = tiers.FirstOrDefault(item => item.Id == request.TierId)
|
||||||
|
?? throw new BusinessException(ErrorCodes.NotFound, "会员等级不存在");
|
||||||
|
|
||||||
|
if (tier.IsDefault)
|
||||||
|
{
|
||||||
|
throw new BusinessException(ErrorCodes.BadRequest, "默认等级不允许删除");
|
||||||
|
}
|
||||||
|
|
||||||
|
var profiles = await memberRepository.GetProfilesAsync(tenantId, cancellationToken);
|
||||||
|
if (profiles.Any(item => item.MemberTierId == request.TierId))
|
||||||
|
{
|
||||||
|
throw new BusinessException(ErrorCodes.BadRequest, "当前等级下存在会员,无法删除");
|
||||||
|
}
|
||||||
|
|
||||||
|
await memberRepository.DeleteTierAsync(tier, cancellationToken);
|
||||||
|
await memberRepository.SaveChangesAsync(cancellationToken);
|
||||||
|
|
||||||
|
var latestTiers = await memberRepository.GetTiersAsync(tenantId, cancellationToken);
|
||||||
|
if (!latestTiers.Any(item => item.IsDefault) && latestTiers.Count > 0)
|
||||||
|
{
|
||||||
|
var fallback = latestTiers
|
||||||
|
.OrderBy(item => item.SortOrder)
|
||||||
|
.ThenBy(item => item.Id)
|
||||||
|
.First();
|
||||||
|
fallback.IsDefault = true;
|
||||||
|
await memberRepository.UpdateTierAsync(fallback, cancellationToken);
|
||||||
|
await memberRepository.SaveChangesAsync(cancellationToken);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,87 @@
|
|||||||
|
using System.Text;
|
||||||
|
using MediatR;
|
||||||
|
using TakeoutSaaS.Application.App.Members.Dto;
|
||||||
|
using TakeoutSaaS.Application.App.Members.Queries;
|
||||||
|
using TakeoutSaaS.Domain.Membership.Repositories;
|
||||||
|
using TakeoutSaaS.Domain.Orders.Repositories;
|
||||||
|
using TakeoutSaaS.Shared.Abstractions.Tenancy;
|
||||||
|
|
||||||
|
namespace TakeoutSaaS.Application.App.Members.Handlers;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 会员列表 CSV 导出查询处理器。
|
||||||
|
/// </summary>
|
||||||
|
public sealed class ExportMemberCsvQueryHandler(
|
||||||
|
IMemberRepository memberRepository,
|
||||||
|
IOrderRepository orderRepository,
|
||||||
|
ITenantProvider tenantProvider)
|
||||||
|
: IRequestHandler<ExportMemberCsvQuery, MemberExportDto>
|
||||||
|
{
|
||||||
|
/// <inheritdoc />
|
||||||
|
public async Task<MemberExportDto> Handle(
|
||||||
|
ExportMemberCsvQuery request,
|
||||||
|
CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
if (request.VisibleStoreIds.Count == 0)
|
||||||
|
{
|
||||||
|
return new MemberExportDto
|
||||||
|
{
|
||||||
|
FileName = BuildFileName(),
|
||||||
|
FileContentBase64 = string.Empty,
|
||||||
|
TotalCount = 0
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
var context = await MemberCenterSupport.LoadMemberContextAsync(
|
||||||
|
memberRepository,
|
||||||
|
orderRepository,
|
||||||
|
tenantProvider,
|
||||||
|
request.VisibleStoreIds,
|
||||||
|
cancellationToken);
|
||||||
|
|
||||||
|
var filtered = MemberCenterSupport.ApplyFilters(
|
||||||
|
context.Aggregates,
|
||||||
|
request.Keyword,
|
||||||
|
request.TierId)
|
||||||
|
.OrderByDescending(item => item.Metrics.LastOrderAt)
|
||||||
|
.ThenByDescending(item => item.MemberId)
|
||||||
|
.ToList();
|
||||||
|
|
||||||
|
var rows = filtered
|
||||||
|
.Select(item => new MemberListItemDto
|
||||||
|
{
|
||||||
|
MemberId = item.MemberId,
|
||||||
|
Name = item.Name,
|
||||||
|
AvatarText = item.AvatarText,
|
||||||
|
AvatarColor = item.AvatarColor,
|
||||||
|
MobileMasked = item.MobileMasked,
|
||||||
|
TierId = item.Tier?.Id,
|
||||||
|
TierName = item.Tier?.Name ?? string.Empty,
|
||||||
|
TierColorHex = item.Tier?.ColorHex ?? "#999999",
|
||||||
|
TotalAmount = decimal.Round(item.Metrics.TotalAmount, 2, MidpointRounding.AwayFromZero),
|
||||||
|
OrderCount = item.Metrics.TotalOrderCount,
|
||||||
|
LastOrderAt = item.Metrics.LastOrderAt,
|
||||||
|
StoredBalance = item.Profile.StoredBalance,
|
||||||
|
PointsBalance = Math.Max(0, item.Profile.PointsBalance),
|
||||||
|
IsDormant = item.IsDormant
|
||||||
|
})
|
||||||
|
.ToList();
|
||||||
|
|
||||||
|
var csv = MemberCenterSupport.BuildCsv(rows);
|
||||||
|
var payload = Encoding.UTF8.GetPreamble()
|
||||||
|
.Concat(Encoding.UTF8.GetBytes(csv))
|
||||||
|
.ToArray();
|
||||||
|
|
||||||
|
return new MemberExportDto
|
||||||
|
{
|
||||||
|
FileName = BuildFileName(),
|
||||||
|
FileContentBase64 = Convert.ToBase64String(payload),
|
||||||
|
TotalCount = rows.Count
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
private static string BuildFileName()
|
||||||
|
{
|
||||||
|
return $"member-list-{DateTime.UtcNow:yyyyMMddHHmmss}.csv";
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,42 @@
|
|||||||
|
using MediatR;
|
||||||
|
using TakeoutSaaS.Application.App.Members.Dto;
|
||||||
|
using TakeoutSaaS.Application.App.Members.Queries;
|
||||||
|
using TakeoutSaaS.Domain.Membership.Repositories;
|
||||||
|
using TakeoutSaaS.Domain.Orders.Repositories;
|
||||||
|
using TakeoutSaaS.Shared.Abstractions.Tenancy;
|
||||||
|
|
||||||
|
namespace TakeoutSaaS.Application.App.Members.Handlers;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 会员日配置查询处理器。
|
||||||
|
/// </summary>
|
||||||
|
public sealed class GetMemberDaySettingQueryHandler(
|
||||||
|
IMemberRepository memberRepository,
|
||||||
|
IOrderRepository orderRepository,
|
||||||
|
ITenantProvider tenantProvider)
|
||||||
|
: IRequestHandler<GetMemberDaySettingQuery, MemberDaySettingDto>
|
||||||
|
{
|
||||||
|
/// <inheritdoc />
|
||||||
|
public async Task<MemberDaySettingDto> Handle(GetMemberDaySettingQuery request, CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
await MemberCenterSupport.EnsureMemberCenterInitializedAsync(
|
||||||
|
memberRepository,
|
||||||
|
orderRepository,
|
||||||
|
tenantProvider,
|
||||||
|
cancellationToken);
|
||||||
|
|
||||||
|
var tenantId = tenantProvider.GetCurrentTenantId();
|
||||||
|
var setting = await memberRepository.GetMemberDaySettingAsync(tenantId, cancellationToken);
|
||||||
|
if (setting is null)
|
||||||
|
{
|
||||||
|
return new MemberDaySettingDto
|
||||||
|
{
|
||||||
|
IsEnabled = true,
|
||||||
|
Weekday = 2,
|
||||||
|
ExtraDiscountRate = 9m
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
return MemberCenterSupport.ToMemberDaySettingDto(setting);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,84 @@
|
|||||||
|
using MediatR;
|
||||||
|
using TakeoutSaaS.Application.App.Members.Dto;
|
||||||
|
using TakeoutSaaS.Application.App.Members.Queries;
|
||||||
|
using TakeoutSaaS.Domain.Membership.Repositories;
|
||||||
|
using TakeoutSaaS.Domain.Orders.Repositories;
|
||||||
|
using TakeoutSaaS.Shared.Abstractions.Tenancy;
|
||||||
|
|
||||||
|
namespace TakeoutSaaS.Application.App.Members.Handlers;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 会员详情查询处理器。
|
||||||
|
/// </summary>
|
||||||
|
public sealed class GetMemberDetailQueryHandler(
|
||||||
|
IMemberRepository memberRepository,
|
||||||
|
IOrderRepository orderRepository,
|
||||||
|
ITenantProvider tenantProvider)
|
||||||
|
: IRequestHandler<GetMemberDetailQuery, MemberDetailDto?>
|
||||||
|
{
|
||||||
|
/// <inheritdoc />
|
||||||
|
public async Task<MemberDetailDto?> Handle(
|
||||||
|
GetMemberDetailQuery request,
|
||||||
|
CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
if (request.VisibleStoreIds.Count == 0)
|
||||||
|
{
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
var context = await MemberCenterSupport.LoadMemberContextAsync(
|
||||||
|
memberRepository,
|
||||||
|
orderRepository,
|
||||||
|
tenantProvider,
|
||||||
|
request.VisibleStoreIds,
|
||||||
|
cancellationToken);
|
||||||
|
|
||||||
|
var aggregate = context.Aggregates.FirstOrDefault(item => item.MemberId == request.MemberId);
|
||||||
|
if (aggregate is null)
|
||||||
|
{
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
var tenantId = tenantProvider.GetCurrentTenantId();
|
||||||
|
var tags = await memberRepository.GetProfileTagsAsync(tenantId, aggregate.MemberId, cancellationToken);
|
||||||
|
|
||||||
|
var recentOrders = aggregate.Metrics.Orders
|
||||||
|
.OrderByDescending(item => item.OrderedAt)
|
||||||
|
.ThenByDescending(item => item.OrderId)
|
||||||
|
.Take(3)
|
||||||
|
.Select(item => new MemberRecentOrderDto
|
||||||
|
{
|
||||||
|
OrderedAt = item.OrderedAt,
|
||||||
|
OrderNo = item.OrderNo,
|
||||||
|
Amount = decimal.Round(item.Amount, 2, MidpointRounding.AwayFromZero),
|
||||||
|
StatusText = MemberCenterSupport.ResolveOrderStatusText(item.Status)
|
||||||
|
})
|
||||||
|
.ToList();
|
||||||
|
|
||||||
|
var averageAmount = aggregate.Metrics.TotalOrderCount <= 0
|
||||||
|
? 0
|
||||||
|
: decimal.Round(aggregate.Metrics.TotalAmount / aggregate.Metrics.TotalOrderCount, 2, MidpointRounding.AwayFromZero);
|
||||||
|
|
||||||
|
return new MemberDetailDto
|
||||||
|
{
|
||||||
|
MemberId = aggregate.MemberId,
|
||||||
|
Name = aggregate.Name,
|
||||||
|
AvatarText = aggregate.AvatarText,
|
||||||
|
AvatarColor = aggregate.AvatarColor,
|
||||||
|
MobileMasked = aggregate.MobileMasked,
|
||||||
|
JoinedAt = aggregate.JoinedAt,
|
||||||
|
TierId = aggregate.Tier?.Id,
|
||||||
|
TierName = aggregate.Tier?.Name ?? string.Empty,
|
||||||
|
TierColorHex = aggregate.Tier?.ColorHex ?? "#999999",
|
||||||
|
TotalAmount = decimal.Round(aggregate.Metrics.TotalAmount, 2, MidpointRounding.AwayFromZero),
|
||||||
|
OrderCount = aggregate.Metrics.TotalOrderCount,
|
||||||
|
AverageAmount = averageAmount,
|
||||||
|
StoredBalance = aggregate.Profile.StoredBalance,
|
||||||
|
StoredRechargeBalance = aggregate.Profile.StoredRechargeBalance,
|
||||||
|
StoredGiftBalance = aggregate.Profile.StoredGiftBalance,
|
||||||
|
PointsBalance = Math.Max(0, aggregate.Profile.PointsBalance),
|
||||||
|
Tags = tags.Select(item => item.TagName).ToList(),
|
||||||
|
RecentOrders = recentOrders
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,52 @@
|
|||||||
|
using MediatR;
|
||||||
|
using TakeoutSaaS.Application.App.Members.Dto;
|
||||||
|
using TakeoutSaaS.Application.App.Members.Queries;
|
||||||
|
using TakeoutSaaS.Domain.Membership.Repositories;
|
||||||
|
using TakeoutSaaS.Domain.Orders.Repositories;
|
||||||
|
using TakeoutSaaS.Shared.Abstractions.Tenancy;
|
||||||
|
|
||||||
|
namespace TakeoutSaaS.Application.App.Members.Handlers;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 会员列表统计查询处理器。
|
||||||
|
/// </summary>
|
||||||
|
public sealed class GetMemberListStatsQueryHandler(
|
||||||
|
IMemberRepository memberRepository,
|
||||||
|
IOrderRepository orderRepository,
|
||||||
|
ITenantProvider tenantProvider)
|
||||||
|
: IRequestHandler<GetMemberListStatsQuery, MemberListStatsDto>
|
||||||
|
{
|
||||||
|
/// <inheritdoc />
|
||||||
|
public async Task<MemberListStatsDto> Handle(
|
||||||
|
GetMemberListStatsQuery request,
|
||||||
|
CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
if (request.VisibleStoreIds.Count == 0)
|
||||||
|
{
|
||||||
|
return new MemberListStatsDto();
|
||||||
|
}
|
||||||
|
|
||||||
|
var context = await MemberCenterSupport.LoadMemberContextAsync(
|
||||||
|
memberRepository,
|
||||||
|
orderRepository,
|
||||||
|
tenantProvider,
|
||||||
|
request.VisibleStoreIds,
|
||||||
|
cancellationToken);
|
||||||
|
|
||||||
|
var filtered = MemberCenterSupport.ApplyFilters(
|
||||||
|
context.Aggregates,
|
||||||
|
request.Keyword,
|
||||||
|
request.TierId);
|
||||||
|
|
||||||
|
var nowUtc = DateTime.UtcNow;
|
||||||
|
var monthStart = new DateTime(nowUtc.Year, nowUtc.Month, 1, 0, 0, 0, DateTimeKind.Utc);
|
||||||
|
|
||||||
|
return new MemberListStatsDto
|
||||||
|
{
|
||||||
|
TotalMembers = filtered.Count,
|
||||||
|
MonthlyNewMembers = filtered.Count(item => item.JoinedAt >= monthStart),
|
||||||
|
ActiveMembers = filtered.Count(item => item.Metrics.LastOrderAt >= nowUtc.AddDays(-30)),
|
||||||
|
DormantMembers = filtered.Count(item => item.Metrics.LastOrderAt < nowUtc.AddDays(-60))
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,70 @@
|
|||||||
|
using MediatR;
|
||||||
|
using TakeoutSaaS.Application.App.Members.Dto;
|
||||||
|
using TakeoutSaaS.Application.App.Members.Queries;
|
||||||
|
using TakeoutSaaS.Domain.Membership.Repositories;
|
||||||
|
using TakeoutSaaS.Domain.Orders.Repositories;
|
||||||
|
using TakeoutSaaS.Shared.Abstractions.Constants;
|
||||||
|
using TakeoutSaaS.Shared.Abstractions.Exceptions;
|
||||||
|
using TakeoutSaaS.Shared.Abstractions.Tenancy;
|
||||||
|
|
||||||
|
namespace TakeoutSaaS.Application.App.Members.Handlers;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 会员等级详情查询处理器。
|
||||||
|
/// </summary>
|
||||||
|
public sealed class GetMemberTierDetailQueryHandler(
|
||||||
|
IMemberRepository memberRepository,
|
||||||
|
IOrderRepository orderRepository,
|
||||||
|
ITenantProvider tenantProvider)
|
||||||
|
: IRequestHandler<GetMemberTierDetailQuery, MemberTierDetailDto>
|
||||||
|
{
|
||||||
|
/// <inheritdoc />
|
||||||
|
public async Task<MemberTierDetailDto> Handle(
|
||||||
|
GetMemberTierDetailQuery request,
|
||||||
|
CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
await MemberCenterSupport.EnsureMemberCenterInitializedAsync(
|
||||||
|
memberRepository,
|
||||||
|
orderRepository,
|
||||||
|
tenantProvider,
|
||||||
|
cancellationToken);
|
||||||
|
|
||||||
|
var tenantId = tenantProvider.GetCurrentTenantId();
|
||||||
|
var tiers = await memberRepository.GetTiersAsync(tenantId, cancellationToken);
|
||||||
|
|
||||||
|
if (!request.TierId.HasValue)
|
||||||
|
{
|
||||||
|
var nextSort = tiers.Count == 0 ? 1 : tiers.Max(item => item.SortOrder) + 1;
|
||||||
|
return new MemberTierDetailDto
|
||||||
|
{
|
||||||
|
TierId = null,
|
||||||
|
SortOrder = nextSort,
|
||||||
|
Name = string.Empty,
|
||||||
|
IconKey = "user",
|
||||||
|
ColorHex = "#999999",
|
||||||
|
IsDefault = false,
|
||||||
|
Rule = new MemberTierRuleDto
|
||||||
|
{
|
||||||
|
UpgradeRuleType = "amount",
|
||||||
|
UpgradeAmountThreshold = 500m,
|
||||||
|
UpgradeOrderCountThreshold = null,
|
||||||
|
DowngradeWindowDays = 90
|
||||||
|
},
|
||||||
|
Benefits = new MemberTierBenefitsDto(),
|
||||||
|
CanDelete = false
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
var tier = tiers.FirstOrDefault(item => item.Id == request.TierId.Value);
|
||||||
|
if (tier is null)
|
||||||
|
{
|
||||||
|
throw new BusinessException(ErrorCodes.NotFound, "会员等级不存在");
|
||||||
|
}
|
||||||
|
|
||||||
|
var profiles = await memberRepository.GetProfilesAsync(tenantId, cancellationToken);
|
||||||
|
var assignedCount = profiles.Count(item => item.MemberTierId == tier.Id);
|
||||||
|
var canDelete = !tier.IsDefault && assignedCount <= 0 && tiers.Count > 1;
|
||||||
|
|
||||||
|
return MemberCenterSupport.ToTierDetailDto(tier, canDelete);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,71 @@
|
|||||||
|
using MediatR;
|
||||||
|
using TakeoutSaaS.Application.App.Members.Dto;
|
||||||
|
using TakeoutSaaS.Application.App.Members.Queries;
|
||||||
|
using TakeoutSaaS.Domain.Membership.Repositories;
|
||||||
|
using TakeoutSaaS.Domain.Orders.Repositories;
|
||||||
|
using TakeoutSaaS.Shared.Abstractions.Tenancy;
|
||||||
|
|
||||||
|
namespace TakeoutSaaS.Application.App.Members.Handlers;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 会员等级列表查询处理器。
|
||||||
|
/// </summary>
|
||||||
|
public sealed class GetMemberTierListQueryHandler(
|
||||||
|
IMemberRepository memberRepository,
|
||||||
|
IOrderRepository orderRepository,
|
||||||
|
ITenantProvider tenantProvider)
|
||||||
|
: IRequestHandler<GetMemberTierListQuery, IReadOnlyList<MemberTierListItemDto>>
|
||||||
|
{
|
||||||
|
/// <inheritdoc />
|
||||||
|
public async Task<IReadOnlyList<MemberTierListItemDto>> Handle(
|
||||||
|
GetMemberTierListQuery request,
|
||||||
|
CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
await MemberCenterSupport.EnsureMemberCenterInitializedAsync(
|
||||||
|
memberRepository,
|
||||||
|
orderRepository,
|
||||||
|
tenantProvider,
|
||||||
|
cancellationToken);
|
||||||
|
|
||||||
|
var tenantId = tenantProvider.GetCurrentTenantId();
|
||||||
|
var tiers = await memberRepository.GetTiersAsync(tenantId, cancellationToken);
|
||||||
|
var profiles = await memberRepository.GetProfilesAsync(tenantId, cancellationToken);
|
||||||
|
|
||||||
|
if (tiers.Count == 0)
|
||||||
|
{
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
var memberCountMap = profiles
|
||||||
|
.Where(item => item.MemberTierId.HasValue)
|
||||||
|
.GroupBy(item => item.MemberTierId!.Value)
|
||||||
|
.ToDictionary(group => group.Key, group => group.Count());
|
||||||
|
|
||||||
|
var result = tiers
|
||||||
|
.OrderBy(item => item.SortOrder)
|
||||||
|
.ThenBy(item => item.Id)
|
||||||
|
.Select(tier =>
|
||||||
|
{
|
||||||
|
memberCountMap.TryGetValue(tier.Id, out var memberCount);
|
||||||
|
var benefits = MemberCenterSupport.DeserializeBenefits(tier.BenefitsJson);
|
||||||
|
var canDelete = !tier.IsDefault && memberCount <= 0 && tiers.Count > 1;
|
||||||
|
|
||||||
|
return new MemberTierListItemDto
|
||||||
|
{
|
||||||
|
TierId = tier.Id,
|
||||||
|
SortOrder = tier.SortOrder,
|
||||||
|
Name = tier.Name,
|
||||||
|
IconKey = tier.IconKey,
|
||||||
|
ColorHex = tier.ColorHex,
|
||||||
|
ConditionText = MemberCenterSupport.BuildConditionText(tier),
|
||||||
|
Perks = MemberCenterSupport.BuildPerks(tier, benefits),
|
||||||
|
MemberCount = memberCount,
|
||||||
|
IsDefault = tier.IsDefault,
|
||||||
|
CanDelete = canDelete
|
||||||
|
};
|
||||||
|
})
|
||||||
|
.ToList();
|
||||||
|
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
}
|
||||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,67 @@
|
|||||||
|
using MediatR;
|
||||||
|
using TakeoutSaaS.Application.App.Members.Commands;
|
||||||
|
using TakeoutSaaS.Application.App.Members.Dto;
|
||||||
|
using TakeoutSaaS.Domain.Membership.Entities;
|
||||||
|
using TakeoutSaaS.Domain.Membership.Repositories;
|
||||||
|
using TakeoutSaaS.Domain.Orders.Repositories;
|
||||||
|
using TakeoutSaaS.Shared.Abstractions.Constants;
|
||||||
|
using TakeoutSaaS.Shared.Abstractions.Exceptions;
|
||||||
|
using TakeoutSaaS.Shared.Abstractions.Tenancy;
|
||||||
|
|
||||||
|
namespace TakeoutSaaS.Application.App.Members.Handlers;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 保存会员日配置命令处理器。
|
||||||
|
/// </summary>
|
||||||
|
public sealed class SaveMemberDaySettingCommandHandler(
|
||||||
|
IMemberRepository memberRepository,
|
||||||
|
IOrderRepository orderRepository,
|
||||||
|
ITenantProvider tenantProvider)
|
||||||
|
: IRequestHandler<SaveMemberDaySettingCommand, MemberDaySettingDto>
|
||||||
|
{
|
||||||
|
/// <inheritdoc />
|
||||||
|
public async Task<MemberDaySettingDto> Handle(
|
||||||
|
SaveMemberDaySettingCommand request,
|
||||||
|
CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
await MemberCenterSupport.EnsureMemberCenterInitializedAsync(
|
||||||
|
memberRepository,
|
||||||
|
orderRepository,
|
||||||
|
tenantProvider,
|
||||||
|
cancellationToken);
|
||||||
|
|
||||||
|
if (request.Weekday is < 1 or > 7)
|
||||||
|
{
|
||||||
|
throw new BusinessException(ErrorCodes.BadRequest, "weekday 必须在 1-7 之间");
|
||||||
|
}
|
||||||
|
|
||||||
|
var normalizedDiscount = decimal.Round(request.ExtraDiscountRate, 2, MidpointRounding.AwayFromZero);
|
||||||
|
if (normalizedDiscount <= 0 || normalizedDiscount > 10)
|
||||||
|
{
|
||||||
|
throw new BusinessException(ErrorCodes.BadRequest, "extraDiscountRate 必须大于 0 且不超过 10");
|
||||||
|
}
|
||||||
|
|
||||||
|
var tenantId = tenantProvider.GetCurrentTenantId();
|
||||||
|
var setting = await memberRepository.GetMemberDaySettingAsync(tenantId, cancellationToken);
|
||||||
|
if (setting is null)
|
||||||
|
{
|
||||||
|
setting = new MemberDaySetting
|
||||||
|
{
|
||||||
|
IsEnabled = request.IsEnabled,
|
||||||
|
Weekday = request.Weekday,
|
||||||
|
ExtraDiscountRate = normalizedDiscount
|
||||||
|
};
|
||||||
|
await memberRepository.AddMemberDaySettingAsync(setting, cancellationToken);
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
setting.IsEnabled = request.IsEnabled;
|
||||||
|
setting.Weekday = request.Weekday;
|
||||||
|
setting.ExtraDiscountRate = normalizedDiscount;
|
||||||
|
await memberRepository.UpdateMemberDaySettingAsync(setting, cancellationToken);
|
||||||
|
}
|
||||||
|
|
||||||
|
await memberRepository.SaveChangesAsync(cancellationToken);
|
||||||
|
return MemberCenterSupport.ToMemberDaySettingDto(setting);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,47 @@
|
|||||||
|
using MediatR;
|
||||||
|
using TakeoutSaaS.Application.App.Members.Commands;
|
||||||
|
using TakeoutSaaS.Domain.Membership.Repositories;
|
||||||
|
using TakeoutSaaS.Shared.Abstractions.Constants;
|
||||||
|
using TakeoutSaaS.Shared.Abstractions.Exceptions;
|
||||||
|
using TakeoutSaaS.Shared.Abstractions.Tenancy;
|
||||||
|
|
||||||
|
namespace TakeoutSaaS.Application.App.Members.Handlers;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 保存会员标签命令处理器。
|
||||||
|
/// </summary>
|
||||||
|
public sealed class SaveMemberTagsCommandHandler(
|
||||||
|
IMemberRepository memberRepository,
|
||||||
|
ITenantProvider tenantProvider)
|
||||||
|
: IRequestHandler<SaveMemberTagsCommand>
|
||||||
|
{
|
||||||
|
/// <inheritdoc />
|
||||||
|
public async Task Handle(SaveMemberTagsCommand request, CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
var tenantId = tenantProvider.GetCurrentTenantId();
|
||||||
|
var profile = await memberRepository.FindProfileByIdAsync(tenantId, request.MemberId, cancellationToken);
|
||||||
|
if (profile is null)
|
||||||
|
{
|
||||||
|
throw new BusinessException(ErrorCodes.NotFound, "会员不存在");
|
||||||
|
}
|
||||||
|
|
||||||
|
var tags = (request.Tags ?? [])
|
||||||
|
.Select(item => (item ?? string.Empty).Trim())
|
||||||
|
.Where(item => !string.IsNullOrWhiteSpace(item))
|
||||||
|
.Distinct(StringComparer.OrdinalIgnoreCase)
|
||||||
|
.ToList();
|
||||||
|
|
||||||
|
if (tags.Count > 20)
|
||||||
|
{
|
||||||
|
throw new BusinessException(ErrorCodes.BadRequest, "标签数量不能超过 20 个");
|
||||||
|
}
|
||||||
|
|
||||||
|
if (tags.Any(item => item.Length > 32))
|
||||||
|
{
|
||||||
|
throw new BusinessException(ErrorCodes.BadRequest, "标签长度不能超过 32 个字符");
|
||||||
|
}
|
||||||
|
|
||||||
|
await memberRepository.ReplaceProfileTagsAsync(tenantId, request.MemberId, tags, cancellationToken);
|
||||||
|
await memberRepository.SaveChangesAsync(cancellationToken);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,202 @@
|
|||||||
|
using MediatR;
|
||||||
|
using TakeoutSaaS.Application.App.Members.Commands;
|
||||||
|
using TakeoutSaaS.Application.App.Members.Dto;
|
||||||
|
using TakeoutSaaS.Domain.Membership.Entities;
|
||||||
|
using TakeoutSaaS.Domain.Membership.Repositories;
|
||||||
|
using TakeoutSaaS.Domain.Orders.Repositories;
|
||||||
|
using TakeoutSaaS.Shared.Abstractions.Constants;
|
||||||
|
using TakeoutSaaS.Shared.Abstractions.Exceptions;
|
||||||
|
using TakeoutSaaS.Shared.Abstractions.Tenancy;
|
||||||
|
|
||||||
|
namespace TakeoutSaaS.Application.App.Members.Handlers;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 保存会员等级命令处理器。
|
||||||
|
/// </summary>
|
||||||
|
public sealed class SaveMemberTierCommandHandler(
|
||||||
|
IMemberRepository memberRepository,
|
||||||
|
IOrderRepository orderRepository,
|
||||||
|
ITenantProvider tenantProvider)
|
||||||
|
: IRequestHandler<SaveMemberTierCommand, MemberTierDetailDto>
|
||||||
|
{
|
||||||
|
/// <inheritdoc />
|
||||||
|
public async Task<MemberTierDetailDto> Handle(
|
||||||
|
SaveMemberTierCommand request,
|
||||||
|
CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
await MemberCenterSupport.EnsureMemberCenterInitializedAsync(
|
||||||
|
memberRepository,
|
||||||
|
orderRepository,
|
||||||
|
tenantProvider,
|
||||||
|
cancellationToken);
|
||||||
|
|
||||||
|
var tenantId = tenantProvider.GetCurrentTenantId();
|
||||||
|
var tiers = await memberRepository.GetTiersAsync(tenantId, cancellationToken);
|
||||||
|
var normalizedName = (request.Name ?? string.Empty).Trim();
|
||||||
|
if (string.IsNullOrWhiteSpace(normalizedName))
|
||||||
|
{
|
||||||
|
throw new BusinessException(ErrorCodes.BadRequest, "等级名称不能为空");
|
||||||
|
}
|
||||||
|
|
||||||
|
if (normalizedName.Length > 64)
|
||||||
|
{
|
||||||
|
throw new BusinessException(ErrorCodes.BadRequest, "等级名称不能超过 64 个字符");
|
||||||
|
}
|
||||||
|
|
||||||
|
var normalizedIconKey = (request.IconKey ?? "user").Trim().ToLowerInvariant();
|
||||||
|
if (string.IsNullOrWhiteSpace(normalizedIconKey) || normalizedIconKey.Length > 32)
|
||||||
|
{
|
||||||
|
throw new BusinessException(ErrorCodes.BadRequest, "等级图标不合法");
|
||||||
|
}
|
||||||
|
|
||||||
|
var normalizedColorHex = NormalizeColorHex(request.ColorHex);
|
||||||
|
var normalizedRuleType = MemberCenterSupport.NormalizeRuleType(request.Rule.UpgradeRuleType);
|
||||||
|
var amountThreshold = request.Rule.UpgradeAmountThreshold;
|
||||||
|
var countThreshold = request.Rule.UpgradeOrderCountThreshold;
|
||||||
|
|
||||||
|
if (normalizedRuleType is "amount" or "both")
|
||||||
|
{
|
||||||
|
if (!amountThreshold.HasValue || amountThreshold.Value <= 0)
|
||||||
|
{
|
||||||
|
throw new BusinessException(ErrorCodes.BadRequest, "升级金额门槛必须大于 0");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (normalizedRuleType is "count" or "both")
|
||||||
|
{
|
||||||
|
if (!countThreshold.HasValue || countThreshold.Value <= 0)
|
||||||
|
{
|
||||||
|
throw new BusinessException(ErrorCodes.BadRequest, "升级消费次数门槛必须大于 0");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
var normalizedDowngradeWindowDays = Math.Clamp(request.Rule.DowngradeWindowDays <= 0 ? 90 : request.Rule.DowngradeWindowDays, 30, 365);
|
||||||
|
var normalizedSortOrder = request.SortOrder > 0
|
||||||
|
? request.SortOrder
|
||||||
|
: (request.TierId.HasValue
|
||||||
|
? tiers.FirstOrDefault(item => item.Id == request.TierId.Value)?.SortOrder ?? 1
|
||||||
|
: (tiers.Count == 0 ? 1 : tiers.Max(item => item.SortOrder) + 1));
|
||||||
|
|
||||||
|
var normalizedBenefits = MemberCenterSupport.NormalizeBenefits(request.Benefits);
|
||||||
|
var duplicate = tiers.Any(item =>
|
||||||
|
item.Id != (request.TierId ?? 0) &&
|
||||||
|
string.Equals(item.Name, normalizedName, StringComparison.OrdinalIgnoreCase));
|
||||||
|
if (duplicate)
|
||||||
|
{
|
||||||
|
throw new BusinessException(ErrorCodes.BadRequest, "等级名称已存在");
|
||||||
|
}
|
||||||
|
|
||||||
|
MemberTier targetTier;
|
||||||
|
if (request.TierId.HasValue)
|
||||||
|
{
|
||||||
|
targetTier = tiers.FirstOrDefault(item => item.Id == request.TierId.Value)
|
||||||
|
?? throw new BusinessException(ErrorCodes.NotFound, "会员等级不存在");
|
||||||
|
targetTier.Name = normalizedName;
|
||||||
|
targetTier.IconKey = normalizedIconKey;
|
||||||
|
targetTier.ColorHex = normalizedColorHex;
|
||||||
|
targetTier.UpgradeRuleType = normalizedRuleType;
|
||||||
|
targetTier.UpgradeAmountThreshold = normalizedRuleType is "amount" or "both"
|
||||||
|
? decimal.Round(amountThreshold ?? 0, 2, MidpointRounding.AwayFromZero)
|
||||||
|
: null;
|
||||||
|
targetTier.UpgradeOrderCountThreshold = normalizedRuleType is "count" or "both"
|
||||||
|
? Math.Max(0, countThreshold ?? 0)
|
||||||
|
: null;
|
||||||
|
targetTier.DowngradeWindowDays = normalizedDowngradeWindowDays;
|
||||||
|
targetTier.IsDefault = request.IsDefault;
|
||||||
|
targetTier.SortOrder = normalizedSortOrder;
|
||||||
|
targetTier.RequiredGrowth = ResolveRequiredGrowth(targetTier.UpgradeAmountThreshold, targetTier.UpgradeOrderCountThreshold);
|
||||||
|
targetTier.BenefitsJson = MemberCenterSupport.SerializeBenefits(normalizedBenefits);
|
||||||
|
await memberRepository.UpdateTierAsync(targetTier, cancellationToken);
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
targetTier = new MemberTier
|
||||||
|
{
|
||||||
|
Name = normalizedName,
|
||||||
|
IconKey = normalizedIconKey,
|
||||||
|
ColorHex = normalizedColorHex,
|
||||||
|
UpgradeRuleType = normalizedRuleType,
|
||||||
|
UpgradeAmountThreshold = normalizedRuleType is "amount" or "both"
|
||||||
|
? decimal.Round(amountThreshold ?? 0, 2, MidpointRounding.AwayFromZero)
|
||||||
|
: null,
|
||||||
|
UpgradeOrderCountThreshold = normalizedRuleType is "count" or "both"
|
||||||
|
? Math.Max(0, countThreshold ?? 0)
|
||||||
|
: null,
|
||||||
|
DowngradeWindowDays = normalizedDowngradeWindowDays,
|
||||||
|
IsDefault = request.IsDefault,
|
||||||
|
SortOrder = normalizedSortOrder,
|
||||||
|
RequiredGrowth = ResolveRequiredGrowth(amountThreshold, countThreshold),
|
||||||
|
BenefitsJson = MemberCenterSupport.SerializeBenefits(normalizedBenefits)
|
||||||
|
};
|
||||||
|
await memberRepository.AddTierAsync(targetTier, cancellationToken);
|
||||||
|
tiers = tiers.Append(targetTier).ToList();
|
||||||
|
}
|
||||||
|
|
||||||
|
if (request.IsDefault)
|
||||||
|
{
|
||||||
|
foreach (var tier in tiers.Where(item => item.Id != targetTier.Id && item.IsDefault))
|
||||||
|
{
|
||||||
|
tier.IsDefault = false;
|
||||||
|
await memberRepository.UpdateTierAsync(tier, cancellationToken);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
var hasDefault = tiers.Any(item => (item.Id == targetTier.Id ? targetTier.IsDefault : item.IsDefault));
|
||||||
|
if (!hasDefault)
|
||||||
|
{
|
||||||
|
var fallbackTier = tiers
|
||||||
|
.Where(item => item.Id != targetTier.Id)
|
||||||
|
.OrderBy(item => item.SortOrder)
|
||||||
|
.ThenBy(item => item.Id)
|
||||||
|
.FirstOrDefault();
|
||||||
|
if (fallbackTier is not null)
|
||||||
|
{
|
||||||
|
fallbackTier.IsDefault = true;
|
||||||
|
await memberRepository.UpdateTierAsync(fallbackTier, cancellationToken);
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
targetTier.IsDefault = true;
|
||||||
|
await memberRepository.UpdateTierAsync(targetTier, cancellationToken);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
await memberRepository.SaveChangesAsync(cancellationToken);
|
||||||
|
|
||||||
|
var profileCount = (await memberRepository.GetProfilesAsync(tenantId, cancellationToken)).Count(item => item.MemberTierId == targetTier.Id);
|
||||||
|
var latestTiers = await memberRepository.GetTiersAsync(tenantId, cancellationToken);
|
||||||
|
var canDelete = !targetTier.IsDefault && profileCount <= 0 && latestTiers.Count > 1;
|
||||||
|
var latestTier = latestTiers.First(item => item.Id == targetTier.Id);
|
||||||
|
return MemberCenterSupport.ToTierDetailDto(latestTier, canDelete);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static string NormalizeColorHex(string? value)
|
||||||
|
{
|
||||||
|
var candidate = (value ?? string.Empty).Trim();
|
||||||
|
if (string.IsNullOrWhiteSpace(candidate))
|
||||||
|
{
|
||||||
|
return "#999999";
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!candidate.StartsWith('#'))
|
||||||
|
{
|
||||||
|
candidate = $"#{candidate}";
|
||||||
|
}
|
||||||
|
|
||||||
|
if (candidate.Length is not (4 or 7 or 9))
|
||||||
|
{
|
||||||
|
throw new BusinessException(ErrorCodes.BadRequest, "等级颜色不合法");
|
||||||
|
}
|
||||||
|
|
||||||
|
return candidate.ToLowerInvariant();
|
||||||
|
}
|
||||||
|
|
||||||
|
private static int ResolveRequiredGrowth(decimal? amountThreshold, int? countThreshold)
|
||||||
|
{
|
||||||
|
var amountGrowth = amountThreshold.HasValue ? (int)Math.Clamp(decimal.Round(amountThreshold.Value, 0, MidpointRounding.AwayFromZero), 0m, int.MaxValue) : 0;
|
||||||
|
var countGrowth = Math.Max(0, countThreshold ?? 0) * 100;
|
||||||
|
return Math.Max(amountGrowth, countGrowth);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,55 @@
|
|||||||
|
using MediatR;
|
||||||
|
using TakeoutSaaS.Application.App.Members.Dto;
|
||||||
|
using TakeoutSaaS.Application.App.Members.Queries;
|
||||||
|
using TakeoutSaaS.Domain.Coupons.Repositories;
|
||||||
|
using TakeoutSaaS.Shared.Abstractions.Tenancy;
|
||||||
|
|
||||||
|
namespace TakeoutSaaS.Application.App.Members.Handlers;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 优惠券选择器查询处理器。
|
||||||
|
/// </summary>
|
||||||
|
public sealed class SearchMemberCouponPickerQueryHandler(
|
||||||
|
ICouponRepository couponRepository,
|
||||||
|
ITenantProvider tenantProvider)
|
||||||
|
: IRequestHandler<SearchMemberCouponPickerQuery, IReadOnlyList<MemberCouponPickerItemDto>>
|
||||||
|
{
|
||||||
|
/// <inheritdoc />
|
||||||
|
public async Task<IReadOnlyList<MemberCouponPickerItemDto>> Handle(
|
||||||
|
SearchMemberCouponPickerQuery request,
|
||||||
|
CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
if (request.VisibleStoreIds.Count == 0)
|
||||||
|
{
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
var tenantId = tenantProvider.GetCurrentTenantId();
|
||||||
|
var keyword = (request.Keyword ?? string.Empty).Trim();
|
||||||
|
var nowUtc = DateTime.UtcNow;
|
||||||
|
|
||||||
|
var templates = await couponRepository.GetTemplatesAsync(tenantId, cancellationToken);
|
||||||
|
|
||||||
|
var filtered = templates
|
||||||
|
.Where(template => MemberCenterSupport.IsCouponVisibleToStores(template, request.VisibleStoreIds))
|
||||||
|
.Where(template => MemberCenterSupport.IsCouponActive(template, nowUtc))
|
||||||
|
.Where(template =>
|
||||||
|
string.IsNullOrWhiteSpace(keyword) ||
|
||||||
|
template.Name.Contains(keyword, StringComparison.OrdinalIgnoreCase))
|
||||||
|
.OrderByDescending(template => template.UpdatedAt ?? template.CreatedAt)
|
||||||
|
.ThenByDescending(template => template.Id)
|
||||||
|
.Take(200)
|
||||||
|
.Select(template => new MemberCouponPickerItemDto
|
||||||
|
{
|
||||||
|
CouponTemplateId = template.Id,
|
||||||
|
Name = template.Name,
|
||||||
|
CouponType = MemberCenterSupport.ResolveCouponTypeText(template.CouponType),
|
||||||
|
Value = decimal.Round(template.Value, 2, MidpointRounding.AwayFromZero),
|
||||||
|
MinimumSpend = template.MinimumSpend,
|
||||||
|
DisplayText = MemberCenterSupport.BuildCouponDisplayText(template)
|
||||||
|
})
|
||||||
|
.ToList();
|
||||||
|
|
||||||
|
return filtered;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,73 @@
|
|||||||
|
using MediatR;
|
||||||
|
using TakeoutSaaS.Application.App.Members.Dto;
|
||||||
|
using TakeoutSaaS.Application.App.Members.Queries;
|
||||||
|
using TakeoutSaaS.Domain.Membership.Repositories;
|
||||||
|
using TakeoutSaaS.Domain.Orders.Repositories;
|
||||||
|
using TakeoutSaaS.Shared.Abstractions.Results;
|
||||||
|
using TakeoutSaaS.Shared.Abstractions.Tenancy;
|
||||||
|
|
||||||
|
namespace TakeoutSaaS.Application.App.Members.Handlers;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 会员列表查询处理器。
|
||||||
|
/// </summary>
|
||||||
|
public sealed class SearchMemberListQueryHandler(
|
||||||
|
IMemberRepository memberRepository,
|
||||||
|
IOrderRepository orderRepository,
|
||||||
|
ITenantProvider tenantProvider)
|
||||||
|
: IRequestHandler<SearchMemberListQuery, PagedResult<MemberListItemDto>>
|
||||||
|
{
|
||||||
|
/// <inheritdoc />
|
||||||
|
public async Task<PagedResult<MemberListItemDto>> Handle(
|
||||||
|
SearchMemberListQuery request,
|
||||||
|
CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
var page = Math.Max(1, request.Page);
|
||||||
|
var pageSize = Math.Clamp(request.PageSize, 1, 200);
|
||||||
|
|
||||||
|
if (request.VisibleStoreIds.Count == 0)
|
||||||
|
{
|
||||||
|
return new PagedResult<MemberListItemDto>([], page, pageSize, 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
var context = await MemberCenterSupport.LoadMemberContextAsync(
|
||||||
|
memberRepository,
|
||||||
|
orderRepository,
|
||||||
|
tenantProvider,
|
||||||
|
request.VisibleStoreIds,
|
||||||
|
cancellationToken);
|
||||||
|
|
||||||
|
var filtered = MemberCenterSupport.ApplyFilters(
|
||||||
|
context.Aggregates,
|
||||||
|
request.Keyword,
|
||||||
|
request.TierId)
|
||||||
|
.OrderByDescending(item => item.Metrics.LastOrderAt)
|
||||||
|
.ThenByDescending(item => item.MemberId)
|
||||||
|
.ToList();
|
||||||
|
|
||||||
|
var totalCount = filtered.Count;
|
||||||
|
var items = filtered
|
||||||
|
.Skip((page - 1) * pageSize)
|
||||||
|
.Take(pageSize)
|
||||||
|
.Select(item => new MemberListItemDto
|
||||||
|
{
|
||||||
|
MemberId = item.MemberId,
|
||||||
|
Name = item.Name,
|
||||||
|
AvatarText = item.AvatarText,
|
||||||
|
AvatarColor = item.AvatarColor,
|
||||||
|
MobileMasked = item.MobileMasked,
|
||||||
|
TierId = item.Tier?.Id,
|
||||||
|
TierName = item.Tier?.Name ?? string.Empty,
|
||||||
|
TierColorHex = item.Tier?.ColorHex ?? "#999999",
|
||||||
|
TotalAmount = decimal.Round(item.Metrics.TotalAmount, 2, MidpointRounding.AwayFromZero),
|
||||||
|
OrderCount = item.Metrics.TotalOrderCount,
|
||||||
|
LastOrderAt = item.Metrics.LastOrderAt,
|
||||||
|
StoredBalance = item.Profile.StoredBalance,
|
||||||
|
PointsBalance = Math.Max(0, item.Profile.PointsBalance),
|
||||||
|
IsDormant = item.IsDormant
|
||||||
|
})
|
||||||
|
.ToList();
|
||||||
|
|
||||||
|
return new PagedResult<MemberListItemDto>(items, page, pageSize, totalCount);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,25 @@
|
|||||||
|
using MediatR;
|
||||||
|
using TakeoutSaaS.Application.App.Members.Dto;
|
||||||
|
|
||||||
|
namespace TakeoutSaaS.Application.App.Members.Queries;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 会员列表 CSV 导出查询。
|
||||||
|
/// </summary>
|
||||||
|
public sealed class ExportMemberCsvQuery : IRequest<MemberExportDto>
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// 可见门店 ID 集合。
|
||||||
|
/// </summary>
|
||||||
|
public IReadOnlyCollection<long> VisibleStoreIds { get; init; } = [];
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 关键词(姓名/手机号)。
|
||||||
|
/// </summary>
|
||||||
|
public string? Keyword { get; init; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 等级标识。
|
||||||
|
/// </summary>
|
||||||
|
public long? TierId { get; init; }
|
||||||
|
}
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
using MediatR;
|
||||||
|
using TakeoutSaaS.Application.App.Members.Dto;
|
||||||
|
|
||||||
|
namespace TakeoutSaaS.Application.App.Members.Queries;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 会员日配置查询。
|
||||||
|
/// </summary>
|
||||||
|
public sealed class GetMemberDaySettingQuery : IRequest<MemberDaySettingDto>
|
||||||
|
{
|
||||||
|
}
|
||||||
@@ -0,0 +1,20 @@
|
|||||||
|
using MediatR;
|
||||||
|
using TakeoutSaaS.Application.App.Members.Dto;
|
||||||
|
|
||||||
|
namespace TakeoutSaaS.Application.App.Members.Queries;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 会员详情查询。
|
||||||
|
/// </summary>
|
||||||
|
public sealed class GetMemberDetailQuery : IRequest<MemberDetailDto?>
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// 可见门店 ID 集合。
|
||||||
|
/// </summary>
|
||||||
|
public IReadOnlyCollection<long> VisibleStoreIds { get; init; } = [];
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 会员标识。
|
||||||
|
/// </summary>
|
||||||
|
public long MemberId { get; init; }
|
||||||
|
}
|
||||||
@@ -0,0 +1,25 @@
|
|||||||
|
using MediatR;
|
||||||
|
using TakeoutSaaS.Application.App.Members.Dto;
|
||||||
|
|
||||||
|
namespace TakeoutSaaS.Application.App.Members.Queries;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 会员列表统计查询。
|
||||||
|
/// </summary>
|
||||||
|
public sealed class GetMemberListStatsQuery : IRequest<MemberListStatsDto>
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// 可见门店 ID 集合。
|
||||||
|
/// </summary>
|
||||||
|
public IReadOnlyCollection<long> VisibleStoreIds { get; init; } = [];
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 关键词(姓名/手机号)。
|
||||||
|
/// </summary>
|
||||||
|
public string? Keyword { get; init; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 等级标识。
|
||||||
|
/// </summary>
|
||||||
|
public long? TierId { get; init; }
|
||||||
|
}
|
||||||
@@ -0,0 +1,15 @@
|
|||||||
|
using MediatR;
|
||||||
|
using TakeoutSaaS.Application.App.Members.Dto;
|
||||||
|
|
||||||
|
namespace TakeoutSaaS.Application.App.Members.Queries;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 会员等级详情查询。
|
||||||
|
/// </summary>
|
||||||
|
public sealed class GetMemberTierDetailQuery : IRequest<MemberTierDetailDto>
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// 等级标识(为空时返回新增默认模板)。
|
||||||
|
/// </summary>
|
||||||
|
public long? TierId { get; init; }
|
||||||
|
}
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
using MediatR;
|
||||||
|
using TakeoutSaaS.Application.App.Members.Dto;
|
||||||
|
|
||||||
|
namespace TakeoutSaaS.Application.App.Members.Queries;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 会员等级列表查询。
|
||||||
|
/// </summary>
|
||||||
|
public sealed class GetMemberTierListQuery : IRequest<IReadOnlyList<MemberTierListItemDto>>
|
||||||
|
{
|
||||||
|
}
|
||||||
@@ -0,0 +1,20 @@
|
|||||||
|
using MediatR;
|
||||||
|
using TakeoutSaaS.Application.App.Members.Dto;
|
||||||
|
|
||||||
|
namespace TakeoutSaaS.Application.App.Members.Queries;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 优惠券选择器查询。
|
||||||
|
/// </summary>
|
||||||
|
public sealed class SearchMemberCouponPickerQuery : IRequest<IReadOnlyList<MemberCouponPickerItemDto>>
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// 可见门店 ID 集合。
|
||||||
|
/// </summary>
|
||||||
|
public IReadOnlyCollection<long> VisibleStoreIds { get; init; } = [];
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 搜索关键词。
|
||||||
|
/// </summary>
|
||||||
|
public string? Keyword { get; init; }
|
||||||
|
}
|
||||||
@@ -0,0 +1,36 @@
|
|||||||
|
using MediatR;
|
||||||
|
using TakeoutSaaS.Application.App.Members.Dto;
|
||||||
|
using TakeoutSaaS.Shared.Abstractions.Results;
|
||||||
|
|
||||||
|
namespace TakeoutSaaS.Application.App.Members.Queries;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 会员列表查询。
|
||||||
|
/// </summary>
|
||||||
|
public sealed class SearchMemberListQuery : IRequest<PagedResult<MemberListItemDto>>
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// 可见门店 ID 集合。
|
||||||
|
/// </summary>
|
||||||
|
public IReadOnlyCollection<long> VisibleStoreIds { get; init; } = [];
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 关键词(姓名/手机号)。
|
||||||
|
/// </summary>
|
||||||
|
public string? Keyword { get; init; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 等级标识。
|
||||||
|
/// </summary>
|
||||||
|
public long? TierId { get; init; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 页码。
|
||||||
|
/// </summary>
|
||||||
|
public int Page { get; init; } = 1;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 每页条数。
|
||||||
|
/// </summary>
|
||||||
|
public int PageSize { get; init; } = 10;
|
||||||
|
}
|
||||||
@@ -0,0 +1,24 @@
|
|||||||
|
using TakeoutSaaS.Shared.Abstractions.Entities;
|
||||||
|
|
||||||
|
namespace TakeoutSaaS.Domain.Membership.Entities;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 会员日配置。
|
||||||
|
/// </summary>
|
||||||
|
public sealed class MemberDaySetting : MultiTenantEntityBase
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// 是否启用会员日。
|
||||||
|
/// </summary>
|
||||||
|
public bool IsEnabled { get; set; } = true;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 周几(1-7,对应周一到周日)。
|
||||||
|
/// </summary>
|
||||||
|
public int Weekday { get; set; } = 2;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 会员日额外折扣(如 9 表示 9 折)。
|
||||||
|
/// </summary>
|
||||||
|
public decimal ExtraDiscountRate { get; set; } = 9m;
|
||||||
|
}
|
||||||
@@ -33,6 +33,21 @@ public sealed class MemberProfile : MultiTenantEntityBase
|
|||||||
/// </summary>
|
/// </summary>
|
||||||
public long? MemberTierId { get; set; }
|
public long? MemberTierId { get; set; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 储值余额。
|
||||||
|
/// </summary>
|
||||||
|
public decimal StoredBalance { get; set; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 储值实充余额。
|
||||||
|
/// </summary>
|
||||||
|
public decimal StoredRechargeBalance { get; set; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 储值赠金余额。
|
||||||
|
/// </summary>
|
||||||
|
public decimal StoredGiftBalance { get; set; }
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// 会员状态。
|
/// 会员状态。
|
||||||
/// </summary>
|
/// </summary>
|
||||||
|
|||||||
@@ -0,0 +1,19 @@
|
|||||||
|
using TakeoutSaaS.Shared.Abstractions.Entities;
|
||||||
|
|
||||||
|
namespace TakeoutSaaS.Domain.Membership.Entities;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 会员标签。
|
||||||
|
/// </summary>
|
||||||
|
public sealed class MemberProfileTag : MultiTenantEntityBase
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// 会员标识。
|
||||||
|
/// </summary>
|
||||||
|
public long MemberProfileId { get; set; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 标签名。
|
||||||
|
/// </summary>
|
||||||
|
public string TagName { get; set; } = string.Empty;
|
||||||
|
}
|
||||||
@@ -17,6 +17,41 @@ public sealed class MemberTier : MultiTenantEntityBase
|
|||||||
/// </summary>
|
/// </summary>
|
||||||
public int RequiredGrowth { get; set; }
|
public int RequiredGrowth { get; set; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 图标键。
|
||||||
|
/// </summary>
|
||||||
|
public string IconKey { get; set; } = "user";
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 主题色。
|
||||||
|
/// </summary>
|
||||||
|
public string ColorHex { get; set; } = "#999999";
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 升级规则类型(none/amount/count/both)。
|
||||||
|
/// </summary>
|
||||||
|
public string UpgradeRuleType { get; set; } = "none";
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 升级累计消费门槛。
|
||||||
|
/// </summary>
|
||||||
|
public decimal? UpgradeAmountThreshold { get; set; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 升级消费次数门槛。
|
||||||
|
/// </summary>
|
||||||
|
public int? UpgradeOrderCountThreshold { get; set; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 降级观察窗口天数。
|
||||||
|
/// </summary>
|
||||||
|
public int DowngradeWindowDays { get; set; } = 90;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 是否默认等级。
|
||||||
|
/// </summary>
|
||||||
|
public bool IsDefault { get; set; }
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// 等级权益(JSON)。
|
/// 等级权益(JSON)。
|
||||||
/// </summary>
|
/// </summary>
|
||||||
|
|||||||
@@ -0,0 +1,99 @@
|
|||||||
|
using TakeoutSaaS.Domain.Membership.Entities;
|
||||||
|
|
||||||
|
namespace TakeoutSaaS.Domain.Membership.Repositories;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 会员聚合仓储契约。
|
||||||
|
/// </summary>
|
||||||
|
public interface IMemberRepository
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// 查询租户下会员档案。
|
||||||
|
/// </summary>
|
||||||
|
Task<IReadOnlyList<MemberProfile>> GetProfilesAsync(long tenantId, CancellationToken cancellationToken = default);
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 按手机号集合查询会员档案。
|
||||||
|
/// </summary>
|
||||||
|
Task<IReadOnlyList<MemberProfile>> GetProfilesByMobilesAsync(
|
||||||
|
long tenantId,
|
||||||
|
IReadOnlyCollection<string> mobiles,
|
||||||
|
CancellationToken cancellationToken = default);
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 按标识查询会员档案。
|
||||||
|
/// </summary>
|
||||||
|
Task<MemberProfile?> FindProfileByIdAsync(long tenantId, long memberId, CancellationToken cancellationToken = default);
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 新增会员档案集合。
|
||||||
|
/// </summary>
|
||||||
|
Task AddProfilesAsync(IEnumerable<MemberProfile> profiles, CancellationToken cancellationToken = default);
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 更新会员档案。
|
||||||
|
/// </summary>
|
||||||
|
Task UpdateProfileAsync(MemberProfile profile, CancellationToken cancellationToken = default);
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 查询租户下会员等级。
|
||||||
|
/// </summary>
|
||||||
|
Task<IReadOnlyList<MemberTier>> GetTiersAsync(long tenantId, CancellationToken cancellationToken = default);
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 按标识查询会员等级。
|
||||||
|
/// </summary>
|
||||||
|
Task<MemberTier?> FindTierByIdAsync(long tenantId, long tierId, CancellationToken cancellationToken = default);
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 新增会员等级。
|
||||||
|
/// </summary>
|
||||||
|
Task AddTierAsync(MemberTier tier, CancellationToken cancellationToken = default);
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 更新会员等级。
|
||||||
|
/// </summary>
|
||||||
|
Task UpdateTierAsync(MemberTier tier, CancellationToken cancellationToken = default);
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 删除会员等级。
|
||||||
|
/// </summary>
|
||||||
|
Task DeleteTierAsync(MemberTier tier, CancellationToken cancellationToken = default);
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 查询租户会员日配置。
|
||||||
|
/// </summary>
|
||||||
|
Task<MemberDaySetting?> GetMemberDaySettingAsync(long tenantId, CancellationToken cancellationToken = default);
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 新增会员日配置。
|
||||||
|
/// </summary>
|
||||||
|
Task AddMemberDaySettingAsync(MemberDaySetting setting, CancellationToken cancellationToken = default);
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 更新会员日配置。
|
||||||
|
/// </summary>
|
||||||
|
Task UpdateMemberDaySettingAsync(MemberDaySetting setting, CancellationToken cancellationToken = default);
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 查询会员标签集合。
|
||||||
|
/// </summary>
|
||||||
|
Task<IReadOnlyList<MemberProfileTag>> GetProfileTagsAsync(
|
||||||
|
long tenantId,
|
||||||
|
long memberProfileId,
|
||||||
|
CancellationToken cancellationToken = default);
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 替换会员标签集合。
|
||||||
|
/// </summary>
|
||||||
|
Task ReplaceProfileTagsAsync(
|
||||||
|
long tenantId,
|
||||||
|
long memberProfileId,
|
||||||
|
IReadOnlyCollection<string> tags,
|
||||||
|
CancellationToken cancellationToken = default);
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 持久化变更。
|
||||||
|
/// </summary>
|
||||||
|
Task SaveChangesAsync(CancellationToken cancellationToken = default);
|
||||||
|
}
|
||||||
@@ -4,6 +4,7 @@ using TakeoutSaaS.Application.App.Stores.Services;
|
|||||||
using TakeoutSaaS.Domain.Coupons.Repositories;
|
using TakeoutSaaS.Domain.Coupons.Repositories;
|
||||||
using TakeoutSaaS.Domain.Deliveries.Repositories;
|
using TakeoutSaaS.Domain.Deliveries.Repositories;
|
||||||
using TakeoutSaaS.Domain.Inventory.Repositories;
|
using TakeoutSaaS.Domain.Inventory.Repositories;
|
||||||
|
using TakeoutSaaS.Domain.Membership.Repositories;
|
||||||
using TakeoutSaaS.Domain.Merchants.Repositories;
|
using TakeoutSaaS.Domain.Merchants.Repositories;
|
||||||
using TakeoutSaaS.Domain.Merchants.Services;
|
using TakeoutSaaS.Domain.Merchants.Services;
|
||||||
using TakeoutSaaS.Domain.Orders.Repositories;
|
using TakeoutSaaS.Domain.Orders.Repositories;
|
||||||
@@ -49,6 +50,7 @@ public static class AppServiceCollectionExtensions
|
|||||||
services.AddScoped<INewCustomerGiftRepository, EfNewCustomerGiftRepository>();
|
services.AddScoped<INewCustomerGiftRepository, EfNewCustomerGiftRepository>();
|
||||||
services.AddScoped<IPromotionCampaignRepository, EfPromotionCampaignRepository>();
|
services.AddScoped<IPromotionCampaignRepository, EfPromotionCampaignRepository>();
|
||||||
services.AddScoped<IPunchCardRepository, EfPunchCardRepository>();
|
services.AddScoped<IPunchCardRepository, EfPunchCardRepository>();
|
||||||
|
services.AddScoped<IMemberRepository, EfMemberRepository>();
|
||||||
services.AddScoped<IOrderRepository, EfOrderRepository>();
|
services.AddScoped<IOrderRepository, EfOrderRepository>();
|
||||||
services.AddScoped<IPaymentRepository, EfPaymentRepository>();
|
services.AddScoped<IPaymentRepository, EfPaymentRepository>();
|
||||||
services.AddScoped<IDeliveryRepository, EfDeliveryRepository>();
|
services.AddScoped<IDeliveryRepository, EfDeliveryRepository>();
|
||||||
|
|||||||
@@ -390,6 +390,14 @@ public sealed class TakeoutAppDbContext(
|
|||||||
/// </summary>
|
/// </summary>
|
||||||
public DbSet<MemberTier> MemberTiers => Set<MemberTier>();
|
public DbSet<MemberTier> MemberTiers => Set<MemberTier>();
|
||||||
/// <summary>
|
/// <summary>
|
||||||
|
/// 会员标签。
|
||||||
|
/// </summary>
|
||||||
|
public DbSet<MemberProfileTag> MemberProfileTags => Set<MemberProfileTag>();
|
||||||
|
/// <summary>
|
||||||
|
/// 会员日设置。
|
||||||
|
/// </summary>
|
||||||
|
public DbSet<MemberDaySetting> MemberDaySettings => Set<MemberDaySetting>();
|
||||||
|
/// <summary>
|
||||||
/// 积分流水。
|
/// 积分流水。
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public DbSet<MemberPointLedger> MemberPointLedgers => Set<MemberPointLedger>();
|
public DbSet<MemberPointLedger> MemberPointLedgers => Set<MemberPointLedger>();
|
||||||
@@ -557,6 +565,8 @@ public sealed class TakeoutAppDbContext(
|
|||||||
ConfigurePunchCardUsageRecord(modelBuilder.Entity<PunchCardUsageRecord>());
|
ConfigurePunchCardUsageRecord(modelBuilder.Entity<PunchCardUsageRecord>());
|
||||||
ConfigureMemberProfile(modelBuilder.Entity<MemberProfile>());
|
ConfigureMemberProfile(modelBuilder.Entity<MemberProfile>());
|
||||||
ConfigureMemberTier(modelBuilder.Entity<MemberTier>());
|
ConfigureMemberTier(modelBuilder.Entity<MemberTier>());
|
||||||
|
ConfigureMemberProfileTag(modelBuilder.Entity<MemberProfileTag>());
|
||||||
|
ConfigureMemberDaySetting(modelBuilder.Entity<MemberDaySetting>());
|
||||||
ConfigureMemberPointLedger(modelBuilder.Entity<MemberPointLedger>());
|
ConfigureMemberPointLedger(modelBuilder.Entity<MemberPointLedger>());
|
||||||
ConfigureChatSession(modelBuilder.Entity<ChatSession>());
|
ConfigureChatSession(modelBuilder.Entity<ChatSession>());
|
||||||
ConfigureChatMessage(modelBuilder.Entity<ChatMessage>());
|
ConfigureChatMessage(modelBuilder.Entity<ChatMessage>());
|
||||||
@@ -1785,8 +1795,12 @@ public sealed class TakeoutAppDbContext(
|
|||||||
builder.Property(x => x.Mobile).HasMaxLength(32).IsRequired();
|
builder.Property(x => x.Mobile).HasMaxLength(32).IsRequired();
|
||||||
builder.Property(x => x.Nickname).HasMaxLength(64);
|
builder.Property(x => x.Nickname).HasMaxLength(64);
|
||||||
builder.Property(x => x.AvatarUrl).HasMaxLength(256);
|
builder.Property(x => x.AvatarUrl).HasMaxLength(256);
|
||||||
|
builder.Property(x => x.StoredBalance).HasPrecision(18, 2);
|
||||||
|
builder.Property(x => x.StoredRechargeBalance).HasPrecision(18, 2);
|
||||||
|
builder.Property(x => x.StoredGiftBalance).HasPrecision(18, 2);
|
||||||
builder.Property(x => x.Status).HasConversion<int>();
|
builder.Property(x => x.Status).HasConversion<int>();
|
||||||
builder.HasIndex(x => new { x.TenantId, x.Mobile }).IsUnique();
|
builder.HasIndex(x => new { x.TenantId, x.Mobile }).IsUnique();
|
||||||
|
builder.HasIndex(x => new { x.TenantId, x.MemberTierId });
|
||||||
}
|
}
|
||||||
|
|
||||||
private static void ConfigureMemberTier(EntityTypeBuilder<MemberTier> builder)
|
private static void ConfigureMemberTier(EntityTypeBuilder<MemberTier> builder)
|
||||||
@@ -1794,8 +1808,33 @@ public sealed class TakeoutAppDbContext(
|
|||||||
builder.ToTable("member_tiers");
|
builder.ToTable("member_tiers");
|
||||||
builder.HasKey(x => x.Id);
|
builder.HasKey(x => x.Id);
|
||||||
builder.Property(x => x.Name).HasMaxLength(64).IsRequired();
|
builder.Property(x => x.Name).HasMaxLength(64).IsRequired();
|
||||||
|
builder.Property(x => x.IconKey).HasMaxLength(32).IsRequired();
|
||||||
|
builder.Property(x => x.ColorHex).HasMaxLength(16).IsRequired();
|
||||||
|
builder.Property(x => x.UpgradeRuleType).HasMaxLength(16).IsRequired();
|
||||||
|
builder.Property(x => x.UpgradeAmountThreshold).HasPrecision(18, 2);
|
||||||
builder.Property(x => x.BenefitsJson).HasColumnType("text");
|
builder.Property(x => x.BenefitsJson).HasColumnType("text");
|
||||||
builder.HasIndex(x => new { x.TenantId, x.Name }).IsUnique();
|
builder.HasIndex(x => new { x.TenantId, x.Name }).IsUnique();
|
||||||
|
builder.HasIndex(x => new { x.TenantId, x.SortOrder });
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void ConfigureMemberProfileTag(EntityTypeBuilder<MemberProfileTag> builder)
|
||||||
|
{
|
||||||
|
builder.ToTable("member_profile_tags");
|
||||||
|
builder.HasKey(x => x.Id);
|
||||||
|
builder.Property(x => x.MemberProfileId).IsRequired();
|
||||||
|
builder.Property(x => x.TagName).HasMaxLength(32).IsRequired();
|
||||||
|
builder.HasIndex(x => new { x.TenantId, x.MemberProfileId, x.TagName }).IsUnique();
|
||||||
|
builder.HasIndex(x => new { x.TenantId, x.MemberProfileId });
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void ConfigureMemberDaySetting(EntityTypeBuilder<MemberDaySetting> builder)
|
||||||
|
{
|
||||||
|
builder.ToTable("member_day_settings");
|
||||||
|
builder.HasKey(x => x.Id);
|
||||||
|
builder.Property(x => x.IsEnabled).IsRequired();
|
||||||
|
builder.Property(x => x.Weekday).IsRequired();
|
||||||
|
builder.Property(x => x.ExtraDiscountRate).HasPrecision(5, 2);
|
||||||
|
builder.HasIndex(x => x.TenantId).IsUnique();
|
||||||
}
|
}
|
||||||
|
|
||||||
private static void ConfigureMemberPointLedger(EntityTypeBuilder<MemberPointLedger> builder)
|
private static void ConfigureMemberPointLedger(EntityTypeBuilder<MemberPointLedger> builder)
|
||||||
|
|||||||
@@ -0,0 +1,177 @@
|
|||||||
|
using Microsoft.EntityFrameworkCore;
|
||||||
|
using TakeoutSaaS.Domain.Membership.Entities;
|
||||||
|
using TakeoutSaaS.Domain.Membership.Repositories;
|
||||||
|
using TakeoutSaaS.Infrastructure.App.Persistence;
|
||||||
|
|
||||||
|
namespace TakeoutSaaS.Infrastructure.App.Repositories;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 会员聚合 EF Core 仓储实现。
|
||||||
|
/// </summary>
|
||||||
|
public sealed class EfMemberRepository(TakeoutAppDbContext context) : IMemberRepository
|
||||||
|
{
|
||||||
|
/// <inheritdoc />
|
||||||
|
public async Task<IReadOnlyList<MemberProfile>> GetProfilesAsync(long tenantId, CancellationToken cancellationToken = default)
|
||||||
|
{
|
||||||
|
return await context.MemberProfiles
|
||||||
|
.Where(x => x.TenantId == tenantId)
|
||||||
|
.OrderByDescending(x => x.UpdatedAt ?? x.CreatedAt)
|
||||||
|
.ThenByDescending(x => x.Id)
|
||||||
|
.ToListAsync(cancellationToken);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <inheritdoc />
|
||||||
|
public async Task<IReadOnlyList<MemberProfile>> GetProfilesByMobilesAsync(
|
||||||
|
long tenantId,
|
||||||
|
IReadOnlyCollection<string> mobiles,
|
||||||
|
CancellationToken cancellationToken = default)
|
||||||
|
{
|
||||||
|
if (mobiles.Count == 0)
|
||||||
|
{
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
return await context.MemberProfiles
|
||||||
|
.Where(x => x.TenantId == tenantId && mobiles.Contains(x.Mobile))
|
||||||
|
.ToListAsync(cancellationToken);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <inheritdoc />
|
||||||
|
public Task<MemberProfile?> FindProfileByIdAsync(long tenantId, long memberId, CancellationToken cancellationToken = default)
|
||||||
|
{
|
||||||
|
return context.MemberProfiles
|
||||||
|
.Where(x => x.TenantId == tenantId && x.Id == memberId)
|
||||||
|
.FirstOrDefaultAsync(cancellationToken);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <inheritdoc />
|
||||||
|
public async Task AddProfilesAsync(IEnumerable<MemberProfile> profiles, CancellationToken cancellationToken = default)
|
||||||
|
{
|
||||||
|
var profileList = profiles?.ToList() ?? [];
|
||||||
|
if (profileList.Count == 0)
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
await context.MemberProfiles.AddRangeAsync(profileList, cancellationToken);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <inheritdoc />
|
||||||
|
public Task UpdateProfileAsync(MemberProfile profile, CancellationToken cancellationToken = default)
|
||||||
|
{
|
||||||
|
context.MemberProfiles.Update(profile);
|
||||||
|
return Task.CompletedTask;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <inheritdoc />
|
||||||
|
public async Task<IReadOnlyList<MemberTier>> GetTiersAsync(long tenantId, CancellationToken cancellationToken = default)
|
||||||
|
{
|
||||||
|
return await context.MemberTiers
|
||||||
|
.Where(x => x.TenantId == tenantId)
|
||||||
|
.OrderBy(x => x.SortOrder)
|
||||||
|
.ThenBy(x => x.Id)
|
||||||
|
.ToListAsync(cancellationToken);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <inheritdoc />
|
||||||
|
public Task<MemberTier?> FindTierByIdAsync(long tenantId, long tierId, CancellationToken cancellationToken = default)
|
||||||
|
{
|
||||||
|
return context.MemberTiers
|
||||||
|
.Where(x => x.TenantId == tenantId && x.Id == tierId)
|
||||||
|
.FirstOrDefaultAsync(cancellationToken);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <inheritdoc />
|
||||||
|
public Task AddTierAsync(MemberTier tier, CancellationToken cancellationToken = default)
|
||||||
|
{
|
||||||
|
return context.MemberTiers.AddAsync(tier, cancellationToken).AsTask();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <inheritdoc />
|
||||||
|
public Task UpdateTierAsync(MemberTier tier, CancellationToken cancellationToken = default)
|
||||||
|
{
|
||||||
|
context.MemberTiers.Update(tier);
|
||||||
|
return Task.CompletedTask;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <inheritdoc />
|
||||||
|
public Task DeleteTierAsync(MemberTier tier, CancellationToken cancellationToken = default)
|
||||||
|
{
|
||||||
|
context.MemberTiers.Remove(tier);
|
||||||
|
return Task.CompletedTask;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <inheritdoc />
|
||||||
|
public Task<MemberDaySetting?> GetMemberDaySettingAsync(long tenantId, CancellationToken cancellationToken = default)
|
||||||
|
{
|
||||||
|
return context.MemberDaySettings
|
||||||
|
.Where(x => x.TenantId == tenantId)
|
||||||
|
.FirstOrDefaultAsync(cancellationToken);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <inheritdoc />
|
||||||
|
public Task AddMemberDaySettingAsync(MemberDaySetting setting, CancellationToken cancellationToken = default)
|
||||||
|
{
|
||||||
|
return context.MemberDaySettings.AddAsync(setting, cancellationToken).AsTask();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <inheritdoc />
|
||||||
|
public Task UpdateMemberDaySettingAsync(MemberDaySetting setting, CancellationToken cancellationToken = default)
|
||||||
|
{
|
||||||
|
context.MemberDaySettings.Update(setting);
|
||||||
|
return Task.CompletedTask;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <inheritdoc />
|
||||||
|
public async Task<IReadOnlyList<MemberProfileTag>> GetProfileTagsAsync(
|
||||||
|
long tenantId,
|
||||||
|
long memberProfileId,
|
||||||
|
CancellationToken cancellationToken = default)
|
||||||
|
{
|
||||||
|
return await context.MemberProfileTags
|
||||||
|
.AsNoTracking()
|
||||||
|
.Where(x => x.TenantId == tenantId && x.MemberProfileId == memberProfileId)
|
||||||
|
.OrderBy(x => x.TagName)
|
||||||
|
.ToListAsync(cancellationToken);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <inheritdoc />
|
||||||
|
public async Task ReplaceProfileTagsAsync(
|
||||||
|
long tenantId,
|
||||||
|
long memberProfileId,
|
||||||
|
IReadOnlyCollection<string> tags,
|
||||||
|
CancellationToken cancellationToken = default)
|
||||||
|
{
|
||||||
|
var normalizedTags = (tags ?? Array.Empty<string>())
|
||||||
|
.Select(x => (x ?? string.Empty).Trim())
|
||||||
|
.Where(x => !string.IsNullOrWhiteSpace(x))
|
||||||
|
.Distinct(StringComparer.OrdinalIgnoreCase)
|
||||||
|
.OrderBy(x => x, StringComparer.OrdinalIgnoreCase)
|
||||||
|
.ToList();
|
||||||
|
|
||||||
|
var existing = await context.MemberProfileTags
|
||||||
|
.Where(x => x.TenantId == tenantId && x.MemberProfileId == memberProfileId)
|
||||||
|
.ToListAsync(cancellationToken);
|
||||||
|
|
||||||
|
context.MemberProfileTags.RemoveRange(existing);
|
||||||
|
|
||||||
|
if (normalizedTags.Count == 0)
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
var entities = normalizedTags.Select(tag => new MemberProfileTag
|
||||||
|
{
|
||||||
|
MemberProfileId = memberProfileId,
|
||||||
|
TagName = tag
|
||||||
|
});
|
||||||
|
|
||||||
|
await context.MemberProfileTags.AddRangeAsync(entities, cancellationToken);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <inheritdoc />
|
||||||
|
public Task SaveChangesAsync(CancellationToken cancellationToken = default)
|
||||||
|
{
|
||||||
|
return context.SaveChangesAsync(cancellationToken);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,225 @@
|
|||||||
|
using System;
|
||||||
|
using Microsoft.EntityFrameworkCore.Migrations;
|
||||||
|
using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata;
|
||||||
|
|
||||||
|
#nullable disable
|
||||||
|
|
||||||
|
namespace TakeoutSaaS.Infrastructure.Migrations
|
||||||
|
{
|
||||||
|
/// <inheritdoc />
|
||||||
|
public partial class AddMemberCenterModule : Migration
|
||||||
|
{
|
||||||
|
/// <inheritdoc />
|
||||||
|
protected override void Up(MigrationBuilder migrationBuilder)
|
||||||
|
{
|
||||||
|
migrationBuilder.AddColumn<string>(
|
||||||
|
name: "ColorHex",
|
||||||
|
table: "member_tiers",
|
||||||
|
type: "character varying(16)",
|
||||||
|
maxLength: 16,
|
||||||
|
nullable: false,
|
||||||
|
defaultValue: "#999999");
|
||||||
|
|
||||||
|
migrationBuilder.AddColumn<int>(
|
||||||
|
name: "DowngradeWindowDays",
|
||||||
|
table: "member_tiers",
|
||||||
|
type: "integer",
|
||||||
|
nullable: false,
|
||||||
|
defaultValue: 90);
|
||||||
|
|
||||||
|
migrationBuilder.AddColumn<string>(
|
||||||
|
name: "IconKey",
|
||||||
|
table: "member_tiers",
|
||||||
|
type: "character varying(32)",
|
||||||
|
maxLength: 32,
|
||||||
|
nullable: false,
|
||||||
|
defaultValue: "user");
|
||||||
|
|
||||||
|
migrationBuilder.AddColumn<bool>(
|
||||||
|
name: "IsDefault",
|
||||||
|
table: "member_tiers",
|
||||||
|
type: "boolean",
|
||||||
|
nullable: false,
|
||||||
|
defaultValue: false);
|
||||||
|
|
||||||
|
migrationBuilder.AddColumn<decimal>(
|
||||||
|
name: "UpgradeAmountThreshold",
|
||||||
|
table: "member_tiers",
|
||||||
|
type: "numeric(18,2)",
|
||||||
|
precision: 18,
|
||||||
|
scale: 2,
|
||||||
|
nullable: true);
|
||||||
|
|
||||||
|
migrationBuilder.AddColumn<int>(
|
||||||
|
name: "UpgradeOrderCountThreshold",
|
||||||
|
table: "member_tiers",
|
||||||
|
type: "integer",
|
||||||
|
nullable: true);
|
||||||
|
|
||||||
|
migrationBuilder.AddColumn<string>(
|
||||||
|
name: "UpgradeRuleType",
|
||||||
|
table: "member_tiers",
|
||||||
|
type: "character varying(16)",
|
||||||
|
maxLength: 16,
|
||||||
|
nullable: false,
|
||||||
|
defaultValue: "none");
|
||||||
|
|
||||||
|
migrationBuilder.AddColumn<decimal>(
|
||||||
|
name: "StoredBalance",
|
||||||
|
table: "member_profiles",
|
||||||
|
type: "numeric(18,2)",
|
||||||
|
precision: 18,
|
||||||
|
scale: 2,
|
||||||
|
nullable: false,
|
||||||
|
defaultValue: 0m);
|
||||||
|
|
||||||
|
migrationBuilder.AddColumn<decimal>(
|
||||||
|
name: "StoredGiftBalance",
|
||||||
|
table: "member_profiles",
|
||||||
|
type: "numeric(18,2)",
|
||||||
|
precision: 18,
|
||||||
|
scale: 2,
|
||||||
|
nullable: false,
|
||||||
|
defaultValue: 0m);
|
||||||
|
|
||||||
|
migrationBuilder.AddColumn<decimal>(
|
||||||
|
name: "StoredRechargeBalance",
|
||||||
|
table: "member_profiles",
|
||||||
|
type: "numeric(18,2)",
|
||||||
|
precision: 18,
|
||||||
|
scale: 2,
|
||||||
|
nullable: false,
|
||||||
|
defaultValue: 0m);
|
||||||
|
|
||||||
|
migrationBuilder.CreateTable(
|
||||||
|
name: "member_day_settings",
|
||||||
|
columns: table => new
|
||||||
|
{
|
||||||
|
Id = table.Column<long>(type: "bigint", nullable: false, comment: "实体唯一标识。")
|
||||||
|
.Annotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.IdentityByDefaultColumn),
|
||||||
|
IsEnabled = table.Column<bool>(type: "boolean", nullable: false, comment: "是否启用会员日。"),
|
||||||
|
Weekday = table.Column<int>(type: "integer", nullable: false, comment: "周几(1-7)。"),
|
||||||
|
ExtraDiscountRate = table.Column<decimal>(type: "numeric(5,2)", precision: 5, scale: 2, 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_member_day_settings", x => x.Id);
|
||||||
|
},
|
||||||
|
comment: "会员日设置。");
|
||||||
|
|
||||||
|
migrationBuilder.CreateTable(
|
||||||
|
name: "member_profile_tags",
|
||||||
|
columns: table => new
|
||||||
|
{
|
||||||
|
Id = table.Column<long>(type: "bigint", nullable: false, comment: "实体唯一标识。")
|
||||||
|
.Annotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.IdentityByDefaultColumn),
|
||||||
|
MemberProfileId = table.Column<long>(type: "bigint", nullable: false, comment: "会员标识。"),
|
||||||
|
TagName = table.Column<string>(type: "character varying(32)", maxLength: 32, 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_member_profile_tags", x => x.Id);
|
||||||
|
},
|
||||||
|
comment: "会员标签。");
|
||||||
|
|
||||||
|
migrationBuilder.CreateIndex(
|
||||||
|
name: "IX_member_day_settings_TenantId",
|
||||||
|
table: "member_day_settings",
|
||||||
|
column: "TenantId",
|
||||||
|
unique: true);
|
||||||
|
|
||||||
|
migrationBuilder.CreateIndex(
|
||||||
|
name: "IX_member_profiles_TenantId_MemberTierId",
|
||||||
|
table: "member_profiles",
|
||||||
|
columns: new[] { "TenantId", "MemberTierId" });
|
||||||
|
|
||||||
|
migrationBuilder.CreateIndex(
|
||||||
|
name: "IX_member_profile_tags_TenantId_MemberProfileId",
|
||||||
|
table: "member_profile_tags",
|
||||||
|
columns: new[] { "TenantId", "MemberProfileId" });
|
||||||
|
|
||||||
|
migrationBuilder.CreateIndex(
|
||||||
|
name: "IX_member_profile_tags_TenantId_MemberProfileId_TagName",
|
||||||
|
table: "member_profile_tags",
|
||||||
|
columns: new[] { "TenantId", "MemberProfileId", "TagName" },
|
||||||
|
unique: true);
|
||||||
|
|
||||||
|
migrationBuilder.CreateIndex(
|
||||||
|
name: "IX_member_tiers_TenantId_SortOrder",
|
||||||
|
table: "member_tiers",
|
||||||
|
columns: new[] { "TenantId", "SortOrder" });
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <inheritdoc />
|
||||||
|
protected override void Down(MigrationBuilder migrationBuilder)
|
||||||
|
{
|
||||||
|
migrationBuilder.DropTable(
|
||||||
|
name: "member_day_settings");
|
||||||
|
|
||||||
|
migrationBuilder.DropTable(
|
||||||
|
name: "member_profile_tags");
|
||||||
|
|
||||||
|
migrationBuilder.DropIndex(
|
||||||
|
name: "IX_member_profiles_TenantId_MemberTierId",
|
||||||
|
table: "member_profiles");
|
||||||
|
|
||||||
|
migrationBuilder.DropIndex(
|
||||||
|
name: "IX_member_tiers_TenantId_SortOrder",
|
||||||
|
table: "member_tiers");
|
||||||
|
|
||||||
|
migrationBuilder.DropColumn(
|
||||||
|
name: "ColorHex",
|
||||||
|
table: "member_tiers");
|
||||||
|
|
||||||
|
migrationBuilder.DropColumn(
|
||||||
|
name: "DowngradeWindowDays",
|
||||||
|
table: "member_tiers");
|
||||||
|
|
||||||
|
migrationBuilder.DropColumn(
|
||||||
|
name: "IconKey",
|
||||||
|
table: "member_tiers");
|
||||||
|
|
||||||
|
migrationBuilder.DropColumn(
|
||||||
|
name: "IsDefault",
|
||||||
|
table: "member_tiers");
|
||||||
|
|
||||||
|
migrationBuilder.DropColumn(
|
||||||
|
name: "UpgradeAmountThreshold",
|
||||||
|
table: "member_tiers");
|
||||||
|
|
||||||
|
migrationBuilder.DropColumn(
|
||||||
|
name: "UpgradeOrderCountThreshold",
|
||||||
|
table: "member_tiers");
|
||||||
|
|
||||||
|
migrationBuilder.DropColumn(
|
||||||
|
name: "UpgradeRuleType",
|
||||||
|
table: "member_tiers");
|
||||||
|
|
||||||
|
migrationBuilder.DropColumn(
|
||||||
|
name: "StoredBalance",
|
||||||
|
table: "member_profiles");
|
||||||
|
|
||||||
|
migrationBuilder.DropColumn(
|
||||||
|
name: "StoredGiftBalance",
|
||||||
|
table: "member_profiles");
|
||||||
|
|
||||||
|
migrationBuilder.DropColumn(
|
||||||
|
name: "StoredRechargeBalance",
|
||||||
|
table: "member_profiles");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user