Compare commits
28 Commits
c9e2226b48
...
dev
| Author | SHA1 | Date | |
|---|---|---|---|
| 2ba8c0732b | |||
| f7eba55039 | |||
| fdbefca650 | |||
| 4a7d012a58 | |||
| d330db84fc | |||
| c79e9bd6e8 | |||
| 3f308c2d0c | |||
| 5dfaac01fd | |||
| 21a689edec | |||
| fa6e376b86 | |||
| 76366cbc30 | |||
| b0bb87d97c | |||
| b5aa060faf | |||
| 39e28c1a62 | |||
| dd2ac79d48 | |||
| bd418c5927 | |||
| a8cfda88f7 | |||
| e4f7ceeaa7 | |||
| d437b146d1 | |||
| 2970134200 | |||
| d96ca4971a | |||
| c2821202c7 | |||
| 26afffd874 | |||
| a993b81aeb | |||
| 1b28fa6db4 | |||
| decfa4fa12 | |||
| 3b3bdcee71 | |||
| 6588c85f27 |
Submodule TakeoutSaaS.Docs updated: 5da102c97c...6daa444c5e
@@ -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,562 @@
|
||||
namespace TakeoutSaaS.TenantApi.Contracts.Customer;
|
||||
|
||||
/// <summary>
|
||||
/// 客户列表筛选请求。
|
||||
/// </summary>
|
||||
public class CustomerListFilterRequest
|
||||
{
|
||||
/// <summary>
|
||||
/// 门店 ID(可选,未传表示当前商户全部可见门店)。
|
||||
/// </summary>
|
||||
public string? StoreId { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 关键词(姓名/手机号)。
|
||||
/// </summary>
|
||||
public string? Keyword { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 客户标签(high_value/active/dormant/churn/new_customer)。
|
||||
/// </summary>
|
||||
public string? Tag { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 下单次数区间(once/two_to_five/six_to_ten/ten_plus)。
|
||||
/// </summary>
|
||||
public string? OrderCountRange { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 注册周期(7/30/90 或 7d/30d/90d)。
|
||||
/// </summary>
|
||||
public string? RegisterPeriod { get; set; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 客户列表分页请求。
|
||||
/// </summary>
|
||||
public sealed class CustomerListRequest : CustomerListFilterRequest
|
||||
{
|
||||
/// <summary>
|
||||
/// 页码。
|
||||
/// </summary>
|
||||
public int Page { get; set; } = 1;
|
||||
|
||||
/// <summary>
|
||||
/// 每页条数。
|
||||
/// </summary>
|
||||
public int PageSize { get; set; } = 10;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 客户详情请求。
|
||||
/// </summary>
|
||||
public sealed class CustomerDetailRequest
|
||||
{
|
||||
/// <summary>
|
||||
/// 门店 ID(可选,未传表示当前商户全部可见门店)。
|
||||
/// </summary>
|
||||
public string? StoreId { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 客户标识(手机号归一化)。
|
||||
/// </summary>
|
||||
public string CustomerKey { get; set; } = string.Empty;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 客户画像请求。
|
||||
/// </summary>
|
||||
public sealed class CustomerProfileRequest
|
||||
{
|
||||
/// <summary>
|
||||
/// 门店 ID(可选,未传表示当前商户全部可见门店)。
|
||||
/// </summary>
|
||||
public string? StoreId { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 客户标识(手机号归一化)。
|
||||
/// </summary>
|
||||
public string CustomerKey { get; set; } = string.Empty;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 客户标签响应。
|
||||
/// </summary>
|
||||
public sealed class CustomerTagResponse
|
||||
{
|
||||
/// <summary>
|
||||
/// 标签编码。
|
||||
/// </summary>
|
||||
public string Code { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// 标签文案。
|
||||
/// </summary>
|
||||
public string Label { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// 标签色调(orange/blue/green/gray/red)。
|
||||
/// </summary>
|
||||
public string Tone { get; set; } = "blue";
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 客户列表行响应。
|
||||
/// </summary>
|
||||
public sealed class CustomerListItemResponse
|
||||
{
|
||||
/// <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 int OrderCount { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 下单次数条形宽度百分比。
|
||||
/// </summary>
|
||||
public int OrderCountBarPercent { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 累计消费。
|
||||
/// </summary>
|
||||
public decimal TotalAmount { 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 bool IsDimmed { get; set; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 客户列表响应。
|
||||
/// </summary>
|
||||
public sealed class CustomerListResultResponse
|
||||
{
|
||||
/// <summary>
|
||||
/// 列表项。
|
||||
/// </summary>
|
||||
public List<CustomerListItemResponse> 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 CustomerListStatsResponse
|
||||
{
|
||||
/// <summary>
|
||||
/// 客户总数。
|
||||
/// </summary>
|
||||
public int TotalCustomers { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 本月新增客户数。
|
||||
/// </summary>
|
||||
public int MonthlyNewCustomers { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 本月较上月增长百分比。
|
||||
/// </summary>
|
||||
public decimal MonthlyGrowthRatePercent { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 活跃客户数(近 30 天有下单)。
|
||||
/// </summary>
|
||||
public int ActiveCustomers { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 近 30 天客均消费(按订单均值)。
|
||||
/// </summary>
|
||||
public decimal AverageAmountLast30Days { get; set; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 客户偏好响应。
|
||||
/// </summary>
|
||||
public sealed class CustomerPreferenceResponse
|
||||
{
|
||||
/// <summary>
|
||||
/// 偏好品类。
|
||||
/// </summary>
|
||||
public List<string> PreferredCategories { get; set; } = [];
|
||||
|
||||
/// <summary>
|
||||
/// 偏好下单时段。
|
||||
/// </summary>
|
||||
public string PreferredOrderPeaks { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// 偏好履约方式。
|
||||
/// </summary>
|
||||
public string PreferredDelivery { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// 偏好支付方式。
|
||||
/// </summary>
|
||||
public string PreferredPaymentMethod { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// 平均配送距离文案(当前无配送距离数据时返回空字符串)。
|
||||
/// </summary>
|
||||
public string AverageDeliveryDistance { get; set; } = string.Empty;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 客户常购商品响应。
|
||||
/// </summary>
|
||||
public sealed class CustomerTopProductResponse
|
||||
{
|
||||
/// <summary>
|
||||
/// 排名。
|
||||
/// </summary>
|
||||
public int Rank { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 商品名称。
|
||||
/// </summary>
|
||||
public string ProductName { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// 购买次数。
|
||||
/// </summary>
|
||||
public int Count { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 占比(0-100)。
|
||||
/// </summary>
|
||||
public decimal ProportionPercent { get; set; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 客户月度趋势响应。
|
||||
/// </summary>
|
||||
public sealed class CustomerTrendPointResponse
|
||||
{
|
||||
/// <summary>
|
||||
/// 月份标签。
|
||||
/// </summary>
|
||||
public string Label { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// 消费金额。
|
||||
/// </summary>
|
||||
public decimal Amount { get; set; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 客户最近订单响应。
|
||||
/// </summary>
|
||||
public sealed class CustomerRecentOrderResponse
|
||||
{
|
||||
/// <summary>
|
||||
/// 订单号。
|
||||
/// </summary>
|
||||
public string OrderNo { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// 订单金额。
|
||||
/// </summary>
|
||||
public decimal Amount { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 商品摘要。
|
||||
/// </summary>
|
||||
public string ItemsSummary { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// 履约方式。
|
||||
/// </summary>
|
||||
public string DeliveryType { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// 订单状态。
|
||||
/// </summary>
|
||||
public string Status { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// 下单时间(yyyy-MM-dd HH:mm:ss)。
|
||||
/// </summary>
|
||||
public string OrderedAt { get; set; } = string.Empty;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 客户会员摘要响应。
|
||||
/// </summary>
|
||||
public sealed class CustomerMemberSummaryResponse
|
||||
{
|
||||
/// <summary>
|
||||
/// 是否会员。
|
||||
/// </summary>
|
||||
public bool IsMember { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 会员等级名称。
|
||||
/// </summary>
|
||||
public string TierName { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// 积分余额。
|
||||
/// </summary>
|
||||
public int PointsBalance { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 成长值。
|
||||
/// </summary>
|
||||
public int GrowthValue { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 入会时间(yyyy-MM-dd)。
|
||||
/// </summary>
|
||||
public string JoinedAt { get; set; } = string.Empty;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 客户详情响应。
|
||||
/// </summary>
|
||||
public sealed class CustomerDetailResponse
|
||||
{
|
||||
/// <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>
|
||||
/// 注册时间(yyyy-MM-dd)。
|
||||
/// </summary>
|
||||
public string RegisteredAt { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// 首次下单时间(yyyy-MM-dd)。
|
||||
/// </summary>
|
||||
public string FirstOrderAt { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// 客户来源。
|
||||
/// </summary>
|
||||
public string Source { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// 客户标签。
|
||||
/// </summary>
|
||||
public List<CustomerTagResponse> Tags { get; set; } = [];
|
||||
|
||||
/// <summary>
|
||||
/// 会员摘要。
|
||||
/// </summary>
|
||||
public CustomerMemberSummaryResponse Member { get; set; } = new();
|
||||
|
||||
/// <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 CustomerPreferenceResponse Preference { get; set; } = new();
|
||||
|
||||
/// <summary>
|
||||
/// 常购商品 Top 5。
|
||||
/// </summary>
|
||||
public List<CustomerTopProductResponse> TopProducts { get; set; } = [];
|
||||
|
||||
/// <summary>
|
||||
/// 近 6 月消费趋势。
|
||||
/// </summary>
|
||||
public List<CustomerTrendPointResponse> Trend { get; set; } = [];
|
||||
|
||||
/// <summary>
|
||||
/// 最近订单(最多 3 条)。
|
||||
/// </summary>
|
||||
public List<CustomerRecentOrderResponse> RecentOrders { get; set; } = [];
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 客户画像响应。
|
||||
/// </summary>
|
||||
public sealed class CustomerProfileResponse
|
||||
{
|
||||
/// <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>
|
||||
/// 注册时间(yyyy-MM-dd)。
|
||||
/// </summary>
|
||||
public string RegisteredAt { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// 首次下单时间(yyyy-MM-dd)。
|
||||
/// </summary>
|
||||
public string FirstOrderAt { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// 客户来源。
|
||||
/// </summary>
|
||||
public string Source { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// 客户标签。
|
||||
/// </summary>
|
||||
public List<CustomerTagResponse> Tags { get; set; } = [];
|
||||
|
||||
/// <summary>
|
||||
/// 会员摘要。
|
||||
/// </summary>
|
||||
public CustomerMemberSummaryResponse Member { get; set; } = new();
|
||||
|
||||
/// <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 decimal AverageOrderIntervalDays { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 消费偏好。
|
||||
/// </summary>
|
||||
public CustomerPreferenceResponse Preference { get; set; } = new();
|
||||
|
||||
/// <summary>
|
||||
/// 常购商品 Top 5。
|
||||
/// </summary>
|
||||
public List<CustomerTopProductResponse> TopProducts { get; set; } = [];
|
||||
|
||||
/// <summary>
|
||||
/// 近 12 月消费趋势。
|
||||
/// </summary>
|
||||
public List<CustomerTrendPointResponse> Trend { get; set; } = [];
|
||||
|
||||
/// <summary>
|
||||
/// 最近订单(最多 5 条)。
|
||||
/// </summary>
|
||||
public List<CustomerRecentOrderResponse> RecentOrders { get; set; } = [];
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 客户导出响应。
|
||||
/// </summary>
|
||||
public sealed class CustomerExportResponse
|
||||
{
|
||||
/// <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,285 @@
|
||||
namespace TakeoutSaaS.TenantApi.Contracts.Finance;
|
||||
|
||||
/// <summary>
|
||||
/// 经营报表列表请求。
|
||||
/// </summary>
|
||||
public sealed class FinanceBusinessReportListRequest
|
||||
{
|
||||
/// <summary>
|
||||
/// 门店 ID。
|
||||
/// </summary>
|
||||
public string StoreId { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// 周期类型(daily/weekly/monthly)。
|
||||
/// </summary>
|
||||
public string? PeriodType { get; set; } = "daily";
|
||||
|
||||
/// <summary>
|
||||
/// 页码。
|
||||
/// </summary>
|
||||
public int Page { get; set; } = 1;
|
||||
|
||||
/// <summary>
|
||||
/// 每页条数。
|
||||
/// </summary>
|
||||
public int PageSize { get; set; } = 20;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 经营报表详情请求。
|
||||
/// </summary>
|
||||
public sealed class FinanceBusinessReportDetailRequest
|
||||
{
|
||||
/// <summary>
|
||||
/// 门店 ID。
|
||||
/// </summary>
|
||||
public string StoreId { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// 报表 ID。
|
||||
/// </summary>
|
||||
public string ReportId { get; set; } = string.Empty;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 经营报表批量导出请求。
|
||||
/// </summary>
|
||||
public sealed class FinanceBusinessReportBatchExportRequest
|
||||
{
|
||||
/// <summary>
|
||||
/// 门店 ID。
|
||||
/// </summary>
|
||||
public string StoreId { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// 周期类型(daily/weekly/monthly)。
|
||||
/// </summary>
|
||||
public string? PeriodType { get; set; } = "daily";
|
||||
|
||||
/// <summary>
|
||||
/// 页码。
|
||||
/// </summary>
|
||||
public int Page { get; set; } = 1;
|
||||
|
||||
/// <summary>
|
||||
/// 每页条数。
|
||||
/// </summary>
|
||||
public int PageSize { get; set; } = 20;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 经营报表列表行响应。
|
||||
/// </summary>
|
||||
public sealed class FinanceBusinessReportListItemResponse
|
||||
{
|
||||
/// <summary>
|
||||
/// 报表 ID。
|
||||
/// </summary>
|
||||
public string ReportId { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// 日期文案。
|
||||
/// </summary>
|
||||
public string DateText { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// 营业额。
|
||||
/// </summary>
|
||||
public decimal RevenueAmount { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 订单数。
|
||||
/// </summary>
|
||||
public int OrderCount { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 客单价。
|
||||
/// </summary>
|
||||
public decimal AverageOrderValue { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 退款率(百分数)。
|
||||
/// </summary>
|
||||
public decimal RefundRatePercent { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 成本总额。
|
||||
/// </summary>
|
||||
public decimal CostTotalAmount { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 净利润。
|
||||
/// </summary>
|
||||
public decimal NetProfitAmount { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 利润率(百分数)。
|
||||
/// </summary>
|
||||
public decimal ProfitRatePercent { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 状态编码。
|
||||
/// </summary>
|
||||
public string Status { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// 状态文案。
|
||||
/// </summary>
|
||||
public string StatusText { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// 是否可下载。
|
||||
/// </summary>
|
||||
public bool CanDownload { get; set; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 经营报表列表响应。
|
||||
/// </summary>
|
||||
public sealed class FinanceBusinessReportListResultResponse
|
||||
{
|
||||
/// <summary>
|
||||
/// 列表。
|
||||
/// </summary>
|
||||
public List<FinanceBusinessReportListItemResponse> 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>
|
||||
/// KPI 响应项。
|
||||
/// </summary>
|
||||
public sealed class FinanceBusinessReportKpiResponse
|
||||
{
|
||||
/// <summary>
|
||||
/// 指标键。
|
||||
/// </summary>
|
||||
public string Key { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// 指标名称。
|
||||
/// </summary>
|
||||
public string Label { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// 指标值文案。
|
||||
/// </summary>
|
||||
public string ValueText { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// 同比变化率(百分数)。
|
||||
/// </summary>
|
||||
public decimal YoyChangeRate { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 环比变化率(百分数)。
|
||||
/// </summary>
|
||||
public decimal MomChangeRate { get; set; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 明细行响应项。
|
||||
/// </summary>
|
||||
public sealed class FinanceBusinessReportBreakdownItemResponse
|
||||
{
|
||||
/// <summary>
|
||||
/// 明细键。
|
||||
/// </summary>
|
||||
public string Key { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// 明细名称。
|
||||
/// </summary>
|
||||
public string Label { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// 金额。
|
||||
/// </summary>
|
||||
public decimal Amount { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 占比(百分数)。
|
||||
/// </summary>
|
||||
public decimal RatioPercent { get; set; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 经营报表详情响应。
|
||||
/// </summary>
|
||||
public sealed class FinanceBusinessReportDetailResponse
|
||||
{
|
||||
/// <summary>
|
||||
/// 报表 ID。
|
||||
/// </summary>
|
||||
public string ReportId { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// 标题。
|
||||
/// </summary>
|
||||
public string Title { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// 周期类型编码。
|
||||
/// </summary>
|
||||
public string PeriodType { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// 状态编码。
|
||||
/// </summary>
|
||||
public string Status { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// 状态文案。
|
||||
/// </summary>
|
||||
public string StatusText { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// KPI 列表。
|
||||
/// </summary>
|
||||
public List<FinanceBusinessReportKpiResponse> Kpis { get; set; } = [];
|
||||
|
||||
/// <summary>
|
||||
/// 收入明细(按渠道)。
|
||||
/// </summary>
|
||||
public List<FinanceBusinessReportBreakdownItemResponse> IncomeBreakdowns { get; set; } = [];
|
||||
|
||||
/// <summary>
|
||||
/// 成本明细(按类别)。
|
||||
/// </summary>
|
||||
public List<FinanceBusinessReportBreakdownItemResponse> CostBreakdowns { get; set; } = [];
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 经营报表导出响应。
|
||||
/// </summary>
|
||||
public sealed class FinanceBusinessReportExportResponse
|
||||
{
|
||||
/// <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,384 @@
|
||||
namespace TakeoutSaaS.TenantApi.Contracts.Finance;
|
||||
|
||||
/// <summary>
|
||||
/// 成本模块通用作用域请求。
|
||||
/// </summary>
|
||||
public class FinanceCostScopeRequest
|
||||
{
|
||||
/// <summary>
|
||||
/// 维度(tenant/store)。
|
||||
/// </summary>
|
||||
public string? Dimension { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 门店标识(门店维度必填)。
|
||||
/// </summary>
|
||||
public string? StoreId { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 月份(yyyy-MM)。
|
||||
/// </summary>
|
||||
public string? Month { get; set; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 成本录入查询请求。
|
||||
/// </summary>
|
||||
public sealed class FinanceCostEntryRequest : FinanceCostScopeRequest;
|
||||
|
||||
/// <summary>
|
||||
/// 成本分析查询请求。
|
||||
/// </summary>
|
||||
public sealed class FinanceCostAnalysisRequest : FinanceCostScopeRequest
|
||||
{
|
||||
/// <summary>
|
||||
/// 趋势月份数量。
|
||||
/// </summary>
|
||||
public int TrendMonthCount { get; set; } = 6;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 成本录入保存请求。
|
||||
/// </summary>
|
||||
public sealed class SaveFinanceCostEntryRequest : FinanceCostScopeRequest
|
||||
{
|
||||
/// <summary>
|
||||
/// 分类列表。
|
||||
/// </summary>
|
||||
public List<SaveFinanceCostCategoryRequest> Categories { get; set; } = [];
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 成本分类保存项请求。
|
||||
/// </summary>
|
||||
public sealed class SaveFinanceCostCategoryRequest
|
||||
{
|
||||
/// <summary>
|
||||
/// 分类编码(food/labor/fixed/packaging)。
|
||||
/// </summary>
|
||||
public string Category { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// 分类总金额。
|
||||
/// </summary>
|
||||
public decimal TotalAmount { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 分类明细。
|
||||
/// </summary>
|
||||
public List<SaveFinanceCostDetailRequest> Items { get; set; } = [];
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 成本明细保存项请求。
|
||||
/// </summary>
|
||||
public sealed class SaveFinanceCostDetailRequest
|
||||
{
|
||||
/// <summary>
|
||||
/// 明细标识(可空)。
|
||||
/// </summary>
|
||||
public string? ItemId { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 明细名称。
|
||||
/// </summary>
|
||||
public string ItemName { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// 明细金额。
|
||||
/// </summary>
|
||||
public decimal Amount { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 数量(人工类可用)。
|
||||
/// </summary>
|
||||
public decimal? Quantity { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 单价(人工类可用)。
|
||||
/// </summary>
|
||||
public decimal? UnitPrice { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 排序值。
|
||||
/// </summary>
|
||||
public int SortOrder { get; set; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 成本录入响应。
|
||||
/// </summary>
|
||||
public sealed class FinanceCostEntryResponse
|
||||
{
|
||||
/// <summary>
|
||||
/// 维度编码。
|
||||
/// </summary>
|
||||
public string Dimension { get; set; } = "tenant";
|
||||
|
||||
/// <summary>
|
||||
/// 门店标识(门店维度时有值)。
|
||||
/// </summary>
|
||||
public string? StoreId { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 月份(yyyy-MM)。
|
||||
/// </summary>
|
||||
public string Month { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// 本月营业额。
|
||||
/// </summary>
|
||||
public decimal MonthRevenue { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 本月总成本。
|
||||
/// </summary>
|
||||
public decimal TotalCost { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 本月成本率(%)。
|
||||
/// </summary>
|
||||
public decimal CostRate { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 分类数据。
|
||||
/// </summary>
|
||||
public List<FinanceCostEntryCategoryResponse> Categories { get; set; } = [];
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 成本分类响应。
|
||||
/// </summary>
|
||||
public sealed class FinanceCostEntryCategoryResponse
|
||||
{
|
||||
/// <summary>
|
||||
/// 分类编码。
|
||||
/// </summary>
|
||||
public string Category { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// 分类文案。
|
||||
/// </summary>
|
||||
public string CategoryText { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// 分类总金额。
|
||||
/// </summary>
|
||||
public decimal TotalAmount { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 分类占比(%)。
|
||||
/// </summary>
|
||||
public decimal Percentage { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 明细数据。
|
||||
/// </summary>
|
||||
public List<FinanceCostEntryDetailResponse> Items { get; set; } = [];
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 成本明细响应。
|
||||
/// </summary>
|
||||
public sealed class FinanceCostEntryDetailResponse
|
||||
{
|
||||
/// <summary>
|
||||
/// 明细标识。
|
||||
/// </summary>
|
||||
public string? ItemId { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 明细名称。
|
||||
/// </summary>
|
||||
public string ItemName { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// 明细金额。
|
||||
/// </summary>
|
||||
public decimal Amount { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 数量(人工类可用)。
|
||||
/// </summary>
|
||||
public decimal? Quantity { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 单价(人工类可用)。
|
||||
/// </summary>
|
||||
public decimal? UnitPrice { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 排序值。
|
||||
/// </summary>
|
||||
public int SortOrder { get; set; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 成本分析响应。
|
||||
/// </summary>
|
||||
public sealed class FinanceCostAnalysisResponse
|
||||
{
|
||||
/// <summary>
|
||||
/// 维度编码。
|
||||
/// </summary>
|
||||
public string Dimension { get; set; } = "tenant";
|
||||
|
||||
/// <summary>
|
||||
/// 门店标识(门店维度时有值)。
|
||||
/// </summary>
|
||||
public string? StoreId { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 月份(yyyy-MM)。
|
||||
/// </summary>
|
||||
public string Month { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// 统计卡。
|
||||
/// </summary>
|
||||
public FinanceCostAnalysisStatsResponse Stats { get; set; } = new();
|
||||
|
||||
/// <summary>
|
||||
/// 趋势数据。
|
||||
/// </summary>
|
||||
public List<FinanceCostTrendPointResponse> Trend { get; set; } = [];
|
||||
|
||||
/// <summary>
|
||||
/// 构成数据。
|
||||
/// </summary>
|
||||
public List<FinanceCostCompositionResponse> Composition { get; set; } = [];
|
||||
|
||||
/// <summary>
|
||||
/// 明细表数据。
|
||||
/// </summary>
|
||||
public List<FinanceCostMonthlyDetailResponse> DetailRows { get; set; } = [];
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 成本分析统计卡响应。
|
||||
/// </summary>
|
||||
public sealed class FinanceCostAnalysisStatsResponse
|
||||
{
|
||||
/// <summary>
|
||||
/// 本月总成本。
|
||||
/// </summary>
|
||||
public decimal TotalCost { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 食材成本率(%)。
|
||||
/// </summary>
|
||||
public decimal FoodCostRate { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 单均成本。
|
||||
/// </summary>
|
||||
public decimal AverageCostPerPaidOrder { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 环比变化(%)。
|
||||
/// </summary>
|
||||
public decimal MonthOnMonthChangeRate { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 本月营业额。
|
||||
/// </summary>
|
||||
public decimal Revenue { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 本月支付成功订单数。
|
||||
/// </summary>
|
||||
public int PaidOrderCount { get; set; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 成本趋势点响应。
|
||||
/// </summary>
|
||||
public sealed class FinanceCostTrendPointResponse
|
||||
{
|
||||
/// <summary>
|
||||
/// 月份(yyyy-MM)。
|
||||
/// </summary>
|
||||
public string Month { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// 月度总成本。
|
||||
/// </summary>
|
||||
public decimal TotalCost { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 月度营业额。
|
||||
/// </summary>
|
||||
public decimal Revenue { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 月度成本率(%)。
|
||||
/// </summary>
|
||||
public decimal CostRate { get; set; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 成本构成响应。
|
||||
/// </summary>
|
||||
public sealed class FinanceCostCompositionResponse
|
||||
{
|
||||
/// <summary>
|
||||
/// 分类编码。
|
||||
/// </summary>
|
||||
public string Category { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// 分类文案。
|
||||
/// </summary>
|
||||
public string CategoryText { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// 金额。
|
||||
/// </summary>
|
||||
public decimal Amount { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 占比(%)。
|
||||
/// </summary>
|
||||
public decimal Percentage { get; set; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 成本分析明细表行响应。
|
||||
/// </summary>
|
||||
public sealed class FinanceCostMonthlyDetailResponse
|
||||
{
|
||||
/// <summary>
|
||||
/// 月份(yyyy-MM)。
|
||||
/// </summary>
|
||||
public string Month { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// 食材成本。
|
||||
/// </summary>
|
||||
public decimal FoodAmount { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 人工成本。
|
||||
/// </summary>
|
||||
public decimal LaborAmount { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 固定费用。
|
||||
/// </summary>
|
||||
public decimal FixedAmount { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 包装耗材。
|
||||
/// </summary>
|
||||
public decimal PackagingAmount { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 总成本。
|
||||
/// </summary>
|
||||
public decimal TotalCost { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 成本率(%)。
|
||||
/// </summary>
|
||||
public decimal CostRate { get; set; }
|
||||
}
|
||||
@@ -0,0 +1,533 @@
|
||||
namespace TakeoutSaaS.TenantApi.Contracts.Finance;
|
||||
|
||||
/// <summary>
|
||||
/// 保存发票设置请求。
|
||||
/// </summary>
|
||||
public sealed class FinanceInvoiceSettingSaveRequest
|
||||
{
|
||||
/// <summary>
|
||||
/// 企业名称。
|
||||
/// </summary>
|
||||
public string CompanyName { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// 纳税人识别号。
|
||||
/// </summary>
|
||||
public string TaxpayerNumber { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// 注册地址。
|
||||
/// </summary>
|
||||
public string? RegisteredAddress { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 注册电话。
|
||||
/// </summary>
|
||||
public string? RegisteredPhone { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 开户银行。
|
||||
/// </summary>
|
||||
public string? BankName { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 银行账号。
|
||||
/// </summary>
|
||||
public string? BankAccount { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 是否启用电子普通发票。
|
||||
/// </summary>
|
||||
public bool EnableElectronicNormalInvoice { get; set; } = true;
|
||||
|
||||
/// <summary>
|
||||
/// 是否启用电子专用发票。
|
||||
/// </summary>
|
||||
public bool EnableElectronicSpecialInvoice { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 是否启用自动开票。
|
||||
/// </summary>
|
||||
public bool EnableAutoIssue { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 自动开票单张最大金额。
|
||||
/// </summary>
|
||||
public decimal AutoIssueMaxAmount { get; set; } = 10_000m;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 发票记录列表请求。
|
||||
/// </summary>
|
||||
public sealed class FinanceInvoiceRecordListRequest
|
||||
{
|
||||
/// <summary>
|
||||
/// 开始日期(yyyy-MM-dd)。
|
||||
/// </summary>
|
||||
public string? StartDate { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 结束日期(yyyy-MM-dd)。
|
||||
/// </summary>
|
||||
public string? EndDate { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 状态(pending/issued/voided)。
|
||||
/// </summary>
|
||||
public string? Status { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 类型(normal/special)。
|
||||
/// </summary>
|
||||
public string? InvoiceType { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 关键词(发票号/公司名/申请人)。
|
||||
/// </summary>
|
||||
public string? Keyword { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 页码。
|
||||
/// </summary>
|
||||
public int Page { get; set; } = 1;
|
||||
|
||||
/// <summary>
|
||||
/// 每页条数。
|
||||
/// </summary>
|
||||
public int PageSize { get; set; } = 10;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 发票记录详情请求。
|
||||
/// </summary>
|
||||
public sealed class FinanceInvoiceRecordDetailRequest
|
||||
{
|
||||
/// <summary>
|
||||
/// 发票记录 ID。
|
||||
/// </summary>
|
||||
public string RecordId { get; set; } = string.Empty;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 发票开票请求。
|
||||
/// </summary>
|
||||
public sealed class FinanceInvoiceRecordIssueRequest
|
||||
{
|
||||
/// <summary>
|
||||
/// 发票记录 ID。
|
||||
/// </summary>
|
||||
public string RecordId { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// 接收邮箱(可选)。
|
||||
/// </summary>
|
||||
public string? ContactEmail { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 开票备注。
|
||||
/// </summary>
|
||||
public string? IssueRemark { get; set; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 发票作废请求。
|
||||
/// </summary>
|
||||
public sealed class FinanceInvoiceRecordVoidRequest
|
||||
{
|
||||
/// <summary>
|
||||
/// 发票记录 ID。
|
||||
/// </summary>
|
||||
public string RecordId { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// 作废原因。
|
||||
/// </summary>
|
||||
public string VoidReason { get; set; } = string.Empty;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 发票申请请求。
|
||||
/// </summary>
|
||||
public sealed class FinanceInvoiceRecordApplyRequest
|
||||
{
|
||||
/// <summary>
|
||||
/// 申请人。
|
||||
/// </summary>
|
||||
public string ApplicantName { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// 开票抬头(公司名)。
|
||||
/// </summary>
|
||||
public string CompanyName { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// 纳税人识别号。
|
||||
/// </summary>
|
||||
public string? TaxpayerNumber { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 发票类型(normal/special)。
|
||||
/// </summary>
|
||||
public string InvoiceType { get; set; } = "normal";
|
||||
|
||||
/// <summary>
|
||||
/// 开票金额。
|
||||
/// </summary>
|
||||
public decimal Amount { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 关联订单号。
|
||||
/// </summary>
|
||||
public string OrderNo { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// 接收邮箱。
|
||||
/// </summary>
|
||||
public string? ContactEmail { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 联系电话。
|
||||
/// </summary>
|
||||
public string? ContactPhone { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 申请备注。
|
||||
/// </summary>
|
||||
public string? ApplyRemark { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 申请时间(可空)。
|
||||
/// </summary>
|
||||
public DateTime? AppliedAt { get; set; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 发票设置响应。
|
||||
/// </summary>
|
||||
public sealed class FinanceInvoiceSettingResponse
|
||||
{
|
||||
/// <summary>
|
||||
/// 企业名称。
|
||||
/// </summary>
|
||||
public string CompanyName { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// 纳税人识别号。
|
||||
/// </summary>
|
||||
public string TaxpayerNumber { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// 注册地址。
|
||||
/// </summary>
|
||||
public string? RegisteredAddress { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 注册电话。
|
||||
/// </summary>
|
||||
public string? RegisteredPhone { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 开户银行。
|
||||
/// </summary>
|
||||
public string? BankName { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 银行账号。
|
||||
/// </summary>
|
||||
public string? BankAccount { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 是否启用电子普通发票。
|
||||
/// </summary>
|
||||
public bool EnableElectronicNormalInvoice { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 是否启用电子专用发票。
|
||||
/// </summary>
|
||||
public bool EnableElectronicSpecialInvoice { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 是否启用自动开票。
|
||||
/// </summary>
|
||||
public bool EnableAutoIssue { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 自动开票单张最大金额。
|
||||
/// </summary>
|
||||
public decimal AutoIssueMaxAmount { get; set; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 发票统计响应。
|
||||
/// </summary>
|
||||
public sealed class FinanceInvoiceStatsResponse
|
||||
{
|
||||
/// <summary>
|
||||
/// 本月已开票金额。
|
||||
/// </summary>
|
||||
public decimal CurrentMonthIssuedAmount { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 本月已开票张数。
|
||||
/// </summary>
|
||||
public int CurrentMonthIssuedCount { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 待开票数量。
|
||||
/// </summary>
|
||||
public int PendingCount { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 已作废数量。
|
||||
/// </summary>
|
||||
public int VoidedCount { get; set; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 发票记录列表项响应。
|
||||
/// </summary>
|
||||
public sealed class FinanceInvoiceRecordResponse
|
||||
{
|
||||
/// <summary>
|
||||
/// 记录 ID。
|
||||
/// </summary>
|
||||
public string RecordId { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// 发票号码。
|
||||
/// </summary>
|
||||
public string InvoiceNo { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// 申请人。
|
||||
/// </summary>
|
||||
public string ApplicantName { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// 开票抬头(公司名)。
|
||||
/// </summary>
|
||||
public string CompanyName { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// 发票类型编码。
|
||||
/// </summary>
|
||||
public string InvoiceType { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// 发票类型文案。
|
||||
/// </summary>
|
||||
public string InvoiceTypeText { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// 金额。
|
||||
/// </summary>
|
||||
public decimal Amount { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 关联订单号。
|
||||
/// </summary>
|
||||
public string OrderNo { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// 状态编码。
|
||||
/// </summary>
|
||||
public string Status { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// 状态文案。
|
||||
/// </summary>
|
||||
public string StatusText { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// 申请时间(本地显示字符串)。
|
||||
/// </summary>
|
||||
public string AppliedAt { get; set; } = string.Empty;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 发票记录详情响应。
|
||||
/// </summary>
|
||||
public sealed class FinanceInvoiceRecordDetailResponse
|
||||
{
|
||||
/// <summary>
|
||||
/// 记录 ID。
|
||||
/// </summary>
|
||||
public string RecordId { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// 发票号码。
|
||||
/// </summary>
|
||||
public string InvoiceNo { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// 申请人。
|
||||
/// </summary>
|
||||
public string ApplicantName { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// 开票抬头(公司名)。
|
||||
/// </summary>
|
||||
public string CompanyName { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// 纳税人识别号。
|
||||
/// </summary>
|
||||
public string? TaxpayerNumber { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 发票类型编码。
|
||||
/// </summary>
|
||||
public string InvoiceType { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// 发票类型文案。
|
||||
/// </summary>
|
||||
public string InvoiceTypeText { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// 金额。
|
||||
/// </summary>
|
||||
public decimal Amount { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 关联订单号。
|
||||
/// </summary>
|
||||
public string OrderNo { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// 接收邮箱。
|
||||
/// </summary>
|
||||
public string? ContactEmail { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 联系电话。
|
||||
/// </summary>
|
||||
public string? ContactPhone { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 申请备注。
|
||||
/// </summary>
|
||||
public string? ApplyRemark { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 状态编码。
|
||||
/// </summary>
|
||||
public string Status { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// 状态文案。
|
||||
/// </summary>
|
||||
public string StatusText { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// 申请时间(本地显示字符串)。
|
||||
/// </summary>
|
||||
public string AppliedAt { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// 开票时间(本地显示字符串)。
|
||||
/// </summary>
|
||||
public string? IssuedAt { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 开票人 ID。
|
||||
/// </summary>
|
||||
public string? IssuedByUserId { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 开票备注。
|
||||
/// </summary>
|
||||
public string? IssueRemark { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 作废时间(本地显示字符串)。
|
||||
/// </summary>
|
||||
public string? VoidedAt { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 作废人 ID。
|
||||
/// </summary>
|
||||
public string? VoidedByUserId { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 作废原因。
|
||||
/// </summary>
|
||||
public string? VoidReason { get; set; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 发票开票结果响应。
|
||||
/// </summary>
|
||||
public sealed class FinanceInvoiceIssueResultResponse
|
||||
{
|
||||
/// <summary>
|
||||
/// 记录 ID。
|
||||
/// </summary>
|
||||
public string RecordId { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// 发票号码。
|
||||
/// </summary>
|
||||
public string InvoiceNo { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// 开票抬头。
|
||||
/// </summary>
|
||||
public string CompanyName { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// 金额。
|
||||
/// </summary>
|
||||
public decimal Amount { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 接收邮箱。
|
||||
/// </summary>
|
||||
public string? ContactEmail { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 开票时间(本地显示字符串)。
|
||||
/// </summary>
|
||||
public string IssuedAt { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// 状态编码。
|
||||
/// </summary>
|
||||
public string Status { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// 状态文案。
|
||||
/// </summary>
|
||||
public string StatusText { get; set; } = string.Empty;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 发票记录分页响应。
|
||||
/// </summary>
|
||||
public sealed class FinanceInvoiceRecordListResultResponse
|
||||
{
|
||||
/// <summary>
|
||||
/// 列表项。
|
||||
/// </summary>
|
||||
public List<FinanceInvoiceRecordResponse> 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 FinanceInvoiceStatsResponse Stats { get; set; } = new();
|
||||
}
|
||||
@@ -0,0 +1,329 @@
|
||||
namespace TakeoutSaaS.TenantApi.Contracts.Finance;
|
||||
|
||||
/// <summary>
|
||||
/// 财务概览查询请求。
|
||||
/// </summary>
|
||||
public sealed class FinanceOverviewDashboardRequest
|
||||
{
|
||||
/// <summary>
|
||||
/// 维度(tenant/store)。
|
||||
/// </summary>
|
||||
public string? Dimension { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 门店 ID(门店维度必填)。
|
||||
/// </summary>
|
||||
public string? StoreId { get; set; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 财务概览指标卡响应。
|
||||
/// </summary>
|
||||
public sealed class FinanceOverviewKpiCardResponse
|
||||
{
|
||||
/// <summary>
|
||||
/// 指标值。
|
||||
/// </summary>
|
||||
public decimal Amount { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 对比值。
|
||||
/// </summary>
|
||||
public decimal CompareAmount { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 变化率(%)。
|
||||
/// </summary>
|
||||
public decimal ChangeRate { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 趋势(up/down/flat)。
|
||||
/// </summary>
|
||||
public string Trend { get; set; } = "flat";
|
||||
|
||||
/// <summary>
|
||||
/// 对比文案。
|
||||
/// </summary>
|
||||
public string CompareLabel { get; set; } = "较昨日";
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 收入趋势点响应。
|
||||
/// </summary>
|
||||
public sealed class FinanceOverviewIncomeTrendPointResponse
|
||||
{
|
||||
/// <summary>
|
||||
/// 日期(yyyy-MM-dd)。
|
||||
/// </summary>
|
||||
public string Date { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// 轴标签(MM/dd)。
|
||||
/// </summary>
|
||||
public string DateLabel { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// 实收金额。
|
||||
/// </summary>
|
||||
public decimal Amount { get; set; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 收入趋势响应。
|
||||
/// </summary>
|
||||
public sealed class FinanceOverviewIncomeTrendResponse
|
||||
{
|
||||
/// <summary>
|
||||
/// 近 7 天。
|
||||
/// </summary>
|
||||
public List<FinanceOverviewIncomeTrendPointResponse> Last7Days { get; set; } = [];
|
||||
|
||||
/// <summary>
|
||||
/// 近 30 天。
|
||||
/// </summary>
|
||||
public List<FinanceOverviewIncomeTrendPointResponse> Last30Days { get; set; } = [];
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 利润趋势点响应。
|
||||
/// </summary>
|
||||
public sealed class FinanceOverviewProfitTrendPointResponse
|
||||
{
|
||||
/// <summary>
|
||||
/// 日期(yyyy-MM-dd)。
|
||||
/// </summary>
|
||||
public string Date { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// 轴标签(MM/dd)。
|
||||
/// </summary>
|
||||
public string DateLabel { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// 营收。
|
||||
/// </summary>
|
||||
public decimal RevenueAmount { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 成本。
|
||||
/// </summary>
|
||||
public decimal CostAmount { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 净利润。
|
||||
/// </summary>
|
||||
public decimal NetProfitAmount { get; set; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 利润趋势响应。
|
||||
/// </summary>
|
||||
public sealed class FinanceOverviewProfitTrendResponse
|
||||
{
|
||||
/// <summary>
|
||||
/// 近 7 天。
|
||||
/// </summary>
|
||||
public List<FinanceOverviewProfitTrendPointResponse> Last7Days { get; set; } = [];
|
||||
|
||||
/// <summary>
|
||||
/// 近 30 天。
|
||||
/// </summary>
|
||||
public List<FinanceOverviewProfitTrendPointResponse> Last30Days { get; set; } = [];
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 收入构成项响应。
|
||||
/// </summary>
|
||||
public sealed class FinanceOverviewIncomeCompositionItemResponse
|
||||
{
|
||||
/// <summary>
|
||||
/// 渠道编码。
|
||||
/// </summary>
|
||||
public string Channel { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// 渠道文案。
|
||||
/// </summary>
|
||||
public string ChannelText { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// 金额。
|
||||
/// </summary>
|
||||
public decimal Amount { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 占比(%)。
|
||||
/// </summary>
|
||||
public decimal Percentage { get; set; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 收入构成响应。
|
||||
/// </summary>
|
||||
public sealed class FinanceOverviewIncomeCompositionResponse
|
||||
{
|
||||
/// <summary>
|
||||
/// 总实收。
|
||||
/// </summary>
|
||||
public decimal TotalAmount { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 构成项。
|
||||
/// </summary>
|
||||
public List<FinanceOverviewIncomeCompositionItemResponse> Items { get; set; } = [];
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 成本构成项响应。
|
||||
/// </summary>
|
||||
public sealed class FinanceOverviewCostCompositionItemResponse
|
||||
{
|
||||
/// <summary>
|
||||
/// 分类编码。
|
||||
/// </summary>
|
||||
public string Category { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// 分类文案。
|
||||
/// </summary>
|
||||
public string CategoryText { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// 金额。
|
||||
/// </summary>
|
||||
public decimal Amount { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 占比(%)。
|
||||
/// </summary>
|
||||
public decimal Percentage { get; set; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 成本构成响应。
|
||||
/// </summary>
|
||||
public sealed class FinanceOverviewCostCompositionResponse
|
||||
{
|
||||
/// <summary>
|
||||
/// 总成本。
|
||||
/// </summary>
|
||||
public decimal TotalAmount { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 构成项。
|
||||
/// </summary>
|
||||
public List<FinanceOverviewCostCompositionItemResponse> Items { get; set; } = [];
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// TOP 商品项响应。
|
||||
/// </summary>
|
||||
public sealed class FinanceOverviewTopProductItemResponse
|
||||
{
|
||||
/// <summary>
|
||||
/// 排名。
|
||||
/// </summary>
|
||||
public int Rank { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 商品名称。
|
||||
/// </summary>
|
||||
public string ProductName { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// 销量。
|
||||
/// </summary>
|
||||
public int SalesQuantity { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 营收金额。
|
||||
/// </summary>
|
||||
public decimal RevenueAmount { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 占比(%)。
|
||||
/// </summary>
|
||||
public decimal Percentage { get; set; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// TOP 商品响应。
|
||||
/// </summary>
|
||||
public sealed class FinanceOverviewTopProductResponse
|
||||
{
|
||||
/// <summary>
|
||||
/// 周期天数。
|
||||
/// </summary>
|
||||
public int PeriodDays { get; set; } = 30;
|
||||
|
||||
/// <summary>
|
||||
/// 排行项。
|
||||
/// </summary>
|
||||
public List<FinanceOverviewTopProductItemResponse> Items { get; set; } = [];
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 财务概览响应。
|
||||
/// </summary>
|
||||
public sealed class FinanceOverviewDashboardResponse
|
||||
{
|
||||
/// <summary>
|
||||
/// 维度编码。
|
||||
/// </summary>
|
||||
public string Dimension { get; set; } = "tenant";
|
||||
|
||||
/// <summary>
|
||||
/// 门店标识。
|
||||
/// </summary>
|
||||
public string? StoreId { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 今日营业额卡片。
|
||||
/// </summary>
|
||||
public FinanceOverviewKpiCardResponse TodayRevenue { get; set; } = new();
|
||||
|
||||
/// <summary>
|
||||
/// 实收卡片。
|
||||
/// </summary>
|
||||
public FinanceOverviewKpiCardResponse ActualReceived { get; set; } = new();
|
||||
|
||||
/// <summary>
|
||||
/// 退款卡片。
|
||||
/// </summary>
|
||||
public FinanceOverviewKpiCardResponse RefundAmount { get; set; } = new();
|
||||
|
||||
/// <summary>
|
||||
/// 净收入卡片。
|
||||
/// </summary>
|
||||
public FinanceOverviewKpiCardResponse NetIncome { get; set; } = new();
|
||||
|
||||
/// <summary>
|
||||
/// 可提现余额卡片。
|
||||
/// </summary>
|
||||
public FinanceOverviewKpiCardResponse WithdrawableBalance { get; set; } = new();
|
||||
|
||||
/// <summary>
|
||||
/// 收入趋势。
|
||||
/// </summary>
|
||||
public FinanceOverviewIncomeTrendResponse IncomeTrend { get; set; } = new();
|
||||
|
||||
/// <summary>
|
||||
/// 利润趋势。
|
||||
/// </summary>
|
||||
public FinanceOverviewProfitTrendResponse ProfitTrend { get; set; } = new();
|
||||
|
||||
/// <summary>
|
||||
/// 收入构成。
|
||||
/// </summary>
|
||||
public FinanceOverviewIncomeCompositionResponse IncomeComposition { get; set; } = new();
|
||||
|
||||
/// <summary>
|
||||
/// 成本构成。
|
||||
/// </summary>
|
||||
public FinanceOverviewCostCompositionResponse CostComposition { get; set; } = new();
|
||||
|
||||
/// <summary>
|
||||
/// TOP 商品排行。
|
||||
/// </summary>
|
||||
public FinanceOverviewTopProductResponse TopProducts { get; set; } = new();
|
||||
}
|
||||
@@ -0,0 +1,247 @@
|
||||
namespace TakeoutSaaS.TenantApi.Contracts.Finance;
|
||||
|
||||
/// <summary>
|
||||
/// 到账统计请求。
|
||||
/// </summary>
|
||||
public sealed class FinanceSettlementStatsRequest
|
||||
{
|
||||
/// <summary>
|
||||
/// 门店 ID。
|
||||
/// </summary>
|
||||
public string StoreId { get; set; } = string.Empty;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 到账筛选请求。
|
||||
/// </summary>
|
||||
public class FinanceSettlementFilterRequest
|
||||
{
|
||||
/// <summary>
|
||||
/// 门店 ID。
|
||||
/// </summary>
|
||||
public string StoreId { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// 开始日期(yyyy-MM-dd)。
|
||||
/// </summary>
|
||||
public string? StartDate { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 结束日期(yyyy-MM-dd)。
|
||||
/// </summary>
|
||||
public string? EndDate { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 渠道(wechat/alipay)。
|
||||
/// </summary>
|
||||
public string? Channel { get; set; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 到账列表请求。
|
||||
/// </summary>
|
||||
public sealed class FinanceSettlementListRequest : FinanceSettlementFilterRequest
|
||||
{
|
||||
/// <summary>
|
||||
/// 页码。
|
||||
/// </summary>
|
||||
public int Page { get; set; } = 1;
|
||||
|
||||
/// <summary>
|
||||
/// 每页条数。
|
||||
/// </summary>
|
||||
public int PageSize { get; set; } = 20;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 到账明细请求。
|
||||
/// </summary>
|
||||
public sealed class FinanceSettlementDetailRequest
|
||||
{
|
||||
/// <summary>
|
||||
/// 门店 ID。
|
||||
/// </summary>
|
||||
public string StoreId { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// 到账日期(yyyy-MM-dd)。
|
||||
/// </summary>
|
||||
public string ArrivedDate { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// 渠道(wechat/alipay)。
|
||||
/// </summary>
|
||||
public string Channel { get; set; } = string.Empty;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 到账统计响应。
|
||||
/// </summary>
|
||||
public sealed class FinanceSettlementStatsResponse
|
||||
{
|
||||
/// <summary>
|
||||
/// 今日到账。
|
||||
/// </summary>
|
||||
public decimal TodayArrivedAmount { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 昨日到账。
|
||||
/// </summary>
|
||||
public decimal YesterdayArrivedAmount { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 本月到账。
|
||||
/// </summary>
|
||||
public decimal CurrentMonthArrivedAmount { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 本月交易笔数。
|
||||
/// </summary>
|
||||
public int CurrentMonthTransactionCount { get; set; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 到账账户信息响应。
|
||||
/// </summary>
|
||||
public sealed class FinanceSettlementAccountResponse
|
||||
{
|
||||
/// <summary>
|
||||
/// 银行名称。
|
||||
/// </summary>
|
||||
public string BankName { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// 开户名。
|
||||
/// </summary>
|
||||
public string BankAccountName { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// 脱敏银行账号。
|
||||
/// </summary>
|
||||
public string BankAccountNoMasked { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// 脱敏微信商户号。
|
||||
/// </summary>
|
||||
public string WechatMerchantNoMasked { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// 脱敏支付宝 PID。
|
||||
/// </summary>
|
||||
public string AlipayPidMasked { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// 结算周期文案。
|
||||
/// </summary>
|
||||
public string SettlementPeriodText { get; set; } = string.Empty;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 到账列表行响应。
|
||||
/// </summary>
|
||||
public sealed class FinanceSettlementListItemResponse
|
||||
{
|
||||
/// <summary>
|
||||
/// 到账日期。
|
||||
/// </summary>
|
||||
public string ArrivedDate { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// 渠道编码。
|
||||
/// </summary>
|
||||
public string Channel { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// 渠道文案。
|
||||
/// </summary>
|
||||
public string ChannelText { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// 交易笔数。
|
||||
/// </summary>
|
||||
public int TransactionCount { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 到账金额。
|
||||
/// </summary>
|
||||
public decimal ArrivedAmount { get; set; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 到账列表响应。
|
||||
/// </summary>
|
||||
public sealed class FinanceSettlementListResultResponse
|
||||
{
|
||||
/// <summary>
|
||||
/// 列表项。
|
||||
/// </summary>
|
||||
public List<FinanceSettlementListItemResponse> 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 FinanceSettlementDetailItemResponse
|
||||
{
|
||||
/// <summary>
|
||||
/// 订单号。
|
||||
/// </summary>
|
||||
public string OrderNo { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// 金额。
|
||||
/// </summary>
|
||||
public decimal Amount { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 支付时间。
|
||||
/// </summary>
|
||||
public string PaidAt { get; set; } = string.Empty;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 到账明细响应。
|
||||
/// </summary>
|
||||
public sealed class FinanceSettlementDetailResultResponse
|
||||
{
|
||||
/// <summary>
|
||||
/// 明细列表。
|
||||
/// </summary>
|
||||
public List<FinanceSettlementDetailItemResponse> Items { get; set; } = [];
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 到账导出响应。
|
||||
/// </summary>
|
||||
public sealed class FinanceSettlementExportResponse
|
||||
{
|
||||
/// <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,329 @@
|
||||
namespace TakeoutSaaS.TenantApi.Contracts.Finance;
|
||||
|
||||
/// <summary>
|
||||
/// 交易流水筛选请求。
|
||||
/// </summary>
|
||||
public class FinanceTransactionFilterRequest
|
||||
{
|
||||
/// <summary>
|
||||
/// 门店 ID。
|
||||
/// </summary>
|
||||
public string StoreId { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// 开始日期(yyyy-MM-dd)。
|
||||
/// </summary>
|
||||
public string? StartDate { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 结束日期(yyyy-MM-dd)。
|
||||
/// </summary>
|
||||
public string? EndDate { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 交易类型(income/refund/stored_card_recharge/point_redeem)。
|
||||
/// </summary>
|
||||
public string? Type { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 渠道(delivery/pickup/dine_in)。
|
||||
/// </summary>
|
||||
public string? Channel { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 支付方式(wechat/alipay/cash/card/balance)。
|
||||
/// </summary>
|
||||
public string? PaymentMethod { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 关键词(流水号/订单号)。
|
||||
/// </summary>
|
||||
public string? Keyword { get; set; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 交易流水列表请求。
|
||||
/// </summary>
|
||||
public sealed class FinanceTransactionListRequest : FinanceTransactionFilterRequest
|
||||
{
|
||||
/// <summary>
|
||||
/// 页码。
|
||||
/// </summary>
|
||||
public int Page { get; set; } = 1;
|
||||
|
||||
/// <summary>
|
||||
/// 每页条数。
|
||||
/// </summary>
|
||||
public int PageSize { get; set; } = 20;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 交易流水详情请求。
|
||||
/// </summary>
|
||||
public sealed class FinanceTransactionDetailRequest
|
||||
{
|
||||
/// <summary>
|
||||
/// 门店 ID。
|
||||
/// </summary>
|
||||
public string StoreId { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// 交易标识(sourceType:sourceId)。
|
||||
/// </summary>
|
||||
public string TransactionId { get; set; } = string.Empty;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 交易流水列表结果。
|
||||
/// </summary>
|
||||
public sealed class FinanceTransactionListResultResponse
|
||||
{
|
||||
/// <summary>
|
||||
/// 列表。
|
||||
/// </summary>
|
||||
public List<FinanceTransactionListItemResponse> 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 decimal PageIncomeAmount { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 本页退款。
|
||||
/// </summary>
|
||||
public decimal PageRefundAmount { get; set; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 交易流水行。
|
||||
/// </summary>
|
||||
public sealed class FinanceTransactionListItemResponse
|
||||
{
|
||||
/// <summary>
|
||||
/// 交易标识。
|
||||
/// </summary>
|
||||
public string TransactionId { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// 流水号。
|
||||
/// </summary>
|
||||
public string TransactionNo { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// 关联订单号。
|
||||
/// </summary>
|
||||
public string? OrderNo { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 类型编码。
|
||||
/// </summary>
|
||||
public string Type { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// 类型文案。
|
||||
/// </summary>
|
||||
public string TypeText { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// 渠道文案。
|
||||
/// </summary>
|
||||
public string Channel { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// 支付方式文案。
|
||||
/// </summary>
|
||||
public string PaymentMethod { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// 交易金额。
|
||||
/// </summary>
|
||||
public decimal Amount { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 交易时间。
|
||||
/// </summary>
|
||||
public string OccurredAt { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// 备注。
|
||||
/// </summary>
|
||||
public string Remark { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// 是否收入。
|
||||
/// </summary>
|
||||
public bool IsIncome { get; set; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 交易流水统计结果。
|
||||
/// </summary>
|
||||
public sealed class FinanceTransactionStatsResponse
|
||||
{
|
||||
/// <summary>
|
||||
/// 总收入。
|
||||
/// </summary>
|
||||
public decimal TotalIncome { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 总退款。
|
||||
/// </summary>
|
||||
public decimal TotalRefund { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 总笔数。
|
||||
/// </summary>
|
||||
public int TotalCount { get; set; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 交易流水详情。
|
||||
/// </summary>
|
||||
public sealed class FinanceTransactionDetailResponse
|
||||
{
|
||||
/// <summary>
|
||||
/// 交易标识。
|
||||
/// </summary>
|
||||
public string TransactionId { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// 流水号。
|
||||
/// </summary>
|
||||
public string TransactionNo { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// 类型编码。
|
||||
/// </summary>
|
||||
public string Type { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// 类型文案。
|
||||
/// </summary>
|
||||
public string TypeText { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// 门店 ID。
|
||||
/// </summary>
|
||||
public string StoreId { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// 关联订单号。
|
||||
/// </summary>
|
||||
public string? OrderNo { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 渠道文案。
|
||||
/// </summary>
|
||||
public string Channel { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// 支付方式文案。
|
||||
/// </summary>
|
||||
public string PaymentMethod { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// 交易金额。
|
||||
/// </summary>
|
||||
public decimal Amount { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 交易时间。
|
||||
/// </summary>
|
||||
public string OccurredAt { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// 备注。
|
||||
/// </summary>
|
||||
public string Remark { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// 顾客姓名。
|
||||
/// </summary>
|
||||
public string CustomerName { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// 顾客手机号。
|
||||
/// </summary>
|
||||
public string CustomerPhone { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// 退款单号。
|
||||
/// </summary>
|
||||
public string? RefundNo { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 退款原因。
|
||||
/// </summary>
|
||||
public string? RefundReason { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 会员名称。
|
||||
/// </summary>
|
||||
public string? MemberName { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 会员手机号。
|
||||
/// </summary>
|
||||
public string? MemberMobileMasked { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 充值金额。
|
||||
/// </summary>
|
||||
public decimal? RechargeAmount { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 赠送金额。
|
||||
/// </summary>
|
||||
public decimal? GiftAmount { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 到账金额。
|
||||
/// </summary>
|
||||
public decimal? ArrivedAmount { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 积分变动值。
|
||||
/// </summary>
|
||||
public int? PointChangeAmount { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 积分变动后余额。
|
||||
/// </summary>
|
||||
public int? PointBalanceAfterChange { get; set; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 交易流水导出结果。
|
||||
/// </summary>
|
||||
public sealed class FinanceTransactionExportResponse
|
||||
{
|
||||
/// <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,190 @@
|
||||
namespace TakeoutSaaS.TenantApi.Contracts.Marketing;
|
||||
|
||||
/// <summary>
|
||||
/// 营销日历总览查询请求。
|
||||
/// </summary>
|
||||
public sealed class MarketingCalendarOverviewRequest
|
||||
{
|
||||
/// <summary>
|
||||
/// 门店 ID。
|
||||
/// </summary>
|
||||
public string StoreId { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// 年份。
|
||||
/// </summary>
|
||||
public int Year { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 月份(1-12)。
|
||||
/// </summary>
|
||||
public int Month { get; set; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 营销日历总览响应。
|
||||
/// </summary>
|
||||
public sealed class MarketingCalendarOverviewResponse
|
||||
{
|
||||
public string Month { get; set; } = string.Empty;
|
||||
|
||||
public int Year { get; set; }
|
||||
|
||||
public int MonthValue { get; set; }
|
||||
|
||||
public string MonthStartDate { get; set; } = string.Empty;
|
||||
|
||||
public string MonthEndDate { get; set; } = string.Empty;
|
||||
|
||||
public int TodayDay { get; set; }
|
||||
|
||||
public List<MarketingCalendarDayResponse> Days { get; set; } = [];
|
||||
|
||||
public List<MarketingCalendarLegendResponse> Legends { get; set; } = [];
|
||||
|
||||
public MarketingCalendarStatsResponse Stats { get; set; } = new();
|
||||
|
||||
public MarketingCalendarConflictBannerResponse? ConflictBanner { get; set; }
|
||||
|
||||
public List<MarketingCalendarConflictResponse> Conflicts { get; set; } = [];
|
||||
|
||||
public List<MarketingCalendarActivityResponse> Activities { get; set; } = [];
|
||||
}
|
||||
|
||||
public sealed class MarketingCalendarDayResponse
|
||||
{
|
||||
public int Day { get; set; }
|
||||
|
||||
public bool IsWeekend { get; set; }
|
||||
|
||||
public bool IsToday { get; set; }
|
||||
}
|
||||
|
||||
public sealed class MarketingCalendarLegendResponse
|
||||
{
|
||||
public string Type { get; set; } = string.Empty;
|
||||
|
||||
public string Label { get; set; } = string.Empty;
|
||||
|
||||
public string Color { get; set; } = string.Empty;
|
||||
}
|
||||
|
||||
public sealed class MarketingCalendarStatsResponse
|
||||
{
|
||||
public int TotalActivityCount { get; set; }
|
||||
|
||||
public int OngoingCount { get; set; }
|
||||
|
||||
public int MaxConcurrentCount { get; set; }
|
||||
|
||||
public decimal EstimatedDiscountAmount { get; set; }
|
||||
}
|
||||
|
||||
public sealed class MarketingCalendarActivityResponse
|
||||
{
|
||||
public string ActivityId { get; set; } = string.Empty;
|
||||
|
||||
public string SourceType { get; set; } = string.Empty;
|
||||
|
||||
public string SourceId { get; set; } = string.Empty;
|
||||
|
||||
public string CalendarType { get; set; } = string.Empty;
|
||||
|
||||
public string Name { get; set; } = string.Empty;
|
||||
|
||||
public string Color { get; set; } = string.Empty;
|
||||
|
||||
public string Summary { get; set; } = string.Empty;
|
||||
|
||||
public string DisplayStatus { get; set; } = string.Empty;
|
||||
|
||||
public bool IsDimmed { get; set; }
|
||||
|
||||
public string StartDate { get; set; } = string.Empty;
|
||||
|
||||
public string EndDate { get; set; } = string.Empty;
|
||||
|
||||
public decimal EstimatedDiscountAmount { get; set; }
|
||||
|
||||
public List<MarketingCalendarActivityBarResponse> Bars { get; set; } = [];
|
||||
|
||||
public MarketingCalendarActivityDetailResponse Detail { get; set; } = new();
|
||||
}
|
||||
|
||||
public sealed class MarketingCalendarActivityBarResponse
|
||||
{
|
||||
public string BarId { get; set; } = string.Empty;
|
||||
|
||||
public int StartDay { get; set; }
|
||||
|
||||
public int EndDay { get; set; }
|
||||
|
||||
public string Label { get; set; } = string.Empty;
|
||||
|
||||
public bool IsMilestone { get; set; }
|
||||
|
||||
public bool IsDimmed { get; set; }
|
||||
}
|
||||
|
||||
public sealed class MarketingCalendarActivityDetailResponse
|
||||
{
|
||||
public string ModuleName { get; set; } = string.Empty;
|
||||
|
||||
public string Description { get; set; } = string.Empty;
|
||||
|
||||
public List<MarketingCalendarDetailFieldResponse> Fields { get; set; } = [];
|
||||
}
|
||||
|
||||
public sealed class MarketingCalendarDetailFieldResponse
|
||||
{
|
||||
public string Label { get; set; } = string.Empty;
|
||||
|
||||
public string Value { get; set; } = string.Empty;
|
||||
}
|
||||
|
||||
public sealed class MarketingCalendarConflictBannerResponse
|
||||
{
|
||||
public string ConflictId { get; set; } = string.Empty;
|
||||
|
||||
public int StartDay { get; set; }
|
||||
|
||||
public int EndDay { get; set; }
|
||||
|
||||
public int ActivityCount { get; set; }
|
||||
|
||||
public int MaxConcurrentCount { get; set; }
|
||||
|
||||
public int ConflictCount { get; set; }
|
||||
}
|
||||
|
||||
public sealed class MarketingCalendarConflictResponse
|
||||
{
|
||||
public string ConflictId { get; set; } = string.Empty;
|
||||
|
||||
public int StartDay { get; set; }
|
||||
|
||||
public int EndDay { get; set; }
|
||||
|
||||
public int ActivityCount { get; set; }
|
||||
|
||||
public int MaxConcurrentCount { get; set; }
|
||||
|
||||
public List<string> ActivityIds { get; set; } = [];
|
||||
|
||||
public List<MarketingCalendarConflictActivityResponse> Activities { get; set; } = [];
|
||||
}
|
||||
|
||||
public sealed class MarketingCalendarConflictActivityResponse
|
||||
{
|
||||
public string ActivityId { get; set; } = string.Empty;
|
||||
|
||||
public string CalendarType { get; set; } = string.Empty;
|
||||
|
||||
public string Name { get; set; } = string.Empty;
|
||||
|
||||
public string Summary { get; set; } = string.Empty;
|
||||
|
||||
public string Color { get; set; } = string.Empty;
|
||||
|
||||
public string DisplayStatus { get; set; } = string.Empty;
|
||||
}
|
||||
@@ -0,0 +1,489 @@
|
||||
namespace TakeoutSaaS.TenantApi.Contracts.Marketing;
|
||||
|
||||
/// <summary>
|
||||
/// 新客有礼详情请求。
|
||||
/// </summary>
|
||||
public sealed class NewCustomerDetailRequest
|
||||
{
|
||||
/// <summary>
|
||||
/// 操作门店 ID。
|
||||
/// </summary>
|
||||
public string StoreId { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// 邀请记录页码。
|
||||
/// </summary>
|
||||
public int RecordPage { get; set; } = 1;
|
||||
|
||||
/// <summary>
|
||||
/// 邀请记录每页条数。
|
||||
/// </summary>
|
||||
public int RecordPageSize { get; set; } = 10;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 新客有礼配置保存请求。
|
||||
/// </summary>
|
||||
public sealed class SaveNewCustomerSettingsRequest
|
||||
{
|
||||
/// <summary>
|
||||
/// 操作门店 ID。
|
||||
/// </summary>
|
||||
public string StoreId { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// 是否开启新客礼包。
|
||||
/// </summary>
|
||||
public bool GiftEnabled { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 礼包类型(coupon/direct)。
|
||||
/// </summary>
|
||||
public string GiftType { get; set; } = "coupon";
|
||||
|
||||
/// <summary>
|
||||
/// 首单直减金额。
|
||||
/// </summary>
|
||||
public decimal? DirectReduceAmount { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 首单直减门槛金额。
|
||||
/// </summary>
|
||||
public decimal? DirectMinimumSpend { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 是否开启老带新分享。
|
||||
/// </summary>
|
||||
public bool InviteEnabled { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 分享渠道(wechat_friend/moments/sms)。
|
||||
/// </summary>
|
||||
public List<string> ShareChannels { get; set; } = [];
|
||||
|
||||
/// <summary>
|
||||
/// 新客礼包券列表。
|
||||
/// </summary>
|
||||
public List<NewCustomerSaveCouponRuleRequest> WelcomeCoupons { get; set; } = [];
|
||||
|
||||
/// <summary>
|
||||
/// 邀请人奖励券列表。
|
||||
/// </summary>
|
||||
public List<NewCustomerSaveCouponRuleRequest> InviterCoupons { get; set; } = [];
|
||||
|
||||
/// <summary>
|
||||
/// 被邀请人奖励券列表。
|
||||
/// </summary>
|
||||
public List<NewCustomerSaveCouponRuleRequest> InviteeCoupons { get; set; } = [];
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 新客邀请记录分页请求。
|
||||
/// </summary>
|
||||
public sealed class NewCustomerInviteRecordListRequest
|
||||
{
|
||||
/// <summary>
|
||||
/// 操作门店 ID。
|
||||
/// </summary>
|
||||
public string StoreId { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// 页码。
|
||||
/// </summary>
|
||||
public int Page { get; set; } = 1;
|
||||
|
||||
/// <summary>
|
||||
/// 每页条数。
|
||||
/// </summary>
|
||||
public int PageSize { get; set; } = 10;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 写入新客邀请记录请求。
|
||||
/// </summary>
|
||||
public sealed class WriteNewCustomerInviteRecordRequest
|
||||
{
|
||||
/// <summary>
|
||||
/// 操作门店 ID。
|
||||
/// </summary>
|
||||
public string StoreId { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// 邀请人展示名。
|
||||
/// </summary>
|
||||
public string InviterName { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// 被邀请人展示名。
|
||||
/// </summary>
|
||||
public string InviteeName { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// 邀请时间。
|
||||
/// </summary>
|
||||
public DateTime InviteTime { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 订单状态(pending_order/ordered)。
|
||||
/// </summary>
|
||||
public string OrderStatus { get; set; } = "pending_order";
|
||||
|
||||
/// <summary>
|
||||
/// 奖励状态(pending/issued)。
|
||||
/// </summary>
|
||||
public string RewardStatus { get; set; } = "pending";
|
||||
|
||||
/// <summary>
|
||||
/// 奖励发放时间。
|
||||
/// </summary>
|
||||
public DateTime? RewardIssuedAt { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 来源渠道。
|
||||
/// </summary>
|
||||
public string? SourceChannel { get; set; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 写入新客成长记录请求。
|
||||
/// </summary>
|
||||
public sealed class WriteNewCustomerGrowthRecordRequest
|
||||
{
|
||||
/// <summary>
|
||||
/// 操作门店 ID。
|
||||
/// </summary>
|
||||
public string StoreId { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// 顾客业务唯一键。
|
||||
/// </summary>
|
||||
public string CustomerKey { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// 顾客展示名。
|
||||
/// </summary>
|
||||
public string? CustomerName { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 注册时间。
|
||||
/// </summary>
|
||||
public DateTime RegisteredAt { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 礼包领取时间。
|
||||
/// </summary>
|
||||
public DateTime? GiftClaimedAt { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 首单时间。
|
||||
/// </summary>
|
||||
public DateTime? FirstOrderAt { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 来源渠道。
|
||||
/// </summary>
|
||||
public string? SourceChannel { get; set; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 保存优惠券规则请求项。
|
||||
/// </summary>
|
||||
public sealed class NewCustomerSaveCouponRuleRequest
|
||||
{
|
||||
/// <summary>
|
||||
/// 券类型(amount_off/discount/free_shipping)。
|
||||
/// </summary>
|
||||
public string CouponType { get; set; } = "amount_off";
|
||||
|
||||
/// <summary>
|
||||
/// 面值或折扣值。
|
||||
/// </summary>
|
||||
public decimal? Value { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 使用门槛金额。
|
||||
/// </summary>
|
||||
public decimal? MinimumSpend { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 有效期天数。
|
||||
/// </summary>
|
||||
public int ValidDays { get; set; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 新客有礼详情响应。
|
||||
/// </summary>
|
||||
public sealed class NewCustomerDetailResponse
|
||||
{
|
||||
/// <summary>
|
||||
/// 配置详情。
|
||||
/// </summary>
|
||||
public NewCustomerSettingsResponse Settings { get; set; } = new();
|
||||
|
||||
/// <summary>
|
||||
/// 统计数据。
|
||||
/// </summary>
|
||||
public NewCustomerStatsResponse Stats { get; set; } = new();
|
||||
|
||||
/// <summary>
|
||||
/// 邀请记录分页。
|
||||
/// </summary>
|
||||
public NewCustomerInviteRecordListResultResponse InviteRecords { get; set; } = new();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 新客有礼配置响应。
|
||||
/// </summary>
|
||||
public sealed class NewCustomerSettingsResponse
|
||||
{
|
||||
/// <summary>
|
||||
/// 门店 ID。
|
||||
/// </summary>
|
||||
public string StoreId { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// 是否开启新客礼包。
|
||||
/// </summary>
|
||||
public bool GiftEnabled { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 礼包类型(coupon/direct)。
|
||||
/// </summary>
|
||||
public string GiftType { get; set; } = "coupon";
|
||||
|
||||
/// <summary>
|
||||
/// 首单直减金额。
|
||||
/// </summary>
|
||||
public decimal? DirectReduceAmount { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 首单直减门槛金额。
|
||||
/// </summary>
|
||||
public decimal? DirectMinimumSpend { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 是否开启老带新分享。
|
||||
/// </summary>
|
||||
public bool InviteEnabled { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 分享渠道(wechat_friend/moments/sms)。
|
||||
/// </summary>
|
||||
public List<string> ShareChannels { get; set; } = [];
|
||||
|
||||
/// <summary>
|
||||
/// 新客礼包券列表。
|
||||
/// </summary>
|
||||
public List<NewCustomerCouponRuleResponse> WelcomeCoupons { get; set; } = [];
|
||||
|
||||
/// <summary>
|
||||
/// 邀请人奖励券列表。
|
||||
/// </summary>
|
||||
public List<NewCustomerCouponRuleResponse> InviterCoupons { get; set; } = [];
|
||||
|
||||
/// <summary>
|
||||
/// 被邀请人奖励券列表。
|
||||
/// </summary>
|
||||
public List<NewCustomerCouponRuleResponse> InviteeCoupons { get; set; } = [];
|
||||
|
||||
/// <summary>
|
||||
/// 更新时间(yyyy-MM-dd HH:mm:ss)。
|
||||
/// </summary>
|
||||
public string UpdatedAt { get; set; } = string.Empty;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 新客有礼统计响应。
|
||||
/// </summary>
|
||||
public sealed class NewCustomerStatsResponse
|
||||
{
|
||||
/// <summary>
|
||||
/// 本月新客数。
|
||||
/// </summary>
|
||||
public int MonthlyNewCustomers { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 较上月增长人数。
|
||||
/// </summary>
|
||||
public int MonthlyGrowthCount { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 较上月增长百分比。
|
||||
/// </summary>
|
||||
public decimal MonthlyGrowthRatePercent { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 本月礼包领取率(百分比)。
|
||||
/// </summary>
|
||||
public decimal GiftClaimRate { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 本月礼包已领取人数。
|
||||
/// </summary>
|
||||
public int GiftClaimedCount { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 本月首单转化率(百分比)。
|
||||
/// </summary>
|
||||
public decimal FirstOrderConversionRate { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 本月首单完成人数。
|
||||
/// </summary>
|
||||
public int FirstOrderedCount { get; set; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 邀请记录分页结果响应。
|
||||
/// </summary>
|
||||
public sealed class NewCustomerInviteRecordListResultResponse
|
||||
{
|
||||
/// <summary>
|
||||
/// 列表项。
|
||||
/// </summary>
|
||||
public List<NewCustomerInviteRecordResponse> Items { get; set; } = [];
|
||||
|
||||
/// <summary>
|
||||
/// 页码。
|
||||
/// </summary>
|
||||
public int Page { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 每页条数。
|
||||
/// </summary>
|
||||
public int PageSize { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 总条数。
|
||||
/// </summary>
|
||||
public int TotalCount { get; set; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 新客邀请记录响应。
|
||||
/// </summary>
|
||||
public sealed class NewCustomerInviteRecordResponse
|
||||
{
|
||||
/// <summary>
|
||||
/// 记录 ID。
|
||||
/// </summary>
|
||||
public string Id { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// 邀请人展示名。
|
||||
/// </summary>
|
||||
public string InviterName { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// 被邀请人展示名。
|
||||
/// </summary>
|
||||
public string InviteeName { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// 邀请时间(yyyy-MM-dd HH:mm:ss)。
|
||||
/// </summary>
|
||||
public string InviteTime { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// 订单状态(pending_order/ordered)。
|
||||
/// </summary>
|
||||
public string OrderStatus { get; set; } = "pending_order";
|
||||
|
||||
/// <summary>
|
||||
/// 奖励状态(pending/issued)。
|
||||
/// </summary>
|
||||
public string RewardStatus { get; set; } = "pending";
|
||||
|
||||
/// <summary>
|
||||
/// 奖励发放时间(yyyy-MM-dd HH:mm:ss)。
|
||||
/// </summary>
|
||||
public string? RewardIssuedAt { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 来源渠道。
|
||||
/// </summary>
|
||||
public string? SourceChannel { get; set; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 新客成长记录响应。
|
||||
/// </summary>
|
||||
public sealed class NewCustomerGrowthRecordResponse
|
||||
{
|
||||
/// <summary>
|
||||
/// 记录 ID。
|
||||
/// </summary>
|
||||
public string Id { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// 顾客业务唯一键。
|
||||
/// </summary>
|
||||
public string CustomerKey { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// 顾客展示名。
|
||||
/// </summary>
|
||||
public string? CustomerName { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 注册时间(yyyy-MM-dd HH:mm:ss)。
|
||||
/// </summary>
|
||||
public string RegisteredAt { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// 礼包领取时间(yyyy-MM-dd HH:mm:ss)。
|
||||
/// </summary>
|
||||
public string? GiftClaimedAt { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 首单时间(yyyy-MM-dd HH:mm:ss)。
|
||||
/// </summary>
|
||||
public string? FirstOrderAt { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 来源渠道。
|
||||
/// </summary>
|
||||
public string? SourceChannel { get; set; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 新客券规则响应。
|
||||
/// </summary>
|
||||
public sealed class NewCustomerCouponRuleResponse
|
||||
{
|
||||
/// <summary>
|
||||
/// 规则 ID。
|
||||
/// </summary>
|
||||
public string Id { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// 场景(welcome/inviter/invitee)。
|
||||
/// </summary>
|
||||
public string Scene { get; set; } = "welcome";
|
||||
|
||||
/// <summary>
|
||||
/// 券类型(amount_off/discount/free_shipping)。
|
||||
/// </summary>
|
||||
public string CouponType { get; set; } = "amount_off";
|
||||
|
||||
/// <summary>
|
||||
/// 面值或折扣值。
|
||||
/// </summary>
|
||||
public decimal? Value { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 使用门槛金额。
|
||||
/// </summary>
|
||||
public decimal? MinimumSpend { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 有效期天数。
|
||||
/// </summary>
|
||||
public int ValidDays { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 排序值。
|
||||
/// </summary>
|
||||
public int SortOrder { get; set; }
|
||||
}
|
||||
@@ -0,0 +1,809 @@
|
||||
namespace TakeoutSaaS.TenantApi.Contracts.Marketing;
|
||||
|
||||
/// <summary>
|
||||
/// 次卡列表查询请求。
|
||||
/// </summary>
|
||||
public sealed class PunchCardListRequest
|
||||
{
|
||||
/// <summary>
|
||||
/// 门店 ID。
|
||||
/// </summary>
|
||||
public string StoreId { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// 名称关键字。
|
||||
/// </summary>
|
||||
public string? Keyword { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 状态筛选(enabled/disabled)。
|
||||
/// </summary>
|
||||
public string? Status { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 页码。
|
||||
/// </summary>
|
||||
public int Page { get; set; } = 1;
|
||||
|
||||
/// <summary>
|
||||
/// 每页条数。
|
||||
/// </summary>
|
||||
public int PageSize { get; set; } = 4;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 次卡详情请求。
|
||||
/// </summary>
|
||||
public sealed class PunchCardDetailRequest
|
||||
{
|
||||
/// <summary>
|
||||
/// 门店 ID。
|
||||
/// </summary>
|
||||
public string StoreId { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// 次卡 ID。
|
||||
/// </summary>
|
||||
public string PunchCardId { get; set; } = string.Empty;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 保存次卡请求。
|
||||
/// </summary>
|
||||
public sealed class SavePunchCardRequest
|
||||
{
|
||||
/// <summary>
|
||||
/// 门店 ID。
|
||||
/// </summary>
|
||||
public string StoreId { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// 次卡 ID(编辑时传)。
|
||||
/// </summary>
|
||||
public string? Id { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 次卡名称。
|
||||
/// </summary>
|
||||
public string Name { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// 封面图地址。
|
||||
/// </summary>
|
||||
public string? CoverImageUrl { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 售价。
|
||||
/// </summary>
|
||||
public decimal SalePrice { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 原价。
|
||||
/// </summary>
|
||||
public decimal? OriginalPrice { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 总次数。
|
||||
/// </summary>
|
||||
public int TotalTimes { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 有效期类型(days/range)。
|
||||
/// </summary>
|
||||
public string ValidityType { get; set; } = "days";
|
||||
|
||||
/// <summary>
|
||||
/// 固定天数。
|
||||
/// </summary>
|
||||
public int? ValidityDays { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 固定开始日期(yyyy-MM-dd)。
|
||||
/// </summary>
|
||||
public string? ValidFrom { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 固定结束日期(yyyy-MM-dd)。
|
||||
/// </summary>
|
||||
public string? ValidTo { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 范围类型(all/category/tag/product)。
|
||||
/// </summary>
|
||||
public string ScopeType { get; set; } = "all";
|
||||
|
||||
/// <summary>
|
||||
/// 指定分类 ID。
|
||||
/// </summary>
|
||||
public List<string> ScopeCategoryIds { get; set; } = [];
|
||||
|
||||
/// <summary>
|
||||
/// 指定标签 ID。
|
||||
/// </summary>
|
||||
public List<string> ScopeTagIds { get; set; } = [];
|
||||
|
||||
/// <summary>
|
||||
/// 指定商品 ID。
|
||||
/// </summary>
|
||||
public List<string> ScopeProductIds { get; set; } = [];
|
||||
|
||||
/// <summary>
|
||||
/// 使用模式(free/cap)。
|
||||
/// </summary>
|
||||
public string UsageMode { get; set; } = "free";
|
||||
|
||||
/// <summary>
|
||||
/// 单次上限金额。
|
||||
/// </summary>
|
||||
public decimal? UsageCapAmount { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 每日限用次数。
|
||||
/// </summary>
|
||||
public int? DailyLimit { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 每单限用次数。
|
||||
/// </summary>
|
||||
public int? PerOrderLimit { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 每人限购。
|
||||
/// </summary>
|
||||
public int? PerUserPurchaseLimit { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 是否允许转赠。
|
||||
/// </summary>
|
||||
public bool AllowTransfer { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 过期策略(invalidate/refund)。
|
||||
/// </summary>
|
||||
public string ExpireStrategy { get; set; } = "invalidate";
|
||||
|
||||
/// <summary>
|
||||
/// 次卡描述。
|
||||
/// </summary>
|
||||
public string? Description { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 通知渠道(in_app/sms)。
|
||||
/// </summary>
|
||||
public List<string> NotifyChannels { get; set; } = [];
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 次卡状态修改请求。
|
||||
/// </summary>
|
||||
public sealed class ChangePunchCardStatusRequest
|
||||
{
|
||||
/// <summary>
|
||||
/// 门店 ID。
|
||||
/// </summary>
|
||||
public string StoreId { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// 次卡 ID。
|
||||
/// </summary>
|
||||
public string PunchCardId { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// 状态(enabled/disabled)。
|
||||
/// </summary>
|
||||
public string Status { get; set; } = "disabled";
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 次卡删除请求。
|
||||
/// </summary>
|
||||
public sealed class DeletePunchCardRequest
|
||||
{
|
||||
/// <summary>
|
||||
/// 门店 ID。
|
||||
/// </summary>
|
||||
public string StoreId { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// 次卡 ID。
|
||||
/// </summary>
|
||||
public string PunchCardId { get; set; } = string.Empty;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 次卡使用记录查询请求。
|
||||
/// </summary>
|
||||
public sealed class PunchCardUsageRecordListRequest
|
||||
{
|
||||
/// <summary>
|
||||
/// 门店 ID。
|
||||
/// </summary>
|
||||
public string StoreId { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// 次卡模板 ID。
|
||||
/// </summary>
|
||||
public string? PunchCardId { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 状态筛选(normal/used_up/expired)。
|
||||
/// </summary>
|
||||
public string? Status { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 关键字(会员/商品)。
|
||||
/// </summary>
|
||||
public string? Keyword { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 页码。
|
||||
/// </summary>
|
||||
public int Page { get; set; } = 1;
|
||||
|
||||
/// <summary>
|
||||
/// 每页条数。
|
||||
/// </summary>
|
||||
public int PageSize { get; set; } = 10;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 次卡使用记录导出请求。
|
||||
/// </summary>
|
||||
public sealed class ExportPunchCardUsageRecordRequest
|
||||
{
|
||||
/// <summary>
|
||||
/// 门店 ID。
|
||||
/// </summary>
|
||||
public string StoreId { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// 次卡模板 ID。
|
||||
/// </summary>
|
||||
public string? PunchCardId { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 状态筛选(normal/used_up/expired)。
|
||||
/// </summary>
|
||||
public string? Status { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 关键字(会员/商品)。
|
||||
/// </summary>
|
||||
public string? Keyword { get; set; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 写入次卡使用记录请求。
|
||||
/// </summary>
|
||||
public sealed class WritePunchCardUsageRecordRequest
|
||||
{
|
||||
/// <summary>
|
||||
/// 门店 ID。
|
||||
/// </summary>
|
||||
public string StoreId { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// 次卡模板 ID。
|
||||
/// </summary>
|
||||
public string PunchCardId { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// 次卡实例 ID(可空)。
|
||||
/// </summary>
|
||||
public string? PunchCardInstanceId { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 次卡实例编号(可空)。
|
||||
/// </summary>
|
||||
public string? PunchCardInstanceNo { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 会员名称。
|
||||
/// </summary>
|
||||
public string? MemberName { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 会员手机号(脱敏)。
|
||||
/// </summary>
|
||||
public string? MemberPhoneMasked { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 兑换商品。
|
||||
/// </summary>
|
||||
public string ProductName { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// 使用时间。
|
||||
/// </summary>
|
||||
public DateTime? UsedAt { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 本次使用次数。
|
||||
/// </summary>
|
||||
public int UsedTimes { get; set; } = 1;
|
||||
|
||||
/// <summary>
|
||||
/// 超额补差金额。
|
||||
/// </summary>
|
||||
public decimal? ExtraPayAmount { get; set; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 次卡模板统计。
|
||||
/// </summary>
|
||||
public sealed class PunchCardStatsResponse
|
||||
{
|
||||
/// <summary>
|
||||
/// 在售次卡数量。
|
||||
/// </summary>
|
||||
public int OnSaleCount { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 累计售出数量。
|
||||
/// </summary>
|
||||
public int TotalSoldCount { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 累计收入。
|
||||
/// </summary>
|
||||
public decimal TotalRevenueAmount { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 使用中数量。
|
||||
/// </summary>
|
||||
public int ActiveInUseCount { get; set; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 次卡列表项。
|
||||
/// </summary>
|
||||
public sealed class PunchCardListItemResponse
|
||||
{
|
||||
/// <summary>
|
||||
/// 次卡 ID。
|
||||
/// </summary>
|
||||
public string Id { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// 次卡名称。
|
||||
/// </summary>
|
||||
public string Name { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// 封面图。
|
||||
/// </summary>
|
||||
public string? CoverImageUrl { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 售价。
|
||||
/// </summary>
|
||||
public decimal SalePrice { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 原价。
|
||||
/// </summary>
|
||||
public decimal? OriginalPrice { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 总次数。
|
||||
/// </summary>
|
||||
public int TotalTimes { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 有效期展示。
|
||||
/// </summary>
|
||||
public string ValiditySummary { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// 适用范围类型。
|
||||
/// </summary>
|
||||
public string ScopeType { get; set; } = "all";
|
||||
|
||||
/// <summary>
|
||||
/// 使用模式。
|
||||
/// </summary>
|
||||
public string UsageMode { get; set; } = "free";
|
||||
|
||||
/// <summary>
|
||||
/// 单次上限金额。
|
||||
/// </summary>
|
||||
public decimal? UsageCapAmount { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 每日限用。
|
||||
/// </summary>
|
||||
public int? DailyLimit { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 状态(enabled/disabled)。
|
||||
/// </summary>
|
||||
public string Status { get; set; } = "enabled";
|
||||
|
||||
/// <summary>
|
||||
/// 是否弱化展示。
|
||||
/// </summary>
|
||||
public bool IsDimmed { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 已售数量。
|
||||
/// </summary>
|
||||
public int SoldCount { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 使用中数量。
|
||||
/// </summary>
|
||||
public int ActiveCount { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 累计收入。
|
||||
/// </summary>
|
||||
public decimal RevenueAmount { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 更新时间(yyyy-MM-dd HH:mm:ss)。
|
||||
/// </summary>
|
||||
public string UpdatedAt { get; set; } = string.Empty;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 次卡列表结果。
|
||||
/// </summary>
|
||||
public sealed class PunchCardListResultResponse
|
||||
{
|
||||
/// <summary>
|
||||
/// 列表。
|
||||
/// </summary>
|
||||
public List<PunchCardListItemResponse> Items { get; set; } = [];
|
||||
|
||||
/// <summary>
|
||||
/// 当前页。
|
||||
/// </summary>
|
||||
public int Page { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 每页条数。
|
||||
/// </summary>
|
||||
public int PageSize { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 总条数。
|
||||
/// </summary>
|
||||
public int TotalCount { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 统计。
|
||||
/// </summary>
|
||||
public PunchCardStatsResponse Stats { get; set; } = new();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 次卡范围。
|
||||
/// </summary>
|
||||
public sealed class PunchCardScopeResponse
|
||||
{
|
||||
/// <summary>
|
||||
/// 范围类型(all/category/tag/product)。
|
||||
/// </summary>
|
||||
public string ScopeType { get; set; } = "all";
|
||||
|
||||
/// <summary>
|
||||
/// 分类 ID。
|
||||
/// </summary>
|
||||
public List<string> CategoryIds { get; set; } = [];
|
||||
|
||||
/// <summary>
|
||||
/// 标签 ID。
|
||||
/// </summary>
|
||||
public List<string> TagIds { get; set; } = [];
|
||||
|
||||
/// <summary>
|
||||
/// 商品 ID。
|
||||
/// </summary>
|
||||
public List<string> ProductIds { get; set; } = [];
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 次卡详情。
|
||||
/// </summary>
|
||||
public sealed class PunchCardDetailResponse
|
||||
{
|
||||
/// <summary>
|
||||
/// 次卡 ID。
|
||||
/// </summary>
|
||||
public string Id { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// 门店 ID。
|
||||
/// </summary>
|
||||
public string StoreId { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// 次卡名称。
|
||||
/// </summary>
|
||||
public string Name { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// 封面图。
|
||||
/// </summary>
|
||||
public string? CoverImageUrl { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 售价。
|
||||
/// </summary>
|
||||
public decimal SalePrice { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 原价。
|
||||
/// </summary>
|
||||
public decimal? OriginalPrice { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 总次数。
|
||||
/// </summary>
|
||||
public int TotalTimes { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 有效期类型(days/range)。
|
||||
/// </summary>
|
||||
public string ValidityType { get; set; } = "days";
|
||||
|
||||
/// <summary>
|
||||
/// 固定天数。
|
||||
/// </summary>
|
||||
public int? ValidityDays { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 固定开始日期(yyyy-MM-dd)。
|
||||
/// </summary>
|
||||
public string? ValidFrom { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 固定结束日期(yyyy-MM-dd)。
|
||||
/// </summary>
|
||||
public string? ValidTo { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 适用范围。
|
||||
/// </summary>
|
||||
public PunchCardScopeResponse Scope { get; set; } = new();
|
||||
|
||||
/// <summary>
|
||||
/// 使用模式(free/cap)。
|
||||
/// </summary>
|
||||
public string UsageMode { get; set; } = "free";
|
||||
|
||||
/// <summary>
|
||||
/// 单次上限金额。
|
||||
/// </summary>
|
||||
public decimal? UsageCapAmount { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 每日限用。
|
||||
/// </summary>
|
||||
public int? DailyLimit { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 每单限用。
|
||||
/// </summary>
|
||||
public int? PerOrderLimit { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 每人限购。
|
||||
/// </summary>
|
||||
public int? PerUserPurchaseLimit { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 是否允许转赠。
|
||||
/// </summary>
|
||||
public bool AllowTransfer { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 过期策略(invalidate/refund)。
|
||||
/// </summary>
|
||||
public string ExpireStrategy { get; set; } = "invalidate";
|
||||
|
||||
/// <summary>
|
||||
/// 描述。
|
||||
/// </summary>
|
||||
public string? Description { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 通知渠道。
|
||||
/// </summary>
|
||||
public List<string> NotifyChannels { get; set; } = [];
|
||||
|
||||
/// <summary>
|
||||
/// 状态(enabled/disabled)。
|
||||
/// </summary>
|
||||
public string Status { get; set; } = "enabled";
|
||||
|
||||
/// <summary>
|
||||
/// 已售数量。
|
||||
/// </summary>
|
||||
public int SoldCount { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 使用中数量。
|
||||
/// </summary>
|
||||
public int ActiveCount { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 累计收入。
|
||||
/// </summary>
|
||||
public decimal RevenueAmount { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 更新时间(yyyy-MM-dd HH:mm:ss)。
|
||||
/// </summary>
|
||||
public string UpdatedAt { get; set; } = string.Empty;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 次卡下拉选项。
|
||||
/// </summary>
|
||||
public sealed class PunchCardTemplateOptionResponse
|
||||
{
|
||||
/// <summary>
|
||||
/// 次卡 ID。
|
||||
/// </summary>
|
||||
public string TemplateId { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// 次卡名称。
|
||||
/// </summary>
|
||||
public string Name { get; set; } = string.Empty;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 使用记录统计。
|
||||
/// </summary>
|
||||
public sealed class PunchCardUsageStatsResponse
|
||||
{
|
||||
/// <summary>
|
||||
/// 今日使用次数。
|
||||
/// </summary>
|
||||
public int TodayUsedCount { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 本月使用次数。
|
||||
/// </summary>
|
||||
public int MonthUsedCount { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 7 天内即将过期数量。
|
||||
/// </summary>
|
||||
public int ExpiringSoonCount { get; set; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 次卡使用记录项。
|
||||
/// </summary>
|
||||
public sealed class PunchCardUsageRecordResponse
|
||||
{
|
||||
/// <summary>
|
||||
/// 使用记录 ID。
|
||||
/// </summary>
|
||||
public string Id { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// 使用单号。
|
||||
/// </summary>
|
||||
public string RecordNo { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// 次卡模板 ID。
|
||||
/// </summary>
|
||||
public string PunchCardId { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// 次卡名称。
|
||||
/// </summary>
|
||||
public string PunchCardName { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// 次卡实例 ID。
|
||||
/// </summary>
|
||||
public string PunchCardInstanceId { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// 会员名称。
|
||||
/// </summary>
|
||||
public string MemberName { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// 会员手机号(脱敏)。
|
||||
/// </summary>
|
||||
public string MemberPhoneMasked { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// 兑换商品。
|
||||
/// </summary>
|
||||
public string ProductName { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// 使用时间(yyyy-MM-dd HH:mm:ss)。
|
||||
/// </summary>
|
||||
public string UsedAt { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// 本次使用次数。
|
||||
/// </summary>
|
||||
public int UsedTimes { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 剩余次数。
|
||||
/// </summary>
|
||||
public int RemainingTimesAfterUse { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 总次数。
|
||||
/// </summary>
|
||||
public int TotalTimes { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 状态(normal/almost_used_up/used_up/expired)。
|
||||
/// </summary>
|
||||
public string DisplayStatus { get; set; } = "normal";
|
||||
|
||||
/// <summary>
|
||||
/// 超额补差金额。
|
||||
/// </summary>
|
||||
public decimal? ExtraPayAmount { get; set; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 使用记录分页结果。
|
||||
/// </summary>
|
||||
public sealed class PunchCardUsageRecordListResultResponse
|
||||
{
|
||||
/// <summary>
|
||||
/// 列表。
|
||||
/// </summary>
|
||||
public List<PunchCardUsageRecordResponse> Items { get; set; } = [];
|
||||
|
||||
/// <summary>
|
||||
/// 页码。
|
||||
/// </summary>
|
||||
public int Page { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 每页条数。
|
||||
/// </summary>
|
||||
public int PageSize { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 总条数。
|
||||
/// </summary>
|
||||
public int TotalCount { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 统计。
|
||||
/// </summary>
|
||||
public PunchCardUsageStatsResponse Stats { get; set; } = new();
|
||||
|
||||
/// <summary>
|
||||
/// 次卡筛选项。
|
||||
/// </summary>
|
||||
public List<PunchCardTemplateOptionResponse> TemplateOptions { get; set; } = [];
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 使用记录导出回执。
|
||||
/// </summary>
|
||||
public sealed class PunchCardUsageRecordExportResponse
|
||||
{
|
||||
/// <summary>
|
||||
/// 文件名。
|
||||
/// </summary>
|
||||
public string FileName { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// Base64 文件内容。
|
||||
/// </summary>
|
||||
public string FileContentBase64 { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// 导出总条数。
|
||||
/// </summary>
|
||||
public int TotalCount { get; set; }
|
||||
}
|
||||
@@ -0,0 +1,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,585 @@
|
||||
namespace TakeoutSaaS.TenantApi.Contracts.Member;
|
||||
|
||||
/// <summary>
|
||||
/// 消息触达统计请求。
|
||||
/// </summary>
|
||||
public sealed class MemberMessageReachStatsRequest
|
||||
{
|
||||
/// <summary>
|
||||
/// 门店 ID(可选)。
|
||||
/// </summary>
|
||||
public string? StoreId { get; set; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 消息列表请求。
|
||||
/// </summary>
|
||||
public sealed class MemberMessageReachListRequest
|
||||
{
|
||||
/// <summary>
|
||||
/// 状态过滤(draft/pending/sending/sent/failed)。
|
||||
/// </summary>
|
||||
public string? Status { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 渠道过滤(inapp/sms/wechat-mini)。
|
||||
/// </summary>
|
||||
public string? Channel { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 关键词(标题)。
|
||||
/// </summary>
|
||||
public string? Keyword { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 页码。
|
||||
/// </summary>
|
||||
public int Page { get; set; } = 1;
|
||||
|
||||
/// <summary>
|
||||
/// 每页条数。
|
||||
/// </summary>
|
||||
public int PageSize { get; set; } = 10;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 消息详情请求。
|
||||
/// </summary>
|
||||
public sealed class MemberMessageReachDetailRequest
|
||||
{
|
||||
/// <summary>
|
||||
/// 消息 ID。
|
||||
/// </summary>
|
||||
public string MessageId { get; set; } = string.Empty;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 保存消息请求。
|
||||
/// </summary>
|
||||
public sealed class SaveMemberMessageReachRequest
|
||||
{
|
||||
/// <summary>
|
||||
/// 消息 ID(编辑时传)。
|
||||
/// </summary>
|
||||
public string? MessageId { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 门店 ID(可选)。
|
||||
/// </summary>
|
||||
public string? StoreId { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 模板 ID(可选)。
|
||||
/// </summary>
|
||||
public string? TemplateId { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 标题。
|
||||
/// </summary>
|
||||
public string Title { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// 内容。
|
||||
/// </summary>
|
||||
public string Content { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// 发送渠道。
|
||||
/// </summary>
|
||||
public List<string> Channels { get; set; } = [];
|
||||
|
||||
/// <summary>
|
||||
/// 目标类型(all/tag)。
|
||||
/// </summary>
|
||||
public string AudienceType { get; set; } = "all";
|
||||
|
||||
/// <summary>
|
||||
/// 目标标签。
|
||||
/// </summary>
|
||||
public List<string> AudienceTags { get; set; } = [];
|
||||
|
||||
/// <summary>
|
||||
/// 发送时间类型(immediate/scheduled)。
|
||||
/// </summary>
|
||||
public string ScheduleType { get; set; } = "immediate";
|
||||
|
||||
/// <summary>
|
||||
/// 定时发送时间(UTC 或本地时间,后端统一转 UTC)。
|
||||
/// </summary>
|
||||
public DateTime? ScheduledAt { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 提交动作(draft/send)。
|
||||
/// </summary>
|
||||
public string SubmitAction { get; set; } = "draft";
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 删除消息请求。
|
||||
/// </summary>
|
||||
public sealed class DeleteMemberMessageReachRequest
|
||||
{
|
||||
/// <summary>
|
||||
/// 消息 ID。
|
||||
/// </summary>
|
||||
public string MessageId { get; set; } = string.Empty;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 估算人群请求。
|
||||
/// </summary>
|
||||
public sealed class MemberMessageAudienceEstimateRequest
|
||||
{
|
||||
/// <summary>
|
||||
/// 目标类型(all/tag)。
|
||||
/// </summary>
|
||||
public string AudienceType { get; set; } = "all";
|
||||
|
||||
/// <summary>
|
||||
/// 标签。
|
||||
/// </summary>
|
||||
public List<string> Tags { get; set; } = [];
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 模板列表请求。
|
||||
/// </summary>
|
||||
public sealed class MemberMessageTemplateListRequest
|
||||
{
|
||||
/// <summary>
|
||||
/// 模板分类(marketing/notice/recall)。
|
||||
/// </summary>
|
||||
public string? Category { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 关键词(模板名称)。
|
||||
/// </summary>
|
||||
public string? Keyword { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 页码。
|
||||
/// </summary>
|
||||
public int Page { get; set; } = 1;
|
||||
|
||||
/// <summary>
|
||||
/// 每页条数。
|
||||
/// </summary>
|
||||
public int PageSize { get; set; } = 10;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 模板详情请求。
|
||||
/// </summary>
|
||||
public sealed class MemberMessageTemplateDetailRequest
|
||||
{
|
||||
/// <summary>
|
||||
/// 模板 ID。
|
||||
/// </summary>
|
||||
public string TemplateId { get; set; } = string.Empty;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 保存模板请求。
|
||||
/// </summary>
|
||||
public sealed class SaveMemberMessageTemplateRequest
|
||||
{
|
||||
/// <summary>
|
||||
/// 模板 ID(编辑时传)。
|
||||
/// </summary>
|
||||
public string? TemplateId { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 模板名称。
|
||||
/// </summary>
|
||||
public string Name { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// 模板分类(marketing/notice/recall)。
|
||||
/// </summary>
|
||||
public string Category { get; set; } = "notice";
|
||||
|
||||
/// <summary>
|
||||
/// 模板内容。
|
||||
/// </summary>
|
||||
public string Content { get; set; } = string.Empty;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 删除模板请求。
|
||||
/// </summary>
|
||||
public sealed class DeleteMemberMessageTemplateRequest
|
||||
{
|
||||
/// <summary>
|
||||
/// 模板 ID。
|
||||
/// </summary>
|
||||
public string TemplateId { get; set; } = string.Empty;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 消息触达统计响应。
|
||||
/// </summary>
|
||||
public sealed class MemberMessageReachStatsResponse
|
||||
{
|
||||
/// <summary>
|
||||
/// 本月发送条数。
|
||||
/// </summary>
|
||||
public int MonthlySentCount { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 触达人数。
|
||||
/// </summary>
|
||||
public int ReachMemberCount { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 打开率(百分比)。
|
||||
/// </summary>
|
||||
public decimal OpenRate { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 转化率(百分比)。
|
||||
/// </summary>
|
||||
public decimal ConversionRate { get; set; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 消息列表项响应。
|
||||
/// </summary>
|
||||
public sealed class MemberMessageReachListItemResponse
|
||||
{
|
||||
/// <summary>
|
||||
/// 消息 ID。
|
||||
/// </summary>
|
||||
public string MessageId { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// 标题。
|
||||
/// </summary>
|
||||
public string Title { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// 渠道。
|
||||
/// </summary>
|
||||
public List<string> Channels { get; set; } = [];
|
||||
|
||||
/// <summary>
|
||||
/// 目标文案。
|
||||
/// </summary>
|
||||
public string AudienceText { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// 预计触达人数。
|
||||
/// </summary>
|
||||
public int EstimatedReachCount { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 状态。
|
||||
/// </summary>
|
||||
public string Status { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// 发送时间(yyyy-MM-dd HH:mm:ss)。
|
||||
/// </summary>
|
||||
public string? SentAt { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 定时发送时间(yyyy-MM-dd HH:mm:ss)。
|
||||
/// </summary>
|
||||
public string? ScheduledAt { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 打开率(百分比)。
|
||||
/// </summary>
|
||||
public decimal OpenRate { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 转化率(百分比)。
|
||||
/// </summary>
|
||||
public decimal ConversionRate { get; set; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 消息列表响应。
|
||||
/// </summary>
|
||||
public sealed class MemberMessageReachListResultResponse
|
||||
{
|
||||
/// <summary>
|
||||
/// 列表。
|
||||
/// </summary>
|
||||
public List<MemberMessageReachListItemResponse> 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 MemberMessageReachRecipientResponse
|
||||
{
|
||||
/// <summary>
|
||||
/// 会员 ID。
|
||||
/// </summary>
|
||||
public string MemberId { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// 渠道。
|
||||
/// </summary>
|
||||
public string Channel { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// 状态。
|
||||
/// </summary>
|
||||
public string Status { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// 手机号。
|
||||
/// </summary>
|
||||
public string? Mobile { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// OpenId。
|
||||
/// </summary>
|
||||
public string? OpenId { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 发送时间(yyyy-MM-dd HH:mm:ss)。
|
||||
/// </summary>
|
||||
public string? SentAt { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 已读时间(yyyy-MM-dd HH:mm:ss)。
|
||||
/// </summary>
|
||||
public string? ReadAt { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 转化时间(yyyy-MM-dd HH:mm:ss)。
|
||||
/// </summary>
|
||||
public string? ConvertedAt { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 错误信息。
|
||||
/// </summary>
|
||||
public string? ErrorMessage { get; set; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 消息详情响应。
|
||||
/// </summary>
|
||||
public sealed class MemberMessageReachDetailResponse
|
||||
{
|
||||
/// <summary>
|
||||
/// 消息 ID。
|
||||
/// </summary>
|
||||
public string MessageId { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// 模板 ID。
|
||||
/// </summary>
|
||||
public string? TemplateId { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 标题。
|
||||
/// </summary>
|
||||
public string Title { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// 内容。
|
||||
/// </summary>
|
||||
public string Content { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// 渠道。
|
||||
/// </summary>
|
||||
public List<string> Channels { get; set; } = [];
|
||||
|
||||
/// <summary>
|
||||
/// 目标类型。
|
||||
/// </summary>
|
||||
public string AudienceType { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// 目标标签。
|
||||
/// </summary>
|
||||
public List<string> AudienceTags { get; set; } = [];
|
||||
|
||||
/// <summary>
|
||||
/// 目标文案。
|
||||
/// </summary>
|
||||
public string AudienceText { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// 预计触达人数。
|
||||
/// </summary>
|
||||
public int EstimatedReachCount { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 发送时间类型。
|
||||
/// </summary>
|
||||
public string ScheduleType { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// 定时发送时间(yyyy-MM-dd HH:mm:ss)。
|
||||
/// </summary>
|
||||
public string? ScheduledAt { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 发送状态。
|
||||
/// </summary>
|
||||
public string Status { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// 实际发送时间(yyyy-MM-dd HH:mm:ss)。
|
||||
/// </summary>
|
||||
public string? SentAt { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 成功发送数。
|
||||
/// </summary>
|
||||
public int SentCount { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 已读数。
|
||||
/// </summary>
|
||||
public int ReadCount { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 转化数。
|
||||
/// </summary>
|
||||
public int ConvertedCount { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 打开率(百分比)。
|
||||
/// </summary>
|
||||
public decimal OpenRate { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 转化率(百分比)。
|
||||
/// </summary>
|
||||
public decimal ConversionRate { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 错误信息。
|
||||
/// </summary>
|
||||
public string? LastError { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 收件明细。
|
||||
/// </summary>
|
||||
public List<MemberMessageReachRecipientResponse> Recipients { get; set; } = [];
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 消息调度元信息响应。
|
||||
/// </summary>
|
||||
public sealed class MemberMessageDispatchMetaResponse
|
||||
{
|
||||
/// <summary>
|
||||
/// 消息 ID。
|
||||
/// </summary>
|
||||
public string MessageId { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// 状态。
|
||||
/// </summary>
|
||||
public string Status { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// 时间类型。
|
||||
/// </summary>
|
||||
public string ScheduleType { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// 定时发送时间(yyyy-MM-dd HH:mm:ss)。
|
||||
/// </summary>
|
||||
public string? ScheduledAt { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Hangfire 任务 ID。
|
||||
/// </summary>
|
||||
public string? HangfireJobId { get; set; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 模板响应。
|
||||
/// </summary>
|
||||
public sealed class MemberMessageTemplateResponse
|
||||
{
|
||||
/// <summary>
|
||||
/// 模板 ID。
|
||||
/// </summary>
|
||||
public string TemplateId { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// 名称。
|
||||
/// </summary>
|
||||
public string Name { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// 分类。
|
||||
/// </summary>
|
||||
public string Category { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// 内容。
|
||||
/// </summary>
|
||||
public string Content { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// 使用次数。
|
||||
/// </summary>
|
||||
public int UsageCount { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 最近使用时间(yyyy-MM-dd HH:mm:ss)。
|
||||
/// </summary>
|
||||
public string? LastUsedAt { get; set; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 模板列表响应。
|
||||
/// </summary>
|
||||
public sealed class MemberMessageTemplateListResultResponse
|
||||
{
|
||||
/// <summary>
|
||||
/// 列表。
|
||||
/// </summary>
|
||||
public List<MemberMessageTemplateResponse> 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 MemberMessageAudienceEstimateResponse
|
||||
{
|
||||
/// <summary>
|
||||
/// 预计触达人数。
|
||||
/// </summary>
|
||||
public int ReachCount { get; set; }
|
||||
}
|
||||
@@ -0,0 +1,808 @@
|
||||
namespace TakeoutSaaS.TenantApi.Contracts.Member;
|
||||
|
||||
/// <summary>
|
||||
/// 积分商城规则详情查询请求。
|
||||
/// </summary>
|
||||
public sealed class PointMallRuleDetailRequest
|
||||
{
|
||||
/// <summary>
|
||||
/// 门店 ID。
|
||||
/// </summary>
|
||||
public string StoreId { get; set; } = string.Empty;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 保存积分商城规则请求。
|
||||
/// </summary>
|
||||
public sealed class SavePointMallRuleRequest
|
||||
{
|
||||
/// <summary>
|
||||
/// 门店 ID。
|
||||
/// </summary>
|
||||
public string StoreId { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// 是否启用消费获取。
|
||||
/// </summary>
|
||||
public bool IsConsumeRewardEnabled { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 每消费多少元触发一次积分计算。
|
||||
/// </summary>
|
||||
public int ConsumeAmountPerStep { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 每步获得积分。
|
||||
/// </summary>
|
||||
public int ConsumeRewardPointsPerStep { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 是否启用评价奖励。
|
||||
/// </summary>
|
||||
public bool IsReviewRewardEnabled { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 评价奖励积分。
|
||||
/// </summary>
|
||||
public int ReviewRewardPoints { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 是否启用注册奖励。
|
||||
/// </summary>
|
||||
public bool IsRegisterRewardEnabled { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 注册奖励积分。
|
||||
/// </summary>
|
||||
public int RegisterRewardPoints { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 是否启用签到奖励。
|
||||
/// </summary>
|
||||
public bool IsSigninRewardEnabled { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 签到奖励积分。
|
||||
/// </summary>
|
||||
public int SigninRewardPoints { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 有效期模式(permanent/yearly_clear)。
|
||||
/// </summary>
|
||||
public string ExpiryMode { get; set; } = "yearly_clear";
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 积分商城商品列表查询请求。
|
||||
/// </summary>
|
||||
public sealed class PointMallProductListRequest
|
||||
{
|
||||
/// <summary>
|
||||
/// 门店 ID。
|
||||
/// </summary>
|
||||
public string StoreId { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// 状态(enabled/disabled,可空)。
|
||||
/// </summary>
|
||||
public string? Status { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 关键字。
|
||||
/// </summary>
|
||||
public string? Keyword { get; set; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 积分商城商品详情查询请求。
|
||||
/// </summary>
|
||||
public sealed class PointMallProductDetailRequest
|
||||
{
|
||||
/// <summary>
|
||||
/// 门店 ID。
|
||||
/// </summary>
|
||||
public string StoreId { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// 积分商城商品 ID。
|
||||
/// </summary>
|
||||
public string PointMallProductId { get; set; } = string.Empty;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 保存积分商城商品请求。
|
||||
/// </summary>
|
||||
public sealed class SavePointMallProductRequest
|
||||
{
|
||||
/// <summary>
|
||||
/// 门店 ID。
|
||||
/// </summary>
|
||||
public string StoreId { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// 积分商城商品 ID(编辑时传)。
|
||||
/// </summary>
|
||||
public string? PointMallProductId { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 展示名称。
|
||||
/// </summary>
|
||||
public string Name { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// 展示图片。
|
||||
/// </summary>
|
||||
public string? ImageUrl { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 兑换类型(product/coupon/physical)。
|
||||
/// </summary>
|
||||
public string RedeemType { get; set; } = "product";
|
||||
|
||||
/// <summary>
|
||||
/// 关联商品 ID。
|
||||
/// </summary>
|
||||
public string? ProductId { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 关联优惠券模板 ID。
|
||||
/// </summary>
|
||||
public string? CouponTemplateId { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 实物名称。
|
||||
/// </summary>
|
||||
public string? PhysicalName { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 领取方式(store_pickup/delivery)。
|
||||
/// </summary>
|
||||
public string? PickupMethod { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 商品描述。
|
||||
/// </summary>
|
||||
public string? Description { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 兑换方式(points/mixed)。
|
||||
/// </summary>
|
||||
public string ExchangeType { get; set; } = "points";
|
||||
|
||||
/// <summary>
|
||||
/// 所需积分。
|
||||
/// </summary>
|
||||
public int RequiredPoints { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 现金部分。
|
||||
/// </summary>
|
||||
public decimal CashAmount { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 库存总量。
|
||||
/// </summary>
|
||||
public int StockTotal { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 每人限兑次数。
|
||||
/// </summary>
|
||||
public int? PerMemberLimit { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 通知渠道(in_app/sms)。
|
||||
/// </summary>
|
||||
public List<string> NotifyChannels { get; set; } = [];
|
||||
|
||||
/// <summary>
|
||||
/// 状态(enabled/disabled)。
|
||||
/// </summary>
|
||||
public string Status { get; set; } = "enabled";
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 修改积分商城商品状态请求。
|
||||
/// </summary>
|
||||
public sealed class ChangePointMallProductStatusRequest
|
||||
{
|
||||
/// <summary>
|
||||
/// 门店 ID。
|
||||
/// </summary>
|
||||
public string StoreId { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// 积分商城商品 ID。
|
||||
/// </summary>
|
||||
public string PointMallProductId { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// 状态(enabled/disabled)。
|
||||
/// </summary>
|
||||
public string Status { get; set; } = "disabled";
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 删除积分商城商品请求。
|
||||
/// </summary>
|
||||
public sealed class DeletePointMallProductRequest
|
||||
{
|
||||
/// <summary>
|
||||
/// 门店 ID。
|
||||
/// </summary>
|
||||
public string StoreId { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// 积分商城商品 ID。
|
||||
/// </summary>
|
||||
public string PointMallProductId { get; set; } = string.Empty;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 积分商城兑换记录分页查询请求。
|
||||
/// </summary>
|
||||
public sealed class PointMallRecordListRequest
|
||||
{
|
||||
/// <summary>
|
||||
/// 门店 ID。
|
||||
/// </summary>
|
||||
public string StoreId { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// 兑换类型(product/coupon/physical)。
|
||||
/// </summary>
|
||||
public string? RedeemType { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 状态(pending_pickup/issued/completed/canceled)。
|
||||
/// </summary>
|
||||
public string? Status { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 开始日期(yyyy-MM-dd)。
|
||||
/// </summary>
|
||||
public string? StartDate { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 结束日期(yyyy-MM-dd)。
|
||||
/// </summary>
|
||||
public string? EndDate { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 关键字。
|
||||
/// </summary>
|
||||
public string? Keyword { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 页码。
|
||||
/// </summary>
|
||||
public int Page { get; set; } = 1;
|
||||
|
||||
/// <summary>
|
||||
/// 每页条数。
|
||||
/// </summary>
|
||||
public int PageSize { get; set; } = 10;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 积分商城兑换记录详情请求。
|
||||
/// </summary>
|
||||
public sealed class PointMallRecordDetailRequest
|
||||
{
|
||||
/// <summary>
|
||||
/// 门店 ID。
|
||||
/// </summary>
|
||||
public string StoreId { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// 兑换记录 ID。
|
||||
/// </summary>
|
||||
public string RecordId { get; set; } = string.Empty;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 导出积分商城兑换记录请求。
|
||||
/// </summary>
|
||||
public sealed class ExportPointMallRecordRequest
|
||||
{
|
||||
/// <summary>
|
||||
/// 门店 ID。
|
||||
/// </summary>
|
||||
public string StoreId { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// 兑换类型(product/coupon/physical)。
|
||||
/// </summary>
|
||||
public string? RedeemType { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 状态(pending_pickup/issued/completed/canceled)。
|
||||
/// </summary>
|
||||
public string? Status { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 开始日期(yyyy-MM-dd)。
|
||||
/// </summary>
|
||||
public string? StartDate { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 结束日期(yyyy-MM-dd)。
|
||||
/// </summary>
|
||||
public string? EndDate { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 关键字。
|
||||
/// </summary>
|
||||
public string? Keyword { get; set; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 写入积分商城兑换记录请求。
|
||||
/// </summary>
|
||||
public sealed class WritePointMallRecordRequest
|
||||
{
|
||||
/// <summary>
|
||||
/// 门店 ID。
|
||||
/// </summary>
|
||||
public string StoreId { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// 积分商城商品 ID。
|
||||
/// </summary>
|
||||
public string PointMallProductId { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// 会员 ID。
|
||||
/// </summary>
|
||||
public string MemberId { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// 兑换时间(可空,默认当前时间)。
|
||||
/// </summary>
|
||||
public DateTime? RedeemedAt { get; set; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 核销积分商城兑换记录请求。
|
||||
/// </summary>
|
||||
public sealed class VerifyPointMallRecordRequest
|
||||
{
|
||||
/// <summary>
|
||||
/// 门店 ID。
|
||||
/// </summary>
|
||||
public string StoreId { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// 兑换记录 ID。
|
||||
/// </summary>
|
||||
public string RecordId { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// 核销方式(scan/manual)。
|
||||
/// </summary>
|
||||
public string VerifyMethod { get; set; } = "manual";
|
||||
|
||||
/// <summary>
|
||||
/// 核销备注。
|
||||
/// </summary>
|
||||
public string? VerifyRemark { get; set; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 积分商城规则响应。
|
||||
/// </summary>
|
||||
public sealed class PointMallRuleResponse
|
||||
{
|
||||
/// <summary>
|
||||
/// 门店 ID。
|
||||
/// </summary>
|
||||
public string StoreId { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// 是否启用消费获取。
|
||||
/// </summary>
|
||||
public bool IsConsumeRewardEnabled { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 每消费多少元触发一次积分计算。
|
||||
/// </summary>
|
||||
public int ConsumeAmountPerStep { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 每步获得积分。
|
||||
/// </summary>
|
||||
public int ConsumeRewardPointsPerStep { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 是否启用评价奖励。
|
||||
/// </summary>
|
||||
public bool IsReviewRewardEnabled { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 评价奖励积分。
|
||||
/// </summary>
|
||||
public int ReviewRewardPoints { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 是否启用注册奖励。
|
||||
/// </summary>
|
||||
public bool IsRegisterRewardEnabled { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 注册奖励积分。
|
||||
/// </summary>
|
||||
public int RegisterRewardPoints { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 是否启用签到奖励。
|
||||
/// </summary>
|
||||
public bool IsSigninRewardEnabled { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 签到奖励积分。
|
||||
/// </summary>
|
||||
public int SigninRewardPoints { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 有效期模式(permanent/yearly_clear)。
|
||||
/// </summary>
|
||||
public string ExpiryMode { get; set; } = "yearly_clear";
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 积分商城规则统计响应。
|
||||
/// </summary>
|
||||
public sealed class PointMallRuleStatsResponse
|
||||
{
|
||||
/// <summary>
|
||||
/// 累计发放积分。
|
||||
/// </summary>
|
||||
public int TotalIssuedPoints { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 已兑换积分。
|
||||
/// </summary>
|
||||
public int RedeemedPoints { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 积分用户。
|
||||
/// </summary>
|
||||
public int PointMembers { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 兑换率(0-100)。
|
||||
/// </summary>
|
||||
public decimal RedeemRate { get; set; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 积分商城规则详情响应。
|
||||
/// </summary>
|
||||
public sealed class PointMallRuleDetailResultResponse
|
||||
{
|
||||
/// <summary>
|
||||
/// 规则。
|
||||
/// </summary>
|
||||
public PointMallRuleResponse Rule { get; set; } = new();
|
||||
|
||||
/// <summary>
|
||||
/// 统计。
|
||||
/// </summary>
|
||||
public PointMallRuleStatsResponse Stats { get; set; } = new();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 积分商城商品响应。
|
||||
/// </summary>
|
||||
public sealed class PointMallProductResponse
|
||||
{
|
||||
/// <summary>
|
||||
/// 积分商城商品 ID。
|
||||
/// </summary>
|
||||
public string PointMallProductId { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// 门店 ID。
|
||||
/// </summary>
|
||||
public string StoreId { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// 展示名称。
|
||||
/// </summary>
|
||||
public string Name { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// 展示图片。
|
||||
/// </summary>
|
||||
public string? ImageUrl { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 兑换类型(product/coupon/physical)。
|
||||
/// </summary>
|
||||
public string RedeemType { get; set; } = "product";
|
||||
|
||||
/// <summary>
|
||||
/// 兑换类型文案。
|
||||
/// </summary>
|
||||
public string RedeemTypeText { get; set; } = "商品";
|
||||
|
||||
/// <summary>
|
||||
/// 关联商品 ID。
|
||||
/// </summary>
|
||||
public string? ProductId { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 关联优惠券模板 ID。
|
||||
/// </summary>
|
||||
public string? CouponTemplateId { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 实物名称。
|
||||
/// </summary>
|
||||
public string? PhysicalName { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 领取方式(store_pickup/delivery)。
|
||||
/// </summary>
|
||||
public string? PickupMethod { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 商品描述。
|
||||
/// </summary>
|
||||
public string? Description { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 兑换方式(points/mixed)。
|
||||
/// </summary>
|
||||
public string ExchangeType { get; set; } = "points";
|
||||
|
||||
/// <summary>
|
||||
/// 所需积分。
|
||||
/// </summary>
|
||||
public int RequiredPoints { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 现金部分。
|
||||
/// </summary>
|
||||
public decimal CashAmount { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 初始库存。
|
||||
/// </summary>
|
||||
public int StockTotal { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 剩余库存。
|
||||
/// </summary>
|
||||
public int StockAvailable { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 已兑换数量。
|
||||
/// </summary>
|
||||
public int RedeemedCount { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 每人限兑次数。
|
||||
/// </summary>
|
||||
public int? PerMemberLimit { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 通知渠道。
|
||||
/// </summary>
|
||||
public List<string> NotifyChannels { get; set; } = [];
|
||||
|
||||
/// <summary>
|
||||
/// 状态(enabled/disabled)。
|
||||
/// </summary>
|
||||
public string Status { get; set; } = "enabled";
|
||||
|
||||
/// <summary>
|
||||
/// 状态文案。
|
||||
/// </summary>
|
||||
public string StatusText { get; set; } = "上架";
|
||||
|
||||
/// <summary>
|
||||
/// 更新时间。
|
||||
/// </summary>
|
||||
public string UpdatedAt { get; set; } = string.Empty;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 积分商城商品列表响应。
|
||||
/// </summary>
|
||||
public sealed class PointMallProductListResultResponse
|
||||
{
|
||||
/// <summary>
|
||||
/// 列表。
|
||||
/// </summary>
|
||||
public List<PointMallProductResponse> Items { get; set; } = [];
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 积分商城兑换记录响应。
|
||||
/// </summary>
|
||||
public class PointMallRecordResponse
|
||||
{
|
||||
/// <summary>
|
||||
/// 兑换记录 ID。
|
||||
/// </summary>
|
||||
public string RecordId { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// 兑换单号。
|
||||
/// </summary>
|
||||
public string RecordNo { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// 积分商城商品 ID。
|
||||
/// </summary>
|
||||
public string PointMallProductId { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// 商品名称。
|
||||
/// </summary>
|
||||
public string ProductName { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// 兑换类型(product/coupon/physical)。
|
||||
/// </summary>
|
||||
public string RedeemType { get; set; } = "product";
|
||||
|
||||
/// <summary>
|
||||
/// 兑换类型文案。
|
||||
/// </summary>
|
||||
public string RedeemTypeText { get; set; } = "商品";
|
||||
|
||||
/// <summary>
|
||||
/// 兑换方式(points/mixed)。
|
||||
/// </summary>
|
||||
public string ExchangeType { get; set; } = "points";
|
||||
|
||||
/// <summary>
|
||||
/// 会员 ID。
|
||||
/// </summary>
|
||||
public string MemberId { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// 会员名称。
|
||||
/// </summary>
|
||||
public string MemberName { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// 会员手机号(脱敏)。
|
||||
/// </summary>
|
||||
public string MemberMobileMasked { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// 消耗积分。
|
||||
/// </summary>
|
||||
public int UsedPoints { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 现金部分。
|
||||
/// </summary>
|
||||
public decimal CashAmount { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 状态(pending_pickup/issued/completed/canceled)。
|
||||
/// </summary>
|
||||
public string Status { get; set; } = "issued";
|
||||
|
||||
/// <summary>
|
||||
/// 状态文案。
|
||||
/// </summary>
|
||||
public string StatusText { get; set; } = "已发放";
|
||||
|
||||
/// <summary>
|
||||
/// 兑换时间。
|
||||
/// </summary>
|
||||
public string RedeemedAt { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// 发放时间。
|
||||
/// </summary>
|
||||
public string? IssuedAt { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 核销时间。
|
||||
/// </summary>
|
||||
public string? VerifiedAt { get; set; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 积分商城兑换记录详情响应。
|
||||
/// </summary>
|
||||
public sealed class PointMallRecordDetailResponse : PointMallRecordResponse
|
||||
{
|
||||
/// <summary>
|
||||
/// 核销方式(scan/manual)。
|
||||
/// </summary>
|
||||
public string? VerifyMethod { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 核销方式文案。
|
||||
/// </summary>
|
||||
public string? VerifyMethodText { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 核销备注。
|
||||
/// </summary>
|
||||
public string? VerifyRemark { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 核销人 ID。
|
||||
/// </summary>
|
||||
public string? VerifiedBy { get; set; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 积分商城兑换记录统计响应。
|
||||
/// </summary>
|
||||
public sealed class PointMallRecordStatsResponse
|
||||
{
|
||||
/// <summary>
|
||||
/// 今日兑换。
|
||||
/// </summary>
|
||||
public int TodayRedeemCount { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 待领取实物。
|
||||
/// </summary>
|
||||
public int PendingPhysicalCount { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 本月消耗积分。
|
||||
/// </summary>
|
||||
public int CurrentMonthUsedPoints { get; set; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 积分商城兑换记录分页响应。
|
||||
/// </summary>
|
||||
public sealed class PointMallRecordListResultResponse
|
||||
{
|
||||
/// <summary>
|
||||
/// 列表。
|
||||
/// </summary>
|
||||
public List<PointMallRecordResponse> 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 PointMallRecordStatsResponse Stats { get; set; } = new();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 积分商城兑换记录导出响应。
|
||||
/// </summary>
|
||||
public sealed class PointMallRecordExportResponse
|
||||
{
|
||||
/// <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,399 @@
|
||||
namespace TakeoutSaaS.TenantApi.Contracts.Member;
|
||||
|
||||
/// <summary>
|
||||
/// 储值卡方案列表请求。
|
||||
/// </summary>
|
||||
public sealed class StoredCardPlanListRequest
|
||||
{
|
||||
/// <summary>
|
||||
/// 门店 ID。
|
||||
/// </summary>
|
||||
public string StoreId { get; set; } = string.Empty;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 保存储值卡方案请求。
|
||||
/// </summary>
|
||||
public sealed class SaveStoredCardPlanRequest
|
||||
{
|
||||
/// <summary>
|
||||
/// 门店 ID。
|
||||
/// </summary>
|
||||
public string StoreId { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// 方案 ID(编辑时传)。
|
||||
/// </summary>
|
||||
public string? PlanId { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 充值金额。
|
||||
/// </summary>
|
||||
public decimal RechargeAmount { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 赠送金额。
|
||||
/// </summary>
|
||||
public decimal GiftAmount { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 排序值。
|
||||
/// </summary>
|
||||
public int SortOrder { get; set; } = 100;
|
||||
|
||||
/// <summary>
|
||||
/// 状态(enabled/disabled)。
|
||||
/// </summary>
|
||||
public string Status { get; set; } = "enabled";
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 修改方案状态请求。
|
||||
/// </summary>
|
||||
public sealed class ChangeStoredCardPlanStatusRequest
|
||||
{
|
||||
/// <summary>
|
||||
/// 门店 ID。
|
||||
/// </summary>
|
||||
public string StoreId { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// 方案 ID。
|
||||
/// </summary>
|
||||
public string PlanId { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// 状态(enabled/disabled)。
|
||||
/// </summary>
|
||||
public string Status { get; set; } = "disabled";
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 删除方案请求。
|
||||
/// </summary>
|
||||
public sealed class DeleteStoredCardPlanRequest
|
||||
{
|
||||
/// <summary>
|
||||
/// 门店 ID。
|
||||
/// </summary>
|
||||
public string StoreId { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// 方案 ID。
|
||||
/// </summary>
|
||||
public string PlanId { get; set; } = string.Empty;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 充值记录分页查询请求。
|
||||
/// </summary>
|
||||
public sealed class StoredCardRechargeRecordListRequest
|
||||
{
|
||||
/// <summary>
|
||||
/// 门店 ID。
|
||||
/// </summary>
|
||||
public string StoreId { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// 开始日期(yyyy-MM-dd)。
|
||||
/// </summary>
|
||||
public string? StartDate { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 结束日期(yyyy-MM-dd)。
|
||||
/// </summary>
|
||||
public string? EndDate { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 关键字(会员名称/手机号/单号)。
|
||||
/// </summary>
|
||||
public string? Keyword { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 页码。
|
||||
/// </summary>
|
||||
public int Page { get; set; } = 1;
|
||||
|
||||
/// <summary>
|
||||
/// 每页条数。
|
||||
/// </summary>
|
||||
public int PageSize { get; set; } = 8;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 充值记录导出请求。
|
||||
/// </summary>
|
||||
public sealed class ExportStoredCardRechargeRecordRequest
|
||||
{
|
||||
/// <summary>
|
||||
/// 门店 ID。
|
||||
/// </summary>
|
||||
public string StoreId { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// 开始日期(yyyy-MM-dd)。
|
||||
/// </summary>
|
||||
public string? StartDate { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 结束日期(yyyy-MM-dd)。
|
||||
/// </summary>
|
||||
public string? EndDate { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 关键字(会员名称/手机号/单号)。
|
||||
/// </summary>
|
||||
public string? Keyword { get; set; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 写入充值记录请求。
|
||||
/// </summary>
|
||||
public sealed class WriteStoredCardRechargeRecordRequest
|
||||
{
|
||||
/// <summary>
|
||||
/// 门店 ID。
|
||||
/// </summary>
|
||||
public string StoreId { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// 会员 ID(必填)。
|
||||
/// </summary>
|
||||
public string MemberId { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// 方案 ID(可空)。
|
||||
/// </summary>
|
||||
public string? PlanId { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 充值金额。
|
||||
/// </summary>
|
||||
public decimal RechargeAmount { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 赠送金额。
|
||||
/// </summary>
|
||||
public decimal GiftAmount { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 支付方式(wechat/alipay/cash/card/balance)。
|
||||
/// </summary>
|
||||
public string PaymentMethod { get; set; } = "wechat";
|
||||
|
||||
/// <summary>
|
||||
/// 充值时间(可空,默认当前时间)。
|
||||
/// </summary>
|
||||
public DateTime? RechargedAt { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 备注。
|
||||
/// </summary>
|
||||
public string? Remark { get; set; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 储值卡方案统计响应。
|
||||
/// </summary>
|
||||
public sealed class StoredCardPlanStatsResponse
|
||||
{
|
||||
/// <summary>
|
||||
/// 储值总额。
|
||||
/// </summary>
|
||||
public decimal TotalRechargeAmount { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 赠金总额。
|
||||
/// </summary>
|
||||
public decimal TotalGiftAmount { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 本月充值。
|
||||
/// </summary>
|
||||
public decimal CurrentMonthRechargeAmount { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 储值用户。
|
||||
/// </summary>
|
||||
public int RechargeMemberCount { get; set; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 储值卡方案响应。
|
||||
/// </summary>
|
||||
public sealed class StoredCardPlanResponse
|
||||
{
|
||||
/// <summary>
|
||||
/// 方案 ID。
|
||||
/// </summary>
|
||||
public string PlanId { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// 充值金额。
|
||||
/// </summary>
|
||||
public decimal RechargeAmount { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 赠送金额。
|
||||
/// </summary>
|
||||
public decimal GiftAmount { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 到账金额。
|
||||
/// </summary>
|
||||
public decimal ArrivedAmount { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 排序。
|
||||
/// </summary>
|
||||
public int SortOrder { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 状态。
|
||||
/// </summary>
|
||||
public string Status { get; set; } = "enabled";
|
||||
|
||||
/// <summary>
|
||||
/// 累计充值次数。
|
||||
/// </summary>
|
||||
public int RechargeCount { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 累计充值金额。
|
||||
/// </summary>
|
||||
public decimal TotalRechargeAmount { get; set; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 储值卡方案列表响应。
|
||||
/// </summary>
|
||||
public sealed class StoredCardPlanListResultResponse
|
||||
{
|
||||
/// <summary>
|
||||
/// 方案列表。
|
||||
/// </summary>
|
||||
public List<StoredCardPlanResponse> Items { get; set; } = [];
|
||||
|
||||
/// <summary>
|
||||
/// 页面统计。
|
||||
/// </summary>
|
||||
public StoredCardPlanStatsResponse Stats { get; set; } = new();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 充值记录响应。
|
||||
/// </summary>
|
||||
public sealed class StoredCardRechargeRecordResponse
|
||||
{
|
||||
/// <summary>
|
||||
/// 记录 ID。
|
||||
/// </summary>
|
||||
public string RecordId { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// 充值单号。
|
||||
/// </summary>
|
||||
public string RecordNo { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// 会员 ID。
|
||||
/// </summary>
|
||||
public string MemberId { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// 会员名称。
|
||||
/// </summary>
|
||||
public string MemberName { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// 手机号(脱敏)。
|
||||
/// </summary>
|
||||
public string MemberMobileMasked { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// 充值金额。
|
||||
/// </summary>
|
||||
public decimal RechargeAmount { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 赠送金额。
|
||||
/// </summary>
|
||||
public decimal GiftAmount { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 到账金额。
|
||||
/// </summary>
|
||||
public decimal ArrivedAmount { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 支付方式编码。
|
||||
/// </summary>
|
||||
public string PaymentMethod { get; set; } = "unknown";
|
||||
|
||||
/// <summary>
|
||||
/// 支付方式文案。
|
||||
/// </summary>
|
||||
public string PaymentMethodText { get; set; } = "未知";
|
||||
|
||||
/// <summary>
|
||||
/// 充值时间(本地显示字符串)。
|
||||
/// </summary>
|
||||
public string RechargedAt { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// 方案 ID。
|
||||
/// </summary>
|
||||
public string? PlanId { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 备注。
|
||||
/// </summary>
|
||||
public string? Remark { get; set; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 充值记录分页结果响应。
|
||||
/// </summary>
|
||||
public sealed class StoredCardRechargeRecordListResultResponse
|
||||
{
|
||||
/// <summary>
|
||||
/// 列表项。
|
||||
/// </summary>
|
||||
public List<StoredCardRechargeRecordResponse> 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 StoredCardRechargeRecordExportResponse
|
||||
{
|
||||
/// <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;
|
||||
}
|
||||
@@ -79,6 +79,10 @@ public sealed class StoreFeesSettingsDto
|
||||
/// </summary>
|
||||
public decimal BaseDeliveryFee { get; set; }
|
||||
/// <summary>
|
||||
/// PlatformServiceRate。
|
||||
/// </summary>
|
||||
public decimal PlatformServiceRate { get; set; }
|
||||
/// <summary>
|
||||
/// FreeDeliveryThreshold。
|
||||
/// </summary>
|
||||
public decimal? FreeDeliveryThreshold { get; set; }
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
392
src/Api/TakeoutSaaS.TenantApi/Controllers/CustomerController.cs
Normal file
392
src/Api/TakeoutSaaS.TenantApi/Controllers/CustomerController.cs
Normal file
@@ -0,0 +1,392 @@
|
||||
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/list")]
|
||||
public sealed class CustomerController(
|
||||
IMediator mediator,
|
||||
TakeoutAppDbContext dbContext,
|
||||
StoreContextService storeContextService)
|
||||
: BaseApiController
|
||||
{
|
||||
private const string ViewPermission = "tenant:customer:list:view";
|
||||
private const string ManagePermission = "tenant:customer:list:manage";
|
||||
private const string ProfilePermission = "tenant:customer:profile:view";
|
||||
|
||||
/// <summary>
|
||||
/// 获取客户列表。
|
||||
/// </summary>
|
||||
[HttpGet("list")]
|
||||
[PermissionAuthorize(ViewPermission, ManagePermission)]
|
||||
[ProducesResponseType(typeof(ApiResponse<CustomerListResultResponse>), StatusCodes.Status200OK)]
|
||||
public async Task<ApiResponse<CustomerListResultResponse>> List(
|
||||
[FromQuery] CustomerListRequest request,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var visibleStoreIds = await ResolveVisibleStoreIdsAsync(request.StoreId, cancellationToken);
|
||||
|
||||
var result = await mediator.Send(new SearchCustomerListQuery
|
||||
{
|
||||
VisibleStoreIds = visibleStoreIds,
|
||||
Keyword = request.Keyword,
|
||||
Tag = request.Tag,
|
||||
OrderCountRange = request.OrderCountRange,
|
||||
RegisterPeriodDays = ParseRegisterPeriodDays(request.RegisterPeriod),
|
||||
Page = Math.Max(1, request.Page),
|
||||
PageSize = Math.Clamp(request.PageSize, 1, 200)
|
||||
}, cancellationToken);
|
||||
|
||||
return ApiResponse<CustomerListResultResponse>.Ok(new CustomerListResultResponse
|
||||
{
|
||||
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<CustomerListStatsResponse>), StatusCodes.Status200OK)]
|
||||
public async Task<ApiResponse<CustomerListStatsResponse>> Stats(
|
||||
[FromQuery] CustomerListFilterRequest request,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var visibleStoreIds = await ResolveVisibleStoreIdsAsync(request.StoreId, cancellationToken);
|
||||
|
||||
var result = await mediator.Send(new GetCustomerListStatsQuery
|
||||
{
|
||||
VisibleStoreIds = visibleStoreIds,
|
||||
Keyword = request.Keyword,
|
||||
Tag = request.Tag,
|
||||
OrderCountRange = request.OrderCountRange,
|
||||
RegisterPeriodDays = ParseRegisterPeriodDays(request.RegisterPeriod)
|
||||
}, cancellationToken);
|
||||
|
||||
return ApiResponse<CustomerListStatsResponse>.Ok(new CustomerListStatsResponse
|
||||
{
|
||||
TotalCustomers = result.TotalCustomers,
|
||||
MonthlyNewCustomers = result.MonthlyNewCustomers,
|
||||
MonthlyGrowthRatePercent = result.MonthlyGrowthRatePercent,
|
||||
ActiveCustomers = result.ActiveCustomers,
|
||||
AverageAmountLast30Days = result.AverageAmountLast30Days
|
||||
});
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 获取客户详情(一级抽屉)。
|
||||
/// </summary>
|
||||
[HttpGet("detail")]
|
||||
[PermissionAuthorize(ViewPermission, ManagePermission)]
|
||||
[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(ProfilePermission)]
|
||||
[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>
|
||||
/// 导出客户 CSV。
|
||||
/// </summary>
|
||||
[HttpGet("export")]
|
||||
[PermissionAuthorize(ManagePermission)]
|
||||
[ProducesResponseType(typeof(ApiResponse<CustomerExportResponse>), StatusCodes.Status200OK)]
|
||||
public async Task<ApiResponse<CustomerExportResponse>> Export(
|
||||
[FromQuery] CustomerListFilterRequest request,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var visibleStoreIds = await ResolveVisibleStoreIdsAsync(request.StoreId, cancellationToken);
|
||||
|
||||
var result = await mediator.Send(new ExportCustomerCsvQuery
|
||||
{
|
||||
VisibleStoreIds = visibleStoreIds,
|
||||
Keyword = request.Keyword,
|
||||
Tag = request.Tag,
|
||||
OrderCountRange = request.OrderCountRange,
|
||||
RegisterPeriodDays = ParseRegisterPeriodDays(request.RegisterPeriod)
|
||||
}, 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 int? ParseRegisterPeriodDays(string? registerPeriod)
|
||||
{
|
||||
var normalized = (registerPeriod ?? string.Empty).Trim().ToLowerInvariant();
|
||||
if (string.IsNullOrWhiteSpace(normalized))
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
return normalized switch
|
||||
{
|
||||
"7" or "7d" => 7,
|
||||
"30" or "30d" => 30,
|
||||
"90" or "90d" => 90,
|
||||
_ => throw new BusinessException(ErrorCodes.BadRequest, "registerPeriod 参数不合法")
|
||||
};
|
||||
}
|
||||
|
||||
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 CustomerListItemResponse MapListItem(CustomerListItemDto source)
|
||||
{
|
||||
return new CustomerListItemResponse
|
||||
{
|
||||
CustomerKey = source.CustomerKey,
|
||||
Name = source.Name,
|
||||
PhoneMasked = source.PhoneMasked,
|
||||
AvatarText = source.AvatarText,
|
||||
AvatarColor = source.AvatarColor,
|
||||
OrderCount = source.OrderCount,
|
||||
OrderCountBarPercent = source.OrderCountBarPercent,
|
||||
TotalAmount = source.TotalAmount,
|
||||
AverageAmount = source.AverageAmount,
|
||||
LastOrderAt = ToDateOnly(source.LastOrderAt),
|
||||
Tags = source.Tags.Select(MapTag).ToList(),
|
||||
IsDimmed = source.IsDimmed
|
||||
};
|
||||
}
|
||||
|
||||
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 CustomerTagResponse MapTag(CustomerTagDto source)
|
||||
{
|
||||
return new CustomerTagResponse
|
||||
{
|
||||
Code = source.Code,
|
||||
Label = source.Label,
|
||||
Tone = source.Tone
|
||||
};
|
||||
}
|
||||
|
||||
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 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 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);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,270 @@
|
||||
using System.Globalization;
|
||||
using MediatR;
|
||||
using Microsoft.AspNetCore.Authorization;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using TakeoutSaaS.Application.App.Finance.Cost.Commands;
|
||||
using TakeoutSaaS.Application.App.Finance.Cost.Dto;
|
||||
using TakeoutSaaS.Application.App.Finance.Cost.Queries;
|
||||
using TakeoutSaaS.Application.App.Stores.Services;
|
||||
using TakeoutSaaS.Domain.Finance.Enums;
|
||||
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.Finance;
|
||||
|
||||
namespace TakeoutSaaS.TenantApi.Controllers;
|
||||
|
||||
/// <summary>
|
||||
/// 财务中心成本管理。
|
||||
/// </summary>
|
||||
[ApiVersion("1.0")]
|
||||
[Authorize]
|
||||
[Route("api/tenant/v{version:apiVersion}/finance/cost")]
|
||||
public sealed class FinanceCostController(
|
||||
IMediator mediator,
|
||||
TakeoutAppDbContext dbContext,
|
||||
StoreContextService storeContextService)
|
||||
: BaseApiController
|
||||
{
|
||||
private const string ViewPermission = "tenant:finance:cost:view";
|
||||
private const string ManagePermission = "tenant:finance:cost:manage";
|
||||
|
||||
/// <summary>
|
||||
/// 查询成本录入数据。
|
||||
/// </summary>
|
||||
[HttpGet("entry")]
|
||||
[PermissionAuthorize(ViewPermission, ManagePermission)]
|
||||
[ProducesResponseType(typeof(ApiResponse<FinanceCostEntryResponse>), StatusCodes.Status200OK)]
|
||||
public async Task<ApiResponse<FinanceCostEntryResponse>> Entry(
|
||||
[FromQuery] FinanceCostEntryRequest request,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
// 1. 解析维度与作用域。
|
||||
var scope = await ParseScopeAsync(request, cancellationToken);
|
||||
|
||||
// 2. 查询录入数据并映射响应。
|
||||
var result = await mediator.Send(new GetFinanceCostEntryQuery
|
||||
{
|
||||
Dimension = scope.Dimension,
|
||||
StoreId = scope.StoreId,
|
||||
CostMonth = scope.CostMonth
|
||||
}, cancellationToken);
|
||||
|
||||
return ApiResponse<FinanceCostEntryResponse>.Ok(MapEntry(result));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 保存成本录入数据。
|
||||
/// </summary>
|
||||
[HttpPost("entry/save")]
|
||||
[PermissionAuthorize(ManagePermission)]
|
||||
[ProducesResponseType(typeof(ApiResponse<FinanceCostEntryResponse>), StatusCodes.Status200OK)]
|
||||
public async Task<ApiResponse<FinanceCostEntryResponse>> SaveEntry(
|
||||
[FromBody] SaveFinanceCostEntryRequest request,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
// 1. 解析维度与作用域。
|
||||
var scope = await ParseScopeAsync(request, cancellationToken);
|
||||
|
||||
// 2. 发起保存命令并映射响应。
|
||||
var result = await mediator.Send(new SaveFinanceCostEntryCommand
|
||||
{
|
||||
Dimension = scope.Dimension,
|
||||
StoreId = scope.StoreId,
|
||||
CostMonth = scope.CostMonth,
|
||||
Categories = (request.Categories ?? [])
|
||||
.Select(MapSaveCategory)
|
||||
.ToList()
|
||||
}, cancellationToken);
|
||||
|
||||
return ApiResponse<FinanceCostEntryResponse>.Ok(MapEntry(result));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 查询成本分析数据。
|
||||
/// </summary>
|
||||
[HttpGet("analysis")]
|
||||
[PermissionAuthorize(ViewPermission, ManagePermission)]
|
||||
[ProducesResponseType(typeof(ApiResponse<FinanceCostAnalysisResponse>), StatusCodes.Status200OK)]
|
||||
public async Task<ApiResponse<FinanceCostAnalysisResponse>> Analysis(
|
||||
[FromQuery] FinanceCostAnalysisRequest request,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
// 1. 解析维度与作用域。
|
||||
var scope = await ParseScopeAsync(request, cancellationToken);
|
||||
|
||||
// 2. 查询分析数据并映射响应。
|
||||
var result = await mediator.Send(new GetFinanceCostAnalysisQuery
|
||||
{
|
||||
Dimension = scope.Dimension,
|
||||
StoreId = scope.StoreId,
|
||||
CostMonth = scope.CostMonth,
|
||||
TrendMonthCount = Math.Clamp(request.TrendMonthCount, 3, 12)
|
||||
}, cancellationToken);
|
||||
|
||||
return ApiResponse<FinanceCostAnalysisResponse>.Ok(MapAnalysis(result));
|
||||
}
|
||||
|
||||
private async Task<(FinanceCostDimension Dimension, long? StoreId, DateTime CostMonth)> ParseScopeAsync(
|
||||
FinanceCostScopeRequest request,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var dimension = ParseDimension(request.Dimension);
|
||||
var costMonth = ParseMonthOrDefault(request.Month);
|
||||
|
||||
if (dimension == FinanceCostDimension.Tenant)
|
||||
{
|
||||
return (dimension, null, costMonth);
|
||||
}
|
||||
|
||||
var storeId = StoreApiHelpers.ParseRequiredSnowflake(request.StoreId, nameof(request.StoreId));
|
||||
await EnsureStoreAccessibleAsync(storeId, cancellationToken);
|
||||
return (dimension, storeId, costMonth);
|
||||
}
|
||||
|
||||
private async Task EnsureStoreAccessibleAsync(long storeId, CancellationToken cancellationToken)
|
||||
{
|
||||
var (tenantId, merchantId) = StoreApiHelpers.GetTenantMerchantContext(storeContextService);
|
||||
await StoreApiHelpers.EnsureStoreAccessibleAsync(dbContext, tenantId, merchantId, storeId, cancellationToken);
|
||||
}
|
||||
|
||||
private static FinanceCostDimension ParseDimension(string? value)
|
||||
{
|
||||
var normalized = (value ?? string.Empty).Trim().ToLowerInvariant();
|
||||
return normalized switch
|
||||
{
|
||||
"" or "tenant" => FinanceCostDimension.Tenant,
|
||||
"store" => FinanceCostDimension.Store,
|
||||
_ => throw new BusinessException(ErrorCodes.BadRequest, "dimension 非法")
|
||||
};
|
||||
}
|
||||
|
||||
private static DateTime ParseMonthOrDefault(string? value)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(value))
|
||||
{
|
||||
var utcNow = DateTime.UtcNow;
|
||||
return new DateTime(utcNow.Year, utcNow.Month, 1, 0, 0, 0, DateTimeKind.Utc);
|
||||
}
|
||||
|
||||
if (DateTime.TryParseExact(
|
||||
value.Trim(),
|
||||
"yyyy-MM",
|
||||
CultureInfo.InvariantCulture,
|
||||
DateTimeStyles.None,
|
||||
out var parsed))
|
||||
{
|
||||
return new DateTime(parsed.Year, parsed.Month, 1, 0, 0, 0, DateTimeKind.Utc);
|
||||
}
|
||||
|
||||
throw new BusinessException(ErrorCodes.BadRequest, "month 格式必须为 yyyy-MM");
|
||||
}
|
||||
|
||||
private static FinanceCostCategory ParseCategory(string? value)
|
||||
{
|
||||
var normalized = (value ?? string.Empty).Trim().ToLowerInvariant();
|
||||
return normalized switch
|
||||
{
|
||||
"food" => FinanceCostCategory.FoodMaterial,
|
||||
"labor" => FinanceCostCategory.Labor,
|
||||
"fixed" => FinanceCostCategory.FixedExpense,
|
||||
"packaging" => FinanceCostCategory.PackagingConsumable,
|
||||
_ => throw new BusinessException(ErrorCodes.BadRequest, "category 非法")
|
||||
};
|
||||
}
|
||||
|
||||
private static SaveFinanceCostCategoryCommandItem MapSaveCategory(SaveFinanceCostCategoryRequest source)
|
||||
{
|
||||
return new SaveFinanceCostCategoryCommandItem
|
||||
{
|
||||
Category = ParseCategory(source.Category),
|
||||
TotalAmount = source.TotalAmount,
|
||||
Items = (source.Items ?? [])
|
||||
.Select(item => new SaveFinanceCostDetailCommandItem
|
||||
{
|
||||
ItemId = StoreApiHelpers.ParseSnowflakeOrNull(item.ItemId),
|
||||
ItemName = item.ItemName,
|
||||
Amount = item.Amount,
|
||||
Quantity = item.Quantity,
|
||||
UnitPrice = item.UnitPrice,
|
||||
SortOrder = item.SortOrder
|
||||
})
|
||||
.ToList()
|
||||
};
|
||||
}
|
||||
|
||||
private static FinanceCostEntryResponse MapEntry(FinanceCostEntryDto source)
|
||||
{
|
||||
return new FinanceCostEntryResponse
|
||||
{
|
||||
Dimension = source.Dimension,
|
||||
StoreId = source.StoreId,
|
||||
Month = source.Month,
|
||||
MonthRevenue = source.MonthRevenue,
|
||||
TotalCost = source.TotalCost,
|
||||
CostRate = source.CostRate,
|
||||
Categories = source.Categories.Select(category => new FinanceCostEntryCategoryResponse
|
||||
{
|
||||
Category = category.Category,
|
||||
CategoryText = category.CategoryText,
|
||||
TotalAmount = category.TotalAmount,
|
||||
Percentage = category.Percentage,
|
||||
Items = category.Items.Select(item => new FinanceCostEntryDetailResponse
|
||||
{
|
||||
ItemId = item.ItemId,
|
||||
ItemName = item.ItemName,
|
||||
Amount = item.Amount,
|
||||
Quantity = item.Quantity,
|
||||
UnitPrice = item.UnitPrice,
|
||||
SortOrder = item.SortOrder
|
||||
}).ToList()
|
||||
}).ToList()
|
||||
};
|
||||
}
|
||||
|
||||
private static FinanceCostAnalysisResponse MapAnalysis(FinanceCostAnalysisDto source)
|
||||
{
|
||||
return new FinanceCostAnalysisResponse
|
||||
{
|
||||
Dimension = source.Dimension,
|
||||
StoreId = source.StoreId,
|
||||
Month = source.Month,
|
||||
Stats = new FinanceCostAnalysisStatsResponse
|
||||
{
|
||||
TotalCost = source.Stats.TotalCost,
|
||||
FoodCostRate = source.Stats.FoodCostRate,
|
||||
AverageCostPerPaidOrder = source.Stats.AverageCostPerPaidOrder,
|
||||
MonthOnMonthChangeRate = source.Stats.MonthOnMonthChangeRate,
|
||||
Revenue = source.Stats.Revenue,
|
||||
PaidOrderCount = source.Stats.PaidOrderCount
|
||||
},
|
||||
Trend = source.Trend.Select(item => new FinanceCostTrendPointResponse
|
||||
{
|
||||
Month = item.Month,
|
||||
TotalCost = item.TotalCost,
|
||||
Revenue = item.Revenue,
|
||||
CostRate = item.CostRate
|
||||
}).ToList(),
|
||||
Composition = source.Composition.Select(item => new FinanceCostCompositionResponse
|
||||
{
|
||||
Category = item.Category,
|
||||
CategoryText = item.CategoryText,
|
||||
Amount = item.Amount,
|
||||
Percentage = item.Percentage
|
||||
}).ToList(),
|
||||
DetailRows = source.DetailRows.Select(item => new FinanceCostMonthlyDetailResponse
|
||||
{
|
||||
Month = item.Month,
|
||||
FoodAmount = item.FoodAmount,
|
||||
LaborAmount = item.LaborAmount,
|
||||
FixedAmount = item.FixedAmount,
|
||||
PackagingAmount = item.PackagingAmount,
|
||||
TotalCost = item.TotalCost,
|
||||
CostRate = item.CostRate
|
||||
}).ToList()
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,308 @@
|
||||
using System.Globalization;
|
||||
using MediatR;
|
||||
using Microsoft.AspNetCore.Authorization;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using TakeoutSaaS.Application.App.Finance.Invoice.Commands;
|
||||
using TakeoutSaaS.Application.App.Finance.Invoice.Dto;
|
||||
using TakeoutSaaS.Application.App.Finance.Invoice.Queries;
|
||||
using TakeoutSaaS.Domain.Tenants.Enums;
|
||||
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.Finance;
|
||||
|
||||
namespace TakeoutSaaS.TenantApi.Controllers;
|
||||
|
||||
/// <summary>
|
||||
/// 财务中心发票管理。
|
||||
/// </summary>
|
||||
[ApiVersion("1.0")]
|
||||
[Authorize]
|
||||
[Route("api/tenant/v{version:apiVersion}/finance/invoice")]
|
||||
public sealed class FinanceInvoiceController(IMediator mediator) : BaseApiController
|
||||
{
|
||||
private const string ViewPermission = "tenant:finance:invoice:view";
|
||||
private const string IssuePermission = "tenant:finance:invoice:issue";
|
||||
private const string VoidPermission = "tenant:finance:invoice:void";
|
||||
private const string SettingsPermission = "tenant:finance:invoice:settings";
|
||||
|
||||
/// <summary>
|
||||
/// 查询发票设置详情。
|
||||
/// </summary>
|
||||
[HttpGet("settings/detail")]
|
||||
[PermissionAuthorize(ViewPermission, SettingsPermission)]
|
||||
[ProducesResponseType(typeof(ApiResponse<FinanceInvoiceSettingResponse>), StatusCodes.Status200OK)]
|
||||
public async Task<ApiResponse<FinanceInvoiceSettingResponse>> SettingsDetail(CancellationToken cancellationToken)
|
||||
{
|
||||
var result = await mediator.Send(new GetFinanceInvoiceSettingDetailQuery(), cancellationToken);
|
||||
return ApiResponse<FinanceInvoiceSettingResponse>.Ok(MapSetting(result));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 保存发票设置。
|
||||
/// </summary>
|
||||
[HttpPost("settings/save")]
|
||||
[PermissionAuthorize(SettingsPermission)]
|
||||
[ProducesResponseType(typeof(ApiResponse<FinanceInvoiceSettingResponse>), StatusCodes.Status200OK)]
|
||||
public async Task<ApiResponse<FinanceInvoiceSettingResponse>> SettingsSave(
|
||||
[FromBody] FinanceInvoiceSettingSaveRequest request,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var result = await mediator.Send(new SaveFinanceInvoiceSettingCommand
|
||||
{
|
||||
CompanyName = request.CompanyName,
|
||||
TaxpayerNumber = request.TaxpayerNumber,
|
||||
RegisteredAddress = request.RegisteredAddress,
|
||||
RegisteredPhone = request.RegisteredPhone,
|
||||
BankName = request.BankName,
|
||||
BankAccount = request.BankAccount,
|
||||
EnableElectronicNormalInvoice = request.EnableElectronicNormalInvoice,
|
||||
EnableElectronicSpecialInvoice = request.EnableElectronicSpecialInvoice,
|
||||
EnableAutoIssue = request.EnableAutoIssue,
|
||||
AutoIssueMaxAmount = request.AutoIssueMaxAmount
|
||||
}, cancellationToken);
|
||||
|
||||
return ApiResponse<FinanceInvoiceSettingResponse>.Ok(MapSetting(result));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 查询发票记录分页。
|
||||
/// </summary>
|
||||
[HttpGet("record/list")]
|
||||
[PermissionAuthorize(ViewPermission, IssuePermission, VoidPermission)]
|
||||
[ProducesResponseType(typeof(ApiResponse<FinanceInvoiceRecordListResultResponse>), StatusCodes.Status200OK)]
|
||||
public async Task<ApiResponse<FinanceInvoiceRecordListResultResponse>> RecordList(
|
||||
[FromQuery] FinanceInvoiceRecordListRequest request,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var result = await mediator.Send(new GetFinanceInvoiceRecordListQuery
|
||||
{
|
||||
StartDateUtc = ParseDateOrNull(request.StartDate, nameof(request.StartDate)),
|
||||
EndDateUtc = ParseDateOrNull(request.EndDate, nameof(request.EndDate)),
|
||||
Status = ParseStatusOrNull(request.Status),
|
||||
InvoiceType = ParseInvoiceTypeOrNull(request.InvoiceType),
|
||||
Keyword = request.Keyword,
|
||||
Page = request.Page,
|
||||
PageSize = request.PageSize
|
||||
}, cancellationToken);
|
||||
|
||||
return ApiResponse<FinanceInvoiceRecordListResultResponse>.Ok(new FinanceInvoiceRecordListResultResponse
|
||||
{
|
||||
Items = result.Items.Select(MapRecord).ToList(),
|
||||
Page = result.Page,
|
||||
PageSize = result.PageSize,
|
||||
TotalCount = result.TotalCount,
|
||||
Stats = new FinanceInvoiceStatsResponse
|
||||
{
|
||||
CurrentMonthIssuedAmount = result.Stats.CurrentMonthIssuedAmount,
|
||||
CurrentMonthIssuedCount = result.Stats.CurrentMonthIssuedCount,
|
||||
PendingCount = result.Stats.PendingCount,
|
||||
VoidedCount = result.Stats.VoidedCount
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 查询发票记录详情。
|
||||
/// </summary>
|
||||
[HttpGet("record/detail")]
|
||||
[PermissionAuthorize(ViewPermission, IssuePermission, VoidPermission)]
|
||||
[ProducesResponseType(typeof(ApiResponse<FinanceInvoiceRecordDetailResponse>), StatusCodes.Status200OK)]
|
||||
public async Task<ApiResponse<FinanceInvoiceRecordDetailResponse>> RecordDetail(
|
||||
[FromQuery] FinanceInvoiceRecordDetailRequest request,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var result = await mediator.Send(new GetFinanceInvoiceRecordDetailQuery
|
||||
{
|
||||
RecordId = StoreApiHelpers.ParseRequiredSnowflake(request.RecordId, nameof(request.RecordId))
|
||||
}, cancellationToken);
|
||||
|
||||
return ApiResponse<FinanceInvoiceRecordDetailResponse>.Ok(MapRecordDetail(result));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 发票开票。
|
||||
/// </summary>
|
||||
[HttpPost("record/issue")]
|
||||
[PermissionAuthorize(IssuePermission)]
|
||||
[ProducesResponseType(typeof(ApiResponse<FinanceInvoiceIssueResultResponse>), StatusCodes.Status200OK)]
|
||||
public async Task<ApiResponse<FinanceInvoiceIssueResultResponse>> RecordIssue(
|
||||
[FromBody] FinanceInvoiceRecordIssueRequest request,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var result = await mediator.Send(new IssueFinanceInvoiceRecordCommand
|
||||
{
|
||||
RecordId = StoreApiHelpers.ParseRequiredSnowflake(request.RecordId, nameof(request.RecordId)),
|
||||
ContactEmail = request.ContactEmail,
|
||||
IssueRemark = request.IssueRemark
|
||||
}, cancellationToken);
|
||||
|
||||
return ApiResponse<FinanceInvoiceIssueResultResponse>.Ok(MapIssueResult(result));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 作废发票。
|
||||
/// </summary>
|
||||
[HttpPost("record/void")]
|
||||
[PermissionAuthorize(VoidPermission)]
|
||||
[ProducesResponseType(typeof(ApiResponse<FinanceInvoiceRecordDetailResponse>), StatusCodes.Status200OK)]
|
||||
public async Task<ApiResponse<FinanceInvoiceRecordDetailResponse>> RecordVoid(
|
||||
[FromBody] FinanceInvoiceRecordVoidRequest request,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var result = await mediator.Send(new VoidFinanceInvoiceRecordCommand
|
||||
{
|
||||
RecordId = StoreApiHelpers.ParseRequiredSnowflake(request.RecordId, nameof(request.RecordId)),
|
||||
VoidReason = request.VoidReason
|
||||
}, cancellationToken);
|
||||
|
||||
return ApiResponse<FinanceInvoiceRecordDetailResponse>.Ok(MapRecordDetail(result));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 申请发票。
|
||||
/// </summary>
|
||||
[HttpPost("record/apply")]
|
||||
[PermissionAuthorize(ViewPermission, IssuePermission)]
|
||||
[ProducesResponseType(typeof(ApiResponse<FinanceInvoiceRecordDetailResponse>), StatusCodes.Status200OK)]
|
||||
public async Task<ApiResponse<FinanceInvoiceRecordDetailResponse>> RecordApply(
|
||||
[FromBody] FinanceInvoiceRecordApplyRequest request,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var result = await mediator.Send(new ApplyFinanceInvoiceRecordCommand
|
||||
{
|
||||
ApplicantName = request.ApplicantName,
|
||||
CompanyName = request.CompanyName,
|
||||
TaxpayerNumber = request.TaxpayerNumber,
|
||||
InvoiceType = request.InvoiceType,
|
||||
Amount = request.Amount,
|
||||
OrderNo = request.OrderNo,
|
||||
ContactEmail = request.ContactEmail,
|
||||
ContactPhone = request.ContactPhone,
|
||||
ApplyRemark = request.ApplyRemark,
|
||||
AppliedAt = request.AppliedAt
|
||||
}, cancellationToken);
|
||||
|
||||
return ApiResponse<FinanceInvoiceRecordDetailResponse>.Ok(MapRecordDetail(result));
|
||||
}
|
||||
|
||||
private static DateTime? ParseDateOrNull(string? value, string fieldName)
|
||||
{
|
||||
return string.IsNullOrWhiteSpace(value)
|
||||
? null
|
||||
: StoreApiHelpers.ParseDateOnly(value, fieldName);
|
||||
}
|
||||
|
||||
private static TenantInvoiceStatus? ParseStatusOrNull(string? value)
|
||||
{
|
||||
var normalized = (value ?? string.Empty).Trim().ToLowerInvariant();
|
||||
if (string.IsNullOrWhiteSpace(normalized))
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
return normalized switch
|
||||
{
|
||||
"pending" => TenantInvoiceStatus.Pending,
|
||||
"issued" => TenantInvoiceStatus.Issued,
|
||||
"voided" => TenantInvoiceStatus.Voided,
|
||||
_ => throw new BusinessException(ErrorCodes.BadRequest, "status 参数不合法")
|
||||
};
|
||||
}
|
||||
|
||||
private static TenantInvoiceType? ParseInvoiceTypeOrNull(string? value)
|
||||
{
|
||||
var normalized = (value ?? string.Empty).Trim().ToLowerInvariant();
|
||||
if (string.IsNullOrWhiteSpace(normalized))
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
return normalized switch
|
||||
{
|
||||
"normal" => TenantInvoiceType.Normal,
|
||||
"special" => TenantInvoiceType.Special,
|
||||
_ => throw new BusinessException(ErrorCodes.BadRequest, "invoiceType 参数不合法")
|
||||
};
|
||||
}
|
||||
|
||||
private static FinanceInvoiceSettingResponse MapSetting(FinanceInvoiceSettingDto source)
|
||||
{
|
||||
return new FinanceInvoiceSettingResponse
|
||||
{
|
||||
CompanyName = source.CompanyName,
|
||||
TaxpayerNumber = source.TaxpayerNumber,
|
||||
RegisteredAddress = source.RegisteredAddress,
|
||||
RegisteredPhone = source.RegisteredPhone,
|
||||
BankName = source.BankName,
|
||||
BankAccount = source.BankAccount,
|
||||
EnableElectronicNormalInvoice = source.EnableElectronicNormalInvoice,
|
||||
EnableElectronicSpecialInvoice = source.EnableElectronicSpecialInvoice,
|
||||
EnableAutoIssue = source.EnableAutoIssue,
|
||||
AutoIssueMaxAmount = source.AutoIssueMaxAmount
|
||||
};
|
||||
}
|
||||
|
||||
private static FinanceInvoiceRecordResponse MapRecord(FinanceInvoiceRecordDto source)
|
||||
{
|
||||
return new FinanceInvoiceRecordResponse
|
||||
{
|
||||
RecordId = source.RecordId.ToString(),
|
||||
InvoiceNo = source.InvoiceNo,
|
||||
ApplicantName = source.ApplicantName,
|
||||
CompanyName = source.CompanyName,
|
||||
InvoiceType = source.InvoiceType,
|
||||
InvoiceTypeText = source.InvoiceTypeText,
|
||||
Amount = source.Amount,
|
||||
OrderNo = source.OrderNo,
|
||||
Status = source.Status,
|
||||
StatusText = source.StatusText,
|
||||
AppliedAt = source.AppliedAt.ToString("yyyy-MM-dd HH:mm:ss", CultureInfo.InvariantCulture)
|
||||
};
|
||||
}
|
||||
|
||||
private static FinanceInvoiceRecordDetailResponse MapRecordDetail(FinanceInvoiceRecordDetailDto source)
|
||||
{
|
||||
return new FinanceInvoiceRecordDetailResponse
|
||||
{
|
||||
RecordId = source.RecordId.ToString(),
|
||||
InvoiceNo = source.InvoiceNo,
|
||||
ApplicantName = source.ApplicantName,
|
||||
CompanyName = source.CompanyName,
|
||||
TaxpayerNumber = source.TaxpayerNumber,
|
||||
InvoiceType = source.InvoiceType,
|
||||
InvoiceTypeText = source.InvoiceTypeText,
|
||||
Amount = source.Amount,
|
||||
OrderNo = source.OrderNo,
|
||||
ContactEmail = source.ContactEmail,
|
||||
ContactPhone = source.ContactPhone,
|
||||
ApplyRemark = source.ApplyRemark,
|
||||
Status = source.Status,
|
||||
StatusText = source.StatusText,
|
||||
AppliedAt = source.AppliedAt.ToString("yyyy-MM-dd HH:mm:ss", CultureInfo.InvariantCulture),
|
||||
IssuedAt = source.IssuedAt?.ToString("yyyy-MM-dd HH:mm:ss", CultureInfo.InvariantCulture),
|
||||
IssuedByUserId = source.IssuedByUserId?.ToString(),
|
||||
IssueRemark = source.IssueRemark,
|
||||
VoidedAt = source.VoidedAt?.ToString("yyyy-MM-dd HH:mm:ss", CultureInfo.InvariantCulture),
|
||||
VoidedByUserId = source.VoidedByUserId?.ToString(),
|
||||
VoidReason = source.VoidReason
|
||||
};
|
||||
}
|
||||
|
||||
private static FinanceInvoiceIssueResultResponse MapIssueResult(FinanceInvoiceIssueResultDto source)
|
||||
{
|
||||
return new FinanceInvoiceIssueResultResponse
|
||||
{
|
||||
RecordId = source.RecordId.ToString(),
|
||||
InvoiceNo = source.InvoiceNo,
|
||||
CompanyName = source.CompanyName,
|
||||
Amount = source.Amount,
|
||||
ContactEmail = source.ContactEmail,
|
||||
IssuedAt = source.IssuedAt.ToString("yyyy-MM-dd HH:mm:ss", CultureInfo.InvariantCulture),
|
||||
Status = source.Status,
|
||||
StatusText = source.StatusText
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,171 @@
|
||||
using MediatR;
|
||||
using Microsoft.AspNetCore.Authorization;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using TakeoutSaaS.Application.App.Finance.Overview.Dto;
|
||||
using TakeoutSaaS.Application.App.Finance.Overview.Queries;
|
||||
using TakeoutSaaS.Application.App.Stores.Services;
|
||||
using TakeoutSaaS.Domain.Finance.Enums;
|
||||
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.Finance;
|
||||
|
||||
namespace TakeoutSaaS.TenantApi.Controllers;
|
||||
|
||||
/// <summary>
|
||||
/// 财务中心概览驾驶舱。
|
||||
/// </summary>
|
||||
[ApiVersion("1.0")]
|
||||
[Authorize]
|
||||
[Route("api/tenant/v{version:apiVersion}/finance/overview")]
|
||||
public sealed class FinanceOverviewController(
|
||||
IMediator mediator,
|
||||
TakeoutAppDbContext dbContext,
|
||||
StoreContextService storeContextService) : BaseApiController
|
||||
{
|
||||
private const string ViewPermission = "tenant:finance:overview:view";
|
||||
|
||||
/// <summary>
|
||||
/// 查询财务概览驾驶舱数据。
|
||||
/// </summary>
|
||||
[HttpGet("dashboard")]
|
||||
[PermissionAuthorize(ViewPermission)]
|
||||
[ProducesResponseType(typeof(ApiResponse<FinanceOverviewDashboardResponse>), StatusCodes.Status200OK)]
|
||||
public async Task<ApiResponse<FinanceOverviewDashboardResponse>> Dashboard(
|
||||
[FromQuery] FinanceOverviewDashboardRequest request,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
// 1. 解析维度与作用域。
|
||||
var dimension = ParseDimension(request.Dimension);
|
||||
long? storeId = null;
|
||||
if (dimension == FinanceCostDimension.Store)
|
||||
{
|
||||
storeId = StoreApiHelpers.ParseRequiredSnowflake(request.StoreId, nameof(request.StoreId));
|
||||
await EnsureStoreAccessibleAsync(storeId.Value, cancellationToken);
|
||||
}
|
||||
|
||||
// 2. 查询概览数据。
|
||||
var dashboard = await mediator.Send(new GetFinanceOverviewDashboardQuery
|
||||
{
|
||||
Dimension = dimension,
|
||||
StoreId = storeId,
|
||||
CurrentUtc = DateTime.UtcNow
|
||||
}, cancellationToken);
|
||||
|
||||
// 3. 映射响应并返回。
|
||||
return ApiResponse<FinanceOverviewDashboardResponse>.Ok(MapDashboard(dashboard));
|
||||
}
|
||||
|
||||
private async Task EnsureStoreAccessibleAsync(long storeId, CancellationToken cancellationToken)
|
||||
{
|
||||
var (tenantId, merchantId) = StoreApiHelpers.GetTenantMerchantContext(storeContextService);
|
||||
await StoreApiHelpers.EnsureStoreAccessibleAsync(dbContext, tenantId, merchantId, storeId, cancellationToken);
|
||||
}
|
||||
|
||||
private static FinanceCostDimension ParseDimension(string? value)
|
||||
{
|
||||
return (value ?? string.Empty).Trim().ToLowerInvariant() switch
|
||||
{
|
||||
"" or "tenant" => FinanceCostDimension.Tenant,
|
||||
"store" => FinanceCostDimension.Store,
|
||||
_ => throw new BusinessException(ErrorCodes.BadRequest, "dimension 非法")
|
||||
};
|
||||
}
|
||||
|
||||
private static FinanceOverviewDashboardResponse MapDashboard(FinanceOverviewDashboardDto source)
|
||||
{
|
||||
return new FinanceOverviewDashboardResponse
|
||||
{
|
||||
Dimension = source.Dimension,
|
||||
StoreId = source.StoreId,
|
||||
TodayRevenue = MapKpi(source.TodayRevenue),
|
||||
ActualReceived = MapKpi(source.ActualReceived),
|
||||
RefundAmount = MapKpi(source.RefundAmount),
|
||||
NetIncome = MapKpi(source.NetIncome),
|
||||
WithdrawableBalance = MapKpi(source.WithdrawableBalance),
|
||||
IncomeTrend = new FinanceOverviewIncomeTrendResponse
|
||||
{
|
||||
Last7Days = source.IncomeTrend.Last7Days.Select(item => new FinanceOverviewIncomeTrendPointResponse
|
||||
{
|
||||
Date = item.Date,
|
||||
DateLabel = item.DateLabel,
|
||||
Amount = item.Amount
|
||||
}).ToList(),
|
||||
Last30Days = source.IncomeTrend.Last30Days.Select(item => new FinanceOverviewIncomeTrendPointResponse
|
||||
{
|
||||
Date = item.Date,
|
||||
DateLabel = item.DateLabel,
|
||||
Amount = item.Amount
|
||||
}).ToList()
|
||||
},
|
||||
ProfitTrend = new FinanceOverviewProfitTrendResponse
|
||||
{
|
||||
Last7Days = source.ProfitTrend.Last7Days.Select(item => new FinanceOverviewProfitTrendPointResponse
|
||||
{
|
||||
Date = item.Date,
|
||||
DateLabel = item.DateLabel,
|
||||
RevenueAmount = item.RevenueAmount,
|
||||
CostAmount = item.CostAmount,
|
||||
NetProfitAmount = item.NetProfitAmount
|
||||
}).ToList(),
|
||||
Last30Days = source.ProfitTrend.Last30Days.Select(item => new FinanceOverviewProfitTrendPointResponse
|
||||
{
|
||||
Date = item.Date,
|
||||
DateLabel = item.DateLabel,
|
||||
RevenueAmount = item.RevenueAmount,
|
||||
CostAmount = item.CostAmount,
|
||||
NetProfitAmount = item.NetProfitAmount
|
||||
}).ToList()
|
||||
},
|
||||
IncomeComposition = new FinanceOverviewIncomeCompositionResponse
|
||||
{
|
||||
TotalAmount = source.IncomeComposition.TotalAmount,
|
||||
Items = source.IncomeComposition.Items.Select(item => new FinanceOverviewIncomeCompositionItemResponse
|
||||
{
|
||||
Channel = item.Channel,
|
||||
ChannelText = item.ChannelText,
|
||||
Amount = item.Amount,
|
||||
Percentage = item.Percentage
|
||||
}).ToList()
|
||||
},
|
||||
CostComposition = new FinanceOverviewCostCompositionResponse
|
||||
{
|
||||
TotalAmount = source.CostComposition.TotalAmount,
|
||||
Items = source.CostComposition.Items.Select(item => new FinanceOverviewCostCompositionItemResponse
|
||||
{
|
||||
Category = item.Category,
|
||||
CategoryText = item.CategoryText,
|
||||
Amount = item.Amount,
|
||||
Percentage = item.Percentage
|
||||
}).ToList()
|
||||
},
|
||||
TopProducts = new FinanceOverviewTopProductResponse
|
||||
{
|
||||
PeriodDays = source.TopProducts.PeriodDays,
|
||||
Items = source.TopProducts.Items.Select(item => new FinanceOverviewTopProductItemResponse
|
||||
{
|
||||
Rank = item.Rank,
|
||||
ProductName = item.ProductName,
|
||||
SalesQuantity = item.SalesQuantity,
|
||||
RevenueAmount = item.RevenueAmount,
|
||||
Percentage = item.Percentage
|
||||
}).ToList()
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
private static FinanceOverviewKpiCardResponse MapKpi(FinanceOverviewKpiCardDto source)
|
||||
{
|
||||
return new FinanceOverviewKpiCardResponse
|
||||
{
|
||||
Amount = source.Amount,
|
||||
CompareAmount = source.CompareAmount,
|
||||
ChangeRate = source.ChangeRate,
|
||||
Trend = source.Trend,
|
||||
CompareLabel = source.CompareLabel
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,250 @@
|
||||
using MediatR;
|
||||
using Microsoft.AspNetCore.Authorization;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using TakeoutSaaS.Application.App.Finance.Reports.Dto;
|
||||
using TakeoutSaaS.Application.App.Finance.Reports.Queries;
|
||||
using TakeoutSaaS.Application.App.Stores.Services;
|
||||
using TakeoutSaaS.Domain.Finance.Enums;
|
||||
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.Finance;
|
||||
|
||||
namespace TakeoutSaaS.TenantApi.Controllers;
|
||||
|
||||
/// <summary>
|
||||
/// 财务中心经营报表。
|
||||
/// </summary>
|
||||
[ApiVersion("1.0")]
|
||||
[Authorize]
|
||||
[Route("api/tenant/v{version:apiVersion}/finance/report")]
|
||||
public sealed class FinanceReportController(
|
||||
IMediator mediator,
|
||||
TakeoutAppDbContext dbContext,
|
||||
StoreContextService storeContextService) : BaseApiController
|
||||
{
|
||||
private const string ViewPermission = "tenant:statistics:report:view";
|
||||
private const string ExportPermission = "tenant:statistics:report:export";
|
||||
|
||||
/// <summary>
|
||||
/// 查询经营报表列表。
|
||||
/// </summary>
|
||||
[HttpGet("list")]
|
||||
[PermissionAuthorize(ViewPermission)]
|
||||
[ProducesResponseType(typeof(ApiResponse<FinanceBusinessReportListResultResponse>), StatusCodes.Status200OK)]
|
||||
public async Task<ApiResponse<FinanceBusinessReportListResultResponse>> List(
|
||||
[FromQuery] FinanceBusinessReportListRequest request,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
// 1. 校验门店访问权限并解析查询参数。
|
||||
var storeId = StoreApiHelpers.ParseRequiredSnowflake(request.StoreId, nameof(request.StoreId));
|
||||
await EnsureStoreAccessibleAsync(storeId, cancellationToken);
|
||||
var periodType = ParsePeriodType(request.PeriodType);
|
||||
|
||||
// 2. 发起查询并返回结果。
|
||||
var result = await mediator.Send(new SearchFinanceBusinessReportListQuery
|
||||
{
|
||||
StoreId = storeId,
|
||||
PeriodType = periodType,
|
||||
Page = Math.Max(1, request.Page),
|
||||
PageSize = Math.Clamp(request.PageSize, 1, 200)
|
||||
}, cancellationToken);
|
||||
|
||||
return ApiResponse<FinanceBusinessReportListResultResponse>.Ok(new FinanceBusinessReportListResultResponse
|
||||
{
|
||||
Items = result.Items.Select(MapListItem).ToList(),
|
||||
Total = result.Total,
|
||||
Page = result.Page,
|
||||
PageSize = result.PageSize
|
||||
});
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 查询经营报表详情。
|
||||
/// </summary>
|
||||
[HttpGet("detail")]
|
||||
[PermissionAuthorize(ViewPermission)]
|
||||
[ProducesResponseType(typeof(ApiResponse<FinanceBusinessReportDetailResponse>), StatusCodes.Status200OK)]
|
||||
public async Task<ApiResponse<FinanceBusinessReportDetailResponse>> Detail(
|
||||
[FromQuery] FinanceBusinessReportDetailRequest request,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
// 1. 校验门店访问权限并解析参数。
|
||||
var storeId = StoreApiHelpers.ParseRequiredSnowflake(request.StoreId, nameof(request.StoreId));
|
||||
await EnsureStoreAccessibleAsync(storeId, cancellationToken);
|
||||
var reportId = StoreApiHelpers.ParseRequiredSnowflake(request.ReportId, nameof(request.ReportId));
|
||||
|
||||
// 2. 发起详情查询。
|
||||
var detail = await mediator.Send(new GetFinanceBusinessReportDetailQuery
|
||||
{
|
||||
StoreId = storeId,
|
||||
ReportId = reportId
|
||||
}, cancellationToken);
|
||||
|
||||
if (detail is null)
|
||||
{
|
||||
return ApiResponse<FinanceBusinessReportDetailResponse>.Error(ErrorCodes.NotFound, "经营报表不存在");
|
||||
}
|
||||
|
||||
return ApiResponse<FinanceBusinessReportDetailResponse>.Ok(MapDetail(detail));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 导出单条报表 PDF。
|
||||
/// </summary>
|
||||
[HttpGet("export/pdf")]
|
||||
[PermissionAuthorize(ExportPermission)]
|
||||
[ProducesResponseType(typeof(ApiResponse<FinanceBusinessReportExportResponse>), StatusCodes.Status200OK)]
|
||||
public async Task<ApiResponse<FinanceBusinessReportExportResponse>> ExportPdf(
|
||||
[FromQuery] FinanceBusinessReportDetailRequest request,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
// 1. 校验门店访问权限并解析参数。
|
||||
var storeId = StoreApiHelpers.ParseRequiredSnowflake(request.StoreId, nameof(request.StoreId));
|
||||
await EnsureStoreAccessibleAsync(storeId, cancellationToken);
|
||||
var reportId = StoreApiHelpers.ParseRequiredSnowflake(request.ReportId, nameof(request.ReportId));
|
||||
|
||||
// 2. 执行导出。
|
||||
var export = await mediator.Send(new ExportFinanceBusinessReportPdfQuery
|
||||
{
|
||||
StoreId = storeId,
|
||||
ReportId = reportId
|
||||
}, cancellationToken);
|
||||
|
||||
return ApiResponse<FinanceBusinessReportExportResponse>.Ok(MapExport(export));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 导出单条报表 Excel。
|
||||
/// </summary>
|
||||
[HttpGet("export/excel")]
|
||||
[PermissionAuthorize(ExportPermission)]
|
||||
[ProducesResponseType(typeof(ApiResponse<FinanceBusinessReportExportResponse>), StatusCodes.Status200OK)]
|
||||
public async Task<ApiResponse<FinanceBusinessReportExportResponse>> ExportExcel(
|
||||
[FromQuery] FinanceBusinessReportDetailRequest request,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
// 1. 校验门店访问权限并解析参数。
|
||||
var storeId = StoreApiHelpers.ParseRequiredSnowflake(request.StoreId, nameof(request.StoreId));
|
||||
await EnsureStoreAccessibleAsync(storeId, cancellationToken);
|
||||
var reportId = StoreApiHelpers.ParseRequiredSnowflake(request.ReportId, nameof(request.ReportId));
|
||||
|
||||
// 2. 执行导出。
|
||||
var export = await mediator.Send(new ExportFinanceBusinessReportExcelQuery
|
||||
{
|
||||
StoreId = storeId,
|
||||
ReportId = reportId
|
||||
}, cancellationToken);
|
||||
|
||||
return ApiResponse<FinanceBusinessReportExportResponse>.Ok(MapExport(export));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 批量导出报表 ZIP(PDF + Excel)。
|
||||
/// </summary>
|
||||
[HttpGet("export/batch")]
|
||||
[PermissionAuthorize(ExportPermission)]
|
||||
[ProducesResponseType(typeof(ApiResponse<FinanceBusinessReportExportResponse>), StatusCodes.Status200OK)]
|
||||
public async Task<ApiResponse<FinanceBusinessReportExportResponse>> ExportBatch(
|
||||
[FromQuery] FinanceBusinessReportBatchExportRequest request,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
// 1. 校验门店访问权限并解析参数。
|
||||
var storeId = StoreApiHelpers.ParseRequiredSnowflake(request.StoreId, nameof(request.StoreId));
|
||||
await EnsureStoreAccessibleAsync(storeId, cancellationToken);
|
||||
var periodType = ParsePeriodType(request.PeriodType);
|
||||
|
||||
// 2. 执行批量导出。
|
||||
var export = await mediator.Send(new ExportFinanceBusinessReportBatchQuery
|
||||
{
|
||||
StoreId = storeId,
|
||||
PeriodType = periodType,
|
||||
Page = Math.Max(1, request.Page),
|
||||
PageSize = Math.Clamp(request.PageSize, 1, 200)
|
||||
}, cancellationToken);
|
||||
|
||||
return ApiResponse<FinanceBusinessReportExportResponse>.Ok(MapExport(export));
|
||||
}
|
||||
|
||||
private async Task EnsureStoreAccessibleAsync(long storeId, CancellationToken cancellationToken)
|
||||
{
|
||||
var (tenantId, merchantId) = StoreApiHelpers.GetTenantMerchantContext(storeContextService);
|
||||
await StoreApiHelpers.EnsureStoreAccessibleAsync(dbContext, tenantId, merchantId, storeId, cancellationToken);
|
||||
}
|
||||
|
||||
private static FinanceBusinessReportPeriodType ParsePeriodType(string? value)
|
||||
{
|
||||
return (value ?? string.Empty).Trim().ToLowerInvariant() switch
|
||||
{
|
||||
"" or "daily" => FinanceBusinessReportPeriodType.Daily,
|
||||
"weekly" => FinanceBusinessReportPeriodType.Weekly,
|
||||
"monthly" => FinanceBusinessReportPeriodType.Monthly,
|
||||
_ => throw new BusinessException(ErrorCodes.BadRequest, "periodType 参数不合法")
|
||||
};
|
||||
}
|
||||
|
||||
private static FinanceBusinessReportListItemResponse MapListItem(FinanceBusinessReportListItemDto source)
|
||||
{
|
||||
return new FinanceBusinessReportListItemResponse
|
||||
{
|
||||
ReportId = source.ReportId,
|
||||
DateText = source.DateText,
|
||||
RevenueAmount = source.RevenueAmount,
|
||||
OrderCount = source.OrderCount,
|
||||
AverageOrderValue = source.AverageOrderValue,
|
||||
RefundRatePercent = source.RefundRatePercent,
|
||||
CostTotalAmount = source.CostTotalAmount,
|
||||
NetProfitAmount = source.NetProfitAmount,
|
||||
ProfitRatePercent = source.ProfitRatePercent,
|
||||
Status = source.Status,
|
||||
StatusText = source.StatusText,
|
||||
CanDownload = source.CanDownload
|
||||
};
|
||||
}
|
||||
|
||||
private static FinanceBusinessReportDetailResponse MapDetail(FinanceBusinessReportDetailDto source)
|
||||
{
|
||||
return new FinanceBusinessReportDetailResponse
|
||||
{
|
||||
ReportId = source.ReportId,
|
||||
Title = source.Title,
|
||||
PeriodType = source.PeriodType,
|
||||
Status = source.Status,
|
||||
StatusText = source.StatusText,
|
||||
Kpis = source.Kpis.Select(item => new FinanceBusinessReportKpiResponse
|
||||
{
|
||||
Key = item.Key,
|
||||
Label = item.Label,
|
||||
ValueText = item.ValueText,
|
||||
YoyChangeRate = item.YoyChangeRate,
|
||||
MomChangeRate = item.MomChangeRate
|
||||
}).ToList(),
|
||||
IncomeBreakdowns = source.IncomeBreakdowns.Select(MapBreakdown).ToList(),
|
||||
CostBreakdowns = source.CostBreakdowns.Select(MapBreakdown).ToList()
|
||||
};
|
||||
}
|
||||
|
||||
private static FinanceBusinessReportBreakdownItemResponse MapBreakdown(FinanceBusinessReportBreakdownItemDto source)
|
||||
{
|
||||
return new FinanceBusinessReportBreakdownItemResponse
|
||||
{
|
||||
Key = source.Key,
|
||||
Label = source.Label,
|
||||
Amount = source.Amount,
|
||||
RatioPercent = source.RatioPercent
|
||||
};
|
||||
}
|
||||
|
||||
private static FinanceBusinessReportExportResponse MapExport(FinanceBusinessReportExportDto source)
|
||||
{
|
||||
return new FinanceBusinessReportExportResponse
|
||||
{
|
||||
FileName = source.FileName,
|
||||
FileContentBase64 = source.FileContentBase64,
|
||||
TotalCount = source.TotalCount
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,262 @@
|
||||
using System.Globalization;
|
||||
using MediatR;
|
||||
using Microsoft.AspNetCore.Authorization;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using TakeoutSaaS.Application.App.Finance.Settlement.Dto;
|
||||
using TakeoutSaaS.Application.App.Finance.Settlement.Queries;
|
||||
using TakeoutSaaS.Application.App.Stores.Services;
|
||||
using TakeoutSaaS.Domain.Payments.Enums;
|
||||
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.Finance;
|
||||
|
||||
namespace TakeoutSaaS.TenantApi.Controllers;
|
||||
|
||||
/// <summary>
|
||||
/// 财务中心到账查询。
|
||||
/// </summary>
|
||||
[ApiVersion("1.0")]
|
||||
[Authorize]
|
||||
[Route("api/tenant/v{version:apiVersion}/finance/settlement")]
|
||||
public sealed class FinanceSettlementController(
|
||||
IMediator mediator,
|
||||
TakeoutAppDbContext dbContext,
|
||||
StoreContextService storeContextService) : BaseApiController
|
||||
{
|
||||
private const string ViewPermission = "tenant:finance:settlement:view";
|
||||
private const string ExportPermission = "tenant:finance:settlement:export";
|
||||
|
||||
/// <summary>
|
||||
/// 查询到账统计。
|
||||
/// </summary>
|
||||
[HttpGet("stats")]
|
||||
[PermissionAuthorize(ViewPermission)]
|
||||
[ProducesResponseType(typeof(ApiResponse<FinanceSettlementStatsResponse>), StatusCodes.Status200OK)]
|
||||
public async Task<ApiResponse<FinanceSettlementStatsResponse>> Stats(
|
||||
[FromQuery] FinanceSettlementStatsRequest request,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var storeId = StoreApiHelpers.ParseRequiredSnowflake(request.StoreId, nameof(request.StoreId));
|
||||
await EnsureStoreAccessibleAsync(storeId, cancellationToken);
|
||||
|
||||
var stats = await mediator.Send(new GetFinanceSettlementStatsQuery
|
||||
{
|
||||
StoreId = storeId
|
||||
}, cancellationToken);
|
||||
|
||||
return ApiResponse<FinanceSettlementStatsResponse>.Ok(new FinanceSettlementStatsResponse
|
||||
{
|
||||
TodayArrivedAmount = stats.TodayArrivedAmount,
|
||||
YesterdayArrivedAmount = stats.YesterdayArrivedAmount,
|
||||
CurrentMonthArrivedAmount = stats.CurrentMonthArrivedAmount,
|
||||
CurrentMonthTransactionCount = stats.CurrentMonthTransactionCount
|
||||
});
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 查询到账账户信息。
|
||||
/// </summary>
|
||||
[HttpGet("account")]
|
||||
[PermissionAuthorize(ViewPermission)]
|
||||
[ProducesResponseType(typeof(ApiResponse<FinanceSettlementAccountResponse>), StatusCodes.Status200OK)]
|
||||
public async Task<ApiResponse<FinanceSettlementAccountResponse>> Account(
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var account = await mediator.Send(new GetFinanceSettlementAccountQuery(), cancellationToken);
|
||||
if (account is null)
|
||||
{
|
||||
return ApiResponse<FinanceSettlementAccountResponse>.Error(ErrorCodes.NotFound, "结算账户信息不存在");
|
||||
}
|
||||
|
||||
return ApiResponse<FinanceSettlementAccountResponse>.Ok(new FinanceSettlementAccountResponse
|
||||
{
|
||||
BankName = account.BankName,
|
||||
BankAccountName = account.BankAccountName,
|
||||
BankAccountNoMasked = account.BankAccountNoMasked,
|
||||
WechatMerchantNoMasked = account.WechatMerchantNoMasked,
|
||||
AlipayPidMasked = account.AlipayPidMasked,
|
||||
SettlementPeriodText = account.SettlementPeriodText
|
||||
});
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 查询到账汇总列表。
|
||||
/// </summary>
|
||||
[HttpGet("list")]
|
||||
[PermissionAuthorize(ViewPermission)]
|
||||
[ProducesResponseType(typeof(ApiResponse<FinanceSettlementListResultResponse>), StatusCodes.Status200OK)]
|
||||
public async Task<ApiResponse<FinanceSettlementListResultResponse>> List(
|
||||
[FromQuery] FinanceSettlementListRequest request,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var parsed = await ParseFilterAsync(request, cancellationToken);
|
||||
|
||||
var result = await mediator.Send(new SearchFinanceSettlementListQuery
|
||||
{
|
||||
StoreId = parsed.StoreId,
|
||||
StartAt = parsed.StartAt,
|
||||
EndAt = parsed.EndAt,
|
||||
PaymentMethod = parsed.PaymentMethod,
|
||||
Page = Math.Max(1, request.Page),
|
||||
PageSize = Math.Clamp(request.PageSize, 1, 200)
|
||||
}, cancellationToken);
|
||||
|
||||
return ApiResponse<FinanceSettlementListResultResponse>.Ok(new FinanceSettlementListResultResponse
|
||||
{
|
||||
Items = result.Items.Select(MapListItem).ToList(),
|
||||
Total = result.Total,
|
||||
Page = result.Page,
|
||||
PageSize = result.PageSize
|
||||
});
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 查询到账明细(展开行)。
|
||||
/// </summary>
|
||||
[HttpGet("detail")]
|
||||
[PermissionAuthorize(ViewPermission)]
|
||||
[ProducesResponseType(typeof(ApiResponse<FinanceSettlementDetailResultResponse>), StatusCodes.Status200OK)]
|
||||
public async Task<ApiResponse<FinanceSettlementDetailResultResponse>> Detail(
|
||||
[FromQuery] FinanceSettlementDetailRequest request,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var storeId = StoreApiHelpers.ParseRequiredSnowflake(request.StoreId, nameof(request.StoreId));
|
||||
await EnsureStoreAccessibleAsync(storeId, cancellationToken);
|
||||
|
||||
var arrivedDate = ParseRequiredDate(request.ArrivedDate, nameof(request.ArrivedDate));
|
||||
var paymentMethod = ParseRequiredSettlementChannel(request.Channel);
|
||||
|
||||
var result = await mediator.Send(new GetFinanceSettlementDetailQuery
|
||||
{
|
||||
StoreId = storeId,
|
||||
ArrivedDate = arrivedDate,
|
||||
PaymentMethod = paymentMethod,
|
||||
Take = 50
|
||||
}, cancellationToken);
|
||||
|
||||
return ApiResponse<FinanceSettlementDetailResultResponse>.Ok(new FinanceSettlementDetailResultResponse
|
||||
{
|
||||
Items = result.Items.Select(MapDetailItem).ToList()
|
||||
});
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 导出到账汇总 CSV。
|
||||
/// </summary>
|
||||
[HttpGet("export")]
|
||||
[PermissionAuthorize(ExportPermission)]
|
||||
[ProducesResponseType(typeof(ApiResponse<FinanceSettlementExportResponse>), StatusCodes.Status200OK)]
|
||||
public async Task<ApiResponse<FinanceSettlementExportResponse>> Export(
|
||||
[FromQuery] FinanceSettlementFilterRequest request,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var parsed = await ParseFilterAsync(request, cancellationToken);
|
||||
|
||||
var result = await mediator.Send(new ExportFinanceSettlementCsvQuery
|
||||
{
|
||||
StoreId = parsed.StoreId,
|
||||
StartAt = parsed.StartAt,
|
||||
EndAt = parsed.EndAt,
|
||||
PaymentMethod = parsed.PaymentMethod
|
||||
}, cancellationToken);
|
||||
|
||||
return ApiResponse<FinanceSettlementExportResponse>.Ok(new FinanceSettlementExportResponse
|
||||
{
|
||||
FileName = result.FileName,
|
||||
FileContentBase64 = result.FileContentBase64,
|
||||
TotalCount = result.TotalCount
|
||||
});
|
||||
}
|
||||
|
||||
private async Task<(long StoreId, DateTime? StartAt, DateTime? EndAt, PaymentMethod? PaymentMethod)> ParseFilterAsync(
|
||||
FinanceSettlementFilterRequest request,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var storeId = StoreApiHelpers.ParseRequiredSnowflake(request.StoreId, nameof(request.StoreId));
|
||||
await EnsureStoreAccessibleAsync(storeId, cancellationToken);
|
||||
|
||||
var startAt = ParseDateOrNull(request.StartDate);
|
||||
var endAt = ParseDateOrNull(request.EndDate)?.AddDays(1);
|
||||
if (startAt.HasValue && endAt.HasValue && startAt >= endAt)
|
||||
{
|
||||
throw new BusinessException(ErrorCodes.BadRequest, "开始日期不能晚于结束日期");
|
||||
}
|
||||
|
||||
return (storeId, startAt, endAt, ParseOptionalSettlementChannel(request.Channel));
|
||||
}
|
||||
|
||||
private async Task EnsureStoreAccessibleAsync(long storeId, CancellationToken cancellationToken)
|
||||
{
|
||||
var (tenantId, merchantId) = StoreApiHelpers.GetTenantMerchantContext(storeContextService);
|
||||
await StoreApiHelpers.EnsureStoreAccessibleAsync(dbContext, tenantId, merchantId, storeId, cancellationToken);
|
||||
}
|
||||
|
||||
private static DateTime ParseRequiredDate(string? value, string parameterName)
|
||||
{
|
||||
return ParseDateOrNull(value)
|
||||
?? throw new BusinessException(ErrorCodes.BadRequest, $"{parameterName} 必填,格式为 yyyy-MM-dd");
|
||||
}
|
||||
|
||||
private static DateTime? ParseDateOrNull(string? value)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(value))
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
if (DateTime.TryParseExact(
|
||||
value,
|
||||
"yyyy-MM-dd",
|
||||
CultureInfo.InvariantCulture,
|
||||
DateTimeStyles.None,
|
||||
out var parsed))
|
||||
{
|
||||
return DateTime.SpecifyKind(parsed.Date, DateTimeKind.Utc);
|
||||
}
|
||||
|
||||
throw new BusinessException(ErrorCodes.BadRequest, "日期格式必须为 yyyy-MM-dd");
|
||||
}
|
||||
|
||||
private static PaymentMethod ParseRequiredSettlementChannel(string? channel)
|
||||
{
|
||||
return ParseOptionalSettlementChannel(channel)
|
||||
?? throw new BusinessException(ErrorCodes.BadRequest, "channel 必填,仅支持 wechat 或 alipay");
|
||||
}
|
||||
|
||||
private static PaymentMethod? ParseOptionalSettlementChannel(string? channel)
|
||||
{
|
||||
return (channel ?? string.Empty).Trim().ToLowerInvariant() switch
|
||||
{
|
||||
"wechat" => PaymentMethod.WeChatPay,
|
||||
"alipay" => PaymentMethod.Alipay,
|
||||
"" => null,
|
||||
_ => throw new BusinessException(ErrorCodes.BadRequest, "channel 仅支持 wechat 或 alipay")
|
||||
};
|
||||
}
|
||||
|
||||
private static FinanceSettlementListItemResponse MapListItem(FinanceSettlementListItemDto source)
|
||||
{
|
||||
return new FinanceSettlementListItemResponse
|
||||
{
|
||||
ArrivedDate = source.ArrivedDate.ToString("yyyy-MM-dd", CultureInfo.InvariantCulture),
|
||||
Channel = source.Channel,
|
||||
ChannelText = source.ChannelText,
|
||||
TransactionCount = source.TransactionCount,
|
||||
ArrivedAmount = source.ArrivedAmount
|
||||
};
|
||||
}
|
||||
|
||||
private static FinanceSettlementDetailItemResponse MapDetailItem(FinanceSettlementDetailItemDto source)
|
||||
{
|
||||
return new FinanceSettlementDetailItemResponse
|
||||
{
|
||||
OrderNo = source.OrderNo,
|
||||
Amount = source.Amount,
|
||||
PaidAt = source.PaidAt.ToString("yyyy-MM-dd HH:mm:ss", CultureInfo.InvariantCulture)
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,343 @@
|
||||
using System.Globalization;
|
||||
using MediatR;
|
||||
using Microsoft.AspNetCore.Authorization;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using TakeoutSaaS.Application.App.Finance.Transactions.Dto;
|
||||
using TakeoutSaaS.Application.App.Finance.Transactions.Queries;
|
||||
using TakeoutSaaS.Application.App.Stores.Services;
|
||||
using TakeoutSaaS.Domain.Finance.Enums;
|
||||
using TakeoutSaaS.Domain.Orders.Enums;
|
||||
using TakeoutSaaS.Domain.Payments.Enums;
|
||||
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.Finance;
|
||||
|
||||
namespace TakeoutSaaS.TenantApi.Controllers;
|
||||
|
||||
/// <summary>
|
||||
/// 财务中心交易流水。
|
||||
/// </summary>
|
||||
[ApiVersion("1.0")]
|
||||
[Authorize]
|
||||
[Route("api/tenant/v{version:apiVersion}/finance/transaction")]
|
||||
public sealed class FinanceTransactionController(
|
||||
IMediator mediator,
|
||||
TakeoutAppDbContext dbContext,
|
||||
StoreContextService storeContextService) : BaseApiController
|
||||
{
|
||||
private const string ViewPermission = "tenant:finance:transaction:view";
|
||||
private const string DetailPermission = "tenant:finance:transaction:detail";
|
||||
private const string ExportPermission = "tenant:finance:transaction:export";
|
||||
|
||||
/// <summary>
|
||||
/// 查询交易流水列表。
|
||||
/// </summary>
|
||||
[HttpGet("list")]
|
||||
[PermissionAuthorize(ViewPermission)]
|
||||
[ProducesResponseType(typeof(ApiResponse<FinanceTransactionListResultResponse>), StatusCodes.Status200OK)]
|
||||
public async Task<ApiResponse<FinanceTransactionListResultResponse>> List(
|
||||
[FromQuery] FinanceTransactionListRequest request,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
// 1. 解析并校验筛选参数。
|
||||
var parsed = await ParseFilterAsync(request, cancellationToken);
|
||||
|
||||
// 2. 发起查询并映射响应。
|
||||
var result = await mediator.Send(new SearchFinanceTransactionListQuery
|
||||
{
|
||||
StoreId = parsed.StoreId,
|
||||
StartAt = parsed.StartAt,
|
||||
EndAt = parsed.EndAt,
|
||||
TransactionType = parsed.TransactionType,
|
||||
DeliveryType = parsed.DeliveryType,
|
||||
PaymentMethod = parsed.PaymentMethod,
|
||||
Keyword = request.Keyword,
|
||||
Page = Math.Max(1, request.Page),
|
||||
PageSize = Math.Clamp(request.PageSize, 1, 200)
|
||||
}, cancellationToken);
|
||||
|
||||
return ApiResponse<FinanceTransactionListResultResponse>.Ok(new FinanceTransactionListResultResponse
|
||||
{
|
||||
Items = result.Items.Select(MapListItem).ToList(),
|
||||
Total = result.Total,
|
||||
Page = result.Page,
|
||||
PageSize = result.PageSize,
|
||||
PageIncomeAmount = result.PageIncomeAmount,
|
||||
PageRefundAmount = result.PageRefundAmount
|
||||
});
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 查询交易流水统计。
|
||||
/// </summary>
|
||||
[HttpGet("stats")]
|
||||
[PermissionAuthorize(ViewPermission)]
|
||||
[ProducesResponseType(typeof(ApiResponse<FinanceTransactionStatsResponse>), StatusCodes.Status200OK)]
|
||||
public async Task<ApiResponse<FinanceTransactionStatsResponse>> Stats(
|
||||
[FromQuery] FinanceTransactionFilterRequest request,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
// 1. 解析并校验筛选参数。
|
||||
var parsed = await ParseFilterAsync(request, cancellationToken);
|
||||
|
||||
// 2. 发起查询并映射响应。
|
||||
var result = await mediator.Send(new GetFinanceTransactionStatsQuery
|
||||
{
|
||||
StoreId = parsed.StoreId,
|
||||
StartAt = parsed.StartAt,
|
||||
EndAt = parsed.EndAt,
|
||||
TransactionType = parsed.TransactionType,
|
||||
DeliveryType = parsed.DeliveryType,
|
||||
PaymentMethod = parsed.PaymentMethod,
|
||||
Keyword = request.Keyword
|
||||
}, cancellationToken);
|
||||
|
||||
return ApiResponse<FinanceTransactionStatsResponse>.Ok(new FinanceTransactionStatsResponse
|
||||
{
|
||||
TotalIncome = result.TotalIncome,
|
||||
TotalRefund = result.TotalRefund,
|
||||
TotalCount = result.TotalCount
|
||||
});
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 查询交易流水详情。
|
||||
/// </summary>
|
||||
[HttpGet("detail")]
|
||||
[PermissionAuthorize(ViewPermission, DetailPermission)]
|
||||
[ProducesResponseType(typeof(ApiResponse<FinanceTransactionDetailResponse>), StatusCodes.Status200OK)]
|
||||
public async Task<ApiResponse<FinanceTransactionDetailResponse>> Detail(
|
||||
[FromQuery] FinanceTransactionDetailRequest request,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
// 1. 校验门店参数与门店访问权限。
|
||||
var storeId = StoreApiHelpers.ParseRequiredSnowflake(request.StoreId, nameof(request.StoreId));
|
||||
await EnsureStoreAccessibleAsync(storeId, cancellationToken);
|
||||
|
||||
// 2. 解析交易复合标识。
|
||||
if (!TryParseTransactionId(request.TransactionId, out var sourceType, out var sourceId))
|
||||
{
|
||||
return ApiResponse<FinanceTransactionDetailResponse>.Error(ErrorCodes.BadRequest, "transactionId 非法");
|
||||
}
|
||||
|
||||
// 3. 查询详情并返回。
|
||||
var detail = await mediator.Send(new GetFinanceTransactionDetailQuery
|
||||
{
|
||||
StoreId = storeId,
|
||||
SourceType = sourceType,
|
||||
SourceId = sourceId
|
||||
}, cancellationToken);
|
||||
|
||||
if (detail is null)
|
||||
{
|
||||
return ApiResponse<FinanceTransactionDetailResponse>.Error(ErrorCodes.NotFound, "交易流水不存在");
|
||||
}
|
||||
|
||||
return ApiResponse<FinanceTransactionDetailResponse>.Ok(MapDetail(detail));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 导出交易流水 CSV。
|
||||
/// </summary>
|
||||
[HttpGet("export")]
|
||||
[PermissionAuthorize(ExportPermission)]
|
||||
[ProducesResponseType(typeof(ApiResponse<FinanceTransactionExportResponse>), StatusCodes.Status200OK)]
|
||||
public async Task<ApiResponse<FinanceTransactionExportResponse>> Export(
|
||||
[FromQuery] FinanceTransactionFilterRequest request,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
// 1. 解析并校验筛选参数。
|
||||
var parsed = await ParseFilterAsync(request, cancellationToken);
|
||||
|
||||
// 2. 发起导出并返回结果。
|
||||
var result = await mediator.Send(new ExportFinanceTransactionCsvQuery
|
||||
{
|
||||
StoreId = parsed.StoreId,
|
||||
StartAt = parsed.StartAt,
|
||||
EndAt = parsed.EndAt,
|
||||
TransactionType = parsed.TransactionType,
|
||||
DeliveryType = parsed.DeliveryType,
|
||||
PaymentMethod = parsed.PaymentMethod,
|
||||
Keyword = request.Keyword
|
||||
}, cancellationToken);
|
||||
|
||||
return ApiResponse<FinanceTransactionExportResponse>.Ok(new FinanceTransactionExportResponse
|
||||
{
|
||||
FileName = result.FileName,
|
||||
FileContentBase64 = result.FileContentBase64,
|
||||
TotalCount = result.TotalCount
|
||||
});
|
||||
}
|
||||
|
||||
private async Task<(long StoreId, DateTime? StartAt, DateTime? EndAt, FinanceTransactionType? TransactionType, DeliveryType? DeliveryType, PaymentMethod? PaymentMethod)> ParseFilterAsync(
|
||||
FinanceTransactionFilterRequest request,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var storeId = StoreApiHelpers.ParseRequiredSnowflake(request.StoreId, nameof(request.StoreId));
|
||||
await EnsureStoreAccessibleAsync(storeId, cancellationToken);
|
||||
|
||||
var startAt = ParseDateOrNull(request.StartDate);
|
||||
var endAt = ParseDateOrNull(request.EndDate)?.AddDays(1);
|
||||
if (startAt.HasValue && endAt.HasValue && startAt >= endAt)
|
||||
{
|
||||
throw new BusinessException(ErrorCodes.BadRequest, "开始日期不能晚于结束日期");
|
||||
}
|
||||
|
||||
var transactionType = ParseTransactionType(request.Type);
|
||||
var deliveryType = ParseDeliveryType(request.Channel);
|
||||
var paymentMethod = ParsePaymentMethod(request.PaymentMethod);
|
||||
|
||||
return (storeId, startAt, endAt, transactionType, deliveryType, paymentMethod);
|
||||
}
|
||||
|
||||
private async Task EnsureStoreAccessibleAsync(long storeId, CancellationToken cancellationToken)
|
||||
{
|
||||
var (tenantId, merchantId) = StoreApiHelpers.GetTenantMerchantContext(storeContextService);
|
||||
await StoreApiHelpers.EnsureStoreAccessibleAsync(dbContext, tenantId, merchantId, storeId, cancellationToken);
|
||||
}
|
||||
|
||||
private static DateTime? ParseDateOrNull(string? value)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(value))
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
if (DateTime.TryParseExact(
|
||||
value,
|
||||
"yyyy-MM-dd",
|
||||
CultureInfo.InvariantCulture,
|
||||
DateTimeStyles.None,
|
||||
out var parsed))
|
||||
{
|
||||
return DateTime.SpecifyKind(parsed.Date, DateTimeKind.Utc);
|
||||
}
|
||||
|
||||
throw new BusinessException(ErrorCodes.BadRequest, "日期格式必须为 yyyy-MM-dd");
|
||||
}
|
||||
|
||||
private static FinanceTransactionType? ParseTransactionType(string? value)
|
||||
{
|
||||
var normalized = (value ?? string.Empty).Trim().ToLowerInvariant();
|
||||
return normalized switch
|
||||
{
|
||||
"income" => FinanceTransactionType.Income,
|
||||
"refund" => FinanceTransactionType.Refund,
|
||||
"stored_card_recharge" => FinanceTransactionType.StoredCardRecharge,
|
||||
"point_redeem" => FinanceTransactionType.PointRedeem,
|
||||
_ => null
|
||||
};
|
||||
}
|
||||
|
||||
private static DeliveryType? ParseDeliveryType(string? value)
|
||||
{
|
||||
var normalized = (value ?? string.Empty).Trim().ToLowerInvariant();
|
||||
return normalized switch
|
||||
{
|
||||
"delivery" => DeliveryType.Delivery,
|
||||
"pickup" => DeliveryType.Pickup,
|
||||
"dine_in" => DeliveryType.DineIn,
|
||||
_ => null
|
||||
};
|
||||
}
|
||||
|
||||
private static PaymentMethod? ParsePaymentMethod(string? value)
|
||||
{
|
||||
var normalized = (value ?? string.Empty).Trim().ToLowerInvariant();
|
||||
return normalized switch
|
||||
{
|
||||
"wechat" => PaymentMethod.WeChatPay,
|
||||
"alipay" => PaymentMethod.Alipay,
|
||||
"cash" => PaymentMethod.Cash,
|
||||
"card" => PaymentMethod.Card,
|
||||
"balance" => PaymentMethod.Balance,
|
||||
_ => null
|
||||
};
|
||||
}
|
||||
|
||||
private static bool TryParseTransactionId(string? value, out FinanceTransactionSourceType sourceType, out long sourceId)
|
||||
{
|
||||
sourceType = default;
|
||||
sourceId = 0;
|
||||
|
||||
var normalized = (value ?? string.Empty).Trim();
|
||||
if (string.IsNullOrWhiteSpace(normalized))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
var parts = normalized.Split(':', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries);
|
||||
if (parts.Length != 2)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
if (!long.TryParse(parts[1], out sourceId) || sourceId <= 0)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
sourceType = parts[0].ToLowerInvariant() switch
|
||||
{
|
||||
"payment" => FinanceTransactionSourceType.PaymentRecord,
|
||||
"payment_refund" => FinanceTransactionSourceType.PaymentRefundRecord,
|
||||
"refund_request" => FinanceTransactionSourceType.RefundRequest,
|
||||
"stored_card_recharge" => FinanceTransactionSourceType.StoredCardRechargeRecord,
|
||||
"member_point" => FinanceTransactionSourceType.MemberPointLedger,
|
||||
_ => default
|
||||
};
|
||||
|
||||
return sourceType != default;
|
||||
}
|
||||
|
||||
private static FinanceTransactionListItemResponse MapListItem(FinanceTransactionListItemDto source)
|
||||
{
|
||||
return new FinanceTransactionListItemResponse
|
||||
{
|
||||
TransactionId = source.TransactionId,
|
||||
TransactionNo = source.TransactionNo,
|
||||
OrderNo = source.OrderNo,
|
||||
Type = source.TransactionType,
|
||||
TypeText = source.TransactionTypeText,
|
||||
Channel = source.ChannelText,
|
||||
PaymentMethod = source.PaymentMethodText,
|
||||
Amount = source.AmountSigned,
|
||||
OccurredAt = source.OccurredAt.ToString("yyyy-MM-dd HH:mm:ss", CultureInfo.InvariantCulture),
|
||||
Remark = source.Remark,
|
||||
IsIncome = source.IsIncome
|
||||
};
|
||||
}
|
||||
|
||||
private static FinanceTransactionDetailResponse MapDetail(FinanceTransactionDetailDto source)
|
||||
{
|
||||
return new FinanceTransactionDetailResponse
|
||||
{
|
||||
TransactionId = source.TransactionId,
|
||||
TransactionNo = source.TransactionNo,
|
||||
Type = source.TransactionType,
|
||||
TypeText = source.TransactionTypeText,
|
||||
StoreId = source.StoreId.ToString(),
|
||||
OrderNo = source.OrderNo,
|
||||
Channel = source.ChannelText,
|
||||
PaymentMethod = source.PaymentMethodText,
|
||||
Amount = source.AmountSigned,
|
||||
OccurredAt = source.OccurredAt.ToString("yyyy-MM-dd HH:mm:ss", CultureInfo.InvariantCulture),
|
||||
Remark = source.Remark,
|
||||
CustomerName = source.CustomerName,
|
||||
CustomerPhone = source.CustomerPhone,
|
||||
RefundNo = source.RefundNo,
|
||||
RefundReason = source.RefundReason,
|
||||
MemberName = source.MemberName,
|
||||
MemberMobileMasked = source.MemberMobileMasked,
|
||||
RechargeAmount = source.RechargeAmount,
|
||||
GiftAmount = source.GiftAmount,
|
||||
ArrivedAmount = source.ArrivedAmount,
|
||||
PointChangeAmount = source.PointChangeAmount,
|
||||
PointBalanceAfterChange = source.PointBalanceAfterChange
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,171 @@
|
||||
using MediatR;
|
||||
using Microsoft.AspNetCore.Authorization;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using TakeoutSaaS.Application.App.Coupons.Calendar.Dto;
|
||||
using TakeoutSaaS.Application.App.Coupons.Calendar.Queries;
|
||||
using TakeoutSaaS.Application.App.Stores.Services;
|
||||
using TakeoutSaaS.Infrastructure.App.Persistence;
|
||||
using TakeoutSaaS.Module.Authorization.Attributes;
|
||||
using TakeoutSaaS.Shared.Abstractions.Results;
|
||||
using TakeoutSaaS.Shared.Web.Api;
|
||||
using TakeoutSaaS.TenantApi.Contracts.Marketing;
|
||||
|
||||
namespace TakeoutSaaS.TenantApi.Controllers;
|
||||
|
||||
/// <summary>
|
||||
/// 营销中心营销日历。
|
||||
/// </summary>
|
||||
[ApiVersion("1.0")]
|
||||
[Authorize]
|
||||
[Route("api/tenant/v{version:apiVersion}/marketing/calendar")]
|
||||
public sealed class MarketingCalendarController(
|
||||
IMediator mediator,
|
||||
TakeoutAppDbContext dbContext,
|
||||
StoreContextService storeContextService)
|
||||
: BaseApiController
|
||||
{
|
||||
private const string ViewPermission = "tenant:marketing:calendar:view";
|
||||
private const string ManagePermission = "tenant:marketing:calendar:manage";
|
||||
|
||||
/// <summary>
|
||||
/// 获取营销日历总览。
|
||||
/// </summary>
|
||||
[HttpGet("overview")]
|
||||
[PermissionAuthorize(ViewPermission, ManagePermission)]
|
||||
[ProducesResponseType(typeof(ApiResponse<MarketingCalendarOverviewResponse>), StatusCodes.Status200OK)]
|
||||
public async Task<ApiResponse<MarketingCalendarOverviewResponse>> Overview(
|
||||
[FromQuery] MarketingCalendarOverviewRequest request,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var storeId = StoreApiHelpers.ParseRequiredSnowflake(request.StoreId, nameof(request.StoreId));
|
||||
await EnsureStoreAccessibleAsync(storeId, cancellationToken);
|
||||
|
||||
var result = await mediator.Send(new GetMarketingCalendarOverviewQuery
|
||||
{
|
||||
StoreId = storeId,
|
||||
Year = request.Year,
|
||||
Month = request.Month
|
||||
}, cancellationToken);
|
||||
|
||||
return ApiResponse<MarketingCalendarOverviewResponse>.Ok(MapOverview(result));
|
||||
}
|
||||
|
||||
private async Task EnsureStoreAccessibleAsync(long storeId, CancellationToken cancellationToken)
|
||||
{
|
||||
var (tenantId, merchantId) = StoreApiHelpers.GetTenantMerchantContext(storeContextService);
|
||||
await StoreApiHelpers.EnsureStoreAccessibleAsync(dbContext, tenantId, merchantId, storeId, cancellationToken);
|
||||
}
|
||||
|
||||
private static MarketingCalendarOverviewResponse MapOverview(MarketingCalendarOverviewDto source)
|
||||
{
|
||||
return new MarketingCalendarOverviewResponse
|
||||
{
|
||||
Month = source.Month,
|
||||
Year = source.Year,
|
||||
MonthValue = source.MonthValue,
|
||||
MonthStartDate = StoreApiHelpers.ToDateOnly(source.MonthStartDate),
|
||||
MonthEndDate = StoreApiHelpers.ToDateOnly(source.MonthEndDate),
|
||||
TodayDay = source.TodayDay,
|
||||
Days = source.Days
|
||||
.Select(item => new MarketingCalendarDayResponse
|
||||
{
|
||||
Day = item.Day,
|
||||
IsWeekend = item.IsWeekend,
|
||||
IsToday = item.IsToday
|
||||
})
|
||||
.ToList(),
|
||||
Legends = source.Legends
|
||||
.Select(item => new MarketingCalendarLegendResponse
|
||||
{
|
||||
Type = item.Type,
|
||||
Label = item.Label,
|
||||
Color = item.Color
|
||||
})
|
||||
.ToList(),
|
||||
Stats = new MarketingCalendarStatsResponse
|
||||
{
|
||||
TotalActivityCount = source.Stats.TotalActivityCount,
|
||||
OngoingCount = source.Stats.OngoingCount,
|
||||
MaxConcurrentCount = source.Stats.MaxConcurrentCount,
|
||||
EstimatedDiscountAmount = source.Stats.EstimatedDiscountAmount
|
||||
},
|
||||
ConflictBanner = source.ConflictBanner is null
|
||||
? null
|
||||
: new MarketingCalendarConflictBannerResponse
|
||||
{
|
||||
ConflictId = source.ConflictBanner.ConflictId,
|
||||
StartDay = source.ConflictBanner.StartDay,
|
||||
EndDay = source.ConflictBanner.EndDay,
|
||||
ActivityCount = source.ConflictBanner.ActivityCount,
|
||||
MaxConcurrentCount = source.ConflictBanner.MaxConcurrentCount,
|
||||
ConflictCount = source.ConflictBanner.ConflictCount
|
||||
},
|
||||
Conflicts = source.Conflicts
|
||||
.Select(MapConflict)
|
||||
.ToList(),
|
||||
Activities = source.Activities
|
||||
.Select(MapActivity)
|
||||
.ToList()
|
||||
};
|
||||
}
|
||||
|
||||
private static MarketingCalendarActivityResponse MapActivity(MarketingCalendarActivityDto source)
|
||||
{
|
||||
return new MarketingCalendarActivityResponse
|
||||
{
|
||||
ActivityId = source.ActivityId,
|
||||
SourceType = source.SourceType,
|
||||
SourceId = source.SourceId,
|
||||
CalendarType = source.CalendarType,
|
||||
Name = source.Name,
|
||||
Color = source.Color,
|
||||
Summary = source.Summary,
|
||||
DisplayStatus = source.DisplayStatus,
|
||||
IsDimmed = source.IsDimmed,
|
||||
StartDate = StoreApiHelpers.ToDateOnly(source.StartDate),
|
||||
EndDate = StoreApiHelpers.ToDateOnly(source.EndDate),
|
||||
EstimatedDiscountAmount = source.EstimatedDiscountAmount,
|
||||
Bars = source.Bars.Select(item => new MarketingCalendarActivityBarResponse
|
||||
{
|
||||
BarId = item.BarId,
|
||||
StartDay = item.StartDay,
|
||||
EndDay = item.EndDay,
|
||||
Label = item.Label,
|
||||
IsMilestone = item.IsMilestone,
|
||||
IsDimmed = item.IsDimmed
|
||||
}).ToList(),
|
||||
Detail = new MarketingCalendarActivityDetailResponse
|
||||
{
|
||||
ModuleName = source.Detail.ModuleName,
|
||||
Description = source.Detail.Description,
|
||||
Fields = source.Detail.Fields.Select(item => new MarketingCalendarDetailFieldResponse
|
||||
{
|
||||
Label = item.Label,
|
||||
Value = item.Value
|
||||
}).ToList()
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
private static MarketingCalendarConflictResponse MapConflict(MarketingCalendarConflictDto source)
|
||||
{
|
||||
return new MarketingCalendarConflictResponse
|
||||
{
|
||||
ConflictId = source.ConflictId,
|
||||
StartDay = source.StartDay,
|
||||
EndDay = source.EndDay,
|
||||
ActivityCount = source.ActivityCount,
|
||||
MaxConcurrentCount = source.MaxConcurrentCount,
|
||||
ActivityIds = source.ActivityIds.ToList(),
|
||||
Activities = source.Activities.Select(item => new MarketingCalendarConflictActivityResponse
|
||||
{
|
||||
ActivityId = item.ActivityId,
|
||||
CalendarType = item.CalendarType,
|
||||
Name = item.Name,
|
||||
Summary = item.Summary,
|
||||
Color = item.Color,
|
||||
DisplayStatus = item.DisplayStatus
|
||||
}).ToList()
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,297 @@
|
||||
using System.Globalization;
|
||||
using MediatR;
|
||||
using Microsoft.AspNetCore.Authorization;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using TakeoutSaaS.Application.App.Coupons.NewCustomer.Commands;
|
||||
using TakeoutSaaS.Application.App.Coupons.NewCustomer.Dto;
|
||||
using TakeoutSaaS.Application.App.Coupons.NewCustomer.Queries;
|
||||
using TakeoutSaaS.Application.App.Stores.Services;
|
||||
using TakeoutSaaS.Infrastructure.App.Persistence;
|
||||
using TakeoutSaaS.Module.Authorization.Attributes;
|
||||
using TakeoutSaaS.Shared.Abstractions.Results;
|
||||
using TakeoutSaaS.Shared.Web.Api;
|
||||
using TakeoutSaaS.TenantApi.Contracts.Marketing;
|
||||
|
||||
namespace TakeoutSaaS.TenantApi.Controllers;
|
||||
|
||||
/// <summary>
|
||||
/// 营销中心新客有礼管理。
|
||||
/// </summary>
|
||||
[ApiVersion("1.0")]
|
||||
[Authorize]
|
||||
[Route("api/tenant/v{version:apiVersion}/marketing/new-customer")]
|
||||
public sealed class MarketingNewCustomerController(
|
||||
IMediator mediator,
|
||||
TakeoutAppDbContext dbContext,
|
||||
StoreContextService storeContextService)
|
||||
: BaseApiController
|
||||
{
|
||||
private const string ViewPermission = "tenant:marketing:new-customer:view";
|
||||
private const string ManagePermission = "tenant:marketing:new-customer:manage";
|
||||
|
||||
/// <summary>
|
||||
/// 获取新客有礼详情。
|
||||
/// </summary>
|
||||
[HttpGet("detail")]
|
||||
[PermissionAuthorize(ViewPermission, ManagePermission)]
|
||||
[ProducesResponseType(typeof(ApiResponse<NewCustomerDetailResponse>), StatusCodes.Status200OK)]
|
||||
public async Task<ApiResponse<NewCustomerDetailResponse>> Detail(
|
||||
[FromQuery] NewCustomerDetailRequest request,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
// 1. 解析并校验门店权限
|
||||
var storeId = StoreApiHelpers.ParseRequiredSnowflake(request.StoreId, nameof(request.StoreId));
|
||||
await EnsureStoreAccessibleAsync(storeId, cancellationToken);
|
||||
|
||||
// 2. 查询应用层详情
|
||||
var result = await mediator.Send(new GetNewCustomerDetailQuery
|
||||
{
|
||||
StoreId = storeId,
|
||||
RecordPage = request.RecordPage,
|
||||
RecordPageSize = request.RecordPageSize
|
||||
}, cancellationToken);
|
||||
|
||||
// 3. 返回响应
|
||||
return ApiResponse<NewCustomerDetailResponse>.Ok(MapDetail(result));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 保存新客有礼配置。
|
||||
/// </summary>
|
||||
[HttpPost("save")]
|
||||
[PermissionAuthorize(ManagePermission)]
|
||||
[ProducesResponseType(typeof(ApiResponse<NewCustomerSettingsResponse>), StatusCodes.Status200OK)]
|
||||
public async Task<ApiResponse<NewCustomerSettingsResponse>> Save(
|
||||
[FromBody] SaveNewCustomerSettingsRequest request,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
// 1. 解析并校验门店权限
|
||||
var storeId = StoreApiHelpers.ParseRequiredSnowflake(request.StoreId, nameof(request.StoreId));
|
||||
await EnsureStoreAccessibleAsync(storeId, cancellationToken);
|
||||
|
||||
// 2. 调用应用层保存
|
||||
var result = await mediator.Send(new SaveNewCustomerSettingsCommand
|
||||
{
|
||||
StoreId = storeId,
|
||||
GiftEnabled = request.GiftEnabled,
|
||||
GiftType = request.GiftType,
|
||||
DirectReduceAmount = request.DirectReduceAmount,
|
||||
DirectMinimumSpend = request.DirectMinimumSpend,
|
||||
InviteEnabled = request.InviteEnabled,
|
||||
ShareChannels = request.ShareChannels,
|
||||
WelcomeCoupons = request.WelcomeCoupons.Select(MapSaveCouponRule).ToList(),
|
||||
InviterCoupons = request.InviterCoupons.Select(MapSaveCouponRule).ToList(),
|
||||
InviteeCoupons = request.InviteeCoupons.Select(MapSaveCouponRule).ToList()
|
||||
}, cancellationToken);
|
||||
|
||||
// 3. 返回响应
|
||||
return ApiResponse<NewCustomerSettingsResponse>.Ok(MapSettings(result));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 获取新客邀请记录分页。
|
||||
/// </summary>
|
||||
[HttpGet("invite-record/list")]
|
||||
[PermissionAuthorize(ViewPermission, ManagePermission)]
|
||||
[ProducesResponseType(typeof(ApiResponse<NewCustomerInviteRecordListResultResponse>), StatusCodes.Status200OK)]
|
||||
public async Task<ApiResponse<NewCustomerInviteRecordListResultResponse>> InviteRecordList(
|
||||
[FromQuery] NewCustomerInviteRecordListRequest request,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
// 1. 解析并校验门店权限
|
||||
var storeId = StoreApiHelpers.ParseRequiredSnowflake(request.StoreId, nameof(request.StoreId));
|
||||
await EnsureStoreAccessibleAsync(storeId, cancellationToken);
|
||||
|
||||
// 2. 查询应用层分页
|
||||
var result = await mediator.Send(new GetNewCustomerInviteRecordListQuery
|
||||
{
|
||||
StoreId = storeId,
|
||||
Page = request.Page,
|
||||
PageSize = request.PageSize
|
||||
}, cancellationToken);
|
||||
|
||||
// 3. 返回响应
|
||||
return ApiResponse<NewCustomerInviteRecordListResultResponse>.Ok(MapInviteRecordList(result));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 写入新客邀请记录。
|
||||
/// </summary>
|
||||
[HttpPost("invite-record/write")]
|
||||
[PermissionAuthorize(ManagePermission)]
|
||||
[ProducesResponseType(typeof(ApiResponse<NewCustomerInviteRecordResponse>), StatusCodes.Status200OK)]
|
||||
public async Task<ApiResponse<NewCustomerInviteRecordResponse>> WriteInviteRecord(
|
||||
[FromBody] WriteNewCustomerInviteRecordRequest request,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
// 1. 解析并校验门店权限
|
||||
var storeId = StoreApiHelpers.ParseRequiredSnowflake(request.StoreId, nameof(request.StoreId));
|
||||
await EnsureStoreAccessibleAsync(storeId, cancellationToken);
|
||||
|
||||
// 2. 调用应用层写入
|
||||
var result = await mediator.Send(new WriteNewCustomerInviteRecordCommand
|
||||
{
|
||||
StoreId = storeId,
|
||||
InviterName = request.InviterName,
|
||||
InviteeName = request.InviteeName,
|
||||
InviteTime = request.InviteTime,
|
||||
OrderStatus = request.OrderStatus,
|
||||
RewardStatus = request.RewardStatus,
|
||||
RewardIssuedAt = request.RewardIssuedAt,
|
||||
SourceChannel = request.SourceChannel
|
||||
}, cancellationToken);
|
||||
|
||||
// 3. 返回响应
|
||||
return ApiResponse<NewCustomerInviteRecordResponse>.Ok(MapInviteRecord(result));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 写入新客成长记录。
|
||||
/// </summary>
|
||||
[HttpPost("growth-record/write")]
|
||||
[PermissionAuthorize(ManagePermission)]
|
||||
[ProducesResponseType(typeof(ApiResponse<NewCustomerGrowthRecordResponse>), StatusCodes.Status200OK)]
|
||||
public async Task<ApiResponse<NewCustomerGrowthRecordResponse>> WriteGrowthRecord(
|
||||
[FromBody] WriteNewCustomerGrowthRecordRequest request,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
// 1. 解析并校验门店权限
|
||||
var storeId = StoreApiHelpers.ParseRequiredSnowflake(request.StoreId, nameof(request.StoreId));
|
||||
await EnsureStoreAccessibleAsync(storeId, cancellationToken);
|
||||
|
||||
// 2. 调用应用层写入
|
||||
var result = await mediator.Send(new WriteNewCustomerGrowthRecordCommand
|
||||
{
|
||||
StoreId = storeId,
|
||||
CustomerKey = request.CustomerKey,
|
||||
CustomerName = request.CustomerName,
|
||||
RegisteredAt = request.RegisteredAt,
|
||||
GiftClaimedAt = request.GiftClaimedAt,
|
||||
FirstOrderAt = request.FirstOrderAt,
|
||||
SourceChannel = request.SourceChannel
|
||||
}, cancellationToken);
|
||||
|
||||
// 3. 返回响应
|
||||
return ApiResponse<NewCustomerGrowthRecordResponse>.Ok(MapGrowthRecord(result));
|
||||
}
|
||||
|
||||
private async Task EnsureStoreAccessibleAsync(long storeId, CancellationToken cancellationToken)
|
||||
{
|
||||
var (tenantId, merchantId) = StoreApiHelpers.GetTenantMerchantContext(storeContextService);
|
||||
await StoreApiHelpers.EnsureStoreAccessibleAsync(dbContext, tenantId, merchantId, storeId, cancellationToken);
|
||||
}
|
||||
|
||||
private static NewCustomerSaveCouponRuleInputDto MapSaveCouponRule(NewCustomerSaveCouponRuleRequest source)
|
||||
{
|
||||
return new NewCustomerSaveCouponRuleInputDto
|
||||
{
|
||||
CouponType = source.CouponType,
|
||||
Value = source.Value,
|
||||
MinimumSpend = source.MinimumSpend,
|
||||
ValidDays = source.ValidDays
|
||||
};
|
||||
}
|
||||
|
||||
private static NewCustomerDetailResponse MapDetail(NewCustomerDetailDto source)
|
||||
{
|
||||
return new NewCustomerDetailResponse
|
||||
{
|
||||
Settings = MapSettings(source.Settings),
|
||||
Stats = new NewCustomerStatsResponse
|
||||
{
|
||||
MonthlyNewCustomers = source.Stats.MonthlyNewCustomers,
|
||||
MonthlyGrowthCount = source.Stats.MonthlyGrowthCount,
|
||||
MonthlyGrowthRatePercent = source.Stats.MonthlyGrowthRatePercent,
|
||||
GiftClaimRate = source.Stats.GiftClaimRate,
|
||||
GiftClaimedCount = source.Stats.GiftClaimedCount,
|
||||
FirstOrderConversionRate = source.Stats.FirstOrderConversionRate,
|
||||
FirstOrderedCount = source.Stats.FirstOrderedCount
|
||||
},
|
||||
InviteRecords = MapInviteRecordList(source.InviteRecords)
|
||||
};
|
||||
}
|
||||
|
||||
private static NewCustomerSettingsResponse MapSettings(NewCustomerSettingsDto source)
|
||||
{
|
||||
return new NewCustomerSettingsResponse
|
||||
{
|
||||
StoreId = source.StoreId.ToString(),
|
||||
GiftEnabled = source.GiftEnabled,
|
||||
GiftType = source.GiftType,
|
||||
DirectReduceAmount = source.DirectReduceAmount,
|
||||
DirectMinimumSpend = source.DirectMinimumSpend,
|
||||
InviteEnabled = source.InviteEnabled,
|
||||
ShareChannels = source.ShareChannels.ToList(),
|
||||
WelcomeCoupons = source.WelcomeCoupons.Select(MapCouponRule).ToList(),
|
||||
InviterCoupons = source.InviterCoupons.Select(MapCouponRule).ToList(),
|
||||
InviteeCoupons = source.InviteeCoupons.Select(MapCouponRule).ToList(),
|
||||
UpdatedAt = ToDateTime(source.UpdatedAt)
|
||||
};
|
||||
}
|
||||
|
||||
private static NewCustomerCouponRuleResponse MapCouponRule(NewCustomerCouponRuleDto source)
|
||||
{
|
||||
return new NewCustomerCouponRuleResponse
|
||||
{
|
||||
Id = source.Id.ToString(),
|
||||
Scene = source.Scene,
|
||||
CouponType = source.CouponType,
|
||||
Value = source.Value,
|
||||
MinimumSpend = source.MinimumSpend,
|
||||
ValidDays = source.ValidDays,
|
||||
SortOrder = source.SortOrder
|
||||
};
|
||||
}
|
||||
|
||||
private static NewCustomerInviteRecordListResultResponse MapInviteRecordList(
|
||||
NewCustomerInviteRecordListResultDto source)
|
||||
{
|
||||
return new NewCustomerInviteRecordListResultResponse
|
||||
{
|
||||
Items = source.Items.Select(MapInviteRecord).ToList(),
|
||||
Page = source.Page,
|
||||
PageSize = source.PageSize,
|
||||
TotalCount = source.TotalCount
|
||||
};
|
||||
}
|
||||
|
||||
private static NewCustomerInviteRecordResponse MapInviteRecord(NewCustomerInviteRecordDto source)
|
||||
{
|
||||
return new NewCustomerInviteRecordResponse
|
||||
{
|
||||
Id = source.Id.ToString(),
|
||||
InviterName = source.InviterName,
|
||||
InviteeName = source.InviteeName,
|
||||
InviteTime = ToDateTime(source.InviteTime),
|
||||
OrderStatus = source.OrderStatus,
|
||||
RewardStatus = source.RewardStatus,
|
||||
RewardIssuedAt = source.RewardIssuedAt.HasValue
|
||||
? ToDateTime(source.RewardIssuedAt.Value)
|
||||
: null,
|
||||
SourceChannel = source.SourceChannel
|
||||
};
|
||||
}
|
||||
|
||||
private static NewCustomerGrowthRecordResponse MapGrowthRecord(NewCustomerGrowthRecordDto source)
|
||||
{
|
||||
return new NewCustomerGrowthRecordResponse
|
||||
{
|
||||
Id = source.Id.ToString(),
|
||||
CustomerKey = source.CustomerKey,
|
||||
CustomerName = source.CustomerName,
|
||||
RegisteredAt = ToDateTime(source.RegisteredAt),
|
||||
GiftClaimedAt = source.GiftClaimedAt.HasValue
|
||||
? ToDateTime(source.GiftClaimedAt.Value)
|
||||
: null,
|
||||
FirstOrderAt = source.FirstOrderAt.HasValue
|
||||
? ToDateTime(source.FirstOrderAt.Value)
|
||||
: null,
|
||||
SourceChannel = source.SourceChannel
|
||||
};
|
||||
}
|
||||
|
||||
private static string ToDateTime(DateTime value)
|
||||
{
|
||||
return value.ToString("yyyy-MM-dd HH:mm:ss", CultureInfo.InvariantCulture);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,402 @@
|
||||
using System.Globalization;
|
||||
using MediatR;
|
||||
using Microsoft.AspNetCore.Authorization;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using TakeoutSaaS.Application.App.Coupons.PunchCard.Commands;
|
||||
using TakeoutSaaS.Application.App.Coupons.PunchCard.Dto;
|
||||
using TakeoutSaaS.Application.App.Coupons.PunchCard.Queries;
|
||||
using TakeoutSaaS.Application.App.Stores.Services;
|
||||
using TakeoutSaaS.Infrastructure.App.Persistence;
|
||||
using TakeoutSaaS.Module.Authorization.Attributes;
|
||||
using TakeoutSaaS.Shared.Abstractions.Constants;
|
||||
using TakeoutSaaS.Shared.Abstractions.Results;
|
||||
using TakeoutSaaS.Shared.Web.Api;
|
||||
using TakeoutSaaS.TenantApi.Contracts.Marketing;
|
||||
|
||||
namespace TakeoutSaaS.TenantApi.Controllers;
|
||||
|
||||
/// <summary>
|
||||
/// 营销中心次卡管理。
|
||||
/// </summary>
|
||||
[ApiVersion("1.0")]
|
||||
[Authorize]
|
||||
[Route("api/tenant/v{version:apiVersion}/marketing/punch-card")]
|
||||
public sealed class MarketingPunchCardController(
|
||||
IMediator mediator,
|
||||
TakeoutAppDbContext dbContext,
|
||||
StoreContextService storeContextService)
|
||||
: BaseApiController
|
||||
{
|
||||
private const string ViewPermission = "tenant:marketing:punch-card:view";
|
||||
private const string ManagePermission = "tenant:marketing:punch-card:manage";
|
||||
|
||||
/// <summary>
|
||||
/// 获取次卡列表。
|
||||
/// </summary>
|
||||
[HttpGet("list")]
|
||||
[PermissionAuthorize(ViewPermission, ManagePermission)]
|
||||
[ProducesResponseType(typeof(ApiResponse<PunchCardListResultResponse>), StatusCodes.Status200OK)]
|
||||
public async Task<ApiResponse<PunchCardListResultResponse>> List(
|
||||
[FromQuery] PunchCardListRequest request,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var storeId = StoreApiHelpers.ParseRequiredSnowflake(request.StoreId, nameof(request.StoreId));
|
||||
await EnsureStoreAccessibleAsync(storeId, cancellationToken);
|
||||
|
||||
var result = await mediator.Send(new GetPunchCardTemplateListQuery
|
||||
{
|
||||
StoreId = storeId,
|
||||
Keyword = request.Keyword,
|
||||
Status = request.Status,
|
||||
Page = request.Page,
|
||||
PageSize = request.PageSize
|
||||
}, cancellationToken);
|
||||
|
||||
return ApiResponse<PunchCardListResultResponse>.Ok(new PunchCardListResultResponse
|
||||
{
|
||||
Items = result.Items.Select(MapListItem).ToList(),
|
||||
Page = result.Page,
|
||||
PageSize = result.PageSize,
|
||||
TotalCount = result.TotalCount,
|
||||
Stats = MapTemplateStats(result.Stats)
|
||||
});
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 获取次卡详情。
|
||||
/// </summary>
|
||||
[HttpGet("detail")]
|
||||
[PermissionAuthorize(ViewPermission, ManagePermission)]
|
||||
[ProducesResponseType(typeof(ApiResponse<PunchCardDetailResponse>), StatusCodes.Status200OK)]
|
||||
public async Task<ApiResponse<PunchCardDetailResponse>> Detail(
|
||||
[FromQuery] PunchCardDetailRequest request,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var storeId = StoreApiHelpers.ParseRequiredSnowflake(request.StoreId, nameof(request.StoreId));
|
||||
await EnsureStoreAccessibleAsync(storeId, cancellationToken);
|
||||
|
||||
var result = await mediator.Send(new GetPunchCardTemplateDetailQuery
|
||||
{
|
||||
StoreId = storeId,
|
||||
TemplateId = StoreApiHelpers.ParseRequiredSnowflake(request.PunchCardId, nameof(request.PunchCardId))
|
||||
}, cancellationToken);
|
||||
|
||||
if (result is null)
|
||||
{
|
||||
return ApiResponse<PunchCardDetailResponse>.Error(ErrorCodes.NotFound, "次卡不存在");
|
||||
}
|
||||
|
||||
return ApiResponse<PunchCardDetailResponse>.Ok(MapDetail(result));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 保存次卡。
|
||||
/// </summary>
|
||||
[HttpPost("save")]
|
||||
[PermissionAuthorize(ManagePermission)]
|
||||
[ProducesResponseType(typeof(ApiResponse<PunchCardDetailResponse>), StatusCodes.Status200OK)]
|
||||
public async Task<ApiResponse<PunchCardDetailResponse>> Save(
|
||||
[FromBody] SavePunchCardRequest request,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var storeId = StoreApiHelpers.ParseRequiredSnowflake(request.StoreId, nameof(request.StoreId));
|
||||
await EnsureStoreAccessibleAsync(storeId, cancellationToken);
|
||||
|
||||
var result = await mediator.Send(new SavePunchCardTemplateCommand
|
||||
{
|
||||
StoreId = storeId,
|
||||
TemplateId = StoreApiHelpers.ParseSnowflakeOrNull(request.Id),
|
||||
Name = request.Name,
|
||||
CoverImageUrl = request.CoverImageUrl,
|
||||
SalePrice = request.SalePrice,
|
||||
OriginalPrice = request.OriginalPrice,
|
||||
TotalTimes = request.TotalTimes,
|
||||
ValidityType = request.ValidityType,
|
||||
ValidityDays = request.ValidityDays,
|
||||
ValidFrom = ParseDateOrNull(request.ValidFrom, nameof(request.ValidFrom)),
|
||||
ValidTo = ParseDateOrNull(request.ValidTo, nameof(request.ValidTo)),
|
||||
ScopeType = request.ScopeType,
|
||||
ScopeCategoryIds = StoreApiHelpers.ParseSnowflakeList(request.ScopeCategoryIds),
|
||||
ScopeTagIds = StoreApiHelpers.ParseSnowflakeList(request.ScopeTagIds),
|
||||
ScopeProductIds = StoreApiHelpers.ParseSnowflakeList(request.ScopeProductIds),
|
||||
UsageMode = request.UsageMode,
|
||||
UsageCapAmount = request.UsageCapAmount,
|
||||
DailyLimit = request.DailyLimit,
|
||||
PerOrderLimit = request.PerOrderLimit,
|
||||
PerUserPurchaseLimit = request.PerUserPurchaseLimit,
|
||||
AllowTransfer = request.AllowTransfer,
|
||||
ExpireStrategy = request.ExpireStrategy,
|
||||
Description = request.Description,
|
||||
NotifyChannels = request.NotifyChannels
|
||||
}, cancellationToken);
|
||||
|
||||
return ApiResponse<PunchCardDetailResponse>.Ok(MapDetail(result));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 修改次卡状态。
|
||||
/// </summary>
|
||||
[HttpPost("status")]
|
||||
[PermissionAuthorize(ManagePermission)]
|
||||
[ProducesResponseType(typeof(ApiResponse<PunchCardDetailResponse>), StatusCodes.Status200OK)]
|
||||
public async Task<ApiResponse<PunchCardDetailResponse>> ChangeStatus(
|
||||
[FromBody] ChangePunchCardStatusRequest request,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var storeId = StoreApiHelpers.ParseRequiredSnowflake(request.StoreId, nameof(request.StoreId));
|
||||
await EnsureStoreAccessibleAsync(storeId, cancellationToken);
|
||||
|
||||
var result = await mediator.Send(new ChangePunchCardTemplateStatusCommand
|
||||
{
|
||||
StoreId = storeId,
|
||||
TemplateId = StoreApiHelpers.ParseRequiredSnowflake(request.PunchCardId, nameof(request.PunchCardId)),
|
||||
Status = request.Status
|
||||
}, cancellationToken);
|
||||
|
||||
return ApiResponse<PunchCardDetailResponse>.Ok(MapDetail(result));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 删除次卡。
|
||||
/// </summary>
|
||||
[HttpPost("delete")]
|
||||
[PermissionAuthorize(ManagePermission)]
|
||||
[ProducesResponseType(typeof(ApiResponse<object>), StatusCodes.Status200OK)]
|
||||
public async Task<ApiResponse<object>> Delete(
|
||||
[FromBody] DeletePunchCardRequest request,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var storeId = StoreApiHelpers.ParseRequiredSnowflake(request.StoreId, nameof(request.StoreId));
|
||||
await EnsureStoreAccessibleAsync(storeId, cancellationToken);
|
||||
|
||||
await mediator.Send(new DeletePunchCardTemplateCommand
|
||||
{
|
||||
StoreId = storeId,
|
||||
TemplateId = StoreApiHelpers.ParseRequiredSnowflake(request.PunchCardId, nameof(request.PunchCardId))
|
||||
}, cancellationToken);
|
||||
|
||||
return ApiResponse<object>.Ok(null);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 获取次卡使用记录。
|
||||
/// </summary>
|
||||
[HttpGet("usage-record/list")]
|
||||
[PermissionAuthorize(ViewPermission, ManagePermission)]
|
||||
[ProducesResponseType(typeof(ApiResponse<PunchCardUsageRecordListResultResponse>), StatusCodes.Status200OK)]
|
||||
public async Task<ApiResponse<PunchCardUsageRecordListResultResponse>> UsageRecordList(
|
||||
[FromQuery] PunchCardUsageRecordListRequest request,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var storeId = StoreApiHelpers.ParseRequiredSnowflake(request.StoreId, nameof(request.StoreId));
|
||||
await EnsureStoreAccessibleAsync(storeId, cancellationToken);
|
||||
|
||||
var result = await mediator.Send(new GetPunchCardUsageRecordListQuery
|
||||
{
|
||||
StoreId = storeId,
|
||||
TemplateId = StoreApiHelpers.ParseSnowflakeOrNull(request.PunchCardId),
|
||||
Status = request.Status,
|
||||
Keyword = request.Keyword,
|
||||
Page = request.Page,
|
||||
PageSize = request.PageSize
|
||||
}, cancellationToken);
|
||||
|
||||
return ApiResponse<PunchCardUsageRecordListResultResponse>.Ok(new PunchCardUsageRecordListResultResponse
|
||||
{
|
||||
Items = result.Items.Select(MapUsageRecord).ToList(),
|
||||
Page = result.Page,
|
||||
PageSize = result.PageSize,
|
||||
TotalCount = result.TotalCount,
|
||||
Stats = new PunchCardUsageStatsResponse
|
||||
{
|
||||
TodayUsedCount = result.Stats.TodayUsedCount,
|
||||
MonthUsedCount = result.Stats.MonthUsedCount,
|
||||
ExpiringSoonCount = result.Stats.ExpiringSoonCount
|
||||
},
|
||||
TemplateOptions = result.TemplateOptions.Select(item => new PunchCardTemplateOptionResponse
|
||||
{
|
||||
TemplateId = item.TemplateId.ToString(),
|
||||
Name = item.Name
|
||||
}).ToList()
|
||||
});
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 导出次卡使用记录。
|
||||
/// </summary>
|
||||
[HttpGet("usage-record/export")]
|
||||
[PermissionAuthorize(ManagePermission)]
|
||||
[ProducesResponseType(typeof(ApiResponse<PunchCardUsageRecordExportResponse>), StatusCodes.Status200OK)]
|
||||
public async Task<ApiResponse<PunchCardUsageRecordExportResponse>> ExportUsageRecord(
|
||||
[FromQuery] ExportPunchCardUsageRecordRequest request,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var storeId = StoreApiHelpers.ParseRequiredSnowflake(request.StoreId, nameof(request.StoreId));
|
||||
await EnsureStoreAccessibleAsync(storeId, cancellationToken);
|
||||
|
||||
var result = await mediator.Send(new ExportPunchCardUsageRecordCsvQuery
|
||||
{
|
||||
StoreId = storeId,
|
||||
TemplateId = StoreApiHelpers.ParseSnowflakeOrNull(request.PunchCardId),
|
||||
Status = request.Status,
|
||||
Keyword = request.Keyword
|
||||
}, cancellationToken);
|
||||
|
||||
return ApiResponse<PunchCardUsageRecordExportResponse>.Ok(new PunchCardUsageRecordExportResponse
|
||||
{
|
||||
FileName = result.FileName,
|
||||
FileContentBase64 = result.FileContentBase64,
|
||||
TotalCount = result.TotalCount
|
||||
});
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 写入次卡使用记录。
|
||||
/// </summary>
|
||||
[HttpPost("usage-record/write")]
|
||||
[PermissionAuthorize(ManagePermission)]
|
||||
[ProducesResponseType(typeof(ApiResponse<PunchCardUsageRecordResponse>), StatusCodes.Status200OK)]
|
||||
public async Task<ApiResponse<PunchCardUsageRecordResponse>> WriteUsageRecord(
|
||||
[FromBody] WritePunchCardUsageRecordRequest request,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var storeId = StoreApiHelpers.ParseRequiredSnowflake(request.StoreId, nameof(request.StoreId));
|
||||
await EnsureStoreAccessibleAsync(storeId, cancellationToken);
|
||||
|
||||
var result = await mediator.Send(new WritePunchCardUsageRecordCommand
|
||||
{
|
||||
StoreId = storeId,
|
||||
TemplateId = StoreApiHelpers.ParseRequiredSnowflake(request.PunchCardId, nameof(request.PunchCardId)),
|
||||
InstanceId = StoreApiHelpers.ParseSnowflakeOrNull(request.PunchCardInstanceId),
|
||||
InstanceNo = request.PunchCardInstanceNo,
|
||||
MemberName = request.MemberName,
|
||||
MemberPhoneMasked = request.MemberPhoneMasked,
|
||||
ProductName = request.ProductName,
|
||||
UsedAt = request.UsedAt,
|
||||
UsedTimes = request.UsedTimes,
|
||||
ExtraPayAmount = request.ExtraPayAmount
|
||||
}, cancellationToken);
|
||||
|
||||
return ApiResponse<PunchCardUsageRecordResponse>.Ok(MapUsageRecord(result));
|
||||
}
|
||||
|
||||
private async Task EnsureStoreAccessibleAsync(long storeId, CancellationToken cancellationToken)
|
||||
{
|
||||
var (tenantId, merchantId) = StoreApiHelpers.GetTenantMerchantContext(storeContextService);
|
||||
await StoreApiHelpers.EnsureStoreAccessibleAsync(dbContext, tenantId, merchantId, storeId, cancellationToken);
|
||||
}
|
||||
|
||||
private static DateTime? ParseDateOrNull(string? value, string fieldName)
|
||||
{
|
||||
return string.IsNullOrWhiteSpace(value)
|
||||
? null
|
||||
: StoreApiHelpers.ParseDateOnly(value, fieldName);
|
||||
}
|
||||
|
||||
private static PunchCardListItemResponse MapListItem(PunchCardListItemDto source)
|
||||
{
|
||||
return new PunchCardListItemResponse
|
||||
{
|
||||
Id = source.Id.ToString(),
|
||||
Name = source.Name,
|
||||
CoverImageUrl = source.CoverImageUrl,
|
||||
SalePrice = source.SalePrice,
|
||||
OriginalPrice = source.OriginalPrice,
|
||||
TotalTimes = source.TotalTimes,
|
||||
ValiditySummary = source.ValiditySummary,
|
||||
ScopeType = source.ScopeType,
|
||||
UsageMode = source.UsageMode,
|
||||
UsageCapAmount = source.UsageCapAmount,
|
||||
DailyLimit = source.DailyLimit,
|
||||
Status = source.Status,
|
||||
IsDimmed = source.IsDimmed,
|
||||
SoldCount = source.SoldCount,
|
||||
ActiveCount = source.ActiveCount,
|
||||
RevenueAmount = source.RevenueAmount,
|
||||
UpdatedAt = ToDateTime(source.UpdatedAt)
|
||||
};
|
||||
}
|
||||
|
||||
private static PunchCardStatsResponse MapTemplateStats(PunchCardStatsDto source)
|
||||
{
|
||||
return new PunchCardStatsResponse
|
||||
{
|
||||
OnSaleCount = source.OnSaleCount,
|
||||
TotalSoldCount = source.TotalSoldCount,
|
||||
TotalRevenueAmount = source.TotalRevenueAmount,
|
||||
ActiveInUseCount = source.ActiveInUseCount
|
||||
};
|
||||
}
|
||||
|
||||
private static PunchCardDetailResponse MapDetail(PunchCardDetailDto source)
|
||||
{
|
||||
return new PunchCardDetailResponse
|
||||
{
|
||||
Id = source.Id.ToString(),
|
||||
StoreId = source.StoreId.ToString(),
|
||||
Name = source.Name,
|
||||
CoverImageUrl = source.CoverImageUrl,
|
||||
SalePrice = source.SalePrice,
|
||||
OriginalPrice = source.OriginalPrice,
|
||||
TotalTimes = source.TotalTimes,
|
||||
ValidityType = source.ValidityType,
|
||||
ValidityDays = source.ValidityDays,
|
||||
ValidFrom = ToDateOnly(source.ValidFrom),
|
||||
ValidTo = ToDateOnly(source.ValidTo),
|
||||
Scope = new PunchCardScopeResponse
|
||||
{
|
||||
ScopeType = source.Scope.ScopeType,
|
||||
CategoryIds = source.Scope.CategoryIds.Select(item => item.ToString()).ToList(),
|
||||
TagIds = source.Scope.TagIds.Select(item => item.ToString()).ToList(),
|
||||
ProductIds = source.Scope.ProductIds.Select(item => item.ToString()).ToList()
|
||||
},
|
||||
UsageMode = source.UsageMode,
|
||||
UsageCapAmount = source.UsageCapAmount,
|
||||
DailyLimit = source.DailyLimit,
|
||||
PerOrderLimit = source.PerOrderLimit,
|
||||
PerUserPurchaseLimit = source.PerUserPurchaseLimit,
|
||||
AllowTransfer = source.AllowTransfer,
|
||||
ExpireStrategy = source.ExpireStrategy,
|
||||
Description = source.Description,
|
||||
NotifyChannels = source.NotifyChannels.ToList(),
|
||||
Status = source.Status,
|
||||
SoldCount = source.SoldCount,
|
||||
ActiveCount = source.ActiveCount,
|
||||
RevenueAmount = source.RevenueAmount,
|
||||
UpdatedAt = ToDateTime(source.UpdatedAt)
|
||||
};
|
||||
}
|
||||
|
||||
private static PunchCardUsageRecordResponse MapUsageRecord(PunchCardUsageRecordDto source)
|
||||
{
|
||||
return new PunchCardUsageRecordResponse
|
||||
{
|
||||
Id = source.Id.ToString(),
|
||||
RecordNo = source.RecordNo,
|
||||
PunchCardId = source.PunchCardTemplateId.ToString(),
|
||||
PunchCardName = source.PunchCardName,
|
||||
PunchCardInstanceId = source.PunchCardInstanceId.ToString(),
|
||||
MemberName = source.MemberName,
|
||||
MemberPhoneMasked = source.MemberPhoneMasked,
|
||||
ProductName = source.ProductName,
|
||||
UsedAt = ToDateTime(source.UsedAt),
|
||||
UsedTimes = source.UsedTimes,
|
||||
RemainingTimesAfterUse = source.RemainingTimesAfterUse,
|
||||
TotalTimes = source.TotalTimes,
|
||||
DisplayStatus = source.DisplayStatus,
|
||||
ExtraPayAmount = source.ExtraPayAmount
|
||||
};
|
||||
}
|
||||
|
||||
private static string? ToDateOnly(DateTime? value)
|
||||
{
|
||||
return value.HasValue
|
||||
? value.Value.ToString("yyyy-MM-dd", CultureInfo.InvariantCulture)
|
||||
: null;
|
||||
}
|
||||
|
||||
private static string ToDateTime(DateTime value)
|
||||
{
|
||||
return value.ToString("yyyy-MM-dd HH:mm:ss", CultureInfo.InvariantCulture);
|
||||
}
|
||||
}
|
||||
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,428 @@
|
||||
using System.Globalization;
|
||||
using Asp.Versioning;
|
||||
using Hangfire;
|
||||
using Microsoft.AspNetCore.Authorization;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using TakeoutSaaS.Application.App.Members.MessageReach.Dto;
|
||||
using TakeoutSaaS.Application.App.Members.MessageReach.Services;
|
||||
using TakeoutSaaS.Application.App.Stores.Services;
|
||||
using TakeoutSaaS.Infrastructure.App.Persistence;
|
||||
using TakeoutSaaS.Module.Authorization.Attributes;
|
||||
using TakeoutSaaS.Shared.Abstractions.Constants;
|
||||
using TakeoutSaaS.Shared.Abstractions.Results;
|
||||
using TakeoutSaaS.Shared.Web.Api;
|
||||
using TakeoutSaaS.TenantApi.Contracts.Member;
|
||||
using TakeoutSaaS.TenantApi.Services;
|
||||
|
||||
namespace TakeoutSaaS.TenantApi.Controllers;
|
||||
|
||||
/// <summary>
|
||||
/// 会员消息触达管理。
|
||||
/// </summary>
|
||||
[ApiVersion("1.0")]
|
||||
[Authorize]
|
||||
[Route("api/tenant/v{version:apiVersion}/member/message-reach")]
|
||||
public sealed class MemberMessageReachController(
|
||||
IMemberMessageReachAppService memberMessageReachAppService,
|
||||
TakeoutAppDbContext dbContext,
|
||||
StoreContextService storeContextService)
|
||||
: BaseApiController
|
||||
{
|
||||
private const string ViewPermission = "tenant:member:message-reach:view";
|
||||
private const string ManagePermission = "tenant:member:message-reach:manage";
|
||||
|
||||
/// <summary>
|
||||
/// 获取页面统计。
|
||||
/// </summary>
|
||||
[HttpGet("stats")]
|
||||
[PermissionAuthorize(ViewPermission, ManagePermission)]
|
||||
[ProducesResponseType(typeof(ApiResponse<MemberMessageReachStatsResponse>), StatusCodes.Status200OK)]
|
||||
public async Task<ApiResponse<MemberMessageReachStatsResponse>> Stats(
|
||||
[FromQuery] MemberMessageReachStatsRequest request,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var tenantId = await ResolveTenantIdAsync(request.StoreId, cancellationToken);
|
||||
var result = await memberMessageReachAppService.GetStatsAsync(tenantId, cancellationToken);
|
||||
return ApiResponse<MemberMessageReachStatsResponse>.Ok(new MemberMessageReachStatsResponse
|
||||
{
|
||||
MonthlySentCount = result.MonthlySentCount,
|
||||
ReachMemberCount = result.ReachMemberCount,
|
||||
OpenRate = result.OpenRate,
|
||||
ConversionRate = result.ConversionRate
|
||||
});
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 分页查询消息列表。
|
||||
/// </summary>
|
||||
[HttpGet("list")]
|
||||
[PermissionAuthorize(ViewPermission, ManagePermission)]
|
||||
[ProducesResponseType(typeof(ApiResponse<MemberMessageReachListResultResponse>), StatusCodes.Status200OK)]
|
||||
public async Task<ApiResponse<MemberMessageReachListResultResponse>> List(
|
||||
[FromQuery] MemberMessageReachListRequest request,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var tenantId = ResolveTenantId();
|
||||
var result = await memberMessageReachAppService.SearchMessagesAsync(
|
||||
tenantId,
|
||||
new SearchMemberMessageInput
|
||||
{
|
||||
Status = request.Status,
|
||||
Channel = request.Channel,
|
||||
Keyword = request.Keyword,
|
||||
Page = request.Page,
|
||||
PageSize = request.PageSize
|
||||
},
|
||||
cancellationToken);
|
||||
|
||||
return ApiResponse<MemberMessageReachListResultResponse>.Ok(new MemberMessageReachListResultResponse
|
||||
{
|
||||
Items = result.Items.Select(MapMessageListItem).ToList(),
|
||||
Page = result.Page,
|
||||
PageSize = result.PageSize,
|
||||
TotalCount = result.TotalCount
|
||||
});
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 获取消息详情。
|
||||
/// </summary>
|
||||
[HttpGet("detail")]
|
||||
[PermissionAuthorize(ViewPermission, ManagePermission)]
|
||||
[ProducesResponseType(typeof(ApiResponse<MemberMessageReachDetailResponse>), StatusCodes.Status200OK)]
|
||||
public async Task<ApiResponse<MemberMessageReachDetailResponse>> Detail(
|
||||
[FromQuery] MemberMessageReachDetailRequest request,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var tenantId = ResolveTenantId();
|
||||
var messageId = StoreApiHelpers.ParseRequiredSnowflake(request.MessageId, nameof(request.MessageId));
|
||||
var result = await memberMessageReachAppService.GetMessageDetailAsync(tenantId, messageId, cancellationToken);
|
||||
if (result is null)
|
||||
{
|
||||
return ApiResponse<MemberMessageReachDetailResponse>.Error(ErrorCodes.NotFound, "消息不存在");
|
||||
}
|
||||
|
||||
return ApiResponse<MemberMessageReachDetailResponse>.Ok(MapMessageDetail(result));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 保存消息(草稿/发送)。
|
||||
/// </summary>
|
||||
[HttpPost("save")]
|
||||
[PermissionAuthorize(ManagePermission)]
|
||||
[ProducesResponseType(typeof(ApiResponse<MemberMessageDispatchMetaResponse>), StatusCodes.Status200OK)]
|
||||
public async Task<ApiResponse<MemberMessageDispatchMetaResponse>> Save(
|
||||
[FromBody] SaveMemberMessageReachRequest request,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var tenantId = await ResolveTenantIdAsync(request.StoreId, cancellationToken);
|
||||
var messageId = StoreApiHelpers.ParseSnowflakeOrNull(request.MessageId);
|
||||
var previousMeta = messageId.HasValue
|
||||
? await memberMessageReachAppService.GetDispatchMetaAsync(tenantId, messageId.Value, cancellationToken)
|
||||
: null;
|
||||
|
||||
var saved = await memberMessageReachAppService.SaveMessageAsync(
|
||||
tenantId,
|
||||
new SaveMemberMessageInput
|
||||
{
|
||||
MessageId = messageId,
|
||||
StoreId = StoreApiHelpers.ParseSnowflakeOrNull(request.StoreId),
|
||||
TemplateId = StoreApiHelpers.ParseSnowflakeOrNull(request.TemplateId),
|
||||
Title = request.Title,
|
||||
Content = request.Content,
|
||||
Channels = request.Channels,
|
||||
AudienceType = request.AudienceType,
|
||||
AudienceTags = request.AudienceTags,
|
||||
ScheduleType = request.ScheduleType,
|
||||
ScheduledAt = request.ScheduledAt,
|
||||
SubmitAction = request.SubmitAction
|
||||
},
|
||||
cancellationToken);
|
||||
|
||||
// 1. 清理旧任务(若存在)。
|
||||
if (!string.IsNullOrWhiteSpace(previousMeta?.HangfireJobId))
|
||||
{
|
||||
BackgroundJob.Delete(previousMeta.HangfireJobId);
|
||||
}
|
||||
|
||||
// 2. 发送动作创建新任务并回写任务 ID。
|
||||
if (string.Equals(request.SubmitAction, "send", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
var newJobId = ScheduleDispatchJob(saved.MessageId, saved.ScheduleType, saved.ScheduledAt);
|
||||
await memberMessageReachAppService.BindDispatchJobAsync(tenantId, saved.MessageId, newJobId, cancellationToken);
|
||||
}
|
||||
|
||||
// 3. 返回最新调度状态。
|
||||
var latest = await memberMessageReachAppService.GetDispatchMetaAsync(tenantId, saved.MessageId, cancellationToken);
|
||||
return ApiResponse<MemberMessageDispatchMetaResponse>.Ok(MapDispatchMeta(latest ?? saved));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 删除消息。
|
||||
/// </summary>
|
||||
[HttpPost("delete")]
|
||||
[PermissionAuthorize(ManagePermission)]
|
||||
[ProducesResponseType(typeof(ApiResponse<object>), StatusCodes.Status200OK)]
|
||||
public async Task<ApiResponse<object>> Delete(
|
||||
[FromBody] DeleteMemberMessageReachRequest request,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var tenantId = ResolveTenantId();
|
||||
var messageId = StoreApiHelpers.ParseRequiredSnowflake(request.MessageId, nameof(request.MessageId));
|
||||
var oldJobId = await memberMessageReachAppService.DeleteMessageAsync(tenantId, messageId, cancellationToken);
|
||||
if (!string.IsNullOrWhiteSpace(oldJobId))
|
||||
{
|
||||
BackgroundJob.Delete(oldJobId);
|
||||
}
|
||||
|
||||
return ApiResponse<object>.Ok(null);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 估算目标人群。
|
||||
/// </summary>
|
||||
[HttpPost("audience/estimate")]
|
||||
[PermissionAuthorize(ViewPermission, ManagePermission)]
|
||||
[ProducesResponseType(typeof(ApiResponse<MemberMessageAudienceEstimateResponse>), StatusCodes.Status200OK)]
|
||||
public async Task<ApiResponse<MemberMessageAudienceEstimateResponse>> EstimateAudience(
|
||||
[FromBody] MemberMessageAudienceEstimateRequest request,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var tenantId = ResolveTenantId();
|
||||
var result = await memberMessageReachAppService.EstimateAudienceAsync(
|
||||
tenantId,
|
||||
new MemberMessageAudienceEstimateInput
|
||||
{
|
||||
AudienceType = request.AudienceType,
|
||||
Tags = request.Tags
|
||||
},
|
||||
cancellationToken);
|
||||
|
||||
return ApiResponse<MemberMessageAudienceEstimateResponse>.Ok(new MemberMessageAudienceEstimateResponse
|
||||
{
|
||||
ReachCount = result.ReachCount
|
||||
});
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 分页查询模板。
|
||||
/// </summary>
|
||||
[HttpGet("template/list")]
|
||||
[PermissionAuthorize(ViewPermission, ManagePermission)]
|
||||
[ProducesResponseType(typeof(ApiResponse<MemberMessageTemplateListResultResponse>), StatusCodes.Status200OK)]
|
||||
public async Task<ApiResponse<MemberMessageTemplateListResultResponse>> TemplateList(
|
||||
[FromQuery] MemberMessageTemplateListRequest request,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var tenantId = ResolveTenantId();
|
||||
var result = await memberMessageReachAppService.SearchTemplatesAsync(
|
||||
tenantId,
|
||||
new SearchMemberMessageTemplateInput
|
||||
{
|
||||
Category = request.Category,
|
||||
Keyword = request.Keyword,
|
||||
Page = request.Page,
|
||||
PageSize = request.PageSize
|
||||
},
|
||||
cancellationToken);
|
||||
|
||||
return ApiResponse<MemberMessageTemplateListResultResponse>.Ok(new MemberMessageTemplateListResultResponse
|
||||
{
|
||||
Items = result.Items.Select(MapTemplate).ToList(),
|
||||
Page = result.Page,
|
||||
PageSize = result.PageSize,
|
||||
TotalCount = result.TotalCount
|
||||
});
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 获取模板详情。
|
||||
/// </summary>
|
||||
[HttpGet("template/detail")]
|
||||
[PermissionAuthorize(ViewPermission, ManagePermission)]
|
||||
[ProducesResponseType(typeof(ApiResponse<MemberMessageTemplateResponse>), StatusCodes.Status200OK)]
|
||||
public async Task<ApiResponse<MemberMessageTemplateResponse>> TemplateDetail(
|
||||
[FromQuery] MemberMessageTemplateDetailRequest request,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var tenantId = ResolveTenantId();
|
||||
var templateId = StoreApiHelpers.ParseRequiredSnowflake(request.TemplateId, nameof(request.TemplateId));
|
||||
var result = await memberMessageReachAppService.GetTemplateAsync(tenantId, templateId, cancellationToken);
|
||||
if (result is null)
|
||||
{
|
||||
return ApiResponse<MemberMessageTemplateResponse>.Error(ErrorCodes.NotFound, "模板不存在");
|
||||
}
|
||||
|
||||
return ApiResponse<MemberMessageTemplateResponse>.Ok(MapTemplate(result));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 保存模板。
|
||||
/// </summary>
|
||||
[HttpPost("template/save")]
|
||||
[PermissionAuthorize(ManagePermission)]
|
||||
[ProducesResponseType(typeof(ApiResponse<MemberMessageTemplateResponse>), StatusCodes.Status200OK)]
|
||||
public async Task<ApiResponse<MemberMessageTemplateResponse>> SaveTemplate(
|
||||
[FromBody] SaveMemberMessageTemplateRequest request,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var tenantId = ResolveTenantId();
|
||||
var result = await memberMessageReachAppService.SaveTemplateAsync(
|
||||
tenantId,
|
||||
new SaveMemberMessageTemplateInput
|
||||
{
|
||||
TemplateId = StoreApiHelpers.ParseSnowflakeOrNull(request.TemplateId),
|
||||
Name = request.Name,
|
||||
Category = request.Category,
|
||||
Content = request.Content
|
||||
},
|
||||
cancellationToken);
|
||||
|
||||
return ApiResponse<MemberMessageTemplateResponse>.Ok(MapTemplate(result));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 删除模板。
|
||||
/// </summary>
|
||||
[HttpPost("template/delete")]
|
||||
[PermissionAuthorize(ManagePermission)]
|
||||
[ProducesResponseType(typeof(ApiResponse<object>), StatusCodes.Status200OK)]
|
||||
public async Task<ApiResponse<object>> DeleteTemplate(
|
||||
[FromBody] DeleteMemberMessageTemplateRequest request,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var tenantId = ResolveTenantId();
|
||||
var templateId = StoreApiHelpers.ParseRequiredSnowflake(request.TemplateId, nameof(request.TemplateId));
|
||||
await memberMessageReachAppService.DeleteTemplateAsync(tenantId, templateId, cancellationToken);
|
||||
return ApiResponse<object>.Ok(null);
|
||||
}
|
||||
|
||||
private long ResolveTenantId()
|
||||
{
|
||||
var (tenantId, _) = StoreApiHelpers.GetTenantMerchantContext(storeContextService);
|
||||
return tenantId;
|
||||
}
|
||||
|
||||
private async Task<long> ResolveTenantIdAsync(string? storeId, CancellationToken cancellationToken)
|
||||
{
|
||||
var (tenantId, merchantId) = StoreApiHelpers.GetTenantMerchantContext(storeContextService);
|
||||
if (string.IsNullOrWhiteSpace(storeId))
|
||||
{
|
||||
return tenantId;
|
||||
}
|
||||
|
||||
var parsedStoreId = StoreApiHelpers.ParseRequiredSnowflake(storeId, nameof(storeId));
|
||||
await StoreApiHelpers.EnsureStoreAccessibleAsync(
|
||||
dbContext,
|
||||
tenantId,
|
||||
merchantId,
|
||||
parsedStoreId,
|
||||
cancellationToken);
|
||||
return tenantId;
|
||||
}
|
||||
|
||||
private static string ScheduleDispatchJob(long messageId, string scheduleType, DateTime? scheduledAtUtc)
|
||||
{
|
||||
if (string.Equals(scheduleType, "scheduled", StringComparison.OrdinalIgnoreCase) && scheduledAtUtc.HasValue)
|
||||
{
|
||||
var delay = scheduledAtUtc.Value.ToUniversalTime() - DateTime.UtcNow;
|
||||
if (delay < TimeSpan.Zero)
|
||||
{
|
||||
delay = TimeSpan.Zero;
|
||||
}
|
||||
|
||||
return BackgroundJob.Schedule<MemberMessageReachDispatchJobRunner>(
|
||||
runner => runner.ExecuteAsync(messageId),
|
||||
delay);
|
||||
}
|
||||
|
||||
return BackgroundJob.Enqueue<MemberMessageReachDispatchJobRunner>(runner => runner.ExecuteAsync(messageId));
|
||||
}
|
||||
|
||||
private static MemberMessageReachListItemResponse MapMessageListItem(MemberMessageReachListItemDto source)
|
||||
{
|
||||
return new MemberMessageReachListItemResponse
|
||||
{
|
||||
MessageId = source.MessageId.ToString(),
|
||||
Title = source.Title,
|
||||
Channels = source.Channels.ToList(),
|
||||
AudienceText = source.AudienceText,
|
||||
EstimatedReachCount = source.EstimatedReachCount,
|
||||
Status = source.Status,
|
||||
SentAt = FormatDateTime(source.SentAt),
|
||||
ScheduledAt = FormatDateTime(source.ScheduledAt),
|
||||
OpenRate = source.OpenRate,
|
||||
ConversionRate = source.ConversionRate
|
||||
};
|
||||
}
|
||||
|
||||
private static MemberMessageReachDetailResponse MapMessageDetail(MemberMessageReachDetailDto source)
|
||||
{
|
||||
return new MemberMessageReachDetailResponse
|
||||
{
|
||||
MessageId = source.MessageId.ToString(),
|
||||
TemplateId = source.TemplateId?.ToString(),
|
||||
Title = source.Title,
|
||||
Content = source.Content,
|
||||
Channels = source.Channels.ToList(),
|
||||
AudienceType = source.AudienceType,
|
||||
AudienceTags = source.AudienceTags.ToList(),
|
||||
AudienceText = source.AudienceText,
|
||||
EstimatedReachCount = source.EstimatedReachCount,
|
||||
ScheduleType = source.ScheduleType,
|
||||
ScheduledAt = FormatDateTime(source.ScheduledAt),
|
||||
Status = source.Status,
|
||||
SentAt = FormatDateTime(source.SentAt),
|
||||
SentCount = source.SentCount,
|
||||
ReadCount = source.ReadCount,
|
||||
ConvertedCount = source.ConvertedCount,
|
||||
OpenRate = source.OpenRate,
|
||||
ConversionRate = source.ConversionRate,
|
||||
LastError = source.LastError,
|
||||
Recipients = source.Recipients.Select(item => new MemberMessageReachRecipientResponse
|
||||
{
|
||||
MemberId = item.MemberId.ToString(),
|
||||
Channel = item.Channel,
|
||||
Status = item.Status,
|
||||
Mobile = item.Mobile,
|
||||
OpenId = item.OpenId,
|
||||
SentAt = FormatDateTime(item.SentAt),
|
||||
ReadAt = FormatDateTime(item.ReadAt),
|
||||
ConvertedAt = FormatDateTime(item.ConvertedAt),
|
||||
ErrorMessage = item.ErrorMessage
|
||||
}).ToList()
|
||||
};
|
||||
}
|
||||
|
||||
private static MemberMessageDispatchMetaResponse MapDispatchMeta(MemberMessageDispatchMetaDto source)
|
||||
{
|
||||
return new MemberMessageDispatchMetaResponse
|
||||
{
|
||||
MessageId = source.MessageId.ToString(),
|
||||
Status = source.Status,
|
||||
ScheduleType = source.ScheduleType,
|
||||
ScheduledAt = FormatDateTime(source.ScheduledAt),
|
||||
HangfireJobId = source.HangfireJobId
|
||||
};
|
||||
}
|
||||
|
||||
private static MemberMessageTemplateResponse MapTemplate(MemberMessageTemplateDto source)
|
||||
{
|
||||
return new MemberMessageTemplateResponse
|
||||
{
|
||||
TemplateId = source.TemplateId.ToString(),
|
||||
Name = source.Name,
|
||||
Category = source.Category,
|
||||
Content = source.Content,
|
||||
UsageCount = source.UsageCount,
|
||||
LastUsedAt = FormatDateTime(source.LastUsedAt)
|
||||
};
|
||||
}
|
||||
|
||||
private static string? FormatDateTime(DateTime? value)
|
||||
{
|
||||
return value.HasValue
|
||||
? value.Value.ToLocalTime().ToString("yyyy-MM-dd HH:mm:ss", CultureInfo.InvariantCulture)
|
||||
: null;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,526 @@
|
||||
using System.Globalization;
|
||||
using MediatR;
|
||||
using Microsoft.AspNetCore.Authorization;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using TakeoutSaaS.Application.App.Members.PointsMall.Commands;
|
||||
using TakeoutSaaS.Application.App.Members.PointsMall.Dto;
|
||||
using TakeoutSaaS.Application.App.Members.PointsMall.Queries;
|
||||
using TakeoutSaaS.Application.App.Stores.Services;
|
||||
using TakeoutSaaS.Infrastructure.App.Persistence;
|
||||
using TakeoutSaaS.Module.Authorization.Attributes;
|
||||
using TakeoutSaaS.Shared.Abstractions.Results;
|
||||
using TakeoutSaaS.Shared.Web.Api;
|
||||
using TakeoutSaaS.TenantApi.Contracts.Member;
|
||||
|
||||
namespace TakeoutSaaS.TenantApi.Controllers;
|
||||
|
||||
/// <summary>
|
||||
/// 会员中心积分商城管理。
|
||||
/// </summary>
|
||||
[ApiVersion("1.0")]
|
||||
[Authorize]
|
||||
[Route("api/tenant/v{version:apiVersion}/member/points-mall")]
|
||||
public sealed class MemberPointsMallController(
|
||||
IMediator mediator,
|
||||
TakeoutAppDbContext dbContext,
|
||||
StoreContextService storeContextService)
|
||||
: BaseApiController
|
||||
{
|
||||
private const string ViewPermission = "tenant:member:points-mall:view";
|
||||
private const string ManagePermission = "tenant:member:points-mall:manage";
|
||||
|
||||
/// <summary>
|
||||
/// 获取积分规则详情。
|
||||
/// </summary>
|
||||
[HttpGet("rule/detail")]
|
||||
[PermissionAuthorize(ViewPermission, ManagePermission)]
|
||||
[ProducesResponseType(typeof(ApiResponse<PointMallRuleDetailResultResponse>), StatusCodes.Status200OK)]
|
||||
public async Task<ApiResponse<PointMallRuleDetailResultResponse>> RuleDetail(
|
||||
[FromQuery] PointMallRuleDetailRequest request,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var storeId = StoreApiHelpers.ParseRequiredSnowflake(request.StoreId, nameof(request.StoreId));
|
||||
await EnsureStoreAccessibleAsync(storeId, cancellationToken);
|
||||
|
||||
var result = await mediator.Send(new GetPointMallRuleDetailQuery
|
||||
{
|
||||
StoreId = storeId
|
||||
}, cancellationToken);
|
||||
|
||||
return ApiResponse<PointMallRuleDetailResultResponse>.Ok(new PointMallRuleDetailResultResponse
|
||||
{
|
||||
Rule = MapRule(result.Rule),
|
||||
Stats = new PointMallRuleStatsResponse
|
||||
{
|
||||
TotalIssuedPoints = result.Stats.TotalIssuedPoints,
|
||||
RedeemedPoints = result.Stats.RedeemedPoints,
|
||||
PointMembers = result.Stats.PointMembers,
|
||||
RedeemRate = result.Stats.RedeemRate
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 保存积分规则。
|
||||
/// </summary>
|
||||
[HttpPost("rule/save")]
|
||||
[PermissionAuthorize(ManagePermission)]
|
||||
[ProducesResponseType(typeof(ApiResponse<PointMallRuleResponse>), StatusCodes.Status200OK)]
|
||||
public async Task<ApiResponse<PointMallRuleResponse>> SaveRule(
|
||||
[FromBody] SavePointMallRuleRequest request,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var storeId = StoreApiHelpers.ParseRequiredSnowflake(request.StoreId, nameof(request.StoreId));
|
||||
await EnsureStoreAccessibleAsync(storeId, cancellationToken);
|
||||
|
||||
var result = await mediator.Send(new SavePointMallRuleCommand
|
||||
{
|
||||
StoreId = storeId,
|
||||
IsConsumeRewardEnabled = request.IsConsumeRewardEnabled,
|
||||
ConsumeAmountPerStep = request.ConsumeAmountPerStep,
|
||||
ConsumeRewardPointsPerStep = request.ConsumeRewardPointsPerStep,
|
||||
IsReviewRewardEnabled = request.IsReviewRewardEnabled,
|
||||
ReviewRewardPoints = request.ReviewRewardPoints,
|
||||
IsRegisterRewardEnabled = request.IsRegisterRewardEnabled,
|
||||
RegisterRewardPoints = request.RegisterRewardPoints,
|
||||
IsSigninRewardEnabled = request.IsSigninRewardEnabled,
|
||||
SigninRewardPoints = request.SigninRewardPoints,
|
||||
ExpiryMode = request.ExpiryMode
|
||||
}, cancellationToken);
|
||||
|
||||
return ApiResponse<PointMallRuleResponse>.Ok(MapRule(result));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 查询兑换商品列表。
|
||||
/// </summary>
|
||||
[HttpGet("product/list")]
|
||||
[PermissionAuthorize(ViewPermission, ManagePermission)]
|
||||
[ProducesResponseType(typeof(ApiResponse<PointMallProductListResultResponse>), StatusCodes.Status200OK)]
|
||||
public async Task<ApiResponse<PointMallProductListResultResponse>> ProductList(
|
||||
[FromQuery] PointMallProductListRequest request,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var storeId = StoreApiHelpers.ParseRequiredSnowflake(request.StoreId, nameof(request.StoreId));
|
||||
await EnsureStoreAccessibleAsync(storeId, cancellationToken);
|
||||
|
||||
var result = await mediator.Send(new GetPointMallProductListQuery
|
||||
{
|
||||
StoreId = storeId,
|
||||
Status = request.Status,
|
||||
Keyword = request.Keyword
|
||||
}, cancellationToken);
|
||||
|
||||
return ApiResponse<PointMallProductListResultResponse>.Ok(new PointMallProductListResultResponse
|
||||
{
|
||||
Items = result.Items.Select(MapProduct).ToList()
|
||||
});
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 查询兑换商品详情。
|
||||
/// </summary>
|
||||
[HttpGet("product/detail")]
|
||||
[PermissionAuthorize(ViewPermission, ManagePermission)]
|
||||
[ProducesResponseType(typeof(ApiResponse<PointMallProductResponse>), StatusCodes.Status200OK)]
|
||||
public async Task<ApiResponse<PointMallProductResponse>> ProductDetail(
|
||||
[FromQuery] PointMallProductDetailRequest request,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var storeId = StoreApiHelpers.ParseRequiredSnowflake(request.StoreId, nameof(request.StoreId));
|
||||
await EnsureStoreAccessibleAsync(storeId, cancellationToken);
|
||||
|
||||
var result = await mediator.Send(new GetPointMallProductDetailQuery
|
||||
{
|
||||
StoreId = storeId,
|
||||
PointMallProductId = StoreApiHelpers.ParseRequiredSnowflake(request.PointMallProductId, nameof(request.PointMallProductId))
|
||||
}, cancellationToken);
|
||||
|
||||
return ApiResponse<PointMallProductResponse>.Ok(MapProduct(result));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 保存兑换商品。
|
||||
/// </summary>
|
||||
[HttpPost("product/save")]
|
||||
[PermissionAuthorize(ManagePermission)]
|
||||
[ProducesResponseType(typeof(ApiResponse<PointMallProductResponse>), StatusCodes.Status200OK)]
|
||||
public async Task<ApiResponse<PointMallProductResponse>> SaveProduct(
|
||||
[FromBody] SavePointMallProductRequest request,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var storeId = StoreApiHelpers.ParseRequiredSnowflake(request.StoreId, nameof(request.StoreId));
|
||||
await EnsureStoreAccessibleAsync(storeId, cancellationToken);
|
||||
|
||||
var result = await mediator.Send(new SavePointMallProductCommand
|
||||
{
|
||||
StoreId = storeId,
|
||||
PointMallProductId = StoreApiHelpers.ParseSnowflakeOrNull(request.PointMallProductId),
|
||||
Name = request.Name,
|
||||
ImageUrl = request.ImageUrl,
|
||||
RedeemType = request.RedeemType,
|
||||
ProductId = StoreApiHelpers.ParseSnowflakeOrNull(request.ProductId),
|
||||
CouponTemplateId = StoreApiHelpers.ParseSnowflakeOrNull(request.CouponTemplateId),
|
||||
PhysicalName = request.PhysicalName,
|
||||
PickupMethod = request.PickupMethod,
|
||||
Description = request.Description,
|
||||
ExchangeType = request.ExchangeType,
|
||||
RequiredPoints = request.RequiredPoints,
|
||||
CashAmount = request.CashAmount,
|
||||
StockTotal = request.StockTotal,
|
||||
PerMemberLimit = request.PerMemberLimit,
|
||||
NotifyChannels = request.NotifyChannels,
|
||||
Status = request.Status
|
||||
}, cancellationToken);
|
||||
|
||||
return ApiResponse<PointMallProductResponse>.Ok(MapProduct(result));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 修改兑换商品状态。
|
||||
/// </summary>
|
||||
[HttpPost("product/status")]
|
||||
[PermissionAuthorize(ManagePermission)]
|
||||
[ProducesResponseType(typeof(ApiResponse<PointMallProductResponse>), StatusCodes.Status200OK)]
|
||||
public async Task<ApiResponse<PointMallProductResponse>> ChangeProductStatus(
|
||||
[FromBody] ChangePointMallProductStatusRequest request,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var storeId = StoreApiHelpers.ParseRequiredSnowflake(request.StoreId, nameof(request.StoreId));
|
||||
await EnsureStoreAccessibleAsync(storeId, cancellationToken);
|
||||
|
||||
var result = await mediator.Send(new ChangePointMallProductStatusCommand
|
||||
{
|
||||
StoreId = storeId,
|
||||
PointMallProductId = StoreApiHelpers.ParseRequiredSnowflake(request.PointMallProductId, nameof(request.PointMallProductId)),
|
||||
Status = request.Status
|
||||
}, cancellationToken);
|
||||
|
||||
return ApiResponse<PointMallProductResponse>.Ok(MapProduct(result));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 删除兑换商品。
|
||||
/// </summary>
|
||||
[HttpPost("product/delete")]
|
||||
[PermissionAuthorize(ManagePermission)]
|
||||
[ProducesResponseType(typeof(ApiResponse<object>), StatusCodes.Status200OK)]
|
||||
public async Task<ApiResponse<object>> DeleteProduct(
|
||||
[FromBody] DeletePointMallProductRequest request,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var storeId = StoreApiHelpers.ParseRequiredSnowflake(request.StoreId, nameof(request.StoreId));
|
||||
await EnsureStoreAccessibleAsync(storeId, cancellationToken);
|
||||
|
||||
await mediator.Send(new DeletePointMallProductCommand
|
||||
{
|
||||
StoreId = storeId,
|
||||
PointMallProductId = StoreApiHelpers.ParseRequiredSnowflake(request.PointMallProductId, nameof(request.PointMallProductId))
|
||||
}, cancellationToken);
|
||||
|
||||
return ApiResponse<object>.Ok(null);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 查询兑换记录分页。
|
||||
/// </summary>
|
||||
[HttpGet("record/list")]
|
||||
[PermissionAuthorize(ViewPermission, ManagePermission)]
|
||||
[ProducesResponseType(typeof(ApiResponse<PointMallRecordListResultResponse>), StatusCodes.Status200OK)]
|
||||
public async Task<ApiResponse<PointMallRecordListResultResponse>> RecordList(
|
||||
[FromQuery] PointMallRecordListRequest request,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var storeId = StoreApiHelpers.ParseRequiredSnowflake(request.StoreId, nameof(request.StoreId));
|
||||
await EnsureStoreAccessibleAsync(storeId, cancellationToken);
|
||||
|
||||
var result = await mediator.Send(new GetPointMallRecordListQuery
|
||||
{
|
||||
StoreId = storeId,
|
||||
RedeemType = request.RedeemType,
|
||||
Status = request.Status,
|
||||
StartDateUtc = ParseDateOrNull(request.StartDate, nameof(request.StartDate)),
|
||||
EndDateUtc = ParseDateOrNull(request.EndDate, nameof(request.EndDate)),
|
||||
Keyword = request.Keyword,
|
||||
Page = request.Page,
|
||||
PageSize = request.PageSize
|
||||
}, cancellationToken);
|
||||
|
||||
return ApiResponse<PointMallRecordListResultResponse>.Ok(new PointMallRecordListResultResponse
|
||||
{
|
||||
Items = result.Items.Select(MapRecord).ToList(),
|
||||
Page = result.Page,
|
||||
PageSize = result.PageSize,
|
||||
TotalCount = result.TotalCount,
|
||||
Stats = new PointMallRecordStatsResponse
|
||||
{
|
||||
TodayRedeemCount = result.Stats.TodayRedeemCount,
|
||||
PendingPhysicalCount = result.Stats.PendingPhysicalCount,
|
||||
CurrentMonthUsedPoints = result.Stats.CurrentMonthUsedPoints
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 查询兑换记录详情。
|
||||
/// </summary>
|
||||
[HttpGet("record/detail")]
|
||||
[PermissionAuthorize(ViewPermission, ManagePermission)]
|
||||
[ProducesResponseType(typeof(ApiResponse<PointMallRecordDetailResponse>), StatusCodes.Status200OK)]
|
||||
public async Task<ApiResponse<PointMallRecordDetailResponse>> RecordDetail(
|
||||
[FromQuery] PointMallRecordDetailRequest request,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var storeId = StoreApiHelpers.ParseRequiredSnowflake(request.StoreId, nameof(request.StoreId));
|
||||
await EnsureStoreAccessibleAsync(storeId, cancellationToken);
|
||||
|
||||
var result = await mediator.Send(new GetPointMallRecordDetailQuery
|
||||
{
|
||||
StoreId = storeId,
|
||||
RecordId = StoreApiHelpers.ParseRequiredSnowflake(request.RecordId, nameof(request.RecordId))
|
||||
}, cancellationToken);
|
||||
|
||||
return ApiResponse<PointMallRecordDetailResponse>.Ok(MapRecordDetail(result));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 导出兑换记录 CSV。
|
||||
/// </summary>
|
||||
[HttpGet("record/export")]
|
||||
[PermissionAuthorize(ManagePermission)]
|
||||
[ProducesResponseType(typeof(ApiResponse<PointMallRecordExportResponse>), StatusCodes.Status200OK)]
|
||||
public async Task<ApiResponse<PointMallRecordExportResponse>> ExportRecord(
|
||||
[FromQuery] ExportPointMallRecordRequest request,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var storeId = StoreApiHelpers.ParseRequiredSnowflake(request.StoreId, nameof(request.StoreId));
|
||||
await EnsureStoreAccessibleAsync(storeId, cancellationToken);
|
||||
|
||||
var result = await mediator.Send(new ExportPointMallRecordCsvQuery
|
||||
{
|
||||
StoreId = storeId,
|
||||
RedeemType = request.RedeemType,
|
||||
Status = request.Status,
|
||||
StartDateUtc = ParseDateOrNull(request.StartDate, nameof(request.StartDate)),
|
||||
EndDateUtc = ParseDateOrNull(request.EndDate, nameof(request.EndDate)),
|
||||
Keyword = request.Keyword
|
||||
}, cancellationToken);
|
||||
|
||||
return ApiResponse<PointMallRecordExportResponse>.Ok(new PointMallRecordExportResponse
|
||||
{
|
||||
FileName = result.FileName,
|
||||
FileContentBase64 = result.FileContentBase64,
|
||||
TotalCount = result.TotalCount
|
||||
});
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 写入兑换记录。
|
||||
/// </summary>
|
||||
[HttpPost("record/write")]
|
||||
[PermissionAuthorize(ManagePermission)]
|
||||
[ProducesResponseType(typeof(ApiResponse<PointMallRecordResponse>), StatusCodes.Status200OK)]
|
||||
public async Task<ApiResponse<PointMallRecordResponse>> WriteRecord(
|
||||
[FromBody] WritePointMallRecordRequest request,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var storeId = StoreApiHelpers.ParseRequiredSnowflake(request.StoreId, nameof(request.StoreId));
|
||||
await EnsureStoreAccessibleAsync(storeId, cancellationToken);
|
||||
|
||||
var result = await mediator.Send(new WritePointMallRecordCommand
|
||||
{
|
||||
StoreId = storeId,
|
||||
PointMallProductId = StoreApiHelpers.ParseRequiredSnowflake(request.PointMallProductId, nameof(request.PointMallProductId)),
|
||||
MemberId = StoreApiHelpers.ParseRequiredSnowflake(request.MemberId, nameof(request.MemberId)),
|
||||
RedeemedAt = request.RedeemedAt
|
||||
}, cancellationToken);
|
||||
|
||||
return ApiResponse<PointMallRecordResponse>.Ok(MapRecord(result));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 核销兑换记录。
|
||||
/// </summary>
|
||||
[HttpPost("record/verify")]
|
||||
[PermissionAuthorize(ManagePermission)]
|
||||
[ProducesResponseType(typeof(ApiResponse<PointMallRecordDetailResponse>), StatusCodes.Status200OK)]
|
||||
public async Task<ApiResponse<PointMallRecordDetailResponse>> VerifyRecord(
|
||||
[FromBody] VerifyPointMallRecordRequest request,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var storeId = StoreApiHelpers.ParseRequiredSnowflake(request.StoreId, nameof(request.StoreId));
|
||||
await EnsureStoreAccessibleAsync(storeId, cancellationToken);
|
||||
|
||||
var result = await mediator.Send(new VerifyPointMallRecordCommand
|
||||
{
|
||||
StoreId = storeId,
|
||||
RecordId = StoreApiHelpers.ParseRequiredSnowflake(request.RecordId, nameof(request.RecordId)),
|
||||
VerifyMethod = request.VerifyMethod,
|
||||
VerifyRemark = request.VerifyRemark
|
||||
}, cancellationToken);
|
||||
|
||||
return ApiResponse<PointMallRecordDetailResponse>.Ok(MapRecordDetail(result));
|
||||
}
|
||||
|
||||
private async Task EnsureStoreAccessibleAsync(long storeId, CancellationToken cancellationToken)
|
||||
{
|
||||
var (tenantId, merchantId) = StoreApiHelpers.GetTenantMerchantContext(storeContextService);
|
||||
await StoreApiHelpers.EnsureStoreAccessibleAsync(dbContext, tenantId, merchantId, storeId, cancellationToken);
|
||||
}
|
||||
|
||||
private static DateTime? ParseDateOrNull(string? value, string fieldName)
|
||||
{
|
||||
return string.IsNullOrWhiteSpace(value)
|
||||
? null
|
||||
: StoreApiHelpers.ParseDateOnly(value, fieldName);
|
||||
}
|
||||
|
||||
private static PointMallRuleResponse MapRule(MemberPointMallRuleDto source)
|
||||
{
|
||||
return new PointMallRuleResponse
|
||||
{
|
||||
StoreId = source.StoreId.ToString(),
|
||||
IsConsumeRewardEnabled = source.IsConsumeRewardEnabled,
|
||||
ConsumeAmountPerStep = source.ConsumeAmountPerStep,
|
||||
ConsumeRewardPointsPerStep = source.ConsumeRewardPointsPerStep,
|
||||
IsReviewRewardEnabled = source.IsReviewRewardEnabled,
|
||||
ReviewRewardPoints = source.ReviewRewardPoints,
|
||||
IsRegisterRewardEnabled = source.IsRegisterRewardEnabled,
|
||||
RegisterRewardPoints = source.RegisterRewardPoints,
|
||||
IsSigninRewardEnabled = source.IsSigninRewardEnabled,
|
||||
SigninRewardPoints = source.SigninRewardPoints,
|
||||
ExpiryMode = source.ExpiryMode
|
||||
};
|
||||
}
|
||||
|
||||
private static PointMallProductResponse MapProduct(MemberPointMallProductDto source)
|
||||
{
|
||||
return new PointMallProductResponse
|
||||
{
|
||||
PointMallProductId = source.PointMallProductId.ToString(),
|
||||
StoreId = source.StoreId.ToString(),
|
||||
Name = source.Name,
|
||||
ImageUrl = source.ImageUrl,
|
||||
RedeemType = source.RedeemType,
|
||||
RedeemTypeText = ResolveRedeemTypeText(source.RedeemType),
|
||||
ProductId = source.ProductId?.ToString(),
|
||||
CouponTemplateId = source.CouponTemplateId?.ToString(),
|
||||
PhysicalName = source.PhysicalName,
|
||||
PickupMethod = source.PickupMethod,
|
||||
Description = source.Description,
|
||||
ExchangeType = source.ExchangeType,
|
||||
RequiredPoints = source.RequiredPoints,
|
||||
CashAmount = source.CashAmount,
|
||||
StockTotal = source.StockTotal,
|
||||
StockAvailable = source.StockAvailable,
|
||||
RedeemedCount = source.RedeemedCount,
|
||||
PerMemberLimit = source.PerMemberLimit,
|
||||
NotifyChannels = source.NotifyChannels.ToList(),
|
||||
Status = source.Status,
|
||||
StatusText = ResolveProductStatusText(source.Status),
|
||||
UpdatedAt = source.UpdatedAt.ToString("yyyy-MM-dd HH:mm:ss", CultureInfo.InvariantCulture)
|
||||
};
|
||||
}
|
||||
|
||||
private static PointMallRecordResponse MapRecord(MemberPointMallRecordDto source)
|
||||
{
|
||||
return new PointMallRecordResponse
|
||||
{
|
||||
RecordId = source.RecordId.ToString(),
|
||||
RecordNo = source.RecordNo,
|
||||
PointMallProductId = source.PointMallProductId.ToString(),
|
||||
ProductName = source.ProductName,
|
||||
RedeemType = source.RedeemType,
|
||||
RedeemTypeText = ResolveRedeemTypeText(source.RedeemType),
|
||||
ExchangeType = source.ExchangeType,
|
||||
MemberId = source.MemberId.ToString(),
|
||||
MemberName = source.MemberName,
|
||||
MemberMobileMasked = source.MemberMobileMasked,
|
||||
UsedPoints = source.UsedPoints,
|
||||
CashAmount = source.CashAmount,
|
||||
Status = source.Status,
|
||||
StatusText = ResolveRecordStatusText(source.Status),
|
||||
RedeemedAt = source.RedeemedAt.ToString("yyyy-MM-dd HH:mm:ss", CultureInfo.InvariantCulture),
|
||||
IssuedAt = source.IssuedAt?.ToString("yyyy-MM-dd HH:mm:ss", CultureInfo.InvariantCulture),
|
||||
VerifiedAt = source.VerifiedAt?.ToString("yyyy-MM-dd HH:mm:ss", CultureInfo.InvariantCulture)
|
||||
};
|
||||
}
|
||||
|
||||
private static PointMallRecordDetailResponse MapRecordDetail(MemberPointMallRecordDetailDto source)
|
||||
{
|
||||
var response = new PointMallRecordDetailResponse
|
||||
{
|
||||
RecordId = source.RecordId.ToString(),
|
||||
RecordNo = source.RecordNo,
|
||||
PointMallProductId = source.PointMallProductId.ToString(),
|
||||
ProductName = source.ProductName,
|
||||
RedeemType = source.RedeemType,
|
||||
RedeemTypeText = ResolveRedeemTypeText(source.RedeemType),
|
||||
ExchangeType = source.ExchangeType,
|
||||
MemberId = source.MemberId.ToString(),
|
||||
MemberName = source.MemberName,
|
||||
MemberMobileMasked = source.MemberMobileMasked,
|
||||
UsedPoints = source.UsedPoints,
|
||||
CashAmount = source.CashAmount,
|
||||
Status = source.Status,
|
||||
StatusText = ResolveRecordStatusText(source.Status),
|
||||
RedeemedAt = source.RedeemedAt.ToString("yyyy-MM-dd HH:mm:ss", CultureInfo.InvariantCulture),
|
||||
IssuedAt = source.IssuedAt?.ToString("yyyy-MM-dd HH:mm:ss", CultureInfo.InvariantCulture),
|
||||
VerifiedAt = source.VerifiedAt?.ToString("yyyy-MM-dd HH:mm:ss", CultureInfo.InvariantCulture),
|
||||
VerifyMethod = source.VerifyMethod,
|
||||
VerifyMethodText = ResolveVerifyMethodText(source.VerifyMethod),
|
||||
VerifyRemark = source.VerifyRemark,
|
||||
VerifiedBy = source.VerifiedBy?.ToString()
|
||||
};
|
||||
|
||||
return response;
|
||||
}
|
||||
|
||||
private static string ResolveRedeemTypeText(string value)
|
||||
{
|
||||
return (value ?? string.Empty).Trim().ToLowerInvariant() switch
|
||||
{
|
||||
"product" => "商品",
|
||||
"coupon" => "优惠券",
|
||||
"physical" => "实物",
|
||||
_ => "未知"
|
||||
};
|
||||
}
|
||||
|
||||
private static string ResolveProductStatusText(string value)
|
||||
{
|
||||
return (value ?? string.Empty).Trim().ToLowerInvariant() switch
|
||||
{
|
||||
"enabled" => "上架",
|
||||
"disabled" => "下架",
|
||||
_ => "未知"
|
||||
};
|
||||
}
|
||||
|
||||
private static string ResolveRecordStatusText(string value)
|
||||
{
|
||||
return (value ?? string.Empty).Trim().ToLowerInvariant() switch
|
||||
{
|
||||
"pending_pickup" => "待领取",
|
||||
"issued" => "已发放",
|
||||
"completed" => "已完成",
|
||||
"canceled" => "已取消",
|
||||
_ => "未知"
|
||||
};
|
||||
}
|
||||
|
||||
private static string? ResolveVerifyMethodText(string? value)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(value))
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
return value.Trim().ToLowerInvariant() switch
|
||||
{
|
||||
"scan" => "扫码核销",
|
||||
"manual" => "手动核销",
|
||||
_ => "未知"
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,283 @@
|
||||
using System.Globalization;
|
||||
using MediatR;
|
||||
using Microsoft.AspNetCore.Authorization;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using TakeoutSaaS.Application.App.Members.StoredCard.Commands;
|
||||
using TakeoutSaaS.Application.App.Members.StoredCard.Dto;
|
||||
using TakeoutSaaS.Application.App.Members.StoredCard.Queries;
|
||||
using TakeoutSaaS.Application.App.Stores.Services;
|
||||
using TakeoutSaaS.Infrastructure.App.Persistence;
|
||||
using TakeoutSaaS.Module.Authorization.Attributes;
|
||||
using TakeoutSaaS.Shared.Abstractions.Results;
|
||||
using TakeoutSaaS.Shared.Web.Api;
|
||||
using TakeoutSaaS.TenantApi.Contracts.Member;
|
||||
|
||||
namespace TakeoutSaaS.TenantApi.Controllers;
|
||||
|
||||
/// <summary>
|
||||
/// 会员中心储值卡管理。
|
||||
/// </summary>
|
||||
[ApiVersion("1.0")]
|
||||
[Authorize]
|
||||
[Route("api/tenant/v{version:apiVersion}/member/stored-card")]
|
||||
public sealed class MemberStoredCardController(
|
||||
IMediator mediator,
|
||||
TakeoutAppDbContext dbContext,
|
||||
StoreContextService storeContextService)
|
||||
: BaseApiController
|
||||
{
|
||||
private const string ViewPermission = "tenant:member:stored-card:view";
|
||||
private const string ManagePermission = "tenant:member:stored-card:manage";
|
||||
|
||||
/// <summary>
|
||||
/// 获取储值卡方案列表。
|
||||
/// </summary>
|
||||
[HttpGet("plan/list")]
|
||||
[PermissionAuthorize(ViewPermission, ManagePermission)]
|
||||
[ProducesResponseType(typeof(ApiResponse<StoredCardPlanListResultResponse>), StatusCodes.Status200OK)]
|
||||
public async Task<ApiResponse<StoredCardPlanListResultResponse>> PlanList(
|
||||
[FromQuery] StoredCardPlanListRequest request,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var storeId = StoreApiHelpers.ParseRequiredSnowflake(request.StoreId, nameof(request.StoreId));
|
||||
await EnsureStoreAccessibleAsync(storeId, cancellationToken);
|
||||
|
||||
var result = await mediator.Send(new GetStoredCardPlanListQuery
|
||||
{
|
||||
StoreId = storeId
|
||||
}, cancellationToken);
|
||||
|
||||
return ApiResponse<StoredCardPlanListResultResponse>.Ok(new StoredCardPlanListResultResponse
|
||||
{
|
||||
Items = result.Items.Select(MapPlan).ToList(),
|
||||
Stats = new StoredCardPlanStatsResponse
|
||||
{
|
||||
TotalRechargeAmount = result.Stats.TotalRechargeAmount,
|
||||
TotalGiftAmount = result.Stats.TotalGiftAmount,
|
||||
CurrentMonthRechargeAmount = result.Stats.CurrentMonthRechargeAmount,
|
||||
RechargeMemberCount = result.Stats.RechargeMemberCount
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 保存储值卡方案。
|
||||
/// </summary>
|
||||
[HttpPost("plan/save")]
|
||||
[PermissionAuthorize(ManagePermission)]
|
||||
[ProducesResponseType(typeof(ApiResponse<StoredCardPlanResponse>), StatusCodes.Status200OK)]
|
||||
public async Task<ApiResponse<StoredCardPlanResponse>> SavePlan(
|
||||
[FromBody] SaveStoredCardPlanRequest request,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var storeId = StoreApiHelpers.ParseRequiredSnowflake(request.StoreId, nameof(request.StoreId));
|
||||
await EnsureStoreAccessibleAsync(storeId, cancellationToken);
|
||||
|
||||
var result = await mediator.Send(new SaveStoredCardPlanCommand
|
||||
{
|
||||
StoreId = storeId,
|
||||
PlanId = StoreApiHelpers.ParseSnowflakeOrNull(request.PlanId),
|
||||
RechargeAmount = request.RechargeAmount,
|
||||
GiftAmount = request.GiftAmount,
|
||||
SortOrder = request.SortOrder,
|
||||
Status = request.Status
|
||||
}, cancellationToken);
|
||||
|
||||
return ApiResponse<StoredCardPlanResponse>.Ok(MapPlan(result));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 修改储值卡方案状态。
|
||||
/// </summary>
|
||||
[HttpPost("plan/status")]
|
||||
[PermissionAuthorize(ManagePermission)]
|
||||
[ProducesResponseType(typeof(ApiResponse<StoredCardPlanResponse>), StatusCodes.Status200OK)]
|
||||
public async Task<ApiResponse<StoredCardPlanResponse>> ChangePlanStatus(
|
||||
[FromBody] ChangeStoredCardPlanStatusRequest request,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var storeId = StoreApiHelpers.ParseRequiredSnowflake(request.StoreId, nameof(request.StoreId));
|
||||
await EnsureStoreAccessibleAsync(storeId, cancellationToken);
|
||||
|
||||
var result = await mediator.Send(new ChangeStoredCardPlanStatusCommand
|
||||
{
|
||||
StoreId = storeId,
|
||||
PlanId = StoreApiHelpers.ParseRequiredSnowflake(request.PlanId, nameof(request.PlanId)),
|
||||
Status = request.Status
|
||||
}, cancellationToken);
|
||||
|
||||
return ApiResponse<StoredCardPlanResponse>.Ok(MapPlan(result));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 删除储值卡方案。
|
||||
/// </summary>
|
||||
[HttpPost("plan/delete")]
|
||||
[PermissionAuthorize(ManagePermission)]
|
||||
[ProducesResponseType(typeof(ApiResponse<object>), StatusCodes.Status200OK)]
|
||||
public async Task<ApiResponse<object>> DeletePlan(
|
||||
[FromBody] DeleteStoredCardPlanRequest request,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var storeId = StoreApiHelpers.ParseRequiredSnowflake(request.StoreId, nameof(request.StoreId));
|
||||
await EnsureStoreAccessibleAsync(storeId, cancellationToken);
|
||||
|
||||
await mediator.Send(new DeleteStoredCardPlanCommand
|
||||
{
|
||||
StoreId = storeId,
|
||||
PlanId = StoreApiHelpers.ParseRequiredSnowflake(request.PlanId, nameof(request.PlanId))
|
||||
}, cancellationToken);
|
||||
|
||||
return ApiResponse<object>.Ok(null);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 获取充值记录分页。
|
||||
/// </summary>
|
||||
[HttpGet("record/list")]
|
||||
[PermissionAuthorize(ViewPermission, ManagePermission)]
|
||||
[ProducesResponseType(typeof(ApiResponse<StoredCardRechargeRecordListResultResponse>), StatusCodes.Status200OK)]
|
||||
public async Task<ApiResponse<StoredCardRechargeRecordListResultResponse>> RecordList(
|
||||
[FromQuery] StoredCardRechargeRecordListRequest request,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var storeId = StoreApiHelpers.ParseRequiredSnowflake(request.StoreId, nameof(request.StoreId));
|
||||
await EnsureStoreAccessibleAsync(storeId, cancellationToken);
|
||||
|
||||
var result = await mediator.Send(new GetStoredCardRechargeRecordListQuery
|
||||
{
|
||||
StoreId = storeId,
|
||||
StartDateUtc = ParseDateOrNull(request.StartDate, nameof(request.StartDate)),
|
||||
EndDateUtc = ParseDateOrNull(request.EndDate, nameof(request.EndDate)),
|
||||
Keyword = request.Keyword,
|
||||
Page = request.Page,
|
||||
PageSize = request.PageSize
|
||||
}, cancellationToken);
|
||||
|
||||
return ApiResponse<StoredCardRechargeRecordListResultResponse>.Ok(new StoredCardRechargeRecordListResultResponse
|
||||
{
|
||||
Items = result.Items.Select(MapRecord).ToList(),
|
||||
Page = result.Page,
|
||||
PageSize = result.PageSize,
|
||||
TotalCount = result.TotalCount
|
||||
});
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 导出充值记录 CSV。
|
||||
/// </summary>
|
||||
[HttpGet("record/export")]
|
||||
[PermissionAuthorize(ManagePermission)]
|
||||
[ProducesResponseType(typeof(ApiResponse<StoredCardRechargeRecordExportResponse>), StatusCodes.Status200OK)]
|
||||
public async Task<ApiResponse<StoredCardRechargeRecordExportResponse>> ExportRecord(
|
||||
[FromQuery] ExportStoredCardRechargeRecordRequest request,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var storeId = StoreApiHelpers.ParseRequiredSnowflake(request.StoreId, nameof(request.StoreId));
|
||||
await EnsureStoreAccessibleAsync(storeId, cancellationToken);
|
||||
|
||||
var result = await mediator.Send(new ExportStoredCardRechargeRecordCsvQuery
|
||||
{
|
||||
StoreId = storeId,
|
||||
StartDateUtc = ParseDateOrNull(request.StartDate, nameof(request.StartDate)),
|
||||
EndDateUtc = ParseDateOrNull(request.EndDate, nameof(request.EndDate)),
|
||||
Keyword = request.Keyword
|
||||
}, cancellationToken);
|
||||
|
||||
return ApiResponse<StoredCardRechargeRecordExportResponse>.Ok(new StoredCardRechargeRecordExportResponse
|
||||
{
|
||||
FileName = result.FileName,
|
||||
FileContentBase64 = result.FileContentBase64,
|
||||
TotalCount = result.TotalCount
|
||||
});
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 写入充值记录。
|
||||
/// </summary>
|
||||
[HttpPost("record/write")]
|
||||
[PermissionAuthorize(ManagePermission)]
|
||||
[ProducesResponseType(typeof(ApiResponse<StoredCardRechargeRecordResponse>), StatusCodes.Status200OK)]
|
||||
public async Task<ApiResponse<StoredCardRechargeRecordResponse>> WriteRecord(
|
||||
[FromBody] WriteStoredCardRechargeRecordRequest request,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var storeId = StoreApiHelpers.ParseRequiredSnowflake(request.StoreId, nameof(request.StoreId));
|
||||
await EnsureStoreAccessibleAsync(storeId, cancellationToken);
|
||||
|
||||
var result = await mediator.Send(new WriteStoredCardRechargeRecordCommand
|
||||
{
|
||||
StoreId = storeId,
|
||||
MemberId = StoreApiHelpers.ParseRequiredSnowflake(request.MemberId, nameof(request.MemberId)),
|
||||
PlanId = StoreApiHelpers.ParseSnowflakeOrNull(request.PlanId),
|
||||
RechargeAmount = request.RechargeAmount,
|
||||
GiftAmount = request.GiftAmount,
|
||||
PaymentMethod = request.PaymentMethod,
|
||||
RechargedAt = request.RechargedAt,
|
||||
Remark = request.Remark
|
||||
}, cancellationToken);
|
||||
|
||||
return ApiResponse<StoredCardRechargeRecordResponse>.Ok(MapRecord(result));
|
||||
}
|
||||
|
||||
private async Task EnsureStoreAccessibleAsync(long storeId, CancellationToken cancellationToken)
|
||||
{
|
||||
var (tenantId, merchantId) = StoreApiHelpers.GetTenantMerchantContext(storeContextService);
|
||||
await StoreApiHelpers.EnsureStoreAccessibleAsync(dbContext, tenantId, merchantId, storeId, cancellationToken);
|
||||
}
|
||||
|
||||
private static DateTime? ParseDateOrNull(string? value, string fieldName)
|
||||
{
|
||||
return string.IsNullOrWhiteSpace(value)
|
||||
? null
|
||||
: StoreApiHelpers.ParseDateOnly(value, fieldName);
|
||||
}
|
||||
|
||||
private static StoredCardPlanResponse MapPlan(MemberStoredCardPlanDto source)
|
||||
{
|
||||
return new StoredCardPlanResponse
|
||||
{
|
||||
PlanId = source.PlanId.ToString(),
|
||||
RechargeAmount = source.RechargeAmount,
|
||||
GiftAmount = source.GiftAmount,
|
||||
ArrivedAmount = source.ArrivedAmount,
|
||||
SortOrder = source.SortOrder,
|
||||
Status = source.Status,
|
||||
RechargeCount = source.RechargeCount,
|
||||
TotalRechargeAmount = source.TotalRechargeAmount
|
||||
};
|
||||
}
|
||||
|
||||
private static StoredCardRechargeRecordResponse MapRecord(MemberStoredCardRechargeRecordDto source)
|
||||
{
|
||||
return new StoredCardRechargeRecordResponse
|
||||
{
|
||||
RecordId = source.RecordId.ToString(),
|
||||
RecordNo = source.RecordNo,
|
||||
MemberId = source.MemberId.ToString(),
|
||||
MemberName = source.MemberName,
|
||||
MemberMobileMasked = source.MemberMobileMasked,
|
||||
RechargeAmount = source.RechargeAmount,
|
||||
GiftAmount = source.GiftAmount,
|
||||
ArrivedAmount = source.ArrivedAmount,
|
||||
PaymentMethod = source.PaymentMethod,
|
||||
PaymentMethodText = ResolvePaymentMethodText(source.PaymentMethod),
|
||||
RechargedAt = source.RechargedAt.ToString("yyyy-MM-dd HH:mm:ss", CultureInfo.InvariantCulture),
|
||||
PlanId = source.PlanId?.ToString(),
|
||||
Remark = source.Remark
|
||||
};
|
||||
}
|
||||
|
||||
private static string ResolvePaymentMethodText(string paymentMethod)
|
||||
{
|
||||
return (paymentMethod ?? string.Empty).Trim().ToLowerInvariant() switch
|
||||
{
|
||||
"wechat" => "微信支付",
|
||||
"alipay" => "支付宝",
|
||||
"cash" => "现金",
|
||||
"card" => "刷卡",
|
||||
"balance" => "余额",
|
||||
_ => "未知"
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -16,6 +16,12 @@ using TakeoutSaaS.TenantApi.Contracts.Product;
|
||||
|
||||
namespace TakeoutSaaS.TenantApi.Controllers;
|
||||
|
||||
/// <summary>
|
||||
/// 提供商品批量工具能力,包括批量调价、上下架、移类、跨店同步、导入与导出。
|
||||
/// </summary>
|
||||
/// <param name="dbContext">应用数据库上下文。</param>
|
||||
/// <param name="storeContextService">门店上下文服务。</param>
|
||||
/// <param name="idGenerator">雪花 ID 生成器。</param>
|
||||
[ApiVersion("1.0")]
|
||||
[Authorize]
|
||||
[Route("api/tenant/v{version:apiVersion}/product/batch")]
|
||||
@@ -39,6 +45,12 @@ public sealed class ProductBatchToolController(
|
||||
"状态"
|
||||
];
|
||||
|
||||
/// <summary>
|
||||
/// 预览批量调价结果。
|
||||
/// </summary>
|
||||
/// <param name="request">调价预览请求。</param>
|
||||
/// <param name="cancellationToken">取消令牌。</param>
|
||||
/// <returns>调价预览结果。</returns>
|
||||
[HttpPost("price-adjust/preview")]
|
||||
[ProducesResponseType(typeof(ApiResponse<BatchPricePreviewResponse>), StatusCodes.Status200OK)]
|
||||
public async Task<ApiResponse<BatchPricePreviewResponse>> PreviewPriceAdjust(
|
||||
@@ -81,6 +93,12 @@ public sealed class ProductBatchToolController(
|
||||
});
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 执行批量调价。
|
||||
/// </summary>
|
||||
/// <param name="request">调价请求。</param>
|
||||
/// <param name="cancellationToken">取消令牌。</param>
|
||||
/// <returns>批量工具执行结果。</returns>
|
||||
[HttpPost("price-adjust")]
|
||||
[ProducesResponseType(typeof(ApiResponse<BatchToolResultResponse>), StatusCodes.Status200OK)]
|
||||
public async Task<ApiResponse<BatchToolResultResponse>> PriceAdjust(
|
||||
@@ -123,6 +141,12 @@ public sealed class ProductBatchToolController(
|
||||
});
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 批量切换商品上下架状态。
|
||||
/// </summary>
|
||||
/// <param name="request">上下架切换请求。</param>
|
||||
/// <param name="cancellationToken">取消令牌。</param>
|
||||
/// <returns>批量工具执行结果。</returns>
|
||||
[HttpPost("sale-switch")]
|
||||
[ProducesResponseType(typeof(ApiResponse<BatchToolResultResponse>), StatusCodes.Status200OK)]
|
||||
public async Task<ApiResponse<BatchToolResultResponse>> SaleSwitch(
|
||||
@@ -186,6 +210,12 @@ public sealed class ProductBatchToolController(
|
||||
});
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 批量移动商品分类。
|
||||
/// </summary>
|
||||
/// <param name="request">移类请求。</param>
|
||||
/// <param name="cancellationToken">取消令牌。</param>
|
||||
/// <returns>批量工具执行结果。</returns>
|
||||
[HttpPost("move-category")]
|
||||
[ProducesResponseType(typeof(ApiResponse<BatchToolResultResponse>), StatusCodes.Status200OK)]
|
||||
public async Task<ApiResponse<BatchToolResultResponse>> MoveCategory(
|
||||
@@ -256,6 +286,12 @@ public sealed class ProductBatchToolController(
|
||||
});
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 将源门店商品批量同步到目标门店。
|
||||
/// </summary>
|
||||
/// <param name="request">跨店同步请求。</param>
|
||||
/// <param name="cancellationToken">取消令牌。</param>
|
||||
/// <returns>批量工具执行结果。</returns>
|
||||
[HttpPost("store-sync")]
|
||||
[ProducesResponseType(typeof(ApiResponse<BatchToolResultResponse>), StatusCodes.Status200OK)]
|
||||
public async Task<ApiResponse<BatchToolResultResponse>> SyncStore(
|
||||
@@ -479,6 +515,12 @@ public sealed class ProductBatchToolController(
|
||||
});
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 下载商品批量导入模板。
|
||||
/// </summary>
|
||||
/// <param name="storeId">门店 ID。</param>
|
||||
/// <param name="cancellationToken">取消令牌。</param>
|
||||
/// <returns>导入模板文件。</returns>
|
||||
[HttpGet("import/template")]
|
||||
[ProducesResponseType(typeof(ApiResponse<BatchExcelFileResponse>), StatusCodes.Status200OK)]
|
||||
public async Task<ApiResponse<BatchExcelFileResponse>> DownloadImportTemplate(
|
||||
@@ -519,6 +561,12 @@ public sealed class ProductBatchToolController(
|
||||
});
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 批量导入商品。
|
||||
/// </summary>
|
||||
/// <param name="request">导入请求。</param>
|
||||
/// <param name="cancellationToken">取消令牌。</param>
|
||||
/// <returns>导入结果。</returns>
|
||||
[HttpPost("import")]
|
||||
[Consumes("multipart/form-data")]
|
||||
[ProducesResponseType(typeof(ApiResponse<BatchImportResultResponse>), StatusCodes.Status200OK)]
|
||||
@@ -858,6 +906,12 @@ public sealed class ProductBatchToolController(
|
||||
});
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 按范围导出商品 Excel。
|
||||
/// </summary>
|
||||
/// <param name="request">导出请求。</param>
|
||||
/// <param name="cancellationToken">取消令牌。</param>
|
||||
/// <returns>导出文件。</returns>
|
||||
[HttpPost("export")]
|
||||
[ProducesResponseType(typeof(ApiResponse<BatchExcelFileResponse>), StatusCodes.Status200OK)]
|
||||
public async Task<ApiResponse<BatchExcelFileResponse>> Export(
|
||||
|
||||
@@ -64,6 +64,7 @@ public sealed class StoreFeesController(
|
||||
StoreId = parsedStoreId,
|
||||
MinimumOrderAmount = request.MinimumOrderAmount,
|
||||
DeliveryFee = request.BaseDeliveryFee,
|
||||
PlatformServiceRate = request.PlatformServiceRate,
|
||||
FreeDeliveryThreshold = request.FreeDeliveryThreshold,
|
||||
PackagingFeeMode = ParsePackagingFeeMode(request.PackagingFeeMode),
|
||||
OrderPackagingFeeMode = ParseOrderPackagingFeeMode(request.OrderPackagingFeeMode),
|
||||
@@ -175,6 +176,7 @@ public sealed class StoreFeesController(
|
||||
|
||||
targetFee.MinimumOrderAmount = sourceFee.MinimumOrderAmount;
|
||||
targetFee.BaseDeliveryFee = sourceFee.BaseDeliveryFee;
|
||||
targetFee.PlatformServiceRate = sourceFee.PlatformServiceRate;
|
||||
targetFee.FreeDeliveryThreshold = sourceFee.FreeDeliveryThreshold;
|
||||
targetFee.PackagingFeeMode = sourceFee.PackagingFeeMode;
|
||||
targetFee.OrderPackagingFeeMode = sourceFee.OrderPackagingFeeMode;
|
||||
@@ -214,6 +216,7 @@ public sealed class StoreFeesController(
|
||||
IsConfigured = source is not null,
|
||||
MinimumOrderAmount = source?.MinimumOrderAmount ?? 0m,
|
||||
BaseDeliveryFee = source?.DeliveryFee ?? 0m,
|
||||
PlatformServiceRate = source?.PlatformServiceRate ?? 0m,
|
||||
FreeDeliveryThreshold = source?.FreeDeliveryThreshold,
|
||||
PackagingFeeMode = ToPackagingFeeModeText(source?.PackagingFeeMode ?? PackagingFeeMode.Fixed),
|
||||
OrderPackagingFeeMode = ToOrderPackagingFeeModeText(source?.OrderPackagingFeeMode ?? OrderPackagingFeeMode.Fixed),
|
||||
|
||||
@@ -10,9 +10,12 @@ using Serilog;
|
||||
using StackExchange.Redis;
|
||||
using TakeoutSaaS.Application.App.Common.Geo;
|
||||
using TakeoutSaaS.Application.App.Extensions;
|
||||
using TakeoutSaaS.Application.App.Members.MessageReach.Options;
|
||||
using TakeoutSaaS.Application.App.Members.MessageReach.Services;
|
||||
using TakeoutSaaS.Application.Dictionary.Extensions;
|
||||
using TakeoutSaaS.Application.Identity.Extensions;
|
||||
using TakeoutSaaS.Application.Messaging.Extensions;
|
||||
using TakeoutSaaS.Application.Sms.Extensions;
|
||||
using TakeoutSaaS.Application.Storage.Extensions;
|
||||
using TakeoutSaaS.Infrastructure.App.Extensions;
|
||||
using TakeoutSaaS.Infrastructure.Dictionary.Extensions;
|
||||
@@ -22,6 +25,7 @@ using TakeoutSaaS.Module.Authorization.Extensions;
|
||||
using TakeoutSaaS.Module.Messaging.Extensions;
|
||||
using TakeoutSaaS.Module.Messaging.Options;
|
||||
using TakeoutSaaS.Module.Scheduler.Extensions;
|
||||
using TakeoutSaaS.Module.Sms.Extensions;
|
||||
using TakeoutSaaS.Module.Storage.Extensions;
|
||||
using TakeoutSaaS.Module.Tenancy.Extensions;
|
||||
using TakeoutSaaS.Shared.Abstractions.Security;
|
||||
@@ -118,6 +122,7 @@ if (!string.IsNullOrWhiteSpace(redisConn))
|
||||
|
||||
// 6. 注册应用层与基础设施(仅租户侧所需)
|
||||
builder.Services.AddAppApplication();
|
||||
builder.Services.AddSmsApplication(builder.Configuration);
|
||||
builder.Services.AddIdentityApplication(enableMiniSupport: false);
|
||||
builder.Services.AddAppInfrastructure(builder.Configuration);
|
||||
builder.Services.AddIdentityInfrastructure(builder.Configuration, enableMiniFeatures: false, enableAdminSeed: false);
|
||||
@@ -132,6 +137,7 @@ builder.Services.AddDictionaryInfrastructure(builder.Configuration);
|
||||
// 9. 注册消息发布能力(未配置 RabbitMQ 时自动降级为 NoOp 实现)
|
||||
builder.Services.AddMessagingApplication();
|
||||
builder.Services.AddMessagingModule(builder.Configuration);
|
||||
builder.Services.AddSmsModule(builder.Configuration);
|
||||
builder.Services.AddMassTransit(configurator =>
|
||||
{
|
||||
// 注册 SignalR 推送消费者
|
||||
@@ -167,6 +173,16 @@ builder.Services.AddMassTransit(configurator =>
|
||||
builder.Services.AddStorageModule(builder.Configuration);
|
||||
builder.Services.AddStorageApplication();
|
||||
builder.Services.AddSchedulerModule(builder.Configuration);
|
||||
builder.Services.AddOptions<MemberMessagingOptions>()
|
||||
.Bind(builder.Configuration.GetSection("MemberMessaging"))
|
||||
.ValidateDataAnnotations()
|
||||
.ValidateOnStart();
|
||||
builder.Services.AddHttpClient<IMemberMessageWeChatSender, MemberMessageWeChatSender>(client =>
|
||||
{
|
||||
client.BaseAddress = new Uri("https://api.weixin.qq.com/");
|
||||
client.Timeout = TimeSpan.FromSeconds(10);
|
||||
});
|
||||
builder.Services.AddScoped<MemberMessageReachDispatchJobRunner>();
|
||||
|
||||
// 9.1 注册腾讯地图地理编码服务(服务端签名)
|
||||
builder.Services.Configure<TencentMapOptions>(builder.Configuration.GetSection(TencentMapOptions.SectionName));
|
||||
|
||||
@@ -0,0 +1,50 @@
|
||||
using Hangfire;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using TakeoutSaaS.Application.App.Members.MessageReach.Services;
|
||||
using TakeoutSaaS.Infrastructure.App.Persistence;
|
||||
using TakeoutSaaS.Shared.Abstractions.Tenancy;
|
||||
|
||||
namespace TakeoutSaaS.TenantApi.Services;
|
||||
|
||||
/// <summary>
|
||||
/// 会员消息触达发送任务执行器。
|
||||
/// </summary>
|
||||
public sealed class MemberMessageReachDispatchJobRunner(
|
||||
TakeoutAppDbContext dbContext,
|
||||
ITenantContextAccessor tenantContextAccessor,
|
||||
IMemberMessageReachAppService memberMessageReachAppService,
|
||||
ILogger<MemberMessageReachDispatchJobRunner> logger)
|
||||
{
|
||||
/// <summary>
|
||||
/// 执行消息发送任务。
|
||||
/// </summary>
|
||||
[AutomaticRetry(Attempts = 0)]
|
||||
public async Task ExecuteAsync(long messageId)
|
||||
{
|
||||
// 1. 查询任务所属租户,避免跨租户执行。
|
||||
var jobMeta = await dbContext.MemberReachMessages
|
||||
.IgnoreQueryFilters()
|
||||
.AsNoTracking()
|
||||
.Where(item => item.Id == messageId)
|
||||
.Select(item => new JobMeta(item.Id, item.TenantId))
|
||||
.SingleOrDefaultAsync();
|
||||
if (jobMeta is null || jobMeta.TenantId <= 0)
|
||||
{
|
||||
logger.LogWarning("会员消息任务不存在或租户无效,MessageId={MessageId}", messageId);
|
||||
return;
|
||||
}
|
||||
|
||||
// 2. 切换租户作用域并执行发送逻辑。
|
||||
using var _ = tenantContextAccessor.EnterTenantScope(jobMeta.TenantId, "scheduler", $"tenant-{jobMeta.TenantId}");
|
||||
try
|
||||
{
|
||||
await memberMessageReachAppService.ExecuteDispatchAsync(jobMeta.TenantId, jobMeta.Id, CancellationToken.None);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
logger.LogError(ex, "会员消息任务执行失败,TenantId={TenantId} MessageId={MessageId}", jobMeta.TenantId, jobMeta.Id);
|
||||
}
|
||||
}
|
||||
|
||||
private sealed record JobMeta(long Id, long TenantId);
|
||||
}
|
||||
@@ -125,6 +125,49 @@
|
||||
"AntiLeechTokenSecret": "ReplaceWithARandomToken"
|
||||
}
|
||||
},
|
||||
"Sms": {
|
||||
"Provider": "Tencent",
|
||||
"DefaultSignName": "外卖SaaS",
|
||||
"UseMock": true,
|
||||
"Tencent": {
|
||||
"SecretId": "TENCENT_SMS_SECRET_ID",
|
||||
"SecretKey": "TENCENT_SMS_SECRET_KEY",
|
||||
"SdkAppId": "1400000000",
|
||||
"SignName": "外卖SaaS",
|
||||
"Region": "ap-beijing",
|
||||
"Endpoint": "https://sms.tencentcloudapi.com"
|
||||
},
|
||||
"Aliyun": {
|
||||
"AccessKeyId": "ALIYUN_SMS_AK",
|
||||
"AccessKeySecret": "ALIYUN_SMS_SK",
|
||||
"Endpoint": "dysmsapi.aliyuncs.com",
|
||||
"SignName": "外卖SaaS",
|
||||
"Region": "cn-hangzhou"
|
||||
},
|
||||
"SceneTemplates": {
|
||||
"login": "LOGIN_TEMPLATE_ID",
|
||||
"register": "REGISTER_TEMPLATE_ID",
|
||||
"reset": "RESET_TEMPLATE_ID",
|
||||
"member_message": "MEMBER_MESSAGE_TEMPLATE_ID"
|
||||
},
|
||||
"VerificationCode": {
|
||||
"CodeLength": 6,
|
||||
"ExpireMinutes": 5,
|
||||
"CooldownSeconds": 60,
|
||||
"CachePrefix": "sms:code"
|
||||
}
|
||||
},
|
||||
"MemberMessaging": {
|
||||
"SmsScene": "member_message",
|
||||
"WeChatMini": {
|
||||
"AppId": "WECHAT_MINI_APP_ID",
|
||||
"AppSecret": "WECHAT_MINI_APP_SECRET",
|
||||
"SubscribeTemplateId": "WECHAT_SUBSCRIBE_TEMPLATE_ID",
|
||||
"PagePath": "pages/member/message-center/index",
|
||||
"TitleDataKey": "thing1",
|
||||
"ContentDataKey": "thing2"
|
||||
}
|
||||
},
|
||||
"Scheduler": {
|
||||
"ConnectionString": "Host=120.53.222.17;Port=5432;Database=takeout_app_db;Username=app_user;Password=AppUser112233;Pooling=true;Minimum Pool Size=5;Maximum Pool Size=50",
|
||||
"WorkerCount": 10,
|
||||
|
||||
@@ -123,6 +123,49 @@
|
||||
"AntiLeechTokenSecret": "ReplaceWithARandomToken"
|
||||
}
|
||||
},
|
||||
"Sms": {
|
||||
"Provider": "Tencent",
|
||||
"DefaultSignName": "外卖SaaS",
|
||||
"UseMock": false,
|
||||
"Tencent": {
|
||||
"SecretId": "TENCENT_SMS_SECRET_ID",
|
||||
"SecretKey": "TENCENT_SMS_SECRET_KEY",
|
||||
"SdkAppId": "1400000000",
|
||||
"SignName": "外卖SaaS",
|
||||
"Region": "ap-beijing",
|
||||
"Endpoint": "https://sms.tencentcloudapi.com"
|
||||
},
|
||||
"Aliyun": {
|
||||
"AccessKeyId": "ALIYUN_SMS_AK",
|
||||
"AccessKeySecret": "ALIYUN_SMS_SK",
|
||||
"Endpoint": "dysmsapi.aliyuncs.com",
|
||||
"SignName": "外卖SaaS",
|
||||
"Region": "cn-hangzhou"
|
||||
},
|
||||
"SceneTemplates": {
|
||||
"login": "LOGIN_TEMPLATE_ID",
|
||||
"register": "REGISTER_TEMPLATE_ID",
|
||||
"reset": "RESET_TEMPLATE_ID",
|
||||
"member_message": "MEMBER_MESSAGE_TEMPLATE_ID"
|
||||
},
|
||||
"VerificationCode": {
|
||||
"CodeLength": 6,
|
||||
"ExpireMinutes": 5,
|
||||
"CooldownSeconds": 60,
|
||||
"CachePrefix": "sms:code"
|
||||
}
|
||||
},
|
||||
"MemberMessaging": {
|
||||
"SmsScene": "member_message",
|
||||
"WeChatMini": {
|
||||
"AppId": "WECHAT_MINI_APP_ID",
|
||||
"AppSecret": "WECHAT_MINI_APP_SECRET",
|
||||
"SubscribeTemplateId": "WECHAT_SUBSCRIBE_TEMPLATE_ID",
|
||||
"PagePath": "pages/member/message-center/index",
|
||||
"TitleDataKey": "thing1",
|
||||
"ContentDataKey": "thing2"
|
||||
}
|
||||
},
|
||||
"Scheduler": {
|
||||
"ConnectionString": "Host=120.53.222.17;Port=5432;Database=takeout_app_db;Username=app_user;Password=AppUser112233;Pooling=true;Minimum Pool Size=5;Maximum Pool Size=50",
|
||||
"WorkerCount": 10,
|
||||
|
||||
@@ -0,0 +1,397 @@
|
||||
namespace TakeoutSaaS.Application.App.Coupons.Calendar.Dto;
|
||||
|
||||
/// <summary>
|
||||
/// 营销日历总览。
|
||||
/// </summary>
|
||||
public sealed class MarketingCalendarOverviewDto
|
||||
{
|
||||
/// <summary>
|
||||
/// 月份标识(yyyy-MM)。
|
||||
/// </summary>
|
||||
public string Month { get; init; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// 年份。
|
||||
/// </summary>
|
||||
public int Year { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// 月份(1-12)。
|
||||
/// </summary>
|
||||
public int MonthValue { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// 月初日期(UTC)。
|
||||
/// </summary>
|
||||
public DateTime MonthStartDate { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// 月末日期(UTC)。
|
||||
/// </summary>
|
||||
public DateTime MonthEndDate { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// 今天所在日(不在当月则为 0)。
|
||||
/// </summary>
|
||||
public int TodayDay { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// 日期头列表。
|
||||
/// </summary>
|
||||
public IReadOnlyList<MarketingCalendarDayDto> Days { get; init; } = [];
|
||||
|
||||
/// <summary>
|
||||
/// 图例。
|
||||
/// </summary>
|
||||
public IReadOnlyList<MarketingCalendarLegendDto> Legends { get; init; } = [];
|
||||
|
||||
/// <summary>
|
||||
/// 顶部统计。
|
||||
/// </summary>
|
||||
public MarketingCalendarStatsDto Stats { get; init; } = new();
|
||||
|
||||
/// <summary>
|
||||
/// 冲突横幅。
|
||||
/// </summary>
|
||||
public MarketingCalendarConflictBannerDto? ConflictBanner { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// 冲突区间。
|
||||
/// </summary>
|
||||
public IReadOnlyList<MarketingCalendarConflictDto> Conflicts { get; init; } = [];
|
||||
|
||||
/// <summary>
|
||||
/// 活动列表。
|
||||
/// </summary>
|
||||
public IReadOnlyList<MarketingCalendarActivityDto> Activities { get; init; } = [];
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 日期头。
|
||||
/// </summary>
|
||||
public sealed class MarketingCalendarDayDto
|
||||
{
|
||||
/// <summary>
|
||||
/// 日(1-31)。
|
||||
/// </summary>
|
||||
public int Day { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// 是否周末。
|
||||
/// </summary>
|
||||
public bool IsWeekend { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// 是否今日。
|
||||
/// </summary>
|
||||
public bool IsToday { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 图例。
|
||||
/// </summary>
|
||||
public sealed class MarketingCalendarLegendDto
|
||||
{
|
||||
/// <summary>
|
||||
/// 图例类型。
|
||||
/// </summary>
|
||||
public string Type { get; init; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// 图例名称。
|
||||
/// </summary>
|
||||
public string Label { get; init; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// 图例颜色。
|
||||
/// </summary>
|
||||
public string Color { get; init; } = string.Empty;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 顶部统计。
|
||||
/// </summary>
|
||||
public sealed class MarketingCalendarStatsDto
|
||||
{
|
||||
/// <summary>
|
||||
/// 本月活动数。
|
||||
/// </summary>
|
||||
public int TotalActivityCount { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// 进行中活动数。
|
||||
/// </summary>
|
||||
public int OngoingCount { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// 最大并行活动数。
|
||||
/// </summary>
|
||||
public int MaxConcurrentCount { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// 本月预计优惠金额。
|
||||
/// </summary>
|
||||
public decimal EstimatedDiscountAmount { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 活动。
|
||||
/// </summary>
|
||||
public sealed class MarketingCalendarActivityDto
|
||||
{
|
||||
/// <summary>
|
||||
/// 活动唯一键(跨模块)。
|
||||
/// </summary>
|
||||
public string ActivityId { get; init; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// 来源模块(full_reduction/flash_sale/seckill/coupon/punch_card)。
|
||||
/// </summary>
|
||||
public string SourceType { get; init; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// 来源标识。
|
||||
/// </summary>
|
||||
public string SourceId { get; init; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// 日历类型(reduce/gift/second_half/flash_sale/seckill/coupon/punch_card)。
|
||||
/// </summary>
|
||||
public string CalendarType { get; init; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// 活动名称。
|
||||
/// </summary>
|
||||
public string Name { get; init; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// 活动颜色。
|
||||
/// </summary>
|
||||
public string Color { get; init; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// 活动摘要。
|
||||
/// </summary>
|
||||
public string Summary { get; init; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// 展示状态(ongoing/upcoming/ended/disabled)。
|
||||
/// </summary>
|
||||
public string DisplayStatus { get; init; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// 是否弱化。
|
||||
/// </summary>
|
||||
public bool IsDimmed { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// 活动开始日期(UTC)。
|
||||
/// </summary>
|
||||
public DateTime StartDate { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// 活动结束日期(UTC)。
|
||||
/// </summary>
|
||||
public DateTime EndDate { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// 预计优惠金额。
|
||||
/// </summary>
|
||||
public decimal EstimatedDiscountAmount { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// 活动条。
|
||||
/// </summary>
|
||||
public IReadOnlyList<MarketingCalendarActivityBarDto> Bars { get; init; } = [];
|
||||
|
||||
/// <summary>
|
||||
/// 二级抽屉详情。
|
||||
/// </summary>
|
||||
public MarketingCalendarActivityDetailDto Detail { get; init; } = new();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 活动条。
|
||||
/// </summary>
|
||||
public sealed class MarketingCalendarActivityBarDto
|
||||
{
|
||||
/// <summary>
|
||||
/// 条标识。
|
||||
/// </summary>
|
||||
public string BarId { get; init; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// 开始日(1-31)。
|
||||
/// </summary>
|
||||
public int StartDay { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// 结束日(1-31)。
|
||||
/// </summary>
|
||||
public int EndDay { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// 条文案。
|
||||
/// </summary>
|
||||
public string Label { get; init; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// 是否里程碑。
|
||||
/// </summary>
|
||||
public bool IsMilestone { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// 是否弱化。
|
||||
/// </summary>
|
||||
public bool IsDimmed { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 活动详情。
|
||||
/// </summary>
|
||||
public sealed class MarketingCalendarActivityDetailDto
|
||||
{
|
||||
/// <summary>
|
||||
/// 模块名称。
|
||||
/// </summary>
|
||||
public string ModuleName { get; init; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// 详情描述。
|
||||
/// </summary>
|
||||
public string Description { get; init; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// 明细字段。
|
||||
/// </summary>
|
||||
public IReadOnlyList<MarketingCalendarDetailFieldDto> Fields { get; init; } = [];
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 详情字段。
|
||||
/// </summary>
|
||||
public sealed class MarketingCalendarDetailFieldDto
|
||||
{
|
||||
/// <summary>
|
||||
/// 标签。
|
||||
/// </summary>
|
||||
public string Label { get; init; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// 值。
|
||||
/// </summary>
|
||||
public string Value { get; init; } = string.Empty;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 冲突横幅。
|
||||
/// </summary>
|
||||
public sealed class MarketingCalendarConflictBannerDto
|
||||
{
|
||||
/// <summary>
|
||||
/// 冲突标识。
|
||||
/// </summary>
|
||||
public string ConflictId { get; init; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// 开始日。
|
||||
/// </summary>
|
||||
public int StartDay { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// 结束日。
|
||||
/// </summary>
|
||||
public int EndDay { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// 同时进行活动数。
|
||||
/// </summary>
|
||||
public int ActivityCount { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// 最大并行活动数。
|
||||
/// </summary>
|
||||
public int MaxConcurrentCount { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// 冲突区间数。
|
||||
/// </summary>
|
||||
public int ConflictCount { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 冲突区间。
|
||||
/// </summary>
|
||||
public sealed class MarketingCalendarConflictDto
|
||||
{
|
||||
/// <summary>
|
||||
/// 冲突标识。
|
||||
/// </summary>
|
||||
public string ConflictId { get; init; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// 开始日。
|
||||
/// </summary>
|
||||
public int StartDay { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// 结束日。
|
||||
/// </summary>
|
||||
public int EndDay { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// 同时进行活动数。
|
||||
/// </summary>
|
||||
public int ActivityCount { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// 最大并行活动数。
|
||||
/// </summary>
|
||||
public int MaxConcurrentCount { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// 活动标识集合。
|
||||
/// </summary>
|
||||
public IReadOnlyList<string> ActivityIds { get; init; } = [];
|
||||
|
||||
/// <summary>
|
||||
/// 冲突活动摘要。
|
||||
/// </summary>
|
||||
public IReadOnlyList<MarketingCalendarConflictActivityDto> Activities { get; init; } = [];
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 冲突活动摘要。
|
||||
/// </summary>
|
||||
public sealed class MarketingCalendarConflictActivityDto
|
||||
{
|
||||
/// <summary>
|
||||
/// 活动唯一键。
|
||||
/// </summary>
|
||||
public string ActivityId { get; init; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// 日历类型。
|
||||
/// </summary>
|
||||
public string CalendarType { get; init; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// 活动名称。
|
||||
/// </summary>
|
||||
public string Name { get; init; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// 活动摘要。
|
||||
/// </summary>
|
||||
public string Summary { get; init; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// 活动颜色。
|
||||
/// </summary>
|
||||
public string Color { get; init; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// 展示状态。
|
||||
/// </summary>
|
||||
public string DisplayStatus { get; init; } = string.Empty;
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,25 @@
|
||||
using MediatR;
|
||||
using TakeoutSaaS.Application.App.Coupons.Calendar.Dto;
|
||||
|
||||
namespace TakeoutSaaS.Application.App.Coupons.Calendar.Queries;
|
||||
|
||||
/// <summary>
|
||||
/// 查询营销日历总览。
|
||||
/// </summary>
|
||||
public sealed class GetMarketingCalendarOverviewQuery : IRequest<MarketingCalendarOverviewDto>
|
||||
{
|
||||
/// <summary>
|
||||
/// 门店 ID。
|
||||
/// </summary>
|
||||
public long StoreId { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// 年份。
|
||||
/// </summary>
|
||||
public int Year { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// 月份(1-12)。
|
||||
/// </summary>
|
||||
public int Month { get; init; }
|
||||
}
|
||||
@@ -0,0 +1,60 @@
|
||||
using MediatR;
|
||||
using TakeoutSaaS.Application.App.Coupons.NewCustomer.Dto;
|
||||
|
||||
namespace TakeoutSaaS.Application.App.Coupons.NewCustomer.Commands;
|
||||
|
||||
/// <summary>
|
||||
/// 保存新客有礼配置。
|
||||
/// </summary>
|
||||
public sealed class SaveNewCustomerSettingsCommand : IRequest<NewCustomerSettingsDto>
|
||||
{
|
||||
/// <summary>
|
||||
/// 门店 ID。
|
||||
/// </summary>
|
||||
public long StoreId { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// 是否开启新客礼包。
|
||||
/// </summary>
|
||||
public bool GiftEnabled { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// 礼包类型(coupon/direct)。
|
||||
/// </summary>
|
||||
public string GiftType { get; init; } = "coupon";
|
||||
|
||||
/// <summary>
|
||||
/// 首单直减金额。
|
||||
/// </summary>
|
||||
public decimal? DirectReduceAmount { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// 首单直减门槛金额。
|
||||
/// </summary>
|
||||
public decimal? DirectMinimumSpend { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// 是否开启邀请分享。
|
||||
/// </summary>
|
||||
public bool InviteEnabled { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// 分享渠道。
|
||||
/// </summary>
|
||||
public IReadOnlyCollection<string> ShareChannels { get; init; } = [];
|
||||
|
||||
/// <summary>
|
||||
/// 新客礼包券。
|
||||
/// </summary>
|
||||
public IReadOnlyCollection<NewCustomerSaveCouponRuleInputDto> WelcomeCoupons { get; init; } = [];
|
||||
|
||||
/// <summary>
|
||||
/// 邀请人奖励券。
|
||||
/// </summary>
|
||||
public IReadOnlyCollection<NewCustomerSaveCouponRuleInputDto> InviterCoupons { get; init; } = [];
|
||||
|
||||
/// <summary>
|
||||
/// 被邀请人奖励券。
|
||||
/// </summary>
|
||||
public IReadOnlyCollection<NewCustomerSaveCouponRuleInputDto> InviteeCoupons { get; init; } = [];
|
||||
}
|
||||
@@ -0,0 +1,45 @@
|
||||
using MediatR;
|
||||
using TakeoutSaaS.Application.App.Coupons.NewCustomer.Dto;
|
||||
|
||||
namespace TakeoutSaaS.Application.App.Coupons.NewCustomer.Commands;
|
||||
|
||||
/// <summary>
|
||||
/// 写入新客成长记录。
|
||||
/// </summary>
|
||||
public sealed class WriteNewCustomerGrowthRecordCommand : IRequest<NewCustomerGrowthRecordDto>
|
||||
{
|
||||
/// <summary>
|
||||
/// 门店 ID。
|
||||
/// </summary>
|
||||
public long StoreId { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// 顾客业务唯一键。
|
||||
/// </summary>
|
||||
public string CustomerKey { get; init; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// 顾客展示名。
|
||||
/// </summary>
|
||||
public string? CustomerName { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// 注册时间。
|
||||
/// </summary>
|
||||
public DateTime RegisteredAt { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// 礼包领取时间。
|
||||
/// </summary>
|
||||
public DateTime? GiftClaimedAt { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// 首单时间。
|
||||
/// </summary>
|
||||
public DateTime? FirstOrderAt { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// 来源渠道。
|
||||
/// </summary>
|
||||
public string? SourceChannel { get; init; }
|
||||
}
|
||||
@@ -0,0 +1,50 @@
|
||||
using MediatR;
|
||||
using TakeoutSaaS.Application.App.Coupons.NewCustomer.Dto;
|
||||
|
||||
namespace TakeoutSaaS.Application.App.Coupons.NewCustomer.Commands;
|
||||
|
||||
/// <summary>
|
||||
/// 写入新客邀请记录。
|
||||
/// </summary>
|
||||
public sealed class WriteNewCustomerInviteRecordCommand : IRequest<NewCustomerInviteRecordDto>
|
||||
{
|
||||
/// <summary>
|
||||
/// 门店 ID。
|
||||
/// </summary>
|
||||
public long StoreId { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// 邀请人展示名。
|
||||
/// </summary>
|
||||
public string InviterName { get; init; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// 被邀请人展示名。
|
||||
/// </summary>
|
||||
public string InviteeName { get; init; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// 邀请时间。
|
||||
/// </summary>
|
||||
public DateTime InviteTime { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// 订单状态(pending_order/ordered)。
|
||||
/// </summary>
|
||||
public string OrderStatus { get; init; } = "pending_order";
|
||||
|
||||
/// <summary>
|
||||
/// 奖励状态(pending/issued)。
|
||||
/// </summary>
|
||||
public string RewardStatus { get; init; } = "pending";
|
||||
|
||||
/// <summary>
|
||||
/// 奖励发放时间。
|
||||
/// </summary>
|
||||
public DateTime? RewardIssuedAt { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// 来源渠道。
|
||||
/// </summary>
|
||||
public string? SourceChannel { get; init; }
|
||||
}
|
||||
@@ -0,0 +1,42 @@
|
||||
namespace TakeoutSaaS.Application.App.Coupons.NewCustomer.Dto;
|
||||
|
||||
/// <summary>
|
||||
/// 新客有礼券规则 DTO。
|
||||
/// </summary>
|
||||
public sealed class NewCustomerCouponRuleDto
|
||||
{
|
||||
/// <summary>
|
||||
/// 规则 ID。
|
||||
/// </summary>
|
||||
public long Id { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// 场景(welcome/inviter/invitee)。
|
||||
/// </summary>
|
||||
public string Scene { get; init; } = "welcome";
|
||||
|
||||
/// <summary>
|
||||
/// 券类型(amount_off/discount/free_shipping)。
|
||||
/// </summary>
|
||||
public string CouponType { get; init; } = "amount_off";
|
||||
|
||||
/// <summary>
|
||||
/// 面值或折扣值。
|
||||
/// </summary>
|
||||
public decimal? Value { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// 使用门槛。
|
||||
/// </summary>
|
||||
public decimal? MinimumSpend { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// 有效期天数。
|
||||
/// </summary>
|
||||
public int ValidDays { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// 排序值。
|
||||
/// </summary>
|
||||
public int SortOrder { get; init; }
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
namespace TakeoutSaaS.Application.App.Coupons.NewCustomer.Dto;
|
||||
|
||||
/// <summary>
|
||||
/// 新客有礼详情 DTO。
|
||||
/// </summary>
|
||||
public sealed class NewCustomerDetailDto
|
||||
{
|
||||
/// <summary>
|
||||
/// 配置详情。
|
||||
/// </summary>
|
||||
public NewCustomerSettingsDto Settings { get; init; } = new();
|
||||
|
||||
/// <summary>
|
||||
/// 统计数据。
|
||||
/// </summary>
|
||||
public NewCustomerStatsDto Stats { get; init; } = new();
|
||||
|
||||
/// <summary>
|
||||
/// 邀请记录分页结果。
|
||||
/// </summary>
|
||||
public NewCustomerInviteRecordListResultDto InviteRecords { get; init; } = new();
|
||||
}
|
||||
@@ -0,0 +1,42 @@
|
||||
namespace TakeoutSaaS.Application.App.Coupons.NewCustomer.Dto;
|
||||
|
||||
/// <summary>
|
||||
/// 新客成长记录 DTO。
|
||||
/// </summary>
|
||||
public sealed class NewCustomerGrowthRecordDto
|
||||
{
|
||||
/// <summary>
|
||||
/// 记录 ID。
|
||||
/// </summary>
|
||||
public long Id { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// 顾客业务键。
|
||||
/// </summary>
|
||||
public string CustomerKey { get; init; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// 顾客展示名。
|
||||
/// </summary>
|
||||
public string? CustomerName { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// 注册时间。
|
||||
/// </summary>
|
||||
public DateTime RegisteredAt { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// 礼包领取时间。
|
||||
/// </summary>
|
||||
public DateTime? GiftClaimedAt { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// 首单时间。
|
||||
/// </summary>
|
||||
public DateTime? FirstOrderAt { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// 渠道来源。
|
||||
/// </summary>
|
||||
public string? SourceChannel { get; init; }
|
||||
}
|
||||
@@ -0,0 +1,47 @@
|
||||
namespace TakeoutSaaS.Application.App.Coupons.NewCustomer.Dto;
|
||||
|
||||
/// <summary>
|
||||
/// 新客邀请记录 DTO。
|
||||
/// </summary>
|
||||
public sealed class NewCustomerInviteRecordDto
|
||||
{
|
||||
/// <summary>
|
||||
/// 记录 ID。
|
||||
/// </summary>
|
||||
public long Id { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// 邀请人展示名。
|
||||
/// </summary>
|
||||
public string InviterName { get; init; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// 被邀请人展示名。
|
||||
/// </summary>
|
||||
public string InviteeName { get; init; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// 邀请时间。
|
||||
/// </summary>
|
||||
public DateTime InviteTime { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// 状态(pending_order/ordered)。
|
||||
/// </summary>
|
||||
public string OrderStatus { get; init; } = "pending_order";
|
||||
|
||||
/// <summary>
|
||||
/// 奖励发放状态(pending/issued)。
|
||||
/// </summary>
|
||||
public string RewardStatus { get; init; } = "pending";
|
||||
|
||||
/// <summary>
|
||||
/// 奖励发放时间。
|
||||
/// </summary>
|
||||
public DateTime? RewardIssuedAt { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// 渠道来源。
|
||||
/// </summary>
|
||||
public string? SourceChannel { get; init; }
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
namespace TakeoutSaaS.Application.App.Coupons.NewCustomer.Dto;
|
||||
|
||||
/// <summary>
|
||||
/// 新客邀请记录分页结果 DTO。
|
||||
/// </summary>
|
||||
public sealed class NewCustomerInviteRecordListResultDto
|
||||
{
|
||||
/// <summary>
|
||||
/// 列表项。
|
||||
/// </summary>
|
||||
public IReadOnlyList<NewCustomerInviteRecordDto> Items { get; init; } = [];
|
||||
|
||||
/// <summary>
|
||||
/// 页码。
|
||||
/// </summary>
|
||||
public int Page { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// 每页条数。
|
||||
/// </summary>
|
||||
public int PageSize { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// 总条数。
|
||||
/// </summary>
|
||||
public int TotalCount { get; init; }
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
namespace TakeoutSaaS.Application.App.Coupons.NewCustomer.Dto;
|
||||
|
||||
/// <summary>
|
||||
/// 保存新客有礼券规则输入 DTO。
|
||||
/// </summary>
|
||||
public sealed class NewCustomerSaveCouponRuleInputDto
|
||||
{
|
||||
/// <summary>
|
||||
/// 券类型(amount_off/discount/free_shipping)。
|
||||
/// </summary>
|
||||
public string CouponType { get; init; } = "amount_off";
|
||||
|
||||
/// <summary>
|
||||
/// 面值或折扣值。
|
||||
/// </summary>
|
||||
public decimal? Value { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// 使用门槛金额。
|
||||
/// </summary>
|
||||
public decimal? MinimumSpend { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// 有效期天数。
|
||||
/// </summary>
|
||||
public int ValidDays { get; init; }
|
||||
}
|
||||
@@ -0,0 +1,62 @@
|
||||
namespace TakeoutSaaS.Application.App.Coupons.NewCustomer.Dto;
|
||||
|
||||
/// <summary>
|
||||
/// 新客有礼配置 DTO。
|
||||
/// </summary>
|
||||
public sealed class NewCustomerSettingsDto
|
||||
{
|
||||
/// <summary>
|
||||
/// 门店 ID。
|
||||
/// </summary>
|
||||
public long StoreId { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// 是否开启新客礼包。
|
||||
/// </summary>
|
||||
public bool GiftEnabled { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// 礼包类型(coupon/direct)。
|
||||
/// </summary>
|
||||
public string GiftType { get; init; } = "coupon";
|
||||
|
||||
/// <summary>
|
||||
/// 首单直减金额。
|
||||
/// </summary>
|
||||
public decimal? DirectReduceAmount { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// 首单直减门槛金额。
|
||||
/// </summary>
|
||||
public decimal? DirectMinimumSpend { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// 是否开启老带新分享。
|
||||
/// </summary>
|
||||
public bool InviteEnabled { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// 分享渠道。
|
||||
/// </summary>
|
||||
public IReadOnlyList<string> ShareChannels { get; init; } = [];
|
||||
|
||||
/// <summary>
|
||||
/// 新客礼包券列表。
|
||||
/// </summary>
|
||||
public IReadOnlyList<NewCustomerCouponRuleDto> WelcomeCoupons { get; init; } = [];
|
||||
|
||||
/// <summary>
|
||||
/// 邀请人奖励券列表。
|
||||
/// </summary>
|
||||
public IReadOnlyList<NewCustomerCouponRuleDto> InviterCoupons { get; init; } = [];
|
||||
|
||||
/// <summary>
|
||||
/// 被邀请人奖励券列表。
|
||||
/// </summary>
|
||||
public IReadOnlyList<NewCustomerCouponRuleDto> InviteeCoupons { get; init; } = [];
|
||||
|
||||
/// <summary>
|
||||
/// 更新时间。
|
||||
/// </summary>
|
||||
public DateTime UpdatedAt { get; init; }
|
||||
}
|
||||
@@ -0,0 +1,42 @@
|
||||
namespace TakeoutSaaS.Application.App.Coupons.NewCustomer.Dto;
|
||||
|
||||
/// <summary>
|
||||
/// 新客有礼统计 DTO。
|
||||
/// </summary>
|
||||
public sealed class NewCustomerStatsDto
|
||||
{
|
||||
/// <summary>
|
||||
/// 本月新客数。
|
||||
/// </summary>
|
||||
public int MonthlyNewCustomers { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// 较上月增长人数。
|
||||
/// </summary>
|
||||
public int MonthlyGrowthCount { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// 较上月增长百分比。
|
||||
/// </summary>
|
||||
public decimal MonthlyGrowthRatePercent { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// 本月礼包领取率。
|
||||
/// </summary>
|
||||
public decimal GiftClaimRate { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// 本月礼包已领取人数。
|
||||
/// </summary>
|
||||
public int GiftClaimedCount { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// 本月首单转化率。
|
||||
/// </summary>
|
||||
public decimal FirstOrderConversionRate { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// 本月首单完成人数。
|
||||
/// </summary>
|
||||
public int FirstOrderedCount { get; init; }
|
||||
}
|
||||
@@ -0,0 +1,152 @@
|
||||
using MediatR;
|
||||
using TakeoutSaaS.Application.App.Coupons.NewCustomer.Dto;
|
||||
using TakeoutSaaS.Application.App.Coupons.NewCustomer.Queries;
|
||||
using TakeoutSaaS.Domain.Coupons.Entities;
|
||||
using TakeoutSaaS.Domain.Coupons.Enums;
|
||||
using TakeoutSaaS.Domain.Coupons.Repositories;
|
||||
using TakeoutSaaS.Shared.Abstractions.Tenancy;
|
||||
|
||||
namespace TakeoutSaaS.Application.App.Coupons.NewCustomer.Handlers;
|
||||
|
||||
/// <summary>
|
||||
/// 查询新客有礼详情处理器。
|
||||
/// </summary>
|
||||
public sealed class GetNewCustomerDetailQueryHandler(
|
||||
INewCustomerGiftRepository repository,
|
||||
ITenantProvider tenantProvider)
|
||||
: IRequestHandler<GetNewCustomerDetailQuery, NewCustomerDetailDto>
|
||||
{
|
||||
/// <inheritdoc />
|
||||
public async Task<NewCustomerDetailDto> Handle(GetNewCustomerDetailQuery request, CancellationToken cancellationToken)
|
||||
{
|
||||
var tenantId = tenantProvider.GetCurrentTenantId();
|
||||
var normalizedPage = Math.Max(1, request.RecordPage);
|
||||
var normalizedPageSize = Math.Clamp(request.RecordPageSize, 1, 200);
|
||||
|
||||
var setting = await repository.FindSettingByStoreIdAsync(tenantId, request.StoreId, cancellationToken);
|
||||
var rules = await repository.GetCouponRulesByStoreIdAsync(tenantId, request.StoreId, cancellationToken);
|
||||
var settingsDto = BuildSettingsDto(request.StoreId, setting, rules);
|
||||
|
||||
var nowUtc = DateTime.UtcNow;
|
||||
var currentMonthStart = NewCustomerMapping.StartOfMonthUtc(nowUtc);
|
||||
var nextMonthStart = currentMonthStart.AddMonths(1);
|
||||
var previousMonthStart = currentMonthStart.AddMonths(-1);
|
||||
|
||||
var currentMonthNewCustomerCount = await repository.CountRegisteredCustomersAsync(
|
||||
tenantId,
|
||||
request.StoreId,
|
||||
currentMonthStart,
|
||||
nextMonthStart,
|
||||
cancellationToken);
|
||||
|
||||
var previousMonthNewCustomerCount = await repository.CountRegisteredCustomersAsync(
|
||||
tenantId,
|
||||
request.StoreId,
|
||||
previousMonthStart,
|
||||
currentMonthStart,
|
||||
cancellationToken);
|
||||
|
||||
var currentMonthGiftClaimedCount = await repository.CountGiftClaimedCustomersAsync(
|
||||
tenantId,
|
||||
request.StoreId,
|
||||
currentMonthStart,
|
||||
nextMonthStart,
|
||||
cancellationToken);
|
||||
|
||||
var currentMonthFirstOrderedCount = await repository.CountFirstOrderedCustomersAsync(
|
||||
tenantId,
|
||||
request.StoreId,
|
||||
currentMonthStart,
|
||||
nextMonthStart,
|
||||
cancellationToken);
|
||||
|
||||
var stats = new NewCustomerStatsDto
|
||||
{
|
||||
MonthlyNewCustomers = currentMonthNewCustomerCount,
|
||||
MonthlyGrowthCount = currentMonthNewCustomerCount - previousMonthNewCustomerCount,
|
||||
MonthlyGrowthRatePercent = NewCustomerMapping.ToGrowthRatePercent(
|
||||
currentMonthNewCustomerCount,
|
||||
previousMonthNewCustomerCount),
|
||||
GiftClaimRate = NewCustomerMapping.ToRatePercent(
|
||||
currentMonthGiftClaimedCount,
|
||||
currentMonthNewCustomerCount),
|
||||
GiftClaimedCount = currentMonthGiftClaimedCount,
|
||||
FirstOrderConversionRate = NewCustomerMapping.ToRatePercent(
|
||||
currentMonthFirstOrderedCount,
|
||||
currentMonthNewCustomerCount),
|
||||
FirstOrderedCount = currentMonthFirstOrderedCount
|
||||
};
|
||||
|
||||
var (records, totalCount) = await repository.GetInviteRecordsAsync(
|
||||
tenantId,
|
||||
request.StoreId,
|
||||
normalizedPage,
|
||||
normalizedPageSize,
|
||||
cancellationToken);
|
||||
|
||||
return new NewCustomerDetailDto
|
||||
{
|
||||
Settings = settingsDto,
|
||||
Stats = stats,
|
||||
InviteRecords = new NewCustomerInviteRecordListResultDto
|
||||
{
|
||||
Items = records.Select(NewCustomerMapping.ToInviteRecordDto).ToList(),
|
||||
Page = normalizedPage,
|
||||
PageSize = normalizedPageSize,
|
||||
TotalCount = totalCount
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
private static NewCustomerSettingsDto BuildSettingsDto(
|
||||
long storeId,
|
||||
NewCustomerGiftSetting? setting,
|
||||
IReadOnlyList<NewCustomerCouponRule> rules)
|
||||
{
|
||||
var welcomeCoupons = rules
|
||||
.Where(item => item.Scene == NewCustomerCouponScene.Welcome)
|
||||
.Select(NewCustomerMapping.ToCouponRuleDto)
|
||||
.ToList();
|
||||
|
||||
var inviterCoupons = rules
|
||||
.Where(item => item.Scene == NewCustomerCouponScene.InviterReward)
|
||||
.Select(NewCustomerMapping.ToCouponRuleDto)
|
||||
.ToList();
|
||||
|
||||
var inviteeCoupons = rules
|
||||
.Where(item => item.Scene == NewCustomerCouponScene.InviteeReward)
|
||||
.Select(NewCustomerMapping.ToCouponRuleDto)
|
||||
.ToList();
|
||||
|
||||
if (setting is null)
|
||||
{
|
||||
return new NewCustomerSettingsDto
|
||||
{
|
||||
StoreId = storeId,
|
||||
GiftEnabled = true,
|
||||
GiftType = "coupon",
|
||||
InviteEnabled = true,
|
||||
ShareChannels = ["wechat_friend", "moments"],
|
||||
WelcomeCoupons = welcomeCoupons,
|
||||
InviterCoupons = inviterCoupons,
|
||||
InviteeCoupons = inviteeCoupons,
|
||||
UpdatedAt = DateTime.UtcNow
|
||||
};
|
||||
}
|
||||
|
||||
return new NewCustomerSettingsDto
|
||||
{
|
||||
StoreId = storeId,
|
||||
GiftEnabled = setting.GiftEnabled,
|
||||
GiftType = NewCustomerMapping.ToGiftTypeText(setting.GiftType),
|
||||
DirectReduceAmount = setting.DirectReduceAmount,
|
||||
DirectMinimumSpend = setting.DirectMinimumSpend,
|
||||
InviteEnabled = setting.InviteEnabled,
|
||||
ShareChannels = NewCustomerMapping.DeserializeShareChannels(setting.ShareChannelsJson),
|
||||
WelcomeCoupons = welcomeCoupons,
|
||||
InviterCoupons = inviterCoupons,
|
||||
InviteeCoupons = inviteeCoupons,
|
||||
UpdatedAt = setting.UpdatedAt ?? setting.CreatedAt
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,41 @@
|
||||
using MediatR;
|
||||
using TakeoutSaaS.Application.App.Coupons.NewCustomer.Dto;
|
||||
using TakeoutSaaS.Application.App.Coupons.NewCustomer.Queries;
|
||||
using TakeoutSaaS.Domain.Coupons.Repositories;
|
||||
using TakeoutSaaS.Shared.Abstractions.Tenancy;
|
||||
|
||||
namespace TakeoutSaaS.Application.App.Coupons.NewCustomer.Handlers;
|
||||
|
||||
/// <summary>
|
||||
/// 查询新客邀请记录分页处理器。
|
||||
/// </summary>
|
||||
public sealed class GetNewCustomerInviteRecordListQueryHandler(
|
||||
INewCustomerGiftRepository repository,
|
||||
ITenantProvider tenantProvider)
|
||||
: IRequestHandler<GetNewCustomerInviteRecordListQuery, NewCustomerInviteRecordListResultDto>
|
||||
{
|
||||
/// <inheritdoc />
|
||||
public async Task<NewCustomerInviteRecordListResultDto> Handle(
|
||||
GetNewCustomerInviteRecordListQuery request,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var tenantId = tenantProvider.GetCurrentTenantId();
|
||||
var normalizedPage = Math.Max(1, request.Page);
|
||||
var normalizedPageSize = Math.Clamp(request.PageSize, 1, 200);
|
||||
|
||||
var (items, totalCount) = await repository.GetInviteRecordsAsync(
|
||||
tenantId,
|
||||
request.StoreId,
|
||||
normalizedPage,
|
||||
normalizedPageSize,
|
||||
cancellationToken);
|
||||
|
||||
return new NewCustomerInviteRecordListResultDto
|
||||
{
|
||||
Items = items.Select(NewCustomerMapping.ToInviteRecordDto).ToList(),
|
||||
Page = normalizedPage,
|
||||
PageSize = normalizedPageSize,
|
||||
TotalCount = totalCount
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,129 @@
|
||||
using MediatR;
|
||||
using TakeoutSaaS.Application.App.Coupons.NewCustomer.Commands;
|
||||
using TakeoutSaaS.Application.App.Coupons.NewCustomer.Dto;
|
||||
using TakeoutSaaS.Domain.Coupons.Entities;
|
||||
using TakeoutSaaS.Domain.Coupons.Enums;
|
||||
using TakeoutSaaS.Domain.Coupons.Repositories;
|
||||
using TakeoutSaaS.Shared.Abstractions.Constants;
|
||||
using TakeoutSaaS.Shared.Abstractions.Exceptions;
|
||||
using TakeoutSaaS.Shared.Abstractions.Tenancy;
|
||||
|
||||
namespace TakeoutSaaS.Application.App.Coupons.NewCustomer.Handlers;
|
||||
|
||||
/// <summary>
|
||||
/// 保存新客有礼配置处理器。
|
||||
/// </summary>
|
||||
public sealed class SaveNewCustomerSettingsCommandHandler(
|
||||
INewCustomerGiftRepository repository,
|
||||
ITenantProvider tenantProvider)
|
||||
: IRequestHandler<SaveNewCustomerSettingsCommand, NewCustomerSettingsDto>
|
||||
{
|
||||
/// <inheritdoc />
|
||||
public async Task<NewCustomerSettingsDto> Handle(
|
||||
SaveNewCustomerSettingsCommand request,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
if (request.StoreId <= 0)
|
||||
{
|
||||
throw new BusinessException(ErrorCodes.BadRequest, "storeId 参数不合法");
|
||||
}
|
||||
|
||||
var tenantId = tenantProvider.GetCurrentTenantId();
|
||||
var giftType = NewCustomerMapping.ParseGiftType(request.GiftType);
|
||||
var shareChannels = NewCustomerMapping.NormalizeShareChannels(request.ShareChannels);
|
||||
|
||||
var welcomeRules = NewCustomerMapping.NormalizeCouponRulesForSave(
|
||||
request.StoreId,
|
||||
NewCustomerCouponScene.Welcome,
|
||||
request.WelcomeCoupons);
|
||||
|
||||
var inviterRules = NewCustomerMapping.NormalizeCouponRulesForSave(
|
||||
request.StoreId,
|
||||
NewCustomerCouponScene.InviterReward,
|
||||
request.InviterCoupons);
|
||||
|
||||
var inviteeRules = NewCustomerMapping.NormalizeCouponRulesForSave(
|
||||
request.StoreId,
|
||||
NewCustomerCouponScene.InviteeReward,
|
||||
request.InviteeCoupons);
|
||||
|
||||
if (giftType == NewCustomerGiftType.Coupon && welcomeRules.Count == 0)
|
||||
{
|
||||
throw new BusinessException(ErrorCodes.BadRequest, "优惠券包至少需要一张券");
|
||||
}
|
||||
|
||||
if (giftType == NewCustomerGiftType.Direct)
|
||||
{
|
||||
if (!request.DirectReduceAmount.HasValue || request.DirectReduceAmount.Value <= 0m)
|
||||
{
|
||||
throw new BusinessException(ErrorCodes.BadRequest, "directReduceAmount 必须大于 0");
|
||||
}
|
||||
|
||||
if (!request.DirectMinimumSpend.HasValue || request.DirectMinimumSpend.Value < 0m)
|
||||
{
|
||||
throw new BusinessException(ErrorCodes.BadRequest, "directMinimumSpend 不能小于 0");
|
||||
}
|
||||
}
|
||||
|
||||
if (request.InviteEnabled && (inviterRules.Count == 0 || inviteeRules.Count == 0))
|
||||
{
|
||||
throw new BusinessException(ErrorCodes.BadRequest, "开启邀请后必须配置邀请人和被邀请人奖励券");
|
||||
}
|
||||
|
||||
var setting = await repository.FindSettingByStoreIdAsync(tenantId, request.StoreId, cancellationToken);
|
||||
var isNewSetting = setting is null;
|
||||
if (setting is null)
|
||||
{
|
||||
setting = new NewCustomerGiftSetting
|
||||
{
|
||||
StoreId = request.StoreId
|
||||
};
|
||||
|
||||
await repository.AddSettingAsync(setting, cancellationToken);
|
||||
}
|
||||
|
||||
setting.GiftEnabled = request.GiftEnabled;
|
||||
setting.GiftType = giftType;
|
||||
setting.DirectReduceAmount = giftType == NewCustomerGiftType.Direct
|
||||
? decimal.Round(request.DirectReduceAmount!.Value, 2, MidpointRounding.AwayFromZero)
|
||||
: null;
|
||||
setting.DirectMinimumSpend = giftType == NewCustomerGiftType.Direct
|
||||
? decimal.Round(request.DirectMinimumSpend!.Value, 2, MidpointRounding.AwayFromZero)
|
||||
: null;
|
||||
setting.InviteEnabled = request.InviteEnabled;
|
||||
setting.ShareChannelsJson = NewCustomerMapping.SerializeShareChannels(shareChannels);
|
||||
|
||||
if (!isNewSetting)
|
||||
{
|
||||
await repository.UpdateSettingAsync(setting, cancellationToken);
|
||||
}
|
||||
|
||||
var allRules = new List<NewCustomerCouponRule>(welcomeRules.Count + inviterRules.Count + inviteeRules.Count);
|
||||
allRules.AddRange(welcomeRules);
|
||||
allRules.AddRange(inviterRules);
|
||||
allRules.AddRange(inviteeRules);
|
||||
|
||||
await repository.ReplaceCouponRulesAsync(
|
||||
tenantId,
|
||||
request.StoreId,
|
||||
allRules,
|
||||
cancellationToken);
|
||||
|
||||
await repository.SaveChangesAsync(cancellationToken);
|
||||
|
||||
return new NewCustomerSettingsDto
|
||||
{
|
||||
StoreId = request.StoreId,
|
||||
GiftEnabled = setting.GiftEnabled,
|
||||
GiftType = NewCustomerMapping.ToGiftTypeText(setting.GiftType),
|
||||
DirectReduceAmount = setting.DirectReduceAmount,
|
||||
DirectMinimumSpend = setting.DirectMinimumSpend,
|
||||
InviteEnabled = setting.InviteEnabled,
|
||||
ShareChannels = shareChannels,
|
||||
WelcomeCoupons = welcomeRules.Select(NewCustomerMapping.ToCouponRuleDto).ToList(),
|
||||
InviterCoupons = inviterRules.Select(NewCustomerMapping.ToCouponRuleDto).ToList(),
|
||||
InviteeCoupons = inviteeRules.Select(NewCustomerMapping.ToCouponRuleDto).ToList(),
|
||||
UpdatedAt = DateTime.UtcNow
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,101 @@
|
||||
using MediatR;
|
||||
using TakeoutSaaS.Application.App.Coupons.NewCustomer.Commands;
|
||||
using TakeoutSaaS.Application.App.Coupons.NewCustomer.Dto;
|
||||
using TakeoutSaaS.Domain.Coupons.Entities;
|
||||
using TakeoutSaaS.Domain.Coupons.Repositories;
|
||||
using TakeoutSaaS.Shared.Abstractions.Constants;
|
||||
using TakeoutSaaS.Shared.Abstractions.Exceptions;
|
||||
using TakeoutSaaS.Shared.Abstractions.Tenancy;
|
||||
|
||||
namespace TakeoutSaaS.Application.App.Coupons.NewCustomer.Handlers;
|
||||
|
||||
/// <summary>
|
||||
/// 写入新客成长记录处理器。
|
||||
/// </summary>
|
||||
public sealed class WriteNewCustomerGrowthRecordCommandHandler(
|
||||
INewCustomerGiftRepository repository,
|
||||
ITenantProvider tenantProvider)
|
||||
: IRequestHandler<WriteNewCustomerGrowthRecordCommand, NewCustomerGrowthRecordDto>
|
||||
{
|
||||
/// <inheritdoc />
|
||||
public async Task<NewCustomerGrowthRecordDto> Handle(
|
||||
WriteNewCustomerGrowthRecordCommand request,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
if (request.StoreId <= 0)
|
||||
{
|
||||
throw new BusinessException(ErrorCodes.BadRequest, "storeId 参数不合法");
|
||||
}
|
||||
|
||||
var tenantId = tenantProvider.GetCurrentTenantId();
|
||||
var customerKey = NewCustomerMapping.NormalizeCustomerKey(request.CustomerKey);
|
||||
var customerName = NewCustomerMapping.NormalizeOptionalText(request.CustomerName, "customerName", 64);
|
||||
var sourceChannel = NewCustomerMapping.NormalizeOptionalText(request.SourceChannel, "sourceChannel", 32);
|
||||
var registeredAt = NewCustomerMapping.NormalizeUtc(request.RegisteredAt);
|
||||
DateTime? giftClaimedAt = request.GiftClaimedAt.HasValue
|
||||
? NewCustomerMapping.NormalizeUtc(request.GiftClaimedAt.Value)
|
||||
: null;
|
||||
DateTime? firstOrderAt = request.FirstOrderAt.HasValue
|
||||
? NewCustomerMapping.NormalizeUtc(request.FirstOrderAt.Value)
|
||||
: null;
|
||||
|
||||
var entity = await repository.FindGrowthRecordByCustomerKeyAsync(
|
||||
tenantId: tenantId,
|
||||
storeId: request.StoreId,
|
||||
customerKey: customerKey,
|
||||
cancellationToken: cancellationToken);
|
||||
|
||||
if (entity is null)
|
||||
{
|
||||
entity = new NewCustomerGrowthRecord
|
||||
{
|
||||
StoreId = request.StoreId,
|
||||
CustomerKey = customerKey,
|
||||
CustomerName = customerName,
|
||||
RegisteredAt = registeredAt,
|
||||
GiftClaimedAt = giftClaimedAt,
|
||||
FirstOrderAt = firstOrderAt,
|
||||
SourceChannel = sourceChannel
|
||||
};
|
||||
|
||||
await repository.AddGrowthRecordAsync(entity, cancellationToken);
|
||||
await repository.SaveChangesAsync(cancellationToken);
|
||||
return NewCustomerMapping.ToGrowthRecordDto(entity);
|
||||
}
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(customerName))
|
||||
{
|
||||
entity.CustomerName = customerName;
|
||||
}
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(sourceChannel))
|
||||
{
|
||||
entity.SourceChannel = sourceChannel;
|
||||
}
|
||||
|
||||
entity.RegisteredAt = registeredAt < entity.RegisteredAt
|
||||
? registeredAt
|
||||
: entity.RegisteredAt;
|
||||
entity.GiftClaimedAt = MergeNullableDate(entity.GiftClaimedAt, giftClaimedAt);
|
||||
entity.FirstOrderAt = MergeNullableDate(entity.FirstOrderAt, firstOrderAt);
|
||||
|
||||
await repository.UpdateGrowthRecordAsync(entity, cancellationToken);
|
||||
await repository.SaveChangesAsync(cancellationToken);
|
||||
return NewCustomerMapping.ToGrowthRecordDto(entity);
|
||||
}
|
||||
|
||||
private static DateTime? MergeNullableDate(DateTime? existing, DateTime? incoming)
|
||||
{
|
||||
if (!existing.HasValue)
|
||||
{
|
||||
return incoming;
|
||||
}
|
||||
|
||||
if (!incoming.HasValue)
|
||||
{
|
||||
return existing;
|
||||
}
|
||||
|
||||
return incoming.Value < existing.Value ? incoming : existing;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,59 @@
|
||||
using MediatR;
|
||||
using TakeoutSaaS.Application.App.Coupons.NewCustomer.Commands;
|
||||
using TakeoutSaaS.Application.App.Coupons.NewCustomer.Dto;
|
||||
using TakeoutSaaS.Domain.Coupons.Entities;
|
||||
using TakeoutSaaS.Domain.Coupons.Enums;
|
||||
using TakeoutSaaS.Domain.Coupons.Repositories;
|
||||
using TakeoutSaaS.Shared.Abstractions.Constants;
|
||||
using TakeoutSaaS.Shared.Abstractions.Exceptions;
|
||||
|
||||
namespace TakeoutSaaS.Application.App.Coupons.NewCustomer.Handlers;
|
||||
|
||||
/// <summary>
|
||||
/// 写入新客邀请记录处理器。
|
||||
/// </summary>
|
||||
public sealed class WriteNewCustomerInviteRecordCommandHandler(
|
||||
INewCustomerGiftRepository repository)
|
||||
: IRequestHandler<WriteNewCustomerInviteRecordCommand, NewCustomerInviteRecordDto>
|
||||
{
|
||||
/// <inheritdoc />
|
||||
public async Task<NewCustomerInviteRecordDto> Handle(
|
||||
WriteNewCustomerInviteRecordCommand request,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
if (request.StoreId <= 0)
|
||||
{
|
||||
throw new BusinessException(ErrorCodes.BadRequest, "storeId 参数不合法");
|
||||
}
|
||||
|
||||
var inviterName = NewCustomerMapping.NormalizeDisplayName(request.InviterName, "inviterName");
|
||||
var inviteeName = NewCustomerMapping.NormalizeDisplayName(request.InviteeName, "inviteeName");
|
||||
var orderStatus = NewCustomerMapping.ParseInviteOrderStatus(request.OrderStatus);
|
||||
var rewardStatus = NewCustomerMapping.ParseInviteRewardStatus(request.RewardStatus);
|
||||
var sourceChannel = NewCustomerMapping.NormalizeOptionalText(request.SourceChannel, "sourceChannel", 32);
|
||||
|
||||
var inviteTime = NewCustomerMapping.NormalizeUtc(request.InviteTime);
|
||||
DateTime? rewardIssuedAt = rewardStatus == NewCustomerInviteRewardStatus.Issued
|
||||
? request.RewardIssuedAt.HasValue
|
||||
? NewCustomerMapping.NormalizeUtc(request.RewardIssuedAt.Value)
|
||||
: DateTime.UtcNow
|
||||
: null;
|
||||
|
||||
var entity = new NewCustomerInviteRecord
|
||||
{
|
||||
StoreId = request.StoreId,
|
||||
InviterName = inviterName,
|
||||
InviteeName = inviteeName,
|
||||
InviteTime = inviteTime,
|
||||
OrderStatus = orderStatus,
|
||||
RewardStatus = rewardStatus,
|
||||
RewardIssuedAt = rewardIssuedAt,
|
||||
SourceChannel = sourceChannel
|
||||
};
|
||||
|
||||
await repository.AddInviteRecordAsync(entity, cancellationToken);
|
||||
await repository.SaveChangesAsync(cancellationToken);
|
||||
|
||||
return NewCustomerMapping.ToInviteRecordDto(entity);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,394 @@
|
||||
using System.Globalization;
|
||||
using System.Text.Json;
|
||||
using TakeoutSaaS.Application.App.Coupons.NewCustomer.Dto;
|
||||
using TakeoutSaaS.Domain.Coupons.Entities;
|
||||
using TakeoutSaaS.Domain.Coupons.Enums;
|
||||
using TakeoutSaaS.Shared.Abstractions.Constants;
|
||||
using TakeoutSaaS.Shared.Abstractions.Exceptions;
|
||||
|
||||
namespace TakeoutSaaS.Application.App.Coupons.NewCustomer;
|
||||
|
||||
/// <summary>
|
||||
/// 新客有礼映射与规则校验。
|
||||
/// </summary>
|
||||
internal static class NewCustomerMapping
|
||||
{
|
||||
private static readonly HashSet<string> AllowedShareChannels =
|
||||
[
|
||||
"wechat_friend",
|
||||
"moments",
|
||||
"sms"
|
||||
];
|
||||
|
||||
private static readonly JsonSerializerOptions JsonOptions = new(JsonSerializerDefaults.Web)
|
||||
{
|
||||
PropertyNameCaseInsensitive = true
|
||||
};
|
||||
|
||||
public static NewCustomerGiftType ParseGiftType(string? value)
|
||||
{
|
||||
var normalized = (value ?? string.Empty).Trim().ToLowerInvariant();
|
||||
return normalized switch
|
||||
{
|
||||
"coupon" => NewCustomerGiftType.Coupon,
|
||||
"direct" => NewCustomerGiftType.Direct,
|
||||
_ => throw new BusinessException(ErrorCodes.BadRequest, "giftType 参数不合法")
|
||||
};
|
||||
}
|
||||
|
||||
public static string ToGiftTypeText(NewCustomerGiftType value)
|
||||
{
|
||||
return value switch
|
||||
{
|
||||
NewCustomerGiftType.Coupon => "coupon",
|
||||
NewCustomerGiftType.Direct => "direct",
|
||||
_ => "coupon"
|
||||
};
|
||||
}
|
||||
|
||||
public static NewCustomerCouponScene ParseCouponScene(string? value)
|
||||
{
|
||||
var normalized = (value ?? string.Empty).Trim().ToLowerInvariant();
|
||||
return normalized switch
|
||||
{
|
||||
"welcome" => NewCustomerCouponScene.Welcome,
|
||||
"inviter" => NewCustomerCouponScene.InviterReward,
|
||||
"invitee" => NewCustomerCouponScene.InviteeReward,
|
||||
_ => throw new BusinessException(ErrorCodes.BadRequest, "scene 参数不合法")
|
||||
};
|
||||
}
|
||||
|
||||
public static string ToCouponSceneText(NewCustomerCouponScene value)
|
||||
{
|
||||
return value switch
|
||||
{
|
||||
NewCustomerCouponScene.Welcome => "welcome",
|
||||
NewCustomerCouponScene.InviterReward => "inviter",
|
||||
NewCustomerCouponScene.InviteeReward => "invitee",
|
||||
_ => "welcome"
|
||||
};
|
||||
}
|
||||
|
||||
public static NewCustomerCouponType ParseCouponType(string? value)
|
||||
{
|
||||
var normalized = (value ?? string.Empty).Trim().ToLowerInvariant();
|
||||
return normalized switch
|
||||
{
|
||||
"amount_off" => NewCustomerCouponType.AmountOff,
|
||||
"discount" => NewCustomerCouponType.Discount,
|
||||
"free_shipping" => NewCustomerCouponType.FreeShipping,
|
||||
_ => throw new BusinessException(ErrorCodes.BadRequest, "couponType 参数不合法")
|
||||
};
|
||||
}
|
||||
|
||||
public static string ToCouponTypeText(NewCustomerCouponType value)
|
||||
{
|
||||
return value switch
|
||||
{
|
||||
NewCustomerCouponType.AmountOff => "amount_off",
|
||||
NewCustomerCouponType.Discount => "discount",
|
||||
NewCustomerCouponType.FreeShipping => "free_shipping",
|
||||
_ => "amount_off"
|
||||
};
|
||||
}
|
||||
|
||||
public static NewCustomerInviteOrderStatus ParseInviteOrderStatus(string? value)
|
||||
{
|
||||
var normalized = (value ?? string.Empty).Trim().ToLowerInvariant();
|
||||
return normalized switch
|
||||
{
|
||||
"pending_order" => NewCustomerInviteOrderStatus.PendingOrder,
|
||||
"ordered" => NewCustomerInviteOrderStatus.Ordered,
|
||||
_ => throw new BusinessException(ErrorCodes.BadRequest, "orderStatus 参数不合法")
|
||||
};
|
||||
}
|
||||
|
||||
public static string ToInviteOrderStatusText(NewCustomerInviteOrderStatus value)
|
||||
{
|
||||
return value switch
|
||||
{
|
||||
NewCustomerInviteOrderStatus.PendingOrder => "pending_order",
|
||||
NewCustomerInviteOrderStatus.Ordered => "ordered",
|
||||
_ => "pending_order"
|
||||
};
|
||||
}
|
||||
|
||||
public static NewCustomerInviteRewardStatus ParseInviteRewardStatus(string? value)
|
||||
{
|
||||
var normalized = (value ?? string.Empty).Trim().ToLowerInvariant();
|
||||
return normalized switch
|
||||
{
|
||||
"pending" => NewCustomerInviteRewardStatus.Pending,
|
||||
"issued" => NewCustomerInviteRewardStatus.Issued,
|
||||
_ => throw new BusinessException(ErrorCodes.BadRequest, "rewardStatus 参数不合法")
|
||||
};
|
||||
}
|
||||
|
||||
public static string ToInviteRewardStatusText(NewCustomerInviteRewardStatus value)
|
||||
{
|
||||
return value switch
|
||||
{
|
||||
NewCustomerInviteRewardStatus.Pending => "pending",
|
||||
NewCustomerInviteRewardStatus.Issued => "issued",
|
||||
_ => "pending"
|
||||
};
|
||||
}
|
||||
|
||||
public static DateTime NormalizeUtc(DateTime value)
|
||||
{
|
||||
return value.Kind switch
|
||||
{
|
||||
DateTimeKind.Utc => value,
|
||||
DateTimeKind.Local => value.ToUniversalTime(),
|
||||
_ => DateTime.SpecifyKind(value, DateTimeKind.Utc)
|
||||
};
|
||||
}
|
||||
|
||||
public static DateTime StartOfMonthUtc(DateTime nowUtc)
|
||||
{
|
||||
var utc = NormalizeUtc(nowUtc);
|
||||
return new DateTime(utc.Year, utc.Month, 1, 0, 0, 0, DateTimeKind.Utc);
|
||||
}
|
||||
|
||||
public static decimal ToRatePercent(int numerator, int denominator)
|
||||
{
|
||||
if (denominator <= 0 || numerator <= 0)
|
||||
{
|
||||
return 0m;
|
||||
}
|
||||
|
||||
return decimal.Round(numerator * 100m / denominator, 1, MidpointRounding.AwayFromZero);
|
||||
}
|
||||
|
||||
public static decimal ToGrowthRatePercent(int currentValue, int previousValue)
|
||||
{
|
||||
if (previousValue <= 0)
|
||||
{
|
||||
return currentValue > 0 ? 100m : 0m;
|
||||
}
|
||||
|
||||
return decimal.Round(
|
||||
(currentValue - previousValue) * 100m / previousValue,
|
||||
1,
|
||||
MidpointRounding.AwayFromZero);
|
||||
}
|
||||
|
||||
public static string SerializeShareChannels(IReadOnlyCollection<string> channels)
|
||||
{
|
||||
return JsonSerializer.Serialize(channels, JsonOptions);
|
||||
}
|
||||
|
||||
public static IReadOnlyList<string> DeserializeShareChannels(string? payload)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(payload))
|
||||
{
|
||||
return [];
|
||||
}
|
||||
|
||||
var values = JsonSerializer.Deserialize<List<string>>(payload, JsonOptions) ?? [];
|
||||
return NormalizeShareChannels(values);
|
||||
}
|
||||
|
||||
public static IReadOnlyList<string> NormalizeShareChannels(IEnumerable<string>? values)
|
||||
{
|
||||
var normalized = (values ?? [])
|
||||
.Select(item => (item ?? string.Empty).Trim().ToLowerInvariant())
|
||||
.Where(item => !string.IsNullOrWhiteSpace(item))
|
||||
.Distinct()
|
||||
.ToList();
|
||||
|
||||
if (normalized.Count == 0)
|
||||
{
|
||||
throw new BusinessException(ErrorCodes.BadRequest, "shareChannels 不能为空");
|
||||
}
|
||||
|
||||
if (normalized.Any(item => !AllowedShareChannels.Contains(item)))
|
||||
{
|
||||
throw new BusinessException(ErrorCodes.BadRequest, "shareChannels 存在非法值");
|
||||
}
|
||||
|
||||
return normalized;
|
||||
}
|
||||
|
||||
public static IReadOnlyList<NewCustomerCouponRule> NormalizeCouponRulesForSave(
|
||||
long storeId,
|
||||
NewCustomerCouponScene scene,
|
||||
IReadOnlyCollection<NewCustomerSaveCouponRuleInputDto>? values)
|
||||
{
|
||||
var rules = (values ?? [])
|
||||
.Select((item, index) => NormalizeCouponRuleForSave(storeId, scene, item, index + 1))
|
||||
.ToList();
|
||||
|
||||
return rules;
|
||||
}
|
||||
|
||||
public static NewCustomerCouponRuleDto ToCouponRuleDto(NewCustomerCouponRule source)
|
||||
{
|
||||
return new NewCustomerCouponRuleDto
|
||||
{
|
||||
Id = source.Id,
|
||||
Scene = ToCouponSceneText(source.Scene),
|
||||
CouponType = ToCouponTypeText(source.CouponType),
|
||||
Value = source.Value,
|
||||
MinimumSpend = source.MinimumSpend,
|
||||
ValidDays = source.ValidDays,
|
||||
SortOrder = source.SortOrder
|
||||
};
|
||||
}
|
||||
|
||||
public static NewCustomerInviteRecordDto ToInviteRecordDto(NewCustomerInviteRecord source)
|
||||
{
|
||||
return new NewCustomerInviteRecordDto
|
||||
{
|
||||
Id = source.Id,
|
||||
InviterName = source.InviterName,
|
||||
InviteeName = source.InviteeName,
|
||||
InviteTime = source.InviteTime,
|
||||
OrderStatus = ToInviteOrderStatusText(source.OrderStatus),
|
||||
RewardStatus = ToInviteRewardStatusText(source.RewardStatus),
|
||||
RewardIssuedAt = source.RewardIssuedAt,
|
||||
SourceChannel = source.SourceChannel
|
||||
};
|
||||
}
|
||||
|
||||
public static NewCustomerGrowthRecordDto ToGrowthRecordDto(NewCustomerGrowthRecord source)
|
||||
{
|
||||
return new NewCustomerGrowthRecordDto
|
||||
{
|
||||
Id = source.Id,
|
||||
CustomerKey = source.CustomerKey,
|
||||
CustomerName = source.CustomerName,
|
||||
RegisteredAt = source.RegisteredAt,
|
||||
GiftClaimedAt = source.GiftClaimedAt,
|
||||
FirstOrderAt = source.FirstOrderAt,
|
||||
SourceChannel = source.SourceChannel
|
||||
};
|
||||
}
|
||||
|
||||
public static string NormalizeDisplayName(string? value, string fieldName)
|
||||
{
|
||||
var normalized = (value ?? string.Empty).Trim();
|
||||
if (string.IsNullOrWhiteSpace(normalized))
|
||||
{
|
||||
throw new BusinessException(ErrorCodes.BadRequest, $"{fieldName} 不能为空");
|
||||
}
|
||||
|
||||
if (normalized.Length > 64)
|
||||
{
|
||||
throw new BusinessException(ErrorCodes.BadRequest, $"{fieldName} 长度不能超过 64");
|
||||
}
|
||||
|
||||
return normalized;
|
||||
}
|
||||
|
||||
public static string NormalizeCustomerKey(string? value)
|
||||
{
|
||||
var normalized = (value ?? string.Empty).Trim();
|
||||
if (string.IsNullOrWhiteSpace(normalized))
|
||||
{
|
||||
throw new BusinessException(ErrorCodes.BadRequest, "customerKey 不能为空");
|
||||
}
|
||||
|
||||
if (normalized.Length > 64)
|
||||
{
|
||||
throw new BusinessException(ErrorCodes.BadRequest, "customerKey 长度不能超过 64");
|
||||
}
|
||||
|
||||
return normalized;
|
||||
}
|
||||
|
||||
public static string? NormalizeOptionalText(string? value, string fieldName, int maxLength)
|
||||
{
|
||||
var normalized = (value ?? string.Empty).Trim();
|
||||
if (normalized.Length == 0)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
if (normalized.Length > maxLength)
|
||||
{
|
||||
throw new BusinessException(ErrorCodes.BadRequest, $"{fieldName} 长度不能超过 {maxLength}");
|
||||
}
|
||||
|
||||
return normalized;
|
||||
}
|
||||
|
||||
private static NewCustomerCouponRule NormalizeCouponRuleForSave(
|
||||
long storeId,
|
||||
NewCustomerCouponScene scene,
|
||||
NewCustomerSaveCouponRuleInputDto value,
|
||||
int sortOrder)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(value);
|
||||
|
||||
var couponType = ParseCouponType(value.CouponType);
|
||||
var minimumSpend = NormalizeNonNegativeMoney(value.MinimumSpend, "minimumSpend");
|
||||
decimal? normalizedValue = couponType switch
|
||||
{
|
||||
NewCustomerCouponType.AmountOff => NormalizePositiveMoney(value.Value, "value"),
|
||||
NewCustomerCouponType.Discount => NormalizeDiscount(value.Value),
|
||||
NewCustomerCouponType.FreeShipping => null,
|
||||
_ => throw new BusinessException(ErrorCodes.BadRequest, "couponType 参数不合法")
|
||||
};
|
||||
|
||||
if (couponType == NewCustomerCouponType.AmountOff &&
|
||||
minimumSpend.HasValue &&
|
||||
normalizedValue.HasValue &&
|
||||
normalizedValue.Value >= minimumSpend.Value &&
|
||||
minimumSpend.Value > 0m)
|
||||
{
|
||||
throw new BusinessException(ErrorCodes.BadRequest, "满减券 value 必须小于 minimumSpend");
|
||||
}
|
||||
|
||||
if (value.ValidDays is < 1 or > 365)
|
||||
{
|
||||
throw new BusinessException(ErrorCodes.BadRequest, "validDays 必须在 1-365 之间");
|
||||
}
|
||||
|
||||
return new NewCustomerCouponRule
|
||||
{
|
||||
StoreId = storeId,
|
||||
Scene = scene,
|
||||
CouponType = couponType,
|
||||
Value = normalizedValue,
|
||||
MinimumSpend = minimumSpend,
|
||||
ValidDays = value.ValidDays,
|
||||
SortOrder = sortOrder
|
||||
};
|
||||
}
|
||||
|
||||
private static decimal? NormalizeNonNegativeMoney(decimal? value, string fieldName)
|
||||
{
|
||||
if (!value.HasValue)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
if (value.Value < 0m)
|
||||
{
|
||||
throw new BusinessException(ErrorCodes.BadRequest, $"{fieldName} 不能小于 0");
|
||||
}
|
||||
|
||||
return decimal.Round(value.Value, 2, MidpointRounding.AwayFromZero);
|
||||
}
|
||||
|
||||
private static decimal NormalizePositiveMoney(decimal? value, string fieldName)
|
||||
{
|
||||
if (!value.HasValue || value.Value <= 0m)
|
||||
{
|
||||
throw new BusinessException(ErrorCodes.BadRequest, $"{fieldName} 必须大于 0");
|
||||
}
|
||||
|
||||
return decimal.Round(value.Value, 2, MidpointRounding.AwayFromZero);
|
||||
}
|
||||
|
||||
private static decimal NormalizeDiscount(decimal? value)
|
||||
{
|
||||
if (!value.HasValue || value.Value <= 0m || value.Value >= 10m)
|
||||
{
|
||||
throw new BusinessException(ErrorCodes.BadRequest, "折扣券 value 必须在 0-10 之间");
|
||||
}
|
||||
|
||||
return decimal.Round(value.Value, 2, MidpointRounding.AwayFromZero);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
using MediatR;
|
||||
using TakeoutSaaS.Application.App.Coupons.NewCustomer.Dto;
|
||||
|
||||
namespace TakeoutSaaS.Application.App.Coupons.NewCustomer.Queries;
|
||||
|
||||
/// <summary>
|
||||
/// 查询新客有礼详情。
|
||||
/// </summary>
|
||||
public sealed class GetNewCustomerDetailQuery : IRequest<NewCustomerDetailDto>
|
||||
{
|
||||
/// <summary>
|
||||
/// 操作门店 ID。
|
||||
/// </summary>
|
||||
public long StoreId { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// 邀请记录页码。
|
||||
/// </summary>
|
||||
public int RecordPage { get; init; } = 1;
|
||||
|
||||
/// <summary>
|
||||
/// 邀请记录每页条数。
|
||||
/// </summary>
|
||||
public int RecordPageSize { get; init; } = 10;
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
using MediatR;
|
||||
using TakeoutSaaS.Application.App.Coupons.NewCustomer.Dto;
|
||||
|
||||
namespace TakeoutSaaS.Application.App.Coupons.NewCustomer.Queries;
|
||||
|
||||
/// <summary>
|
||||
/// 查询新客邀请记录分页。
|
||||
/// </summary>
|
||||
public sealed class GetNewCustomerInviteRecordListQuery : IRequest<NewCustomerInviteRecordListResultDto>
|
||||
{
|
||||
/// <summary>
|
||||
/// 门店 ID。
|
||||
/// </summary>
|
||||
public long StoreId { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// 页码。
|
||||
/// </summary>
|
||||
public int Page { get; init; } = 1;
|
||||
|
||||
/// <summary>
|
||||
/// 每页条数。
|
||||
/// </summary>
|
||||
public int PageSize { get; init; } = 10;
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
using MediatR;
|
||||
using TakeoutSaaS.Application.App.Coupons.PunchCard.Dto;
|
||||
|
||||
namespace TakeoutSaaS.Application.App.Coupons.PunchCard.Commands;
|
||||
|
||||
/// <summary>
|
||||
/// 修改次卡模板状态命令。
|
||||
/// </summary>
|
||||
public sealed class ChangePunchCardTemplateStatusCommand : IRequest<PunchCardDetailDto>
|
||||
{
|
||||
/// <summary>
|
||||
/// 操作门店 ID。
|
||||
/// </summary>
|
||||
public long StoreId { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// 次卡模板 ID。
|
||||
/// </summary>
|
||||
public long TemplateId { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// 状态(enabled/disabled)。
|
||||
/// </summary>
|
||||
public string Status { get; init; } = "disabled";
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
using MediatR;
|
||||
|
||||
namespace TakeoutSaaS.Application.App.Coupons.PunchCard.Commands;
|
||||
|
||||
/// <summary>
|
||||
/// 删除次卡模板命令。
|
||||
/// </summary>
|
||||
public sealed class DeletePunchCardTemplateCommand : IRequest
|
||||
{
|
||||
/// <summary>
|
||||
/// 操作门店 ID。
|
||||
/// </summary>
|
||||
public long StoreId { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// 次卡模板 ID。
|
||||
/// </summary>
|
||||
public long TemplateId { get; init; }
|
||||
}
|
||||
@@ -0,0 +1,130 @@
|
||||
using MediatR;
|
||||
using TakeoutSaaS.Application.App.Coupons.PunchCard.Dto;
|
||||
|
||||
namespace TakeoutSaaS.Application.App.Coupons.PunchCard.Commands;
|
||||
|
||||
/// <summary>
|
||||
/// 保存次卡模板命令。
|
||||
/// </summary>
|
||||
public sealed class SavePunchCardTemplateCommand : IRequest<PunchCardDetailDto>
|
||||
{
|
||||
/// <summary>
|
||||
/// 操作门店 ID。
|
||||
/// </summary>
|
||||
public long StoreId { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// 次卡模板 ID(编辑时传)。
|
||||
/// </summary>
|
||||
public long? TemplateId { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// 次卡名称。
|
||||
/// </summary>
|
||||
public string Name { get; init; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// 封面图。
|
||||
/// </summary>
|
||||
public string? CoverImageUrl { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// 售价。
|
||||
/// </summary>
|
||||
public decimal SalePrice { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// 原价。
|
||||
/// </summary>
|
||||
public decimal? OriginalPrice { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// 总次数。
|
||||
/// </summary>
|
||||
public int TotalTimes { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// 有效期类型(days/range)。
|
||||
/// </summary>
|
||||
public string ValidityType { get; init; } = "days";
|
||||
|
||||
/// <summary>
|
||||
/// 固定天数。
|
||||
/// </summary>
|
||||
public int? ValidityDays { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// 固定开始日期。
|
||||
/// </summary>
|
||||
public DateTime? ValidFrom { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// 固定结束日期。
|
||||
/// </summary>
|
||||
public DateTime? ValidTo { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// 范围类型(all/category/tag/product)。
|
||||
/// </summary>
|
||||
public string ScopeType { get; init; } = "all";
|
||||
|
||||
/// <summary>
|
||||
/// 指定分类 ID。
|
||||
/// </summary>
|
||||
public IReadOnlyCollection<long> ScopeCategoryIds { get; init; } = [];
|
||||
|
||||
/// <summary>
|
||||
/// 指定标签 ID。
|
||||
/// </summary>
|
||||
public IReadOnlyCollection<long> ScopeTagIds { get; init; } = [];
|
||||
|
||||
/// <summary>
|
||||
/// 指定商品 ID。
|
||||
/// </summary>
|
||||
public IReadOnlyCollection<long> ScopeProductIds { get; init; } = [];
|
||||
|
||||
/// <summary>
|
||||
/// 使用模式(free/cap)。
|
||||
/// </summary>
|
||||
public string UsageMode { get; init; } = "free";
|
||||
|
||||
/// <summary>
|
||||
/// 单次上限金额。
|
||||
/// </summary>
|
||||
public decimal? UsageCapAmount { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// 每日限用次数。
|
||||
/// </summary>
|
||||
public int? DailyLimit { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// 每单限用次数。
|
||||
/// </summary>
|
||||
public int? PerOrderLimit { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// 每人限购张数。
|
||||
/// </summary>
|
||||
public int? PerUserPurchaseLimit { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// 是否允许转赠。
|
||||
/// </summary>
|
||||
public bool AllowTransfer { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// 过期策略(invalidate/refund)。
|
||||
/// </summary>
|
||||
public string ExpireStrategy { get; init; } = "invalidate";
|
||||
|
||||
/// <summary>
|
||||
/// 次卡说明。
|
||||
/// </summary>
|
||||
public string? Description { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// 通知渠道(in_app/sms)。
|
||||
/// </summary>
|
||||
public IReadOnlyCollection<string> NotifyChannels { get; init; } = [];
|
||||
}
|
||||
@@ -0,0 +1,60 @@
|
||||
using MediatR;
|
||||
using TakeoutSaaS.Application.App.Coupons.PunchCard.Dto;
|
||||
|
||||
namespace TakeoutSaaS.Application.App.Coupons.PunchCard.Commands;
|
||||
|
||||
/// <summary>
|
||||
/// 写入次卡使用记录命令。
|
||||
/// </summary>
|
||||
public sealed class WritePunchCardUsageRecordCommand : IRequest<PunchCardUsageRecordDto>
|
||||
{
|
||||
/// <summary>
|
||||
/// 操作门店 ID。
|
||||
/// </summary>
|
||||
public long StoreId { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// 次卡模板 ID。
|
||||
/// </summary>
|
||||
public long TemplateId { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// 次卡实例 ID(可空)。
|
||||
/// </summary>
|
||||
public long? InstanceId { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// 次卡实例编号(可空)。
|
||||
/// </summary>
|
||||
public string? InstanceNo { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// 会员名称(当未指定实例时用于创建实例)。
|
||||
/// </summary>
|
||||
public string? MemberName { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// 会员手机号(脱敏,当未指定实例时用于创建实例)。
|
||||
/// </summary>
|
||||
public string? MemberPhoneMasked { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// 兑换商品名称。
|
||||
/// </summary>
|
||||
public string ProductName { get; init; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// 使用时间(可空,空则取当前 UTC)。
|
||||
/// </summary>
|
||||
public DateTime? UsedAt { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// 本次使用次数。
|
||||
/// </summary>
|
||||
public int UsedTimes { get; init; } = 1;
|
||||
|
||||
/// <summary>
|
||||
/// 超额补差金额。
|
||||
/// </summary>
|
||||
public decimal? ExtraPayAmount { get; init; }
|
||||
}
|
||||
@@ -0,0 +1,137 @@
|
||||
namespace TakeoutSaaS.Application.App.Coupons.PunchCard.Dto;
|
||||
|
||||
/// <summary>
|
||||
/// 次卡详情。
|
||||
/// </summary>
|
||||
public sealed class PunchCardDetailDto
|
||||
{
|
||||
/// <summary>
|
||||
/// 次卡 ID。
|
||||
/// </summary>
|
||||
public long Id { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// 门店 ID。
|
||||
/// </summary>
|
||||
public long StoreId { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// 次卡名称。
|
||||
/// </summary>
|
||||
public string Name { get; init; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// 封面图。
|
||||
/// </summary>
|
||||
public string? CoverImageUrl { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// 售价。
|
||||
/// </summary>
|
||||
public decimal SalePrice { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// 原价。
|
||||
/// </summary>
|
||||
public decimal? OriginalPrice { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// 总次数。
|
||||
/// </summary>
|
||||
public int TotalTimes { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// 有效期类型(days/range)。
|
||||
/// </summary>
|
||||
public string ValidityType { get; init; } = "days";
|
||||
|
||||
/// <summary>
|
||||
/// 固定天数。
|
||||
/// </summary>
|
||||
public int? ValidityDays { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// 固定开始日期(UTC)。
|
||||
/// </summary>
|
||||
public DateTime? ValidFrom { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// 固定结束日期(UTC)。
|
||||
/// </summary>
|
||||
public DateTime? ValidTo { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// 适用范围。
|
||||
/// </summary>
|
||||
public PunchCardScopeDto Scope { get; init; } = new();
|
||||
|
||||
/// <summary>
|
||||
/// 使用模式(free/cap)。
|
||||
/// </summary>
|
||||
public string UsageMode { get; init; } = "free";
|
||||
|
||||
/// <summary>
|
||||
/// 金额上限。
|
||||
/// </summary>
|
||||
public decimal? UsageCapAmount { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// 每日限用。
|
||||
/// </summary>
|
||||
public int? DailyLimit { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// 每单限用。
|
||||
/// </summary>
|
||||
public int? PerOrderLimit { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// 每人限购。
|
||||
/// </summary>
|
||||
public int? PerUserPurchaseLimit { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// 是否允许转赠。
|
||||
/// </summary>
|
||||
public bool AllowTransfer { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// 过期策略(invalidate/refund)。
|
||||
/// </summary>
|
||||
public string ExpireStrategy { get; init; } = "invalidate";
|
||||
|
||||
/// <summary>
|
||||
/// 描述。
|
||||
/// </summary>
|
||||
public string? Description { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// 通知渠道(in_app/sms)。
|
||||
/// </summary>
|
||||
public IReadOnlyList<string> NotifyChannels { get; init; } = [];
|
||||
|
||||
/// <summary>
|
||||
/// 状态(enabled/disabled)。
|
||||
/// </summary>
|
||||
public string Status { get; init; } = "enabled";
|
||||
|
||||
/// <summary>
|
||||
/// 已售数量。
|
||||
/// </summary>
|
||||
public int SoldCount { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// 使用中数量。
|
||||
/// </summary>
|
||||
public int ActiveCount { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// 累计收入。
|
||||
/// </summary>
|
||||
public decimal RevenueAmount { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// 更新时间。
|
||||
/// </summary>
|
||||
public DateTime UpdatedAt { get; init; }
|
||||
}
|
||||
@@ -0,0 +1,92 @@
|
||||
namespace TakeoutSaaS.Application.App.Coupons.PunchCard.Dto;
|
||||
|
||||
/// <summary>
|
||||
/// 次卡列表项。
|
||||
/// </summary>
|
||||
public sealed class PunchCardListItemDto
|
||||
{
|
||||
/// <summary>
|
||||
/// 次卡 ID。
|
||||
/// </summary>
|
||||
public long Id { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// 次卡名称。
|
||||
/// </summary>
|
||||
public string Name { get; init; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// 封面图。
|
||||
/// </summary>
|
||||
public string? CoverImageUrl { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// 售价。
|
||||
/// </summary>
|
||||
public decimal SalePrice { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// 原价。
|
||||
/// </summary>
|
||||
public decimal? OriginalPrice { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// 总次数。
|
||||
/// </summary>
|
||||
public int TotalTimes { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// 有效期展示文案。
|
||||
/// </summary>
|
||||
public string ValiditySummary { get; init; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// 适用范围类型(all/category/tag/product)。
|
||||
/// </summary>
|
||||
public string ScopeType { get; init; } = "all";
|
||||
|
||||
/// <summary>
|
||||
/// 使用模式(free/cap)。
|
||||
/// </summary>
|
||||
public string UsageMode { get; init; } = "free";
|
||||
|
||||
/// <summary>
|
||||
/// 单次使用上限金额。
|
||||
/// </summary>
|
||||
public decimal? UsageCapAmount { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// 每日限用次数。
|
||||
/// </summary>
|
||||
public int? DailyLimit { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// 状态(enabled/disabled)。
|
||||
/// </summary>
|
||||
public string Status { get; init; } = "enabled";
|
||||
|
||||
/// <summary>
|
||||
/// 是否弱化展示。
|
||||
/// </summary>
|
||||
public bool IsDimmed { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// 已售数量。
|
||||
/// </summary>
|
||||
public int SoldCount { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// 使用中数量。
|
||||
/// </summary>
|
||||
public int ActiveCount { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// 累计收入。
|
||||
/// </summary>
|
||||
public decimal RevenueAmount { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// 更新时间。
|
||||
/// </summary>
|
||||
public DateTime UpdatedAt { get; init; }
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
namespace TakeoutSaaS.Application.App.Coupons.PunchCard.Dto;
|
||||
|
||||
/// <summary>
|
||||
/// 次卡模板列表结果。
|
||||
/// </summary>
|
||||
public sealed class PunchCardListResultDto
|
||||
{
|
||||
/// <summary>
|
||||
/// 列表项。
|
||||
/// </summary>
|
||||
public IReadOnlyList<PunchCardListItemDto> Items { get; init; } = [];
|
||||
|
||||
/// <summary>
|
||||
/// 当前页。
|
||||
/// </summary>
|
||||
public int Page { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// 每页条数。
|
||||
/// </summary>
|
||||
public int PageSize { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// 总条数。
|
||||
/// </summary>
|
||||
public int TotalCount { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// 统计数据。
|
||||
/// </summary>
|
||||
public PunchCardStatsDto Stats { get; init; } = new();
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
namespace TakeoutSaaS.Application.App.Coupons.PunchCard.Dto;
|
||||
|
||||
/// <summary>
|
||||
/// 次卡范围规则。
|
||||
/// </summary>
|
||||
public sealed class PunchCardScopeDto
|
||||
{
|
||||
/// <summary>
|
||||
/// 范围类型(all/category/tag/product)。
|
||||
/// </summary>
|
||||
public string ScopeType { get; init; } = "all";
|
||||
|
||||
/// <summary>
|
||||
/// 指定分类 ID。
|
||||
/// </summary>
|
||||
public IReadOnlyList<long> CategoryIds { get; init; } = [];
|
||||
|
||||
/// <summary>
|
||||
/// 指定标签 ID。
|
||||
/// </summary>
|
||||
public IReadOnlyList<long> TagIds { get; init; } = [];
|
||||
|
||||
/// <summary>
|
||||
/// 指定商品 ID。
|
||||
/// </summary>
|
||||
public IReadOnlyList<long> ProductIds { get; init; } = [];
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
namespace TakeoutSaaS.Application.App.Coupons.PunchCard.Dto;
|
||||
|
||||
/// <summary>
|
||||
/// 次卡模板统计。
|
||||
/// </summary>
|
||||
public sealed class PunchCardStatsDto
|
||||
{
|
||||
/// <summary>
|
||||
/// 在售次卡数量。
|
||||
/// </summary>
|
||||
public int OnSaleCount { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// 累计售出数量。
|
||||
/// </summary>
|
||||
public int TotalSoldCount { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// 累计收入。
|
||||
/// </summary>
|
||||
public decimal TotalRevenueAmount { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// 使用中数量。
|
||||
/// </summary>
|
||||
public int ActiveInUseCount { get; init; }
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
namespace TakeoutSaaS.Application.App.Coupons.PunchCard.Dto;
|
||||
|
||||
/// <summary>
|
||||
/// 次卡过滤选项。
|
||||
/// </summary>
|
||||
public sealed class PunchCardTemplateOptionDto
|
||||
{
|
||||
/// <summary>
|
||||
/// 次卡模板 ID。
|
||||
/// </summary>
|
||||
public long TemplateId { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// 次卡名称。
|
||||
/// </summary>
|
||||
public string Name { get; init; } = string.Empty;
|
||||
}
|
||||
@@ -0,0 +1,77 @@
|
||||
namespace TakeoutSaaS.Application.App.Coupons.PunchCard.Dto;
|
||||
|
||||
/// <summary>
|
||||
/// 次卡使用记录项。
|
||||
/// </summary>
|
||||
public sealed class PunchCardUsageRecordDto
|
||||
{
|
||||
/// <summary>
|
||||
/// 使用记录 ID。
|
||||
/// </summary>
|
||||
public long Id { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// 使用单号。
|
||||
/// </summary>
|
||||
public string RecordNo { get; init; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// 次卡模板 ID。
|
||||
/// </summary>
|
||||
public long PunchCardTemplateId { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// 次卡名称。
|
||||
/// </summary>
|
||||
public string PunchCardName { get; init; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// 次卡实例 ID。
|
||||
/// </summary>
|
||||
public long PunchCardInstanceId { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// 会员名称。
|
||||
/// </summary>
|
||||
public string MemberName { get; init; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// 会员手机号(脱敏)。
|
||||
/// </summary>
|
||||
public string MemberPhoneMasked { get; init; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// 兑换商品名称。
|
||||
/// </summary>
|
||||
public string ProductName { get; init; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// 使用时间。
|
||||
/// </summary>
|
||||
public DateTime UsedAt { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// 本次使用次数。
|
||||
/// </summary>
|
||||
public int UsedTimes { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// 使用后剩余次数。
|
||||
/// </summary>
|
||||
public int RemainingTimesAfterUse { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// 总次数。
|
||||
/// </summary>
|
||||
public int TotalTimes { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// 状态(normal/almost_used_up/used_up/expired)。
|
||||
/// </summary>
|
||||
public string DisplayStatus { get; init; } = "normal";
|
||||
|
||||
/// <summary>
|
||||
/// 超额补差金额。
|
||||
/// </summary>
|
||||
public decimal? ExtraPayAmount { get; init; }
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
namespace TakeoutSaaS.Application.App.Coupons.PunchCard.Dto;
|
||||
|
||||
/// <summary>
|
||||
/// 次卡使用记录导出结果。
|
||||
/// </summary>
|
||||
public sealed class PunchCardUsageRecordExportDto
|
||||
{
|
||||
/// <summary>
|
||||
/// 文件名。
|
||||
/// </summary>
|
||||
public string FileName { get; init; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// 文件内容(Base64)。
|
||||
/// </summary>
|
||||
public string FileContentBase64 { get; init; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// 导出总条数。
|
||||
/// </summary>
|
||||
public int TotalCount { get; init; }
|
||||
}
|
||||
@@ -0,0 +1,37 @@
|
||||
namespace TakeoutSaaS.Application.App.Coupons.PunchCard.Dto;
|
||||
|
||||
/// <summary>
|
||||
/// 次卡使用记录分页结果。
|
||||
/// </summary>
|
||||
public sealed class PunchCardUsageRecordListResultDto
|
||||
{
|
||||
/// <summary>
|
||||
/// 列表数据。
|
||||
/// </summary>
|
||||
public IReadOnlyList<PunchCardUsageRecordDto> Items { get; init; } = [];
|
||||
|
||||
/// <summary>
|
||||
/// 页码。
|
||||
/// </summary>
|
||||
public int Page { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// 每页条数。
|
||||
/// </summary>
|
||||
public int PageSize { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// 总条数。
|
||||
/// </summary>
|
||||
public int TotalCount { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// 统计数据。
|
||||
/// </summary>
|
||||
public PunchCardUsageStatsDto Stats { get; init; } = new();
|
||||
|
||||
/// <summary>
|
||||
/// 次卡筛选选项。
|
||||
/// </summary>
|
||||
public IReadOnlyList<PunchCardTemplateOptionDto> TemplateOptions { get; init; } = [];
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
namespace TakeoutSaaS.Application.App.Coupons.PunchCard.Dto;
|
||||
|
||||
/// <summary>
|
||||
/// 次卡使用记录统计。
|
||||
/// </summary>
|
||||
public sealed class PunchCardUsageStatsDto
|
||||
{
|
||||
/// <summary>
|
||||
/// 今日使用次数。
|
||||
/// </summary>
|
||||
public int TodayUsedCount { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// 本月使用次数。
|
||||
/// </summary>
|
||||
public int MonthUsedCount { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// 7 天内即将过期数量。
|
||||
/// </summary>
|
||||
public int ExpiringSoonCount { get; init; }
|
||||
}
|
||||
@@ -0,0 +1,51 @@
|
||||
using MediatR;
|
||||
using TakeoutSaaS.Application.App.Coupons.PunchCard.Commands;
|
||||
using TakeoutSaaS.Application.App.Coupons.PunchCard.Dto;
|
||||
using TakeoutSaaS.Domain.Coupons.Repositories;
|
||||
using TakeoutSaaS.Shared.Abstractions.Constants;
|
||||
using TakeoutSaaS.Shared.Abstractions.Exceptions;
|
||||
using TakeoutSaaS.Shared.Abstractions.Tenancy;
|
||||
|
||||
namespace TakeoutSaaS.Application.App.Coupons.PunchCard.Handlers;
|
||||
|
||||
/// <summary>
|
||||
/// 次卡状态变更处理器。
|
||||
/// </summary>
|
||||
public sealed class ChangePunchCardTemplateStatusCommandHandler(
|
||||
IPunchCardRepository repository,
|
||||
ITenantProvider tenantProvider)
|
||||
: IRequestHandler<ChangePunchCardTemplateStatusCommand, PunchCardDetailDto>
|
||||
{
|
||||
/// <inheritdoc />
|
||||
public async Task<PunchCardDetailDto> Handle(
|
||||
ChangePunchCardTemplateStatusCommand request,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var tenantId = tenantProvider.GetCurrentTenantId();
|
||||
var normalizedStatus = PunchCardMapping.ParseTemplateStatus(request.Status);
|
||||
|
||||
var entity = await repository.FindTemplateByIdAsync(
|
||||
tenantId,
|
||||
request.StoreId,
|
||||
request.TemplateId,
|
||||
cancellationToken)
|
||||
?? throw new BusinessException(ErrorCodes.NotFound, "次卡不存在");
|
||||
|
||||
entity.Status = normalizedStatus;
|
||||
|
||||
await repository.UpdateTemplateAsync(entity, cancellationToken);
|
||||
await repository.SaveChangesAsync(cancellationToken);
|
||||
|
||||
var aggregateMap = await repository.GetTemplateAggregateByTemplateIdsAsync(
|
||||
tenantId,
|
||||
request.StoreId,
|
||||
[entity.Id],
|
||||
cancellationToken);
|
||||
|
||||
var aggregate = aggregateMap.TryGetValue(entity.Id, out var value)
|
||||
? value
|
||||
: PunchCardDtoFactory.EmptyAggregate(entity.Id);
|
||||
|
||||
return PunchCardDtoFactory.ToDetailDto(entity, aggregate);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,43 @@
|
||||
using MediatR;
|
||||
using TakeoutSaaS.Application.App.Coupons.PunchCard.Commands;
|
||||
using TakeoutSaaS.Domain.Coupons.Repositories;
|
||||
using TakeoutSaaS.Shared.Abstractions.Constants;
|
||||
using TakeoutSaaS.Shared.Abstractions.Exceptions;
|
||||
using TakeoutSaaS.Shared.Abstractions.Tenancy;
|
||||
|
||||
namespace TakeoutSaaS.Application.App.Coupons.PunchCard.Handlers;
|
||||
|
||||
/// <summary>
|
||||
/// 删除次卡模板处理器。
|
||||
/// </summary>
|
||||
public sealed class DeletePunchCardTemplateCommandHandler(
|
||||
IPunchCardRepository repository,
|
||||
ITenantProvider tenantProvider)
|
||||
: IRequestHandler<DeletePunchCardTemplateCommand>
|
||||
{
|
||||
/// <inheritdoc />
|
||||
public async Task Handle(DeletePunchCardTemplateCommand request, CancellationToken cancellationToken)
|
||||
{
|
||||
var tenantId = tenantProvider.GetCurrentTenantId();
|
||||
var entity = await repository.FindTemplateByIdAsync(
|
||||
tenantId,
|
||||
request.StoreId,
|
||||
request.TemplateId,
|
||||
cancellationToken)
|
||||
?? throw new BusinessException(ErrorCodes.NotFound, "次卡不存在");
|
||||
|
||||
var aggregate = await repository.GetTemplateAggregateByTemplateIdsAsync(
|
||||
tenantId,
|
||||
request.StoreId,
|
||||
[entity.Id],
|
||||
cancellationToken);
|
||||
|
||||
if (aggregate.TryGetValue(entity.Id, out var snapshot) && snapshot.SoldCount > 0)
|
||||
{
|
||||
throw new BusinessException(ErrorCodes.BadRequest, "已售出的次卡不可删除");
|
||||
}
|
||||
|
||||
await repository.DeleteTemplateAsync(entity, cancellationToken);
|
||||
await repository.SaveChangesAsync(cancellationToken);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,128 @@
|
||||
using System.Text;
|
||||
using MediatR;
|
||||
using TakeoutSaaS.Application.App.Coupons.PunchCard.Dto;
|
||||
using TakeoutSaaS.Application.App.Coupons.PunchCard.Queries;
|
||||
using TakeoutSaaS.Domain.Coupons.Repositories;
|
||||
using TakeoutSaaS.Shared.Abstractions.Tenancy;
|
||||
|
||||
namespace TakeoutSaaS.Application.App.Coupons.PunchCard.Handlers;
|
||||
|
||||
/// <summary>
|
||||
/// 导出次卡使用记录处理器。
|
||||
/// </summary>
|
||||
public sealed class ExportPunchCardUsageRecordCsvQueryHandler(
|
||||
IPunchCardRepository repository,
|
||||
ITenantProvider tenantProvider)
|
||||
: IRequestHandler<ExportPunchCardUsageRecordCsvQuery, PunchCardUsageRecordExportDto>
|
||||
{
|
||||
/// <inheritdoc />
|
||||
public async Task<PunchCardUsageRecordExportDto> Handle(
|
||||
ExportPunchCardUsageRecordCsvQuery request,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var tenantId = tenantProvider.GetCurrentTenantId();
|
||||
var normalizedStatus = PunchCardMapping.ParseUsageStatusFilter(request.Status);
|
||||
|
||||
var records = await repository.ListUsageRecordsForExportAsync(
|
||||
tenantId,
|
||||
request.StoreId,
|
||||
request.TemplateId,
|
||||
request.Keyword,
|
||||
normalizedStatus,
|
||||
cancellationToken);
|
||||
|
||||
if (records.Count == 0)
|
||||
{
|
||||
return new PunchCardUsageRecordExportDto
|
||||
{
|
||||
FileName = $"次卡使用记录_{DateTime.UtcNow:yyyyMMddHHmmss}.csv",
|
||||
FileContentBase64 = Convert.ToBase64String(Encoding.UTF8.GetBytes("\uFEFF使用单号,会员,手机号,次卡,兑换商品,使用时间,剩余次数,总次数,状态\n")),
|
||||
TotalCount = 0
|
||||
};
|
||||
}
|
||||
|
||||
var instanceIds = records.Select(item => item.PunchCardInstanceId).Distinct().ToList();
|
||||
var instances = await repository.GetInstancesByIdsAsync(
|
||||
tenantId,
|
||||
request.StoreId,
|
||||
instanceIds,
|
||||
cancellationToken);
|
||||
|
||||
var instanceMap = instances.ToDictionary(item => item.Id, item => item);
|
||||
|
||||
var templateIds = records.Select(item => item.PunchCardTemplateId)
|
||||
.Concat(instances.Select(item => item.PunchCardTemplateId))
|
||||
.Distinct()
|
||||
.ToList();
|
||||
|
||||
var templates = await repository.GetTemplatesByIdsAsync(
|
||||
tenantId,
|
||||
request.StoreId,
|
||||
templateIds,
|
||||
cancellationToken);
|
||||
|
||||
var templateMap = templates.ToDictionary(item => item.Id, item => item);
|
||||
|
||||
var csv = BuildCsv(records, instanceMap, templateMap);
|
||||
var bytes = Encoding.UTF8.GetBytes($"\uFEFF{csv}");
|
||||
|
||||
return new PunchCardUsageRecordExportDto
|
||||
{
|
||||
FileName = $"次卡使用记录_{DateTime.UtcNow:yyyyMMddHHmmss}.csv",
|
||||
FileContentBase64 = Convert.ToBase64String(bytes),
|
||||
TotalCount = records.Count
|
||||
};
|
||||
}
|
||||
|
||||
private static string BuildCsv(
|
||||
IReadOnlyCollection<Domain.Coupons.Entities.PunchCardUsageRecord> records,
|
||||
IReadOnlyDictionary<long, Domain.Coupons.Entities.PunchCardInstance> instanceMap,
|
||||
IReadOnlyDictionary<long, Domain.Coupons.Entities.PunchCardTemplate> templateMap)
|
||||
{
|
||||
var lines = new List<string>
|
||||
{
|
||||
"使用单号,会员,手机号,次卡,兑换商品,使用时间,剩余次数,总次数,状态"
|
||||
};
|
||||
|
||||
var nowUtc = DateTime.UtcNow;
|
||||
foreach (var record in records)
|
||||
{
|
||||
instanceMap.TryGetValue(record.PunchCardInstanceId, out var instance);
|
||||
templateMap.TryGetValue(record.PunchCardTemplateId, out var template);
|
||||
|
||||
var dto = PunchCardDtoFactory.ToUsageRecordDto(record, instance, template, nowUtc);
|
||||
var statusText = ResolveStatusText(dto.DisplayStatus);
|
||||
|
||||
lines.Add(string.Join(",",
|
||||
Escape(dto.RecordNo),
|
||||
Escape(dto.MemberName),
|
||||
Escape(dto.MemberPhoneMasked),
|
||||
Escape(dto.PunchCardName),
|
||||
Escape(dto.ProductName),
|
||||
Escape(dto.UsedAt.ToString("yyyy-MM-dd HH:mm:ss")),
|
||||
dto.RemainingTimesAfterUse,
|
||||
dto.TotalTimes,
|
||||
Escape(statusText)));
|
||||
}
|
||||
|
||||
return string.Join('\n', lines);
|
||||
}
|
||||
|
||||
private static string ResolveStatusText(string value)
|
||||
{
|
||||
return value switch
|
||||
{
|
||||
"normal" => "正常使用",
|
||||
"almost_used_up" => "即将用完",
|
||||
"used_up" => "已用完",
|
||||
"expired" => "已过期",
|
||||
_ => "正常使用"
|
||||
};
|
||||
}
|
||||
|
||||
private static string Escape(string value)
|
||||
{
|
||||
var text = value.Replace("\"", "\"\"");
|
||||
return $"\"{text}\"";
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,47 @@
|
||||
using MediatR;
|
||||
using TakeoutSaaS.Application.App.Coupons.PunchCard.Dto;
|
||||
using TakeoutSaaS.Application.App.Coupons.PunchCard.Queries;
|
||||
using TakeoutSaaS.Domain.Coupons.Repositories;
|
||||
using TakeoutSaaS.Shared.Abstractions.Tenancy;
|
||||
|
||||
namespace TakeoutSaaS.Application.App.Coupons.PunchCard.Handlers;
|
||||
|
||||
/// <summary>
|
||||
/// 次卡模板详情查询处理器。
|
||||
/// </summary>
|
||||
public sealed class GetPunchCardTemplateDetailQueryHandler(
|
||||
IPunchCardRepository repository,
|
||||
ITenantProvider tenantProvider)
|
||||
: IRequestHandler<GetPunchCardTemplateDetailQuery, PunchCardDetailDto?>
|
||||
{
|
||||
/// <inheritdoc />
|
||||
public async Task<PunchCardDetailDto?> Handle(
|
||||
GetPunchCardTemplateDetailQuery request,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var tenantId = tenantProvider.GetCurrentTenantId();
|
||||
|
||||
var template = await repository.FindTemplateByIdAsync(
|
||||
tenantId,
|
||||
request.StoreId,
|
||||
request.TemplateId,
|
||||
cancellationToken);
|
||||
|
||||
if (template is null)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
var aggregate = await repository.GetTemplateAggregateByTemplateIdsAsync(
|
||||
tenantId,
|
||||
request.StoreId,
|
||||
[request.TemplateId],
|
||||
cancellationToken);
|
||||
|
||||
var snapshot = aggregate.TryGetValue(template.Id, out var value)
|
||||
? value
|
||||
: PunchCardDtoFactory.EmptyAggregate(template.Id);
|
||||
|
||||
return PunchCardDtoFactory.ToDetailDto(template, snapshot);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,67 @@
|
||||
using MediatR;
|
||||
using TakeoutSaaS.Application.App.Coupons.PunchCard.Dto;
|
||||
using TakeoutSaaS.Application.App.Coupons.PunchCard.Queries;
|
||||
using TakeoutSaaS.Domain.Coupons.Repositories;
|
||||
using TakeoutSaaS.Shared.Abstractions.Tenancy;
|
||||
|
||||
namespace TakeoutSaaS.Application.App.Coupons.PunchCard.Handlers;
|
||||
|
||||
/// <summary>
|
||||
/// 次卡模板列表查询处理器。
|
||||
/// </summary>
|
||||
public sealed class GetPunchCardTemplateListQueryHandler(
|
||||
IPunchCardRepository repository,
|
||||
ITenantProvider tenantProvider)
|
||||
: IRequestHandler<GetPunchCardTemplateListQuery, PunchCardListResultDto>
|
||||
{
|
||||
/// <inheritdoc />
|
||||
public async Task<PunchCardListResultDto> Handle(
|
||||
GetPunchCardTemplateListQuery request,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var tenantId = tenantProvider.GetCurrentTenantId();
|
||||
var page = Math.Max(1, request.Page);
|
||||
var pageSize = Math.Clamp(request.PageSize, 1, 200);
|
||||
var status = PunchCardMapping.ParseTemplateStatusFilter(request.Status);
|
||||
|
||||
var (items, totalCount) = await repository.SearchTemplatesAsync(
|
||||
tenantId,
|
||||
request.StoreId,
|
||||
request.Keyword,
|
||||
status,
|
||||
page,
|
||||
pageSize,
|
||||
cancellationToken);
|
||||
|
||||
var templateIds = items.Select(item => item.Id).ToList();
|
||||
var aggregates = await repository.GetTemplateAggregateByTemplateIdsAsync(
|
||||
tenantId,
|
||||
request.StoreId,
|
||||
templateIds,
|
||||
cancellationToken);
|
||||
|
||||
var mappedItems = items
|
||||
.Select(item =>
|
||||
{
|
||||
var aggregate = aggregates.TryGetValue(item.Id, out var value)
|
||||
? value
|
||||
: PunchCardDtoFactory.EmptyAggregate(item.Id);
|
||||
return PunchCardDtoFactory.ToListItemDto(item, aggregate);
|
||||
})
|
||||
.ToList();
|
||||
|
||||
var statsSnapshot = await repository.GetTemplateStatsAsync(
|
||||
tenantId,
|
||||
request.StoreId,
|
||||
cancellationToken);
|
||||
|
||||
return new PunchCardListResultDto
|
||||
{
|
||||
Items = mappedItems,
|
||||
Page = page,
|
||||
PageSize = pageSize,
|
||||
TotalCount = totalCount,
|
||||
Stats = PunchCardDtoFactory.ToStatsDto(statsSnapshot)
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,104 @@
|
||||
using MediatR;
|
||||
using TakeoutSaaS.Application.App.Coupons.PunchCard.Dto;
|
||||
using TakeoutSaaS.Application.App.Coupons.PunchCard.Queries;
|
||||
using TakeoutSaaS.Domain.Coupons.Repositories;
|
||||
using TakeoutSaaS.Shared.Abstractions.Tenancy;
|
||||
|
||||
namespace TakeoutSaaS.Application.App.Coupons.PunchCard.Handlers;
|
||||
|
||||
/// <summary>
|
||||
/// 次卡使用记录列表查询处理器。
|
||||
/// </summary>
|
||||
public sealed class GetPunchCardUsageRecordListQueryHandler(
|
||||
IPunchCardRepository repository,
|
||||
ITenantProvider tenantProvider)
|
||||
: IRequestHandler<GetPunchCardUsageRecordListQuery, PunchCardUsageRecordListResultDto>
|
||||
{
|
||||
/// <inheritdoc />
|
||||
public async Task<PunchCardUsageRecordListResultDto> Handle(
|
||||
GetPunchCardUsageRecordListQuery request,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var tenantId = tenantProvider.GetCurrentTenantId();
|
||||
var page = Math.Max(1, request.Page);
|
||||
var pageSize = Math.Clamp(request.PageSize, 1, 500);
|
||||
var normalizedStatus = PunchCardMapping.ParseUsageStatusFilter(request.Status);
|
||||
|
||||
var (records, totalCount) = await repository.SearchUsageRecordsAsync(
|
||||
tenantId,
|
||||
request.StoreId,
|
||||
request.TemplateId,
|
||||
request.Keyword,
|
||||
normalizedStatus,
|
||||
page,
|
||||
pageSize,
|
||||
cancellationToken);
|
||||
|
||||
var instanceIds = records.Select(item => item.PunchCardInstanceId).Distinct().ToList();
|
||||
var instances = await repository.GetInstancesByIdsAsync(
|
||||
tenantId,
|
||||
request.StoreId,
|
||||
instanceIds,
|
||||
cancellationToken);
|
||||
|
||||
var instanceMap = instances.ToDictionary(item => item.Id, item => item);
|
||||
|
||||
var templateIds = records.Select(item => item.PunchCardTemplateId)
|
||||
.Concat(instances.Select(item => item.PunchCardTemplateId))
|
||||
.Distinct()
|
||||
.ToList();
|
||||
|
||||
var templates = await repository.GetTemplatesByIdsAsync(
|
||||
tenantId,
|
||||
request.StoreId,
|
||||
templateIds,
|
||||
cancellationToken);
|
||||
|
||||
var templateMap = templates.ToDictionary(item => item.Id, item => item);
|
||||
|
||||
var nowUtc = DateTime.UtcNow;
|
||||
var mappedRecords = records
|
||||
.Select(record =>
|
||||
{
|
||||
instanceMap.TryGetValue(record.PunchCardInstanceId, out var instance);
|
||||
templateMap.TryGetValue(record.PunchCardTemplateId, out var template);
|
||||
return PunchCardDtoFactory.ToUsageRecordDto(record, instance, template, nowUtc);
|
||||
})
|
||||
.ToList();
|
||||
|
||||
var usageStats = await repository.GetUsageStatsAsync(
|
||||
tenantId,
|
||||
request.StoreId,
|
||||
request.TemplateId,
|
||||
nowUtc,
|
||||
cancellationToken);
|
||||
|
||||
var (templateRows, _) = await repository.SearchTemplatesAsync(
|
||||
tenantId,
|
||||
request.StoreId,
|
||||
null,
|
||||
null,
|
||||
1,
|
||||
500,
|
||||
cancellationToken);
|
||||
|
||||
var templateOptions = templateRows
|
||||
.OrderBy(item => item.Name, StringComparer.Ordinal)
|
||||
.Select(item => new PunchCardTemplateOptionDto
|
||||
{
|
||||
TemplateId = item.Id,
|
||||
Name = item.Name
|
||||
})
|
||||
.ToList();
|
||||
|
||||
return new PunchCardUsageRecordListResultDto
|
||||
{
|
||||
Items = mappedRecords,
|
||||
Page = page,
|
||||
PageSize = pageSize,
|
||||
TotalCount = totalCount,
|
||||
Stats = PunchCardDtoFactory.ToUsageStatsDto(usageStats),
|
||||
TemplateOptions = templateOptions
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,158 @@
|
||||
using MediatR;
|
||||
using TakeoutSaaS.Application.App.Coupons.PunchCard.Commands;
|
||||
using TakeoutSaaS.Application.App.Coupons.PunchCard.Dto;
|
||||
using TakeoutSaaS.Domain.Coupons.Enums;
|
||||
using TakeoutSaaS.Domain.Coupons.Repositories;
|
||||
using TakeoutSaaS.Shared.Abstractions.Constants;
|
||||
using TakeoutSaaS.Shared.Abstractions.Exceptions;
|
||||
using TakeoutSaaS.Shared.Abstractions.Tenancy;
|
||||
|
||||
namespace TakeoutSaaS.Application.App.Coupons.PunchCard.Handlers;
|
||||
|
||||
/// <summary>
|
||||
/// 次卡模板保存处理器。
|
||||
/// </summary>
|
||||
public sealed class SavePunchCardTemplateCommandHandler(
|
||||
IPunchCardRepository repository,
|
||||
ITenantProvider tenantProvider)
|
||||
: IRequestHandler<SavePunchCardTemplateCommand, PunchCardDetailDto>
|
||||
{
|
||||
/// <inheritdoc />
|
||||
public async Task<PunchCardDetailDto> Handle(
|
||||
SavePunchCardTemplateCommand request,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var tenantId = tenantProvider.GetCurrentTenantId();
|
||||
|
||||
var normalizedName = PunchCardMapping.NormalizeName(request.Name);
|
||||
var normalizedCoverImageUrl = PunchCardMapping.NormalizeOptionalCoverUrl(request.CoverImageUrl);
|
||||
var normalizedSalePrice = PunchCardMapping.NormalizeAmount(request.SalePrice, "salePrice", false);
|
||||
var normalizedOriginalPrice = PunchCardMapping.NormalizeOptionalAmount(request.OriginalPrice, "originalPrice", true);
|
||||
var normalizedTotalTimes = PunchCardMapping.NormalizeRequiredPositiveInt(request.TotalTimes, "totalTimes", 10_000);
|
||||
|
||||
if (normalizedOriginalPrice.HasValue && normalizedOriginalPrice.Value < normalizedSalePrice)
|
||||
{
|
||||
throw new BusinessException(ErrorCodes.BadRequest, "originalPrice 不能小于 salePrice");
|
||||
}
|
||||
|
||||
var validityType = PunchCardMapping.ParseValidityType(request.ValidityType);
|
||||
var (normalizedValidityDays, normalizedValidFrom, normalizedValidTo) = PunchCardMapping.NormalizeValidity(
|
||||
validityType,
|
||||
request.ValidityDays,
|
||||
request.ValidFrom,
|
||||
request.ValidTo);
|
||||
|
||||
var scopeType = PunchCardMapping.ParseScopeType(request.ScopeType);
|
||||
var (normalizedCategoryIds, normalizedTagIds, normalizedProductIds) = PunchCardMapping.NormalizeScopeIds(
|
||||
scopeType,
|
||||
request.ScopeCategoryIds,
|
||||
request.ScopeTagIds,
|
||||
request.ScopeProductIds);
|
||||
|
||||
var usageMode = PunchCardMapping.ParseUsageMode(request.UsageMode);
|
||||
var normalizedUsageCapAmount = usageMode switch
|
||||
{
|
||||
PunchCardUsageMode.Free => null,
|
||||
PunchCardUsageMode.Cap => PunchCardMapping.NormalizeOptionalAmount(request.UsageCapAmount, "usageCapAmount", false),
|
||||
_ => null
|
||||
};
|
||||
|
||||
if (usageMode == PunchCardUsageMode.Cap && !normalizedUsageCapAmount.HasValue)
|
||||
{
|
||||
throw new BusinessException(ErrorCodes.BadRequest, "usageCapAmount 不能为空");
|
||||
}
|
||||
|
||||
var normalizedDailyLimit = PunchCardMapping.NormalizeOptionalLimit(request.DailyLimit, "dailyLimit", normalizedTotalTimes);
|
||||
var normalizedPerOrderLimit = PunchCardMapping.NormalizeOptionalLimit(request.PerOrderLimit, "perOrderLimit", normalizedTotalTimes);
|
||||
var normalizedPerUserPurchaseLimit = PunchCardMapping.NormalizeOptionalLimit(request.PerUserPurchaseLimit, "perUserPurchaseLimit", 1000);
|
||||
|
||||
var expireStrategy = PunchCardMapping.ParseExpireStrategy(request.ExpireStrategy);
|
||||
var normalizedDescription = PunchCardMapping.NormalizeOptionalDescription(request.Description);
|
||||
var normalizedNotifyChannelsJson = PunchCardMapping.SerializeNotifyChannels(request.NotifyChannels);
|
||||
|
||||
var normalizedCategoryIdsJson = PunchCardMapping.SerializeSnowflakeIds(normalizedCategoryIds);
|
||||
var normalizedTagIdsJson = PunchCardMapping.SerializeSnowflakeIds(normalizedTagIds);
|
||||
var normalizedProductIdsJson = PunchCardMapping.SerializeSnowflakeIds(normalizedProductIds);
|
||||
|
||||
if (!request.TemplateId.HasValue)
|
||||
{
|
||||
var newEntity = PunchCardDtoFactory.CreateTemplateEntity(
|
||||
request,
|
||||
normalizedName,
|
||||
normalizedCoverImageUrl,
|
||||
normalizedSalePrice,
|
||||
normalizedOriginalPrice,
|
||||
normalizedTotalTimes,
|
||||
validityType,
|
||||
normalizedValidityDays,
|
||||
normalizedValidFrom,
|
||||
normalizedValidTo,
|
||||
scopeType,
|
||||
normalizedCategoryIdsJson,
|
||||
normalizedTagIdsJson,
|
||||
normalizedProductIdsJson,
|
||||
usageMode,
|
||||
normalizedUsageCapAmount,
|
||||
normalizedDailyLimit,
|
||||
normalizedPerOrderLimit,
|
||||
normalizedPerUserPurchaseLimit,
|
||||
expireStrategy,
|
||||
normalizedDescription,
|
||||
normalizedNotifyChannelsJson);
|
||||
|
||||
await repository.AddTemplateAsync(newEntity, cancellationToken);
|
||||
await repository.SaveChangesAsync(cancellationToken);
|
||||
|
||||
return PunchCardDtoFactory.ToDetailDto(
|
||||
newEntity,
|
||||
PunchCardDtoFactory.EmptyAggregate(newEntity.Id));
|
||||
}
|
||||
|
||||
var entity = await repository.FindTemplateByIdAsync(
|
||||
tenantId,
|
||||
request.StoreId,
|
||||
request.TemplateId.Value,
|
||||
cancellationToken)
|
||||
?? throw new BusinessException(ErrorCodes.NotFound, "次卡不存在");
|
||||
|
||||
entity.Name = normalizedName;
|
||||
entity.CoverImageUrl = string.IsNullOrWhiteSpace(normalizedCoverImageUrl)
|
||||
? null
|
||||
: normalizedCoverImageUrl;
|
||||
entity.SalePrice = normalizedSalePrice;
|
||||
entity.OriginalPrice = normalizedOriginalPrice;
|
||||
entity.TotalTimes = normalizedTotalTimes;
|
||||
entity.ValidityType = validityType;
|
||||
entity.ValidityDays = normalizedValidityDays;
|
||||
entity.ValidFrom = normalizedValidFrom;
|
||||
entity.ValidTo = normalizedValidTo;
|
||||
entity.ScopeType = scopeType;
|
||||
entity.ScopeCategoryIdsJson = normalizedCategoryIdsJson;
|
||||
entity.ScopeTagIdsJson = normalizedTagIdsJson;
|
||||
entity.ScopeProductIdsJson = normalizedProductIdsJson;
|
||||
entity.UsageMode = usageMode;
|
||||
entity.UsageCapAmount = normalizedUsageCapAmount;
|
||||
entity.DailyLimit = normalizedDailyLimit;
|
||||
entity.PerOrderLimit = normalizedPerOrderLimit;
|
||||
entity.PerUserPurchaseLimit = normalizedPerUserPurchaseLimit;
|
||||
entity.AllowTransfer = request.AllowTransfer;
|
||||
entity.ExpireStrategy = expireStrategy;
|
||||
entity.Description = normalizedDescription;
|
||||
entity.NotifyChannelsJson = normalizedNotifyChannelsJson;
|
||||
|
||||
await repository.UpdateTemplateAsync(entity, cancellationToken);
|
||||
await repository.SaveChangesAsync(cancellationToken);
|
||||
|
||||
var aggregateMap = await repository.GetTemplateAggregateByTemplateIdsAsync(
|
||||
tenantId,
|
||||
request.StoreId,
|
||||
[entity.Id],
|
||||
cancellationToken);
|
||||
|
||||
var aggregate = aggregateMap.TryGetValue(entity.Id, out var value)
|
||||
? value
|
||||
: PunchCardDtoFactory.EmptyAggregate(entity.Id);
|
||||
|
||||
return PunchCardDtoFactory.ToDetailDto(entity, aggregate);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,160 @@
|
||||
using MediatR;
|
||||
using TakeoutSaaS.Application.App.Coupons.PunchCard.Commands;
|
||||
using TakeoutSaaS.Application.App.Coupons.PunchCard.Dto;
|
||||
using TakeoutSaaS.Domain.Coupons.Entities;
|
||||
using TakeoutSaaS.Domain.Coupons.Enums;
|
||||
using TakeoutSaaS.Domain.Coupons.Repositories;
|
||||
using TakeoutSaaS.Shared.Abstractions.Constants;
|
||||
using TakeoutSaaS.Shared.Abstractions.Exceptions;
|
||||
using TakeoutSaaS.Shared.Abstractions.Tenancy;
|
||||
|
||||
namespace TakeoutSaaS.Application.App.Coupons.PunchCard.Handlers;
|
||||
|
||||
/// <summary>
|
||||
/// 写入次卡使用记录处理器。
|
||||
/// </summary>
|
||||
public sealed class WritePunchCardUsageRecordCommandHandler(
|
||||
IPunchCardRepository repository,
|
||||
ITenantProvider tenantProvider)
|
||||
: IRequestHandler<WritePunchCardUsageRecordCommand, PunchCardUsageRecordDto>
|
||||
{
|
||||
/// <inheritdoc />
|
||||
public async Task<PunchCardUsageRecordDto> Handle(
|
||||
WritePunchCardUsageRecordCommand request,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var tenantId = tenantProvider.GetCurrentTenantId();
|
||||
var usedAt = request.UsedAt.HasValue
|
||||
? PunchCardMapping.NormalizeUtc(request.UsedAt.Value)
|
||||
: DateTime.UtcNow;
|
||||
|
||||
var template = await repository.FindTemplateByIdAsync(
|
||||
tenantId,
|
||||
request.StoreId,
|
||||
request.TemplateId,
|
||||
cancellationToken)
|
||||
?? throw new BusinessException(ErrorCodes.NotFound, "次卡不存在");
|
||||
|
||||
if (template.Status != PunchCardStatus.Enabled)
|
||||
{
|
||||
throw new BusinessException(ErrorCodes.BadRequest, "次卡已下架,无法使用");
|
||||
}
|
||||
|
||||
var productName = PunchCardMapping.NormalizeProductName(request.ProductName);
|
||||
var usedTimes = PunchCardMapping.NormalizeRequiredPositiveInt(request.UsedTimes, "usedTimes", template.TotalTimes);
|
||||
var extraPayAmount = PunchCardMapping.NormalizeOptionalAmount(request.ExtraPayAmount, "extraPayAmount", true);
|
||||
|
||||
PunchCardInstance? instance = null;
|
||||
if (request.InstanceId.HasValue && request.InstanceId.Value > 0)
|
||||
{
|
||||
instance = await repository.FindInstanceByIdAsync(
|
||||
tenantId,
|
||||
request.StoreId,
|
||||
request.InstanceId.Value,
|
||||
cancellationToken);
|
||||
}
|
||||
else if (!string.IsNullOrWhiteSpace(request.InstanceNo))
|
||||
{
|
||||
var normalizedInstanceNo = PunchCardMapping.NormalizeInstanceNo(request.InstanceNo);
|
||||
instance = await repository.FindInstanceByNoAsync(
|
||||
tenantId,
|
||||
request.StoreId,
|
||||
normalizedInstanceNo,
|
||||
cancellationToken);
|
||||
}
|
||||
|
||||
if (instance is not null && instance.PunchCardTemplateId != template.Id)
|
||||
{
|
||||
throw new BusinessException(ErrorCodes.BadRequest, "次卡实例与模板不匹配");
|
||||
}
|
||||
|
||||
var isNewInstance = false;
|
||||
if (instance is null)
|
||||
{
|
||||
var memberName = PunchCardMapping.NormalizeMemberName(request.MemberName);
|
||||
var memberPhoneMasked = PunchCardMapping.NormalizeMemberPhoneMasked(request.MemberPhoneMasked);
|
||||
var purchasedAt = usedAt;
|
||||
|
||||
instance = new PunchCardInstance
|
||||
{
|
||||
StoreId = request.StoreId,
|
||||
PunchCardTemplateId = template.Id,
|
||||
InstanceNo = PunchCardDtoFactory.GenerateInstanceNo(usedAt),
|
||||
MemberName = memberName,
|
||||
MemberPhoneMasked = memberPhoneMasked,
|
||||
PurchasedAt = purchasedAt,
|
||||
ExpiresAt = PunchCardMapping.ResolveInstanceExpireAt(template, purchasedAt),
|
||||
TotalTimes = template.TotalTimes,
|
||||
RemainingTimes = template.TotalTimes,
|
||||
PaidAmount = template.SalePrice,
|
||||
Status = PunchCardInstanceStatus.Active
|
||||
};
|
||||
isNewInstance = true;
|
||||
}
|
||||
|
||||
if (PunchCardMapping.IsInstanceExpired(instance, usedAt))
|
||||
{
|
||||
throw new BusinessException(ErrorCodes.BadRequest, "次卡已过期");
|
||||
}
|
||||
|
||||
if (instance.Status == PunchCardInstanceStatus.Refunded)
|
||||
{
|
||||
throw new BusinessException(ErrorCodes.BadRequest, "次卡已退款");
|
||||
}
|
||||
|
||||
if (instance.RemainingTimes <= 0 || instance.Status == PunchCardInstanceStatus.UsedUp)
|
||||
{
|
||||
throw new BusinessException(ErrorCodes.BadRequest, "次卡已用完");
|
||||
}
|
||||
|
||||
if (template.PerOrderLimit.HasValue && usedTimes > template.PerOrderLimit.Value)
|
||||
{
|
||||
throw new BusinessException(ErrorCodes.BadRequest, "超出每单限用次数");
|
||||
}
|
||||
|
||||
if (usedTimes > instance.RemainingTimes)
|
||||
{
|
||||
throw new BusinessException(ErrorCodes.BadRequest, "超出次卡剩余次数");
|
||||
}
|
||||
|
||||
var remainingTimes = instance.RemainingTimes - usedTimes;
|
||||
var statusAfterUse = PunchCardMapping.ResolveUsageRecordStatus(instance, remainingTimes, usedAt);
|
||||
|
||||
instance.RemainingTimes = remainingTimes;
|
||||
instance.Status = statusAfterUse switch
|
||||
{
|
||||
PunchCardUsageRecordStatus.UsedUp => PunchCardInstanceStatus.UsedUp,
|
||||
PunchCardUsageRecordStatus.Expired => PunchCardInstanceStatus.Expired,
|
||||
_ => PunchCardInstanceStatus.Active
|
||||
};
|
||||
|
||||
if (isNewInstance)
|
||||
{
|
||||
await repository.AddInstanceAsync(instance, cancellationToken);
|
||||
await repository.SaveChangesAsync(cancellationToken);
|
||||
}
|
||||
else
|
||||
{
|
||||
await repository.UpdateInstanceAsync(instance, cancellationToken);
|
||||
}
|
||||
|
||||
var record = new PunchCardUsageRecord
|
||||
{
|
||||
StoreId = request.StoreId,
|
||||
PunchCardTemplateId = template.Id,
|
||||
PunchCardInstanceId = instance.Id,
|
||||
RecordNo = PunchCardDtoFactory.GenerateRecordNo(usedAt),
|
||||
ProductName = productName,
|
||||
UsedAt = usedAt,
|
||||
UsedTimes = usedTimes,
|
||||
RemainingTimesAfterUse = remainingTimes,
|
||||
StatusAfterUse = statusAfterUse,
|
||||
ExtraPayAmount = extraPayAmount
|
||||
};
|
||||
|
||||
await repository.AddUsageRecordAsync(record, cancellationToken);
|
||||
await repository.SaveChangesAsync(cancellationToken);
|
||||
|
||||
return PunchCardDtoFactory.ToUsageRecordDto(record, instance, template, usedAt);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,210 @@
|
||||
using TakeoutSaaS.Application.App.Coupons.PunchCard.Commands;
|
||||
using TakeoutSaaS.Application.App.Coupons.PunchCard.Dto;
|
||||
using TakeoutSaaS.Domain.Coupons.Entities;
|
||||
using TakeoutSaaS.Domain.Coupons.Enums;
|
||||
using TakeoutSaaS.Domain.Coupons.Repositories;
|
||||
|
||||
namespace TakeoutSaaS.Application.App.Coupons.PunchCard;
|
||||
|
||||
/// <summary>
|
||||
/// 次卡 DTO 构造器。
|
||||
/// </summary>
|
||||
internal static class PunchCardDtoFactory
|
||||
{
|
||||
public static PunchCardTemplateAggregateSnapshot EmptyAggregate(long templateId)
|
||||
{
|
||||
return new PunchCardTemplateAggregateSnapshot
|
||||
{
|
||||
TemplateId = templateId,
|
||||
SoldCount = 0,
|
||||
ActiveCount = 0,
|
||||
RevenueAmount = 0m
|
||||
};
|
||||
}
|
||||
|
||||
public static PunchCardListItemDto ToListItemDto(
|
||||
PunchCardTemplate template,
|
||||
PunchCardTemplateAggregateSnapshot aggregate)
|
||||
{
|
||||
return new PunchCardListItemDto
|
||||
{
|
||||
Id = template.Id,
|
||||
Name = template.Name,
|
||||
CoverImageUrl = template.CoverImageUrl,
|
||||
SalePrice = template.SalePrice,
|
||||
OriginalPrice = template.OriginalPrice,
|
||||
TotalTimes = template.TotalTimes,
|
||||
ValiditySummary = PunchCardMapping.BuildValiditySummary(template),
|
||||
ScopeType = PunchCardMapping.ToScopeTypeText(template.ScopeType),
|
||||
UsageMode = PunchCardMapping.ToUsageModeText(template.UsageMode),
|
||||
UsageCapAmount = template.UsageCapAmount,
|
||||
DailyLimit = template.DailyLimit,
|
||||
Status = PunchCardMapping.ToTemplateStatusText(template.Status),
|
||||
IsDimmed = template.Status == PunchCardStatus.Disabled,
|
||||
SoldCount = aggregate.SoldCount,
|
||||
ActiveCount = aggregate.ActiveCount,
|
||||
RevenueAmount = decimal.Round(aggregate.RevenueAmount, 2, MidpointRounding.AwayFromZero),
|
||||
UpdatedAt = template.UpdatedAt ?? template.CreatedAt
|
||||
};
|
||||
}
|
||||
|
||||
public static PunchCardDetailDto ToDetailDto(
|
||||
PunchCardTemplate template,
|
||||
PunchCardTemplateAggregateSnapshot aggregate)
|
||||
{
|
||||
return new PunchCardDetailDto
|
||||
{
|
||||
Id = template.Id,
|
||||
StoreId = template.StoreId,
|
||||
Name = template.Name,
|
||||
CoverImageUrl = template.CoverImageUrl,
|
||||
SalePrice = template.SalePrice,
|
||||
OriginalPrice = template.OriginalPrice,
|
||||
TotalTimes = template.TotalTimes,
|
||||
ValidityType = PunchCardMapping.ToValidityTypeText(template.ValidityType),
|
||||
ValidityDays = template.ValidityDays,
|
||||
ValidFrom = template.ValidFrom,
|
||||
ValidTo = template.ValidTo,
|
||||
Scope = new PunchCardScopeDto
|
||||
{
|
||||
ScopeType = PunchCardMapping.ToScopeTypeText(template.ScopeType),
|
||||
CategoryIds = PunchCardMapping.DeserializeSnowflakeIds(template.ScopeCategoryIdsJson),
|
||||
TagIds = PunchCardMapping.DeserializeSnowflakeIds(template.ScopeTagIdsJson),
|
||||
ProductIds = PunchCardMapping.DeserializeSnowflakeIds(template.ScopeProductIdsJson)
|
||||
},
|
||||
UsageMode = PunchCardMapping.ToUsageModeText(template.UsageMode),
|
||||
UsageCapAmount = template.UsageCapAmount,
|
||||
DailyLimit = template.DailyLimit,
|
||||
PerOrderLimit = template.PerOrderLimit,
|
||||
PerUserPurchaseLimit = template.PerUserPurchaseLimit,
|
||||
AllowTransfer = template.AllowTransfer,
|
||||
ExpireStrategy = PunchCardMapping.ToExpireStrategyText(template.ExpireStrategy),
|
||||
Description = template.Description,
|
||||
NotifyChannels = PunchCardMapping.DeserializeNotifyChannels(template.NotifyChannelsJson),
|
||||
Status = PunchCardMapping.ToTemplateStatusText(template.Status),
|
||||
SoldCount = aggregate.SoldCount,
|
||||
ActiveCount = aggregate.ActiveCount,
|
||||
RevenueAmount = decimal.Round(aggregate.RevenueAmount, 2, MidpointRounding.AwayFromZero),
|
||||
UpdatedAt = template.UpdatedAt ?? template.CreatedAt
|
||||
};
|
||||
}
|
||||
|
||||
public static PunchCardStatsDto ToStatsDto(PunchCardTemplateStatsSnapshot source)
|
||||
{
|
||||
return new PunchCardStatsDto
|
||||
{
|
||||
OnSaleCount = source.OnSaleCount,
|
||||
TotalSoldCount = source.TotalSoldCount,
|
||||
TotalRevenueAmount = decimal.Round(source.TotalRevenueAmount, 2, MidpointRounding.AwayFromZero),
|
||||
ActiveInUseCount = source.ActiveInUseCount
|
||||
};
|
||||
}
|
||||
|
||||
public static PunchCardUsageStatsDto ToUsageStatsDto(PunchCardUsageStatsSnapshot source)
|
||||
{
|
||||
return new PunchCardUsageStatsDto
|
||||
{
|
||||
TodayUsedCount = source.TodayUsedCount,
|
||||
MonthUsedCount = source.MonthUsedCount,
|
||||
ExpiringSoonCount = source.ExpiringSoonCount
|
||||
};
|
||||
}
|
||||
|
||||
public static PunchCardUsageRecordDto ToUsageRecordDto(
|
||||
PunchCardUsageRecord record,
|
||||
PunchCardInstance? instance,
|
||||
PunchCardTemplate? template,
|
||||
DateTime nowUtc)
|
||||
{
|
||||
var resolvedTotalTimes = instance?.TotalTimes ?? template?.TotalTimes ?? 0;
|
||||
var status = record.StatusAfterUse;
|
||||
|
||||
if (instance is not null)
|
||||
{
|
||||
status = PunchCardMapping.ResolveUsageRecordStatus(instance, record.RemainingTimesAfterUse, nowUtc);
|
||||
}
|
||||
|
||||
return new PunchCardUsageRecordDto
|
||||
{
|
||||
Id = record.Id,
|
||||
RecordNo = record.RecordNo,
|
||||
PunchCardTemplateId = record.PunchCardTemplateId,
|
||||
PunchCardName = template?.Name ?? string.Empty,
|
||||
PunchCardInstanceId = record.PunchCardInstanceId,
|
||||
MemberName = instance?.MemberName ?? string.Empty,
|
||||
MemberPhoneMasked = instance?.MemberPhoneMasked ?? string.Empty,
|
||||
ProductName = record.ProductName,
|
||||
UsedAt = record.UsedAt,
|
||||
UsedTimes = record.UsedTimes,
|
||||
RemainingTimesAfterUse = record.RemainingTimesAfterUse,
|
||||
TotalTimes = resolvedTotalTimes,
|
||||
DisplayStatus = PunchCardMapping.ToUsageDisplayStatusText(status),
|
||||
ExtraPayAmount = record.ExtraPayAmount
|
||||
};
|
||||
}
|
||||
|
||||
public static PunchCardTemplate CreateTemplateEntity(
|
||||
SavePunchCardTemplateCommand request,
|
||||
string normalizedName,
|
||||
string normalizedCoverImageUrl,
|
||||
decimal normalizedSalePrice,
|
||||
decimal? normalizedOriginalPrice,
|
||||
int normalizedTotalTimes,
|
||||
PunchCardValidityType validityType,
|
||||
int? normalizedValidityDays,
|
||||
DateTime? normalizedValidFrom,
|
||||
DateTime? normalizedValidTo,
|
||||
PunchCardScopeType scopeType,
|
||||
string normalizedCategoryIdsJson,
|
||||
string normalizedTagIdsJson,
|
||||
string normalizedProductIdsJson,
|
||||
PunchCardUsageMode usageMode,
|
||||
decimal? normalizedUsageCapAmount,
|
||||
int? normalizedDailyLimit,
|
||||
int? normalizedPerOrderLimit,
|
||||
int? normalizedPerUserPurchaseLimit,
|
||||
PunchCardExpireStrategy expireStrategy,
|
||||
string? normalizedDescription,
|
||||
string normalizedNotifyChannelsJson)
|
||||
{
|
||||
return new PunchCardTemplate
|
||||
{
|
||||
StoreId = request.StoreId,
|
||||
Name = normalizedName,
|
||||
CoverImageUrl = string.IsNullOrWhiteSpace(normalizedCoverImageUrl)
|
||||
? null
|
||||
: normalizedCoverImageUrl,
|
||||
SalePrice = normalizedSalePrice,
|
||||
OriginalPrice = normalizedOriginalPrice,
|
||||
TotalTimes = normalizedTotalTimes,
|
||||
ValidityType = validityType,
|
||||
ValidityDays = normalizedValidityDays,
|
||||
ValidFrom = normalizedValidFrom,
|
||||
ValidTo = normalizedValidTo,
|
||||
ScopeType = scopeType,
|
||||
ScopeCategoryIdsJson = normalizedCategoryIdsJson,
|
||||
ScopeTagIdsJson = normalizedTagIdsJson,
|
||||
ScopeProductIdsJson = normalizedProductIdsJson,
|
||||
UsageMode = usageMode,
|
||||
UsageCapAmount = normalizedUsageCapAmount,
|
||||
DailyLimit = normalizedDailyLimit,
|
||||
PerOrderLimit = normalizedPerOrderLimit,
|
||||
PerUserPurchaseLimit = normalizedPerUserPurchaseLimit,
|
||||
AllowTransfer = request.AllowTransfer,
|
||||
ExpireStrategy = expireStrategy,
|
||||
Description = normalizedDescription,
|
||||
NotifyChannelsJson = normalizedNotifyChannelsJson,
|
||||
Status = PunchCardStatus.Enabled
|
||||
};
|
||||
}
|
||||
|
||||
public static string GenerateInstanceNo(DateTime nowUtc)
|
||||
{
|
||||
return $"PKI{nowUtc:yyyyMMddHHmmssfff}{Random.Shared.Next(1000, 9999)}";
|
||||
}
|
||||
|
||||
public static string GenerateRecordNo(DateTime nowUtc)
|
||||
{
|
||||
return $"PK{nowUtc:yyyyMMddHHmmssfff}{Random.Shared.Next(1000, 9999)}";
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,546 @@
|
||||
using System.Text.Json;
|
||||
using TakeoutSaaS.Application.App.Coupons.PunchCard.Dto;
|
||||
using TakeoutSaaS.Domain.Coupons.Entities;
|
||||
using TakeoutSaaS.Domain.Coupons.Enums;
|
||||
using TakeoutSaaS.Domain.Coupons.Repositories;
|
||||
using TakeoutSaaS.Shared.Abstractions.Constants;
|
||||
using TakeoutSaaS.Shared.Abstractions.Exceptions;
|
||||
|
||||
namespace TakeoutSaaS.Application.App.Coupons.PunchCard;
|
||||
|
||||
/// <summary>
|
||||
/// 次卡模块映射与标准化。
|
||||
/// </summary>
|
||||
internal static class PunchCardMapping
|
||||
{
|
||||
private static readonly HashSet<string> AllowedNotifyChannels =
|
||||
[
|
||||
"in_app",
|
||||
"sms"
|
||||
];
|
||||
|
||||
private static readonly JsonSerializerOptions JsonOptions = new(JsonSerializerDefaults.Web)
|
||||
{
|
||||
PropertyNameCaseInsensitive = true
|
||||
};
|
||||
|
||||
public static PunchCardStatus? ParseTemplateStatusFilter(string? value)
|
||||
{
|
||||
var normalized = (value ?? string.Empty).Trim().ToLowerInvariant();
|
||||
return normalized switch
|
||||
{
|
||||
"" => null,
|
||||
"enabled" => PunchCardStatus.Enabled,
|
||||
"disabled" => PunchCardStatus.Disabled,
|
||||
_ => throw new BusinessException(ErrorCodes.BadRequest, "status 参数不合法")
|
||||
};
|
||||
}
|
||||
|
||||
public static PunchCardStatus ParseTemplateStatus(string? value)
|
||||
{
|
||||
var normalized = (value ?? string.Empty).Trim().ToLowerInvariant();
|
||||
return normalized switch
|
||||
{
|
||||
"enabled" => PunchCardStatus.Enabled,
|
||||
"disabled" => PunchCardStatus.Disabled,
|
||||
_ => throw new BusinessException(ErrorCodes.BadRequest, "status 参数不合法")
|
||||
};
|
||||
}
|
||||
|
||||
public static string ToTemplateStatusText(PunchCardStatus value)
|
||||
{
|
||||
return value switch
|
||||
{
|
||||
PunchCardStatus.Enabled => "enabled",
|
||||
PunchCardStatus.Disabled => "disabled",
|
||||
_ => "disabled"
|
||||
};
|
||||
}
|
||||
|
||||
public static PunchCardValidityType ParseValidityType(string? value)
|
||||
{
|
||||
var normalized = (value ?? string.Empty).Trim().ToLowerInvariant();
|
||||
return normalized switch
|
||||
{
|
||||
"days" => PunchCardValidityType.Days,
|
||||
"range" => PunchCardValidityType.DateRange,
|
||||
_ => throw new BusinessException(ErrorCodes.BadRequest, "validityType 参数不合法")
|
||||
};
|
||||
}
|
||||
|
||||
public static string ToValidityTypeText(PunchCardValidityType value)
|
||||
{
|
||||
return value switch
|
||||
{
|
||||
PunchCardValidityType.Days => "days",
|
||||
PunchCardValidityType.DateRange => "range",
|
||||
_ => "days"
|
||||
};
|
||||
}
|
||||
|
||||
public static PunchCardScopeType ParseScopeType(string? value)
|
||||
{
|
||||
var normalized = (value ?? string.Empty).Trim().ToLowerInvariant();
|
||||
return normalized switch
|
||||
{
|
||||
"all" => PunchCardScopeType.All,
|
||||
"category" => PunchCardScopeType.Category,
|
||||
"tag" => PunchCardScopeType.Tag,
|
||||
"product" => PunchCardScopeType.Product,
|
||||
_ => throw new BusinessException(ErrorCodes.BadRequest, "scopeType 参数不合法")
|
||||
};
|
||||
}
|
||||
|
||||
public static string ToScopeTypeText(PunchCardScopeType value)
|
||||
{
|
||||
return value switch
|
||||
{
|
||||
PunchCardScopeType.All => "all",
|
||||
PunchCardScopeType.Category => "category",
|
||||
PunchCardScopeType.Tag => "tag",
|
||||
PunchCardScopeType.Product => "product",
|
||||
_ => "all"
|
||||
};
|
||||
}
|
||||
|
||||
public static PunchCardUsageMode ParseUsageMode(string? value)
|
||||
{
|
||||
var normalized = (value ?? string.Empty).Trim().ToLowerInvariant();
|
||||
return normalized switch
|
||||
{
|
||||
"free" => PunchCardUsageMode.Free,
|
||||
"cap" => PunchCardUsageMode.Cap,
|
||||
_ => throw new BusinessException(ErrorCodes.BadRequest, "usageMode 参数不合法")
|
||||
};
|
||||
}
|
||||
|
||||
public static string ToUsageModeText(PunchCardUsageMode value)
|
||||
{
|
||||
return value switch
|
||||
{
|
||||
PunchCardUsageMode.Free => "free",
|
||||
PunchCardUsageMode.Cap => "cap",
|
||||
_ => "free"
|
||||
};
|
||||
}
|
||||
|
||||
public static PunchCardExpireStrategy ParseExpireStrategy(string? value)
|
||||
{
|
||||
var normalized = (value ?? string.Empty).Trim().ToLowerInvariant();
|
||||
return normalized switch
|
||||
{
|
||||
"invalidate" => PunchCardExpireStrategy.Invalidate,
|
||||
"refund" => PunchCardExpireStrategy.Refund,
|
||||
_ => throw new BusinessException(ErrorCodes.BadRequest, "expireStrategy 参数不合法")
|
||||
};
|
||||
}
|
||||
|
||||
public static string ToExpireStrategyText(PunchCardExpireStrategy value)
|
||||
{
|
||||
return value switch
|
||||
{
|
||||
PunchCardExpireStrategy.Invalidate => "invalidate",
|
||||
PunchCardExpireStrategy.Refund => "refund",
|
||||
_ => "invalidate"
|
||||
};
|
||||
}
|
||||
|
||||
public static PunchCardUsageRecordFilterStatus? ParseUsageStatusFilter(string? value)
|
||||
{
|
||||
var normalized = (value ?? string.Empty).Trim().ToLowerInvariant();
|
||||
return normalized switch
|
||||
{
|
||||
"" => null,
|
||||
"normal" => PunchCardUsageRecordFilterStatus.Normal,
|
||||
"used_up" => PunchCardUsageRecordFilterStatus.UsedUp,
|
||||
"expired" => PunchCardUsageRecordFilterStatus.Expired,
|
||||
_ => throw new BusinessException(ErrorCodes.BadRequest, "status 参数不合法")
|
||||
};
|
||||
}
|
||||
|
||||
public static string ToUsageDisplayStatusText(PunchCardUsageRecordStatus value)
|
||||
{
|
||||
return value switch
|
||||
{
|
||||
PunchCardUsageRecordStatus.Normal => "normal",
|
||||
PunchCardUsageRecordStatus.AlmostUsedUp => "almost_used_up",
|
||||
PunchCardUsageRecordStatus.UsedUp => "used_up",
|
||||
PunchCardUsageRecordStatus.Expired => "expired",
|
||||
_ => "normal"
|
||||
};
|
||||
}
|
||||
|
||||
public static DateTime NormalizeUtc(DateTime value)
|
||||
{
|
||||
return value.Kind switch
|
||||
{
|
||||
DateTimeKind.Utc => value,
|
||||
DateTimeKind.Local => value.ToUniversalTime(),
|
||||
_ => DateTime.SpecifyKind(value, DateTimeKind.Utc)
|
||||
};
|
||||
}
|
||||
|
||||
public static string NormalizeName(string? value)
|
||||
{
|
||||
var normalized = (value ?? string.Empty).Trim();
|
||||
if (string.IsNullOrWhiteSpace(normalized))
|
||||
{
|
||||
throw new BusinessException(ErrorCodes.BadRequest, "name 不能为空");
|
||||
}
|
||||
|
||||
if (normalized.Length > 64)
|
||||
{
|
||||
throw new BusinessException(ErrorCodes.BadRequest, "name 长度不能超过 64");
|
||||
}
|
||||
|
||||
return normalized;
|
||||
}
|
||||
|
||||
public static string NormalizeOptionalCoverUrl(string? value)
|
||||
{
|
||||
var normalized = (value ?? string.Empty).Trim();
|
||||
if (string.IsNullOrWhiteSpace(normalized))
|
||||
{
|
||||
return string.Empty;
|
||||
}
|
||||
|
||||
if (normalized.Length > 512)
|
||||
{
|
||||
throw new BusinessException(ErrorCodes.BadRequest, "coverImageUrl 长度不能超过 512");
|
||||
}
|
||||
|
||||
return normalized;
|
||||
}
|
||||
|
||||
public static string? NormalizeOptionalDescription(string? value)
|
||||
{
|
||||
var normalized = (value ?? string.Empty).Trim();
|
||||
if (normalized.Length == 0)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
if (normalized.Length > 512)
|
||||
{
|
||||
throw new BusinessException(ErrorCodes.BadRequest, "description 长度不能超过 512");
|
||||
}
|
||||
|
||||
return normalized;
|
||||
}
|
||||
|
||||
public static string NormalizeInstanceNo(string? value)
|
||||
{
|
||||
var normalized = (value ?? string.Empty).Trim();
|
||||
if (string.IsNullOrWhiteSpace(normalized))
|
||||
{
|
||||
throw new BusinessException(ErrorCodes.BadRequest, "punchCardInstanceNo 不能为空");
|
||||
}
|
||||
|
||||
if (normalized.Length > 32)
|
||||
{
|
||||
throw new BusinessException(ErrorCodes.BadRequest, "punchCardInstanceNo 长度不能超过 32");
|
||||
}
|
||||
|
||||
return normalized;
|
||||
}
|
||||
|
||||
public static string NormalizeMemberName(string? value)
|
||||
{
|
||||
var normalized = (value ?? string.Empty).Trim();
|
||||
if (string.IsNullOrWhiteSpace(normalized))
|
||||
{
|
||||
throw new BusinessException(ErrorCodes.BadRequest, "memberName 不能为空");
|
||||
}
|
||||
|
||||
if (normalized.Length > 64)
|
||||
{
|
||||
throw new BusinessException(ErrorCodes.BadRequest, "memberName 长度不能超过 64");
|
||||
}
|
||||
|
||||
return normalized;
|
||||
}
|
||||
|
||||
public static string NormalizeMemberPhoneMasked(string? value)
|
||||
{
|
||||
var normalized = (value ?? string.Empty).Trim();
|
||||
if (string.IsNullOrWhiteSpace(normalized))
|
||||
{
|
||||
throw new BusinessException(ErrorCodes.BadRequest, "memberPhoneMasked 不能为空");
|
||||
}
|
||||
|
||||
if (normalized.Length > 32)
|
||||
{
|
||||
throw new BusinessException(ErrorCodes.BadRequest, "memberPhoneMasked 长度不能超过 32");
|
||||
}
|
||||
|
||||
return normalized;
|
||||
}
|
||||
|
||||
public static string NormalizeProductName(string? value)
|
||||
{
|
||||
var normalized = (value ?? string.Empty).Trim();
|
||||
if (string.IsNullOrWhiteSpace(normalized))
|
||||
{
|
||||
throw new BusinessException(ErrorCodes.BadRequest, "productName 不能为空");
|
||||
}
|
||||
|
||||
if (normalized.Length > 128)
|
||||
{
|
||||
throw new BusinessException(ErrorCodes.BadRequest, "productName 长度不能超过 128");
|
||||
}
|
||||
|
||||
return normalized;
|
||||
}
|
||||
|
||||
public static decimal NormalizeAmount(decimal value, string fieldName, bool allowZero = false)
|
||||
{
|
||||
if (value < 0 || (!allowZero && value <= 0))
|
||||
{
|
||||
throw new BusinessException(ErrorCodes.BadRequest, $"{fieldName} 参数不合法");
|
||||
}
|
||||
|
||||
return decimal.Round(value, 2, MidpointRounding.AwayFromZero);
|
||||
}
|
||||
|
||||
public static decimal? NormalizeOptionalAmount(decimal? value, string fieldName, bool allowZero = true)
|
||||
{
|
||||
if (!value.HasValue)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
if (value.Value < 0 || (!allowZero && value.Value <= 0))
|
||||
{
|
||||
throw new BusinessException(ErrorCodes.BadRequest, $"{fieldName} 参数不合法");
|
||||
}
|
||||
|
||||
return decimal.Round(value.Value, 2, MidpointRounding.AwayFromZero);
|
||||
}
|
||||
|
||||
public static int NormalizeRequiredPositiveInt(int value, string fieldName, int max = 100_000)
|
||||
{
|
||||
if (value <= 0 || value > max)
|
||||
{
|
||||
throw new BusinessException(ErrorCodes.BadRequest, $"{fieldName} 参数不合法");
|
||||
}
|
||||
|
||||
return value;
|
||||
}
|
||||
|
||||
public static int? NormalizeOptionalLimit(int? value, string fieldName, int max = 100_000)
|
||||
{
|
||||
if (!value.HasValue || value.Value <= 0)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
if (value.Value > max)
|
||||
{
|
||||
throw new BusinessException(ErrorCodes.BadRequest, $"{fieldName} 参数不合法");
|
||||
}
|
||||
|
||||
return value;
|
||||
}
|
||||
|
||||
public static (int? ValidityDays, DateTime? ValidFrom, DateTime? ValidTo) NormalizeValidity(
|
||||
PunchCardValidityType validityType,
|
||||
int? validityDays,
|
||||
DateTime? validFrom,
|
||||
DateTime? validTo)
|
||||
{
|
||||
return validityType switch
|
||||
{
|
||||
PunchCardValidityType.Days =>
|
||||
(
|
||||
NormalizeRequiredPositiveInt(validityDays ?? 0, "validityDays", 3650),
|
||||
null,
|
||||
null
|
||||
),
|
||||
PunchCardValidityType.DateRange => NormalizeRange(validFrom, validTo),
|
||||
_ => throw new BusinessException(ErrorCodes.BadRequest, "validityType 参数不合法")
|
||||
};
|
||||
}
|
||||
|
||||
public static (IReadOnlyList<long> CategoryIds, IReadOnlyList<long> TagIds, IReadOnlyList<long> ProductIds) NormalizeScopeIds(
|
||||
PunchCardScopeType scopeType,
|
||||
IReadOnlyCollection<long>? categoryIds,
|
||||
IReadOnlyCollection<long>? tagIds,
|
||||
IReadOnlyCollection<long>? productIds)
|
||||
{
|
||||
var normalizedCategoryIds = NormalizeSnowflakeIds(categoryIds, "scopeCategoryIds", false);
|
||||
var normalizedTagIds = NormalizeSnowflakeIds(tagIds, "scopeTagIds", false);
|
||||
var normalizedProductIds = NormalizeSnowflakeIds(productIds, "scopeProductIds", false);
|
||||
|
||||
return scopeType switch
|
||||
{
|
||||
PunchCardScopeType.All => ([], [], []),
|
||||
PunchCardScopeType.Category =>
|
||||
normalizedCategoryIds.Count == 0
|
||||
? throw new BusinessException(ErrorCodes.BadRequest, "scopeCategoryIds 不能为空")
|
||||
: (normalizedCategoryIds, [], []),
|
||||
PunchCardScopeType.Tag =>
|
||||
normalizedTagIds.Count == 0
|
||||
? throw new BusinessException(ErrorCodes.BadRequest, "scopeTagIds 不能为空")
|
||||
: ([], normalizedTagIds, []),
|
||||
PunchCardScopeType.Product =>
|
||||
normalizedProductIds.Count == 0
|
||||
? throw new BusinessException(ErrorCodes.BadRequest, "scopeProductIds 不能为空")
|
||||
: ([], [], normalizedProductIds),
|
||||
_ => throw new BusinessException(ErrorCodes.BadRequest, "scopeType 参数不合法")
|
||||
};
|
||||
}
|
||||
|
||||
public static IReadOnlyList<string> NormalizeNotifyChannels(IEnumerable<string>? values)
|
||||
{
|
||||
var normalized = (values ?? [])
|
||||
.Select(item => (item ?? string.Empty).Trim().ToLowerInvariant())
|
||||
.Where(item => !string.IsNullOrWhiteSpace(item))
|
||||
.Distinct()
|
||||
.ToList();
|
||||
|
||||
if (normalized.Count == 0)
|
||||
{
|
||||
throw new BusinessException(ErrorCodes.BadRequest, "notifyChannels 不能为空");
|
||||
}
|
||||
|
||||
if (normalized.Any(item => !AllowedNotifyChannels.Contains(item)))
|
||||
{
|
||||
throw new BusinessException(ErrorCodes.BadRequest, "notifyChannels 存在非法值");
|
||||
}
|
||||
|
||||
return normalized;
|
||||
}
|
||||
|
||||
public static IReadOnlyList<string> DeserializeNotifyChannels(string? payload)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(payload))
|
||||
{
|
||||
return [];
|
||||
}
|
||||
|
||||
var values = JsonSerializer.Deserialize<List<string>>(payload, JsonOptions) ?? [];
|
||||
return values
|
||||
.Select(item => (item ?? string.Empty).Trim().ToLowerInvariant())
|
||||
.Where(item => AllowedNotifyChannels.Contains(item))
|
||||
.Distinct()
|
||||
.ToList();
|
||||
}
|
||||
|
||||
public static string SerializeNotifyChannels(IEnumerable<string>? values)
|
||||
{
|
||||
return JsonSerializer.Serialize(NormalizeNotifyChannels(values), JsonOptions);
|
||||
}
|
||||
|
||||
public static IReadOnlyList<long> DeserializeSnowflakeIds(string? payload)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(payload))
|
||||
{
|
||||
return [];
|
||||
}
|
||||
|
||||
var values = JsonSerializer.Deserialize<List<long>>(payload, JsonOptions) ?? [];
|
||||
return values
|
||||
.Where(id => id > 0)
|
||||
.Distinct()
|
||||
.OrderBy(id => id)
|
||||
.ToList();
|
||||
}
|
||||
|
||||
public static string SerializeSnowflakeIds(IEnumerable<long>? values)
|
||||
{
|
||||
return JsonSerializer.Serialize(NormalizeSnowflakeIds(values, "ids", false), JsonOptions);
|
||||
}
|
||||
|
||||
public static string BuildValiditySummary(PunchCardTemplate template)
|
||||
{
|
||||
return template.ValidityType switch
|
||||
{
|
||||
PunchCardValidityType.Days => $"{template.ValidityDays ?? 0}天有效",
|
||||
PunchCardValidityType.DateRange when template.ValidFrom.HasValue && template.ValidTo.HasValue =>
|
||||
$"{template.ValidFrom.Value:yyyy-MM-dd} 至 {template.ValidTo.Value:yyyy-MM-dd}",
|
||||
_ => "-"
|
||||
};
|
||||
}
|
||||
|
||||
public static DateTime ResolveInstanceExpireAt(PunchCardTemplate template, DateTime purchasedAtUtc)
|
||||
{
|
||||
var purchasedAt = NormalizeUtc(purchasedAtUtc);
|
||||
|
||||
return template.ValidityType switch
|
||||
{
|
||||
PunchCardValidityType.Days => purchasedAt.Date.AddDays(template.ValidityDays ?? 0).AddTicks(-1),
|
||||
PunchCardValidityType.DateRange => template.ValidTo ?? purchasedAt.Date.AddTicks(-1),
|
||||
_ => purchasedAt.Date.AddTicks(-1)
|
||||
};
|
||||
}
|
||||
|
||||
public static bool IsInstanceExpired(PunchCardInstance instance, DateTime nowUtc)
|
||||
{
|
||||
var utcNow = NormalizeUtc(nowUtc);
|
||||
if (instance.Status == PunchCardInstanceStatus.Expired)
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
return instance.ExpiresAt.HasValue && instance.ExpiresAt.Value < utcNow;
|
||||
}
|
||||
|
||||
public static PunchCardUsageRecordStatus ResolveUsageRecordStatus(
|
||||
PunchCardInstance instance,
|
||||
int remainingTimes,
|
||||
DateTime usedAtUtc)
|
||||
{
|
||||
if (IsInstanceExpired(instance, usedAtUtc))
|
||||
{
|
||||
return PunchCardUsageRecordStatus.Expired;
|
||||
}
|
||||
|
||||
if (remainingTimes <= 0)
|
||||
{
|
||||
return PunchCardUsageRecordStatus.UsedUp;
|
||||
}
|
||||
|
||||
return remainingTimes <= 2
|
||||
? PunchCardUsageRecordStatus.AlmostUsedUp
|
||||
: PunchCardUsageRecordStatus.Normal;
|
||||
}
|
||||
|
||||
private static IReadOnlyList<long> NormalizeSnowflakeIds(
|
||||
IEnumerable<long>? values,
|
||||
string fieldName,
|
||||
bool required)
|
||||
{
|
||||
var normalized = (values ?? [])
|
||||
.Where(id => id > 0)
|
||||
.Distinct()
|
||||
.OrderBy(id => id)
|
||||
.ToList();
|
||||
|
||||
if (required && normalized.Count == 0)
|
||||
{
|
||||
throw new BusinessException(ErrorCodes.BadRequest, $"{fieldName} 不能为空");
|
||||
}
|
||||
|
||||
return normalized;
|
||||
}
|
||||
|
||||
private static (int? ValidityDays, DateTime? ValidFrom, DateTime? ValidTo) NormalizeRange(
|
||||
DateTime? validFrom,
|
||||
DateTime? validTo)
|
||||
{
|
||||
if (!validFrom.HasValue || !validTo.HasValue)
|
||||
{
|
||||
throw new BusinessException(ErrorCodes.BadRequest, "validFrom / validTo 不能为空");
|
||||
}
|
||||
|
||||
var normalizedFrom = NormalizeUtc(validFrom.Value).Date;
|
||||
var normalizedTo = NormalizeUtc(validTo.Value).Date.AddDays(1).AddTicks(-1);
|
||||
|
||||
if (normalizedFrom > normalizedTo)
|
||||
{
|
||||
throw new BusinessException(ErrorCodes.BadRequest, "validFrom 不能晚于 validTo");
|
||||
}
|
||||
|
||||
return (null, normalizedFrom, normalizedTo);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
using MediatR;
|
||||
using TakeoutSaaS.Application.App.Coupons.PunchCard.Dto;
|
||||
|
||||
namespace TakeoutSaaS.Application.App.Coupons.PunchCard.Queries;
|
||||
|
||||
/// <summary>
|
||||
/// 导出次卡使用记录 CSV。
|
||||
/// </summary>
|
||||
public sealed class ExportPunchCardUsageRecordCsvQuery : IRequest<PunchCardUsageRecordExportDto>
|
||||
{
|
||||
/// <summary>
|
||||
/// 操作门店 ID。
|
||||
/// </summary>
|
||||
public long StoreId { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// 次卡模板筛选 ID(可空)。
|
||||
/// </summary>
|
||||
public long? TemplateId { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// 状态筛选(normal/used_up/expired)。
|
||||
/// </summary>
|
||||
public string? Status { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// 关键字(会员/商品)。
|
||||
/// </summary>
|
||||
public string? Keyword { get; init; }
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
using MediatR;
|
||||
using TakeoutSaaS.Application.App.Coupons.PunchCard.Dto;
|
||||
|
||||
namespace TakeoutSaaS.Application.App.Coupons.PunchCard.Queries;
|
||||
|
||||
/// <summary>
|
||||
/// 查询次卡模板详情。
|
||||
/// </summary>
|
||||
public sealed class GetPunchCardTemplateDetailQuery : IRequest<PunchCardDetailDto?>
|
||||
{
|
||||
/// <summary>
|
||||
/// 操作门店 ID。
|
||||
/// </summary>
|
||||
public long StoreId { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// 次卡模板 ID。
|
||||
/// </summary>
|
||||
public long TemplateId { get; init; }
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
using MediatR;
|
||||
using TakeoutSaaS.Application.App.Coupons.PunchCard.Dto;
|
||||
|
||||
namespace TakeoutSaaS.Application.App.Coupons.PunchCard.Queries;
|
||||
|
||||
/// <summary>
|
||||
/// 查询次卡模板列表。
|
||||
/// </summary>
|
||||
public sealed class GetPunchCardTemplateListQuery : IRequest<PunchCardListResultDto>
|
||||
{
|
||||
/// <summary>
|
||||
/// 操作门店 ID。
|
||||
/// </summary>
|
||||
public long StoreId { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// 名称关键字。
|
||||
/// </summary>
|
||||
public string? Keyword { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// 状态筛选(enabled/disabled)。
|
||||
/// </summary>
|
||||
public string? Status { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// 页码。
|
||||
/// </summary>
|
||||
public int Page { get; init; } = 1;
|
||||
|
||||
/// <summary>
|
||||
/// 每页条数。
|
||||
/// </summary>
|
||||
public int PageSize { get; init; } = 4;
|
||||
}
|
||||
@@ -0,0 +1,40 @@
|
||||
using MediatR;
|
||||
using TakeoutSaaS.Application.App.Coupons.PunchCard.Dto;
|
||||
|
||||
namespace TakeoutSaaS.Application.App.Coupons.PunchCard.Queries;
|
||||
|
||||
/// <summary>
|
||||
/// 查询次卡使用记录列表。
|
||||
/// </summary>
|
||||
public sealed class GetPunchCardUsageRecordListQuery : IRequest<PunchCardUsageRecordListResultDto>
|
||||
{
|
||||
/// <summary>
|
||||
/// 操作门店 ID。
|
||||
/// </summary>
|
||||
public long StoreId { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// 次卡模板筛选 ID(可空)。
|
||||
/// </summary>
|
||||
public long? TemplateId { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// 状态筛选(normal/used_up/expired)。
|
||||
/// </summary>
|
||||
public string? Status { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// 关键字(会员/商品)。
|
||||
/// </summary>
|
||||
public string? Keyword { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// 页码。
|
||||
/// </summary>
|
||||
public int Page { get; init; } = 1;
|
||||
|
||||
/// <summary>
|
||||
/// 每页条数。
|
||||
/// </summary>
|
||||
public int PageSize { get; init; } = 10;
|
||||
}
|
||||
@@ -0,0 +1,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,462 @@
|
||||
namespace TakeoutSaaS.Application.App.Customers.Dto;
|
||||
|
||||
/// <summary>
|
||||
/// 客户标签 DTO。
|
||||
/// </summary>
|
||||
public sealed class CustomerTagDto
|
||||
{
|
||||
/// <summary>
|
||||
/// 标签编码。
|
||||
/// </summary>
|
||||
public string Code { get; init; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// 标签文案。
|
||||
/// </summary>
|
||||
public string Label { get; init; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// 标签色调(orange/blue/green/gray/red)。
|
||||
/// </summary>
|
||||
public string Tone { get; init; } = "blue";
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 客户列表行 DTO。
|
||||
/// </summary>
|
||||
public sealed class CustomerListItemDto
|
||||
{
|
||||
/// <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 int OrderCount { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// 下单次数条形宽度百分比。
|
||||
/// </summary>
|
||||
public int OrderCountBarPercent { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// 累计消费。
|
||||
/// </summary>
|
||||
public decimal TotalAmount { 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 IReadOnlyList<CustomerTagDto> Tags { get; init; } = [];
|
||||
|
||||
/// <summary>
|
||||
/// 是否弱化展示。
|
||||
/// </summary>
|
||||
public bool IsDimmed { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 客户列表统计 DTO。
|
||||
/// </summary>
|
||||
public sealed class CustomerListStatsDto
|
||||
{
|
||||
/// <summary>
|
||||
/// 客户总数。
|
||||
/// </summary>
|
||||
public int TotalCustomers { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// 本月新增客户数。
|
||||
/// </summary>
|
||||
public int MonthlyNewCustomers { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// 本月较上月增长百分比。
|
||||
/// </summary>
|
||||
public decimal MonthlyGrowthRatePercent { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// 活跃客户数(近 30 天有下单)。
|
||||
/// </summary>
|
||||
public int ActiveCustomers { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// 近 30 天客均消费(按订单均值)。
|
||||
/// </summary>
|
||||
public decimal AverageAmountLast30Days { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 客户偏好 DTO。
|
||||
/// </summary>
|
||||
public sealed class CustomerPreferenceDto
|
||||
{
|
||||
/// <summary>
|
||||
/// 偏好品类。
|
||||
/// </summary>
|
||||
public IReadOnlyList<string> PreferredCategories { get; init; } = [];
|
||||
|
||||
/// <summary>
|
||||
/// 偏好下单时段。
|
||||
/// </summary>
|
||||
public string PreferredOrderPeaks { get; init; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// 偏好履约方式。
|
||||
/// </summary>
|
||||
public string PreferredDelivery { get; init; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// 偏好支付方式。
|
||||
/// </summary>
|
||||
public string PreferredPaymentMethod { get; init; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// 平均配送距离文案。
|
||||
/// </summary>
|
||||
public string AverageDeliveryDistance { get; init; } = string.Empty;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 客户常购商品 DTO。
|
||||
/// </summary>
|
||||
public sealed class CustomerTopProductDto
|
||||
{
|
||||
/// <summary>
|
||||
/// 排名。
|
||||
/// </summary>
|
||||
public int Rank { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// 商品名称。
|
||||
/// </summary>
|
||||
public string ProductName { get; init; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// 购买次数。
|
||||
/// </summary>
|
||||
public int Count { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// 占比(0-100)。
|
||||
/// </summary>
|
||||
public decimal ProportionPercent { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 客户趋势点 DTO。
|
||||
/// </summary>
|
||||
public sealed class CustomerTrendPointDto
|
||||
{
|
||||
/// <summary>
|
||||
/// 月份标签。
|
||||
/// </summary>
|
||||
public string Label { get; init; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// 消费金额。
|
||||
/// </summary>
|
||||
public decimal Amount { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 客户最近订单 DTO。
|
||||
/// </summary>
|
||||
public sealed class CustomerRecentOrderDto
|
||||
{
|
||||
/// <summary>
|
||||
/// 订单号。
|
||||
/// </summary>
|
||||
public string OrderNo { get; init; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// 下单时间。
|
||||
/// </summary>
|
||||
public DateTime OrderedAt { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// 订单金额。
|
||||
/// </summary>
|
||||
public decimal Amount { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// 商品摘要。
|
||||
/// </summary>
|
||||
public string ItemsSummary { get; init; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// 履约方式文案。
|
||||
/// </summary>
|
||||
public string DeliveryType { get; init; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// 状态文案。
|
||||
/// </summary>
|
||||
public string Status { get; init; } = string.Empty;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 客户会员摘要 DTO。
|
||||
/// </summary>
|
||||
public sealed class CustomerMemberSummaryDto
|
||||
{
|
||||
/// <summary>
|
||||
/// 是否会员。
|
||||
/// </summary>
|
||||
public bool IsMember { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// 会员等级名称。
|
||||
/// </summary>
|
||||
public string TierName { get; init; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// 积分余额。
|
||||
/// </summary>
|
||||
public int PointsBalance { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// 成长值。
|
||||
/// </summary>
|
||||
public int GrowthValue { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// 入会时间。
|
||||
/// </summary>
|
||||
public DateTime? JoinedAt { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 客户详情 DTO。
|
||||
/// </summary>
|
||||
public sealed class CustomerDetailDto
|
||||
{
|
||||
/// <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 DateTime RegisteredAt { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// 首次下单时间。
|
||||
/// </summary>
|
||||
public DateTime FirstOrderAt { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// 客户来源。
|
||||
/// </summary>
|
||||
public string Source { get; init; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// 客户标签。
|
||||
/// </summary>
|
||||
public IReadOnlyList<CustomerTagDto> Tags { get; init; } = [];
|
||||
|
||||
/// <summary>
|
||||
/// 会员摘要。
|
||||
/// </summary>
|
||||
public CustomerMemberSummaryDto Member { get; init; } = new();
|
||||
|
||||
/// <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 CustomerPreferenceDto Preference { get; init; } = new();
|
||||
|
||||
/// <summary>
|
||||
/// 常购商品 Top 5。
|
||||
/// </summary>
|
||||
public IReadOnlyList<CustomerTopProductDto> TopProducts { get; init; } = [];
|
||||
|
||||
/// <summary>
|
||||
/// 趋势数据。
|
||||
/// </summary>
|
||||
public IReadOnlyList<CustomerTrendPointDto> Trend { get; init; } = [];
|
||||
|
||||
/// <summary>
|
||||
/// 最近订单。
|
||||
/// </summary>
|
||||
public IReadOnlyList<CustomerRecentOrderDto> RecentOrders { get; init; } = [];
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 客户画像 DTO。
|
||||
/// </summary>
|
||||
public sealed class CustomerProfileDto
|
||||
{
|
||||
/// <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 DateTime RegisteredAt { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// 首次下单时间。
|
||||
/// </summary>
|
||||
public DateTime FirstOrderAt { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// 客户来源。
|
||||
/// </summary>
|
||||
public string Source { get; init; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// 客户标签。
|
||||
/// </summary>
|
||||
public IReadOnlyList<CustomerTagDto> Tags { get; init; } = [];
|
||||
|
||||
/// <summary>
|
||||
/// 会员摘要。
|
||||
/// </summary>
|
||||
public CustomerMemberSummaryDto Member { get; init; } = new();
|
||||
|
||||
/// <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 decimal AverageOrderIntervalDays { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// 偏好数据。
|
||||
/// </summary>
|
||||
public CustomerPreferenceDto Preference { get; init; } = new();
|
||||
|
||||
/// <summary>
|
||||
/// 常购商品 Top 5。
|
||||
/// </summary>
|
||||
public IReadOnlyList<CustomerTopProductDto> TopProducts { get; init; } = [];
|
||||
|
||||
/// <summary>
|
||||
/// 趋势数据。
|
||||
/// </summary>
|
||||
public IReadOnlyList<CustomerTrendPointDto> Trend { get; init; } = [];
|
||||
|
||||
/// <summary>
|
||||
/// 最近订单。
|
||||
/// </summary>
|
||||
public IReadOnlyList<CustomerRecentOrderDto> RecentOrders { get; init; } = [];
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 客户导出 DTO。
|
||||
/// </summary>
|
||||
public sealed class CustomerExportDto
|
||||
{
|
||||
/// <summary>
|
||||
/// 文件名。
|
||||
/// </summary>
|
||||
public string FileName { get; init; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// 文件 Base64。
|
||||
/// </summary>
|
||||
public string FileContentBase64 { get; init; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// 导出总数。
|
||||
/// </summary>
|
||||
public int TotalCount { get; init; }
|
||||
}
|
||||
@@ -0,0 +1,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,992 @@
|
||||
using System.Data;
|
||||
using System.Data.Common;
|
||||
using TakeoutSaaS.Application.App.Customers.Dto;
|
||||
using TakeoutSaaS.Domain.Orders.Entities;
|
||||
using TakeoutSaaS.Domain.Orders.Enums;
|
||||
using TakeoutSaaS.Domain.Orders.Repositories;
|
||||
using TakeoutSaaS.Domain.Payments.Enums;
|
||||
using TakeoutSaaS.Domain.Products.Repositories;
|
||||
using TakeoutSaaS.Shared.Abstractions.Constants;
|
||||
using TakeoutSaaS.Shared.Abstractions.Data;
|
||||
using TakeoutSaaS.Shared.Abstractions.Tenancy;
|
||||
|
||||
namespace TakeoutSaaS.Application.App.Customers.Handlers;
|
||||
|
||||
internal static class CustomerAnalyticsSupport
|
||||
{
|
||||
private static readonly string[] AvatarColors =
|
||||
[
|
||||
"#f56a00",
|
||||
"#7265e6",
|
||||
"#52c41a",
|
||||
"#fa8c16",
|
||||
"#1890ff",
|
||||
"#bfbfbf",
|
||||
"#13c2c2",
|
||||
"#eb2f96"
|
||||
];
|
||||
|
||||
internal const string TagHighValue = "high_value";
|
||||
internal const string TagActive = "active";
|
||||
internal const string TagDormant = "dormant";
|
||||
internal const string TagChurn = "churn";
|
||||
internal const string TagNewCustomer = "new_customer";
|
||||
|
||||
internal static readonly string[] SupportedTags =
|
||||
[
|
||||
TagHighValue,
|
||||
TagActive,
|
||||
TagDormant,
|
||||
TagChurn,
|
||||
TagNewCustomer
|
||||
];
|
||||
|
||||
internal 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);
|
||||
}
|
||||
|
||||
internal static string MaskPhone(string normalizedPhone)
|
||||
{
|
||||
if (normalizedPhone.Length >= 11)
|
||||
{
|
||||
return $"{normalizedPhone[..3]}****{normalizedPhone[^4..]}";
|
||||
}
|
||||
|
||||
if (normalizedPhone.Length >= 7)
|
||||
{
|
||||
return $"{normalizedPhone[..3]}***{normalizedPhone[^2..]}";
|
||||
}
|
||||
|
||||
return normalizedPhone;
|
||||
}
|
||||
|
||||
internal static string ResolveAvatarText(string name, string customerKey)
|
||||
{
|
||||
var candidate = (name ?? string.Empty).Trim();
|
||||
if (!string.IsNullOrWhiteSpace(candidate))
|
||||
{
|
||||
return candidate[..1];
|
||||
}
|
||||
|
||||
return customerKey.Length > 0 ? customerKey[..1] : "客";
|
||||
}
|
||||
|
||||
internal static string ResolveAvatarColor(string? seed)
|
||||
{
|
||||
var source = string.IsNullOrWhiteSpace(seed) ? "customer" : seed;
|
||||
var hash = 0;
|
||||
foreach (var ch in source)
|
||||
{
|
||||
hash = (hash * 31 + ch) & int.MaxValue;
|
||||
}
|
||||
|
||||
return AvatarColors[hash % AvatarColors.Length];
|
||||
}
|
||||
|
||||
internal static decimal ResolveDisplayAmount(Order order)
|
||||
{
|
||||
return order.PaidAmount > 0 ? order.PaidAmount : order.PayableAmount;
|
||||
}
|
||||
|
||||
internal static string ToDeliveryTypeText(DeliveryType value)
|
||||
{
|
||||
return value switch
|
||||
{
|
||||
DeliveryType.Delivery => "外卖",
|
||||
DeliveryType.Pickup => "自提",
|
||||
DeliveryType.DineIn => "堂食",
|
||||
_ => "未知"
|
||||
};
|
||||
}
|
||||
|
||||
internal static string ToPaymentMethodText(PaymentMethod value)
|
||||
{
|
||||
return value switch
|
||||
{
|
||||
PaymentMethod.WeChatPay => "微信支付",
|
||||
PaymentMethod.Alipay => "支付宝",
|
||||
PaymentMethod.Balance => "余额支付",
|
||||
PaymentMethod.Cash => "现金",
|
||||
PaymentMethod.Card => "刷卡",
|
||||
_ => "--"
|
||||
};
|
||||
}
|
||||
|
||||
internal static string ToOrderStatusText(OrderStatus status, DeliveryType deliveryType)
|
||||
{
|
||||
return status switch
|
||||
{
|
||||
OrderStatus.PendingPayment => "待接单",
|
||||
OrderStatus.AwaitingPreparation => "待接单",
|
||||
OrderStatus.InProgress => "制作中",
|
||||
OrderStatus.Ready => deliveryType == DeliveryType.Delivery ? "配送中" : "待取餐",
|
||||
OrderStatus.Completed => "已完成",
|
||||
OrderStatus.Cancelled => "已取消",
|
||||
_ => "未知"
|
||||
};
|
||||
}
|
||||
|
||||
internal static decimal ToRatePercent(int numerator, int denominator)
|
||||
{
|
||||
if (denominator <= 0 || numerator <= 0)
|
||||
{
|
||||
return 0;
|
||||
}
|
||||
|
||||
return decimal.Round(
|
||||
numerator * 100m / denominator,
|
||||
1,
|
||||
MidpointRounding.AwayFromZero);
|
||||
}
|
||||
|
||||
internal static decimal ToGrowthRatePercent(int current, int previous)
|
||||
{
|
||||
if (previous <= 0)
|
||||
{
|
||||
return current <= 0 ? 0 : 100;
|
||||
}
|
||||
|
||||
return decimal.Round(
|
||||
(current - previous) * 100m / previous,
|
||||
1,
|
||||
MidpointRounding.AwayFromZero);
|
||||
}
|
||||
|
||||
internal static string NormalizeTag(string? tag)
|
||||
{
|
||||
var normalized = (tag ?? string.Empty).Trim().ToLowerInvariant();
|
||||
return normalized switch
|
||||
{
|
||||
"highvalue" or "high_value" or "high-value" => TagHighValue,
|
||||
"active" => TagActive,
|
||||
"dormant" or "sleeping" => TagDormant,
|
||||
"churn" or "lost" => TagChurn,
|
||||
"new" or "new_customer" or "new-customer" => TagNewCustomer,
|
||||
_ => string.Empty
|
||||
};
|
||||
}
|
||||
|
||||
internal static string NormalizeOrderCountRange(string? range)
|
||||
{
|
||||
var normalized = (range ?? string.Empty).Trim().ToLowerInvariant();
|
||||
return normalized switch
|
||||
{
|
||||
"once" or "one" or "1" => "once",
|
||||
"two_to_five" or "2_5" or "2-5" => "two_to_five",
|
||||
"six_to_ten" or "6_10" or "6-10" => "six_to_ten",
|
||||
"ten_plus" or "10+" or "more_than_ten" => "ten_plus",
|
||||
_ => string.Empty
|
||||
};
|
||||
}
|
||||
|
||||
internal static bool MatchOrderCountRange(int orderCount, string normalizedRange)
|
||||
{
|
||||
return normalizedRange switch
|
||||
{
|
||||
"once" => orderCount == 1,
|
||||
"two_to_five" => orderCount >= 2 && orderCount <= 5,
|
||||
"six_to_ten" => orderCount >= 6 && orderCount <= 10,
|
||||
"ten_plus" => orderCount > 10,
|
||||
_ => true
|
||||
};
|
||||
}
|
||||
|
||||
internal static IReadOnlyList<CustomerTagDto> BuildTags(
|
||||
decimal totalAmount,
|
||||
decimal averageAmount,
|
||||
int orderCount,
|
||||
DateTime registeredAt,
|
||||
DateTime lastOrderAt,
|
||||
DateTime nowUtc)
|
||||
{
|
||||
var tagList = new List<CustomerTagDto>();
|
||||
|
||||
// 1. 计算基础状态
|
||||
var silentDays = (nowUtc.Date - lastOrderAt.Date).TotalDays;
|
||||
var isHighValue = totalAmount >= 3000m || (averageAmount >= 100m && orderCount >= 10);
|
||||
var isNewCustomer = registeredAt >= nowUtc.AddDays(-30);
|
||||
var isActive = silentDays <= 30;
|
||||
var isDormant = silentDays > 30 && silentDays <= 60;
|
||||
var isChurn = silentDays > 60;
|
||||
|
||||
// 2. 组合标签(优先保留原型主标签)
|
||||
if (isHighValue)
|
||||
{
|
||||
tagList.Add(new CustomerTagDto
|
||||
{
|
||||
Code = TagHighValue,
|
||||
Label = "高价值",
|
||||
Tone = "orange"
|
||||
});
|
||||
}
|
||||
|
||||
if (isNewCustomer)
|
||||
{
|
||||
tagList.Add(new CustomerTagDto
|
||||
{
|
||||
Code = TagNewCustomer,
|
||||
Label = "新客户",
|
||||
Tone = "green"
|
||||
});
|
||||
return tagList;
|
||||
}
|
||||
|
||||
if (isActive)
|
||||
{
|
||||
tagList.Add(new CustomerTagDto
|
||||
{
|
||||
Code = TagActive,
|
||||
Label = "活跃",
|
||||
Tone = "blue"
|
||||
});
|
||||
return tagList;
|
||||
}
|
||||
|
||||
if (isDormant)
|
||||
{
|
||||
tagList.Add(new CustomerTagDto
|
||||
{
|
||||
Code = TagDormant,
|
||||
Label = "沉睡",
|
||||
Tone = "gray"
|
||||
});
|
||||
return tagList;
|
||||
}
|
||||
|
||||
if (isChurn)
|
||||
{
|
||||
tagList.Add(new CustomerTagDto
|
||||
{
|
||||
Code = TagChurn,
|
||||
Label = "流失",
|
||||
Tone = "red"
|
||||
});
|
||||
}
|
||||
|
||||
return tagList;
|
||||
}
|
||||
|
||||
internal static string ResolveCustomerName(
|
||||
string customerKey,
|
||||
string latestName,
|
||||
MemberProfileSnapshot? memberProfile)
|
||||
{
|
||||
if (!string.IsNullOrWhiteSpace(memberProfile?.Nickname))
|
||||
{
|
||||
return memberProfile.Nickname.Trim();
|
||||
}
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(latestName))
|
||||
{
|
||||
return latestName.Trim();
|
||||
}
|
||||
|
||||
return customerKey.Length >= 4 ? $"客户{customerKey[^4..]}" : "客户";
|
||||
}
|
||||
|
||||
internal static CustomerMemberSummaryDto BuildMemberSummary(MemberProfileSnapshot? memberProfile)
|
||||
{
|
||||
if (memberProfile is null)
|
||||
{
|
||||
return new CustomerMemberSummaryDto
|
||||
{
|
||||
IsMember = false,
|
||||
TierName = string.Empty,
|
||||
PointsBalance = 0,
|
||||
GrowthValue = 0,
|
||||
JoinedAt = null
|
||||
};
|
||||
}
|
||||
|
||||
return new CustomerMemberSummaryDto
|
||||
{
|
||||
IsMember = true,
|
||||
TierName = string.IsNullOrWhiteSpace(memberProfile.TierName)
|
||||
? string.Empty
|
||||
: memberProfile.TierName.Trim(),
|
||||
PointsBalance = Math.Max(0, memberProfile.PointsBalance),
|
||||
GrowthValue = Math.Max(0, memberProfile.GrowthValue),
|
||||
JoinedAt = memberProfile.JoinedAt
|
||||
};
|
||||
}
|
||||
|
||||
internal static IReadOnlyList<CustomerAggregate> ApplyFilters(
|
||||
IReadOnlyList<CustomerAggregate> customers,
|
||||
string? keyword,
|
||||
string? normalizedTag,
|
||||
string? normalizedOrderCountRange,
|
||||
int? registerPeriodDays,
|
||||
DateTime nowUtc)
|
||||
{
|
||||
var normalizedKeyword = (keyword ?? string.Empty).Trim();
|
||||
var keywordDigits = NormalizePhone(normalizedKeyword);
|
||||
|
||||
return customers
|
||||
.Where(customer =>
|
||||
{
|
||||
if (!string.IsNullOrWhiteSpace(normalizedKeyword))
|
||||
{
|
||||
var matchedByName = customer.Name.Contains(normalizedKeyword, StringComparison.OrdinalIgnoreCase);
|
||||
var matchedByPhone = !string.IsNullOrWhiteSpace(keywordDigits) &&
|
||||
customer.CustomerKey.Contains(keywordDigits, StringComparison.Ordinal);
|
||||
if (!matchedByName && !matchedByPhone)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(normalizedTag) &&
|
||||
!customer.Tags.Any(tag => string.Equals(tag.Code, normalizedTag, StringComparison.OrdinalIgnoreCase)))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(normalizedOrderCountRange) &&
|
||||
!MatchOrderCountRange(customer.OrderCount, normalizedOrderCountRange))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
if (registerPeriodDays.HasValue && registerPeriodDays.Value > 0)
|
||||
{
|
||||
var threshold = nowUtc.AddDays(-registerPeriodDays.Value);
|
||||
if (customer.RegisteredAt < threshold)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
return true;
|
||||
})
|
||||
.ToList();
|
||||
}
|
||||
|
||||
internal static IReadOnlyList<CustomerTrendPointDto> BuildMonthlyTrend(
|
||||
IReadOnlyList<CustomerOrderSnapshot> orders,
|
||||
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);
|
||||
|
||||
// 1. 预计算窗口内订单金额
|
||||
var monthAmountMap = orders
|
||||
.Where(item => item.OrderedAt >= windowStart && item.OrderedAt < monthStart.AddMonths(1))
|
||||
.GroupBy(item => new DateTime(item.OrderedAt.Year, item.OrderedAt.Month, 1, 0, 0, 0, DateTimeKind.Utc))
|
||||
.ToDictionary(group => group.Key, group => group.Sum(item => item.Amount));
|
||||
|
||||
// 2. 生成连续月份点
|
||||
var trend = new List<CustomerTrendPointDto>(normalizedMonthCount);
|
||||
for (var index = 0; index < normalizedMonthCount; index += 1)
|
||||
{
|
||||
var currentMonth = windowStart.AddMonths(index);
|
||||
monthAmountMap.TryGetValue(currentMonth, out var amount);
|
||||
trend.Add(new CustomerTrendPointDto
|
||||
{
|
||||
Label = $"{currentMonth.Month}月",
|
||||
Amount = decimal.Round(amount, 2, MidpointRounding.AwayFromZero)
|
||||
});
|
||||
}
|
||||
|
||||
return trend;
|
||||
}
|
||||
|
||||
internal static decimal CalculateAverageIntervalDays(IReadOnlyList<CustomerOrderSnapshot> orders)
|
||||
{
|
||||
if (orders.Count < 2)
|
||||
{
|
||||
return 0;
|
||||
}
|
||||
|
||||
var ascOrders = orders
|
||||
.OrderBy(item => item.OrderedAt)
|
||||
.ThenBy(item => item.OrderId)
|
||||
.ToList();
|
||||
|
||||
var totalDays = 0m;
|
||||
for (var index = 1; index < ascOrders.Count; index += 1)
|
||||
{
|
||||
totalDays += (decimal)(ascOrders[index].OrderedAt - ascOrders[index - 1].OrderedAt).TotalDays;
|
||||
}
|
||||
|
||||
return decimal.Round(totalDays / (ascOrders.Count - 1), 1, MidpointRounding.AwayFromZero);
|
||||
}
|
||||
|
||||
internal static string ResolvePreferredOrderPeaks(IReadOnlyList<CustomerOrderSnapshot> orders)
|
||||
{
|
||||
if (orders.Count == 0)
|
||||
{
|
||||
return string.Empty;
|
||||
}
|
||||
|
||||
var slots = orders
|
||||
.GroupBy(item => ResolvePeakSlot(item.OrderedAt.Hour))
|
||||
.Select(group => new
|
||||
{
|
||||
Slot = group.Key,
|
||||
Count = group.Count()
|
||||
})
|
||||
.OrderByDescending(item => item.Count)
|
||||
.ThenBy(item => item.Slot, StringComparer.Ordinal)
|
||||
.Take(2)
|
||||
.Select(item => item.Slot)
|
||||
.ToList();
|
||||
|
||||
return slots.Count == 0 ? string.Empty : string.Join("、", slots);
|
||||
}
|
||||
|
||||
internal static string ResolvePreferredDelivery(IReadOnlyList<CustomerOrderSnapshot> orders)
|
||||
{
|
||||
if (orders.Count == 0)
|
||||
{
|
||||
return string.Empty;
|
||||
}
|
||||
|
||||
var grouped = orders
|
||||
.GroupBy(item => item.DeliveryType)
|
||||
.Select(group => new
|
||||
{
|
||||
Type = group.Key,
|
||||
Count = group.Count()
|
||||
})
|
||||
.OrderByDescending(item => item.Count)
|
||||
.ThenBy(item => item.Type)
|
||||
.ToList();
|
||||
|
||||
var totalCount = grouped.Sum(item => item.Count);
|
||||
if (totalCount <= 0)
|
||||
{
|
||||
return string.Empty;
|
||||
}
|
||||
|
||||
return string.Join("、", grouped.Select(item =>
|
||||
{
|
||||
var ratio = ToRatePercent(item.Count, totalCount);
|
||||
return $"{ToDeliveryTypeText(item.Type)} ({ratio:0.#}%)";
|
||||
}));
|
||||
}
|
||||
|
||||
internal static async Task<string> ResolvePreferredPaymentMethodAsync(
|
||||
IOrderRepository orderRepository,
|
||||
long tenantId,
|
||||
IReadOnlyList<CustomerOrderSnapshot> orders,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
if (orders.Count == 0)
|
||||
{
|
||||
return string.Empty;
|
||||
}
|
||||
|
||||
// 1. 控制计算成本,仅统计最近 60 单
|
||||
var recentOrders = orders
|
||||
.OrderByDescending(item => item.OrderedAt)
|
||||
.ThenByDescending(item => item.OrderId)
|
||||
.Take(60)
|
||||
.ToList();
|
||||
|
||||
// 2. 统计支付方式
|
||||
var counter = new Dictionary<PaymentMethod, int>();
|
||||
foreach (var order in recentOrders)
|
||||
{
|
||||
var payment = await orderRepository.GetLatestPaymentRecordAsync(order.OrderId, tenantId, cancellationToken);
|
||||
if (payment is null)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
if (!counter.TryAdd(payment.Method, 1))
|
||||
{
|
||||
counter[payment.Method] += 1;
|
||||
}
|
||||
}
|
||||
|
||||
if (counter.Count == 0)
|
||||
{
|
||||
return string.Empty;
|
||||
}
|
||||
|
||||
return counter
|
||||
.OrderByDescending(item => item.Value)
|
||||
.ThenBy(item => item.Key)
|
||||
.Select(item => $"{ToPaymentMethodText(item.Key)} ({item.Value}次)")
|
||||
.FirstOrDefault() ?? string.Empty;
|
||||
}
|
||||
|
||||
internal static IReadOnlyList<CustomerTopProductDto> BuildTopProducts(
|
||||
IReadOnlyDictionary<long, IReadOnlyList<OrderItem>> itemsLookup,
|
||||
IReadOnlyList<long> orderIds,
|
||||
int takeCount)
|
||||
{
|
||||
if (orderIds.Count == 0 || takeCount <= 0)
|
||||
{
|
||||
return [];
|
||||
}
|
||||
|
||||
// 1. 汇总商品购买次数
|
||||
var productCounter = new Dictionary<string, ProductCounter>();
|
||||
foreach (var orderId in orderIds)
|
||||
{
|
||||
if (!itemsLookup.TryGetValue(orderId, out var items))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
foreach (var item in items)
|
||||
{
|
||||
var normalizedName = string.IsNullOrWhiteSpace(item.ProductName) ? "商品" : item.ProductName.Trim();
|
||||
var key = item.ProductId > 0 ? $"id:{item.ProductId}" : $"name:{normalizedName}";
|
||||
var quantity = Math.Max(1, item.Quantity);
|
||||
|
||||
if (!productCounter.TryGetValue(key, out var counter))
|
||||
{
|
||||
counter = new ProductCounter
|
||||
{
|
||||
ProductId = item.ProductId,
|
||||
ProductName = normalizedName,
|
||||
Count = quantity
|
||||
};
|
||||
productCounter[key] = counter;
|
||||
continue;
|
||||
}
|
||||
|
||||
counter.Count += quantity;
|
||||
}
|
||||
}
|
||||
|
||||
var sorted = productCounter.Values
|
||||
.OrderByDescending(item => item.Count)
|
||||
.ThenBy(item => item.ProductName, StringComparer.Ordinal)
|
||||
.Take(takeCount)
|
||||
.ToList();
|
||||
|
||||
if (sorted.Count == 0)
|
||||
{
|
||||
return [];
|
||||
}
|
||||
|
||||
var maxCount = Math.Max(1, sorted[0].Count);
|
||||
return sorted
|
||||
.Select((item, index) => new CustomerTopProductDto
|
||||
{
|
||||
Rank = index + 1,
|
||||
ProductName = item.ProductName,
|
||||
Count = item.Count,
|
||||
ProportionPercent = decimal.Round(item.Count * 100m / maxCount, 1, MidpointRounding.AwayFromZero)
|
||||
})
|
||||
.ToList();
|
||||
}
|
||||
|
||||
internal static async Task<IReadOnlyList<string>> ResolvePreferredCategoriesAsync(
|
||||
IProductRepository productRepository,
|
||||
long tenantId,
|
||||
IReadOnlyDictionary<long, IReadOnlyList<OrderItem>> itemsLookup,
|
||||
IReadOnlyList<long> orderIds,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
if (orderIds.Count == 0)
|
||||
{
|
||||
return [];
|
||||
}
|
||||
|
||||
// 1. 汇总分类出现频次
|
||||
var productCache = new Dictionary<long, long>();
|
||||
var categoryCounter = new Dictionary<long, int>();
|
||||
|
||||
foreach (var orderId in orderIds)
|
||||
{
|
||||
if (!itemsLookup.TryGetValue(orderId, out var items))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
foreach (var item in items)
|
||||
{
|
||||
if (item.ProductId <= 0)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
if (!productCache.TryGetValue(item.ProductId, out var categoryId))
|
||||
{
|
||||
var product = await productRepository.FindByIdAsync(item.ProductId, tenantId, cancellationToken);
|
||||
categoryId = product?.CategoryId ?? 0;
|
||||
productCache[item.ProductId] = categoryId;
|
||||
}
|
||||
|
||||
if (categoryId <= 0)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
var quantity = Math.Max(1, item.Quantity);
|
||||
if (!categoryCounter.TryAdd(categoryId, quantity))
|
||||
{
|
||||
categoryCounter[categoryId] += quantity;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (categoryCounter.Count == 0)
|
||||
{
|
||||
return [];
|
||||
}
|
||||
|
||||
// 2. 读取分类名称并返回前 3
|
||||
var categoryIds = categoryCounter
|
||||
.OrderByDescending(item => item.Value)
|
||||
.ThenBy(item => item.Key)
|
||||
.Take(3)
|
||||
.Select(item => item.Key)
|
||||
.ToList();
|
||||
|
||||
var categoryNames = new List<string>(categoryIds.Count);
|
||||
foreach (var categoryId in categoryIds)
|
||||
{
|
||||
var category = await productRepository.FindCategoryByIdAsync(categoryId, tenantId, cancellationToken);
|
||||
if (category is null || string.IsNullOrWhiteSpace(category.Name))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
categoryNames.Add(category.Name.Trim());
|
||||
}
|
||||
|
||||
return categoryNames;
|
||||
}
|
||||
|
||||
internal static IReadOnlyList<CustomerRecentOrderDto> BuildRecentOrders(
|
||||
IReadOnlyList<CustomerOrderSnapshot> orders,
|
||||
IReadOnlyDictionary<long, IReadOnlyList<OrderItem>> itemsLookup,
|
||||
int takeCount)
|
||||
{
|
||||
return orders
|
||||
.OrderByDescending(item => item.OrderedAt)
|
||||
.ThenByDescending(item => item.OrderId)
|
||||
.Take(Math.Max(1, takeCount))
|
||||
.Select(item => new CustomerRecentOrderDto
|
||||
{
|
||||
OrderNo = item.OrderNo,
|
||||
OrderedAt = item.OrderedAt,
|
||||
Amount = item.Amount,
|
||||
ItemsSummary = BuildItemsSummary(item.OrderId, itemsLookup),
|
||||
DeliveryType = ToDeliveryTypeText(item.DeliveryType),
|
||||
Status = ToOrderStatusText(item.Status, item.DeliveryType)
|
||||
})
|
||||
.ToList();
|
||||
}
|
||||
|
||||
internal static string BuildItemsSummary(
|
||||
long orderId,
|
||||
IReadOnlyDictionary<long, IReadOnlyList<OrderItem>> itemsLookup)
|
||||
{
|
||||
if (!itemsLookup.TryGetValue(orderId, out var items) || items.Count == 0)
|
||||
{
|
||||
return "--";
|
||||
}
|
||||
|
||||
var summaries = items
|
||||
.Take(3)
|
||||
.Select(item =>
|
||||
{
|
||||
var productName = string.IsNullOrWhiteSpace(item.ProductName) ? "商品" : item.ProductName.Trim();
|
||||
var quantity = Math.Max(1, item.Quantity);
|
||||
return $"{productName} x{quantity}";
|
||||
})
|
||||
.ToList();
|
||||
|
||||
if (items.Count > 3)
|
||||
{
|
||||
summaries.Add("等");
|
||||
}
|
||||
|
||||
return string.Join("、", summaries);
|
||||
}
|
||||
|
||||
internal static async Task<IReadOnlyList<CustomerAggregate>> LoadCustomersAsync(
|
||||
IOrderRepository orderRepository,
|
||||
IDapperExecutor dapperExecutor,
|
||||
ITenantProvider tenantProvider,
|
||||
IReadOnlyCollection<long> visibleStoreIds,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
if (visibleStoreIds.Count == 0)
|
||||
{
|
||||
return [];
|
||||
}
|
||||
|
||||
var tenantId = tenantProvider.GetCurrentTenantId();
|
||||
if (tenantId <= 0)
|
||||
{
|
||||
return [];
|
||||
}
|
||||
|
||||
var visibleStoreSet = visibleStoreIds.ToHashSet();
|
||||
var rawOrders = await orderRepository.SearchAllOrdersAsync(
|
||||
tenantId,
|
||||
null,
|
||||
null,
|
||||
null,
|
||||
null,
|
||||
null,
|
||||
null,
|
||||
null,
|
||||
cancellationToken);
|
||||
|
||||
// 1. 过滤可见门店并构建订单快照
|
||||
var orderSnapshots = rawOrders
|
||||
.Where(item => visibleStoreSet.Contains(item.StoreId))
|
||||
.Select(item =>
|
||||
{
|
||||
var customerKey = NormalizePhone(item.CustomerPhone);
|
||||
return new CustomerOrderSnapshot
|
||||
{
|
||||
OrderId = item.Id,
|
||||
OrderNo = item.OrderNo,
|
||||
StoreId = item.StoreId,
|
||||
CustomerKey = customerKey,
|
||||
CustomerName = string.IsNullOrWhiteSpace(item.CustomerName) ? string.Empty : item.CustomerName.Trim(),
|
||||
OrderedAt = item.CreatedAt,
|
||||
Amount = ResolveDisplayAmount(item),
|
||||
DeliveryType = item.DeliveryType,
|
||||
Status = item.Status
|
||||
};
|
||||
})
|
||||
.Where(item => !string.IsNullOrWhiteSpace(item.CustomerKey))
|
||||
.ToList();
|
||||
|
||||
if (orderSnapshots.Count == 0)
|
||||
{
|
||||
return [];
|
||||
}
|
||||
|
||||
var customerKeys = orderSnapshots
|
||||
.Select(item => item.CustomerKey)
|
||||
.Distinct(StringComparer.Ordinal)
|
||||
.ToHashSet(StringComparer.Ordinal);
|
||||
|
||||
var memberLookup = await LoadMemberProfileLookupAsync(
|
||||
dapperExecutor,
|
||||
tenantId,
|
||||
customerKeys,
|
||||
cancellationToken);
|
||||
|
||||
var nowUtc = DateTime.UtcNow;
|
||||
return orderSnapshots
|
||||
.GroupBy(item => item.CustomerKey, StringComparer.Ordinal)
|
||||
.Select(group =>
|
||||
{
|
||||
var customerOrders = group
|
||||
.OrderByDescending(item => item.OrderedAt)
|
||||
.ThenByDescending(item => item.OrderId)
|
||||
.ToList();
|
||||
|
||||
var firstOrderAt = customerOrders.Min(item => item.OrderedAt);
|
||||
var lastOrderAt = customerOrders.Max(item => item.OrderedAt);
|
||||
var orderCount = customerOrders.Count;
|
||||
var totalAmount = customerOrders.Sum(item => item.Amount);
|
||||
var averageAmount = orderCount == 0
|
||||
? 0
|
||||
: decimal.Round(totalAmount / orderCount, 2, MidpointRounding.AwayFromZero);
|
||||
|
||||
memberLookup.TryGetValue(group.Key, out var memberProfile);
|
||||
|
||||
var registeredAt = firstOrderAt;
|
||||
if (memberProfile?.JoinedAt is not null && memberProfile.JoinedAt.Value < registeredAt)
|
||||
{
|
||||
registeredAt = memberProfile.JoinedAt.Value;
|
||||
}
|
||||
|
||||
var latestName = customerOrders
|
||||
.Select(item => item.CustomerName)
|
||||
.FirstOrDefault(name => !string.IsNullOrWhiteSpace(name)) ?? string.Empty;
|
||||
|
||||
var name = ResolveCustomerName(group.Key, latestName, memberProfile);
|
||||
var tags = BuildTags(totalAmount, averageAmount, orderCount, registeredAt, lastOrderAt, nowUtc);
|
||||
var member = BuildMemberSummary(memberProfile);
|
||||
|
||||
return new CustomerAggregate
|
||||
{
|
||||
CustomerKey = group.Key,
|
||||
Name = name,
|
||||
PhoneMasked = MaskPhone(group.Key),
|
||||
AvatarText = ResolveAvatarText(name, group.Key),
|
||||
AvatarColor = ResolveAvatarColor(group.Key),
|
||||
RegisteredAt = registeredAt,
|
||||
FirstOrderAt = firstOrderAt,
|
||||
LastOrderAt = lastOrderAt,
|
||||
Source = member.IsMember ? "会员中心" : "小程序",
|
||||
TotalAmount = totalAmount,
|
||||
AverageAmount = averageAmount,
|
||||
OrderCount = orderCount,
|
||||
Member = member,
|
||||
Tags = tags,
|
||||
IsDimmed = tags.Any(tag => tag.Code is TagDormant or TagChurn),
|
||||
Orders = customerOrders
|
||||
};
|
||||
})
|
||||
.OrderByDescending(item => item.LastOrderAt)
|
||||
.ThenBy(item => item.CustomerKey, StringComparer.Ordinal)
|
||||
.ToList();
|
||||
}
|
||||
|
||||
private static string ResolvePeakSlot(int hour)
|
||||
{
|
||||
return hour switch
|
||||
{
|
||||
>= 6 and < 10 => "06:00-10:00",
|
||||
>= 10 and < 14 => "10:00-14:00",
|
||||
>= 14 and < 17 => "14:00-17:00",
|
||||
>= 17 and < 21 => "17:00-21:00",
|
||||
_ => "21:00-06:00"
|
||||
};
|
||||
}
|
||||
|
||||
private static async Task<Dictionary<string, MemberProfileSnapshot>> LoadMemberProfileLookupAsync(
|
||||
IDapperExecutor dapperExecutor,
|
||||
long tenantId,
|
||||
IReadOnlySet<string> customerKeys,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
if (customerKeys.Count == 0)
|
||||
{
|
||||
return [];
|
||||
}
|
||||
|
||||
return await dapperExecutor.QueryAsync(
|
||||
DatabaseConstants.AppDataSource,
|
||||
DatabaseConnectionRole.Read,
|
||||
async (connection, token) =>
|
||||
{
|
||||
await using var command = CreateCommand(
|
||||
connection,
|
||||
"""
|
||||
select
|
||||
profile."Mobile",
|
||||
profile."Nickname",
|
||||
profile."JoinedAt",
|
||||
profile."PointsBalance",
|
||||
profile."GrowthValue",
|
||||
tier."Name" as "TierName"
|
||||
from public.member_profiles profile
|
||||
left join public.member_tiers tier
|
||||
on tier."Id" = profile."MemberTierId"
|
||||
and tier."TenantId" = profile."TenantId"
|
||||
and tier."DeletedAt" is null
|
||||
where profile."DeletedAt" is null
|
||||
and profile."TenantId" = @tenantId;
|
||||
""",
|
||||
[
|
||||
("tenantId", tenantId)
|
||||
]);
|
||||
|
||||
await using var reader = await command.ExecuteReaderAsync(token);
|
||||
var mobileOrdinal = reader.GetOrdinal("Mobile");
|
||||
var nicknameOrdinal = reader.GetOrdinal("Nickname");
|
||||
var joinedAtOrdinal = reader.GetOrdinal("JoinedAt");
|
||||
var tierNameOrdinal = reader.GetOrdinal("TierName");
|
||||
var pointsBalanceOrdinal = reader.GetOrdinal("PointsBalance");
|
||||
var growthValueOrdinal = reader.GetOrdinal("GrowthValue");
|
||||
|
||||
var result = new Dictionary<string, MemberProfileSnapshot>(StringComparer.Ordinal);
|
||||
while (await reader.ReadAsync(token))
|
||||
{
|
||||
var mobile = reader.IsDBNull(mobileOrdinal) ? string.Empty : reader.GetString(mobileOrdinal);
|
||||
var normalizedPhone = NormalizePhone(mobile);
|
||||
if (string.IsNullOrWhiteSpace(normalizedPhone) || !customerKeys.Contains(normalizedPhone))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
var snapshot = new MemberProfileSnapshot
|
||||
{
|
||||
Nickname = reader.IsDBNull(nicknameOrdinal) ? string.Empty : reader.GetString(nicknameOrdinal),
|
||||
JoinedAt = reader.IsDBNull(joinedAtOrdinal) ? null : reader.GetDateTime(joinedAtOrdinal),
|
||||
TierName = reader.IsDBNull(tierNameOrdinal) ? string.Empty : reader.GetString(tierNameOrdinal),
|
||||
PointsBalance = reader.IsDBNull(pointsBalanceOrdinal) ? 0 : reader.GetInt32(pointsBalanceOrdinal),
|
||||
GrowthValue = reader.IsDBNull(growthValueOrdinal) ? 0 : reader.GetInt32(growthValueOrdinal)
|
||||
};
|
||||
|
||||
result[normalizedPhone] = snapshot;
|
||||
}
|
||||
|
||||
return result;
|
||||
},
|
||||
cancellationToken);
|
||||
}
|
||||
|
||||
private static DbCommand CreateCommand(
|
||||
IDbConnection connection,
|
||||
string sql,
|
||||
(string Name, object? Value)[] parameters)
|
||||
{
|
||||
var command = connection.CreateCommand();
|
||||
command.CommandText = sql;
|
||||
|
||||
foreach (var (name, value) in parameters)
|
||||
{
|
||||
var parameter = command.CreateParameter();
|
||||
parameter.ParameterName = name;
|
||||
parameter.Value = value ?? DBNull.Value;
|
||||
command.Parameters.Add(parameter);
|
||||
}
|
||||
|
||||
return (DbCommand)command;
|
||||
}
|
||||
|
||||
private sealed class ProductCounter
|
||||
{
|
||||
internal int Count { get; set; }
|
||||
internal long ProductId { get; init; }
|
||||
internal string ProductName { get; init; } = string.Empty;
|
||||
}
|
||||
}
|
||||
|
||||
internal sealed class CustomerAggregate
|
||||
{
|
||||
internal string AvatarColor { get; init; } = string.Empty;
|
||||
internal string AvatarText { get; init; } = string.Empty;
|
||||
internal decimal AverageAmount { get; init; }
|
||||
internal string CustomerKey { get; init; } = string.Empty;
|
||||
internal DateTime FirstOrderAt { get; init; }
|
||||
internal bool IsDimmed { get; init; }
|
||||
internal DateTime LastOrderAt { get; init; }
|
||||
internal CustomerMemberSummaryDto Member { get; init; } = new();
|
||||
internal string Name { get; init; } = string.Empty;
|
||||
internal int OrderCount { get; init; }
|
||||
internal IReadOnlyList<CustomerOrderSnapshot> Orders { get; init; } = [];
|
||||
internal string PhoneMasked { get; init; } = string.Empty;
|
||||
internal DateTime RegisteredAt { get; init; }
|
||||
internal string Source { get; init; } = string.Empty;
|
||||
internal IReadOnlyList<CustomerTagDto> Tags { get; init; } = [];
|
||||
internal decimal TotalAmount { get; init; }
|
||||
}
|
||||
|
||||
internal sealed class CustomerOrderSnapshot
|
||||
{
|
||||
internal decimal Amount { get; init; }
|
||||
internal string CustomerKey { get; init; } = string.Empty;
|
||||
internal string CustomerName { get; init; } = string.Empty;
|
||||
internal DeliveryType DeliveryType { get; init; }
|
||||
internal long OrderId { get; init; }
|
||||
internal string OrderNo { get; init; } = string.Empty;
|
||||
internal DateTime OrderedAt { get; init; }
|
||||
internal OrderStatus Status { get; init; }
|
||||
internal long StoreId { get; init; }
|
||||
}
|
||||
|
||||
internal sealed class MemberProfileSnapshot
|
||||
{
|
||||
internal int GrowthValue { get; init; }
|
||||
internal DateTime? JoinedAt { get; init; }
|
||||
internal string Nickname { get; init; } = string.Empty;
|
||||
internal int PointsBalance { get; init; }
|
||||
internal string TierName { get; init; } = string.Empty;
|
||||
}
|
||||
@@ -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,107 @@
|
||||
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>
|
||||
/// 客户列表 CSV 导出处理器。
|
||||
/// </summary>
|
||||
public sealed class ExportCustomerCsvQueryHandler(
|
||||
IOrderRepository orderRepository,
|
||||
IDapperExecutor dapperExecutor,
|
||||
ITenantProvider tenantProvider)
|
||||
: IRequestHandler<ExportCustomerCsvQuery, CustomerExportDto>
|
||||
{
|
||||
/// <inheritdoc />
|
||||
public async Task<CustomerExportDto> Handle(
|
||||
ExportCustomerCsvQuery request,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
if (request.VisibleStoreIds.Count == 0)
|
||||
{
|
||||
return BuildExport([], 0);
|
||||
}
|
||||
|
||||
// 1. 加载聚合并应用筛选
|
||||
var customers = await CustomerAnalyticsSupport.LoadCustomersAsync(
|
||||
orderRepository,
|
||||
dapperExecutor,
|
||||
tenantProvider,
|
||||
request.VisibleStoreIds,
|
||||
cancellationToken);
|
||||
|
||||
var nowUtc = DateTime.UtcNow;
|
||||
var filteredCustomers = CustomerAnalyticsSupport.ApplyFilters(
|
||||
customers,
|
||||
request.Keyword,
|
||||
CustomerAnalyticsSupport.NormalizeTag(request.Tag),
|
||||
CustomerAnalyticsSupport.NormalizeOrderCountRange(request.OrderCountRange),
|
||||
request.RegisterPeriodDays,
|
||||
nowUtc)
|
||||
.OrderByDescending(item => item.LastOrderAt)
|
||||
.ThenBy(item => item.CustomerKey, StringComparer.Ordinal)
|
||||
.ToList();
|
||||
|
||||
return BuildExport(filteredCustomers, filteredCustomers.Count);
|
||||
}
|
||||
|
||||
private static CustomerExportDto BuildExport(
|
||||
IReadOnlyList<CustomerAggregate> customers,
|
||||
int totalCount)
|
||||
{
|
||||
var csv = BuildCsv(customers);
|
||||
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(IReadOnlyList<CustomerAggregate> customers)
|
||||
{
|
||||
var sb = new StringBuilder();
|
||||
sb.AppendLine("客户名称,手机号,下单次数,累计消费,客单价,最近下单时间,注册时间,客户标签");
|
||||
|
||||
foreach (var customer in customers)
|
||||
{
|
||||
var tags = customer.Tags.Count == 0
|
||||
? string.Empty
|
||||
: string.Join('、', customer.Tags.Select(item => item.Label));
|
||||
|
||||
var row = new[]
|
||||
{
|
||||
Escape(customer.Name),
|
||||
Escape(customer.PhoneMasked),
|
||||
Escape(customer.OrderCount.ToString(CultureInfo.InvariantCulture)),
|
||||
Escape(customer.TotalAmount.ToString("0.00", CultureInfo.InvariantCulture)),
|
||||
Escape(customer.AverageAmount.ToString("0.00", CultureInfo.InvariantCulture)),
|
||||
Escape(customer.LastOrderAt.ToString("yyyy-MM-dd", CultureInfo.InvariantCulture)),
|
||||
Escape(customer.RegisteredAt.ToString("yyyy-MM-dd", CultureInfo.InvariantCulture)),
|
||||
Escape(tags)
|
||||
};
|
||||
|
||||
sb.AppendLine(string.Join(',', row));
|
||||
}
|
||||
|
||||
return sb.ToString();
|
||||
}
|
||||
|
||||
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,107 @@
|
||||
using MediatR;
|
||||
using TakeoutSaaS.Application.App.Customers.Dto;
|
||||
using TakeoutSaaS.Application.App.Customers.Queries;
|
||||
using TakeoutSaaS.Domain.Orders.Repositories;
|
||||
using TakeoutSaaS.Domain.Products.Repositories;
|
||||
using TakeoutSaaS.Shared.Abstractions.Data;
|
||||
using TakeoutSaaS.Shared.Abstractions.Tenancy;
|
||||
|
||||
namespace TakeoutSaaS.Application.App.Customers.Handlers;
|
||||
|
||||
/// <summary>
|
||||
/// 客户详情查询处理器。
|
||||
/// </summary>
|
||||
public sealed class GetCustomerDetailQueryHandler(
|
||||
IOrderRepository orderRepository,
|
||||
IProductRepository productRepository,
|
||||
IDapperExecutor dapperExecutor,
|
||||
ITenantProvider tenantProvider)
|
||||
: IRequestHandler<GetCustomerDetailQuery, CustomerDetailDto?>
|
||||
{
|
||||
/// <inheritdoc />
|
||||
public async Task<CustomerDetailDto?> Handle(
|
||||
GetCustomerDetailQuery request,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
// 1. 参数与可见门店校验
|
||||
var customerKey = CustomerAnalyticsSupport.NormalizePhone(request.CustomerKey);
|
||||
if (string.IsNullOrWhiteSpace(customerKey) || request.VisibleStoreIds.Count == 0)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
// 2. 加载客户聚合并定位目标客户
|
||||
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;
|
||||
}
|
||||
|
||||
// 3. 加载订单明细并计算画像细节
|
||||
var orderIds = customer.Orders
|
||||
.Select(item => item.OrderId)
|
||||
.ToList();
|
||||
var itemsLookup = await orderRepository.GetItemsByOrderIdsAsync(orderIds, tenantId, cancellationToken);
|
||||
|
||||
var topProducts = CustomerAnalyticsSupport.BuildTopProducts(itemsLookup, orderIds, 5);
|
||||
var preferredCategories = await CustomerAnalyticsSupport.ResolvePreferredCategoriesAsync(
|
||||
productRepository,
|
||||
tenantId,
|
||||
itemsLookup,
|
||||
orderIds,
|
||||
cancellationToken);
|
||||
var preferredDelivery = CustomerAnalyticsSupport.ResolvePreferredDelivery(customer.Orders);
|
||||
var preferredPaymentMethod = await CustomerAnalyticsSupport.ResolvePreferredPaymentMethodAsync(
|
||||
orderRepository,
|
||||
tenantId,
|
||||
customer.Orders,
|
||||
cancellationToken);
|
||||
var preferredOrderPeaks = CustomerAnalyticsSupport.ResolvePreferredOrderPeaks(customer.Orders);
|
||||
var recentOrders = CustomerAnalyticsSupport.BuildRecentOrders(customer.Orders, itemsLookup, 3);
|
||||
var trend = CustomerAnalyticsSupport.BuildMonthlyTrend(customer.Orders, DateTime.UtcNow, 6);
|
||||
var repurchaseRatePercent = CustomerAnalyticsSupport.ToRatePercent(
|
||||
Math.Max(0, customer.OrderCount - 1),
|
||||
customer.OrderCount);
|
||||
|
||||
return new CustomerDetailDto
|
||||
{
|
||||
CustomerKey = customer.CustomerKey,
|
||||
Name = customer.Name,
|
||||
PhoneMasked = customer.PhoneMasked,
|
||||
RegisteredAt = customer.RegisteredAt,
|
||||
FirstOrderAt = customer.FirstOrderAt,
|
||||
Source = customer.Source,
|
||||
Tags = customer.Tags,
|
||||
Member = customer.Member,
|
||||
TotalOrders = customer.OrderCount,
|
||||
TotalAmount = customer.TotalAmount,
|
||||
AverageAmount = customer.AverageAmount,
|
||||
RepurchaseRatePercent = repurchaseRatePercent,
|
||||
Preference = new CustomerPreferenceDto
|
||||
{
|
||||
PreferredCategories = preferredCategories,
|
||||
PreferredOrderPeaks = preferredOrderPeaks,
|
||||
PreferredDelivery = preferredDelivery,
|
||||
PreferredPaymentMethod = preferredPaymentMethod,
|
||||
AverageDeliveryDistance = string.Empty
|
||||
},
|
||||
TopProducts = topProducts,
|
||||
Trend = trend,
|
||||
RecentOrders = recentOrders
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,85 @@
|
||||
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 GetCustomerListStatsQueryHandler(
|
||||
IOrderRepository orderRepository,
|
||||
IDapperExecutor dapperExecutor,
|
||||
ITenantProvider tenantProvider)
|
||||
: IRequestHandler<GetCustomerListStatsQuery, CustomerListStatsDto>
|
||||
{
|
||||
/// <inheritdoc />
|
||||
public async Task<CustomerListStatsDto> Handle(
|
||||
GetCustomerListStatsQuery request,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
// 1. 可见门店为空时直接返回空统计
|
||||
if (request.VisibleStoreIds.Count == 0)
|
||||
{
|
||||
return new CustomerListStatsDto();
|
||||
}
|
||||
|
||||
// 2. 加载客户聚合并应用筛选
|
||||
var customers = await CustomerAnalyticsSupport.LoadCustomersAsync(
|
||||
orderRepository,
|
||||
dapperExecutor,
|
||||
tenantProvider,
|
||||
request.VisibleStoreIds,
|
||||
cancellationToken);
|
||||
|
||||
var nowUtc = DateTime.UtcNow;
|
||||
var filteredCustomers = CustomerAnalyticsSupport.ApplyFilters(
|
||||
customers,
|
||||
request.Keyword,
|
||||
CustomerAnalyticsSupport.NormalizeTag(request.Tag),
|
||||
CustomerAnalyticsSupport.NormalizeOrderCountRange(request.OrderCountRange),
|
||||
request.RegisterPeriodDays,
|
||||
nowUtc);
|
||||
|
||||
// 3. 计算统计指标
|
||||
var monthStart = new DateTime(nowUtc.Year, nowUtc.Month, 1, 0, 0, 0, DateTimeKind.Utc);
|
||||
var nextMonthStart = monthStart.AddMonths(1);
|
||||
var previousMonthStart = monthStart.AddMonths(-1);
|
||||
|
||||
var totalCustomers = filteredCustomers.Count;
|
||||
var monthlyNewCustomers = filteredCustomers.Count(item =>
|
||||
item.RegisteredAt >= monthStart &&
|
||||
item.RegisteredAt < nextMonthStart);
|
||||
var previousMonthlyNewCustomers = filteredCustomers.Count(item =>
|
||||
item.RegisteredAt >= previousMonthStart &&
|
||||
item.RegisteredAt < monthStart);
|
||||
var activeCustomers = filteredCustomers.Count(item =>
|
||||
item.LastOrderAt >= nowUtc.AddDays(-30));
|
||||
|
||||
var recentOrders = filteredCustomers
|
||||
.SelectMany(item => item.Orders)
|
||||
.Where(item => item.OrderedAt >= nowUtc.AddDays(-30))
|
||||
.ToList();
|
||||
|
||||
var averageAmountLast30Days = recentOrders.Count == 0
|
||||
? 0
|
||||
: decimal.Round(
|
||||
recentOrders.Sum(item => item.Amount) / recentOrders.Count,
|
||||
2,
|
||||
MidpointRounding.AwayFromZero);
|
||||
|
||||
return new CustomerListStatsDto
|
||||
{
|
||||
TotalCustomers = totalCustomers,
|
||||
MonthlyNewCustomers = monthlyNewCustomers,
|
||||
MonthlyGrowthRatePercent = CustomerAnalyticsSupport.ToGrowthRatePercent(
|
||||
monthlyNewCustomers,
|
||||
previousMonthlyNewCustomers),
|
||||
ActiveCustomers = activeCustomers,
|
||||
AverageAmountLast30Days = averageAmountLast30Days
|
||||
};
|
||||
}
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user