Compare commits

...

28 Commits

Author SHA1 Message Date
2ba8c0732b Merge pull request 'feat(finance): 财务概览模块 1:1 还原' (#10) from feature/finance-overview-1to1 into dev
Some checks failed
Build and Deploy TenantApi + SkuWorker / build-and-deploy (push) Failing after 2m21s
2026-03-05 03:12:11 +00:00
f7eba55039 feat(finance): add overview dashboard and platform fee rate 2026-03-05 10:47:15 +08:00
fdbefca650 merge: bring finance report api changes into dev
Some checks failed
Build and Deploy TenantApi + SkuWorker / build-and-deploy (push) Failing after 26s
2026-03-04 22:03:57 +08:00
4a7d012a58 merge: bring member points mall changes into dev 2026-03-04 22:02:45 +08:00
d330db84fc Merge pull request #6 from msumshk/feature/finance-invoice-1to1
Feature/finance invoice 1to1
2026-03-04 21:23:17 +08:00
c79e9bd6e8 feat(finance): 完成发票管理模块后端实现 2026-03-04 21:13:33 +08:00
3f308c2d0c chore(docs): update submodule with finance scripts 2026-03-04 17:07:43 +08:00
5dfaac01fd feat(finance): implement invoice and business report backend modules 2026-03-04 16:57:06 +08:00
21a689edec Merge pull request #5 from msumshk/feature/finance-cost-1to1
feat(finance): add cost management backend module
2026-03-04 16:15:02 +08:00
fa6e376b86 feat(finance): add cost management backend module 2026-03-04 16:07:16 +08:00
76366cbc30 Merge pull request #4 from msumshk/feature/finance-report-1to1
feat(finance): add tenant settlement query backend
2026-03-04 16:00:02 +08:00
b0bb87d97c feat(finance): add tenant settlement query backend 2026-03-04 15:48:37 +08:00
b5aa060faf feat(member): add message reach backend module and docs seeds 2026-03-04 13:35:22 +08:00
39e28c1a62 Merge pull request #3 from msumshk/feature/member-points-mall-1to1
feat(member): implement points mall backend module
2026-03-04 12:32:58 +08:00
dd2ac79d48 Merge pull request #2 from msumshk/feature/member-message-reach-module
feat: 完成会员消息触达后端模块
2026-03-04 12:17:33 +08:00
bd418c5927 feat(member): implement points mall backend module 2026-03-04 12:15:18 +08:00
a8cfda88f7 feat: 完成会员消息触达后端模块 2026-03-04 11:53:52 +08:00
e4f7ceeaa7 Merge pull request #1 from msumshk/feature/finance-transaction-module
feat: 新增财务交易流水后端模块
2026-03-04 11:38:34 +08:00
d437b146d1 feat: 新增财务交易流水后端模块 2026-03-04 11:33:29 +08:00
2970134200 feat: implement tenant member stored card module
All checks were successful
Build and Deploy TenantApi + SkuWorker / build-and-deploy (push) Successful in 2m24s
2026-03-04 09:14:57 +08:00
d96ca4971a feat(member): implement member center management module
Some checks failed
Build and Deploy TenantApi + SkuWorker / build-and-deploy (push) Failing after 1m54s
2026-03-03 20:38:31 +08:00
c2821202c7 chore(docs): bump docs submodule for customer analysis seed 2026-03-03 16:46:22 +08:00
26afffd874 feat(customer): add customer analysis query APIs 2026-03-03 16:45:39 +08:00
a993b81aeb feat(customer): 完成客户画像会员摘要与权限链路
All checks were successful
Build and Deploy TenantApi + SkuWorker / build-and-deploy (push) Successful in 2m2s
2026-03-03 14:39:33 +08:00
1b28fa6db4 feat(marketing): implement marketing calendar backend
All checks were successful
Build and Deploy TenantApi + SkuWorker / build-and-deploy (push) Successful in 1m59s
2026-03-03 10:10:41 +08:00
decfa4fa12 fix: resolve ProductBatchToolController xml doc warnings
All checks were successful
Build and Deploy TenantApi + SkuWorker / build-and-deploy (push) Successful in 2m6s
2026-03-03 08:38:42 +08:00
3b3bdcee71 feat: implement marketing punch card backend module 2026-03-02 21:43:09 +08:00
6588c85f27 feat(marketing): add new customer gift backend module 2026-03-02 15:58:06 +08:00
417 changed files with 99295 additions and 2 deletions

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,809 @@
namespace TakeoutSaaS.TenantApi.Contracts.Marketing;
/// <summary>
/// 次卡列表查询请求。
/// </summary>
public sealed class PunchCardListRequest
{
/// <summary>
/// 门店 ID。
/// </summary>
public string StoreId { get; set; } = string.Empty;
/// <summary>
/// 名称关键字。
/// </summary>
public string? Keyword { get; set; }
/// <summary>
/// 状态筛选enabled/disabled
/// </summary>
public string? Status { get; set; }
/// <summary>
/// 页码。
/// </summary>
public int Page { get; set; } = 1;
/// <summary>
/// 每页条数。
/// </summary>
public int PageSize { get; set; } = 4;
}
/// <summary>
/// 次卡详情请求。
/// </summary>
public sealed class PunchCardDetailRequest
{
/// <summary>
/// 门店 ID。
/// </summary>
public string StoreId { get; set; } = string.Empty;
/// <summary>
/// 次卡 ID。
/// </summary>
public string PunchCardId { get; set; } = string.Empty;
}
/// <summary>
/// 保存次卡请求。
/// </summary>
public sealed class SavePunchCardRequest
{
/// <summary>
/// 门店 ID。
/// </summary>
public string StoreId { get; set; } = string.Empty;
/// <summary>
/// 次卡 ID编辑时传
/// </summary>
public string? Id { get; set; }
/// <summary>
/// 次卡名称。
/// </summary>
public string Name { get; set; } = string.Empty;
/// <summary>
/// 封面图地址。
/// </summary>
public string? CoverImageUrl { get; set; }
/// <summary>
/// 售价。
/// </summary>
public decimal SalePrice { get; set; }
/// <summary>
/// 原价。
/// </summary>
public decimal? OriginalPrice { get; set; }
/// <summary>
/// 总次数。
/// </summary>
public int TotalTimes { get; set; }
/// <summary>
/// 有效期类型days/range
/// </summary>
public string ValidityType { get; set; } = "days";
/// <summary>
/// 固定天数。
/// </summary>
public int? ValidityDays { get; set; }
/// <summary>
/// 固定开始日期yyyy-MM-dd
/// </summary>
public string? ValidFrom { get; set; }
/// <summary>
/// 固定结束日期yyyy-MM-dd
/// </summary>
public string? ValidTo { get; set; }
/// <summary>
/// 范围类型all/category/tag/product
/// </summary>
public string ScopeType { get; set; } = "all";
/// <summary>
/// 指定分类 ID。
/// </summary>
public List<string> ScopeCategoryIds { get; set; } = [];
/// <summary>
/// 指定标签 ID。
/// </summary>
public List<string> ScopeTagIds { get; set; } = [];
/// <summary>
/// 指定商品 ID。
/// </summary>
public List<string> ScopeProductIds { get; set; } = [];
/// <summary>
/// 使用模式free/cap
/// </summary>
public string UsageMode { get; set; } = "free";
/// <summary>
/// 单次上限金额。
/// </summary>
public decimal? UsageCapAmount { get; set; }
/// <summary>
/// 每日限用次数。
/// </summary>
public int? DailyLimit { get; set; }
/// <summary>
/// 每单限用次数。
/// </summary>
public int? PerOrderLimit { get; set; }
/// <summary>
/// 每人限购。
/// </summary>
public int? PerUserPurchaseLimit { get; set; }
/// <summary>
/// 是否允许转赠。
/// </summary>
public bool AllowTransfer { get; set; }
/// <summary>
/// 过期策略invalidate/refund
/// </summary>
public string ExpireStrategy { get; set; } = "invalidate";
/// <summary>
/// 次卡描述。
/// </summary>
public string? Description { get; set; }
/// <summary>
/// 通知渠道in_app/sms
/// </summary>
public List<string> NotifyChannels { get; set; } = [];
}
/// <summary>
/// 次卡状态修改请求。
/// </summary>
public sealed class ChangePunchCardStatusRequest
{
/// <summary>
/// 门店 ID。
/// </summary>
public string StoreId { get; set; } = string.Empty;
/// <summary>
/// 次卡 ID。
/// </summary>
public string PunchCardId { get; set; } = string.Empty;
/// <summary>
/// 状态enabled/disabled
/// </summary>
public string Status { get; set; } = "disabled";
}
/// <summary>
/// 次卡删除请求。
/// </summary>
public sealed class DeletePunchCardRequest
{
/// <summary>
/// 门店 ID。
/// </summary>
public string StoreId { get; set; } = string.Empty;
/// <summary>
/// 次卡 ID。
/// </summary>
public string PunchCardId { get; set; } = string.Empty;
}
/// <summary>
/// 次卡使用记录查询请求。
/// </summary>
public sealed class PunchCardUsageRecordListRequest
{
/// <summary>
/// 门店 ID。
/// </summary>
public string StoreId { get; set; } = string.Empty;
/// <summary>
/// 次卡模板 ID。
/// </summary>
public string? PunchCardId { get; set; }
/// <summary>
/// 状态筛选normal/used_up/expired
/// </summary>
public string? Status { get; set; }
/// <summary>
/// 关键字(会员/商品)。
/// </summary>
public string? Keyword { get; set; }
/// <summary>
/// 页码。
/// </summary>
public int Page { get; set; } = 1;
/// <summary>
/// 每页条数。
/// </summary>
public int PageSize { get; set; } = 10;
}
/// <summary>
/// 次卡使用记录导出请求。
/// </summary>
public sealed class ExportPunchCardUsageRecordRequest
{
/// <summary>
/// 门店 ID。
/// </summary>
public string StoreId { get; set; } = string.Empty;
/// <summary>
/// 次卡模板 ID。
/// </summary>
public string? PunchCardId { get; set; }
/// <summary>
/// 状态筛选normal/used_up/expired
/// </summary>
public string? Status { get; set; }
/// <summary>
/// 关键字(会员/商品)。
/// </summary>
public string? Keyword { get; set; }
}
/// <summary>
/// 写入次卡使用记录请求。
/// </summary>
public sealed class WritePunchCardUsageRecordRequest
{
/// <summary>
/// 门店 ID。
/// </summary>
public string StoreId { get; set; } = string.Empty;
/// <summary>
/// 次卡模板 ID。
/// </summary>
public string PunchCardId { get; set; } = string.Empty;
/// <summary>
/// 次卡实例 ID可空
/// </summary>
public string? PunchCardInstanceId { get; set; }
/// <summary>
/// 次卡实例编号(可空)。
/// </summary>
public string? PunchCardInstanceNo { get; set; }
/// <summary>
/// 会员名称。
/// </summary>
public string? MemberName { get; set; }
/// <summary>
/// 会员手机号(脱敏)。
/// </summary>
public string? MemberPhoneMasked { get; set; }
/// <summary>
/// 兑换商品。
/// </summary>
public string ProductName { get; set; } = string.Empty;
/// <summary>
/// 使用时间。
/// </summary>
public DateTime? UsedAt { get; set; }
/// <summary>
/// 本次使用次数。
/// </summary>
public int UsedTimes { get; set; } = 1;
/// <summary>
/// 超额补差金额。
/// </summary>
public decimal? ExtraPayAmount { get; set; }
}
/// <summary>
/// 次卡模板统计。
/// </summary>
public sealed class PunchCardStatsResponse
{
/// <summary>
/// 在售次卡数量。
/// </summary>
public int OnSaleCount { get; set; }
/// <summary>
/// 累计售出数量。
/// </summary>
public int TotalSoldCount { get; set; }
/// <summary>
/// 累计收入。
/// </summary>
public decimal TotalRevenueAmount { get; set; }
/// <summary>
/// 使用中数量。
/// </summary>
public int ActiveInUseCount { get; set; }
}
/// <summary>
/// 次卡列表项。
/// </summary>
public sealed class PunchCardListItemResponse
{
/// <summary>
/// 次卡 ID。
/// </summary>
public string Id { get; set; } = string.Empty;
/// <summary>
/// 次卡名称。
/// </summary>
public string Name { get; set; } = string.Empty;
/// <summary>
/// 封面图。
/// </summary>
public string? CoverImageUrl { get; set; }
/// <summary>
/// 售价。
/// </summary>
public decimal SalePrice { get; set; }
/// <summary>
/// 原价。
/// </summary>
public decimal? OriginalPrice { get; set; }
/// <summary>
/// 总次数。
/// </summary>
public int TotalTimes { get; set; }
/// <summary>
/// 有效期展示。
/// </summary>
public string ValiditySummary { get; set; } = string.Empty;
/// <summary>
/// 适用范围类型。
/// </summary>
public string ScopeType { get; set; } = "all";
/// <summary>
/// 使用模式。
/// </summary>
public string UsageMode { get; set; } = "free";
/// <summary>
/// 单次上限金额。
/// </summary>
public decimal? UsageCapAmount { get; set; }
/// <summary>
/// 每日限用。
/// </summary>
public int? DailyLimit { get; set; }
/// <summary>
/// 状态enabled/disabled
/// </summary>
public string Status { get; set; } = "enabled";
/// <summary>
/// 是否弱化展示。
/// </summary>
public bool IsDimmed { get; set; }
/// <summary>
/// 已售数量。
/// </summary>
public int SoldCount { get; set; }
/// <summary>
/// 使用中数量。
/// </summary>
public int ActiveCount { get; set; }
/// <summary>
/// 累计收入。
/// </summary>
public decimal RevenueAmount { get; set; }
/// <summary>
/// 更新时间yyyy-MM-dd HH:mm:ss
/// </summary>
public string UpdatedAt { get; set; } = string.Empty;
}
/// <summary>
/// 次卡列表结果。
/// </summary>
public sealed class PunchCardListResultResponse
{
/// <summary>
/// 列表。
/// </summary>
public List<PunchCardListItemResponse> Items { get; set; } = [];
/// <summary>
/// 当前页。
/// </summary>
public int Page { get; set; }
/// <summary>
/// 每页条数。
/// </summary>
public int PageSize { get; set; }
/// <summary>
/// 总条数。
/// </summary>
public int TotalCount { get; set; }
/// <summary>
/// 统计。
/// </summary>
public PunchCardStatsResponse Stats { get; set; } = new();
}
/// <summary>
/// 次卡范围。
/// </summary>
public sealed class PunchCardScopeResponse
{
/// <summary>
/// 范围类型all/category/tag/product
/// </summary>
public string ScopeType { get; set; } = "all";
/// <summary>
/// 分类 ID。
/// </summary>
public List<string> CategoryIds { get; set; } = [];
/// <summary>
/// 标签 ID。
/// </summary>
public List<string> TagIds { get; set; } = [];
/// <summary>
/// 商品 ID。
/// </summary>
public List<string> ProductIds { get; set; } = [];
}
/// <summary>
/// 次卡详情。
/// </summary>
public sealed class PunchCardDetailResponse
{
/// <summary>
/// 次卡 ID。
/// </summary>
public string Id { get; set; } = string.Empty;
/// <summary>
/// 门店 ID。
/// </summary>
public string StoreId { get; set; } = string.Empty;
/// <summary>
/// 次卡名称。
/// </summary>
public string Name { get; set; } = string.Empty;
/// <summary>
/// 封面图。
/// </summary>
public string? CoverImageUrl { get; set; }
/// <summary>
/// 售价。
/// </summary>
public decimal SalePrice { get; set; }
/// <summary>
/// 原价。
/// </summary>
public decimal? OriginalPrice { get; set; }
/// <summary>
/// 总次数。
/// </summary>
public int TotalTimes { get; set; }
/// <summary>
/// 有效期类型days/range
/// </summary>
public string ValidityType { get; set; } = "days";
/// <summary>
/// 固定天数。
/// </summary>
public int? ValidityDays { get; set; }
/// <summary>
/// 固定开始日期yyyy-MM-dd
/// </summary>
public string? ValidFrom { get; set; }
/// <summary>
/// 固定结束日期yyyy-MM-dd
/// </summary>
public string? ValidTo { get; set; }
/// <summary>
/// 适用范围。
/// </summary>
public PunchCardScopeResponse Scope { get; set; } = new();
/// <summary>
/// 使用模式free/cap
/// </summary>
public string UsageMode { get; set; } = "free";
/// <summary>
/// 单次上限金额。
/// </summary>
public decimal? UsageCapAmount { get; set; }
/// <summary>
/// 每日限用。
/// </summary>
public int? DailyLimit { get; set; }
/// <summary>
/// 每单限用。
/// </summary>
public int? PerOrderLimit { get; set; }
/// <summary>
/// 每人限购。
/// </summary>
public int? PerUserPurchaseLimit { get; set; }
/// <summary>
/// 是否允许转赠。
/// </summary>
public bool AllowTransfer { get; set; }
/// <summary>
/// 过期策略invalidate/refund
/// </summary>
public string ExpireStrategy { get; set; } = "invalidate";
/// <summary>
/// 描述。
/// </summary>
public string? Description { get; set; }
/// <summary>
/// 通知渠道。
/// </summary>
public List<string> NotifyChannels { get; set; } = [];
/// <summary>
/// 状态enabled/disabled
/// </summary>
public string Status { get; set; } = "enabled";
/// <summary>
/// 已售数量。
/// </summary>
public int SoldCount { get; set; }
/// <summary>
/// 使用中数量。
/// </summary>
public int ActiveCount { get; set; }
/// <summary>
/// 累计收入。
/// </summary>
public decimal RevenueAmount { get; set; }
/// <summary>
/// 更新时间yyyy-MM-dd HH:mm:ss
/// </summary>
public string UpdatedAt { get; set; } = string.Empty;
}
/// <summary>
/// 次卡下拉选项。
/// </summary>
public sealed class PunchCardTemplateOptionResponse
{
/// <summary>
/// 次卡 ID。
/// </summary>
public string TemplateId { get; set; } = string.Empty;
/// <summary>
/// 次卡名称。
/// </summary>
public string Name { get; set; } = string.Empty;
}
/// <summary>
/// 使用记录统计。
/// </summary>
public sealed class PunchCardUsageStatsResponse
{
/// <summary>
/// 今日使用次数。
/// </summary>
public int TodayUsedCount { get; set; }
/// <summary>
/// 本月使用次数。
/// </summary>
public int MonthUsedCount { get; set; }
/// <summary>
/// 7 天内即将过期数量。
/// </summary>
public int ExpiringSoonCount { get; set; }
}
/// <summary>
/// 次卡使用记录项。
/// </summary>
public sealed class PunchCardUsageRecordResponse
{
/// <summary>
/// 使用记录 ID。
/// </summary>
public string Id { get; set; } = string.Empty;
/// <summary>
/// 使用单号。
/// </summary>
public string RecordNo { get; set; } = string.Empty;
/// <summary>
/// 次卡模板 ID。
/// </summary>
public string PunchCardId { get; set; } = string.Empty;
/// <summary>
/// 次卡名称。
/// </summary>
public string PunchCardName { get; set; } = string.Empty;
/// <summary>
/// 次卡实例 ID。
/// </summary>
public string PunchCardInstanceId { get; set; } = string.Empty;
/// <summary>
/// 会员名称。
/// </summary>
public string MemberName { get; set; } = string.Empty;
/// <summary>
/// 会员手机号(脱敏)。
/// </summary>
public string MemberPhoneMasked { get; set; } = string.Empty;
/// <summary>
/// 兑换商品。
/// </summary>
public string ProductName { get; set; } = string.Empty;
/// <summary>
/// 使用时间yyyy-MM-dd HH:mm:ss
/// </summary>
public string UsedAt { get; set; } = string.Empty;
/// <summary>
/// 本次使用次数。
/// </summary>
public int UsedTimes { get; set; }
/// <summary>
/// 剩余次数。
/// </summary>
public int RemainingTimesAfterUse { get; set; }
/// <summary>
/// 总次数。
/// </summary>
public int TotalTimes { get; set; }
/// <summary>
/// 状态normal/almost_used_up/used_up/expired
/// </summary>
public string DisplayStatus { get; set; } = "normal";
/// <summary>
/// 超额补差金额。
/// </summary>
public decimal? ExtraPayAmount { get; set; }
}
/// <summary>
/// 使用记录分页结果。
/// </summary>
public sealed class PunchCardUsageRecordListResultResponse
{
/// <summary>
/// 列表。
/// </summary>
public List<PunchCardUsageRecordResponse> Items { get; set; } = [];
/// <summary>
/// 页码。
/// </summary>
public int Page { get; set; }
/// <summary>
/// 每页条数。
/// </summary>
public int PageSize { get; set; }
/// <summary>
/// 总条数。
/// </summary>
public int TotalCount { get; set; }
/// <summary>
/// 统计。
/// </summary>
public PunchCardUsageStatsResponse Stats { get; set; } = new();
/// <summary>
/// 次卡筛选项。
/// </summary>
public List<PunchCardTemplateOptionResponse> TemplateOptions { get; set; } = [];
}
/// <summary>
/// 使用记录导出回执。
/// </summary>
public sealed class PunchCardUsageRecordExportResponse
{
/// <summary>
/// 文件名。
/// </summary>
public string FileName { get; set; } = string.Empty;
/// <summary>
/// Base64 文件内容。
/// </summary>
public string FileContentBase64 { get; set; } = string.Empty;
/// <summary>
/// 导出总条数。
/// </summary>
public int TotalCount { get; set; }
}

View File

@@ -0,0 +1,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; }
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

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

