feat(customer): add customer analysis query APIs
This commit is contained in:
@@ -0,0 +1,521 @@
|
||||
namespace TakeoutSaaS.TenantApi.Contracts.Customer;
|
||||
|
||||
/// <summary>
|
||||
/// 客户分析总览请求。
|
||||
/// </summary>
|
||||
public sealed class CustomerAnalysisOverviewRequest
|
||||
{
|
||||
/// <summary>
|
||||
/// 门店 ID(可选,未传表示当前商户全部可见门店)。
|
||||
/// </summary>
|
||||
public string? StoreId { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 统计周期(7d/30d/90d/365d)。
|
||||
/// </summary>
|
||||
public string? Period { get; set; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 客群明细筛选请求。
|
||||
/// </summary>
|
||||
public class CustomerAnalysisSegmentFilterRequest
|
||||
{
|
||||
/// <summary>
|
||||
/// 门店 ID(可选,未传表示当前商户全部可见门店)。
|
||||
/// </summary>
|
||||
public string? StoreId { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 统计周期(7d/30d/90d/365d)。
|
||||
/// </summary>
|
||||
public string? Period { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 分群编码。
|
||||
/// </summary>
|
||||
public string SegmentCode { get; set; } = "all";
|
||||
|
||||
/// <summary>
|
||||
/// 关键词(姓名/手机号)。
|
||||
/// </summary>
|
||||
public string? Keyword { get; set; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 客群明细分页请求。
|
||||
/// </summary>
|
||||
public sealed class CustomerAnalysisSegmentListRequest : CustomerAnalysisSegmentFilterRequest
|
||||
{
|
||||
/// <summary>
|
||||
/// 页码。
|
||||
/// </summary>
|
||||
public int Page { get; set; } = 1;
|
||||
|
||||
/// <summary>
|
||||
/// 每页条数。
|
||||
/// </summary>
|
||||
public int PageSize { get; set; } = 10;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 会员详情请求。
|
||||
/// </summary>
|
||||
public sealed class CustomerMemberDetailRequest
|
||||
{
|
||||
/// <summary>
|
||||
/// 门店 ID(可选,未传表示当前商户全部可见门店)。
|
||||
/// </summary>
|
||||
public string? StoreId { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 客户标识(手机号归一化)。
|
||||
/// </summary>
|
||||
public string CustomerKey { get; set; } = string.Empty;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 客户分析导出请求。
|
||||
/// </summary>
|
||||
public sealed class CustomerAnalysisExportRequest
|
||||
{
|
||||
/// <summary>
|
||||
/// 门店 ID(可选,未传表示当前商户全部可见门店)。
|
||||
/// </summary>
|
||||
public string? StoreId { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 统计周期(7d/30d/90d/365d)。
|
||||
/// </summary>
|
||||
public string? Period { get; set; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 客户分析趋势点响应。
|
||||
/// </summary>
|
||||
public sealed class CustomerAnalysisTrendPointResponse
|
||||
{
|
||||
/// <summary>
|
||||
/// 维度标签。
|
||||
/// </summary>
|
||||
public string Label { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// 数量值。
|
||||
/// </summary>
|
||||
public int Value { get; set; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 新老客构成项响应。
|
||||
/// </summary>
|
||||
public sealed class CustomerAnalysisCompositionItemResponse
|
||||
{
|
||||
/// <summary>
|
||||
/// 分群编码。
|
||||
/// </summary>
|
||||
public string SegmentCode { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// 分群名称。
|
||||
/// </summary>
|
||||
public string Label { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// 人数。
|
||||
/// </summary>
|
||||
public int Count { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 占比(百分比)。
|
||||
/// </summary>
|
||||
public decimal Percent { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 色调。
|
||||
/// </summary>
|
||||
public string Tone { get; set; } = "blue";
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 客单价分布项响应。
|
||||
/// </summary>
|
||||
public sealed class CustomerAnalysisAmountDistributionItemResponse
|
||||
{
|
||||
/// <summary>
|
||||
/// 分群编码。
|
||||
/// </summary>
|
||||
public string SegmentCode { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// 区间标签。
|
||||
/// </summary>
|
||||
public string Label { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// 人数。
|
||||
/// </summary>
|
||||
public int Count { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 占比(百分比)。
|
||||
/// </summary>
|
||||
public decimal Percent { get; set; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// RFM 分层单元响应。
|
||||
/// </summary>
|
||||
public sealed class CustomerAnalysisRfmCellResponse
|
||||
{
|
||||
/// <summary>
|
||||
/// 分群编码。
|
||||
/// </summary>
|
||||
public string SegmentCode { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// 标签。
|
||||
/// </summary>
|
||||
public string Label { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// 人数。
|
||||
/// </summary>
|
||||
public int Count { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 温度(hot/warm/cool/cold)。
|
||||
/// </summary>
|
||||
public string Tone { get; set; } = "cold";
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// RFM 分层行响应。
|
||||
/// </summary>
|
||||
public sealed class CustomerAnalysisRfmRowResponse
|
||||
{
|
||||
/// <summary>
|
||||
/// 行标签。
|
||||
/// </summary>
|
||||
public string Label { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// 单元格集合。
|
||||
/// </summary>
|
||||
public List<CustomerAnalysisRfmCellResponse> Cells { get; set; } = [];
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 高价值客户响应。
|
||||
/// </summary>
|
||||
public sealed class CustomerAnalysisTopCustomerResponse
|
||||
{
|
||||
/// <summary>
|
||||
/// 排名。
|
||||
/// </summary>
|
||||
public int Rank { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 客户标识。
|
||||
/// </summary>
|
||||
public string CustomerKey { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// 客户名称。
|
||||
/// </summary>
|
||||
public string Name { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// 手机号(脱敏)。
|
||||
/// </summary>
|
||||
public string PhoneMasked { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// 累计消费。
|
||||
/// </summary>
|
||||
public decimal TotalAmount { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 下单次数。
|
||||
/// </summary>
|
||||
public int OrderCount { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 客单价。
|
||||
/// </summary>
|
||||
public decimal AverageAmount { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 最近下单时间(yyyy-MM-dd)。
|
||||
/// </summary>
|
||||
public string LastOrderAt { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// 客户标签。
|
||||
/// </summary>
|
||||
public List<CustomerTagResponse> Tags { get; set; } = [];
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 客户分析总览响应。
|
||||
/// </summary>
|
||||
public sealed class CustomerAnalysisOverviewResponse
|
||||
{
|
||||
/// <summary>
|
||||
/// 统计周期编码。
|
||||
/// </summary>
|
||||
public string PeriodCode { get; set; } = "30d";
|
||||
|
||||
/// <summary>
|
||||
/// 统计周期天数。
|
||||
/// </summary>
|
||||
public int PeriodDays { get; set; } = 30;
|
||||
|
||||
/// <summary>
|
||||
/// 客户总数。
|
||||
/// </summary>
|
||||
public int TotalCustomers { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 周期新增客户数。
|
||||
/// </summary>
|
||||
public int NewCustomers { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 新增较上一周期增长百分比。
|
||||
/// </summary>
|
||||
public decimal GrowthRatePercent { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 周期内日均新增客户。
|
||||
/// </summary>
|
||||
public decimal NewCustomersDailyAverage { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 活跃客户数。
|
||||
/// </summary>
|
||||
public int ActiveCustomers { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 活跃率(百分比)。
|
||||
/// </summary>
|
||||
public decimal ActiveRatePercent { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 平均客户价值。
|
||||
/// </summary>
|
||||
public decimal AverageLifetimeValue { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 客户增长趋势。
|
||||
/// </summary>
|
||||
public List<CustomerAnalysisTrendPointResponse> GrowthTrend { get; set; } = [];
|
||||
|
||||
/// <summary>
|
||||
/// 新老客占比。
|
||||
/// </summary>
|
||||
public List<CustomerAnalysisCompositionItemResponse> Composition { get; set; } = [];
|
||||
|
||||
/// <summary>
|
||||
/// 客单价分布。
|
||||
/// </summary>
|
||||
public List<CustomerAnalysisAmountDistributionItemResponse> AmountDistribution { get; set; } = [];
|
||||
|
||||
/// <summary>
|
||||
/// RFM 分层。
|
||||
/// </summary>
|
||||
public List<CustomerAnalysisRfmRowResponse> RfmRows { get; set; } = [];
|
||||
|
||||
/// <summary>
|
||||
/// 高价值客户 Top10。
|
||||
/// </summary>
|
||||
public List<CustomerAnalysisTopCustomerResponse> TopCustomers { get; set; } = [];
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 客群明细行响应。
|
||||
/// </summary>
|
||||
public sealed class CustomerAnalysisSegmentListItemResponse
|
||||
{
|
||||
/// <summary>
|
||||
/// 客户标识。
|
||||
/// </summary>
|
||||
public string CustomerKey { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// 客户名称。
|
||||
/// </summary>
|
||||
public string Name { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// 手机号(脱敏)。
|
||||
/// </summary>
|
||||
public string PhoneMasked { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// 头像文案。
|
||||
/// </summary>
|
||||
public string AvatarText { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// 头像颜色。
|
||||
/// </summary>
|
||||
public string AvatarColor { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// 客户标签。
|
||||
/// </summary>
|
||||
public List<CustomerTagResponse> Tags { get; set; } = [];
|
||||
|
||||
/// <summary>
|
||||
/// 是否会员。
|
||||
/// </summary>
|
||||
public bool IsMember { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 会员等级。
|
||||
/// </summary>
|
||||
public string MemberTierName { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// 累计消费。
|
||||
/// </summary>
|
||||
public decimal TotalAmount { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 下单次数。
|
||||
/// </summary>
|
||||
public int OrderCount { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 客单价。
|
||||
/// </summary>
|
||||
public decimal AverageAmount { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 注册时间(yyyy-MM-dd)。
|
||||
/// </summary>
|
||||
public string RegisteredAt { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// 最近下单时间(yyyy-MM-dd)。
|
||||
/// </summary>
|
||||
public string LastOrderAt { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// 是否弱化显示。
|
||||
/// </summary>
|
||||
public bool IsDimmed { get; set; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 客群明细分页响应。
|
||||
/// </summary>
|
||||
public sealed class CustomerAnalysisSegmentListResultResponse
|
||||
{
|
||||
/// <summary>
|
||||
/// 分群编码。
|
||||
/// </summary>
|
||||
public string SegmentCode { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// 分群标题。
|
||||
/// </summary>
|
||||
public string SegmentTitle { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// 分群说明。
|
||||
/// </summary>
|
||||
public string SegmentDescription { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// 列表项。
|
||||
/// </summary>
|
||||
public List<CustomerAnalysisSegmentListItemResponse> Items { get; set; } = [];
|
||||
|
||||
/// <summary>
|
||||
/// 当前页。
|
||||
/// </summary>
|
||||
public int Page { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 每页条数。
|
||||
/// </summary>
|
||||
public int PageSize { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 总记录数。
|
||||
/// </summary>
|
||||
public int TotalCount { get; set; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 会员详情响应。
|
||||
/// </summary>
|
||||
public sealed class CustomerMemberDetailResponse
|
||||
{
|
||||
/// <summary>
|
||||
/// 客户标识。
|
||||
/// </summary>
|
||||
public string CustomerKey { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// 客户名称。
|
||||
/// </summary>
|
||||
public string Name { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// 手机号(脱敏)。
|
||||
/// </summary>
|
||||
public string PhoneMasked { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// 来源。
|
||||
/// </summary>
|
||||
public string Source { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// 注册时间(yyyy-MM-dd)。
|
||||
/// </summary>
|
||||
public string RegisteredAt { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// 最近下单时间(yyyy-MM-dd)。
|
||||
/// </summary>
|
||||
public string LastOrderAt { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// 会员摘要。
|
||||
/// </summary>
|
||||
public CustomerMemberSummaryResponse Member { get; set; } = new();
|
||||
|
||||
/// <summary>
|
||||
/// 客户标签。
|
||||
/// </summary>
|
||||
public List<CustomerTagResponse> Tags { get; set; } = [];
|
||||
|
||||
/// <summary>
|
||||
/// 累计下单次数。
|
||||
/// </summary>
|
||||
public int TotalOrders { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 累计消费。
|
||||
/// </summary>
|
||||
public decimal TotalAmount { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 客单价。
|
||||
/// </summary>
|
||||
public decimal AverageAmount { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 复购率(百分比)。
|
||||
/// </summary>
|
||||
public decimal RepurchaseRatePercent { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 最近订单。
|
||||
/// </summary>
|
||||
public List<CustomerRecentOrderResponse> RecentOrders { get; set; } = [];
|
||||
}
|
||||
@@ -0,0 +1,535 @@
|
||||
using System.Globalization;
|
||||
using MediatR;
|
||||
using Microsoft.AspNetCore.Authorization;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using TakeoutSaaS.Application.App.Customers.Dto;
|
||||
using TakeoutSaaS.Application.App.Customers.Queries;
|
||||
using TakeoutSaaS.Application.App.Stores.Services;
|
||||
using TakeoutSaaS.Infrastructure.App.Persistence;
|
||||
using TakeoutSaaS.Module.Authorization.Attributes;
|
||||
using TakeoutSaaS.Shared.Abstractions.Constants;
|
||||
using TakeoutSaaS.Shared.Abstractions.Exceptions;
|
||||
using TakeoutSaaS.Shared.Abstractions.Results;
|
||||
using TakeoutSaaS.Shared.Web.Api;
|
||||
using TakeoutSaaS.TenantApi.Contracts.Customer;
|
||||
|
||||
namespace TakeoutSaaS.TenantApi.Controllers;
|
||||
|
||||
/// <summary>
|
||||
/// 客户分析。
|
||||
/// </summary>
|
||||
[ApiVersion("1.0")]
|
||||
[Authorize]
|
||||
[Route("api/tenant/v{version:apiVersion}/customer/analysis")]
|
||||
public sealed class CustomerAnalysisController(
|
||||
IMediator mediator,
|
||||
TakeoutAppDbContext dbContext,
|
||||
StoreContextService storeContextService)
|
||||
: BaseApiController
|
||||
{
|
||||
private const string ViewPermission = "tenant:customer:analysis:view";
|
||||
|
||||
/// <summary>
|
||||
/// 获取客户分析总览。
|
||||
/// </summary>
|
||||
[HttpGet("overview")]
|
||||
[PermissionAuthorize(ViewPermission)]
|
||||
[ProducesResponseType(typeof(ApiResponse<CustomerAnalysisOverviewResponse>), StatusCodes.Status200OK)]
|
||||
public async Task<ApiResponse<CustomerAnalysisOverviewResponse>> Overview(
|
||||
[FromQuery] CustomerAnalysisOverviewRequest request,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var visibleStoreIds = await ResolveVisibleStoreIdsAsync(request.StoreId, cancellationToken);
|
||||
var (periodCode, periodDays) = ParsePeriod(request.Period);
|
||||
|
||||
var result = await mediator.Send(new GetCustomerAnalysisOverviewQuery
|
||||
{
|
||||
VisibleStoreIds = visibleStoreIds,
|
||||
PeriodCode = periodCode,
|
||||
PeriodDays = periodDays
|
||||
}, cancellationToken);
|
||||
|
||||
return ApiResponse<CustomerAnalysisOverviewResponse>.Ok(MapOverview(result));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 获取客群明细。
|
||||
/// </summary>
|
||||
[HttpGet("segment/list")]
|
||||
[PermissionAuthorize(ViewPermission)]
|
||||
[ProducesResponseType(typeof(ApiResponse<CustomerAnalysisSegmentListResultResponse>), StatusCodes.Status200OK)]
|
||||
public async Task<ApiResponse<CustomerAnalysisSegmentListResultResponse>> SegmentList(
|
||||
[FromQuery] CustomerAnalysisSegmentListRequest request,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var visibleStoreIds = await ResolveVisibleStoreIdsAsync(request.StoreId, cancellationToken);
|
||||
var (periodCode, periodDays) = ParsePeriod(request.Period);
|
||||
|
||||
var result = await mediator.Send(new GetCustomerAnalysisSegmentListQuery
|
||||
{
|
||||
VisibleStoreIds = visibleStoreIds,
|
||||
PeriodCode = periodCode,
|
||||
PeriodDays = periodDays,
|
||||
SegmentCode = request.SegmentCode,
|
||||
Keyword = request.Keyword,
|
||||
Page = Math.Max(1, request.Page),
|
||||
PageSize = Math.Clamp(request.PageSize, 1, 200)
|
||||
}, cancellationToken);
|
||||
|
||||
return ApiResponse<CustomerAnalysisSegmentListResultResponse>.Ok(MapSegmentList(result));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 获取客户详情(分析页二级抽屉)。
|
||||
/// </summary>
|
||||
[HttpGet("detail")]
|
||||
[PermissionAuthorize(ViewPermission)]
|
||||
[ProducesResponseType(typeof(ApiResponse<CustomerDetailResponse>), StatusCodes.Status200OK)]
|
||||
public async Task<ApiResponse<CustomerDetailResponse>> Detail(
|
||||
[FromQuery] CustomerDetailRequest request,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var customerKey = NormalizePhone(request.CustomerKey);
|
||||
if (string.IsNullOrWhiteSpace(customerKey))
|
||||
{
|
||||
return ApiResponse<CustomerDetailResponse>.Error(ErrorCodes.BadRequest, "customerKey 非法");
|
||||
}
|
||||
|
||||
var visibleStoreIds = await ResolveVisibleStoreIdsAsync(request.StoreId, cancellationToken);
|
||||
var result = await mediator.Send(new GetCustomerDetailQuery
|
||||
{
|
||||
VisibleStoreIds = visibleStoreIds,
|
||||
CustomerKey = customerKey
|
||||
}, cancellationToken);
|
||||
|
||||
if (result is null)
|
||||
{
|
||||
return ApiResponse<CustomerDetailResponse>.Error(ErrorCodes.NotFound, "客户不存在");
|
||||
}
|
||||
|
||||
return ApiResponse<CustomerDetailResponse>.Ok(MapDetail(result));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 获取客户完整画像(分析页二级抽屉)。
|
||||
/// </summary>
|
||||
[HttpGet("profile")]
|
||||
[PermissionAuthorize(ViewPermission)]
|
||||
[ProducesResponseType(typeof(ApiResponse<CustomerProfileResponse>), StatusCodes.Status200OK)]
|
||||
public async Task<ApiResponse<CustomerProfileResponse>> Profile(
|
||||
[FromQuery] CustomerProfileRequest request,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var customerKey = NormalizePhone(request.CustomerKey);
|
||||
if (string.IsNullOrWhiteSpace(customerKey))
|
||||
{
|
||||
return ApiResponse<CustomerProfileResponse>.Error(ErrorCodes.BadRequest, "customerKey 非法");
|
||||
}
|
||||
|
||||
var visibleStoreIds = await ResolveVisibleStoreIdsAsync(request.StoreId, cancellationToken);
|
||||
var result = await mediator.Send(new GetCustomerProfileQuery
|
||||
{
|
||||
VisibleStoreIds = visibleStoreIds,
|
||||
CustomerKey = customerKey
|
||||
}, cancellationToken);
|
||||
|
||||
if (result is null)
|
||||
{
|
||||
return ApiResponse<CustomerProfileResponse>.Error(ErrorCodes.NotFound, "客户不存在");
|
||||
}
|
||||
|
||||
return ApiResponse<CustomerProfileResponse>.Ok(MapProfile(result));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 获取会员详情。
|
||||
/// </summary>
|
||||
[HttpGet("member/detail")]
|
||||
[PermissionAuthorize(ViewPermission)]
|
||||
[ProducesResponseType(typeof(ApiResponse<CustomerMemberDetailResponse>), StatusCodes.Status200OK)]
|
||||
public async Task<ApiResponse<CustomerMemberDetailResponse>> MemberDetail(
|
||||
[FromQuery] CustomerMemberDetailRequest request,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var customerKey = NormalizePhone(request.CustomerKey);
|
||||
if (string.IsNullOrWhiteSpace(customerKey))
|
||||
{
|
||||
return ApiResponse<CustomerMemberDetailResponse>.Error(ErrorCodes.BadRequest, "customerKey 非法");
|
||||
}
|
||||
|
||||
var visibleStoreIds = await ResolveVisibleStoreIdsAsync(request.StoreId, cancellationToken);
|
||||
|
||||
var result = await mediator.Send(new GetCustomerMemberDetailQuery
|
||||
{
|
||||
VisibleStoreIds = visibleStoreIds,
|
||||
CustomerKey = customerKey
|
||||
}, cancellationToken);
|
||||
|
||||
if (result is null)
|
||||
{
|
||||
return ApiResponse<CustomerMemberDetailResponse>.Error(ErrorCodes.NotFound, "客户不存在");
|
||||
}
|
||||
|
||||
return ApiResponse<CustomerMemberDetailResponse>.Ok(MapMemberDetail(result));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 导出客户分析报表。
|
||||
/// </summary>
|
||||
[HttpGet("export")]
|
||||
[PermissionAuthorize(ViewPermission)]
|
||||
[ProducesResponseType(typeof(ApiResponse<CustomerExportResponse>), StatusCodes.Status200OK)]
|
||||
public async Task<ApiResponse<CustomerExportResponse>> Export(
|
||||
[FromQuery] CustomerAnalysisExportRequest request,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var visibleStoreIds = await ResolveVisibleStoreIdsAsync(request.StoreId, cancellationToken);
|
||||
var (periodCode, periodDays) = ParsePeriod(request.Period);
|
||||
|
||||
var result = await mediator.Send(new ExportCustomerAnalysisCsvQuery
|
||||
{
|
||||
VisibleStoreIds = visibleStoreIds,
|
||||
PeriodCode = periodCode,
|
||||
PeriodDays = periodDays
|
||||
}, cancellationToken);
|
||||
|
||||
return ApiResponse<CustomerExportResponse>.Ok(new CustomerExportResponse
|
||||
{
|
||||
FileName = result.FileName,
|
||||
FileContentBase64 = result.FileContentBase64,
|
||||
TotalCount = result.TotalCount
|
||||
});
|
||||
}
|
||||
|
||||
private async Task<IReadOnlyCollection<long>> ResolveVisibleStoreIdsAsync(
|
||||
string? storeId,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var (tenantId, merchantId) = StoreApiHelpers.GetTenantMerchantContext(storeContextService);
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(storeId))
|
||||
{
|
||||
var parsedStoreId = StoreApiHelpers.ParseRequiredSnowflake(storeId, nameof(storeId));
|
||||
await StoreApiHelpers.EnsureStoreAccessibleAsync(
|
||||
dbContext,
|
||||
tenantId,
|
||||
merchantId,
|
||||
parsedStoreId,
|
||||
cancellationToken);
|
||||
return [parsedStoreId];
|
||||
}
|
||||
|
||||
var allStoreIds = await dbContext.Stores
|
||||
.AsNoTracking()
|
||||
.Where(item => item.TenantId == tenantId && item.MerchantId == merchantId)
|
||||
.Select(item => item.Id)
|
||||
.OrderBy(item => item)
|
||||
.ToListAsync(cancellationToken);
|
||||
|
||||
if (allStoreIds.Count == 0)
|
||||
{
|
||||
throw new BusinessException(ErrorCodes.BadRequest, "当前商户下不存在可用门店");
|
||||
}
|
||||
|
||||
return allStoreIds;
|
||||
}
|
||||
|
||||
private static (string PeriodCode, int PeriodDays) ParsePeriod(string? period)
|
||||
{
|
||||
var normalized = (period ?? string.Empty).Trim().ToLowerInvariant();
|
||||
if (string.IsNullOrWhiteSpace(normalized))
|
||||
{
|
||||
return ("30d", 30);
|
||||
}
|
||||
|
||||
return normalized switch
|
||||
{
|
||||
"7" or "7d" => ("7d", 7),
|
||||
"30" or "30d" => ("30d", 30),
|
||||
"90" or "90d" => ("90d", 90),
|
||||
"365" or "365d" or "1y" or "1year" => ("365d", 365),
|
||||
_ => throw new BusinessException(ErrorCodes.BadRequest, "period 参数不合法")
|
||||
};
|
||||
}
|
||||
|
||||
private static string NormalizePhone(string? value)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(value))
|
||||
{
|
||||
return string.Empty;
|
||||
}
|
||||
|
||||
var chars = value.Where(char.IsDigit).ToArray();
|
||||
return chars.Length == 0 ? string.Empty : new string(chars);
|
||||
}
|
||||
|
||||
private static CustomerAnalysisOverviewResponse MapOverview(CustomerAnalysisOverviewDto source)
|
||||
{
|
||||
return new CustomerAnalysisOverviewResponse
|
||||
{
|
||||
PeriodCode = source.PeriodCode,
|
||||
PeriodDays = source.PeriodDays,
|
||||
TotalCustomers = source.TotalCustomers,
|
||||
NewCustomers = source.NewCustomers,
|
||||
GrowthRatePercent = source.GrowthRatePercent,
|
||||
NewCustomersDailyAverage = source.NewCustomersDailyAverage,
|
||||
ActiveCustomers = source.ActiveCustomers,
|
||||
ActiveRatePercent = source.ActiveRatePercent,
|
||||
AverageLifetimeValue = source.AverageLifetimeValue,
|
||||
GrowthTrend = source.GrowthTrend.Select(MapTrendPoint).ToList(),
|
||||
Composition = source.Composition.Select(MapCompositionItem).ToList(),
|
||||
AmountDistribution = source.AmountDistribution.Select(MapAmountDistributionItem).ToList(),
|
||||
RfmRows = source.RfmRows.Select(MapRfmRow).ToList(),
|
||||
TopCustomers = source.TopCustomers.Select(MapTopCustomer).ToList()
|
||||
};
|
||||
}
|
||||
|
||||
private static CustomerAnalysisTrendPointResponse MapTrendPoint(CustomerAnalysisTrendPointDto source)
|
||||
{
|
||||
return new CustomerAnalysisTrendPointResponse
|
||||
{
|
||||
Label = source.Label,
|
||||
Value = source.Value
|
||||
};
|
||||
}
|
||||
|
||||
private static CustomerAnalysisCompositionItemResponse MapCompositionItem(CustomerAnalysisCompositionItemDto source)
|
||||
{
|
||||
return new CustomerAnalysisCompositionItemResponse
|
||||
{
|
||||
SegmentCode = source.SegmentCode,
|
||||
Label = source.Label,
|
||||
Count = source.Count,
|
||||
Percent = source.Percent,
|
||||
Tone = source.Tone
|
||||
};
|
||||
}
|
||||
|
||||
private static CustomerAnalysisAmountDistributionItemResponse MapAmountDistributionItem(
|
||||
CustomerAnalysisAmountDistributionItemDto source)
|
||||
{
|
||||
return new CustomerAnalysisAmountDistributionItemResponse
|
||||
{
|
||||
SegmentCode = source.SegmentCode,
|
||||
Label = source.Label,
|
||||
Count = source.Count,
|
||||
Percent = source.Percent
|
||||
};
|
||||
}
|
||||
|
||||
private static CustomerAnalysisRfmRowResponse MapRfmRow(CustomerAnalysisRfmRowDto source)
|
||||
{
|
||||
return new CustomerAnalysisRfmRowResponse
|
||||
{
|
||||
Label = source.Label,
|
||||
Cells = source.Cells.Select(MapRfmCell).ToList()
|
||||
};
|
||||
}
|
||||
|
||||
private static CustomerAnalysisRfmCellResponse MapRfmCell(CustomerAnalysisRfmCellDto source)
|
||||
{
|
||||
return new CustomerAnalysisRfmCellResponse
|
||||
{
|
||||
SegmentCode = source.SegmentCode,
|
||||
Label = source.Label,
|
||||
Count = source.Count,
|
||||
Tone = source.Tone
|
||||
};
|
||||
}
|
||||
|
||||
private static CustomerAnalysisTopCustomerResponse MapTopCustomer(CustomerAnalysisTopCustomerDto source)
|
||||
{
|
||||
return new CustomerAnalysisTopCustomerResponse
|
||||
{
|
||||
Rank = source.Rank,
|
||||
CustomerKey = source.CustomerKey,
|
||||
Name = source.Name,
|
||||
PhoneMasked = source.PhoneMasked,
|
||||
TotalAmount = source.TotalAmount,
|
||||
OrderCount = source.OrderCount,
|
||||
AverageAmount = source.AverageAmount,
|
||||
LastOrderAt = ToDateOnly(source.LastOrderAt),
|
||||
Tags = source.Tags.Select(MapTag).ToList()
|
||||
};
|
||||
}
|
||||
|
||||
private static CustomerDetailResponse MapDetail(CustomerDetailDto source)
|
||||
{
|
||||
return new CustomerDetailResponse
|
||||
{
|
||||
CustomerKey = source.CustomerKey,
|
||||
Name = source.Name,
|
||||
PhoneMasked = source.PhoneMasked,
|
||||
RegisteredAt = ToDateOnly(source.RegisteredAt),
|
||||
FirstOrderAt = ToDateOnly(source.FirstOrderAt),
|
||||
Source = source.Source,
|
||||
Tags = source.Tags.Select(MapTag).ToList(),
|
||||
Member = MapMember(source.Member),
|
||||
TotalOrders = source.TotalOrders,
|
||||
TotalAmount = source.TotalAmount,
|
||||
AverageAmount = source.AverageAmount,
|
||||
RepurchaseRatePercent = source.RepurchaseRatePercent,
|
||||
Preference = MapPreference(source.Preference),
|
||||
TopProducts = source.TopProducts.Select(MapTopProduct).ToList(),
|
||||
Trend = source.Trend.Select(MapTrend).ToList(),
|
||||
RecentOrders = source.RecentOrders.Select(MapRecentOrder).ToList()
|
||||
};
|
||||
}
|
||||
|
||||
private static CustomerProfileResponse MapProfile(CustomerProfileDto source)
|
||||
{
|
||||
return new CustomerProfileResponse
|
||||
{
|
||||
CustomerKey = source.CustomerKey,
|
||||
Name = source.Name,
|
||||
PhoneMasked = source.PhoneMasked,
|
||||
RegisteredAt = ToDateOnly(source.RegisteredAt),
|
||||
FirstOrderAt = ToDateOnly(source.FirstOrderAt),
|
||||
Source = source.Source,
|
||||
Tags = source.Tags.Select(MapTag).ToList(),
|
||||
Member = MapMember(source.Member),
|
||||
TotalOrders = source.TotalOrders,
|
||||
TotalAmount = source.TotalAmount,
|
||||
AverageAmount = source.AverageAmount,
|
||||
RepurchaseRatePercent = source.RepurchaseRatePercent,
|
||||
AverageOrderIntervalDays = source.AverageOrderIntervalDays,
|
||||
Preference = MapPreference(source.Preference),
|
||||
TopProducts = source.TopProducts.Select(MapTopProduct).ToList(),
|
||||
Trend = source.Trend.Select(MapTrend).ToList(),
|
||||
RecentOrders = source.RecentOrders.Select(MapRecentOrder).ToList()
|
||||
};
|
||||
}
|
||||
|
||||
private static CustomerAnalysisSegmentListResultResponse MapSegmentList(CustomerAnalysisSegmentListResultDto source)
|
||||
{
|
||||
return new CustomerAnalysisSegmentListResultResponse
|
||||
{
|
||||
SegmentCode = source.SegmentCode,
|
||||
SegmentTitle = source.SegmentTitle,
|
||||
SegmentDescription = source.SegmentDescription,
|
||||
Items = source.Items.Select(MapSegmentListItem).ToList(),
|
||||
Page = source.Page,
|
||||
PageSize = source.PageSize,
|
||||
TotalCount = source.TotalCount
|
||||
};
|
||||
}
|
||||
|
||||
private static CustomerAnalysisSegmentListItemResponse MapSegmentListItem(CustomerAnalysisSegmentListItemDto source)
|
||||
{
|
||||
return new CustomerAnalysisSegmentListItemResponse
|
||||
{
|
||||
CustomerKey = source.CustomerKey,
|
||||
Name = source.Name,
|
||||
PhoneMasked = source.PhoneMasked,
|
||||
AvatarText = source.AvatarText,
|
||||
AvatarColor = source.AvatarColor,
|
||||
Tags = source.Tags.Select(MapTag).ToList(),
|
||||
IsMember = source.IsMember,
|
||||
MemberTierName = source.MemberTierName,
|
||||
TotalAmount = source.TotalAmount,
|
||||
OrderCount = source.OrderCount,
|
||||
AverageAmount = source.AverageAmount,
|
||||
RegisteredAt = ToDateOnly(source.RegisteredAt),
|
||||
LastOrderAt = ToDateOnly(source.LastOrderAt),
|
||||
IsDimmed = source.IsDimmed
|
||||
};
|
||||
}
|
||||
|
||||
private static CustomerMemberDetailResponse MapMemberDetail(CustomerMemberDetailDto source)
|
||||
{
|
||||
return new CustomerMemberDetailResponse
|
||||
{
|
||||
CustomerKey = source.CustomerKey,
|
||||
Name = source.Name,
|
||||
PhoneMasked = source.PhoneMasked,
|
||||
Source = source.Source,
|
||||
RegisteredAt = ToDateOnly(source.RegisteredAt),
|
||||
LastOrderAt = ToDateOnly(source.LastOrderAt),
|
||||
Member = MapMember(source.Member),
|
||||
Tags = source.Tags.Select(MapTag).ToList(),
|
||||
TotalOrders = source.TotalOrders,
|
||||
TotalAmount = source.TotalAmount,
|
||||
AverageAmount = source.AverageAmount,
|
||||
RepurchaseRatePercent = source.RepurchaseRatePercent,
|
||||
RecentOrders = source.RecentOrders.Select(MapRecentOrder).ToList()
|
||||
};
|
||||
}
|
||||
|
||||
private static CustomerTagResponse MapTag(CustomerTagDto source)
|
||||
{
|
||||
return new CustomerTagResponse
|
||||
{
|
||||
Code = source.Code,
|
||||
Label = source.Label,
|
||||
Tone = source.Tone
|
||||
};
|
||||
}
|
||||
|
||||
private static CustomerMemberSummaryResponse MapMember(CustomerMemberSummaryDto source)
|
||||
{
|
||||
return new CustomerMemberSummaryResponse
|
||||
{
|
||||
IsMember = source.IsMember,
|
||||
TierName = source.TierName,
|
||||
PointsBalance = source.PointsBalance,
|
||||
GrowthValue = source.GrowthValue,
|
||||
JoinedAt = source.JoinedAt.HasValue ? ToDateOnly(source.JoinedAt.Value) : string.Empty
|
||||
};
|
||||
}
|
||||
|
||||
private static CustomerPreferenceResponse MapPreference(CustomerPreferenceDto source)
|
||||
{
|
||||
return new CustomerPreferenceResponse
|
||||
{
|
||||
PreferredCategories = source.PreferredCategories.ToList(),
|
||||
PreferredOrderPeaks = source.PreferredOrderPeaks,
|
||||
PreferredDelivery = source.PreferredDelivery,
|
||||
PreferredPaymentMethod = source.PreferredPaymentMethod,
|
||||
AverageDeliveryDistance = source.AverageDeliveryDistance
|
||||
};
|
||||
}
|
||||
|
||||
private static CustomerTopProductResponse MapTopProduct(CustomerTopProductDto source)
|
||||
{
|
||||
return new CustomerTopProductResponse
|
||||
{
|
||||
Rank = source.Rank,
|
||||
ProductName = source.ProductName,
|
||||
Count = source.Count,
|
||||
ProportionPercent = source.ProportionPercent
|
||||
};
|
||||
}
|
||||
|
||||
private static CustomerTrendPointResponse MapTrend(CustomerTrendPointDto source)
|
||||
{
|
||||
return new CustomerTrendPointResponse
|
||||
{
|
||||
Label = source.Label,
|
||||
Amount = source.Amount
|
||||
};
|
||||
}
|
||||
|
||||
private static CustomerRecentOrderResponse MapRecentOrder(CustomerRecentOrderDto source)
|
||||
{
|
||||
return new CustomerRecentOrderResponse
|
||||
{
|
||||
OrderNo = source.OrderNo,
|
||||
Amount = source.Amount,
|
||||
ItemsSummary = source.ItemsSummary,
|
||||
DeliveryType = source.DeliveryType,
|
||||
Status = source.Status,
|
||||
OrderedAt = ToDateTime(source.OrderedAt)
|
||||
};
|
||||
}
|
||||
|
||||
private static string ToDateOnly(DateTime value)
|
||||
{
|
||||
return value.ToString("yyyy-MM-dd", CultureInfo.InvariantCulture);
|
||||
}
|
||||
|
||||
private static string ToDateTime(DateTime value)
|
||||
{
|
||||
return value.ToString("yyyy-MM-dd HH:mm:ss", CultureInfo.InvariantCulture);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,431 @@
|
||||
namespace TakeoutSaaS.Application.App.Customers.Dto;
|
||||
|
||||
/// <summary>
|
||||
/// 客户分析增长趋势点 DTO。
|
||||
/// </summary>
|
||||
public sealed class CustomerAnalysisTrendPointDto
|
||||
{
|
||||
/// <summary>
|
||||
/// 维度标签。
|
||||
/// </summary>
|
||||
public string Label { get; init; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// 数量值。
|
||||
/// </summary>
|
||||
public int Value { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 客户分析新老客构成项 DTO。
|
||||
/// </summary>
|
||||
public sealed class CustomerAnalysisCompositionItemDto
|
||||
{
|
||||
/// <summary>
|
||||
/// 分群编码。
|
||||
/// </summary>
|
||||
public string SegmentCode { get; init; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// 分群名称。
|
||||
/// </summary>
|
||||
public string Label { get; init; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// 分群人数。
|
||||
/// </summary>
|
||||
public int Count { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// 分群占比(百分比)。
|
||||
/// </summary>
|
||||
public decimal Percent { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// 色调(blue/green/orange/gray)。
|
||||
/// </summary>
|
||||
public string Tone { get; init; } = "blue";
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 客单价分布项 DTO。
|
||||
/// </summary>
|
||||
public sealed class CustomerAnalysisAmountDistributionItemDto
|
||||
{
|
||||
/// <summary>
|
||||
/// 分群编码。
|
||||
/// </summary>
|
||||
public string SegmentCode { get; init; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// 区间标签。
|
||||
/// </summary>
|
||||
public string Label { get; init; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// 人数。
|
||||
/// </summary>
|
||||
public int Count { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// 占比(百分比)。
|
||||
/// </summary>
|
||||
public decimal Percent { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// RFM 分层单元 DTO。
|
||||
/// </summary>
|
||||
public sealed class CustomerAnalysisRfmCellDto
|
||||
{
|
||||
/// <summary>
|
||||
/// 分群编码。
|
||||
/// </summary>
|
||||
public string SegmentCode { get; init; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// 分层标签。
|
||||
/// </summary>
|
||||
public string Label { get; init; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// 人数。
|
||||
/// </summary>
|
||||
public int Count { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// 温度(hot/warm/cool/cold)。
|
||||
/// </summary>
|
||||
public string Tone { get; init; } = "cold";
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// RFM 分层行 DTO。
|
||||
/// </summary>
|
||||
public sealed class CustomerAnalysisRfmRowDto
|
||||
{
|
||||
/// <summary>
|
||||
/// 行标签。
|
||||
/// </summary>
|
||||
public string Label { get; init; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// 单元格集合。
|
||||
/// </summary>
|
||||
public IReadOnlyList<CustomerAnalysisRfmCellDto> Cells { get; init; } = [];
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 高价值客户 DTO。
|
||||
/// </summary>
|
||||
public sealed class CustomerAnalysisTopCustomerDto
|
||||
{
|
||||
/// <summary>
|
||||
/// 排名。
|
||||
/// </summary>
|
||||
public int Rank { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// 客户标识。
|
||||
/// </summary>
|
||||
public string CustomerKey { get; init; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// 客户名称。
|
||||
/// </summary>
|
||||
public string Name { get; init; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// 手机号(脱敏)。
|
||||
/// </summary>
|
||||
public string PhoneMasked { get; init; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// 累计消费。
|
||||
/// </summary>
|
||||
public decimal TotalAmount { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// 下单次数。
|
||||
/// </summary>
|
||||
public int OrderCount { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// 客单价。
|
||||
/// </summary>
|
||||
public decimal AverageAmount { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// 最近下单时间。
|
||||
/// </summary>
|
||||
public DateTime LastOrderAt { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// 客户标签。
|
||||
/// </summary>
|
||||
public IReadOnlyList<CustomerTagDto> Tags { get; init; } = [];
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 客户分析总览 DTO。
|
||||
/// </summary>
|
||||
public sealed class CustomerAnalysisOverviewDto
|
||||
{
|
||||
/// <summary>
|
||||
/// 统计周期编码。
|
||||
/// </summary>
|
||||
public string PeriodCode { get; init; } = "30d";
|
||||
|
||||
/// <summary>
|
||||
/// 统计周期天数。
|
||||
/// </summary>
|
||||
public int PeriodDays { get; init; } = 30;
|
||||
|
||||
/// <summary>
|
||||
/// 客户总数。
|
||||
/// </summary>
|
||||
public int TotalCustomers { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// 周期新增客户数。
|
||||
/// </summary>
|
||||
public int NewCustomers { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// 新增较上一周期增长百分比。
|
||||
/// </summary>
|
||||
public decimal GrowthRatePercent { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// 周期内日均新增客户。
|
||||
/// </summary>
|
||||
public decimal NewCustomersDailyAverage { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// 活跃客户数。
|
||||
/// </summary>
|
||||
public int ActiveCustomers { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// 活跃率(百分比)。
|
||||
/// </summary>
|
||||
public decimal ActiveRatePercent { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// 平均客户价值(累计消费均值)。
|
||||
/// </summary>
|
||||
public decimal AverageLifetimeValue { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// 客户增长趋势。
|
||||
/// </summary>
|
||||
public IReadOnlyList<CustomerAnalysisTrendPointDto> GrowthTrend { get; init; } = [];
|
||||
|
||||
/// <summary>
|
||||
/// 新老客占比。
|
||||
/// </summary>
|
||||
public IReadOnlyList<CustomerAnalysisCompositionItemDto> Composition { get; init; } = [];
|
||||
|
||||
/// <summary>
|
||||
/// 客单价分布。
|
||||
/// </summary>
|
||||
public IReadOnlyList<CustomerAnalysisAmountDistributionItemDto> AmountDistribution { get; init; } = [];
|
||||
|
||||
/// <summary>
|
||||
/// RFM 分层。
|
||||
/// </summary>
|
||||
public IReadOnlyList<CustomerAnalysisRfmRowDto> RfmRows { get; init; } = [];
|
||||
|
||||
/// <summary>
|
||||
/// 高价值客户 Top10。
|
||||
/// </summary>
|
||||
public IReadOnlyList<CustomerAnalysisTopCustomerDto> TopCustomers { get; init; } = [];
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 客群明细行 DTO。
|
||||
/// </summary>
|
||||
public sealed class CustomerAnalysisSegmentListItemDto
|
||||
{
|
||||
/// <summary>
|
||||
/// 客户标识。
|
||||
/// </summary>
|
||||
public string CustomerKey { get; init; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// 客户名称。
|
||||
/// </summary>
|
||||
public string Name { get; init; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// 手机号(脱敏)。
|
||||
/// </summary>
|
||||
public string PhoneMasked { get; init; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// 头像文案。
|
||||
/// </summary>
|
||||
public string AvatarText { get; init; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// 头像颜色。
|
||||
/// </summary>
|
||||
public string AvatarColor { get; init; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// 客户标签。
|
||||
/// </summary>
|
||||
public IReadOnlyList<CustomerTagDto> Tags { get; init; } = [];
|
||||
|
||||
/// <summary>
|
||||
/// 是否会员。
|
||||
/// </summary>
|
||||
public bool IsMember { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// 会员等级。
|
||||
/// </summary>
|
||||
public string MemberTierName { get; init; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// 累计消费。
|
||||
/// </summary>
|
||||
public decimal TotalAmount { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// 下单次数。
|
||||
/// </summary>
|
||||
public int OrderCount { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// 客单价。
|
||||
/// </summary>
|
||||
public decimal AverageAmount { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// 注册时间。
|
||||
/// </summary>
|
||||
public DateTime RegisteredAt { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// 最近下单时间。
|
||||
/// </summary>
|
||||
public DateTime LastOrderAt { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// 是否弱化显示。
|
||||
/// </summary>
|
||||
public bool IsDimmed { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 客群明细结果 DTO。
|
||||
/// </summary>
|
||||
public sealed class CustomerAnalysisSegmentListResultDto
|
||||
{
|
||||
/// <summary>
|
||||
/// 分群编码。
|
||||
/// </summary>
|
||||
public string SegmentCode { get; init; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// 分群标题。
|
||||
/// </summary>
|
||||
public string SegmentTitle { get; init; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// 分群说明。
|
||||
/// </summary>
|
||||
public string SegmentDescription { get; init; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// 列表项。
|
||||
/// </summary>
|
||||
public IReadOnlyList<CustomerAnalysisSegmentListItemDto> Items { get; init; } = [];
|
||||
|
||||
/// <summary>
|
||||
/// 当前页。
|
||||
/// </summary>
|
||||
public int Page { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// 每页条数。
|
||||
/// </summary>
|
||||
public int PageSize { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// 总记录数。
|
||||
/// </summary>
|
||||
public int TotalCount { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 会员详情 DTO。
|
||||
/// </summary>
|
||||
public sealed class CustomerMemberDetailDto
|
||||
{
|
||||
/// <summary>
|
||||
/// 客户标识。
|
||||
/// </summary>
|
||||
public string CustomerKey { get; init; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// 客户名称。
|
||||
/// </summary>
|
||||
public string Name { get; init; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// 手机号(脱敏)。
|
||||
/// </summary>
|
||||
public string PhoneMasked { get; init; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// 来源。
|
||||
/// </summary>
|
||||
public string Source { get; init; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// 注册时间。
|
||||
/// </summary>
|
||||
public DateTime RegisteredAt { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// 最近下单时间。
|
||||
/// </summary>
|
||||
public DateTime LastOrderAt { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// 会员摘要。
|
||||
/// </summary>
|
||||
public CustomerMemberSummaryDto Member { get; init; } = new();
|
||||
|
||||
/// <summary>
|
||||
/// 客户标签。
|
||||
/// </summary>
|
||||
public IReadOnlyList<CustomerTagDto> Tags { get; init; } = [];
|
||||
|
||||
/// <summary>
|
||||
/// 累计下单次数。
|
||||
/// </summary>
|
||||
public int TotalOrders { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// 累计消费。
|
||||
/// </summary>
|
||||
public decimal TotalAmount { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// 客单价。
|
||||
/// </summary>
|
||||
public decimal AverageAmount { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// 复购率。
|
||||
/// </summary>
|
||||
public decimal RepurchaseRatePercent { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// 最近订单。
|
||||
/// </summary>
|
||||
public IReadOnlyList<CustomerRecentOrderDto> RecentOrders { get; init; } = [];
|
||||
}
|
||||
@@ -0,0 +1,478 @@
|
||||
using TakeoutSaaS.Application.App.Customers.Dto;
|
||||
using TakeoutSaaS.Shared.Abstractions.Constants;
|
||||
using TakeoutSaaS.Shared.Abstractions.Exceptions;
|
||||
|
||||
namespace TakeoutSaaS.Application.App.Customers.Handlers;
|
||||
|
||||
internal static class CustomerAnalysisSegmentSupport
|
||||
{
|
||||
internal const string SegmentAll = "all";
|
||||
internal const string SegmentRepeatLoyal = "repeat_loyal";
|
||||
internal const string SegmentActiveNew = "active_new";
|
||||
internal const string SegmentActiveRecent = "active_recent";
|
||||
internal const string SegmentDormant = "dormant";
|
||||
internal const string SegmentChurn = "churn";
|
||||
internal const string SegmentHighValueTop = "high_value_top";
|
||||
|
||||
private static readonly string[] CompositionSegmentOrder =
|
||||
[
|
||||
SegmentRepeatLoyal,
|
||||
SegmentActiveNew,
|
||||
SegmentDormant,
|
||||
SegmentChurn
|
||||
];
|
||||
|
||||
private static readonly (string SegmentCode, string Label, string Tone)[] CompositionDefinitions =
|
||||
[
|
||||
(SegmentRepeatLoyal, "老客户(复购2次+)", "blue"),
|
||||
(SegmentActiveNew, "活跃新客", "green"),
|
||||
(SegmentDormant, "沉睡客户", "orange"),
|
||||
(SegmentChurn, "流失客户", "gray")
|
||||
];
|
||||
|
||||
private static readonly AmountDistributionDefinition[] AmountDistributionDefinitions =
|
||||
[
|
||||
new("amount_0_30", "0 - 30元", 0m, 30m),
|
||||
new("amount_30_60", "30 - 60元", 30m, 60m),
|
||||
new("amount_60_100", "60 - 100元", 60m, 100m),
|
||||
new("amount_100_150", "100 - 150元", 100m, 150m),
|
||||
new("amount_150_plus", "150元以上", 150m, null)
|
||||
];
|
||||
|
||||
private static readonly string[] RfmRowLabels =
|
||||
[
|
||||
"近期活跃",
|
||||
"中期沉默",
|
||||
"长期流失"
|
||||
];
|
||||
|
||||
private static readonly string[] RfmColumnLabels =
|
||||
[
|
||||
"高频高额",
|
||||
"高频低额",
|
||||
"低频高额",
|
||||
"低频低额"
|
||||
];
|
||||
|
||||
private static readonly string[,] RfmCellLabels =
|
||||
{
|
||||
{ "重要价值", "潜力客户", "新客培育", "一般维护" },
|
||||
{ "重要挽留", "一般发展", "一般保持", "低优先级" },
|
||||
{ "重要召回", "即将流失", "基本流失", "已流失" }
|
||||
};
|
||||
|
||||
private static readonly string[,] RfmCellTones =
|
||||
{
|
||||
{ "hot", "warm", "warm", "cool" },
|
||||
{ "warm", "cool", "cool", "cold" },
|
||||
{ "cool", "cold", "cold", "cold" }
|
||||
};
|
||||
|
||||
internal static string NormalizeSegmentCode(string? segmentCode)
|
||||
{
|
||||
var normalized = (segmentCode ?? string.Empty).Trim().ToLowerInvariant();
|
||||
return string.IsNullOrWhiteSpace(normalized) ? SegmentAll : normalized;
|
||||
}
|
||||
|
||||
internal static SegmentMeta ResolveSegmentMeta(string normalizedSegmentCode)
|
||||
{
|
||||
var amountDefinition = ResolveAmountDistributionDefinition(normalizedSegmentCode);
|
||||
if (amountDefinition is not null)
|
||||
{
|
||||
return new SegmentMeta(
|
||||
normalizedSegmentCode,
|
||||
$"客单价分布 · {amountDefinition.Label}",
|
||||
$"筛选客单价位于 {amountDefinition.Label} 区间的客户");
|
||||
}
|
||||
|
||||
if (TryParseRfmSegmentCode(normalizedSegmentCode, out var rowIndex, out var columnIndex))
|
||||
{
|
||||
return new SegmentMeta(
|
||||
normalizedSegmentCode,
|
||||
$"RFM分层 · {RfmRowLabels[rowIndex]} / {RfmColumnLabels[columnIndex]}",
|
||||
$"当前客群标签:{RfmCellLabels[rowIndex, columnIndex]}");
|
||||
}
|
||||
|
||||
return normalizedSegmentCode switch
|
||||
{
|
||||
SegmentAll => new SegmentMeta(SegmentAll, "全部客户", "当前门店下全部客户明细"),
|
||||
SegmentRepeatLoyal => new SegmentMeta(SegmentRepeatLoyal, "老客户(复购2次+)", "非流失/沉睡且非新客的稳定复购客户"),
|
||||
SegmentActiveNew => new SegmentMeta(SegmentActiveNew, "活跃新客", "统计周期内注册且有消费的客户"),
|
||||
SegmentActiveRecent => new SegmentMeta(SegmentActiveRecent, "周期活跃客户", "统计周期内发生过消费行为的客户"),
|
||||
SegmentDormant => new SegmentMeta(SegmentDormant, "沉睡客户", "31-60天未消费的客户"),
|
||||
SegmentChurn => new SegmentMeta(SegmentChurn, "流失客户", "超过60天未消费的客户"),
|
||||
SegmentHighValueTop => new SegmentMeta(SegmentHighValueTop, "高价值客户", "累计消费或消费能力达到高价值阈值的客户"),
|
||||
_ => throw new BusinessException(ErrorCodes.BadRequest, "segmentCode 参数不合法")
|
||||
};
|
||||
}
|
||||
|
||||
internal static IReadOnlyList<CustomerAnalysisCompositionItemDto> BuildComposition(
|
||||
IReadOnlyList<CustomerAggregate> customers,
|
||||
DateTime nowUtc,
|
||||
int periodDays)
|
||||
{
|
||||
if (customers.Count == 0)
|
||||
{
|
||||
return CompositionDefinitions
|
||||
.Select(item => new CustomerAnalysisCompositionItemDto
|
||||
{
|
||||
SegmentCode = item.SegmentCode,
|
||||
Label = item.Label,
|
||||
Count = 0,
|
||||
Percent = 0,
|
||||
Tone = item.Tone
|
||||
})
|
||||
.ToList();
|
||||
}
|
||||
|
||||
var counter = CompositionSegmentOrder.ToDictionary(item => item, _ => 0, StringComparer.Ordinal);
|
||||
foreach (var customer in customers)
|
||||
{
|
||||
var segmentCode = ResolveCompositionSegmentCode(customer, nowUtc, periodDays);
|
||||
counter[segmentCode] += 1;
|
||||
}
|
||||
|
||||
return CompositionDefinitions
|
||||
.Select(item => new CustomerAnalysisCompositionItemDto
|
||||
{
|
||||
SegmentCode = item.SegmentCode,
|
||||
Label = item.Label,
|
||||
Count = counter[item.SegmentCode],
|
||||
Percent = CustomerAnalyticsSupport.ToRatePercent(counter[item.SegmentCode], customers.Count),
|
||||
Tone = item.Tone
|
||||
})
|
||||
.ToList();
|
||||
}
|
||||
|
||||
internal static IReadOnlyList<CustomerAnalysisAmountDistributionItemDto> BuildAmountDistribution(
|
||||
IReadOnlyList<CustomerAggregate> customers)
|
||||
{
|
||||
if (customers.Count == 0)
|
||||
{
|
||||
return AmountDistributionDefinitions
|
||||
.Select(item => new CustomerAnalysisAmountDistributionItemDto
|
||||
{
|
||||
SegmentCode = item.SegmentCode,
|
||||
Label = item.Label,
|
||||
Count = 0,
|
||||
Percent = 0
|
||||
})
|
||||
.ToList();
|
||||
}
|
||||
|
||||
return AmountDistributionDefinitions
|
||||
.Select(item =>
|
||||
{
|
||||
var count = customers.Count(customer => MatchAmountDistribution(customer.AverageAmount, item));
|
||||
return new CustomerAnalysisAmountDistributionItemDto
|
||||
{
|
||||
SegmentCode = item.SegmentCode,
|
||||
Label = item.Label,
|
||||
Count = count,
|
||||
Percent = CustomerAnalyticsSupport.ToRatePercent(count, customers.Count)
|
||||
};
|
||||
})
|
||||
.ToList();
|
||||
}
|
||||
|
||||
internal static IReadOnlyList<CustomerAnalysisRfmRowDto> BuildRfmRows(
|
||||
IReadOnlyList<CustomerAggregate> customers,
|
||||
DateTime nowUtc)
|
||||
{
|
||||
var counters = new int[3, 4];
|
||||
foreach (var customer in customers)
|
||||
{
|
||||
var rowIndex = ResolveRfmRecencyRow(customer, nowUtc);
|
||||
var columnIndex = ResolveRfmFrequencyAmountColumn(customer);
|
||||
counters[rowIndex, columnIndex] += 1;
|
||||
}
|
||||
|
||||
var rows = new List<CustomerAnalysisRfmRowDto>(3);
|
||||
for (var rowIndex = 0; rowIndex < 3; rowIndex += 1)
|
||||
{
|
||||
var cells = new List<CustomerAnalysisRfmCellDto>(4);
|
||||
for (var columnIndex = 0; columnIndex < 4; columnIndex += 1)
|
||||
{
|
||||
cells.Add(new CustomerAnalysisRfmCellDto
|
||||
{
|
||||
SegmentCode = BuildRfmSegmentCode(rowIndex, columnIndex),
|
||||
Label = RfmCellLabels[rowIndex, columnIndex],
|
||||
Count = counters[rowIndex, columnIndex],
|
||||
Tone = RfmCellTones[rowIndex, columnIndex]
|
||||
});
|
||||
}
|
||||
|
||||
rows.Add(new CustomerAnalysisRfmRowDto
|
||||
{
|
||||
Label = RfmRowLabels[rowIndex],
|
||||
Cells = cells
|
||||
});
|
||||
}
|
||||
|
||||
return rows;
|
||||
}
|
||||
|
||||
internal static IReadOnlyList<CustomerAnalysisTrendPointDto> BuildGrowthTrend(
|
||||
IReadOnlyList<CustomerAggregate> customers,
|
||||
DateTime nowUtc,
|
||||
int monthCount)
|
||||
{
|
||||
var normalizedMonthCount = Math.Clamp(monthCount, 1, 24);
|
||||
var monthStart = new DateTime(nowUtc.Year, nowUtc.Month, 1, 0, 0, 0, DateTimeKind.Utc);
|
||||
var windowStart = monthStart.AddMonths(-normalizedMonthCount + 1);
|
||||
|
||||
var countLookup = customers
|
||||
.Where(item => item.RegisteredAt >= windowStart && item.RegisteredAt < monthStart.AddMonths(1))
|
||||
.GroupBy(item => new DateTime(item.RegisteredAt.Year, item.RegisteredAt.Month, 1, 0, 0, 0, DateTimeKind.Utc))
|
||||
.ToDictionary(group => group.Key, group => group.Count());
|
||||
|
||||
var trend = new List<CustomerAnalysisTrendPointDto>(normalizedMonthCount);
|
||||
for (var index = 0; index < normalizedMonthCount; index += 1)
|
||||
{
|
||||
var currentMonth = windowStart.AddMonths(index);
|
||||
countLookup.TryGetValue(currentMonth, out var value);
|
||||
|
||||
trend.Add(new CustomerAnalysisTrendPointDto
|
||||
{
|
||||
Label = $"{currentMonth.Month}月",
|
||||
Value = value
|
||||
});
|
||||
}
|
||||
|
||||
return trend;
|
||||
}
|
||||
|
||||
internal static IReadOnlyList<CustomerAnalysisTopCustomerDto> BuildTopCustomers(
|
||||
IReadOnlyList<CustomerAggregate> customers,
|
||||
int takeCount)
|
||||
{
|
||||
var normalizedTakeCount = Math.Clamp(takeCount, 1, 200);
|
||||
|
||||
return customers
|
||||
.OrderByDescending(item => item.TotalAmount)
|
||||
.ThenByDescending(item => item.OrderCount)
|
||||
.ThenByDescending(item => item.LastOrderAt)
|
||||
.ThenBy(item => item.CustomerKey, StringComparer.Ordinal)
|
||||
.Take(normalizedTakeCount)
|
||||
.Select((item, index) => new CustomerAnalysisTopCustomerDto
|
||||
{
|
||||
Rank = index + 1,
|
||||
CustomerKey = item.CustomerKey,
|
||||
Name = item.Name,
|
||||
PhoneMasked = item.PhoneMasked,
|
||||
TotalAmount = item.TotalAmount,
|
||||
OrderCount = item.OrderCount,
|
||||
AverageAmount = item.AverageAmount,
|
||||
LastOrderAt = item.LastOrderAt,
|
||||
Tags = item.Tags
|
||||
})
|
||||
.ToList();
|
||||
}
|
||||
|
||||
internal static IReadOnlyList<CustomerAggregate> FilterBySegment(
|
||||
IReadOnlyList<CustomerAggregate> customers,
|
||||
string normalizedSegmentCode,
|
||||
DateTime nowUtc,
|
||||
int periodDays)
|
||||
{
|
||||
if (normalizedSegmentCode == SegmentAll)
|
||||
{
|
||||
return customers;
|
||||
}
|
||||
|
||||
if (TryParseRfmSegmentCode(normalizedSegmentCode, out var rowIndex, out var columnIndex))
|
||||
{
|
||||
return customers
|
||||
.Where(customer =>
|
||||
ResolveRfmRecencyRow(customer, nowUtc) == rowIndex &&
|
||||
ResolveRfmFrequencyAmountColumn(customer) == columnIndex)
|
||||
.ToList();
|
||||
}
|
||||
|
||||
var amountDefinition = ResolveAmountDistributionDefinition(normalizedSegmentCode);
|
||||
if (amountDefinition is not null)
|
||||
{
|
||||
return customers
|
||||
.Where(customer => MatchAmountDistribution(customer.AverageAmount, amountDefinition))
|
||||
.ToList();
|
||||
}
|
||||
|
||||
return normalizedSegmentCode switch
|
||||
{
|
||||
SegmentRepeatLoyal => customers
|
||||
.Where(customer => string.Equals(
|
||||
ResolveCompositionSegmentCode(customer, nowUtc, periodDays),
|
||||
SegmentRepeatLoyal,
|
||||
StringComparison.Ordinal))
|
||||
.ToList(),
|
||||
SegmentActiveNew => customers
|
||||
.Where(customer => string.Equals(
|
||||
ResolveCompositionSegmentCode(customer, nowUtc, periodDays),
|
||||
SegmentActiveNew,
|
||||
StringComparison.Ordinal))
|
||||
.ToList(),
|
||||
SegmentActiveRecent => customers
|
||||
.Where(customer => customer.LastOrderAt >= nowUtc.AddDays(-Math.Clamp(periodDays, 7, 365)))
|
||||
.ToList(),
|
||||
SegmentDormant => customers
|
||||
.Where(customer => string.Equals(
|
||||
ResolveCompositionSegmentCode(customer, nowUtc, periodDays),
|
||||
SegmentDormant,
|
||||
StringComparison.Ordinal))
|
||||
.ToList(),
|
||||
SegmentChurn => customers
|
||||
.Where(customer => string.Equals(
|
||||
ResolveCompositionSegmentCode(customer, nowUtc, periodDays),
|
||||
SegmentChurn,
|
||||
StringComparison.Ordinal))
|
||||
.ToList(),
|
||||
SegmentHighValueTop => customers
|
||||
.Where(customer =>
|
||||
customer.Tags.Any(tag => string.Equals(tag.Code, CustomerAnalyticsSupport.TagHighValue, StringComparison.Ordinal)))
|
||||
.ToList(),
|
||||
_ => throw new BusinessException(ErrorCodes.BadRequest, "segmentCode 参数不合法")
|
||||
};
|
||||
}
|
||||
|
||||
internal static IReadOnlyList<CustomerAggregate> ApplyKeyword(
|
||||
IReadOnlyList<CustomerAggregate> customers,
|
||||
string? keyword)
|
||||
{
|
||||
var normalizedKeyword = (keyword ?? string.Empty).Trim();
|
||||
if (string.IsNullOrWhiteSpace(normalizedKeyword))
|
||||
{
|
||||
return customers;
|
||||
}
|
||||
|
||||
var keywordDigits = CustomerAnalyticsSupport.NormalizePhone(normalizedKeyword);
|
||||
return customers
|
||||
.Where(customer =>
|
||||
{
|
||||
var matchedByName = customer.Name.Contains(normalizedKeyword, StringComparison.OrdinalIgnoreCase);
|
||||
var matchedByPhone = !string.IsNullOrWhiteSpace(keywordDigits) &&
|
||||
customer.CustomerKey.Contains(keywordDigits, StringComparison.Ordinal);
|
||||
return matchedByName || matchedByPhone;
|
||||
})
|
||||
.ToList();
|
||||
}
|
||||
|
||||
internal static bool TryParseRfmSegmentCode(
|
||||
string normalizedSegmentCode,
|
||||
out int rowIndex,
|
||||
out int columnIndex)
|
||||
{
|
||||
rowIndex = -1;
|
||||
columnIndex = -1;
|
||||
|
||||
if (!normalizedSegmentCode.StartsWith("rfm_r", StringComparison.Ordinal) ||
|
||||
normalizedSegmentCode.Length != 8)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
var rowChar = normalizedSegmentCode[5];
|
||||
var separator = normalizedSegmentCode[6];
|
||||
var columnChar = normalizedSegmentCode[7];
|
||||
|
||||
if (separator != 'c' || !char.IsDigit(rowChar) || !char.IsDigit(columnChar))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
rowIndex = rowChar - '1';
|
||||
columnIndex = columnChar - '1';
|
||||
|
||||
return rowIndex is >= 0 and < 3 && columnIndex is >= 0 and < 4;
|
||||
}
|
||||
|
||||
internal static string BuildRfmSegmentCode(int rowIndex, int columnIndex)
|
||||
{
|
||||
return $"rfm_r{rowIndex + 1}c{columnIndex + 1}";
|
||||
}
|
||||
|
||||
private static string ResolveCompositionSegmentCode(
|
||||
CustomerAggregate customer,
|
||||
DateTime nowUtc,
|
||||
int periodDays)
|
||||
{
|
||||
var silentDays = (nowUtc.Date - customer.LastOrderAt.Date).TotalDays;
|
||||
if (silentDays > 60)
|
||||
{
|
||||
return SegmentChurn;
|
||||
}
|
||||
|
||||
if (silentDays > 30)
|
||||
{
|
||||
return SegmentDormant;
|
||||
}
|
||||
|
||||
if (customer.RegisteredAt >= nowUtc.AddDays(-Math.Clamp(periodDays, 7, 365)))
|
||||
{
|
||||
return SegmentActiveNew;
|
||||
}
|
||||
|
||||
return SegmentRepeatLoyal;
|
||||
}
|
||||
|
||||
private static AmountDistributionDefinition? ResolveAmountDistributionDefinition(string normalizedSegmentCode)
|
||||
{
|
||||
return AmountDistributionDefinitions.FirstOrDefault(item =>
|
||||
string.Equals(item.SegmentCode, normalizedSegmentCode, StringComparison.Ordinal));
|
||||
}
|
||||
|
||||
private static bool MatchAmountDistribution(decimal amount, AmountDistributionDefinition definition)
|
||||
{
|
||||
if (amount < definition.MinInclusive)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
if (definition.MaxExclusive.HasValue && amount >= definition.MaxExclusive.Value)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
private static int ResolveRfmRecencyRow(CustomerAggregate customer, DateTime nowUtc)
|
||||
{
|
||||
var silentDays = (nowUtc.Date - customer.LastOrderAt.Date).TotalDays;
|
||||
if (silentDays <= 30)
|
||||
{
|
||||
return 0;
|
||||
}
|
||||
|
||||
if (silentDays <= 60)
|
||||
{
|
||||
return 1;
|
||||
}
|
||||
|
||||
return 2;
|
||||
}
|
||||
|
||||
private static int ResolveRfmFrequencyAmountColumn(CustomerAggregate customer)
|
||||
{
|
||||
var highFrequency = customer.OrderCount >= 10;
|
||||
var highAmount = customer.AverageAmount >= 100m;
|
||||
|
||||
return (highFrequency, highAmount) switch
|
||||
{
|
||||
(true, true) => 0,
|
||||
(true, false) => 1,
|
||||
(false, true) => 2,
|
||||
_ => 3
|
||||
};
|
||||
}
|
||||
|
||||
private sealed record AmountDistributionDefinition(
|
||||
string SegmentCode,
|
||||
string Label,
|
||||
decimal MinInclusive,
|
||||
decimal? MaxExclusive);
|
||||
}
|
||||
|
||||
internal sealed record SegmentMeta(
|
||||
string SegmentCode,
|
||||
string SegmentTitle,
|
||||
string SegmentDescription);
|
||||
@@ -0,0 +1,187 @@
|
||||
using System.Globalization;
|
||||
using System.Text;
|
||||
using MediatR;
|
||||
using TakeoutSaaS.Application.App.Customers.Dto;
|
||||
using TakeoutSaaS.Application.App.Customers.Queries;
|
||||
using TakeoutSaaS.Domain.Orders.Repositories;
|
||||
using TakeoutSaaS.Shared.Abstractions.Data;
|
||||
using TakeoutSaaS.Shared.Abstractions.Tenancy;
|
||||
|
||||
namespace TakeoutSaaS.Application.App.Customers.Handlers;
|
||||
|
||||
/// <summary>
|
||||
/// 客户分析报表导出处理器。
|
||||
/// </summary>
|
||||
public sealed class ExportCustomerAnalysisCsvQueryHandler(
|
||||
IOrderRepository orderRepository,
|
||||
IDapperExecutor dapperExecutor,
|
||||
ITenantProvider tenantProvider)
|
||||
: IRequestHandler<ExportCustomerAnalysisCsvQuery, CustomerExportDto>
|
||||
{
|
||||
/// <inheritdoc />
|
||||
public async Task<CustomerExportDto> Handle(
|
||||
ExportCustomerAnalysisCsvQuery request,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var periodCode = string.IsNullOrWhiteSpace(request.PeriodCode)
|
||||
? "30d"
|
||||
: request.PeriodCode.Trim().ToLowerInvariant();
|
||||
var periodDays = Math.Clamp(request.PeriodDays, 7, 365);
|
||||
|
||||
if (request.VisibleStoreIds.Count == 0)
|
||||
{
|
||||
return BuildExport(
|
||||
BuildCsv(periodCode, periodDays, [], DateTime.UtcNow),
|
||||
0);
|
||||
}
|
||||
|
||||
var customers = await CustomerAnalyticsSupport.LoadCustomersAsync(
|
||||
orderRepository,
|
||||
dapperExecutor,
|
||||
tenantProvider,
|
||||
request.VisibleStoreIds,
|
||||
cancellationToken);
|
||||
|
||||
return BuildExport(
|
||||
BuildCsv(periodCode, periodDays, customers, DateTime.UtcNow),
|
||||
Math.Min(10, customers.Count));
|
||||
}
|
||||
|
||||
private static CustomerExportDto BuildExport(string csv, int totalCount)
|
||||
{
|
||||
var bytes = Encoding.UTF8.GetPreamble().Concat(Encoding.UTF8.GetBytes(csv)).ToArray();
|
||||
return new CustomerExportDto
|
||||
{
|
||||
FileName = $"客户分析报表_{DateTime.UtcNow:yyyyMMddHHmmss}.csv",
|
||||
FileContentBase64 = Convert.ToBase64String(bytes),
|
||||
TotalCount = totalCount
|
||||
};
|
||||
}
|
||||
|
||||
private static string BuildCsv(
|
||||
string periodCode,
|
||||
int periodDays,
|
||||
IReadOnlyList<CustomerAggregate> customers,
|
||||
DateTime nowUtc)
|
||||
{
|
||||
var currentStart = nowUtc.AddDays(-periodDays);
|
||||
var previousStart = currentStart.AddDays(-periodDays);
|
||||
|
||||
var totalCustomers = customers.Count;
|
||||
var newCustomers = customers.Count(item => item.RegisteredAt >= currentStart);
|
||||
var previousNewCustomers = customers.Count(item =>
|
||||
item.RegisteredAt >= previousStart &&
|
||||
item.RegisteredAt < currentStart);
|
||||
var activeCustomers = customers.Count(item => item.LastOrderAt >= currentStart);
|
||||
var averageLifetimeValue = totalCustomers <= 0
|
||||
? 0
|
||||
: decimal.Round(
|
||||
customers.Sum(item => item.TotalAmount) / totalCustomers,
|
||||
2,
|
||||
MidpointRounding.AwayFromZero);
|
||||
|
||||
var composition = CustomerAnalysisSegmentSupport.BuildComposition(customers, nowUtc, periodDays);
|
||||
var amountDistribution = CustomerAnalysisSegmentSupport.BuildAmountDistribution(customers);
|
||||
var rfmRows = CustomerAnalysisSegmentSupport.BuildRfmRows(customers, nowUtc);
|
||||
var topCustomers = CustomerAnalysisSegmentSupport.BuildTopCustomers(customers, 10);
|
||||
|
||||
var sb = new StringBuilder();
|
||||
sb.AppendLine($"统计周期,{Escape(ResolvePeriodLabel(periodCode, periodDays))}");
|
||||
sb.AppendLine();
|
||||
|
||||
sb.AppendLine("核心指标");
|
||||
sb.AppendLine("指标,值");
|
||||
sb.AppendLine($"客户总数,{totalCustomers}");
|
||||
sb.AppendLine($"周期新增,{newCustomers}");
|
||||
sb.AppendLine($"新增增长率,{CustomerAnalyticsSupport.ToGrowthRatePercent(newCustomers, previousNewCustomers):0.#}%");
|
||||
sb.AppendLine($"周期活跃客户,{activeCustomers}");
|
||||
sb.AppendLine($"活跃率,{CustomerAnalyticsSupport.ToRatePercent(activeCustomers, totalCustomers):0.#}%");
|
||||
sb.AppendLine($"平均客户价值,{averageLifetimeValue.ToString("0.00", CultureInfo.InvariantCulture)}");
|
||||
sb.AppendLine();
|
||||
|
||||
sb.AppendLine("新老客占比");
|
||||
sb.AppendLine("分层,人数,占比");
|
||||
foreach (var item in composition)
|
||||
{
|
||||
sb.AppendLine($"{Escape(item.Label)},{item.Count},{item.Percent:0.#}%");
|
||||
}
|
||||
sb.AppendLine();
|
||||
|
||||
sb.AppendLine("客单价分布");
|
||||
sb.AppendLine("区间,人数,占比");
|
||||
foreach (var item in amountDistribution)
|
||||
{
|
||||
sb.AppendLine($"{Escape(item.Label)},{item.Count},{item.Percent:0.#}%");
|
||||
}
|
||||
sb.AppendLine();
|
||||
|
||||
sb.AppendLine("RFM客户分层");
|
||||
sb.AppendLine("活跃度,分群,标签,人数");
|
||||
for (var rowIndex = 0; rowIndex < rfmRows.Count; rowIndex += 1)
|
||||
{
|
||||
var row = rfmRows[rowIndex];
|
||||
for (var columnIndex = 0; columnIndex < row.Cells.Count; columnIndex += 1)
|
||||
{
|
||||
var cell = row.Cells[columnIndex];
|
||||
sb.AppendLine($"{Escape(row.Label)},{Escape(ResolveRfmColumnLabel(columnIndex))},{Escape(cell.Label)},{cell.Count}");
|
||||
}
|
||||
}
|
||||
sb.AppendLine();
|
||||
|
||||
sb.AppendLine("高价值客户TOP10");
|
||||
sb.AppendLine("排名,客户,手机号,累计消费,下单次数,客单价,最近下单,标签");
|
||||
foreach (var item in topCustomers)
|
||||
{
|
||||
var tags = item.Tags.Count == 0
|
||||
? string.Empty
|
||||
: string.Join('、', item.Tags.Select(tag => tag.Label));
|
||||
|
||||
sb.AppendLine(string.Join(',',
|
||||
[
|
||||
item.Rank.ToString(CultureInfo.InvariantCulture),
|
||||
Escape(item.Name),
|
||||
Escape(item.PhoneMasked),
|
||||
Escape(item.TotalAmount.ToString("0.00", CultureInfo.InvariantCulture)),
|
||||
Escape(item.OrderCount.ToString(CultureInfo.InvariantCulture)),
|
||||
Escape(item.AverageAmount.ToString("0.00", CultureInfo.InvariantCulture)),
|
||||
Escape(item.LastOrderAt.ToString("yyyy-MM-dd", CultureInfo.InvariantCulture)),
|
||||
Escape(tags)
|
||||
]));
|
||||
}
|
||||
|
||||
return sb.ToString();
|
||||
}
|
||||
|
||||
private static string ResolvePeriodLabel(string periodCode, int periodDays)
|
||||
{
|
||||
return periodCode switch
|
||||
{
|
||||
"7d" => "近7天",
|
||||
"30d" => "近30天",
|
||||
"90d" => "近90天",
|
||||
"365d" => "近1年",
|
||||
_ => $"近{periodDays}天"
|
||||
};
|
||||
}
|
||||
|
||||
private static string ResolveRfmColumnLabel(int columnIndex)
|
||||
{
|
||||
return columnIndex switch
|
||||
{
|
||||
0 => "高频高额",
|
||||
1 => "高频低额",
|
||||
2 => "低频高额",
|
||||
_ => "低频低额"
|
||||
};
|
||||
}
|
||||
|
||||
private static string Escape(string input)
|
||||
{
|
||||
if (!input.Contains('"') && !input.Contains(',') && !input.Contains('\n') && !input.Contains('\r'))
|
||||
{
|
||||
return input;
|
||||
}
|
||||
|
||||
return $"\"{input.Replace("\"", "\"\"")}\"";
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,94 @@
|
||||
using MediatR;
|
||||
using TakeoutSaaS.Application.App.Customers.Dto;
|
||||
using TakeoutSaaS.Application.App.Customers.Queries;
|
||||
using TakeoutSaaS.Domain.Orders.Repositories;
|
||||
using TakeoutSaaS.Shared.Abstractions.Data;
|
||||
using TakeoutSaaS.Shared.Abstractions.Tenancy;
|
||||
|
||||
namespace TakeoutSaaS.Application.App.Customers.Handlers;
|
||||
|
||||
/// <summary>
|
||||
/// 客户分析总览查询处理器。
|
||||
/// </summary>
|
||||
public sealed class GetCustomerAnalysisOverviewQueryHandler(
|
||||
IOrderRepository orderRepository,
|
||||
IDapperExecutor dapperExecutor,
|
||||
ITenantProvider tenantProvider)
|
||||
: IRequestHandler<GetCustomerAnalysisOverviewQuery, CustomerAnalysisOverviewDto>
|
||||
{
|
||||
/// <inheritdoc />
|
||||
public async Task<CustomerAnalysisOverviewDto> Handle(
|
||||
GetCustomerAnalysisOverviewQuery request,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var periodDays = Math.Clamp(request.PeriodDays, 7, 365);
|
||||
var periodCode = string.IsNullOrWhiteSpace(request.PeriodCode)
|
||||
? "30d"
|
||||
: request.PeriodCode.Trim().ToLowerInvariant();
|
||||
|
||||
if (request.VisibleStoreIds.Count == 0)
|
||||
{
|
||||
return BuildEmptyOverview(periodCode, periodDays);
|
||||
}
|
||||
|
||||
var customers = await CustomerAnalyticsSupport.LoadCustomersAsync(
|
||||
orderRepository,
|
||||
dapperExecutor,
|
||||
tenantProvider,
|
||||
request.VisibleStoreIds,
|
||||
cancellationToken);
|
||||
|
||||
var nowUtc = DateTime.UtcNow;
|
||||
var currentStart = nowUtc.AddDays(-periodDays);
|
||||
var previousStart = currentStart.AddDays(-periodDays);
|
||||
|
||||
var totalCustomers = customers.Count;
|
||||
var newCustomers = customers.Count(item => item.RegisteredAt >= currentStart);
|
||||
var previousNewCustomers = customers.Count(item =>
|
||||
item.RegisteredAt >= previousStart &&
|
||||
item.RegisteredAt < currentStart);
|
||||
var activeCustomers = customers.Count(item => item.LastOrderAt >= currentStart);
|
||||
|
||||
var averageLifetimeValue = totalCustomers <= 0
|
||||
? 0
|
||||
: decimal.Round(
|
||||
customers.Sum(item => item.TotalAmount) / totalCustomers,
|
||||
2,
|
||||
MidpointRounding.AwayFromZero);
|
||||
|
||||
return new CustomerAnalysisOverviewDto
|
||||
{
|
||||
PeriodCode = periodCode,
|
||||
PeriodDays = periodDays,
|
||||
TotalCustomers = totalCustomers,
|
||||
NewCustomers = newCustomers,
|
||||
GrowthRatePercent = CustomerAnalyticsSupport.ToGrowthRatePercent(newCustomers, previousNewCustomers),
|
||||
NewCustomersDailyAverage = decimal.Round(
|
||||
newCustomers / Math.Max(1m, periodDays),
|
||||
1,
|
||||
MidpointRounding.AwayFromZero),
|
||||
ActiveCustomers = activeCustomers,
|
||||
ActiveRatePercent = CustomerAnalyticsSupport.ToRatePercent(activeCustomers, totalCustomers),
|
||||
AverageLifetimeValue = averageLifetimeValue,
|
||||
GrowthTrend = CustomerAnalysisSegmentSupport.BuildGrowthTrend(customers, nowUtc, 12),
|
||||
Composition = CustomerAnalysisSegmentSupport.BuildComposition(customers, nowUtc, periodDays),
|
||||
AmountDistribution = CustomerAnalysisSegmentSupport.BuildAmountDistribution(customers),
|
||||
RfmRows = CustomerAnalysisSegmentSupport.BuildRfmRows(customers, nowUtc),
|
||||
TopCustomers = CustomerAnalysisSegmentSupport.BuildTopCustomers(customers, 10)
|
||||
};
|
||||
}
|
||||
|
||||
private static CustomerAnalysisOverviewDto BuildEmptyOverview(string periodCode, int periodDays)
|
||||
{
|
||||
return new CustomerAnalysisOverviewDto
|
||||
{
|
||||
PeriodCode = periodCode,
|
||||
PeriodDays = periodDays,
|
||||
GrowthTrend = CustomerAnalysisSegmentSupport.BuildGrowthTrend([], DateTime.UtcNow, 12),
|
||||
Composition = CustomerAnalysisSegmentSupport.BuildComposition([], DateTime.UtcNow, periodDays),
|
||||
AmountDistribution = CustomerAnalysisSegmentSupport.BuildAmountDistribution([]),
|
||||
RfmRows = CustomerAnalysisSegmentSupport.BuildRfmRows([], DateTime.UtcNow),
|
||||
TopCustomers = []
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,102 @@
|
||||
using MediatR;
|
||||
using TakeoutSaaS.Application.App.Customers.Dto;
|
||||
using TakeoutSaaS.Application.App.Customers.Queries;
|
||||
using TakeoutSaaS.Domain.Orders.Repositories;
|
||||
using TakeoutSaaS.Shared.Abstractions.Data;
|
||||
using TakeoutSaaS.Shared.Abstractions.Tenancy;
|
||||
|
||||
namespace TakeoutSaaS.Application.App.Customers.Handlers;
|
||||
|
||||
/// <summary>
|
||||
/// 客群明细查询处理器。
|
||||
/// </summary>
|
||||
public sealed class GetCustomerAnalysisSegmentListQueryHandler(
|
||||
IOrderRepository orderRepository,
|
||||
IDapperExecutor dapperExecutor,
|
||||
ITenantProvider tenantProvider)
|
||||
: IRequestHandler<GetCustomerAnalysisSegmentListQuery, CustomerAnalysisSegmentListResultDto>
|
||||
{
|
||||
/// <inheritdoc />
|
||||
public async Task<CustomerAnalysisSegmentListResultDto> Handle(
|
||||
GetCustomerAnalysisSegmentListQuery request,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var page = Math.Max(1, request.Page);
|
||||
var pageSize = Math.Clamp(request.PageSize, 1, 200);
|
||||
var periodDays = Math.Clamp(request.PeriodDays, 7, 365);
|
||||
var normalizedSegmentCode = CustomerAnalysisSegmentSupport.NormalizeSegmentCode(request.SegmentCode);
|
||||
var segmentMeta = CustomerAnalysisSegmentSupport.ResolveSegmentMeta(normalizedSegmentCode);
|
||||
|
||||
if (request.VisibleStoreIds.Count == 0)
|
||||
{
|
||||
return new CustomerAnalysisSegmentListResultDto
|
||||
{
|
||||
SegmentCode = segmentMeta.SegmentCode,
|
||||
SegmentTitle = segmentMeta.SegmentTitle,
|
||||
SegmentDescription = segmentMeta.SegmentDescription,
|
||||
Page = page,
|
||||
PageSize = pageSize,
|
||||
TotalCount = 0,
|
||||
Items = []
|
||||
};
|
||||
}
|
||||
|
||||
var customers = await CustomerAnalyticsSupport.LoadCustomersAsync(
|
||||
orderRepository,
|
||||
dapperExecutor,
|
||||
tenantProvider,
|
||||
request.VisibleStoreIds,
|
||||
cancellationToken);
|
||||
|
||||
var nowUtc = DateTime.UtcNow;
|
||||
var segmentCustomers = CustomerAnalysisSegmentSupport.FilterBySegment(
|
||||
customers,
|
||||
normalizedSegmentCode,
|
||||
nowUtc,
|
||||
periodDays);
|
||||
|
||||
var keywordFiltered = CustomerAnalysisSegmentSupport.ApplyKeyword(
|
||||
segmentCustomers,
|
||||
request.Keyword);
|
||||
|
||||
var sortedCustomers = keywordFiltered
|
||||
.OrderByDescending(item => item.TotalAmount)
|
||||
.ThenByDescending(item => item.OrderCount)
|
||||
.ThenByDescending(item => item.LastOrderAt)
|
||||
.ThenBy(item => item.CustomerKey, StringComparer.Ordinal)
|
||||
.ToList();
|
||||
|
||||
var pagedItems = sortedCustomers
|
||||
.Skip((page - 1) * pageSize)
|
||||
.Take(pageSize)
|
||||
.Select(item => new CustomerAnalysisSegmentListItemDto
|
||||
{
|
||||
CustomerKey = item.CustomerKey,
|
||||
Name = item.Name,
|
||||
PhoneMasked = item.PhoneMasked,
|
||||
AvatarText = item.AvatarText,
|
||||
AvatarColor = item.AvatarColor,
|
||||
Tags = item.Tags,
|
||||
IsMember = item.Member.IsMember,
|
||||
MemberTierName = item.Member.TierName,
|
||||
TotalAmount = item.TotalAmount,
|
||||
OrderCount = item.OrderCount,
|
||||
AverageAmount = item.AverageAmount,
|
||||
RegisteredAt = item.RegisteredAt,
|
||||
LastOrderAt = item.LastOrderAt,
|
||||
IsDimmed = item.IsDimmed
|
||||
})
|
||||
.ToList();
|
||||
|
||||
return new CustomerAnalysisSegmentListResultDto
|
||||
{
|
||||
SegmentCode = segmentMeta.SegmentCode,
|
||||
SegmentTitle = segmentMeta.SegmentTitle,
|
||||
SegmentDescription = segmentMeta.SegmentDescription,
|
||||
Items = pagedItems,
|
||||
Page = page,
|
||||
PageSize = pageSize,
|
||||
TotalCount = sortedCustomers.Count
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,77 @@
|
||||
using MediatR;
|
||||
using TakeoutSaaS.Application.App.Customers.Dto;
|
||||
using TakeoutSaaS.Application.App.Customers.Queries;
|
||||
using TakeoutSaaS.Domain.Orders.Repositories;
|
||||
using TakeoutSaaS.Shared.Abstractions.Data;
|
||||
using TakeoutSaaS.Shared.Abstractions.Tenancy;
|
||||
|
||||
namespace TakeoutSaaS.Application.App.Customers.Handlers;
|
||||
|
||||
/// <summary>
|
||||
/// 会员详情查询处理器。
|
||||
/// </summary>
|
||||
public sealed class GetCustomerMemberDetailQueryHandler(
|
||||
IOrderRepository orderRepository,
|
||||
IDapperExecutor dapperExecutor,
|
||||
ITenantProvider tenantProvider)
|
||||
: IRequestHandler<GetCustomerMemberDetailQuery, CustomerMemberDetailDto?>
|
||||
{
|
||||
/// <inheritdoc />
|
||||
public async Task<CustomerMemberDetailDto?> Handle(
|
||||
GetCustomerMemberDetailQuery request,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var customerKey = CustomerAnalyticsSupport.NormalizePhone(request.CustomerKey);
|
||||
if (request.VisibleStoreIds.Count == 0 || string.IsNullOrWhiteSpace(customerKey))
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
var customers = await CustomerAnalyticsSupport.LoadCustomersAsync(
|
||||
orderRepository,
|
||||
dapperExecutor,
|
||||
tenantProvider,
|
||||
request.VisibleStoreIds,
|
||||
cancellationToken);
|
||||
|
||||
var customer = customers.FirstOrDefault(item =>
|
||||
string.Equals(item.CustomerKey, customerKey, StringComparison.Ordinal));
|
||||
if (customer is null)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
var tenantId = tenantProvider.GetCurrentTenantId();
|
||||
if (tenantId <= 0)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
var orderIds = customer.Orders
|
||||
.Select(item => item.OrderId)
|
||||
.ToList();
|
||||
|
||||
var itemLookup = await orderRepository.GetItemsByOrderIdsAsync(orderIds, tenantId, cancellationToken);
|
||||
var recentOrders = CustomerAnalyticsSupport.BuildRecentOrders(customer.Orders, itemLookup, 5);
|
||||
var repurchaseRatePercent = CustomerAnalyticsSupport.ToRatePercent(
|
||||
Math.Max(0, customer.OrderCount - 1),
|
||||
customer.OrderCount);
|
||||
|
||||
return new CustomerMemberDetailDto
|
||||
{
|
||||
CustomerKey = customer.CustomerKey,
|
||||
Name = customer.Name,
|
||||
PhoneMasked = customer.PhoneMasked,
|
||||
Source = customer.Source,
|
||||
RegisteredAt = customer.RegisteredAt,
|
||||
LastOrderAt = customer.LastOrderAt,
|
||||
Member = customer.Member,
|
||||
Tags = customer.Tags,
|
||||
TotalOrders = customer.OrderCount,
|
||||
TotalAmount = customer.TotalAmount,
|
||||
AverageAmount = customer.AverageAmount,
|
||||
RepurchaseRatePercent = repurchaseRatePercent,
|
||||
RecentOrders = recentOrders
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
using MediatR;
|
||||
using TakeoutSaaS.Application.App.Customers.Dto;
|
||||
|
||||
namespace TakeoutSaaS.Application.App.Customers.Queries;
|
||||
|
||||
/// <summary>
|
||||
/// 客户分析报表导出查询。
|
||||
/// </summary>
|
||||
public sealed class ExportCustomerAnalysisCsvQuery : IRequest<CustomerExportDto>
|
||||
{
|
||||
/// <summary>
|
||||
/// 可见门店 ID 集合。
|
||||
/// </summary>
|
||||
public IReadOnlyCollection<long> VisibleStoreIds { get; init; } = [];
|
||||
|
||||
/// <summary>
|
||||
/// 统计周期编码(7d/30d/90d/365d)。
|
||||
/// </summary>
|
||||
public string PeriodCode { get; init; } = "30d";
|
||||
|
||||
/// <summary>
|
||||
/// 统计周期天数。
|
||||
/// </summary>
|
||||
public int PeriodDays { get; init; } = 30;
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
using MediatR;
|
||||
using TakeoutSaaS.Application.App.Customers.Dto;
|
||||
|
||||
namespace TakeoutSaaS.Application.App.Customers.Queries;
|
||||
|
||||
/// <summary>
|
||||
/// 客户分析总览查询。
|
||||
/// </summary>
|
||||
public sealed class GetCustomerAnalysisOverviewQuery : IRequest<CustomerAnalysisOverviewDto>
|
||||
{
|
||||
/// <summary>
|
||||
/// 可见门店 ID 集合。
|
||||
/// </summary>
|
||||
public IReadOnlyCollection<long> VisibleStoreIds { get; init; } = [];
|
||||
|
||||
/// <summary>
|
||||
/// 统计周期编码(7d/30d/90d/365d)。
|
||||
/// </summary>
|
||||
public string PeriodCode { get; init; } = "30d";
|
||||
|
||||
/// <summary>
|
||||
/// 统计周期天数。
|
||||
/// </summary>
|
||||
public int PeriodDays { get; init; } = 30;
|
||||
}
|
||||
@@ -0,0 +1,45 @@
|
||||
using MediatR;
|
||||
using TakeoutSaaS.Application.App.Customers.Dto;
|
||||
|
||||
namespace TakeoutSaaS.Application.App.Customers.Queries;
|
||||
|
||||
/// <summary>
|
||||
/// 客群明细查询。
|
||||
/// </summary>
|
||||
public sealed class GetCustomerAnalysisSegmentListQuery : IRequest<CustomerAnalysisSegmentListResultDto>
|
||||
{
|
||||
/// <summary>
|
||||
/// 可见门店 ID 集合。
|
||||
/// </summary>
|
||||
public IReadOnlyCollection<long> VisibleStoreIds { get; init; } = [];
|
||||
|
||||
/// <summary>
|
||||
/// 统计周期编码(7d/30d/90d/365d)。
|
||||
/// </summary>
|
||||
public string PeriodCode { get; init; } = "30d";
|
||||
|
||||
/// <summary>
|
||||
/// 统计周期天数。
|
||||
/// </summary>
|
||||
public int PeriodDays { get; init; } = 30;
|
||||
|
||||
/// <summary>
|
||||
/// 分群编码。
|
||||
/// </summary>
|
||||
public string SegmentCode { get; init; } = "all";
|
||||
|
||||
/// <summary>
|
||||
/// 关键词(姓名/手机号)。
|
||||
/// </summary>
|
||||
public string? Keyword { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// 页码。
|
||||
/// </summary>
|
||||
public int Page { get; init; } = 1;
|
||||
|
||||
/// <summary>
|
||||
/// 每页条数。
|
||||
/// </summary>
|
||||
public int PageSize { get; init; } = 10;
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
using MediatR;
|
||||
using TakeoutSaaS.Application.App.Customers.Dto;
|
||||
|
||||
namespace TakeoutSaaS.Application.App.Customers.Queries;
|
||||
|
||||
/// <summary>
|
||||
/// 会员详情查询。
|
||||
/// </summary>
|
||||
public sealed class GetCustomerMemberDetailQuery : IRequest<CustomerMemberDetailDto?>
|
||||
{
|
||||
/// <summary>
|
||||
/// 可见门店 ID 集合。
|
||||
/// </summary>
|
||||
public IReadOnlyCollection<long> VisibleStoreIds { get; init; } = [];
|
||||
|
||||
/// <summary>
|
||||
/// 客户标识(手机号归一化)。
|
||||
/// </summary>
|
||||
public string CustomerKey { get; init; } = string.Empty;
|
||||
}
|
||||
Reference in New Issue
Block a user