feat(customer): 完成客户画像会员摘要与权限链路
All checks were successful
Build and Deploy TenantApi + SkuWorker / build-and-deploy (push) Successful in 2m2s
All checks were successful
Build and Deploy TenantApi + SkuWorker / build-and-deploy (push) Successful in 2m2s
This commit is contained in:
Submodule TakeoutSaaS.Docs updated: c98e4ba3c4...7486bf272e
@@ -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; }
|
||||||
|
}
|
||||||
392
src/Api/TakeoutSaaS.TenantApi/Controllers/CustomerController.cs
Normal file
392
src/Api/TakeoutSaaS.TenantApi/Controllers/CustomerController.cs
Normal file
@@ -0,0 +1,392 @@
|
|||||||
|
using System.Globalization;
|
||||||
|
using MediatR;
|
||||||
|
using Microsoft.AspNetCore.Authorization;
|
||||||
|
using Microsoft.AspNetCore.Mvc;
|
||||||
|
using Microsoft.EntityFrameworkCore;
|
||||||
|
using TakeoutSaaS.Application.App.Customers.Dto;
|
||||||
|
using TakeoutSaaS.Application.App.Customers.Queries;
|
||||||
|
using TakeoutSaaS.Application.App.Stores.Services;
|
||||||
|
using TakeoutSaaS.Infrastructure.App.Persistence;
|
||||||
|
using TakeoutSaaS.Module.Authorization.Attributes;
|
||||||
|
using TakeoutSaaS.Shared.Abstractions.Constants;
|
||||||
|
using TakeoutSaaS.Shared.Abstractions.Exceptions;
|
||||||
|
using TakeoutSaaS.Shared.Abstractions.Results;
|
||||||
|
using TakeoutSaaS.Shared.Web.Api;
|
||||||
|
using TakeoutSaaS.TenantApi.Contracts.Customer;
|
||||||
|
|
||||||
|
namespace TakeoutSaaS.TenantApi.Controllers;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 客户管理列表与画像。
|
||||||
|
/// </summary>
|
||||||
|
[ApiVersion("1.0")]
|
||||||
|
[Authorize]
|
||||||
|
[Route("api/tenant/v{version:apiVersion}/customer/list")]
|
||||||
|
public sealed class CustomerController(
|
||||||
|
IMediator mediator,
|
||||||
|
TakeoutAppDbContext dbContext,
|
||||||
|
StoreContextService storeContextService)
|
||||||
|
: BaseApiController
|
||||||
|
{
|
||||||
|
private const string ViewPermission = "tenant:customer:list:view";
|
||||||
|
private const string ManagePermission = "tenant:customer:list:manage";
|
||||||
|
private const string ProfilePermission = "tenant:customer:profile:view";
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 获取客户列表。
|
||||||
|
/// </summary>
|
||||||
|
[HttpGet("list")]
|
||||||
|
[PermissionAuthorize(ViewPermission, ManagePermission)]
|
||||||
|
[ProducesResponseType(typeof(ApiResponse<CustomerListResultResponse>), StatusCodes.Status200OK)]
|
||||||
|
public async Task<ApiResponse<CustomerListResultResponse>> List(
|
||||||
|
[FromQuery] CustomerListRequest request,
|
||||||
|
CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
var visibleStoreIds = await ResolveVisibleStoreIdsAsync(request.StoreId, cancellationToken);
|
||||||
|
|
||||||
|
var result = await mediator.Send(new SearchCustomerListQuery
|
||||||
|
{
|
||||||
|
VisibleStoreIds = visibleStoreIds,
|
||||||
|
Keyword = request.Keyword,
|
||||||
|
Tag = request.Tag,
|
||||||
|
OrderCountRange = request.OrderCountRange,
|
||||||
|
RegisterPeriodDays = ParseRegisterPeriodDays(request.RegisterPeriod),
|
||||||
|
Page = Math.Max(1, request.Page),
|
||||||
|
PageSize = Math.Clamp(request.PageSize, 1, 200)
|
||||||
|
}, cancellationToken);
|
||||||
|
|
||||||
|
return ApiResponse<CustomerListResultResponse>.Ok(new CustomerListResultResponse
|
||||||
|
{
|
||||||
|
Items = result.Items.Select(MapListItem).ToList(),
|
||||||
|
Total = result.TotalCount,
|
||||||
|
Page = result.Page,
|
||||||
|
PageSize = result.PageSize
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 获取客户列表统计。
|
||||||
|
/// </summary>
|
||||||
|
[HttpGet("stats")]
|
||||||
|
[PermissionAuthorize(ViewPermission, ManagePermission)]
|
||||||
|
[ProducesResponseType(typeof(ApiResponse<CustomerListStatsResponse>), StatusCodes.Status200OK)]
|
||||||
|
public async Task<ApiResponse<CustomerListStatsResponse>> Stats(
|
||||||
|
[FromQuery] CustomerListFilterRequest request,
|
||||||
|
CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
var visibleStoreIds = await ResolveVisibleStoreIdsAsync(request.StoreId, cancellationToken);
|
||||||
|
|
||||||
|
var result = await mediator.Send(new GetCustomerListStatsQuery
|
||||||
|
{
|
||||||
|
VisibleStoreIds = visibleStoreIds,
|
||||||
|
Keyword = request.Keyword,
|
||||||
|
Tag = request.Tag,
|
||||||
|
OrderCountRange = request.OrderCountRange,
|
||||||
|
RegisterPeriodDays = ParseRegisterPeriodDays(request.RegisterPeriod)
|
||||||
|
}, cancellationToken);
|
||||||
|
|
||||||
|
return ApiResponse<CustomerListStatsResponse>.Ok(new CustomerListStatsResponse
|
||||||
|
{
|
||||||
|
TotalCustomers = result.TotalCustomers,
|
||||||
|
MonthlyNewCustomers = result.MonthlyNewCustomers,
|
||||||
|
MonthlyGrowthRatePercent = result.MonthlyGrowthRatePercent,
|
||||||
|
ActiveCustomers = result.ActiveCustomers,
|
||||||
|
AverageAmountLast30Days = result.AverageAmountLast30Days
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 获取客户详情(一级抽屉)。
|
||||||
|
/// </summary>
|
||||||
|
[HttpGet("detail")]
|
||||||
|
[PermissionAuthorize(ViewPermission, ManagePermission)]
|
||||||
|
[ProducesResponseType(typeof(ApiResponse<CustomerDetailResponse>), StatusCodes.Status200OK)]
|
||||||
|
public async Task<ApiResponse<CustomerDetailResponse>> Detail(
|
||||||
|
[FromQuery] CustomerDetailRequest request,
|
||||||
|
CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
var customerKey = NormalizePhone(request.CustomerKey);
|
||||||
|
if (string.IsNullOrWhiteSpace(customerKey))
|
||||||
|
{
|
||||||
|
return ApiResponse<CustomerDetailResponse>.Error(ErrorCodes.BadRequest, "customerKey 非法");
|
||||||
|
}
|
||||||
|
|
||||||
|
var visibleStoreIds = await ResolveVisibleStoreIdsAsync(request.StoreId, cancellationToken);
|
||||||
|
var result = await mediator.Send(new GetCustomerDetailQuery
|
||||||
|
{
|
||||||
|
VisibleStoreIds = visibleStoreIds,
|
||||||
|
CustomerKey = customerKey
|
||||||
|
}, cancellationToken);
|
||||||
|
|
||||||
|
if (result is null)
|
||||||
|
{
|
||||||
|
return ApiResponse<CustomerDetailResponse>.Error(ErrorCodes.NotFound, "客户不存在");
|
||||||
|
}
|
||||||
|
|
||||||
|
return ApiResponse<CustomerDetailResponse>.Ok(MapDetail(result));
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 获取客户画像(二级抽屉)。
|
||||||
|
/// </summary>
|
||||||
|
[HttpGet("profile")]
|
||||||
|
[PermissionAuthorize(ProfilePermission)]
|
||||||
|
[ProducesResponseType(typeof(ApiResponse<CustomerProfileResponse>), StatusCodes.Status200OK)]
|
||||||
|
public async Task<ApiResponse<CustomerProfileResponse>> Profile(
|
||||||
|
[FromQuery] CustomerProfileRequest request,
|
||||||
|
CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
var customerKey = NormalizePhone(request.CustomerKey);
|
||||||
|
if (string.IsNullOrWhiteSpace(customerKey))
|
||||||
|
{
|
||||||
|
return ApiResponse<CustomerProfileResponse>.Error(ErrorCodes.BadRequest, "customerKey 非法");
|
||||||
|
}
|
||||||
|
|
||||||
|
var visibleStoreIds = await ResolveVisibleStoreIdsAsync(request.StoreId, cancellationToken);
|
||||||
|
var result = await mediator.Send(new GetCustomerProfileQuery
|
||||||
|
{
|
||||||
|
VisibleStoreIds = visibleStoreIds,
|
||||||
|
CustomerKey = customerKey
|
||||||
|
}, cancellationToken);
|
||||||
|
|
||||||
|
if (result is null)
|
||||||
|
{
|
||||||
|
return ApiResponse<CustomerProfileResponse>.Error(ErrorCodes.NotFound, "客户不存在");
|
||||||
|
}
|
||||||
|
|
||||||
|
return ApiResponse<CustomerProfileResponse>.Ok(MapProfile(result));
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 导出客户 CSV。
|
||||||
|
/// </summary>
|
||||||
|
[HttpGet("export")]
|
||||||
|
[PermissionAuthorize(ManagePermission)]
|
||||||
|
[ProducesResponseType(typeof(ApiResponse<CustomerExportResponse>), StatusCodes.Status200OK)]
|
||||||
|
public async Task<ApiResponse<CustomerExportResponse>> Export(
|
||||||
|
[FromQuery] CustomerListFilterRequest request,
|
||||||
|
CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
var visibleStoreIds = await ResolveVisibleStoreIdsAsync(request.StoreId, cancellationToken);
|
||||||
|
|
||||||
|
var result = await mediator.Send(new ExportCustomerCsvQuery
|
||||||
|
{
|
||||||
|
VisibleStoreIds = visibleStoreIds,
|
||||||
|
Keyword = request.Keyword,
|
||||||
|
Tag = request.Tag,
|
||||||
|
OrderCountRange = request.OrderCountRange,
|
||||||
|
RegisterPeriodDays = ParseRegisterPeriodDays(request.RegisterPeriod)
|
||||||
|
}, cancellationToken);
|
||||||
|
|
||||||
|
return ApiResponse<CustomerExportResponse>.Ok(new CustomerExportResponse
|
||||||
|
{
|
||||||
|
FileName = result.FileName,
|
||||||
|
FileContentBase64 = result.FileContentBase64,
|
||||||
|
TotalCount = result.TotalCount
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task<IReadOnlyCollection<long>> ResolveVisibleStoreIdsAsync(
|
||||||
|
string? storeId,
|
||||||
|
CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
var (tenantId, merchantId) = StoreApiHelpers.GetTenantMerchantContext(storeContextService);
|
||||||
|
|
||||||
|
if (!string.IsNullOrWhiteSpace(storeId))
|
||||||
|
{
|
||||||
|
var parsedStoreId = StoreApiHelpers.ParseRequiredSnowflake(storeId, nameof(storeId));
|
||||||
|
await StoreApiHelpers.EnsureStoreAccessibleAsync(
|
||||||
|
dbContext,
|
||||||
|
tenantId,
|
||||||
|
merchantId,
|
||||||
|
parsedStoreId,
|
||||||
|
cancellationToken);
|
||||||
|
return [parsedStoreId];
|
||||||
|
}
|
||||||
|
|
||||||
|
var allStoreIds = await dbContext.Stores
|
||||||
|
.AsNoTracking()
|
||||||
|
.Where(item => item.TenantId == tenantId && item.MerchantId == merchantId)
|
||||||
|
.Select(item => item.Id)
|
||||||
|
.OrderBy(item => item)
|
||||||
|
.ToListAsync(cancellationToken);
|
||||||
|
|
||||||
|
if (allStoreIds.Count == 0)
|
||||||
|
{
|
||||||
|
throw new BusinessException(ErrorCodes.BadRequest, "当前商户下不存在可用门店");
|
||||||
|
}
|
||||||
|
|
||||||
|
return allStoreIds;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static int? ParseRegisterPeriodDays(string? registerPeriod)
|
||||||
|
{
|
||||||
|
var normalized = (registerPeriod ?? string.Empty).Trim().ToLowerInvariant();
|
||||||
|
if (string.IsNullOrWhiteSpace(normalized))
|
||||||
|
{
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return normalized switch
|
||||||
|
{
|
||||||
|
"7" or "7d" => 7,
|
||||||
|
"30" or "30d" => 30,
|
||||||
|
"90" or "90d" => 90,
|
||||||
|
_ => throw new BusinessException(ErrorCodes.BadRequest, "registerPeriod 参数不合法")
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
private static string NormalizePhone(string? value)
|
||||||
|
{
|
||||||
|
if (string.IsNullOrWhiteSpace(value))
|
||||||
|
{
|
||||||
|
return string.Empty;
|
||||||
|
}
|
||||||
|
|
||||||
|
var chars = value.Where(char.IsDigit).ToArray();
|
||||||
|
return chars.Length == 0 ? string.Empty : new string(chars);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static CustomerListItemResponse MapListItem(CustomerListItemDto source)
|
||||||
|
{
|
||||||
|
return new CustomerListItemResponse
|
||||||
|
{
|
||||||
|
CustomerKey = source.CustomerKey,
|
||||||
|
Name = source.Name,
|
||||||
|
PhoneMasked = source.PhoneMasked,
|
||||||
|
AvatarText = source.AvatarText,
|
||||||
|
AvatarColor = source.AvatarColor,
|
||||||
|
OrderCount = source.OrderCount,
|
||||||
|
OrderCountBarPercent = source.OrderCountBarPercent,
|
||||||
|
TotalAmount = source.TotalAmount,
|
||||||
|
AverageAmount = source.AverageAmount,
|
||||||
|
LastOrderAt = ToDateOnly(source.LastOrderAt),
|
||||||
|
Tags = source.Tags.Select(MapTag).ToList(),
|
||||||
|
IsDimmed = source.IsDimmed
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
private static CustomerDetailResponse MapDetail(CustomerDetailDto source)
|
||||||
|
{
|
||||||
|
return new CustomerDetailResponse
|
||||||
|
{
|
||||||
|
CustomerKey = source.CustomerKey,
|
||||||
|
Name = source.Name,
|
||||||
|
PhoneMasked = source.PhoneMasked,
|
||||||
|
RegisteredAt = ToDateOnly(source.RegisteredAt),
|
||||||
|
FirstOrderAt = ToDateOnly(source.FirstOrderAt),
|
||||||
|
Source = source.Source,
|
||||||
|
Tags = source.Tags.Select(MapTag).ToList(),
|
||||||
|
Member = MapMember(source.Member),
|
||||||
|
TotalOrders = source.TotalOrders,
|
||||||
|
TotalAmount = source.TotalAmount,
|
||||||
|
AverageAmount = source.AverageAmount,
|
||||||
|
RepurchaseRatePercent = source.RepurchaseRatePercent,
|
||||||
|
Preference = MapPreference(source.Preference),
|
||||||
|
TopProducts = source.TopProducts.Select(MapTopProduct).ToList(),
|
||||||
|
Trend = source.Trend.Select(MapTrend).ToList(),
|
||||||
|
RecentOrders = source.RecentOrders.Select(MapRecentOrder).ToList()
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
private static CustomerProfileResponse MapProfile(CustomerProfileDto source)
|
||||||
|
{
|
||||||
|
return new CustomerProfileResponse
|
||||||
|
{
|
||||||
|
CustomerKey = source.CustomerKey,
|
||||||
|
Name = source.Name,
|
||||||
|
PhoneMasked = source.PhoneMasked,
|
||||||
|
RegisteredAt = ToDateOnly(source.RegisteredAt),
|
||||||
|
FirstOrderAt = ToDateOnly(source.FirstOrderAt),
|
||||||
|
Source = source.Source,
|
||||||
|
Tags = source.Tags.Select(MapTag).ToList(),
|
||||||
|
Member = MapMember(source.Member),
|
||||||
|
TotalOrders = source.TotalOrders,
|
||||||
|
TotalAmount = source.TotalAmount,
|
||||||
|
AverageAmount = source.AverageAmount,
|
||||||
|
RepurchaseRatePercent = source.RepurchaseRatePercent,
|
||||||
|
AverageOrderIntervalDays = source.AverageOrderIntervalDays,
|
||||||
|
Preference = MapPreference(source.Preference),
|
||||||
|
TopProducts = source.TopProducts.Select(MapTopProduct).ToList(),
|
||||||
|
Trend = source.Trend.Select(MapTrend).ToList(),
|
||||||
|
RecentOrders = source.RecentOrders.Select(MapRecentOrder).ToList()
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
private static CustomerTagResponse MapTag(CustomerTagDto source)
|
||||||
|
{
|
||||||
|
return new CustomerTagResponse
|
||||||
|
{
|
||||||
|
Code = source.Code,
|
||||||
|
Label = source.Label,
|
||||||
|
Tone = source.Tone
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
private static CustomerPreferenceResponse MapPreference(CustomerPreferenceDto source)
|
||||||
|
{
|
||||||
|
return new CustomerPreferenceResponse
|
||||||
|
{
|
||||||
|
PreferredCategories = source.PreferredCategories.ToList(),
|
||||||
|
PreferredOrderPeaks = source.PreferredOrderPeaks,
|
||||||
|
PreferredDelivery = source.PreferredDelivery,
|
||||||
|
PreferredPaymentMethod = source.PreferredPaymentMethod,
|
||||||
|
AverageDeliveryDistance = source.AverageDeliveryDistance
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
private static CustomerMemberSummaryResponse MapMember(CustomerMemberSummaryDto source)
|
||||||
|
{
|
||||||
|
return new CustomerMemberSummaryResponse
|
||||||
|
{
|
||||||
|
IsMember = source.IsMember,
|
||||||
|
TierName = source.TierName,
|
||||||
|
PointsBalance = source.PointsBalance,
|
||||||
|
GrowthValue = source.GrowthValue,
|
||||||
|
JoinedAt = source.JoinedAt.HasValue ? ToDateOnly(source.JoinedAt.Value) : string.Empty
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
private static CustomerTopProductResponse MapTopProduct(CustomerTopProductDto source)
|
||||||
|
{
|
||||||
|
return new CustomerTopProductResponse
|
||||||
|
{
|
||||||
|
Rank = source.Rank,
|
||||||
|
ProductName = source.ProductName,
|
||||||
|
Count = source.Count,
|
||||||
|
ProportionPercent = source.ProportionPercent
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
private static CustomerTrendPointResponse MapTrend(CustomerTrendPointDto source)
|
||||||
|
{
|
||||||
|
return new CustomerTrendPointResponse
|
||||||
|
{
|
||||||
|
Label = source.Label,
|
||||||
|
Amount = source.Amount
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
private static CustomerRecentOrderResponse MapRecentOrder(CustomerRecentOrderDto source)
|
||||||
|
{
|
||||||
|
return new CustomerRecentOrderResponse
|
||||||
|
{
|
||||||
|
OrderNo = source.OrderNo,
|
||||||
|
Amount = source.Amount,
|
||||||
|
ItemsSummary = source.ItemsSummary,
|
||||||
|
DeliveryType = source.DeliveryType,
|
||||||
|
Status = source.Status,
|
||||||
|
OrderedAt = ToDateTime(source.OrderedAt)
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
private static string ToDateOnly(DateTime value)
|
||||||
|
{
|
||||||
|
return value.ToString("yyyy-MM-dd", CultureInfo.InvariantCulture);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static string ToDateTime(DateTime value)
|
||||||
|
{
|
||||||
|
return value.ToString("yyyy-MM-dd HH:mm:ss", CultureInfo.InvariantCulture);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,462 @@
|
|||||||
|
namespace TakeoutSaaS.Application.App.Customers.Dto;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 客户标签 DTO。
|
||||||
|
/// </summary>
|
||||||
|
public sealed class CustomerTagDto
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// 标签编码。
|
||||||
|
/// </summary>
|
||||||
|
public string Code { get; init; } = string.Empty;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 标签文案。
|
||||||
|
/// </summary>
|
||||||
|
public string Label { get; init; } = string.Empty;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 标签色调(orange/blue/green/gray/red)。
|
||||||
|
/// </summary>
|
||||||
|
public string Tone { get; init; } = "blue";
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 客户列表行 DTO。
|
||||||
|
/// </summary>
|
||||||
|
public sealed class CustomerListItemDto
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// 客户标识(手机号归一化)。
|
||||||
|
/// </summary>
|
||||||
|
public string CustomerKey { get; init; } = string.Empty;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 客户名称。
|
||||||
|
/// </summary>
|
||||||
|
public string Name { get; init; } = string.Empty;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 手机号(脱敏)。
|
||||||
|
/// </summary>
|
||||||
|
public string PhoneMasked { get; init; } = string.Empty;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 头像文案。
|
||||||
|
/// </summary>
|
||||||
|
public string AvatarText { get; init; } = string.Empty;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 头像颜色。
|
||||||
|
/// </summary>
|
||||||
|
public string AvatarColor { get; init; } = string.Empty;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 下单次数。
|
||||||
|
/// </summary>
|
||||||
|
public int OrderCount { get; init; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 下单次数条形宽度百分比。
|
||||||
|
/// </summary>
|
||||||
|
public int OrderCountBarPercent { get; init; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 累计消费。
|
||||||
|
/// </summary>
|
||||||
|
public decimal TotalAmount { get; init; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 客单价。
|
||||||
|
/// </summary>
|
||||||
|
public decimal AverageAmount { get; init; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 注册时间。
|
||||||
|
/// </summary>
|
||||||
|
public DateTime RegisteredAt { get; init; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 最近下单时间。
|
||||||
|
/// </summary>
|
||||||
|
public DateTime LastOrderAt { get; init; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 客户标签。
|
||||||
|
/// </summary>
|
||||||
|
public IReadOnlyList<CustomerTagDto> Tags { get; init; } = [];
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 是否弱化展示。
|
||||||
|
/// </summary>
|
||||||
|
public bool IsDimmed { get; init; }
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 客户列表统计 DTO。
|
||||||
|
/// </summary>
|
||||||
|
public sealed class CustomerListStatsDto
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// 客户总数。
|
||||||
|
/// </summary>
|
||||||
|
public int TotalCustomers { get; init; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 本月新增客户数。
|
||||||
|
/// </summary>
|
||||||
|
public int MonthlyNewCustomers { get; init; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 本月较上月增长百分比。
|
||||||
|
/// </summary>
|
||||||
|
public decimal MonthlyGrowthRatePercent { get; init; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 活跃客户数(近 30 天有下单)。
|
||||||
|
/// </summary>
|
||||||
|
public int ActiveCustomers { get; init; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 近 30 天客均消费(按订单均值)。
|
||||||
|
/// </summary>
|
||||||
|
public decimal AverageAmountLast30Days { get; init; }
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 客户偏好 DTO。
|
||||||
|
/// </summary>
|
||||||
|
public sealed class CustomerPreferenceDto
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// 偏好品类。
|
||||||
|
/// </summary>
|
||||||
|
public IReadOnlyList<string> PreferredCategories { get; init; } = [];
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 偏好下单时段。
|
||||||
|
/// </summary>
|
||||||
|
public string PreferredOrderPeaks { get; init; } = string.Empty;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 偏好履约方式。
|
||||||
|
/// </summary>
|
||||||
|
public string PreferredDelivery { get; init; } = string.Empty;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 偏好支付方式。
|
||||||
|
/// </summary>
|
||||||
|
public string PreferredPaymentMethod { get; init; } = string.Empty;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 平均配送距离文案。
|
||||||
|
/// </summary>
|
||||||
|
public string AverageDeliveryDistance { get; init; } = string.Empty;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 客户常购商品 DTO。
|
||||||
|
/// </summary>
|
||||||
|
public sealed class CustomerTopProductDto
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// 排名。
|
||||||
|
/// </summary>
|
||||||
|
public int Rank { get; init; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 商品名称。
|
||||||
|
/// </summary>
|
||||||
|
public string ProductName { get; init; } = string.Empty;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 购买次数。
|
||||||
|
/// </summary>
|
||||||
|
public int Count { get; init; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 占比(0-100)。
|
||||||
|
/// </summary>
|
||||||
|
public decimal ProportionPercent { get; init; }
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 客户趋势点 DTO。
|
||||||
|
/// </summary>
|
||||||
|
public sealed class CustomerTrendPointDto
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// 月份标签。
|
||||||
|
/// </summary>
|
||||||
|
public string Label { get; init; } = string.Empty;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 消费金额。
|
||||||
|
/// </summary>
|
||||||
|
public decimal Amount { get; init; }
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 客户最近订单 DTO。
|
||||||
|
/// </summary>
|
||||||
|
public sealed class CustomerRecentOrderDto
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// 订单号。
|
||||||
|
/// </summary>
|
||||||
|
public string OrderNo { get; init; } = string.Empty;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 下单时间。
|
||||||
|
/// </summary>
|
||||||
|
public DateTime OrderedAt { get; init; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 订单金额。
|
||||||
|
/// </summary>
|
||||||
|
public decimal Amount { get; init; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 商品摘要。
|
||||||
|
/// </summary>
|
||||||
|
public string ItemsSummary { get; init; } = string.Empty;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 履约方式文案。
|
||||||
|
/// </summary>
|
||||||
|
public string DeliveryType { get; init; } = string.Empty;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 状态文案。
|
||||||
|
/// </summary>
|
||||||
|
public string Status { get; init; } = string.Empty;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 客户会员摘要 DTO。
|
||||||
|
/// </summary>
|
||||||
|
public sealed class CustomerMemberSummaryDto
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// 是否会员。
|
||||||
|
/// </summary>
|
||||||
|
public bool IsMember { get; init; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 会员等级名称。
|
||||||
|
/// </summary>
|
||||||
|
public string TierName { get; init; } = string.Empty;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 积分余额。
|
||||||
|
/// </summary>
|
||||||
|
public int PointsBalance { get; init; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 成长值。
|
||||||
|
/// </summary>
|
||||||
|
public int GrowthValue { get; init; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 入会时间。
|
||||||
|
/// </summary>
|
||||||
|
public DateTime? JoinedAt { get; init; }
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 客户详情 DTO。
|
||||||
|
/// </summary>
|
||||||
|
public sealed class CustomerDetailDto
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// 客户标识(手机号归一化)。
|
||||||
|
/// </summary>
|
||||||
|
public string CustomerKey { get; init; } = string.Empty;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 客户名称。
|
||||||
|
/// </summary>
|
||||||
|
public string Name { get; init; } = string.Empty;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 手机号(脱敏)。
|
||||||
|
/// </summary>
|
||||||
|
public string PhoneMasked { get; init; } = string.Empty;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 注册时间。
|
||||||
|
/// </summary>
|
||||||
|
public DateTime RegisteredAt { get; init; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 首次下单时间。
|
||||||
|
/// </summary>
|
||||||
|
public DateTime FirstOrderAt { get; init; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 客户来源。
|
||||||
|
/// </summary>
|
||||||
|
public string Source { get; init; } = string.Empty;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 客户标签。
|
||||||
|
/// </summary>
|
||||||
|
public IReadOnlyList<CustomerTagDto> Tags { get; init; } = [];
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 会员摘要。
|
||||||
|
/// </summary>
|
||||||
|
public CustomerMemberSummaryDto Member { get; init; } = new();
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 累计下单次数。
|
||||||
|
/// </summary>
|
||||||
|
public int TotalOrders { get; init; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 累计消费。
|
||||||
|
/// </summary>
|
||||||
|
public decimal TotalAmount { get; init; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 客单价。
|
||||||
|
/// </summary>
|
||||||
|
public decimal AverageAmount { get; init; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 复购率(百分比)。
|
||||||
|
/// </summary>
|
||||||
|
public decimal RepurchaseRatePercent { get; init; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 偏好数据。
|
||||||
|
/// </summary>
|
||||||
|
public CustomerPreferenceDto Preference { get; init; } = new();
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 常购商品 Top 5。
|
||||||
|
/// </summary>
|
||||||
|
public IReadOnlyList<CustomerTopProductDto> TopProducts { get; init; } = [];
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 趋势数据。
|
||||||
|
/// </summary>
|
||||||
|
public IReadOnlyList<CustomerTrendPointDto> Trend { get; init; } = [];
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 最近订单。
|
||||||
|
/// </summary>
|
||||||
|
public IReadOnlyList<CustomerRecentOrderDto> RecentOrders { get; init; } = [];
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 客户画像 DTO。
|
||||||
|
/// </summary>
|
||||||
|
public sealed class CustomerProfileDto
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// 客户标识(手机号归一化)。
|
||||||
|
/// </summary>
|
||||||
|
public string CustomerKey { get; init; } = string.Empty;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 客户名称。
|
||||||
|
/// </summary>
|
||||||
|
public string Name { get; init; } = string.Empty;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 手机号(脱敏)。
|
||||||
|
/// </summary>
|
||||||
|
public string PhoneMasked { get; init; } = string.Empty;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 注册时间。
|
||||||
|
/// </summary>
|
||||||
|
public DateTime RegisteredAt { get; init; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 首次下单时间。
|
||||||
|
/// </summary>
|
||||||
|
public DateTime FirstOrderAt { get; init; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 客户来源。
|
||||||
|
/// </summary>
|
||||||
|
public string Source { get; init; } = string.Empty;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 客户标签。
|
||||||
|
/// </summary>
|
||||||
|
public IReadOnlyList<CustomerTagDto> Tags { get; init; } = [];
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 会员摘要。
|
||||||
|
/// </summary>
|
||||||
|
public CustomerMemberSummaryDto Member { get; init; } = new();
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 累计下单次数。
|
||||||
|
/// </summary>
|
||||||
|
public int TotalOrders { get; init; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 累计消费。
|
||||||
|
/// </summary>
|
||||||
|
public decimal TotalAmount { get; init; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 客单价。
|
||||||
|
/// </summary>
|
||||||
|
public decimal AverageAmount { get; init; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 复购率(百分比)。
|
||||||
|
/// </summary>
|
||||||
|
public decimal RepurchaseRatePercent { get; init; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 平均下单间隔(天)。
|
||||||
|
/// </summary>
|
||||||
|
public decimal AverageOrderIntervalDays { get; init; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 偏好数据。
|
||||||
|
/// </summary>
|
||||||
|
public CustomerPreferenceDto Preference { get; init; } = new();
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 常购商品 Top 5。
|
||||||
|
/// </summary>
|
||||||
|
public IReadOnlyList<CustomerTopProductDto> TopProducts { get; init; } = [];
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 趋势数据。
|
||||||
|
/// </summary>
|
||||||
|
public IReadOnlyList<CustomerTrendPointDto> Trend { get; init; } = [];
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 最近订单。
|
||||||
|
/// </summary>
|
||||||
|
public IReadOnlyList<CustomerRecentOrderDto> RecentOrders { get; init; } = [];
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 客户导出 DTO。
|
||||||
|
/// </summary>
|
||||||
|
public sealed class CustomerExportDto
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// 文件名。
|
||||||
|
/// </summary>
|
||||||
|
public string FileName { get; init; } = string.Empty;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 文件 Base64。
|
||||||
|
/// </summary>
|
||||||
|
public string FileContentBase64 { get; init; } = string.Empty;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 导出总数。
|
||||||
|
/// </summary>
|
||||||
|
public int TotalCount { get; init; }
|
||||||
|
}
|
||||||
@@ -0,0 +1,992 @@
|
|||||||
|
using System.Data;
|
||||||
|
using System.Data.Common;
|
||||||
|
using TakeoutSaaS.Application.App.Customers.Dto;
|
||||||
|
using TakeoutSaaS.Domain.Orders.Entities;
|
||||||
|
using TakeoutSaaS.Domain.Orders.Enums;
|
||||||
|
using TakeoutSaaS.Domain.Orders.Repositories;
|
||||||
|
using TakeoutSaaS.Domain.Payments.Enums;
|
||||||
|
using TakeoutSaaS.Domain.Products.Repositories;
|
||||||
|
using TakeoutSaaS.Shared.Abstractions.Constants;
|
||||||
|
using TakeoutSaaS.Shared.Abstractions.Data;
|
||||||
|
using TakeoutSaaS.Shared.Abstractions.Tenancy;
|
||||||
|
|
||||||
|
namespace TakeoutSaaS.Application.App.Customers.Handlers;
|
||||||
|
|
||||||
|
internal static class CustomerAnalyticsSupport
|
||||||
|
{
|
||||||
|
private static readonly string[] AvatarColors =
|
||||||
|
[
|
||||||
|
"#f56a00",
|
||||||
|
"#7265e6",
|
||||||
|
"#52c41a",
|
||||||
|
"#fa8c16",
|
||||||
|
"#1890ff",
|
||||||
|
"#bfbfbf",
|
||||||
|
"#13c2c2",
|
||||||
|
"#eb2f96"
|
||||||
|
];
|
||||||
|
|
||||||
|
internal const string TagHighValue = "high_value";
|
||||||
|
internal const string TagActive = "active";
|
||||||
|
internal const string TagDormant = "dormant";
|
||||||
|
internal const string TagChurn = "churn";
|
||||||
|
internal const string TagNewCustomer = "new_customer";
|
||||||
|
|
||||||
|
internal static readonly string[] SupportedTags =
|
||||||
|
[
|
||||||
|
TagHighValue,
|
||||||
|
TagActive,
|
||||||
|
TagDormant,
|
||||||
|
TagChurn,
|
||||||
|
TagNewCustomer
|
||||||
|
];
|
||||||
|
|
||||||
|
internal static string NormalizePhone(string? value)
|
||||||
|
{
|
||||||
|
if (string.IsNullOrWhiteSpace(value))
|
||||||
|
{
|
||||||
|
return string.Empty;
|
||||||
|
}
|
||||||
|
|
||||||
|
var chars = value.Where(char.IsDigit).ToArray();
|
||||||
|
return chars.Length == 0 ? string.Empty : new string(chars);
|
||||||
|
}
|
||||||
|
|
||||||
|
internal static string MaskPhone(string normalizedPhone)
|
||||||
|
{
|
||||||
|
if (normalizedPhone.Length >= 11)
|
||||||
|
{
|
||||||
|
return $"{normalizedPhone[..3]}****{normalizedPhone[^4..]}";
|
||||||
|
}
|
||||||
|
|
||||||
|
if (normalizedPhone.Length >= 7)
|
||||||
|
{
|
||||||
|
return $"{normalizedPhone[..3]}***{normalizedPhone[^2..]}";
|
||||||
|
}
|
||||||
|
|
||||||
|
return normalizedPhone;
|
||||||
|
}
|
||||||
|
|
||||||
|
internal static string ResolveAvatarText(string name, string customerKey)
|
||||||
|
{
|
||||||
|
var candidate = (name ?? string.Empty).Trim();
|
||||||
|
if (!string.IsNullOrWhiteSpace(candidate))
|
||||||
|
{
|
||||||
|
return candidate[..1];
|
||||||
|
}
|
||||||
|
|
||||||
|
return customerKey.Length > 0 ? customerKey[..1] : "客";
|
||||||
|
}
|
||||||
|
|
||||||
|
internal static string ResolveAvatarColor(string? seed)
|
||||||
|
{
|
||||||
|
var source = string.IsNullOrWhiteSpace(seed) ? "customer" : seed;
|
||||||
|
var hash = 0;
|
||||||
|
foreach (var ch in source)
|
||||||
|
{
|
||||||
|
hash = (hash * 31 + ch) & int.MaxValue;
|
||||||
|
}
|
||||||
|
|
||||||
|
return AvatarColors[hash % AvatarColors.Length];
|
||||||
|
}
|
||||||
|
|
||||||
|
internal static decimal ResolveDisplayAmount(Order order)
|
||||||
|
{
|
||||||
|
return order.PaidAmount > 0 ? order.PaidAmount : order.PayableAmount;
|
||||||
|
}
|
||||||
|
|
||||||
|
internal static string ToDeliveryTypeText(DeliveryType value)
|
||||||
|
{
|
||||||
|
return value switch
|
||||||
|
{
|
||||||
|
DeliveryType.Delivery => "外卖",
|
||||||
|
DeliveryType.Pickup => "自提",
|
||||||
|
DeliveryType.DineIn => "堂食",
|
||||||
|
_ => "未知"
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
internal static string ToPaymentMethodText(PaymentMethod value)
|
||||||
|
{
|
||||||
|
return value switch
|
||||||
|
{
|
||||||
|
PaymentMethod.WeChatPay => "微信支付",
|
||||||
|
PaymentMethod.Alipay => "支付宝",
|
||||||
|
PaymentMethod.Balance => "余额支付",
|
||||||
|
PaymentMethod.Cash => "现金",
|
||||||
|
PaymentMethod.Card => "刷卡",
|
||||||
|
_ => "--"
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
internal static string ToOrderStatusText(OrderStatus status, DeliveryType deliveryType)
|
||||||
|
{
|
||||||
|
return status switch
|
||||||
|
{
|
||||||
|
OrderStatus.PendingPayment => "待接单",
|
||||||
|
OrderStatus.AwaitingPreparation => "待接单",
|
||||||
|
OrderStatus.InProgress => "制作中",
|
||||||
|
OrderStatus.Ready => deliveryType == DeliveryType.Delivery ? "配送中" : "待取餐",
|
||||||
|
OrderStatus.Completed => "已完成",
|
||||||
|
OrderStatus.Cancelled => "已取消",
|
||||||
|
_ => "未知"
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
internal static decimal ToRatePercent(int numerator, int denominator)
|
||||||
|
{
|
||||||
|
if (denominator <= 0 || numerator <= 0)
|
||||||
|
{
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
return decimal.Round(
|
||||||
|
numerator * 100m / denominator,
|
||||||
|
1,
|
||||||
|
MidpointRounding.AwayFromZero);
|
||||||
|
}
|
||||||
|
|
||||||
|
internal static decimal ToGrowthRatePercent(int current, int previous)
|
||||||
|
{
|
||||||
|
if (previous <= 0)
|
||||||
|
{
|
||||||
|
return current <= 0 ? 0 : 100;
|
||||||
|
}
|
||||||
|
|
||||||
|
return decimal.Round(
|
||||||
|
(current - previous) * 100m / previous,
|
||||||
|
1,
|
||||||
|
MidpointRounding.AwayFromZero);
|
||||||
|
}
|
||||||
|
|
||||||
|
internal static string NormalizeTag(string? tag)
|
||||||
|
{
|
||||||
|
var normalized = (tag ?? string.Empty).Trim().ToLowerInvariant();
|
||||||
|
return normalized switch
|
||||||
|
{
|
||||||
|
"highvalue" or "high_value" or "high-value" => TagHighValue,
|
||||||
|
"active" => TagActive,
|
||||||
|
"dormant" or "sleeping" => TagDormant,
|
||||||
|
"churn" or "lost" => TagChurn,
|
||||||
|
"new" or "new_customer" or "new-customer" => TagNewCustomer,
|
||||||
|
_ => string.Empty
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
internal static string NormalizeOrderCountRange(string? range)
|
||||||
|
{
|
||||||
|
var normalized = (range ?? string.Empty).Trim().ToLowerInvariant();
|
||||||
|
return normalized switch
|
||||||
|
{
|
||||||
|
"once" or "one" or "1" => "once",
|
||||||
|
"two_to_five" or "2_5" or "2-5" => "two_to_five",
|
||||||
|
"six_to_ten" or "6_10" or "6-10" => "six_to_ten",
|
||||||
|
"ten_plus" or "10+" or "more_than_ten" => "ten_plus",
|
||||||
|
_ => string.Empty
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
internal static bool MatchOrderCountRange(int orderCount, string normalizedRange)
|
||||||
|
{
|
||||||
|
return normalizedRange switch
|
||||||
|
{
|
||||||
|
"once" => orderCount == 1,
|
||||||
|
"two_to_five" => orderCount >= 2 && orderCount <= 5,
|
||||||
|
"six_to_ten" => orderCount >= 6 && orderCount <= 10,
|
||||||
|
"ten_plus" => orderCount > 10,
|
||||||
|
_ => true
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
internal static IReadOnlyList<CustomerTagDto> BuildTags(
|
||||||
|
decimal totalAmount,
|
||||||
|
decimal averageAmount,
|
||||||
|
int orderCount,
|
||||||
|
DateTime registeredAt,
|
||||||
|
DateTime lastOrderAt,
|
||||||
|
DateTime nowUtc)
|
||||||
|
{
|
||||||
|
var tagList = new List<CustomerTagDto>();
|
||||||
|
|
||||||
|
// 1. 计算基础状态
|
||||||
|
var silentDays = (nowUtc.Date - lastOrderAt.Date).TotalDays;
|
||||||
|
var isHighValue = totalAmount >= 3000m || (averageAmount >= 100m && orderCount >= 10);
|
||||||
|
var isNewCustomer = registeredAt >= nowUtc.AddDays(-30);
|
||||||
|
var isActive = silentDays <= 30;
|
||||||
|
var isDormant = silentDays > 30 && silentDays <= 60;
|
||||||
|
var isChurn = silentDays > 60;
|
||||||
|
|
||||||
|
// 2. 组合标签(优先保留原型主标签)
|
||||||
|
if (isHighValue)
|
||||||
|
{
|
||||||
|
tagList.Add(new CustomerTagDto
|
||||||
|
{
|
||||||
|
Code = TagHighValue,
|
||||||
|
Label = "高价值",
|
||||||
|
Tone = "orange"
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isNewCustomer)
|
||||||
|
{
|
||||||
|
tagList.Add(new CustomerTagDto
|
||||||
|
{
|
||||||
|
Code = TagNewCustomer,
|
||||||
|
Label = "新客户",
|
||||||
|
Tone = "green"
|
||||||
|
});
|
||||||
|
return tagList;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isActive)
|
||||||
|
{
|
||||||
|
tagList.Add(new CustomerTagDto
|
||||||
|
{
|
||||||
|
Code = TagActive,
|
||||||
|
Label = "活跃",
|
||||||
|
Tone = "blue"
|
||||||
|
});
|
||||||
|
return tagList;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isDormant)
|
||||||
|
{
|
||||||
|
tagList.Add(new CustomerTagDto
|
||||||
|
{
|
||||||
|
Code = TagDormant,
|
||||||
|
Label = "沉睡",
|
||||||
|
Tone = "gray"
|
||||||
|
});
|
||||||
|
return tagList;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isChurn)
|
||||||
|
{
|
||||||
|
tagList.Add(new CustomerTagDto
|
||||||
|
{
|
||||||
|
Code = TagChurn,
|
||||||
|
Label = "流失",
|
||||||
|
Tone = "red"
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return tagList;
|
||||||
|
}
|
||||||
|
|
||||||
|
internal static string ResolveCustomerName(
|
||||||
|
string customerKey,
|
||||||
|
string latestName,
|
||||||
|
MemberProfileSnapshot? memberProfile)
|
||||||
|
{
|
||||||
|
if (!string.IsNullOrWhiteSpace(memberProfile?.Nickname))
|
||||||
|
{
|
||||||
|
return memberProfile.Nickname.Trim();
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!string.IsNullOrWhiteSpace(latestName))
|
||||||
|
{
|
||||||
|
return latestName.Trim();
|
||||||
|
}
|
||||||
|
|
||||||
|
return customerKey.Length >= 4 ? $"客户{customerKey[^4..]}" : "客户";
|
||||||
|
}
|
||||||
|
|
||||||
|
internal static CustomerMemberSummaryDto BuildMemberSummary(MemberProfileSnapshot? memberProfile)
|
||||||
|
{
|
||||||
|
if (memberProfile is null)
|
||||||
|
{
|
||||||
|
return new CustomerMemberSummaryDto
|
||||||
|
{
|
||||||
|
IsMember = false,
|
||||||
|
TierName = string.Empty,
|
||||||
|
PointsBalance = 0,
|
||||||
|
GrowthValue = 0,
|
||||||
|
JoinedAt = null
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
return new CustomerMemberSummaryDto
|
||||||
|
{
|
||||||
|
IsMember = true,
|
||||||
|
TierName = string.IsNullOrWhiteSpace(memberProfile.TierName)
|
||||||
|
? string.Empty
|
||||||
|
: memberProfile.TierName.Trim(),
|
||||||
|
PointsBalance = Math.Max(0, memberProfile.PointsBalance),
|
||||||
|
GrowthValue = Math.Max(0, memberProfile.GrowthValue),
|
||||||
|
JoinedAt = memberProfile.JoinedAt
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
internal static IReadOnlyList<CustomerAggregate> ApplyFilters(
|
||||||
|
IReadOnlyList<CustomerAggregate> customers,
|
||||||
|
string? keyword,
|
||||||
|
string? normalizedTag,
|
||||||
|
string? normalizedOrderCountRange,
|
||||||
|
int? registerPeriodDays,
|
||||||
|
DateTime nowUtc)
|
||||||
|
{
|
||||||
|
var normalizedKeyword = (keyword ?? string.Empty).Trim();
|
||||||
|
var keywordDigits = NormalizePhone(normalizedKeyword);
|
||||||
|
|
||||||
|
return customers
|
||||||
|
.Where(customer =>
|
||||||
|
{
|
||||||
|
if (!string.IsNullOrWhiteSpace(normalizedKeyword))
|
||||||
|
{
|
||||||
|
var matchedByName = customer.Name.Contains(normalizedKeyword, StringComparison.OrdinalIgnoreCase);
|
||||||
|
var matchedByPhone = !string.IsNullOrWhiteSpace(keywordDigits) &&
|
||||||
|
customer.CustomerKey.Contains(keywordDigits, StringComparison.Ordinal);
|
||||||
|
if (!matchedByName && !matchedByPhone)
|
||||||
|
{
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!string.IsNullOrWhiteSpace(normalizedTag) &&
|
||||||
|
!customer.Tags.Any(tag => string.Equals(tag.Code, normalizedTag, StringComparison.OrdinalIgnoreCase)))
|
||||||
|
{
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!string.IsNullOrWhiteSpace(normalizedOrderCountRange) &&
|
||||||
|
!MatchOrderCountRange(customer.OrderCount, normalizedOrderCountRange))
|
||||||
|
{
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (registerPeriodDays.HasValue && registerPeriodDays.Value > 0)
|
||||||
|
{
|
||||||
|
var threshold = nowUtc.AddDays(-registerPeriodDays.Value);
|
||||||
|
if (customer.RegisteredAt < threshold)
|
||||||
|
{
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return true;
|
||||||
|
})
|
||||||
|
.ToList();
|
||||||
|
}
|
||||||
|
|
||||||
|
internal static IReadOnlyList<CustomerTrendPointDto> BuildMonthlyTrend(
|
||||||
|
IReadOnlyList<CustomerOrderSnapshot> orders,
|
||||||
|
DateTime nowUtc,
|
||||||
|
int monthCount)
|
||||||
|
{
|
||||||
|
var normalizedMonthCount = Math.Clamp(monthCount, 1, 24);
|
||||||
|
var monthStart = new DateTime(nowUtc.Year, nowUtc.Month, 1, 0, 0, 0, DateTimeKind.Utc);
|
||||||
|
var windowStart = monthStart.AddMonths(-normalizedMonthCount + 1);
|
||||||
|
|
||||||
|
// 1. 预计算窗口内订单金额
|
||||||
|
var monthAmountMap = orders
|
||||||
|
.Where(item => item.OrderedAt >= windowStart && item.OrderedAt < monthStart.AddMonths(1))
|
||||||
|
.GroupBy(item => new DateTime(item.OrderedAt.Year, item.OrderedAt.Month, 1, 0, 0, 0, DateTimeKind.Utc))
|
||||||
|
.ToDictionary(group => group.Key, group => group.Sum(item => item.Amount));
|
||||||
|
|
||||||
|
// 2. 生成连续月份点
|
||||||
|
var trend = new List<CustomerTrendPointDto>(normalizedMonthCount);
|
||||||
|
for (var index = 0; index < normalizedMonthCount; index += 1)
|
||||||
|
{
|
||||||
|
var currentMonth = windowStart.AddMonths(index);
|
||||||
|
monthAmountMap.TryGetValue(currentMonth, out var amount);
|
||||||
|
trend.Add(new CustomerTrendPointDto
|
||||||
|
{
|
||||||
|
Label = $"{currentMonth.Month}月",
|
||||||
|
Amount = decimal.Round(amount, 2, MidpointRounding.AwayFromZero)
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return trend;
|
||||||
|
}
|
||||||
|
|
||||||
|
internal static decimal CalculateAverageIntervalDays(IReadOnlyList<CustomerOrderSnapshot> orders)
|
||||||
|
{
|
||||||
|
if (orders.Count < 2)
|
||||||
|
{
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
var ascOrders = orders
|
||||||
|
.OrderBy(item => item.OrderedAt)
|
||||||
|
.ThenBy(item => item.OrderId)
|
||||||
|
.ToList();
|
||||||
|
|
||||||
|
var totalDays = 0m;
|
||||||
|
for (var index = 1; index < ascOrders.Count; index += 1)
|
||||||
|
{
|
||||||
|
totalDays += (decimal)(ascOrders[index].OrderedAt - ascOrders[index - 1].OrderedAt).TotalDays;
|
||||||
|
}
|
||||||
|
|
||||||
|
return decimal.Round(totalDays / (ascOrders.Count - 1), 1, MidpointRounding.AwayFromZero);
|
||||||
|
}
|
||||||
|
|
||||||
|
internal static string ResolvePreferredOrderPeaks(IReadOnlyList<CustomerOrderSnapshot> orders)
|
||||||
|
{
|
||||||
|
if (orders.Count == 0)
|
||||||
|
{
|
||||||
|
return string.Empty;
|
||||||
|
}
|
||||||
|
|
||||||
|
var slots = orders
|
||||||
|
.GroupBy(item => ResolvePeakSlot(item.OrderedAt.Hour))
|
||||||
|
.Select(group => new
|
||||||
|
{
|
||||||
|
Slot = group.Key,
|
||||||
|
Count = group.Count()
|
||||||
|
})
|
||||||
|
.OrderByDescending(item => item.Count)
|
||||||
|
.ThenBy(item => item.Slot, StringComparer.Ordinal)
|
||||||
|
.Take(2)
|
||||||
|
.Select(item => item.Slot)
|
||||||
|
.ToList();
|
||||||
|
|
||||||
|
return slots.Count == 0 ? string.Empty : string.Join("、", slots);
|
||||||
|
}
|
||||||
|
|
||||||
|
internal static string ResolvePreferredDelivery(IReadOnlyList<CustomerOrderSnapshot> orders)
|
||||||
|
{
|
||||||
|
if (orders.Count == 0)
|
||||||
|
{
|
||||||
|
return string.Empty;
|
||||||
|
}
|
||||||
|
|
||||||
|
var grouped = orders
|
||||||
|
.GroupBy(item => item.DeliveryType)
|
||||||
|
.Select(group => new
|
||||||
|
{
|
||||||
|
Type = group.Key,
|
||||||
|
Count = group.Count()
|
||||||
|
})
|
||||||
|
.OrderByDescending(item => item.Count)
|
||||||
|
.ThenBy(item => item.Type)
|
||||||
|
.ToList();
|
||||||
|
|
||||||
|
var totalCount = grouped.Sum(item => item.Count);
|
||||||
|
if (totalCount <= 0)
|
||||||
|
{
|
||||||
|
return string.Empty;
|
||||||
|
}
|
||||||
|
|
||||||
|
return string.Join("、", grouped.Select(item =>
|
||||||
|
{
|
||||||
|
var ratio = ToRatePercent(item.Count, totalCount);
|
||||||
|
return $"{ToDeliveryTypeText(item.Type)} ({ratio:0.#}%)";
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
internal static async Task<string> ResolvePreferredPaymentMethodAsync(
|
||||||
|
IOrderRepository orderRepository,
|
||||||
|
long tenantId,
|
||||||
|
IReadOnlyList<CustomerOrderSnapshot> orders,
|
||||||
|
CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
if (orders.Count == 0)
|
||||||
|
{
|
||||||
|
return string.Empty;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 1. 控制计算成本,仅统计最近 60 单
|
||||||
|
var recentOrders = orders
|
||||||
|
.OrderByDescending(item => item.OrderedAt)
|
||||||
|
.ThenByDescending(item => item.OrderId)
|
||||||
|
.Take(60)
|
||||||
|
.ToList();
|
||||||
|
|
||||||
|
// 2. 统计支付方式
|
||||||
|
var counter = new Dictionary<PaymentMethod, int>();
|
||||||
|
foreach (var order in recentOrders)
|
||||||
|
{
|
||||||
|
var payment = await orderRepository.GetLatestPaymentRecordAsync(order.OrderId, tenantId, cancellationToken);
|
||||||
|
if (payment is null)
|
||||||
|
{
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!counter.TryAdd(payment.Method, 1))
|
||||||
|
{
|
||||||
|
counter[payment.Method] += 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (counter.Count == 0)
|
||||||
|
{
|
||||||
|
return string.Empty;
|
||||||
|
}
|
||||||
|
|
||||||
|
return counter
|
||||||
|
.OrderByDescending(item => item.Value)
|
||||||
|
.ThenBy(item => item.Key)
|
||||||
|
.Select(item => $"{ToPaymentMethodText(item.Key)} ({item.Value}次)")
|
||||||
|
.FirstOrDefault() ?? string.Empty;
|
||||||
|
}
|
||||||
|
|
||||||
|
internal static IReadOnlyList<CustomerTopProductDto> BuildTopProducts(
|
||||||
|
IReadOnlyDictionary<long, IReadOnlyList<OrderItem>> itemsLookup,
|
||||||
|
IReadOnlyList<long> orderIds,
|
||||||
|
int takeCount)
|
||||||
|
{
|
||||||
|
if (orderIds.Count == 0 || takeCount <= 0)
|
||||||
|
{
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
// 1. 汇总商品购买次数
|
||||||
|
var productCounter = new Dictionary<string, ProductCounter>();
|
||||||
|
foreach (var orderId in orderIds)
|
||||||
|
{
|
||||||
|
if (!itemsLookup.TryGetValue(orderId, out var items))
|
||||||
|
{
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
foreach (var item in items)
|
||||||
|
{
|
||||||
|
var normalizedName = string.IsNullOrWhiteSpace(item.ProductName) ? "商品" : item.ProductName.Trim();
|
||||||
|
var key = item.ProductId > 0 ? $"id:{item.ProductId}" : $"name:{normalizedName}";
|
||||||
|
var quantity = Math.Max(1, item.Quantity);
|
||||||
|
|
||||||
|
if (!productCounter.TryGetValue(key, out var counter))
|
||||||
|
{
|
||||||
|
counter = new ProductCounter
|
||||||
|
{
|
||||||
|
ProductId = item.ProductId,
|
||||||
|
ProductName = normalizedName,
|
||||||
|
Count = quantity
|
||||||
|
};
|
||||||
|
productCounter[key] = counter;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
counter.Count += quantity;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
var sorted = productCounter.Values
|
||||||
|
.OrderByDescending(item => item.Count)
|
||||||
|
.ThenBy(item => item.ProductName, StringComparer.Ordinal)
|
||||||
|
.Take(takeCount)
|
||||||
|
.ToList();
|
||||||
|
|
||||||
|
if (sorted.Count == 0)
|
||||||
|
{
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
var maxCount = Math.Max(1, sorted[0].Count);
|
||||||
|
return sorted
|
||||||
|
.Select((item, index) => new CustomerTopProductDto
|
||||||
|
{
|
||||||
|
Rank = index + 1,
|
||||||
|
ProductName = item.ProductName,
|
||||||
|
Count = item.Count,
|
||||||
|
ProportionPercent = decimal.Round(item.Count * 100m / maxCount, 1, MidpointRounding.AwayFromZero)
|
||||||
|
})
|
||||||
|
.ToList();
|
||||||
|
}
|
||||||
|
|
||||||
|
internal static async Task<IReadOnlyList<string>> ResolvePreferredCategoriesAsync(
|
||||||
|
IProductRepository productRepository,
|
||||||
|
long tenantId,
|
||||||
|
IReadOnlyDictionary<long, IReadOnlyList<OrderItem>> itemsLookup,
|
||||||
|
IReadOnlyList<long> orderIds,
|
||||||
|
CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
if (orderIds.Count == 0)
|
||||||
|
{
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
// 1. 汇总分类出现频次
|
||||||
|
var productCache = new Dictionary<long, long>();
|
||||||
|
var categoryCounter = new Dictionary<long, int>();
|
||||||
|
|
||||||
|
foreach (var orderId in orderIds)
|
||||||
|
{
|
||||||
|
if (!itemsLookup.TryGetValue(orderId, out var items))
|
||||||
|
{
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
foreach (var item in items)
|
||||||
|
{
|
||||||
|
if (item.ProductId <= 0)
|
||||||
|
{
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!productCache.TryGetValue(item.ProductId, out var categoryId))
|
||||||
|
{
|
||||||
|
var product = await productRepository.FindByIdAsync(item.ProductId, tenantId, cancellationToken);
|
||||||
|
categoryId = product?.CategoryId ?? 0;
|
||||||
|
productCache[item.ProductId] = categoryId;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (categoryId <= 0)
|
||||||
|
{
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
var quantity = Math.Max(1, item.Quantity);
|
||||||
|
if (!categoryCounter.TryAdd(categoryId, quantity))
|
||||||
|
{
|
||||||
|
categoryCounter[categoryId] += quantity;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (categoryCounter.Count == 0)
|
||||||
|
{
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2. 读取分类名称并返回前 3
|
||||||
|
var categoryIds = categoryCounter
|
||||||
|
.OrderByDescending(item => item.Value)
|
||||||
|
.ThenBy(item => item.Key)
|
||||||
|
.Take(3)
|
||||||
|
.Select(item => item.Key)
|
||||||
|
.ToList();
|
||||||
|
|
||||||
|
var categoryNames = new List<string>(categoryIds.Count);
|
||||||
|
foreach (var categoryId in categoryIds)
|
||||||
|
{
|
||||||
|
var category = await productRepository.FindCategoryByIdAsync(categoryId, tenantId, cancellationToken);
|
||||||
|
if (category is null || string.IsNullOrWhiteSpace(category.Name))
|
||||||
|
{
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
categoryNames.Add(category.Name.Trim());
|
||||||
|
}
|
||||||
|
|
||||||
|
return categoryNames;
|
||||||
|
}
|
||||||
|
|
||||||
|
internal static IReadOnlyList<CustomerRecentOrderDto> BuildRecentOrders(
|
||||||
|
IReadOnlyList<CustomerOrderSnapshot> orders,
|
||||||
|
IReadOnlyDictionary<long, IReadOnlyList<OrderItem>> itemsLookup,
|
||||||
|
int takeCount)
|
||||||
|
{
|
||||||
|
return orders
|
||||||
|
.OrderByDescending(item => item.OrderedAt)
|
||||||
|
.ThenByDescending(item => item.OrderId)
|
||||||
|
.Take(Math.Max(1, takeCount))
|
||||||
|
.Select(item => new CustomerRecentOrderDto
|
||||||
|
{
|
||||||
|
OrderNo = item.OrderNo,
|
||||||
|
OrderedAt = item.OrderedAt,
|
||||||
|
Amount = item.Amount,
|
||||||
|
ItemsSummary = BuildItemsSummary(item.OrderId, itemsLookup),
|
||||||
|
DeliveryType = ToDeliveryTypeText(item.DeliveryType),
|
||||||
|
Status = ToOrderStatusText(item.Status, item.DeliveryType)
|
||||||
|
})
|
||||||
|
.ToList();
|
||||||
|
}
|
||||||
|
|
||||||
|
internal static string BuildItemsSummary(
|
||||||
|
long orderId,
|
||||||
|
IReadOnlyDictionary<long, IReadOnlyList<OrderItem>> itemsLookup)
|
||||||
|
{
|
||||||
|
if (!itemsLookup.TryGetValue(orderId, out var items) || items.Count == 0)
|
||||||
|
{
|
||||||
|
return "--";
|
||||||
|
}
|
||||||
|
|
||||||
|
var summaries = items
|
||||||
|
.Take(3)
|
||||||
|
.Select(item =>
|
||||||
|
{
|
||||||
|
var productName = string.IsNullOrWhiteSpace(item.ProductName) ? "商品" : item.ProductName.Trim();
|
||||||
|
var quantity = Math.Max(1, item.Quantity);
|
||||||
|
return $"{productName} x{quantity}";
|
||||||
|
})
|
||||||
|
.ToList();
|
||||||
|
|
||||||
|
if (items.Count > 3)
|
||||||
|
{
|
||||||
|
summaries.Add("等");
|
||||||
|
}
|
||||||
|
|
||||||
|
return string.Join("、", summaries);
|
||||||
|
}
|
||||||
|
|
||||||
|
internal static async Task<IReadOnlyList<CustomerAggregate>> LoadCustomersAsync(
|
||||||
|
IOrderRepository orderRepository,
|
||||||
|
IDapperExecutor dapperExecutor,
|
||||||
|
ITenantProvider tenantProvider,
|
||||||
|
IReadOnlyCollection<long> visibleStoreIds,
|
||||||
|
CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
if (visibleStoreIds.Count == 0)
|
||||||
|
{
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
var tenantId = tenantProvider.GetCurrentTenantId();
|
||||||
|
if (tenantId <= 0)
|
||||||
|
{
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
var visibleStoreSet = visibleStoreIds.ToHashSet();
|
||||||
|
var rawOrders = await orderRepository.SearchAllOrdersAsync(
|
||||||
|
tenantId,
|
||||||
|
null,
|
||||||
|
null,
|
||||||
|
null,
|
||||||
|
null,
|
||||||
|
null,
|
||||||
|
null,
|
||||||
|
null,
|
||||||
|
cancellationToken);
|
||||||
|
|
||||||
|
// 1. 过滤可见门店并构建订单快照
|
||||||
|
var orderSnapshots = rawOrders
|
||||||
|
.Where(item => visibleStoreSet.Contains(item.StoreId))
|
||||||
|
.Select(item =>
|
||||||
|
{
|
||||||
|
var customerKey = NormalizePhone(item.CustomerPhone);
|
||||||
|
return new CustomerOrderSnapshot
|
||||||
|
{
|
||||||
|
OrderId = item.Id,
|
||||||
|
OrderNo = item.OrderNo,
|
||||||
|
StoreId = item.StoreId,
|
||||||
|
CustomerKey = customerKey,
|
||||||
|
CustomerName = string.IsNullOrWhiteSpace(item.CustomerName) ? string.Empty : item.CustomerName.Trim(),
|
||||||
|
OrderedAt = item.CreatedAt,
|
||||||
|
Amount = ResolveDisplayAmount(item),
|
||||||
|
DeliveryType = item.DeliveryType,
|
||||||
|
Status = item.Status
|
||||||
|
};
|
||||||
|
})
|
||||||
|
.Where(item => !string.IsNullOrWhiteSpace(item.CustomerKey))
|
||||||
|
.ToList();
|
||||||
|
|
||||||
|
if (orderSnapshots.Count == 0)
|
||||||
|
{
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
var customerKeys = orderSnapshots
|
||||||
|
.Select(item => item.CustomerKey)
|
||||||
|
.Distinct(StringComparer.Ordinal)
|
||||||
|
.ToHashSet(StringComparer.Ordinal);
|
||||||
|
|
||||||
|
var memberLookup = await LoadMemberProfileLookupAsync(
|
||||||
|
dapperExecutor,
|
||||||
|
tenantId,
|
||||||
|
customerKeys,
|
||||||
|
cancellationToken);
|
||||||
|
|
||||||
|
var nowUtc = DateTime.UtcNow;
|
||||||
|
return orderSnapshots
|
||||||
|
.GroupBy(item => item.CustomerKey, StringComparer.Ordinal)
|
||||||
|
.Select(group =>
|
||||||
|
{
|
||||||
|
var customerOrders = group
|
||||||
|
.OrderByDescending(item => item.OrderedAt)
|
||||||
|
.ThenByDescending(item => item.OrderId)
|
||||||
|
.ToList();
|
||||||
|
|
||||||
|
var firstOrderAt = customerOrders.Min(item => item.OrderedAt);
|
||||||
|
var lastOrderAt = customerOrders.Max(item => item.OrderedAt);
|
||||||
|
var orderCount = customerOrders.Count;
|
||||||
|
var totalAmount = customerOrders.Sum(item => item.Amount);
|
||||||
|
var averageAmount = orderCount == 0
|
||||||
|
? 0
|
||||||
|
: decimal.Round(totalAmount / orderCount, 2, MidpointRounding.AwayFromZero);
|
||||||
|
|
||||||
|
memberLookup.TryGetValue(group.Key, out var memberProfile);
|
||||||
|
|
||||||
|
var registeredAt = firstOrderAt;
|
||||||
|
if (memberProfile?.JoinedAt is not null && memberProfile.JoinedAt.Value < registeredAt)
|
||||||
|
{
|
||||||
|
registeredAt = memberProfile.JoinedAt.Value;
|
||||||
|
}
|
||||||
|
|
||||||
|
var latestName = customerOrders
|
||||||
|
.Select(item => item.CustomerName)
|
||||||
|
.FirstOrDefault(name => !string.IsNullOrWhiteSpace(name)) ?? string.Empty;
|
||||||
|
|
||||||
|
var name = ResolveCustomerName(group.Key, latestName, memberProfile);
|
||||||
|
var tags = BuildTags(totalAmount, averageAmount, orderCount, registeredAt, lastOrderAt, nowUtc);
|
||||||
|
var member = BuildMemberSummary(memberProfile);
|
||||||
|
|
||||||
|
return new CustomerAggregate
|
||||||
|
{
|
||||||
|
CustomerKey = group.Key,
|
||||||
|
Name = name,
|
||||||
|
PhoneMasked = MaskPhone(group.Key),
|
||||||
|
AvatarText = ResolveAvatarText(name, group.Key),
|
||||||
|
AvatarColor = ResolveAvatarColor(group.Key),
|
||||||
|
RegisteredAt = registeredAt,
|
||||||
|
FirstOrderAt = firstOrderAt,
|
||||||
|
LastOrderAt = lastOrderAt,
|
||||||
|
Source = member.IsMember ? "会员中心" : "小程序",
|
||||||
|
TotalAmount = totalAmount,
|
||||||
|
AverageAmount = averageAmount,
|
||||||
|
OrderCount = orderCount,
|
||||||
|
Member = member,
|
||||||
|
Tags = tags,
|
||||||
|
IsDimmed = tags.Any(tag => tag.Code is TagDormant or TagChurn),
|
||||||
|
Orders = customerOrders
|
||||||
|
};
|
||||||
|
})
|
||||||
|
.OrderByDescending(item => item.LastOrderAt)
|
||||||
|
.ThenBy(item => item.CustomerKey, StringComparer.Ordinal)
|
||||||
|
.ToList();
|
||||||
|
}
|
||||||
|
|
||||||
|
private static string ResolvePeakSlot(int hour)
|
||||||
|
{
|
||||||
|
return hour switch
|
||||||
|
{
|
||||||
|
>= 6 and < 10 => "06:00-10:00",
|
||||||
|
>= 10 and < 14 => "10:00-14:00",
|
||||||
|
>= 14 and < 17 => "14:00-17:00",
|
||||||
|
>= 17 and < 21 => "17:00-21:00",
|
||||||
|
_ => "21:00-06:00"
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
private static async Task<Dictionary<string, MemberProfileSnapshot>> LoadMemberProfileLookupAsync(
|
||||||
|
IDapperExecutor dapperExecutor,
|
||||||
|
long tenantId,
|
||||||
|
IReadOnlySet<string> customerKeys,
|
||||||
|
CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
if (customerKeys.Count == 0)
|
||||||
|
{
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
return await dapperExecutor.QueryAsync(
|
||||||
|
DatabaseConstants.AppDataSource,
|
||||||
|
DatabaseConnectionRole.Read,
|
||||||
|
async (connection, token) =>
|
||||||
|
{
|
||||||
|
await using var command = CreateCommand(
|
||||||
|
connection,
|
||||||
|
"""
|
||||||
|
select
|
||||||
|
profile."Mobile",
|
||||||
|
profile."Nickname",
|
||||||
|
profile."JoinedAt",
|
||||||
|
profile."PointsBalance",
|
||||||
|
profile."GrowthValue",
|
||||||
|
tier."Name" as "TierName"
|
||||||
|
from public.member_profiles profile
|
||||||
|
left join public.member_tiers tier
|
||||||
|
on tier."Id" = profile."MemberTierId"
|
||||||
|
and tier."TenantId" = profile."TenantId"
|
||||||
|
and tier."DeletedAt" is null
|
||||||
|
where profile."DeletedAt" is null
|
||||||
|
and profile."TenantId" = @tenantId;
|
||||||
|
""",
|
||||||
|
[
|
||||||
|
("tenantId", tenantId)
|
||||||
|
]);
|
||||||
|
|
||||||
|
await using var reader = await command.ExecuteReaderAsync(token);
|
||||||
|
var mobileOrdinal = reader.GetOrdinal("Mobile");
|
||||||
|
var nicknameOrdinal = reader.GetOrdinal("Nickname");
|
||||||
|
var joinedAtOrdinal = reader.GetOrdinal("JoinedAt");
|
||||||
|
var tierNameOrdinal = reader.GetOrdinal("TierName");
|
||||||
|
var pointsBalanceOrdinal = reader.GetOrdinal("PointsBalance");
|
||||||
|
var growthValueOrdinal = reader.GetOrdinal("GrowthValue");
|
||||||
|
|
||||||
|
var result = new Dictionary<string, MemberProfileSnapshot>(StringComparer.Ordinal);
|
||||||
|
while (await reader.ReadAsync(token))
|
||||||
|
{
|
||||||
|
var mobile = reader.IsDBNull(mobileOrdinal) ? string.Empty : reader.GetString(mobileOrdinal);
|
||||||
|
var normalizedPhone = NormalizePhone(mobile);
|
||||||
|
if (string.IsNullOrWhiteSpace(normalizedPhone) || !customerKeys.Contains(normalizedPhone))
|
||||||
|
{
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
var snapshot = new MemberProfileSnapshot
|
||||||
|
{
|
||||||
|
Nickname = reader.IsDBNull(nicknameOrdinal) ? string.Empty : reader.GetString(nicknameOrdinal),
|
||||||
|
JoinedAt = reader.IsDBNull(joinedAtOrdinal) ? null : reader.GetDateTime(joinedAtOrdinal),
|
||||||
|
TierName = reader.IsDBNull(tierNameOrdinal) ? string.Empty : reader.GetString(tierNameOrdinal),
|
||||||
|
PointsBalance = reader.IsDBNull(pointsBalanceOrdinal) ? 0 : reader.GetInt32(pointsBalanceOrdinal),
|
||||||
|
GrowthValue = reader.IsDBNull(growthValueOrdinal) ? 0 : reader.GetInt32(growthValueOrdinal)
|
||||||
|
};
|
||||||
|
|
||||||
|
result[normalizedPhone] = snapshot;
|
||||||
|
}
|
||||||
|
|
||||||
|
return result;
|
||||||
|
},
|
||||||
|
cancellationToken);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static DbCommand CreateCommand(
|
||||||
|
IDbConnection connection,
|
||||||
|
string sql,
|
||||||
|
(string Name, object? Value)[] parameters)
|
||||||
|
{
|
||||||
|
var command = connection.CreateCommand();
|
||||||
|
command.CommandText = sql;
|
||||||
|
|
||||||
|
foreach (var (name, value) in parameters)
|
||||||
|
{
|
||||||
|
var parameter = command.CreateParameter();
|
||||||
|
parameter.ParameterName = name;
|
||||||
|
parameter.Value = value ?? DBNull.Value;
|
||||||
|
command.Parameters.Add(parameter);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (DbCommand)command;
|
||||||
|
}
|
||||||
|
|
||||||
|
private sealed class ProductCounter
|
||||||
|
{
|
||||||
|
internal int Count { get; set; }
|
||||||
|
internal long ProductId { get; init; }
|
||||||
|
internal string ProductName { get; init; } = string.Empty;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
internal sealed class CustomerAggregate
|
||||||
|
{
|
||||||
|
internal string AvatarColor { get; init; } = string.Empty;
|
||||||
|
internal string AvatarText { get; init; } = string.Empty;
|
||||||
|
internal decimal AverageAmount { get; init; }
|
||||||
|
internal string CustomerKey { get; init; } = string.Empty;
|
||||||
|
internal DateTime FirstOrderAt { get; init; }
|
||||||
|
internal bool IsDimmed { get; init; }
|
||||||
|
internal DateTime LastOrderAt { get; init; }
|
||||||
|
internal CustomerMemberSummaryDto Member { get; init; } = new();
|
||||||
|
internal string Name { get; init; } = string.Empty;
|
||||||
|
internal int OrderCount { get; init; }
|
||||||
|
internal IReadOnlyList<CustomerOrderSnapshot> Orders { get; init; } = [];
|
||||||
|
internal string PhoneMasked { get; init; } = string.Empty;
|
||||||
|
internal DateTime RegisteredAt { get; init; }
|
||||||
|
internal string Source { get; init; } = string.Empty;
|
||||||
|
internal IReadOnlyList<CustomerTagDto> Tags { get; init; } = [];
|
||||||
|
internal decimal TotalAmount { get; init; }
|
||||||
|
}
|
||||||
|
|
||||||
|
internal sealed class CustomerOrderSnapshot
|
||||||
|
{
|
||||||
|
internal decimal Amount { get; init; }
|
||||||
|
internal string CustomerKey { get; init; } = string.Empty;
|
||||||
|
internal string CustomerName { get; init; } = string.Empty;
|
||||||
|
internal DeliveryType DeliveryType { get; init; }
|
||||||
|
internal long OrderId { get; init; }
|
||||||
|
internal string OrderNo { get; init; } = string.Empty;
|
||||||
|
internal DateTime OrderedAt { get; init; }
|
||||||
|
internal OrderStatus Status { get; init; }
|
||||||
|
internal long StoreId { get; init; }
|
||||||
|
}
|
||||||
|
|
||||||
|
internal sealed class MemberProfileSnapshot
|
||||||
|
{
|
||||||
|
internal int GrowthValue { get; init; }
|
||||||
|
internal DateTime? JoinedAt { get; init; }
|
||||||
|
internal string Nickname { get; init; } = string.Empty;
|
||||||
|
internal int PointsBalance { get; init; }
|
||||||
|
internal string TierName { get; init; } = string.Empty;
|
||||||
|
}
|
||||||
@@ -0,0 +1,107 @@
|
|||||||
|
using System.Globalization;
|
||||||
|
using System.Text;
|
||||||
|
using MediatR;
|
||||||
|
using TakeoutSaaS.Application.App.Customers.Dto;
|
||||||
|
using TakeoutSaaS.Application.App.Customers.Queries;
|
||||||
|
using TakeoutSaaS.Domain.Orders.Repositories;
|
||||||
|
using TakeoutSaaS.Shared.Abstractions.Data;
|
||||||
|
using TakeoutSaaS.Shared.Abstractions.Tenancy;
|
||||||
|
|
||||||
|
namespace TakeoutSaaS.Application.App.Customers.Handlers;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 客户列表 CSV 导出处理器。
|
||||||
|
/// </summary>
|
||||||
|
public sealed class ExportCustomerCsvQueryHandler(
|
||||||
|
IOrderRepository orderRepository,
|
||||||
|
IDapperExecutor dapperExecutor,
|
||||||
|
ITenantProvider tenantProvider)
|
||||||
|
: IRequestHandler<ExportCustomerCsvQuery, CustomerExportDto>
|
||||||
|
{
|
||||||
|
/// <inheritdoc />
|
||||||
|
public async Task<CustomerExportDto> Handle(
|
||||||
|
ExportCustomerCsvQuery request,
|
||||||
|
CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
if (request.VisibleStoreIds.Count == 0)
|
||||||
|
{
|
||||||
|
return BuildExport([], 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 1. 加载聚合并应用筛选
|
||||||
|
var customers = await CustomerAnalyticsSupport.LoadCustomersAsync(
|
||||||
|
orderRepository,
|
||||||
|
dapperExecutor,
|
||||||
|
tenantProvider,
|
||||||
|
request.VisibleStoreIds,
|
||||||
|
cancellationToken);
|
||||||
|
|
||||||
|
var nowUtc = DateTime.UtcNow;
|
||||||
|
var filteredCustomers = CustomerAnalyticsSupport.ApplyFilters(
|
||||||
|
customers,
|
||||||
|
request.Keyword,
|
||||||
|
CustomerAnalyticsSupport.NormalizeTag(request.Tag),
|
||||||
|
CustomerAnalyticsSupport.NormalizeOrderCountRange(request.OrderCountRange),
|
||||||
|
request.RegisterPeriodDays,
|
||||||
|
nowUtc)
|
||||||
|
.OrderByDescending(item => item.LastOrderAt)
|
||||||
|
.ThenBy(item => item.CustomerKey, StringComparer.Ordinal)
|
||||||
|
.ToList();
|
||||||
|
|
||||||
|
return BuildExport(filteredCustomers, filteredCustomers.Count);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static CustomerExportDto BuildExport(
|
||||||
|
IReadOnlyList<CustomerAggregate> customers,
|
||||||
|
int totalCount)
|
||||||
|
{
|
||||||
|
var csv = BuildCsv(customers);
|
||||||
|
var bytes = Encoding.UTF8.GetPreamble().Concat(Encoding.UTF8.GetBytes(csv)).ToArray();
|
||||||
|
|
||||||
|
return new CustomerExportDto
|
||||||
|
{
|
||||||
|
FileName = $"客户列表_{DateTime.UtcNow:yyyyMMddHHmmss}.csv",
|
||||||
|
FileContentBase64 = Convert.ToBase64String(bytes),
|
||||||
|
TotalCount = totalCount
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
private static string BuildCsv(IReadOnlyList<CustomerAggregate> customers)
|
||||||
|
{
|
||||||
|
var sb = new StringBuilder();
|
||||||
|
sb.AppendLine("客户名称,手机号,下单次数,累计消费,客单价,最近下单时间,注册时间,客户标签");
|
||||||
|
|
||||||
|
foreach (var customer in customers)
|
||||||
|
{
|
||||||
|
var tags = customer.Tags.Count == 0
|
||||||
|
? string.Empty
|
||||||
|
: string.Join('、', customer.Tags.Select(item => item.Label));
|
||||||
|
|
||||||
|
var row = new[]
|
||||||
|
{
|
||||||
|
Escape(customer.Name),
|
||||||
|
Escape(customer.PhoneMasked),
|
||||||
|
Escape(customer.OrderCount.ToString(CultureInfo.InvariantCulture)),
|
||||||
|
Escape(customer.TotalAmount.ToString("0.00", CultureInfo.InvariantCulture)),
|
||||||
|
Escape(customer.AverageAmount.ToString("0.00", CultureInfo.InvariantCulture)),
|
||||||
|
Escape(customer.LastOrderAt.ToString("yyyy-MM-dd", CultureInfo.InvariantCulture)),
|
||||||
|
Escape(customer.RegisteredAt.ToString("yyyy-MM-dd", CultureInfo.InvariantCulture)),
|
||||||
|
Escape(tags)
|
||||||
|
};
|
||||||
|
|
||||||
|
sb.AppendLine(string.Join(',', row));
|
||||||
|
}
|
||||||
|
|
||||||
|
return sb.ToString();
|
||||||
|
}
|
||||||
|
|
||||||
|
private static string Escape(string input)
|
||||||
|
{
|
||||||
|
if (!input.Contains('"') && !input.Contains(',') && !input.Contains('\n') && !input.Contains('\r'))
|
||||||
|
{
|
||||||
|
return input;
|
||||||
|
}
|
||||||
|
|
||||||
|
return $"\"{input.Replace("\"", "\"\"")}\"";
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,107 @@
|
|||||||
|
using MediatR;
|
||||||
|
using TakeoutSaaS.Application.App.Customers.Dto;
|
||||||
|
using TakeoutSaaS.Application.App.Customers.Queries;
|
||||||
|
using TakeoutSaaS.Domain.Orders.Repositories;
|
||||||
|
using TakeoutSaaS.Domain.Products.Repositories;
|
||||||
|
using TakeoutSaaS.Shared.Abstractions.Data;
|
||||||
|
using TakeoutSaaS.Shared.Abstractions.Tenancy;
|
||||||
|
|
||||||
|
namespace TakeoutSaaS.Application.App.Customers.Handlers;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 客户详情查询处理器。
|
||||||
|
/// </summary>
|
||||||
|
public sealed class GetCustomerDetailQueryHandler(
|
||||||
|
IOrderRepository orderRepository,
|
||||||
|
IProductRepository productRepository,
|
||||||
|
IDapperExecutor dapperExecutor,
|
||||||
|
ITenantProvider tenantProvider)
|
||||||
|
: IRequestHandler<GetCustomerDetailQuery, CustomerDetailDto?>
|
||||||
|
{
|
||||||
|
/// <inheritdoc />
|
||||||
|
public async Task<CustomerDetailDto?> Handle(
|
||||||
|
GetCustomerDetailQuery request,
|
||||||
|
CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
// 1. 参数与可见门店校验
|
||||||
|
var customerKey = CustomerAnalyticsSupport.NormalizePhone(request.CustomerKey);
|
||||||
|
if (string.IsNullOrWhiteSpace(customerKey) || request.VisibleStoreIds.Count == 0)
|
||||||
|
{
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2. 加载客户聚合并定位目标客户
|
||||||
|
var customers = await CustomerAnalyticsSupport.LoadCustomersAsync(
|
||||||
|
orderRepository,
|
||||||
|
dapperExecutor,
|
||||||
|
tenantProvider,
|
||||||
|
request.VisibleStoreIds,
|
||||||
|
cancellationToken);
|
||||||
|
|
||||||
|
var customer = customers.FirstOrDefault(item =>
|
||||||
|
string.Equals(item.CustomerKey, customerKey, StringComparison.Ordinal));
|
||||||
|
if (customer is null)
|
||||||
|
{
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
var tenantId = tenantProvider.GetCurrentTenantId();
|
||||||
|
if (tenantId <= 0)
|
||||||
|
{
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 3. 加载订单明细并计算画像细节
|
||||||
|
var orderIds = customer.Orders
|
||||||
|
.Select(item => item.OrderId)
|
||||||
|
.ToList();
|
||||||
|
var itemsLookup = await orderRepository.GetItemsByOrderIdsAsync(orderIds, tenantId, cancellationToken);
|
||||||
|
|
||||||
|
var topProducts = CustomerAnalyticsSupport.BuildTopProducts(itemsLookup, orderIds, 5);
|
||||||
|
var preferredCategories = await CustomerAnalyticsSupport.ResolvePreferredCategoriesAsync(
|
||||||
|
productRepository,
|
||||||
|
tenantId,
|
||||||
|
itemsLookup,
|
||||||
|
orderIds,
|
||||||
|
cancellationToken);
|
||||||
|
var preferredDelivery = CustomerAnalyticsSupport.ResolvePreferredDelivery(customer.Orders);
|
||||||
|
var preferredPaymentMethod = await CustomerAnalyticsSupport.ResolvePreferredPaymentMethodAsync(
|
||||||
|
orderRepository,
|
||||||
|
tenantId,
|
||||||
|
customer.Orders,
|
||||||
|
cancellationToken);
|
||||||
|
var preferredOrderPeaks = CustomerAnalyticsSupport.ResolvePreferredOrderPeaks(customer.Orders);
|
||||||
|
var recentOrders = CustomerAnalyticsSupport.BuildRecentOrders(customer.Orders, itemsLookup, 3);
|
||||||
|
var trend = CustomerAnalyticsSupport.BuildMonthlyTrend(customer.Orders, DateTime.UtcNow, 6);
|
||||||
|
var repurchaseRatePercent = CustomerAnalyticsSupport.ToRatePercent(
|
||||||
|
Math.Max(0, customer.OrderCount - 1),
|
||||||
|
customer.OrderCount);
|
||||||
|
|
||||||
|
return new CustomerDetailDto
|
||||||
|
{
|
||||||
|
CustomerKey = customer.CustomerKey,
|
||||||
|
Name = customer.Name,
|
||||||
|
PhoneMasked = customer.PhoneMasked,
|
||||||
|
RegisteredAt = customer.RegisteredAt,
|
||||||
|
FirstOrderAt = customer.FirstOrderAt,
|
||||||
|
Source = customer.Source,
|
||||||
|
Tags = customer.Tags,
|
||||||
|
Member = customer.Member,
|
||||||
|
TotalOrders = customer.OrderCount,
|
||||||
|
TotalAmount = customer.TotalAmount,
|
||||||
|
AverageAmount = customer.AverageAmount,
|
||||||
|
RepurchaseRatePercent = repurchaseRatePercent,
|
||||||
|
Preference = new CustomerPreferenceDto
|
||||||
|
{
|
||||||
|
PreferredCategories = preferredCategories,
|
||||||
|
PreferredOrderPeaks = preferredOrderPeaks,
|
||||||
|
PreferredDelivery = preferredDelivery,
|
||||||
|
PreferredPaymentMethod = preferredPaymentMethod,
|
||||||
|
AverageDeliveryDistance = string.Empty
|
||||||
|
},
|
||||||
|
TopProducts = topProducts,
|
||||||
|
Trend = trend,
|
||||||
|
RecentOrders = recentOrders
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,85 @@
|
|||||||
|
using MediatR;
|
||||||
|
using TakeoutSaaS.Application.App.Customers.Dto;
|
||||||
|
using TakeoutSaaS.Application.App.Customers.Queries;
|
||||||
|
using TakeoutSaaS.Domain.Orders.Repositories;
|
||||||
|
using TakeoutSaaS.Shared.Abstractions.Data;
|
||||||
|
using TakeoutSaaS.Shared.Abstractions.Tenancy;
|
||||||
|
|
||||||
|
namespace TakeoutSaaS.Application.App.Customers.Handlers;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 客户列表统计查询处理器。
|
||||||
|
/// </summary>
|
||||||
|
public sealed class GetCustomerListStatsQueryHandler(
|
||||||
|
IOrderRepository orderRepository,
|
||||||
|
IDapperExecutor dapperExecutor,
|
||||||
|
ITenantProvider tenantProvider)
|
||||||
|
: IRequestHandler<GetCustomerListStatsQuery, CustomerListStatsDto>
|
||||||
|
{
|
||||||
|
/// <inheritdoc />
|
||||||
|
public async Task<CustomerListStatsDto> Handle(
|
||||||
|
GetCustomerListStatsQuery request,
|
||||||
|
CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
// 1. 可见门店为空时直接返回空统计
|
||||||
|
if (request.VisibleStoreIds.Count == 0)
|
||||||
|
{
|
||||||
|
return new CustomerListStatsDto();
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2. 加载客户聚合并应用筛选
|
||||||
|
var customers = await CustomerAnalyticsSupport.LoadCustomersAsync(
|
||||||
|
orderRepository,
|
||||||
|
dapperExecutor,
|
||||||
|
tenantProvider,
|
||||||
|
request.VisibleStoreIds,
|
||||||
|
cancellationToken);
|
||||||
|
|
||||||
|
var nowUtc = DateTime.UtcNow;
|
||||||
|
var filteredCustomers = CustomerAnalyticsSupport.ApplyFilters(
|
||||||
|
customers,
|
||||||
|
request.Keyword,
|
||||||
|
CustomerAnalyticsSupport.NormalizeTag(request.Tag),
|
||||||
|
CustomerAnalyticsSupport.NormalizeOrderCountRange(request.OrderCountRange),
|
||||||
|
request.RegisterPeriodDays,
|
||||||
|
nowUtc);
|
||||||
|
|
||||||
|
// 3. 计算统计指标
|
||||||
|
var monthStart = new DateTime(nowUtc.Year, nowUtc.Month, 1, 0, 0, 0, DateTimeKind.Utc);
|
||||||
|
var nextMonthStart = monthStart.AddMonths(1);
|
||||||
|
var previousMonthStart = monthStart.AddMonths(-1);
|
||||||
|
|
||||||
|
var totalCustomers = filteredCustomers.Count;
|
||||||
|
var monthlyNewCustomers = filteredCustomers.Count(item =>
|
||||||
|
item.RegisteredAt >= monthStart &&
|
||||||
|
item.RegisteredAt < nextMonthStart);
|
||||||
|
var previousMonthlyNewCustomers = filteredCustomers.Count(item =>
|
||||||
|
item.RegisteredAt >= previousMonthStart &&
|
||||||
|
item.RegisteredAt < monthStart);
|
||||||
|
var activeCustomers = filteredCustomers.Count(item =>
|
||||||
|
item.LastOrderAt >= nowUtc.AddDays(-30));
|
||||||
|
|
||||||
|
var recentOrders = filteredCustomers
|
||||||
|
.SelectMany(item => item.Orders)
|
||||||
|
.Where(item => item.OrderedAt >= nowUtc.AddDays(-30))
|
||||||
|
.ToList();
|
||||||
|
|
||||||
|
var averageAmountLast30Days = recentOrders.Count == 0
|
||||||
|
? 0
|
||||||
|
: decimal.Round(
|
||||||
|
recentOrders.Sum(item => item.Amount) / recentOrders.Count,
|
||||||
|
2,
|
||||||
|
MidpointRounding.AwayFromZero);
|
||||||
|
|
||||||
|
return new CustomerListStatsDto
|
||||||
|
{
|
||||||
|
TotalCustomers = totalCustomers,
|
||||||
|
MonthlyNewCustomers = monthlyNewCustomers,
|
||||||
|
MonthlyGrowthRatePercent = CustomerAnalyticsSupport.ToGrowthRatePercent(
|
||||||
|
monthlyNewCustomers,
|
||||||
|
previousMonthlyNewCustomers),
|
||||||
|
ActiveCustomers = activeCustomers,
|
||||||
|
AverageAmountLast30Days = averageAmountLast30Days
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,109 @@
|
|||||||
|
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 GetCustomerProfileQueryHandler(
|
||||||
|
IOrderRepository orderRepository,
|
||||||
|
IProductRepository productRepository,
|
||||||
|
IDapperExecutor dapperExecutor,
|
||||||
|
ITenantProvider tenantProvider)
|
||||||
|
: IRequestHandler<GetCustomerProfileQuery, CustomerProfileDto?>
|
||||||
|
{
|
||||||
|
/// <inheritdoc />
|
||||||
|
public async Task<CustomerProfileDto?> Handle(
|
||||||
|
GetCustomerProfileQuery 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, 5);
|
||||||
|
var trend = CustomerAnalyticsSupport.BuildMonthlyTrend(customer.Orders, DateTime.UtcNow, 12);
|
||||||
|
var repurchaseRatePercent = CustomerAnalyticsSupport.ToRatePercent(
|
||||||
|
Math.Max(0, customer.OrderCount - 1),
|
||||||
|
customer.OrderCount);
|
||||||
|
var averageOrderIntervalDays = CustomerAnalyticsSupport.CalculateAverageIntervalDays(customer.Orders);
|
||||||
|
|
||||||
|
return new CustomerProfileDto
|
||||||
|
{
|
||||||
|
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,
|
||||||
|
AverageOrderIntervalDays = averageOrderIntervalDays,
|
||||||
|
Preference = new CustomerPreferenceDto
|
||||||
|
{
|
||||||
|
PreferredCategories = preferredCategories,
|
||||||
|
PreferredOrderPeaks = preferredOrderPeaks,
|
||||||
|
PreferredDelivery = preferredDelivery,
|
||||||
|
PreferredPaymentMethod = preferredPaymentMethod,
|
||||||
|
AverageDeliveryDistance = string.Empty
|
||||||
|
},
|
||||||
|
TopProducts = topProducts,
|
||||||
|
Trend = trend,
|
||||||
|
RecentOrders = recentOrders
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,80 @@
|
|||||||
|
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.Results;
|
||||||
|
using TakeoutSaaS.Shared.Abstractions.Tenancy;
|
||||||
|
|
||||||
|
namespace TakeoutSaaS.Application.App.Customers.Handlers;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 客户列表查询处理器。
|
||||||
|
/// </summary>
|
||||||
|
public sealed class SearchCustomerListQueryHandler(
|
||||||
|
IOrderRepository orderRepository,
|
||||||
|
IDapperExecutor dapperExecutor,
|
||||||
|
ITenantProvider tenantProvider)
|
||||||
|
: IRequestHandler<SearchCustomerListQuery, PagedResult<CustomerListItemDto>>
|
||||||
|
{
|
||||||
|
/// <inheritdoc />
|
||||||
|
public async Task<PagedResult<CustomerListItemDto>> Handle(
|
||||||
|
SearchCustomerListQuery request,
|
||||||
|
CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
// 1. 规范化分页参数
|
||||||
|
var page = Math.Max(1, request.Page);
|
||||||
|
var pageSize = Math.Clamp(request.PageSize, 1, 200);
|
||||||
|
if (request.VisibleStoreIds.Count == 0)
|
||||||
|
{
|
||||||
|
return new PagedResult<CustomerListItemDto>([], page, pageSize, 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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)
|
||||||
|
.OrderByDescending(item => item.LastOrderAt)
|
||||||
|
.ThenBy(item => item.CustomerKey, StringComparer.Ordinal)
|
||||||
|
.ToList();
|
||||||
|
|
||||||
|
// 3. 执行分页与列表映射
|
||||||
|
var totalCount = filteredCustomers.Count;
|
||||||
|
var maxOrderCount = Math.Max(1, filteredCustomers.Select(item => item.OrderCount).DefaultIfEmpty(1).Max());
|
||||||
|
|
||||||
|
var items = filteredCustomers
|
||||||
|
.Skip((page - 1) * pageSize)
|
||||||
|
.Take(pageSize)
|
||||||
|
.Select(item => new CustomerListItemDto
|
||||||
|
{
|
||||||
|
CustomerKey = item.CustomerKey,
|
||||||
|
Name = item.Name,
|
||||||
|
PhoneMasked = item.PhoneMasked,
|
||||||
|
AvatarText = item.AvatarText,
|
||||||
|
AvatarColor = item.AvatarColor,
|
||||||
|
OrderCount = item.OrderCount,
|
||||||
|
OrderCountBarPercent = Math.Max(4, (int)Math.Round(item.OrderCount * 100d / maxOrderCount, MidpointRounding.AwayFromZero)),
|
||||||
|
TotalAmount = item.TotalAmount,
|
||||||
|
AverageAmount = item.AverageAmount,
|
||||||
|
RegisteredAt = item.RegisteredAt,
|
||||||
|
LastOrderAt = item.LastOrderAt,
|
||||||
|
Tags = item.Tags,
|
||||||
|
IsDimmed = item.IsDimmed
|
||||||
|
})
|
||||||
|
.ToList();
|
||||||
|
|
||||||
|
return new PagedResult<CustomerListItemDto>(items, page, pageSize, totalCount);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,35 @@
|
|||||||
|
using MediatR;
|
||||||
|
using TakeoutSaaS.Application.App.Customers.Dto;
|
||||||
|
|
||||||
|
namespace TakeoutSaaS.Application.App.Customers.Queries;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 客户列表 CSV 导出查询。
|
||||||
|
/// </summary>
|
||||||
|
public sealed class ExportCustomerCsvQuery : IRequest<CustomerExportDto>
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// 可见门店 ID 集合。
|
||||||
|
/// </summary>
|
||||||
|
public IReadOnlyCollection<long> VisibleStoreIds { get; init; } = [];
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 关键词(姓名/手机号)。
|
||||||
|
/// </summary>
|
||||||
|
public string? Keyword { get; init; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 客户标签筛选。
|
||||||
|
/// </summary>
|
||||||
|
public string? Tag { get; init; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 下单次数区间。
|
||||||
|
/// </summary>
|
||||||
|
public string? OrderCountRange { get; init; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 注册周期天数(7/30/90)。
|
||||||
|
/// </summary>
|
||||||
|
public int? RegisterPeriodDays { get; init; }
|
||||||
|
}
|
||||||
@@ -0,0 +1,20 @@
|
|||||||
|
using MediatR;
|
||||||
|
using TakeoutSaaS.Application.App.Customers.Dto;
|
||||||
|
|
||||||
|
namespace TakeoutSaaS.Application.App.Customers.Queries;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 客户详情查询。
|
||||||
|
/// </summary>
|
||||||
|
public sealed class GetCustomerDetailQuery : IRequest<CustomerDetailDto?>
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// 可见门店 ID 集合。
|
||||||
|
/// </summary>
|
||||||
|
public IReadOnlyCollection<long> VisibleStoreIds { get; init; } = [];
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 客户标识(手机号归一化)。
|
||||||
|
/// </summary>
|
||||||
|
public string CustomerKey { get; init; } = string.Empty;
|
||||||
|
}
|
||||||
@@ -0,0 +1,35 @@
|
|||||||
|
using MediatR;
|
||||||
|
using TakeoutSaaS.Application.App.Customers.Dto;
|
||||||
|
|
||||||
|
namespace TakeoutSaaS.Application.App.Customers.Queries;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 客户列表统计查询。
|
||||||
|
/// </summary>
|
||||||
|
public sealed class GetCustomerListStatsQuery : IRequest<CustomerListStatsDto>
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// 可见门店 ID 集合。
|
||||||
|
/// </summary>
|
||||||
|
public IReadOnlyCollection<long> VisibleStoreIds { get; init; } = [];
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 关键词(姓名/手机号)。
|
||||||
|
/// </summary>
|
||||||
|
public string? Keyword { get; init; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 客户标签筛选。
|
||||||
|
/// </summary>
|
||||||
|
public string? Tag { get; init; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 下单次数区间。
|
||||||
|
/// </summary>
|
||||||
|
public string? OrderCountRange { get; init; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 注册周期天数(7/30/90)。
|
||||||
|
/// </summary>
|
||||||
|
public int? RegisterPeriodDays { get; init; }
|
||||||
|
}
|
||||||
@@ -0,0 +1,20 @@
|
|||||||
|
using MediatR;
|
||||||
|
using TakeoutSaaS.Application.App.Customers.Dto;
|
||||||
|
|
||||||
|
namespace TakeoutSaaS.Application.App.Customers.Queries;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 客户画像查询。
|
||||||
|
/// </summary>
|
||||||
|
public sealed class GetCustomerProfileQuery : IRequest<CustomerProfileDto?>
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// 可见门店 ID 集合。
|
||||||
|
/// </summary>
|
||||||
|
public IReadOnlyCollection<long> VisibleStoreIds { get; init; } = [];
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 客户标识(手机号归一化)。
|
||||||
|
/// </summary>
|
||||||
|
public string CustomerKey { get; init; } = string.Empty;
|
||||||
|
}
|
||||||
@@ -0,0 +1,46 @@
|
|||||||
|
using MediatR;
|
||||||
|
using TakeoutSaaS.Application.App.Customers.Dto;
|
||||||
|
using TakeoutSaaS.Shared.Abstractions.Results;
|
||||||
|
|
||||||
|
namespace TakeoutSaaS.Application.App.Customers.Queries;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 客户列表查询。
|
||||||
|
/// </summary>
|
||||||
|
public sealed class SearchCustomerListQuery : IRequest<PagedResult<CustomerListItemDto>>
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// 可见门店 ID 集合。
|
||||||
|
/// </summary>
|
||||||
|
public IReadOnlyCollection<long> VisibleStoreIds { get; init; } = [];
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 关键词(姓名/手机号)。
|
||||||
|
/// </summary>
|
||||||
|
public string? Keyword { get; init; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 客户标签筛选。
|
||||||
|
/// </summary>
|
||||||
|
public string? Tag { get; init; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 下单次数区间。
|
||||||
|
/// </summary>
|
||||||
|
public string? OrderCountRange { get; init; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 注册周期天数(7/30/90)。
|
||||||
|
/// </summary>
|
||||||
|
public int? RegisterPeriodDays { get; init; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 页码。
|
||||||
|
/// </summary>
|
||||||
|
public int Page { get; init; } = 1;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 每页条数。
|
||||||
|
/// </summary>
|
||||||
|
public int PageSize { get; init; } = 10;
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user