View File

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

View File

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

View File

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

View File

@@ -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>
/// 批量导出报表 ZIPPDF + 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
};
}
}

View File

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

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,402 @@
using System.Globalization;
using MediatR;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
using TakeoutSaaS.Application.App.Coupons.PunchCard.Commands;
using TakeoutSaaS.Application.App.Coupons.PunchCard.Dto;
using TakeoutSaaS.Application.App.Coupons.PunchCard.Queries;
using TakeoutSaaS.Application.App.Stores.Services;
using TakeoutSaaS.Infrastructure.App.Persistence;
using TakeoutSaaS.Module.Authorization.Attributes;
using TakeoutSaaS.Shared.Abstractions.Constants;
using TakeoutSaaS.Shared.Abstractions.Results;
using TakeoutSaaS.Shared.Web.Api;
using TakeoutSaaS.TenantApi.Contracts.Marketing;
namespace TakeoutSaaS.TenantApi.Controllers;
/// <summary>
/// 营销中心次卡管理。
/// </summary>
[ApiVersion("1.0")]
[Authorize]
[Route("api/tenant/v{version:apiVersion}/marketing/punch-card")]
public sealed class MarketingPunchCardController(
IMediator mediator,
TakeoutAppDbContext dbContext,
StoreContextService storeContextService)
: BaseApiController
{
private const string ViewPermission = "tenant:marketing:punch-card:view";
private const string ManagePermission = "tenant:marketing:punch-card:manage";
/// <summary>
/// 获取次卡列表。
/// </summary>
[HttpGet("list")]
[PermissionAuthorize(ViewPermission, ManagePermission)]
[ProducesResponseType(typeof(ApiResponse<PunchCardListResultResponse>), StatusCodes.Status200OK)]
public async Task<ApiResponse<PunchCardListResultResponse>> List(
[FromQuery] PunchCardListRequest request,
CancellationToken cancellationToken)
{
var storeId = StoreApiHelpers.ParseRequiredSnowflake(request.StoreId, nameof(request.StoreId));
await EnsureStoreAccessibleAsync(storeId, cancellationToken);
var result = await mediator.Send(new GetPunchCardTemplateListQuery
{
StoreId = storeId,
Keyword = request.Keyword,
Status = request.Status,
Page = request.Page,
PageSize = request.PageSize
}, cancellationToken);
return ApiResponse<PunchCardListResultResponse>.Ok(new PunchCardListResultResponse
{
Items = result.Items.Select(MapListItem).ToList(),
Page = result.Page,
PageSize = result.PageSize,
TotalCount = result.TotalCount,
Stats = MapTemplateStats(result.Stats)
});
}
/// <summary>
/// 获取次卡详情。
/// </summary>
[HttpGet("detail")]
[PermissionAuthorize(ViewPermission, ManagePermission)]
[ProducesResponseType(typeof(ApiResponse<PunchCardDetailResponse>), StatusCodes.Status200OK)]
public async Task<ApiResponse<PunchCardDetailResponse>> Detail(
[FromQuery] PunchCardDetailRequest request,
CancellationToken cancellationToken)
{
var storeId = StoreApiHelpers.ParseRequiredSnowflake(request.StoreId, nameof(request.StoreId));
await EnsureStoreAccessibleAsync(storeId, cancellationToken);
var result = await mediator.Send(new GetPunchCardTemplateDetailQuery
{
StoreId = storeId,
TemplateId = StoreApiHelpers.ParseRequiredSnowflake(request.PunchCardId, nameof(request.PunchCardId))
}, cancellationToken);
if (result is null)
{
return ApiResponse<PunchCardDetailResponse>.Error(ErrorCodes.NotFound, "次卡不存在");
}
return ApiResponse<PunchCardDetailResponse>.Ok(MapDetail(result));
}
/// <summary>
/// 保存次卡。
/// </summary>
[HttpPost("save")]
[PermissionAuthorize(ManagePermission)]
[ProducesResponseType(typeof(ApiResponse<PunchCardDetailResponse>), StatusCodes.Status200OK)]
public async Task<ApiResponse<PunchCardDetailResponse>> Save(
[FromBody] SavePunchCardRequest request,
CancellationToken cancellationToken)
{
var storeId = StoreApiHelpers.ParseRequiredSnowflake(request.StoreId, nameof(request.StoreId));
await EnsureStoreAccessibleAsync(storeId, cancellationToken);
var result = await mediator.Send(new SavePunchCardTemplateCommand
{
StoreId = storeId,
TemplateId = StoreApiHelpers.ParseSnowflakeOrNull(request.Id),
Name = request.Name,
CoverImageUrl = request.CoverImageUrl,
SalePrice = request.SalePrice,
OriginalPrice = request.OriginalPrice,
TotalTimes = request.TotalTimes,
ValidityType = request.ValidityType,
ValidityDays = request.ValidityDays,
ValidFrom = ParseDateOrNull(request.ValidFrom, nameof(request.ValidFrom)),
ValidTo = ParseDateOrNull(request.ValidTo, nameof(request.ValidTo)),
ScopeType = request.ScopeType,
ScopeCategoryIds = StoreApiHelpers.ParseSnowflakeList(request.ScopeCategoryIds),
ScopeTagIds = StoreApiHelpers.ParseSnowflakeList(request.ScopeTagIds),
ScopeProductIds = StoreApiHelpers.ParseSnowflakeList(request.ScopeProductIds),
UsageMode = request.UsageMode,
UsageCapAmount = request.UsageCapAmount,
DailyLimit = request.DailyLimit,
PerOrderLimit = request.PerOrderLimit,
PerUserPurchaseLimit = request.PerUserPurchaseLimit,
AllowTransfer = request.AllowTransfer,
ExpireStrategy = request.ExpireStrategy,
Description = request.Description,
NotifyChannels = request.NotifyChannels
}, cancellationToken);
return ApiResponse<PunchCardDetailResponse>.Ok(MapDetail(result));
}
/// <summary>
/// 修改次卡状态。
/// </summary>
[HttpPost("status")]
[PermissionAuthorize(ManagePermission)]
[ProducesResponseType(typeof(ApiResponse<PunchCardDetailResponse>), StatusCodes.Status200OK)]
public async Task<ApiResponse<PunchCardDetailResponse>> ChangeStatus(
[FromBody] ChangePunchCardStatusRequest request,
CancellationToken cancellationToken)
{
var storeId = StoreApiHelpers.ParseRequiredSnowflake(request.StoreId, nameof(request.StoreId));
await EnsureStoreAccessibleAsync(storeId, cancellationToken);
var result = await mediator.Send(new ChangePunchCardTemplateStatusCommand
{
StoreId = storeId,
TemplateId = StoreApiHelpers.ParseRequiredSnowflake(request.PunchCardId, nameof(request.PunchCardId)),
Status = request.Status
}, cancellationToken);
return ApiResponse<PunchCardDetailResponse>.Ok(MapDetail(result));
}
/// <summary>
/// 删除次卡。
/// </summary>
[HttpPost("delete")]
[PermissionAuthorize(ManagePermission)]
[ProducesResponseType(typeof(ApiResponse<object>), StatusCodes.Status200OK)]
public async Task<ApiResponse<object>> Delete(
[FromBody] DeletePunchCardRequest request,
CancellationToken cancellationToken)
{
var storeId = StoreApiHelpers.ParseRequiredSnowflake(request.StoreId, nameof(request.StoreId));
await EnsureStoreAccessibleAsync(storeId, cancellationToken);
await mediator.Send(new DeletePunchCardTemplateCommand
{
StoreId = storeId,
TemplateId = StoreApiHelpers.ParseRequiredSnowflake(request.PunchCardId, nameof(request.PunchCardId))
}, cancellationToken);
return ApiResponse<object>.Ok(null);
}
/// <summary>
/// 获取次卡使用记录。
/// </summary>
[HttpGet("usage-record/list")]
[PermissionAuthorize(ViewPermission, ManagePermission)]
[ProducesResponseType(typeof(ApiResponse<PunchCardUsageRecordListResultResponse>), StatusCodes.Status200OK)]
public async Task<ApiResponse<PunchCardUsageRecordListResultResponse>> UsageRecordList(
[FromQuery] PunchCardUsageRecordListRequest request,
CancellationToken cancellationToken)
{
var storeId = StoreApiHelpers.ParseRequiredSnowflake(request.StoreId, nameof(request.StoreId));
await EnsureStoreAccessibleAsync(storeId, cancellationToken);
var result = await mediator.Send(new GetPunchCardUsageRecordListQuery
{
StoreId = storeId,
TemplateId = StoreApiHelpers.ParseSnowflakeOrNull(request.PunchCardId),
Status = request.Status,
Keyword = request.Keyword,
Page = request.Page,
PageSize = request.PageSize
}, cancellationToken);
return ApiResponse<PunchCardUsageRecordListResultResponse>.Ok(new PunchCardUsageRecordListResultResponse
{
Items = result.Items.Select(MapUsageRecord).ToList(),
Page = result.Page,
PageSize = result.PageSize,
TotalCount = result.TotalCount,
Stats = new PunchCardUsageStatsResponse
{
TodayUsedCount = result.Stats.TodayUsedCount,
MonthUsedCount = result.Stats.MonthUsedCount,
ExpiringSoonCount = result.Stats.ExpiringSoonCount
},
TemplateOptions = result.TemplateOptions.Select(item => new PunchCardTemplateOptionResponse
{
TemplateId = item.TemplateId.ToString(),
Name = item.Name
}).ToList()
});
}
/// <summary>
/// 导出次卡使用记录。
/// </summary>
[HttpGet("usage-record/export")]
[PermissionAuthorize(ManagePermission)]
[ProducesResponseType(typeof(ApiResponse<PunchCardUsageRecordExportResponse>), StatusCodes.Status200OK)]
public async Task<ApiResponse<PunchCardUsageRecordExportResponse>> ExportUsageRecord(
[FromQuery] ExportPunchCardUsageRecordRequest request,
CancellationToken cancellationToken)
{
var storeId = StoreApiHelpers.ParseRequiredSnowflake(request.StoreId, nameof(request.StoreId));
await EnsureStoreAccessibleAsync(storeId, cancellationToken);
var result = await mediator.Send(new ExportPunchCardUsageRecordCsvQuery
{
StoreId = storeId,
TemplateId = StoreApiHelpers.ParseSnowflakeOrNull(request.PunchCardId),
Status = request.Status,
Keyword = request.Keyword
}, cancellationToken);
return ApiResponse<PunchCardUsageRecordExportResponse>.Ok(new PunchCardUsageRecordExportResponse
{
FileName = result.FileName,
FileContentBase64 = result.FileContentBase64,
TotalCount = result.TotalCount
});
}
/// <summary>
/// 写入次卡使用记录。
/// </summary>
[HttpPost("usage-record/write")]
[PermissionAuthorize(ManagePermission)]
[ProducesResponseType(typeof(ApiResponse<PunchCardUsageRecordResponse>), StatusCodes.Status200OK)]
public async Task<ApiResponse<PunchCardUsageRecordResponse>> WriteUsageRecord(
[FromBody] WritePunchCardUsageRecordRequest request,
CancellationToken cancellationToken)
{
var storeId = StoreApiHelpers.ParseRequiredSnowflake(request.StoreId, nameof(request.StoreId));
await EnsureStoreAccessibleAsync(storeId, cancellationToken);
var result = await mediator.Send(new WritePunchCardUsageRecordCommand
{
StoreId = storeId,
TemplateId = StoreApiHelpers.ParseRequiredSnowflake(request.PunchCardId, nameof(request.PunchCardId)),
InstanceId = StoreApiHelpers.ParseSnowflakeOrNull(request.PunchCardInstanceId),
InstanceNo = request.PunchCardInstanceNo,
MemberName = request.MemberName,
MemberPhoneMasked = request.MemberPhoneMasked,
ProductName = request.ProductName,
UsedAt = request.UsedAt,
UsedTimes = request.UsedTimes,
ExtraPayAmount = request.ExtraPayAmount
}, cancellationToken);
return ApiResponse<PunchCardUsageRecordResponse>.Ok(MapUsageRecord(result));
}
private async Task EnsureStoreAccessibleAsync(long storeId, CancellationToken cancellationToken)
{
var (tenantId, merchantId) = StoreApiHelpers.GetTenantMerchantContext(storeContextService);
await StoreApiHelpers.EnsureStoreAccessibleAsync(dbContext, tenantId, merchantId, storeId, cancellationToken);
}
private static DateTime? ParseDateOrNull(string? value, string fieldName)
{
return string.IsNullOrWhiteSpace(value)
? null
: StoreApiHelpers.ParseDateOnly(value, fieldName);
}
private static PunchCardListItemResponse MapListItem(PunchCardListItemDto source)
{
return new PunchCardListItemResponse
{
Id = source.Id.ToString(),
Name = source.Name,
CoverImageUrl = source.CoverImageUrl,
SalePrice = source.SalePrice,
OriginalPrice = source.OriginalPrice,
TotalTimes = source.TotalTimes,
ValiditySummary = source.ValiditySummary,
ScopeType = source.ScopeType,
UsageMode = source.UsageMode,
UsageCapAmount = source.UsageCapAmount,
DailyLimit = source.DailyLimit,
Status = source.Status,
IsDimmed = source.IsDimmed,
SoldCount = source.SoldCount,
ActiveCount = source.ActiveCount,
RevenueAmount = source.RevenueAmount,
UpdatedAt = ToDateTime(source.UpdatedAt)
};
}
private static PunchCardStatsResponse MapTemplateStats(PunchCardStatsDto source)
{
return new PunchCardStatsResponse
{
OnSaleCount = source.OnSaleCount,
TotalSoldCount = source.TotalSoldCount,
TotalRevenueAmount = source.TotalRevenueAmount,
ActiveInUseCount = source.ActiveInUseCount
};
}
private static PunchCardDetailResponse MapDetail(PunchCardDetailDto source)
{
return new PunchCardDetailResponse
{
Id = source.Id.ToString(),
StoreId = source.StoreId.ToString(),
Name = source.Name,
CoverImageUrl = source.CoverImageUrl,
SalePrice = source.SalePrice,
OriginalPrice = source.OriginalPrice,
TotalTimes = source.TotalTimes,
ValidityType = source.ValidityType,
ValidityDays = source.ValidityDays,
ValidFrom = ToDateOnly(source.ValidFrom),
ValidTo = ToDateOnly(source.ValidTo),
Scope = new PunchCardScopeResponse
{
ScopeType = source.Scope.ScopeType,
CategoryIds = source.Scope.CategoryIds.Select(item => item.ToString()).ToList(),
TagIds = source.Scope.TagIds.Select(item => item.ToString()).ToList(),
ProductIds = source.Scope.ProductIds.Select(item => item.ToString()).ToList()
},
UsageMode = source.UsageMode,
UsageCapAmount = source.UsageCapAmount,
DailyLimit = source.DailyLimit,
PerOrderLimit = source.PerOrderLimit,
PerUserPurchaseLimit = source.PerUserPurchaseLimit,
AllowTransfer = source.AllowTransfer,
ExpireStrategy = source.ExpireStrategy,
Description = source.Description,
NotifyChannels = source.NotifyChannels.ToList(),
Status = source.Status,
SoldCount = source.SoldCount,
ActiveCount = source.ActiveCount,
RevenueAmount = source.RevenueAmount,
UpdatedAt = ToDateTime(source.UpdatedAt)
};
}
private static PunchCardUsageRecordResponse MapUsageRecord(PunchCardUsageRecordDto source)
{
return new PunchCardUsageRecordResponse
{
Id = source.Id.ToString(),
RecordNo = source.RecordNo,
PunchCardId = source.PunchCardTemplateId.ToString(),
PunchCardName = source.PunchCardName,
PunchCardInstanceId = source.PunchCardInstanceId.ToString(),
MemberName = source.MemberName,
MemberPhoneMasked = source.MemberPhoneMasked,
ProductName = source.ProductName,
UsedAt = ToDateTime(source.UsedAt),
UsedTimes = source.UsedTimes,
RemainingTimesAfterUse = source.RemainingTimesAfterUse,
TotalTimes = source.TotalTimes,
DisplayStatus = source.DisplayStatus,
ExtraPayAmount = source.ExtraPayAmount
};
}
private static string? ToDateOnly(DateTime? value)
{
return value.HasValue
? value.Value.ToString("yyyy-MM-dd", CultureInfo.InvariantCulture)
: null;
}
private static string ToDateTime(DateTime value)
{
return value.ToString("yyyy-MM-dd HH:mm:ss", CultureInfo.InvariantCulture);
}
}

View File

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

View File

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

View File

@@ -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" => "手动核销",
_ => "未知"
};
}
}

View File

@@ -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" => "余额",
_ => "未知"
};
}
}

View File

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

View File

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

View File

@@ -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),

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,25 @@
using MediatR;
using TakeoutSaaS.Application.App.Coupons.PunchCard.Dto;
namespace TakeoutSaaS.Application.App.Coupons.PunchCard.Commands;
/// <summary>
/// 修改次卡模板状态命令。
/// </summary>
public sealed class ChangePunchCardTemplateStatusCommand : IRequest<PunchCardDetailDto>
{
/// <summary>
/// 操作门店 ID。
/// </summary>
public long StoreId { get; init; }
/// <summary>
/// 次卡模板 ID。
/// </summary>
public long TemplateId { get; init; }
/// <summary>
/// 状态enabled/disabled
/// </summary>
public string Status { get; init; } = "disabled";
}

View File

@@ -0,0 +1,19 @@
using MediatR;
namespace TakeoutSaaS.Application.App.Coupons.PunchCard.Commands;
/// <summary>
/// 删除次卡模板命令。
/// </summary>
public sealed class DeletePunchCardTemplateCommand : IRequest
{
/// <summary>
/// 操作门店 ID。
/// </summary>
public long StoreId { get; init; }
/// <summary>
/// 次卡模板 ID。
/// </summary>
public long TemplateId { get; init; }
}

View File

@@ -0,0 +1,130 @@
using MediatR;
using TakeoutSaaS.Application.App.Coupons.PunchCard.Dto;
namespace TakeoutSaaS.Application.App.Coupons.PunchCard.Commands;
/// <summary>
/// 保存次卡模板命令。
/// </summary>
public sealed class SavePunchCardTemplateCommand : IRequest<PunchCardDetailDto>
{
/// <summary>
/// 操作门店 ID。
/// </summary>
public long StoreId { get; init; }
/// <summary>
/// 次卡模板 ID编辑时传
/// </summary>
public long? TemplateId { get; init; }
/// <summary>
/// 次卡名称。
/// </summary>
public string Name { get; init; } = string.Empty;
/// <summary>
/// 封面图。
/// </summary>
public string? CoverImageUrl { get; init; }
/// <summary>
/// 售价。
/// </summary>
public decimal SalePrice { get; init; }
/// <summary>
/// 原价。
/// </summary>
public decimal? OriginalPrice { get; init; }
/// <summary>
/// 总次数。
/// </summary>
public int TotalTimes { get; init; }
/// <summary>
/// 有效期类型days/range
/// </summary>
public string ValidityType { get; init; } = "days";
/// <summary>
/// 固定天数。
/// </summary>
public int? ValidityDays { get; init; }
/// <summary>
/// 固定开始日期。
/// </summary>
public DateTime? ValidFrom { get; init; }
/// <summary>
/// 固定结束日期。
/// </summary>
public DateTime? ValidTo { get; init; }
/// <summary>
/// 范围类型all/category/tag/product
/// </summary>
public string ScopeType { get; init; } = "all";
/// <summary>
/// 指定分类 ID。
/// </summary>
public IReadOnlyCollection<long> ScopeCategoryIds { get; init; } = [];
/// <summary>
/// 指定标签 ID。
/// </summary>
public IReadOnlyCollection<long> ScopeTagIds { get; init; } = [];
/// <summary>
/// 指定商品 ID。
/// </summary>
public IReadOnlyCollection<long> ScopeProductIds { get; init; } = [];
/// <summary>
/// 使用模式free/cap
/// </summary>
public string UsageMode { get; init; } = "free";
/// <summary>
/// 单次上限金额。
/// </summary>
public decimal? UsageCapAmount { get; init; }
/// <summary>
/// 每日限用次数。
/// </summary>
public int? DailyLimit { get; init; }
/// <summary>
/// 每单限用次数。
/// </summary>
public int? PerOrderLimit { get; init; }
/// <summary>
/// 每人限购张数。
/// </summary>
public int? PerUserPurchaseLimit { get; init; }
/// <summary>
/// 是否允许转赠。
/// </summary>
public bool AllowTransfer { get; init; }
/// <summary>
/// 过期策略invalidate/refund
/// </summary>
public string ExpireStrategy { get; init; } = "invalidate";
/// <summary>
/// 次卡说明。
/// </summary>
public string? Description { get; init; }
/// <summary>
/// 通知渠道in_app/sms
/// </summary>
public IReadOnlyCollection<string> NotifyChannels { get; init; } = [];
}

View File

@@ -0,0 +1,60 @@
using MediatR;
using TakeoutSaaS.Application.App.Coupons.PunchCard.Dto;
namespace TakeoutSaaS.Application.App.Coupons.PunchCard.Commands;
/// <summary>
/// 写入次卡使用记录命令。
/// </summary>
public sealed class WritePunchCardUsageRecordCommand : IRequest<PunchCardUsageRecordDto>
{
/// <summary>
/// 操作门店 ID。
/// </summary>
public long StoreId { get; init; }
/// <summary>
/// 次卡模板 ID。
/// </summary>
public long TemplateId { get; init; }
/// <summary>
/// 次卡实例 ID可空
/// </summary>
public long? InstanceId { get; init; }
/// <summary>
/// 次卡实例编号(可空)。
/// </summary>
public string? InstanceNo { get; init; }
/// <summary>
/// 会员名称(当未指定实例时用于创建实例)。
/// </summary>
public string? MemberName { get; init; }
/// <summary>
/// 会员手机号(脱敏,当未指定实例时用于创建实例)。
/// </summary>
public string? MemberPhoneMasked { get; init; }
/// <summary>
/// 兑换商品名称。
/// </summary>
public string ProductName { get; init; } = string.Empty;
/// <summary>
/// 使用时间(可空,空则取当前 UTC
/// </summary>
public DateTime? UsedAt { get; init; }
/// <summary>
/// 本次使用次数。
/// </summary>
public int UsedTimes { get; init; } = 1;
/// <summary>
/// 超额补差金额。
/// </summary>
public decimal? ExtraPayAmount { get; init; }
}

View File

@@ -0,0 +1,137 @@
namespace TakeoutSaaS.Application.App.Coupons.PunchCard.Dto;
/// <summary>
/// 次卡详情。
/// </summary>
public sealed class PunchCardDetailDto
{
/// <summary>
/// 次卡 ID。
/// </summary>
public long Id { get; init; }
/// <summary>
/// 门店 ID。
/// </summary>
public long StoreId { get; init; }
/// <summary>
/// 次卡名称。
/// </summary>
public string Name { get; init; } = string.Empty;
/// <summary>
/// 封面图。
/// </summary>
public string? CoverImageUrl { get; init; }
/// <summary>
/// 售价。
/// </summary>
public decimal SalePrice { get; init; }
/// <summary>
/// 原价。
/// </summary>
public decimal? OriginalPrice { get; init; }
/// <summary>
/// 总次数。
/// </summary>
public int TotalTimes { get; init; }
/// <summary>
/// 有效期类型days/range
/// </summary>
public string ValidityType { get; init; } = "days";
/// <summary>
/// 固定天数。
/// </summary>
public int? ValidityDays { get; init; }
/// <summary>
/// 固定开始日期UTC
/// </summary>
public DateTime? ValidFrom { get; init; }
/// <summary>
/// 固定结束日期UTC
/// </summary>
public DateTime? ValidTo { get; init; }
/// <summary>
/// 适用范围。
/// </summary>
public PunchCardScopeDto Scope { get; init; } = new();
/// <summary>
/// 使用模式free/cap
/// </summary>
public string UsageMode { get; init; } = "free";
/// <summary>
/// 金额上限。
/// </summary>
public decimal? UsageCapAmount { get; init; }
/// <summary>
/// 每日限用。
/// </summary>
public int? DailyLimit { get; init; }
/// <summary>
/// 每单限用。
/// </summary>
public int? PerOrderLimit { get; init; }
/// <summary>
/// 每人限购。
/// </summary>
public int? PerUserPurchaseLimit { get; init; }
/// <summary>
/// 是否允许转赠。
/// </summary>
public bool AllowTransfer { get; init; }
/// <summary>
/// 过期策略invalidate/refund
/// </summary>
public string ExpireStrategy { get; init; } = "invalidate";
/// <summary>
/// 描述。
/// </summary>
public string? Description { get; init; }
/// <summary>
/// 通知渠道in_app/sms
/// </summary>
public IReadOnlyList<string> NotifyChannels { get; init; } = [];
/// <summary>
/// 状态enabled/disabled
/// </summary>
public string Status { get; init; } = "enabled";
/// <summary>
/// 已售数量。
/// </summary>
public int SoldCount { get; init; }
/// <summary>
/// 使用中数量。
/// </summary>
public int ActiveCount { get; init; }
/// <summary>
/// 累计收入。
/// </summary>
public decimal RevenueAmount { get; init; }
/// <summary>
/// 更新时间。
/// </summary>
public DateTime UpdatedAt { get; init; }
}

View File

@@ -0,0 +1,92 @@
namespace TakeoutSaaS.Application.App.Coupons.PunchCard.Dto;
/// <summary>
/// 次卡列表项。
/// </summary>
public sealed class PunchCardListItemDto
{
/// <summary>
/// 次卡 ID。
/// </summary>
public long Id { get; init; }
/// <summary>
/// 次卡名称。
/// </summary>
public string Name { get; init; } = string.Empty;
/// <summary>
/// 封面图。
/// </summary>
public string? CoverImageUrl { get; init; }
/// <summary>
/// 售价。
/// </summary>
public decimal SalePrice { get; init; }
/// <summary>
/// 原价。
/// </summary>
public decimal? OriginalPrice { get; init; }
/// <summary>
/// 总次数。
/// </summary>
public int TotalTimes { get; init; }
/// <summary>
/// 有效期展示文案。
/// </summary>
public string ValiditySummary { get; init; } = string.Empty;
/// <summary>
/// 适用范围类型all/category/tag/product
/// </summary>
public string ScopeType { get; init; } = "all";
/// <summary>
/// 使用模式free/cap
/// </summary>
public string UsageMode { get; init; } = "free";
/// <summary>
/// 单次使用上限金额。
/// </summary>
public decimal? UsageCapAmount { get; init; }
/// <summary>
/// 每日限用次数。
/// </summary>
public int? DailyLimit { get; init; }
/// <summary>
/// 状态enabled/disabled
/// </summary>
public string Status { get; init; } = "enabled";
/// <summary>
/// 是否弱化展示。
/// </summary>
public bool IsDimmed { get; init; }
/// <summary>
/// 已售数量。
/// </summary>
public int SoldCount { get; init; }
/// <summary>
/// 使用中数量。
/// </summary>
public int ActiveCount { get; init; }
/// <summary>
/// 累计收入。
/// </summary>
public decimal RevenueAmount { get; init; }
/// <summary>
/// 更新时间。
/// </summary>
public DateTime UpdatedAt { get; init; }
}

View File

@@ -0,0 +1,32 @@
namespace TakeoutSaaS.Application.App.Coupons.PunchCard.Dto;
/// <summary>
/// 次卡模板列表结果。
/// </summary>
public sealed class PunchCardListResultDto
{
/// <summary>
/// 列表项。
/// </summary>
public IReadOnlyList<PunchCardListItemDto> Items { get; init; } = [];
/// <summary>
/// 当前页。
/// </summary>
public int Page { get; init; }
/// <summary>
/// 每页条数。
/// </summary>
public int PageSize { get; init; }
/// <summary>
/// 总条数。
/// </summary>
public int TotalCount { get; init; }
/// <summary>
/// 统计数据。
/// </summary>
public PunchCardStatsDto Stats { get; init; } = new();
}

View File

@@ -0,0 +1,27 @@
namespace TakeoutSaaS.Application.App.Coupons.PunchCard.Dto;
/// <summary>
/// 次卡范围规则。
/// </summary>
public sealed class PunchCardScopeDto
{
/// <summary>
/// 范围类型all/category/tag/product
/// </summary>
public string ScopeType { get; init; } = "all";
/// <summary>
/// 指定分类 ID。
/// </summary>
public IReadOnlyList<long> CategoryIds { get; init; } = [];
/// <summary>
/// 指定标签 ID。
/// </summary>
public IReadOnlyList<long> TagIds { get; init; } = [];
/// <summary>
/// 指定商品 ID。
/// </summary>
public IReadOnlyList<long> ProductIds { get; init; } = [];
}

View File

@@ -0,0 +1,27 @@
namespace TakeoutSaaS.Application.App.Coupons.PunchCard.Dto;
/// <summary>
/// 次卡模板统计。
/// </summary>
public sealed class PunchCardStatsDto
{
/// <summary>
/// 在售次卡数量。
/// </summary>
public int OnSaleCount { get; init; }
/// <summary>
/// 累计售出数量。
/// </summary>
public int TotalSoldCount { get; init; }
/// <summary>
/// 累计收入。
/// </summary>
public decimal TotalRevenueAmount { get; init; }
/// <summary>
/// 使用中数量。
/// </summary>
public int ActiveInUseCount { get; init; }
}

View File

@@ -0,0 +1,17 @@
namespace TakeoutSaaS.Application.App.Coupons.PunchCard.Dto;
/// <summary>
/// 次卡过滤选项。
/// </summary>
public sealed class PunchCardTemplateOptionDto
{
/// <summary>
/// 次卡模板 ID。
/// </summary>
public long TemplateId { get; init; }
/// <summary>
/// 次卡名称。
/// </summary>
public string Name { get; init; } = string.Empty;
}

View File

@@ -0,0 +1,77 @@
namespace TakeoutSaaS.Application.App.Coupons.PunchCard.Dto;
/// <summary>
/// 次卡使用记录项。
/// </summary>
public sealed class PunchCardUsageRecordDto
{
/// <summary>
/// 使用记录 ID。
/// </summary>
public long Id { get; init; }
/// <summary>
/// 使用单号。
/// </summary>
public string RecordNo { get; init; } = string.Empty;
/// <summary>
/// 次卡模板 ID。
/// </summary>
public long PunchCardTemplateId { get; init; }
/// <summary>
/// 次卡名称。
/// </summary>
public string PunchCardName { get; init; } = string.Empty;
/// <summary>
/// 次卡实例 ID。
/// </summary>
public long PunchCardInstanceId { get; init; }
/// <summary>
/// 会员名称。
/// </summary>
public string MemberName { get; init; } = string.Empty;
/// <summary>
/// 会员手机号(脱敏)。
/// </summary>
public string MemberPhoneMasked { get; init; } = string.Empty;
/// <summary>
/// 兑换商品名称。
/// </summary>
public string ProductName { get; init; } = string.Empty;
/// <summary>
/// 使用时间。
/// </summary>
public DateTime UsedAt { get; init; }
/// <summary>
/// 本次使用次数。
/// </summary>
public int UsedTimes { get; init; }
/// <summary>
/// 使用后剩余次数。
/// </summary>
public int RemainingTimesAfterUse { get; init; }
/// <summary>
/// 总次数。
/// </summary>
public int TotalTimes { get; init; }
/// <summary>
/// 状态normal/almost_used_up/used_up/expired
/// </summary>
public string DisplayStatus { get; init; } = "normal";
/// <summary>
/// 超额补差金额。
/// </summary>
public decimal? ExtraPayAmount { get; init; }
}

View File

@@ -0,0 +1,22 @@
namespace TakeoutSaaS.Application.App.Coupons.PunchCard.Dto;
/// <summary>
/// 次卡使用记录导出结果。
/// </summary>
public sealed class PunchCardUsageRecordExportDto
{
/// <summary>
/// 文件名。
/// </summary>
public string FileName { get; init; } = string.Empty;
/// <summary>
/// 文件内容Base64
/// </summary>
public string FileContentBase64 { get; init; } = string.Empty;
/// <summary>
/// 导出总条数。
/// </summary>
public int TotalCount { get; init; }
}

View File

@@ -0,0 +1,37 @@
namespace TakeoutSaaS.Application.App.Coupons.PunchCard.Dto;
/// <summary>
/// 次卡使用记录分页结果。
/// </summary>
public sealed class PunchCardUsageRecordListResultDto
{
/// <summary>
/// 列表数据。
/// </summary>
public IReadOnlyList<PunchCardUsageRecordDto> Items { get; init; } = [];
/// <summary>
/// 页码。
/// </summary>
public int Page { get; init; }
/// <summary>
/// 每页条数。
/// </summary>
public int PageSize { get; init; }
/// <summary>
/// 总条数。
/// </summary>
public int TotalCount { get; init; }
/// <summary>
/// 统计数据。
/// </summary>
public PunchCardUsageStatsDto Stats { get; init; } = new();
/// <summary>
/// 次卡筛选选项。
/// </summary>
public IReadOnlyList<PunchCardTemplateOptionDto> TemplateOptions { get; init; } = [];
}

View File

@@ -0,0 +1,22 @@
namespace TakeoutSaaS.Application.App.Coupons.PunchCard.Dto;
/// <summary>
/// 次卡使用记录统计。
/// </summary>
public sealed class PunchCardUsageStatsDto
{
/// <summary>
/// 今日使用次数。
/// </summary>
public int TodayUsedCount { get; init; }
/// <summary>
/// 本月使用次数。
/// </summary>
public int MonthUsedCount { get; init; }
/// <summary>
/// 7 天内即将过期数量。
/// </summary>
public int ExpiringSoonCount { get; init; }
}

View File

@@ -0,0 +1,51 @@
using MediatR;
using TakeoutSaaS.Application.App.Coupons.PunchCard.Commands;
using TakeoutSaaS.Application.App.Coupons.PunchCard.Dto;
using TakeoutSaaS.Domain.Coupons.Repositories;
using TakeoutSaaS.Shared.Abstractions.Constants;
using TakeoutSaaS.Shared.Abstractions.Exceptions;
using TakeoutSaaS.Shared.Abstractions.Tenancy;
namespace TakeoutSaaS.Application.App.Coupons.PunchCard.Handlers;
/// <summary>
/// 次卡状态变更处理器。
/// </summary>
public sealed class ChangePunchCardTemplateStatusCommandHandler(
IPunchCardRepository repository,
ITenantProvider tenantProvider)
: IRequestHandler<ChangePunchCardTemplateStatusCommand, PunchCardDetailDto>
{
/// <inheritdoc />
public async Task<PunchCardDetailDto> Handle(
ChangePunchCardTemplateStatusCommand request,
CancellationToken cancellationToken)
{
var tenantId = tenantProvider.GetCurrentTenantId();
var normalizedStatus = PunchCardMapping.ParseTemplateStatus(request.Status);
var entity = await repository.FindTemplateByIdAsync(
tenantId,
request.StoreId,
request.TemplateId,
cancellationToken)
?? throw new BusinessException(ErrorCodes.NotFound, "次卡不存在");
entity.Status = normalizedStatus;
await repository.UpdateTemplateAsync(entity, cancellationToken);
await repository.SaveChangesAsync(cancellationToken);
var aggregateMap = await repository.GetTemplateAggregateByTemplateIdsAsync(
tenantId,
request.StoreId,
[entity.Id],
cancellationToken);
var aggregate = aggregateMap.TryGetValue(entity.Id, out var value)
? value
: PunchCardDtoFactory.EmptyAggregate(entity.Id);
return PunchCardDtoFactory.ToDetailDto(entity, aggregate);
}
}

View File

@@ -0,0 +1,43 @@
using MediatR;
using TakeoutSaaS.Application.App.Coupons.PunchCard.Commands;
using TakeoutSaaS.Domain.Coupons.Repositories;
using TakeoutSaaS.Shared.Abstractions.Constants;
using TakeoutSaaS.Shared.Abstractions.Exceptions;
using TakeoutSaaS.Shared.Abstractions.Tenancy;
namespace TakeoutSaaS.Application.App.Coupons.PunchCard.Handlers;
/// <summary>
/// 删除次卡模板处理器。
/// </summary>
public sealed class DeletePunchCardTemplateCommandHandler(
IPunchCardRepository repository,
ITenantProvider tenantProvider)
: IRequestHandler<DeletePunchCardTemplateCommand>
{
/// <inheritdoc />
public async Task Handle(DeletePunchCardTemplateCommand request, CancellationToken cancellationToken)
{
var tenantId = tenantProvider.GetCurrentTenantId();
var entity = await repository.FindTemplateByIdAsync(
tenantId,
request.StoreId,
request.TemplateId,
cancellationToken)
?? throw new BusinessException(ErrorCodes.NotFound, "次卡不存在");
var aggregate = await repository.GetTemplateAggregateByTemplateIdsAsync(
tenantId,
request.StoreId,
[entity.Id],
cancellationToken);
if (aggregate.TryGetValue(entity.Id, out var snapshot) && snapshot.SoldCount > 0)
{
throw new BusinessException(ErrorCodes.BadRequest, "已售出的次卡不可删除");
}
await repository.DeleteTemplateAsync(entity, cancellationToken);
await repository.SaveChangesAsync(cancellationToken);
}
}

View File

@@ -0,0 +1,128 @@
using System.Text;
using MediatR;
using TakeoutSaaS.Application.App.Coupons.PunchCard.Dto;
using TakeoutSaaS.Application.App.Coupons.PunchCard.Queries;
using TakeoutSaaS.Domain.Coupons.Repositories;
using TakeoutSaaS.Shared.Abstractions.Tenancy;
namespace TakeoutSaaS.Application.App.Coupons.PunchCard.Handlers;
/// <summary>
/// 导出次卡使用记录处理器。
/// </summary>
public sealed class ExportPunchCardUsageRecordCsvQueryHandler(
IPunchCardRepository repository,
ITenantProvider tenantProvider)
: IRequestHandler<ExportPunchCardUsageRecordCsvQuery, PunchCardUsageRecordExportDto>
{
/// <inheritdoc />
public async Task<PunchCardUsageRecordExportDto> Handle(
ExportPunchCardUsageRecordCsvQuery request,
CancellationToken cancellationToken)
{
var tenantId = tenantProvider.GetCurrentTenantId();
var normalizedStatus = PunchCardMapping.ParseUsageStatusFilter(request.Status);
var records = await repository.ListUsageRecordsForExportAsync(
tenantId,
request.StoreId,
request.TemplateId,
request.Keyword,
normalizedStatus,
cancellationToken);
if (records.Count == 0)
{
return new PunchCardUsageRecordExportDto
{
FileName = $"次卡使用记录_{DateTime.UtcNow:yyyyMMddHHmmss}.csv",
FileContentBase64 = Convert.ToBase64String(Encoding.UTF8.GetBytes("\uFEFF使用单号,会员,手机号,次卡,兑换商品,使用时间,剩余次数,总次数,状态\n")),
TotalCount = 0
};
}
var instanceIds = records.Select(item => item.PunchCardInstanceId).Distinct().ToList();
var instances = await repository.GetInstancesByIdsAsync(
tenantId,
request.StoreId,
instanceIds,
cancellationToken);
var instanceMap = instances.ToDictionary(item => item.Id, item => item);
var templateIds = records.Select(item => item.PunchCardTemplateId)
.Concat(instances.Select(item => item.PunchCardTemplateId))
.Distinct()
.ToList();
var templates = await repository.GetTemplatesByIdsAsync(
tenantId,
request.StoreId,
templateIds,
cancellationToken);
var templateMap = templates.ToDictionary(item => item.Id, item => item);
var csv = BuildCsv(records, instanceMap, templateMap);
var bytes = Encoding.UTF8.GetBytes($"\uFEFF{csv}");
return new PunchCardUsageRecordExportDto
{
FileName = $"次卡使用记录_{DateTime.UtcNow:yyyyMMddHHmmss}.csv",
FileContentBase64 = Convert.ToBase64String(bytes),
TotalCount = records.Count
};
}
private static string BuildCsv(
IReadOnlyCollection<Domain.Coupons.Entities.PunchCardUsageRecord> records,
IReadOnlyDictionary<long, Domain.Coupons.Entities.PunchCardInstance> instanceMap,
IReadOnlyDictionary<long, Domain.Coupons.Entities.PunchCardTemplate> templateMap)
{
var lines = new List<string>
{
"使用单号,会员,手机号,次卡,兑换商品,使用时间,剩余次数,总次数,状态"
};
var nowUtc = DateTime.UtcNow;
foreach (var record in records)
{
instanceMap.TryGetValue(record.PunchCardInstanceId, out var instance);
templateMap.TryGetValue(record.PunchCardTemplateId, out var template);
var dto = PunchCardDtoFactory.ToUsageRecordDto(record, instance, template, nowUtc);
var statusText = ResolveStatusText(dto.DisplayStatus);
lines.Add(string.Join(",",
Escape(dto.RecordNo),
Escape(dto.MemberName),
Escape(dto.MemberPhoneMasked),
Escape(dto.PunchCardName),
Escape(dto.ProductName),
Escape(dto.UsedAt.ToString("yyyy-MM-dd HH:mm:ss")),
dto.RemainingTimesAfterUse,
dto.TotalTimes,
Escape(statusText)));
}
return string.Join('\n', lines);
}
private static string ResolveStatusText(string value)
{
return value switch
{
"normal" => "正常使用",
"almost_used_up" => "即将用完",
"used_up" => "已用完",
"expired" => "已过期",
_ => "正常使用"
};
}
private static string Escape(string value)
{
var text = value.Replace("\"", "\"\"");
return $"\"{text}\"";
}
}

View File

@@ -0,0 +1,47 @@
using MediatR;
using TakeoutSaaS.Application.App.Coupons.PunchCard.Dto;
using TakeoutSaaS.Application.App.Coupons.PunchCard.Queries;
using TakeoutSaaS.Domain.Coupons.Repositories;
using TakeoutSaaS.Shared.Abstractions.Tenancy;
namespace TakeoutSaaS.Application.App.Coupons.PunchCard.Handlers;
/// <summary>
/// 次卡模板详情查询处理器。
/// </summary>
public sealed class GetPunchCardTemplateDetailQueryHandler(
IPunchCardRepository repository,
ITenantProvider tenantProvider)
: IRequestHandler<GetPunchCardTemplateDetailQuery, PunchCardDetailDto?>
{
/// <inheritdoc />
public async Task<PunchCardDetailDto?> Handle(
GetPunchCardTemplateDetailQuery request,
CancellationToken cancellationToken)
{
var tenantId = tenantProvider.GetCurrentTenantId();
var template = await repository.FindTemplateByIdAsync(
tenantId,
request.StoreId,
request.TemplateId,
cancellationToken);
if (template is null)
{
return null;
}
var aggregate = await repository.GetTemplateAggregateByTemplateIdsAsync(
tenantId,
request.StoreId,
[request.TemplateId],
cancellationToken);
var snapshot = aggregate.TryGetValue(template.Id, out var value)
? value
: PunchCardDtoFactory.EmptyAggregate(template.Id);
return PunchCardDtoFactory.ToDetailDto(template, snapshot);
}
}

View File

@@ -0,0 +1,67 @@
using MediatR;
using TakeoutSaaS.Application.App.Coupons.PunchCard.Dto;
using TakeoutSaaS.Application.App.Coupons.PunchCard.Queries;
using TakeoutSaaS.Domain.Coupons.Repositories;
using TakeoutSaaS.Shared.Abstractions.Tenancy;
namespace TakeoutSaaS.Application.App.Coupons.PunchCard.Handlers;
/// <summary>
/// 次卡模板列表查询处理器。
/// </summary>
public sealed class GetPunchCardTemplateListQueryHandler(
IPunchCardRepository repository,
ITenantProvider tenantProvider)
: IRequestHandler<GetPunchCardTemplateListQuery, PunchCardListResultDto>
{
/// <inheritdoc />
public async Task<PunchCardListResultDto> Handle(
GetPunchCardTemplateListQuery request,
CancellationToken cancellationToken)
{
var tenantId = tenantProvider.GetCurrentTenantId();
var page = Math.Max(1, request.Page);
var pageSize = Math.Clamp(request.PageSize, 1, 200);
var status = PunchCardMapping.ParseTemplateStatusFilter(request.Status);
var (items, totalCount) = await repository.SearchTemplatesAsync(
tenantId,
request.StoreId,
request.Keyword,
status,
page,
pageSize,
cancellationToken);
var templateIds = items.Select(item => item.Id).ToList();
var aggregates = await repository.GetTemplateAggregateByTemplateIdsAsync(
tenantId,
request.StoreId,
templateIds,
cancellationToken);
var mappedItems = items
.Select(item =>
{
var aggregate = aggregates.TryGetValue(item.Id, out var value)
? value
: PunchCardDtoFactory.EmptyAggregate(item.Id);
return PunchCardDtoFactory.ToListItemDto(item, aggregate);
})
.ToList();
var statsSnapshot = await repository.GetTemplateStatsAsync(
tenantId,
request.StoreId,
cancellationToken);
return new PunchCardListResultDto
{
Items = mappedItems,
Page = page,
PageSize = pageSize,
TotalCount = totalCount,
Stats = PunchCardDtoFactory.ToStatsDto(statsSnapshot)
};
}
}

View File

@@ -0,0 +1,104 @@
using MediatR;
using TakeoutSaaS.Application.App.Coupons.PunchCard.Dto;
using TakeoutSaaS.Application.App.Coupons.PunchCard.Queries;
using TakeoutSaaS.Domain.Coupons.Repositories;
using TakeoutSaaS.Shared.Abstractions.Tenancy;
namespace TakeoutSaaS.Application.App.Coupons.PunchCard.Handlers;
/// <summary>
/// 次卡使用记录列表查询处理器。
/// </summary>
public sealed class GetPunchCardUsageRecordListQueryHandler(
IPunchCardRepository repository,
ITenantProvider tenantProvider)
: IRequestHandler<GetPunchCardUsageRecordListQuery, PunchCardUsageRecordListResultDto>
{
/// <inheritdoc />
public async Task<PunchCardUsageRecordListResultDto> Handle(
GetPunchCardUsageRecordListQuery request,
CancellationToken cancellationToken)
{
var tenantId = tenantProvider.GetCurrentTenantId();
var page = Math.Max(1, request.Page);
var pageSize = Math.Clamp(request.PageSize, 1, 500);
var normalizedStatus = PunchCardMapping.ParseUsageStatusFilter(request.Status);
var (records, totalCount) = await repository.SearchUsageRecordsAsync(
tenantId,
request.StoreId,
request.TemplateId,
request.Keyword,
normalizedStatus,
page,
pageSize,
cancellationToken);
var instanceIds = records.Select(item => item.PunchCardInstanceId).Distinct().ToList();
var instances = await repository.GetInstancesByIdsAsync(
tenantId,
request.StoreId,
instanceIds,
cancellationToken);
var instanceMap = instances.ToDictionary(item => item.Id, item => item);
var templateIds = records.Select(item => item.PunchCardTemplateId)
.Concat(instances.Select(item => item.PunchCardTemplateId))
.Distinct()
.ToList();
var templates = await repository.GetTemplatesByIdsAsync(
tenantId,
request.StoreId,
templateIds,
cancellationToken);
var templateMap = templates.ToDictionary(item => item.Id, item => item);
var nowUtc = DateTime.UtcNow;
var mappedRecords = records
.Select(record =>
{
instanceMap.TryGetValue(record.PunchCardInstanceId, out var instance);
templateMap.TryGetValue(record.PunchCardTemplateId, out var template);
return PunchCardDtoFactory.ToUsageRecordDto(record, instance, template, nowUtc);
})
.ToList();
var usageStats = await repository.GetUsageStatsAsync(
tenantId,
request.StoreId,
request.TemplateId,
nowUtc,
cancellationToken);
var (templateRows, _) = await repository.SearchTemplatesAsync(
tenantId,
request.StoreId,
null,
null,
1,
500,
cancellationToken);
var templateOptions = templateRows
.OrderBy(item => item.Name, StringComparer.Ordinal)
.Select(item => new PunchCardTemplateOptionDto
{
TemplateId = item.Id,
Name = item.Name
})
.ToList();
return new PunchCardUsageRecordListResultDto
{
Items = mappedRecords,
Page = page,
PageSize = pageSize,
TotalCount = totalCount,
Stats = PunchCardDtoFactory.ToUsageStatsDto(usageStats),
TemplateOptions = templateOptions
};
}
}

View File

@@ -0,0 +1,158 @@
using MediatR;
using TakeoutSaaS.Application.App.Coupons.PunchCard.Commands;
using TakeoutSaaS.Application.App.Coupons.PunchCard.Dto;
using TakeoutSaaS.Domain.Coupons.Enums;
using TakeoutSaaS.Domain.Coupons.Repositories;
using TakeoutSaaS.Shared.Abstractions.Constants;
using TakeoutSaaS.Shared.Abstractions.Exceptions;
using TakeoutSaaS.Shared.Abstractions.Tenancy;
namespace TakeoutSaaS.Application.App.Coupons.PunchCard.Handlers;
/// <summary>
/// 次卡模板保存处理器。
/// </summary>
public sealed class SavePunchCardTemplateCommandHandler(
IPunchCardRepository repository,
ITenantProvider tenantProvider)
: IRequestHandler<SavePunchCardTemplateCommand, PunchCardDetailDto>
{
/// <inheritdoc />
public async Task<PunchCardDetailDto> Handle(
SavePunchCardTemplateCommand request,
CancellationToken cancellationToken)
{
var tenantId = tenantProvider.GetCurrentTenantId();
var normalizedName = PunchCardMapping.NormalizeName(request.Name);
var normalizedCoverImageUrl = PunchCardMapping.NormalizeOptionalCoverUrl(request.CoverImageUrl);
var normalizedSalePrice = PunchCardMapping.NormalizeAmount(request.SalePrice, "salePrice", false);
var normalizedOriginalPrice = PunchCardMapping.NormalizeOptionalAmount(request.OriginalPrice, "originalPrice", true);
var normalizedTotalTimes = PunchCardMapping.NormalizeRequiredPositiveInt(request.TotalTimes, "totalTimes", 10_000);
if (normalizedOriginalPrice.HasValue && normalizedOriginalPrice.Value < normalizedSalePrice)
{
throw new BusinessException(ErrorCodes.BadRequest, "originalPrice 不能小于 salePrice");
}
var validityType = PunchCardMapping.ParseValidityType(request.ValidityType);
var (normalizedValidityDays, normalizedValidFrom, normalizedValidTo) = PunchCardMapping.NormalizeValidity(
validityType,
request.ValidityDays,
request.ValidFrom,
request.ValidTo);
var scopeType = PunchCardMapping.ParseScopeType(request.ScopeType);
var (normalizedCategoryIds, normalizedTagIds, normalizedProductIds) = PunchCardMapping.NormalizeScopeIds(
scopeType,
request.ScopeCategoryIds,
request.ScopeTagIds,
request.ScopeProductIds);
var usageMode = PunchCardMapping.ParseUsageMode(request.UsageMode);
var normalizedUsageCapAmount = usageMode switch
{
PunchCardUsageMode.Free => null,
PunchCardUsageMode.Cap => PunchCardMapping.NormalizeOptionalAmount(request.UsageCapAmount, "usageCapAmount", false),
_ => null
};
if (usageMode == PunchCardUsageMode.Cap && !normalizedUsageCapAmount.HasValue)
{
throw new BusinessException(ErrorCodes.BadRequest, "usageCapAmount 不能为空");
}
var normalizedDailyLimit = PunchCardMapping.NormalizeOptionalLimit(request.DailyLimit, "dailyLimit", normalizedTotalTimes);
var normalizedPerOrderLimit = PunchCardMapping.NormalizeOptionalLimit(request.PerOrderLimit, "perOrderLimit", normalizedTotalTimes);
var normalizedPerUserPurchaseLimit = PunchCardMapping.NormalizeOptionalLimit(request.PerUserPurchaseLimit, "perUserPurchaseLimit", 1000);
var expireStrategy = PunchCardMapping.ParseExpireStrategy(request.ExpireStrategy);
var normalizedDescription = PunchCardMapping.NormalizeOptionalDescription(request.Description);
var normalizedNotifyChannelsJson = PunchCardMapping.SerializeNotifyChannels(request.NotifyChannels);
var normalizedCategoryIdsJson = PunchCardMapping.SerializeSnowflakeIds(normalizedCategoryIds);
var normalizedTagIdsJson = PunchCardMapping.SerializeSnowflakeIds(normalizedTagIds);
var normalizedProductIdsJson = PunchCardMapping.SerializeSnowflakeIds(normalizedProductIds);
if (!request.TemplateId.HasValue)
{
var newEntity = PunchCardDtoFactory.CreateTemplateEntity(
request,
normalizedName,
normalizedCoverImageUrl,
normalizedSalePrice,
normalizedOriginalPrice,
normalizedTotalTimes,
validityType,
normalizedValidityDays,
normalizedValidFrom,
normalizedValidTo,
scopeType,
normalizedCategoryIdsJson,
normalizedTagIdsJson,
normalizedProductIdsJson,
usageMode,
normalizedUsageCapAmount,
normalizedDailyLimit,
normalizedPerOrderLimit,
normalizedPerUserPurchaseLimit,
expireStrategy,
normalizedDescription,
normalizedNotifyChannelsJson);
await repository.AddTemplateAsync(newEntity, cancellationToken);
await repository.SaveChangesAsync(cancellationToken);
return PunchCardDtoFactory.ToDetailDto(
newEntity,
PunchCardDtoFactory.EmptyAggregate(newEntity.Id));
}
var entity = await repository.FindTemplateByIdAsync(
tenantId,
request.StoreId,
request.TemplateId.Value,
cancellationToken)
?? throw new BusinessException(ErrorCodes.NotFound, "次卡不存在");
entity.Name = normalizedName;
entity.CoverImageUrl = string.IsNullOrWhiteSpace(normalizedCoverImageUrl)
? null
: normalizedCoverImageUrl;
entity.SalePrice = normalizedSalePrice;
entity.OriginalPrice = normalizedOriginalPrice;
entity.TotalTimes = normalizedTotalTimes;
entity.ValidityType = validityType;
entity.ValidityDays = normalizedValidityDays;
entity.ValidFrom = normalizedValidFrom;
entity.ValidTo = normalizedValidTo;
entity.ScopeType = scopeType;
entity.ScopeCategoryIdsJson = normalizedCategoryIdsJson;
entity.ScopeTagIdsJson = normalizedTagIdsJson;
entity.ScopeProductIdsJson = normalizedProductIdsJson;
entity.UsageMode = usageMode;
entity.UsageCapAmount = normalizedUsageCapAmount;
entity.DailyLimit = normalizedDailyLimit;
entity.PerOrderLimit = normalizedPerOrderLimit;
entity.PerUserPurchaseLimit = normalizedPerUserPurchaseLimit;
entity.AllowTransfer = request.AllowTransfer;
entity.ExpireStrategy = expireStrategy;
entity.Description = normalizedDescription;
entity.NotifyChannelsJson = normalizedNotifyChannelsJson;
await repository.UpdateTemplateAsync(entity, cancellationToken);
await repository.SaveChangesAsync(cancellationToken);
var aggregateMap = await repository.GetTemplateAggregateByTemplateIdsAsync(
tenantId,
request.StoreId,
[entity.Id],
cancellationToken);
var aggregate = aggregateMap.TryGetValue(entity.Id, out var value)
? value
: PunchCardDtoFactory.EmptyAggregate(entity.Id);
return PunchCardDtoFactory.ToDetailDto(entity, aggregate);
}
}

View File

@@ -0,0 +1,160 @@
using MediatR;
using TakeoutSaaS.Application.App.Coupons.PunchCard.Commands;
using TakeoutSaaS.Application.App.Coupons.PunchCard.Dto;
using TakeoutSaaS.Domain.Coupons.Entities;
using TakeoutSaaS.Domain.Coupons.Enums;
using TakeoutSaaS.Domain.Coupons.Repositories;
using TakeoutSaaS.Shared.Abstractions.Constants;
using TakeoutSaaS.Shared.Abstractions.Exceptions;
using TakeoutSaaS.Shared.Abstractions.Tenancy;
namespace TakeoutSaaS.Application.App.Coupons.PunchCard.Handlers;
/// <summary>
/// 写入次卡使用记录处理器。
/// </summary>
public sealed class WritePunchCardUsageRecordCommandHandler(
IPunchCardRepository repository,
ITenantProvider tenantProvider)
: IRequestHandler<WritePunchCardUsageRecordCommand, PunchCardUsageRecordDto>
{
/// <inheritdoc />
public async Task<PunchCardUsageRecordDto> Handle(
WritePunchCardUsageRecordCommand request,
CancellationToken cancellationToken)
{
var tenantId = tenantProvider.GetCurrentTenantId();
var usedAt = request.UsedAt.HasValue
? PunchCardMapping.NormalizeUtc(request.UsedAt.Value)
: DateTime.UtcNow;
var template = await repository.FindTemplateByIdAsync(
tenantId,
request.StoreId,
request.TemplateId,
cancellationToken)
?? throw new BusinessException(ErrorCodes.NotFound, "次卡不存在");
if (template.Status != PunchCardStatus.Enabled)
{
throw new BusinessException(ErrorCodes.BadRequest, "次卡已下架,无法使用");
}
var productName = PunchCardMapping.NormalizeProductName(request.ProductName);
var usedTimes = PunchCardMapping.NormalizeRequiredPositiveInt(request.UsedTimes, "usedTimes", template.TotalTimes);
var extraPayAmount = PunchCardMapping.NormalizeOptionalAmount(request.ExtraPayAmount, "extraPayAmount", true);
PunchCardInstance? instance = null;
if (request.InstanceId.HasValue && request.InstanceId.Value > 0)
{
instance = await repository.FindInstanceByIdAsync(
tenantId,
request.StoreId,
request.InstanceId.Value,
cancellationToken);
}
else if (!string.IsNullOrWhiteSpace(request.InstanceNo))
{
var normalizedInstanceNo = PunchCardMapping.NormalizeInstanceNo(request.InstanceNo);
instance = await repository.FindInstanceByNoAsync(
tenantId,
request.StoreId,
normalizedInstanceNo,
cancellationToken);
}
if (instance is not null && instance.PunchCardTemplateId != template.Id)
{
throw new BusinessException(ErrorCodes.BadRequest, "次卡实例与模板不匹配");
}
var isNewInstance = false;
if (instance is null)
{
var memberName = PunchCardMapping.NormalizeMemberName(request.MemberName);
var memberPhoneMasked = PunchCardMapping.NormalizeMemberPhoneMasked(request.MemberPhoneMasked);
var purchasedAt = usedAt;
instance = new PunchCardInstance
{
StoreId = request.StoreId,
PunchCardTemplateId = template.Id,
InstanceNo = PunchCardDtoFactory.GenerateInstanceNo(usedAt),
MemberName = memberName,
MemberPhoneMasked = memberPhoneMasked,
PurchasedAt = purchasedAt,
ExpiresAt = PunchCardMapping.ResolveInstanceExpireAt(template, purchasedAt),
TotalTimes = template.TotalTimes,
RemainingTimes = template.TotalTimes,
PaidAmount = template.SalePrice,
Status = PunchCardInstanceStatus.Active
};
isNewInstance = true;
}
if (PunchCardMapping.IsInstanceExpired(instance, usedAt))
{
throw new BusinessException(ErrorCodes.BadRequest, "次卡已过期");
}
if (instance.Status == PunchCardInstanceStatus.Refunded)
{
throw new BusinessException(ErrorCodes.BadRequest, "次卡已退款");
}
if (instance.RemainingTimes <= 0 || instance.Status == PunchCardInstanceStatus.UsedUp)
{
throw new BusinessException(ErrorCodes.BadRequest, "次卡已用完");
}
if (template.PerOrderLimit.HasValue && usedTimes > template.PerOrderLimit.Value)
{
throw new BusinessException(ErrorCodes.BadRequest, "超出每单限用次数");
}
if (usedTimes > instance.RemainingTimes)
{
throw new BusinessException(ErrorCodes.BadRequest, "超出次卡剩余次数");
}
var remainingTimes = instance.RemainingTimes - usedTimes;
var statusAfterUse = PunchCardMapping.ResolveUsageRecordStatus(instance, remainingTimes, usedAt);
instance.RemainingTimes = remainingTimes;
instance.Status = statusAfterUse switch
{
PunchCardUsageRecordStatus.UsedUp => PunchCardInstanceStatus.UsedUp,
PunchCardUsageRecordStatus.Expired => PunchCardInstanceStatus.Expired,
_ => PunchCardInstanceStatus.Active
};
if (isNewInstance)
{
await repository.AddInstanceAsync(instance, cancellationToken);
await repository.SaveChangesAsync(cancellationToken);
}
else
{
await repository.UpdateInstanceAsync(instance, cancellationToken);
}
var record = new PunchCardUsageRecord
{
StoreId = request.StoreId,
PunchCardTemplateId = template.Id,
PunchCardInstanceId = instance.Id,
RecordNo = PunchCardDtoFactory.GenerateRecordNo(usedAt),
ProductName = productName,
UsedAt = usedAt,
UsedTimes = usedTimes,
RemainingTimesAfterUse = remainingTimes,
StatusAfterUse = statusAfterUse,
ExtraPayAmount = extraPayAmount
};
await repository.AddUsageRecordAsync(record, cancellationToken);
await repository.SaveChangesAsync(cancellationToken);
return PunchCardDtoFactory.ToUsageRecordDto(record, instance, template, usedAt);
}
}

View File

@@ -0,0 +1,210 @@
using TakeoutSaaS.Application.App.Coupons.PunchCard.Commands;
using TakeoutSaaS.Application.App.Coupons.PunchCard.Dto;
using TakeoutSaaS.Domain.Coupons.Entities;
using TakeoutSaaS.Domain.Coupons.Enums;
using TakeoutSaaS.Domain.Coupons.Repositories;
namespace TakeoutSaaS.Application.App.Coupons.PunchCard;
/// <summary>
/// 次卡 DTO 构造器。
/// </summary>
internal static class PunchCardDtoFactory
{
public static PunchCardTemplateAggregateSnapshot EmptyAggregate(long templateId)
{
return new PunchCardTemplateAggregateSnapshot
{
TemplateId = templateId,
SoldCount = 0,
ActiveCount = 0,
RevenueAmount = 0m
};
}
public static PunchCardListItemDto ToListItemDto(
PunchCardTemplate template,
PunchCardTemplateAggregateSnapshot aggregate)
{
return new PunchCardListItemDto
{
Id = template.Id,
Name = template.Name,
CoverImageUrl = template.CoverImageUrl,
SalePrice = template.SalePrice,
OriginalPrice = template.OriginalPrice,
TotalTimes = template.TotalTimes,
ValiditySummary = PunchCardMapping.BuildValiditySummary(template),
ScopeType = PunchCardMapping.ToScopeTypeText(template.ScopeType),
UsageMode = PunchCardMapping.ToUsageModeText(template.UsageMode),
UsageCapAmount = template.UsageCapAmount,
DailyLimit = template.DailyLimit,
Status = PunchCardMapping.ToTemplateStatusText(template.Status),
IsDimmed = template.Status == PunchCardStatus.Disabled,
SoldCount = aggregate.SoldCount,
ActiveCount = aggregate.ActiveCount,
RevenueAmount = decimal.Round(aggregate.RevenueAmount, 2, MidpointRounding.AwayFromZero),
UpdatedAt = template.UpdatedAt ?? template.CreatedAt
};
}
public static PunchCardDetailDto ToDetailDto(
PunchCardTemplate template,
PunchCardTemplateAggregateSnapshot aggregate)
{
return new PunchCardDetailDto
{
Id = template.Id,
StoreId = template.StoreId,
Name = template.Name,
CoverImageUrl = template.CoverImageUrl,
SalePrice = template.SalePrice,
OriginalPrice = template.OriginalPrice,
TotalTimes = template.TotalTimes,
ValidityType = PunchCardMapping.ToValidityTypeText(template.ValidityType),
ValidityDays = template.ValidityDays,
ValidFrom = template.ValidFrom,
ValidTo = template.ValidTo,
Scope = new PunchCardScopeDto
{
ScopeType = PunchCardMapping.ToScopeTypeText(template.ScopeType),
CategoryIds = PunchCardMapping.DeserializeSnowflakeIds(template.ScopeCategoryIdsJson),
TagIds = PunchCardMapping.DeserializeSnowflakeIds(template.ScopeTagIdsJson),
ProductIds = PunchCardMapping.DeserializeSnowflakeIds(template.ScopeProductIdsJson)
},
UsageMode = PunchCardMapping.ToUsageModeText(template.UsageMode),
UsageCapAmount = template.UsageCapAmount,
DailyLimit = template.DailyLimit,
PerOrderLimit = template.PerOrderLimit,
PerUserPurchaseLimit = template.PerUserPurchaseLimit,
AllowTransfer = template.AllowTransfer,
ExpireStrategy = PunchCardMapping.ToExpireStrategyText(template.ExpireStrategy),
Description = template.Description,
NotifyChannels = PunchCardMapping.DeserializeNotifyChannels(template.NotifyChannelsJson),
Status = PunchCardMapping.ToTemplateStatusText(template.Status),
SoldCount = aggregate.SoldCount,
ActiveCount = aggregate.ActiveCount,
RevenueAmount = decimal.Round(aggregate.RevenueAmount, 2, MidpointRounding.AwayFromZero),
UpdatedAt = template.UpdatedAt ?? template.CreatedAt
};
}
public static PunchCardStatsDto ToStatsDto(PunchCardTemplateStatsSnapshot source)
{
return new PunchCardStatsDto
{
OnSaleCount = source.OnSaleCount,
TotalSoldCount = source.TotalSoldCount,
TotalRevenueAmount = decimal.Round(source.TotalRevenueAmount, 2, MidpointRounding.AwayFromZero),
ActiveInUseCount = source.ActiveInUseCount
};
}
public static PunchCardUsageStatsDto ToUsageStatsDto(PunchCardUsageStatsSnapshot source)
{
return new PunchCardUsageStatsDto
{
TodayUsedCount = source.TodayUsedCount,
MonthUsedCount = source.MonthUsedCount,
ExpiringSoonCount = source.ExpiringSoonCount
};
}
public static PunchCardUsageRecordDto ToUsageRecordDto(
PunchCardUsageRecord record,
PunchCardInstance? instance,
PunchCardTemplate? template,
DateTime nowUtc)
{
var resolvedTotalTimes = instance?.TotalTimes ?? template?.TotalTimes ?? 0;
var status = record.StatusAfterUse;
if (instance is not null)
{
status = PunchCardMapping.ResolveUsageRecordStatus(instance, record.RemainingTimesAfterUse, nowUtc);
}
return new PunchCardUsageRecordDto
{
Id = record.Id,
RecordNo = record.RecordNo,
PunchCardTemplateId = record.PunchCardTemplateId,
PunchCardName = template?.Name ?? string.Empty,
PunchCardInstanceId = record.PunchCardInstanceId,
MemberName = instance?.MemberName ?? string.Empty,
MemberPhoneMasked = instance?.MemberPhoneMasked ?? string.Empty,
ProductName = record.ProductName,
UsedAt = record.UsedAt,
UsedTimes = record.UsedTimes,
RemainingTimesAfterUse = record.RemainingTimesAfterUse,
TotalTimes = resolvedTotalTimes,
DisplayStatus = PunchCardMapping.ToUsageDisplayStatusText(status),
ExtraPayAmount = record.ExtraPayAmount
};
}
public static PunchCardTemplate CreateTemplateEntity(
SavePunchCardTemplateCommand request,
string normalizedName,
string normalizedCoverImageUrl,
decimal normalizedSalePrice,
decimal? normalizedOriginalPrice,
int normalizedTotalTimes,
PunchCardValidityType validityType,
int? normalizedValidityDays,
DateTime? normalizedValidFrom,
DateTime? normalizedValidTo,
PunchCardScopeType scopeType,
string normalizedCategoryIdsJson,
string normalizedTagIdsJson,
string normalizedProductIdsJson,
PunchCardUsageMode usageMode,
decimal? normalizedUsageCapAmount,
int? normalizedDailyLimit,
int? normalizedPerOrderLimit,
int? normalizedPerUserPurchaseLimit,
PunchCardExpireStrategy expireStrategy,
string? normalizedDescription,
string normalizedNotifyChannelsJson)
{
return new PunchCardTemplate
{
StoreId = request.StoreId,
Name = normalizedName,
CoverImageUrl = string.IsNullOrWhiteSpace(normalizedCoverImageUrl)
? null
: normalizedCoverImageUrl,
SalePrice = normalizedSalePrice,
OriginalPrice = normalizedOriginalPrice,
TotalTimes = normalizedTotalTimes,
ValidityType = validityType,
ValidityDays = normalizedValidityDays,
ValidFrom = normalizedValidFrom,
ValidTo = normalizedValidTo,
ScopeType = scopeType,
ScopeCategoryIdsJson = normalizedCategoryIdsJson,
ScopeTagIdsJson = normalizedTagIdsJson,
ScopeProductIdsJson = normalizedProductIdsJson,
UsageMode = usageMode,
UsageCapAmount = normalizedUsageCapAmount,
DailyLimit = normalizedDailyLimit,
PerOrderLimit = normalizedPerOrderLimit,
PerUserPurchaseLimit = normalizedPerUserPurchaseLimit,
AllowTransfer = request.AllowTransfer,
ExpireStrategy = expireStrategy,
Description = normalizedDescription,
NotifyChannelsJson = normalizedNotifyChannelsJson,
Status = PunchCardStatus.Enabled
};
}
public static string GenerateInstanceNo(DateTime nowUtc)
{
return $"PKI{nowUtc:yyyyMMddHHmmssfff}{Random.Shared.Next(1000, 9999)}";
}
public static string GenerateRecordNo(DateTime nowUtc)
{
return $"PK{nowUtc:yyyyMMddHHmmssfff}{Random.Shared.Next(1000, 9999)}";
}
}

View File

@@ -0,0 +1,546 @@
using System.Text.Json;
using TakeoutSaaS.Application.App.Coupons.PunchCard.Dto;
using TakeoutSaaS.Domain.Coupons.Entities;
using TakeoutSaaS.Domain.Coupons.Enums;
using TakeoutSaaS.Domain.Coupons.Repositories;
using TakeoutSaaS.Shared.Abstractions.Constants;
using TakeoutSaaS.Shared.Abstractions.Exceptions;
namespace TakeoutSaaS.Application.App.Coupons.PunchCard;
/// <summary>
/// 次卡模块映射与标准化。
/// </summary>
internal static class PunchCardMapping
{
private static readonly HashSet<string> AllowedNotifyChannels =
[
"in_app",
"sms"
];
private static readonly JsonSerializerOptions JsonOptions = new(JsonSerializerDefaults.Web)
{
PropertyNameCaseInsensitive = true
};
public static PunchCardStatus? ParseTemplateStatusFilter(string? value)
{
var normalized = (value ?? string.Empty).Trim().ToLowerInvariant();
return normalized switch
{
"" => null,
"enabled" => PunchCardStatus.Enabled,
"disabled" => PunchCardStatus.Disabled,
_ => throw new BusinessException(ErrorCodes.BadRequest, "status 参数不合法")
};
}
public static PunchCardStatus ParseTemplateStatus(string? value)
{
var normalized = (value ?? string.Empty).Trim().ToLowerInvariant();
return normalized switch
{
"enabled" => PunchCardStatus.Enabled,
"disabled" => PunchCardStatus.Disabled,
_ => throw new BusinessException(ErrorCodes.BadRequest, "status 参数不合法")
};
}
public static string ToTemplateStatusText(PunchCardStatus value)
{
return value switch
{
PunchCardStatus.Enabled => "enabled",
PunchCardStatus.Disabled => "disabled",
_ => "disabled"
};
}
public static PunchCardValidityType ParseValidityType(string? value)
{
var normalized = (value ?? string.Empty).Trim().ToLowerInvariant();
return normalized switch
{
"days" => PunchCardValidityType.Days,
"range" => PunchCardValidityType.DateRange,
_ => throw new BusinessException(ErrorCodes.BadRequest, "validityType 参数不合法")
};
}
public static string ToValidityTypeText(PunchCardValidityType value)
{
return value switch
{
PunchCardValidityType.Days => "days",
PunchCardValidityType.DateRange => "range",
_ => "days"
};
}
public static PunchCardScopeType ParseScopeType(string? value)
{
var normalized = (value ?? string.Empty).Trim().ToLowerInvariant();
return normalized switch
{
"all" => PunchCardScopeType.All,
"category" => PunchCardScopeType.Category,
"tag" => PunchCardScopeType.Tag,
"product" => PunchCardScopeType.Product,
_ => throw new BusinessException(ErrorCodes.BadRequest, "scopeType 参数不合法")
};
}
public static string ToScopeTypeText(PunchCardScopeType value)
{
return value switch
{
PunchCardScopeType.All => "all",
PunchCardScopeType.Category => "category",
PunchCardScopeType.Tag => "tag",
PunchCardScopeType.Product => "product",
_ => "all"
};
}
public static PunchCardUsageMode ParseUsageMode(string? value)
{
var normalized = (value ?? string.Empty).Trim().ToLowerInvariant();
return normalized switch
{
"free" => PunchCardUsageMode.Free,
"cap" => PunchCardUsageMode.Cap,
_ => throw new BusinessException(ErrorCodes.BadRequest, "usageMode 参数不合法")
};
}
public static string ToUsageModeText(PunchCardUsageMode value)
{
return value switch
{
PunchCardUsageMode.Free => "free",
PunchCardUsageMode.Cap => "cap",
_ => "free"
};
}
public static PunchCardExpireStrategy ParseExpireStrategy(string? value)
{
var normalized = (value ?? string.Empty).Trim().ToLowerInvariant();
return normalized switch
{
"invalidate" => PunchCardExpireStrategy.Invalidate,
"refund" => PunchCardExpireStrategy.Refund,
_ => throw new BusinessException(ErrorCodes.BadRequest, "expireStrategy 参数不合法")
};
}
public static string ToExpireStrategyText(PunchCardExpireStrategy value)
{
return value switch
{
PunchCardExpireStrategy.Invalidate => "invalidate",
PunchCardExpireStrategy.Refund => "refund",
_ => "invalidate"
};
}
public static PunchCardUsageRecordFilterStatus? ParseUsageStatusFilter(string? value)
{
var normalized = (value ?? string.Empty).Trim().ToLowerInvariant();
return normalized switch
{
"" => null,
"normal" => PunchCardUsageRecordFilterStatus.Normal,
"used_up" => PunchCardUsageRecordFilterStatus.UsedUp,
"expired" => PunchCardUsageRecordFilterStatus.Expired,
_ => throw new BusinessException(ErrorCodes.BadRequest, "status 参数不合法")
};
}
public static string ToUsageDisplayStatusText(PunchCardUsageRecordStatus value)
{
return value switch
{
PunchCardUsageRecordStatus.Normal => "normal",
PunchCardUsageRecordStatus.AlmostUsedUp => "almost_used_up",
PunchCardUsageRecordStatus.UsedUp => "used_up",
PunchCardUsageRecordStatus.Expired => "expired",
_ => "normal"
};
}
public static DateTime NormalizeUtc(DateTime value)
{
return value.Kind switch
{
DateTimeKind.Utc => value,
DateTimeKind.Local => value.ToUniversalTime(),
_ => DateTime.SpecifyKind(value, DateTimeKind.Utc)
};
}
public static string NormalizeName(string? value)
{
var normalized = (value ?? string.Empty).Trim();
if (string.IsNullOrWhiteSpace(normalized))
{
throw new BusinessException(ErrorCodes.BadRequest, "name 不能为空");
}
if (normalized.Length > 64)
{
throw new BusinessException(ErrorCodes.BadRequest, "name 长度不能超过 64");
}
return normalized;
}
public static string NormalizeOptionalCoverUrl(string? value)
{
var normalized = (value ?? string.Empty).Trim();
if (string.IsNullOrWhiteSpace(normalized))
{
return string.Empty;
}
if (normalized.Length > 512)
{
throw new BusinessException(ErrorCodes.BadRequest, "coverImageUrl 长度不能超过 512");
}
return normalized;
}
public static string? NormalizeOptionalDescription(string? value)
{
var normalized = (value ?? string.Empty).Trim();
if (normalized.Length == 0)
{
return null;
}
if (normalized.Length > 512)
{
throw new BusinessException(ErrorCodes.BadRequest, "description 长度不能超过 512");
}
return normalized;
}
public static string NormalizeInstanceNo(string? value)
{
var normalized = (value ?? string.Empty).Trim();
if (string.IsNullOrWhiteSpace(normalized))
{
throw new BusinessException(ErrorCodes.BadRequest, "punchCardInstanceNo 不能为空");
}
if (normalized.Length > 32)
{
throw new BusinessException(ErrorCodes.BadRequest, "punchCardInstanceNo 长度不能超过 32");
}
return normalized;
}
public static string NormalizeMemberName(string? value)
{
var normalized = (value ?? string.Empty).Trim();
if (string.IsNullOrWhiteSpace(normalized))
{
throw new BusinessException(ErrorCodes.BadRequest, "memberName 不能为空");
}
if (normalized.Length > 64)
{
throw new BusinessException(ErrorCodes.BadRequest, "memberName 长度不能超过 64");
}
return normalized;
}
public static string NormalizeMemberPhoneMasked(string? value)
{
var normalized = (value ?? string.Empty).Trim();
if (string.IsNullOrWhiteSpace(normalized))
{
throw new BusinessException(ErrorCodes.BadRequest, "memberPhoneMasked 不能为空");
}
if (normalized.Length > 32)
{
throw new BusinessException(ErrorCodes.BadRequest, "memberPhoneMasked 长度不能超过 32");
}
return normalized;
}
public static string NormalizeProductName(string? value)
{
var normalized = (value ?? string.Empty).Trim();
if (string.IsNullOrWhiteSpace(normalized))
{
throw new BusinessException(ErrorCodes.BadRequest, "productName 不能为空");
}
if (normalized.Length > 128)
{
throw new BusinessException(ErrorCodes.BadRequest, "productName 长度不能超过 128");
}
return normalized;
}
public static decimal NormalizeAmount(decimal value, string fieldName, bool allowZero = false)
{
if (value < 0 || (!allowZero && value <= 0))
{
throw new BusinessException(ErrorCodes.BadRequest, $"{fieldName} 参数不合法");
}
return decimal.Round(value, 2, MidpointRounding.AwayFromZero);
}
public static decimal? NormalizeOptionalAmount(decimal? value, string fieldName, bool allowZero = true)
{
if (!value.HasValue)
{
return null;
}
if (value.Value < 0 || (!allowZero && value.Value <= 0))
{
throw new BusinessException(ErrorCodes.BadRequest, $"{fieldName} 参数不合法");
}
return decimal.Round(value.Value, 2, MidpointRounding.AwayFromZero);
}
public static int NormalizeRequiredPositiveInt(int value, string fieldName, int max = 100_000)
{
if (value <= 0 || value > max)
{
throw new BusinessException(ErrorCodes.BadRequest, $"{fieldName} 参数不合法");
}
return value;
}
public static int? NormalizeOptionalLimit(int? value, string fieldName, int max = 100_000)
{
if (!value.HasValue || value.Value <= 0)
{
return null;
}
if (value.Value > max)
{
throw new BusinessException(ErrorCodes.BadRequest, $"{fieldName} 参数不合法");
}
return value;
}
public static (int? ValidityDays, DateTime? ValidFrom, DateTime? ValidTo) NormalizeValidity(
PunchCardValidityType validityType,
int? validityDays,
DateTime? validFrom,
DateTime? validTo)
{
return validityType switch
{
PunchCardValidityType.Days =>
(
NormalizeRequiredPositiveInt(validityDays ?? 0, "validityDays", 3650),
null,
null
),
PunchCardValidityType.DateRange => NormalizeRange(validFrom, validTo),
_ => throw new BusinessException(ErrorCodes.BadRequest, "validityType 参数不合法")
};
}
public static (IReadOnlyList<long> CategoryIds, IReadOnlyList<long> TagIds, IReadOnlyList<long> ProductIds) NormalizeScopeIds(
PunchCardScopeType scopeType,
IReadOnlyCollection<long>? categoryIds,
IReadOnlyCollection<long>? tagIds,
IReadOnlyCollection<long>? productIds)
{
var normalizedCategoryIds = NormalizeSnowflakeIds(categoryIds, "scopeCategoryIds", false);
var normalizedTagIds = NormalizeSnowflakeIds(tagIds, "scopeTagIds", false);
var normalizedProductIds = NormalizeSnowflakeIds(productIds, "scopeProductIds", false);
return scopeType switch
{
PunchCardScopeType.All => ([], [], []),
PunchCardScopeType.Category =>
normalizedCategoryIds.Count == 0
? throw new BusinessException(ErrorCodes.BadRequest, "scopeCategoryIds 不能为空")
: (normalizedCategoryIds, [], []),
PunchCardScopeType.Tag =>
normalizedTagIds.Count == 0
? throw new BusinessException(ErrorCodes.BadRequest, "scopeTagIds 不能为空")
: ([], normalizedTagIds, []),
PunchCardScopeType.Product =>
normalizedProductIds.Count == 0
? throw new BusinessException(ErrorCodes.BadRequest, "scopeProductIds 不能为空")
: ([], [], normalizedProductIds),
_ => throw new BusinessException(ErrorCodes.BadRequest, "scopeType 参数不合法")
};
}
public static IReadOnlyList<string> NormalizeNotifyChannels(IEnumerable<string>? values)
{
var normalized = (values ?? [])
.Select(item => (item ?? string.Empty).Trim().ToLowerInvariant())
.Where(item => !string.IsNullOrWhiteSpace(item))
.Distinct()
.ToList();
if (normalized.Count == 0)
{
throw new BusinessException(ErrorCodes.BadRequest, "notifyChannels 不能为空");
}
if (normalized.Any(item => !AllowedNotifyChannels.Contains(item)))
{
throw new BusinessException(ErrorCodes.BadRequest, "notifyChannels 存在非法值");
}
return normalized;
}
public static IReadOnlyList<string> DeserializeNotifyChannels(string? payload)
{
if (string.IsNullOrWhiteSpace(payload))
{
return [];
}
var values = JsonSerializer.Deserialize<List<string>>(payload, JsonOptions) ?? [];
return values
.Select(item => (item ?? string.Empty).Trim().ToLowerInvariant())
.Where(item => AllowedNotifyChannels.Contains(item))
.Distinct()
.ToList();
}
public static string SerializeNotifyChannels(IEnumerable<string>? values)
{
return JsonSerializer.Serialize(NormalizeNotifyChannels(values), JsonOptions);
}
public static IReadOnlyList<long> DeserializeSnowflakeIds(string? payload)
{
if (string.IsNullOrWhiteSpace(payload))
{
return [];
}
var values = JsonSerializer.Deserialize<List<long>>(payload, JsonOptions) ?? [];
return values
.Where(id => id > 0)
.Distinct()
.OrderBy(id => id)
.ToList();
}
public static string SerializeSnowflakeIds(IEnumerable<long>? values)
{
return JsonSerializer.Serialize(NormalizeSnowflakeIds(values, "ids", false), JsonOptions);
}
public static string BuildValiditySummary(PunchCardTemplate template)
{
return template.ValidityType switch
{
PunchCardValidityType.Days => $"{template.ValidityDays ?? 0}天有效",
PunchCardValidityType.DateRange when template.ValidFrom.HasValue && template.ValidTo.HasValue =>
$"{template.ValidFrom.Value:yyyy-MM-dd} 至 {template.ValidTo.Value:yyyy-MM-dd}",
_ => "-"
};
}
public static DateTime ResolveInstanceExpireAt(PunchCardTemplate template, DateTime purchasedAtUtc)
{
var purchasedAt = NormalizeUtc(purchasedAtUtc);
return template.ValidityType switch
{
PunchCardValidityType.Days => purchasedAt.Date.AddDays(template.ValidityDays ?? 0).AddTicks(-1),
PunchCardValidityType.DateRange => template.ValidTo ?? purchasedAt.Date.AddTicks(-1),
_ => purchasedAt.Date.AddTicks(-1)
};
}
public static bool IsInstanceExpired(PunchCardInstance instance, DateTime nowUtc)
{
var utcNow = NormalizeUtc(nowUtc);
if (instance.Status == PunchCardInstanceStatus.Expired)
{
return true;
}
return instance.ExpiresAt.HasValue && instance.ExpiresAt.Value < utcNow;
}
public static PunchCardUsageRecordStatus ResolveUsageRecordStatus(
PunchCardInstance instance,
int remainingTimes,
DateTime usedAtUtc)
{
if (IsInstanceExpired(instance, usedAtUtc))
{
return PunchCardUsageRecordStatus.Expired;
}
if (remainingTimes <= 0)
{
return PunchCardUsageRecordStatus.UsedUp;
}
return remainingTimes <= 2
? PunchCardUsageRecordStatus.AlmostUsedUp
: PunchCardUsageRecordStatus.Normal;
}
private static IReadOnlyList<long> NormalizeSnowflakeIds(
IEnumerable<long>? values,
string fieldName,
bool required)
{
var normalized = (values ?? [])
.Where(id => id > 0)
.Distinct()
.OrderBy(id => id)
.ToList();
if (required && normalized.Count == 0)
{
throw new BusinessException(ErrorCodes.BadRequest, $"{fieldName} 不能为空");
}
return normalized;
}
private static (int? ValidityDays, DateTime? ValidFrom, DateTime? ValidTo) NormalizeRange(
DateTime? validFrom,
DateTime? validTo)
{
if (!validFrom.HasValue || !validTo.HasValue)
{
throw new BusinessException(ErrorCodes.BadRequest, "validFrom / validTo 不能为空");
}
var normalizedFrom = NormalizeUtc(validFrom.Value).Date;
var normalizedTo = NormalizeUtc(validTo.Value).Date.AddDays(1).AddTicks(-1);
if (normalizedFrom > normalizedTo)
{
throw new BusinessException(ErrorCodes.BadRequest, "validFrom 不能晚于 validTo");
}
return (null, normalizedFrom, normalizedTo);
}
}

View File

@@ -0,0 +1,30 @@
using MediatR;
using TakeoutSaaS.Application.App.Coupons.PunchCard.Dto;
namespace TakeoutSaaS.Application.App.Coupons.PunchCard.Queries;
/// <summary>
/// 导出次卡使用记录 CSV。
/// </summary>
public sealed class ExportPunchCardUsageRecordCsvQuery : IRequest<PunchCardUsageRecordExportDto>
{
/// <summary>
/// 操作门店 ID。
/// </summary>
public long StoreId { get; init; }
/// <summary>
/// 次卡模板筛选 ID可空
/// </summary>
public long? TemplateId { get; init; }
/// <summary>
/// 状态筛选normal/used_up/expired
/// </summary>
public string? Status { get; init; }
/// <summary>
/// 关键字(会员/商品)。
/// </summary>
public string? Keyword { get; init; }
}

View File

@@ -0,0 +1,20 @@
using MediatR;
using TakeoutSaaS.Application.App.Coupons.PunchCard.Dto;
namespace TakeoutSaaS.Application.App.Coupons.PunchCard.Queries;
/// <summary>
/// 查询次卡模板详情。
/// </summary>
public sealed class GetPunchCardTemplateDetailQuery : IRequest<PunchCardDetailDto?>
{
/// <summary>
/// 操作门店 ID。
/// </summary>
public long StoreId { get; init; }
/// <summary>
/// 次卡模板 ID。
/// </summary>
public long TemplateId { get; init; }
}

View File

@@ -0,0 +1,35 @@
using MediatR;
using TakeoutSaaS.Application.App.Coupons.PunchCard.Dto;
namespace TakeoutSaaS.Application.App.Coupons.PunchCard.Queries;
/// <summary>
/// 查询次卡模板列表。
/// </summary>
public sealed class GetPunchCardTemplateListQuery : IRequest<PunchCardListResultDto>
{
/// <summary>
/// 操作门店 ID。
/// </summary>
public long StoreId { get; init; }
/// <summary>
/// 名称关键字。
/// </summary>
public string? Keyword { get; init; }
/// <summary>
/// 状态筛选enabled/disabled
/// </summary>
public string? Status { get; init; }
/// <summary>
/// 页码。
/// </summary>
public int Page { get; init; } = 1;
/// <summary>
/// 每页条数。
/// </summary>
public int PageSize { get; init; } = 4;
}

View File

@@ -0,0 +1,40 @@
using MediatR;
using TakeoutSaaS.Application.App.Coupons.PunchCard.Dto;
namespace TakeoutSaaS.Application.App.Coupons.PunchCard.Queries;
/// <summary>
/// 查询次卡使用记录列表。
/// </summary>
public sealed class GetPunchCardUsageRecordListQuery : IRequest<PunchCardUsageRecordListResultDto>
{
/// <summary>
/// 操作门店 ID。
/// </summary>
public long StoreId { get; init; }
/// <summary>
/// 次卡模板筛选 ID可空
/// </summary>
public long? TemplateId { get; init; }
/// <summary>
/// 状态筛选normal/used_up/expired
/// </summary>
public string? Status { get; init; }
/// <summary>
/// 关键字(会员/商品)。
/// </summary>
public string? Keyword { get; init; }
/// <summary>
/// 页码。
/// </summary>
public int Page { get; init; } = 1;
/// <summary>
/// 每页条数。
/// </summary>
public int PageSize { get; init; } = 10;
}

View File

@@ -0,0 +1,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; } = [];
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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