From 26afffd874b87be2b2e577f861058cbdbcfa2776 Mon Sep 17 00:00:00 2001 From: MSuMshk <2039814060@qq.com> Date: Tue, 3 Mar 2026 16:45:39 +0800 Subject: [PATCH] feat(customer): add customer analysis query APIs --- .../Customer/CustomerAnalysisContracts.cs | 521 +++++++++++++++++ .../Controllers/CustomerAnalysisController.cs | 535 ++++++++++++++++++ .../App/Customers/Dto/CustomerAnalysisDtos.cs | 431 ++++++++++++++ .../CustomerAnalysisSegmentSupport.cs | 478 ++++++++++++++++ .../ExportCustomerAnalysisCsvQueryHandler.cs | 187 ++++++ ...GetCustomerAnalysisOverviewQueryHandler.cs | 94 +++ ...CustomerAnalysisSegmentListQueryHandler.cs | 102 ++++ .../GetCustomerMemberDetailQueryHandler.cs | 77 +++ .../Queries/ExportCustomerAnalysisCsvQuery.cs | 25 + .../GetCustomerAnalysisOverviewQuery.cs | 25 + .../GetCustomerAnalysisSegmentListQuery.cs | 45 ++ .../Queries/GetCustomerMemberDetailQuery.cs | 20 + 12 files changed, 2540 insertions(+) create mode 100644 src/Api/TakeoutSaaS.TenantApi/Contracts/Customer/CustomerAnalysisContracts.cs create mode 100644 src/Api/TakeoutSaaS.TenantApi/Controllers/CustomerAnalysisController.cs create mode 100644 src/Application/TakeoutSaaS.Application/App/Customers/Dto/CustomerAnalysisDtos.cs create mode 100644 src/Application/TakeoutSaaS.Application/App/Customers/Handlers/CustomerAnalysisSegmentSupport.cs create mode 100644 src/Application/TakeoutSaaS.Application/App/Customers/Handlers/ExportCustomerAnalysisCsvQueryHandler.cs create mode 100644 src/Application/TakeoutSaaS.Application/App/Customers/Handlers/GetCustomerAnalysisOverviewQueryHandler.cs create mode 100644 src/Application/TakeoutSaaS.Application/App/Customers/Handlers/GetCustomerAnalysisSegmentListQueryHandler.cs create mode 100644 src/Application/TakeoutSaaS.Application/App/Customers/Handlers/GetCustomerMemberDetailQueryHandler.cs create mode 100644 src/Application/TakeoutSaaS.Application/App/Customers/Queries/ExportCustomerAnalysisCsvQuery.cs create mode 100644 src/Application/TakeoutSaaS.Application/App/Customers/Queries/GetCustomerAnalysisOverviewQuery.cs create mode 100644 src/Application/TakeoutSaaS.Application/App/Customers/Queries/GetCustomerAnalysisSegmentListQuery.cs create mode 100644 src/Application/TakeoutSaaS.Application/App/Customers/Queries/GetCustomerMemberDetailQuery.cs diff --git a/src/Api/TakeoutSaaS.TenantApi/Contracts/Customer/CustomerAnalysisContracts.cs b/src/Api/TakeoutSaaS.TenantApi/Contracts/Customer/CustomerAnalysisContracts.cs new file mode 100644 index 0000000..e6de396 --- /dev/null +++ b/src/Api/TakeoutSaaS.TenantApi/Contracts/Customer/CustomerAnalysisContracts.cs @@ -0,0 +1,521 @@ +namespace TakeoutSaaS.TenantApi.Contracts.Customer; + +/// +/// 客户分析总览请求。 +/// +public sealed class CustomerAnalysisOverviewRequest +{ + /// + /// 门店 ID(可选,未传表示当前商户全部可见门店)。 + /// + public string? StoreId { get; set; } + + /// + /// 统计周期(7d/30d/90d/365d)。 + /// + public string? Period { get; set; } +} + +/// +/// 客群明细筛选请求。 +/// +public class CustomerAnalysisSegmentFilterRequest +{ + /// + /// 门店 ID(可选,未传表示当前商户全部可见门店)。 + /// + public string? StoreId { get; set; } + + /// + /// 统计周期(7d/30d/90d/365d)。 + /// + public string? Period { get; set; } + + /// + /// 分群编码。 + /// + public string SegmentCode { get; set; } = "all"; + + /// + /// 关键词(姓名/手机号)。 + /// + public string? Keyword { get; set; } +} + +/// +/// 客群明细分页请求。 +/// +public sealed class CustomerAnalysisSegmentListRequest : CustomerAnalysisSegmentFilterRequest +{ + /// + /// 页码。 + /// + public int Page { get; set; } = 1; + + /// + /// 每页条数。 + /// + public int PageSize { get; set; } = 10; +} + +/// +/// 会员详情请求。 +/// +public sealed class CustomerMemberDetailRequest +{ + /// + /// 门店 ID(可选,未传表示当前商户全部可见门店)。 + /// + public string? StoreId { get; set; } + + /// + /// 客户标识(手机号归一化)。 + /// + public string CustomerKey { get; set; } = string.Empty; +} + +/// +/// 客户分析导出请求。 +/// +public sealed class CustomerAnalysisExportRequest +{ + /// + /// 门店 ID(可选,未传表示当前商户全部可见门店)。 + /// + public string? StoreId { get; set; } + + /// + /// 统计周期(7d/30d/90d/365d)。 + /// + public string? Period { get; set; } +} + +/// +/// 客户分析趋势点响应。 +/// +public sealed class CustomerAnalysisTrendPointResponse +{ + /// + /// 维度标签。 + /// + public string Label { get; set; } = string.Empty; + + /// + /// 数量值。 + /// + public int Value { get; set; } +} + +/// +/// 新老客构成项响应。 +/// +public sealed class CustomerAnalysisCompositionItemResponse +{ + /// + /// 分群编码。 + /// + public string SegmentCode { get; set; } = string.Empty; + + /// + /// 分群名称。 + /// + public string Label { get; set; } = string.Empty; + + /// + /// 人数。 + /// + public int Count { get; set; } + + /// + /// 占比(百分比)。 + /// + public decimal Percent { get; set; } + + /// + /// 色调。 + /// + public string Tone { get; set; } = "blue"; +} + +/// +/// 客单价分布项响应。 +/// +public sealed class CustomerAnalysisAmountDistributionItemResponse +{ + /// + /// 分群编码。 + /// + public string SegmentCode { get; set; } = string.Empty; + + /// + /// 区间标签。 + /// + public string Label { get; set; } = string.Empty; + + /// + /// 人数。 + /// + public int Count { get; set; } + + /// + /// 占比(百分比)。 + /// + public decimal Percent { get; set; } +} + +/// +/// RFM 分层单元响应。 +/// +public sealed class CustomerAnalysisRfmCellResponse +{ + /// + /// 分群编码。 + /// + public string SegmentCode { get; set; } = string.Empty; + + /// + /// 标签。 + /// + public string Label { get; set; } = string.Empty; + + /// + /// 人数。 + /// + public int Count { get; set; } + + /// + /// 温度(hot/warm/cool/cold)。 + /// + public string Tone { get; set; } = "cold"; +} + +/// +/// RFM 分层行响应。 +/// +public sealed class CustomerAnalysisRfmRowResponse +{ + /// + /// 行标签。 + /// + public string Label { get; set; } = string.Empty; + + /// + /// 单元格集合。 + /// + public List Cells { get; set; } = []; +} + +/// +/// 高价值客户响应。 +/// +public sealed class CustomerAnalysisTopCustomerResponse +{ + /// + /// 排名。 + /// + public int Rank { get; set; } + + /// + /// 客户标识。 + /// + public string CustomerKey { get; set; } = string.Empty; + + /// + /// 客户名称。 + /// + public string Name { get; set; } = string.Empty; + + /// + /// 手机号(脱敏)。 + /// + public string PhoneMasked { get; set; } = string.Empty; + + /// + /// 累计消费。 + /// + public decimal TotalAmount { get; set; } + + /// + /// 下单次数。 + /// + public int OrderCount { get; set; } + + /// + /// 客单价。 + /// + public decimal AverageAmount { get; set; } + + /// + /// 最近下单时间(yyyy-MM-dd)。 + /// + public string LastOrderAt { get; set; } = string.Empty; + + /// + /// 客户标签。 + /// + public List Tags { get; set; } = []; +} + +/// +/// 客户分析总览响应。 +/// +public sealed class CustomerAnalysisOverviewResponse +{ + /// + /// 统计周期编码。 + /// + public string PeriodCode { get; set; } = "30d"; + + /// + /// 统计周期天数。 + /// + public int PeriodDays { get; set; } = 30; + + /// + /// 客户总数。 + /// + public int TotalCustomers { get; set; } + + /// + /// 周期新增客户数。 + /// + public int NewCustomers { get; set; } + + /// + /// 新增较上一周期增长百分比。 + /// + public decimal GrowthRatePercent { get; set; } + + /// + /// 周期内日均新增客户。 + /// + public decimal NewCustomersDailyAverage { get; set; } + + /// + /// 活跃客户数。 + /// + public int ActiveCustomers { get; set; } + + /// + /// 活跃率(百分比)。 + /// + public decimal ActiveRatePercent { get; set; } + + /// + /// 平均客户价值。 + /// + public decimal AverageLifetimeValue { get; set; } + + /// + /// 客户增长趋势。 + /// + public List GrowthTrend { get; set; } = []; + + /// + /// 新老客占比。 + /// + public List Composition { get; set; } = []; + + /// + /// 客单价分布。 + /// + public List AmountDistribution { get; set; } = []; + + /// + /// RFM 分层。 + /// + public List RfmRows { get; set; } = []; + + /// + /// 高价值客户 Top10。 + /// + public List TopCustomers { get; set; } = []; +} + +/// +/// 客群明细行响应。 +/// +public sealed class CustomerAnalysisSegmentListItemResponse +{ + /// + /// 客户标识。 + /// + public string CustomerKey { get; set; } = string.Empty; + + /// + /// 客户名称。 + /// + public string Name { get; set; } = string.Empty; + + /// + /// 手机号(脱敏)。 + /// + public string PhoneMasked { get; set; } = string.Empty; + + /// + /// 头像文案。 + /// + public string AvatarText { get; set; } = string.Empty; + + /// + /// 头像颜色。 + /// + public string AvatarColor { get; set; } = string.Empty; + + /// + /// 客户标签。 + /// + public List Tags { get; set; } = []; + + /// + /// 是否会员。 + /// + public bool IsMember { get; set; } + + /// + /// 会员等级。 + /// + public string MemberTierName { get; set; } = string.Empty; + + /// + /// 累计消费。 + /// + public decimal TotalAmount { get; set; } + + /// + /// 下单次数。 + /// + public int OrderCount { get; set; } + + /// + /// 客单价。 + /// + public decimal AverageAmount { get; set; } + + /// + /// 注册时间(yyyy-MM-dd)。 + /// + public string RegisteredAt { get; set; } = string.Empty; + + /// + /// 最近下单时间(yyyy-MM-dd)。 + /// + public string LastOrderAt { get; set; } = string.Empty; + + /// + /// 是否弱化显示。 + /// + public bool IsDimmed { get; set; } +} + +/// +/// 客群明细分页响应。 +/// +public sealed class CustomerAnalysisSegmentListResultResponse +{ + /// + /// 分群编码。 + /// + public string SegmentCode { get; set; } = string.Empty; + + /// + /// 分群标题。 + /// + public string SegmentTitle { get; set; } = string.Empty; + + /// + /// 分群说明。 + /// + public string SegmentDescription { get; set; } = string.Empty; + + /// + /// 列表项。 + /// + public List Items { get; set; } = []; + + /// + /// 当前页。 + /// + public int Page { get; set; } + + /// + /// 每页条数。 + /// + public int PageSize { get; set; } + + /// + /// 总记录数。 + /// + public int TotalCount { get; set; } +} + +/// +/// 会员详情响应。 +/// +public sealed class CustomerMemberDetailResponse +{ + /// + /// 客户标识。 + /// + public string CustomerKey { get; set; } = string.Empty; + + /// + /// 客户名称。 + /// + public string Name { get; set; } = string.Empty; + + /// + /// 手机号(脱敏)。 + /// + public string PhoneMasked { get; set; } = string.Empty; + + /// + /// 来源。 + /// + public string Source { get; set; } = string.Empty; + + /// + /// 注册时间(yyyy-MM-dd)。 + /// + public string RegisteredAt { get; set; } = string.Empty; + + /// + /// 最近下单时间(yyyy-MM-dd)。 + /// + public string LastOrderAt { get; set; } = string.Empty; + + /// + /// 会员摘要。 + /// + public CustomerMemberSummaryResponse Member { get; set; } = new(); + + /// + /// 客户标签。 + /// + public List Tags { get; set; } = []; + + /// + /// 累计下单次数。 + /// + public int TotalOrders { get; set; } + + /// + /// 累计消费。 + /// + public decimal TotalAmount { get; set; } + + /// + /// 客单价。 + /// + public decimal AverageAmount { get; set; } + + /// + /// 复购率(百分比)。 + /// + public decimal RepurchaseRatePercent { get; set; } + + /// + /// 最近订单。 + /// + public List RecentOrders { get; set; } = []; +} diff --git a/src/Api/TakeoutSaaS.TenantApi/Controllers/CustomerAnalysisController.cs b/src/Api/TakeoutSaaS.TenantApi/Controllers/CustomerAnalysisController.cs new file mode 100644 index 0000000..0d63e2b --- /dev/null +++ b/src/Api/TakeoutSaaS.TenantApi/Controllers/CustomerAnalysisController.cs @@ -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; + +/// +/// 客户分析。 +/// +[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"; + + /// + /// 获取客户分析总览。 + /// + [HttpGet("overview")] + [PermissionAuthorize(ViewPermission)] + [ProducesResponseType(typeof(ApiResponse), StatusCodes.Status200OK)] + public async Task> 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.Ok(MapOverview(result)); + } + + /// + /// 获取客群明细。 + /// + [HttpGet("segment/list")] + [PermissionAuthorize(ViewPermission)] + [ProducesResponseType(typeof(ApiResponse), StatusCodes.Status200OK)] + public async Task> 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.Ok(MapSegmentList(result)); + } + + /// + /// 获取客户详情(分析页二级抽屉)。 + /// + [HttpGet("detail")] + [PermissionAuthorize(ViewPermission)] + [ProducesResponseType(typeof(ApiResponse), StatusCodes.Status200OK)] + public async Task> Detail( + [FromQuery] CustomerDetailRequest request, + CancellationToken cancellationToken) + { + var customerKey = NormalizePhone(request.CustomerKey); + if (string.IsNullOrWhiteSpace(customerKey)) + { + return ApiResponse.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.Error(ErrorCodes.NotFound, "客户不存在"); + } + + return ApiResponse.Ok(MapDetail(result)); + } + + /// + /// 获取客户完整画像(分析页二级抽屉)。 + /// + [HttpGet("profile")] + [PermissionAuthorize(ViewPermission)] + [ProducesResponseType(typeof(ApiResponse), StatusCodes.Status200OK)] + public async Task> Profile( + [FromQuery] CustomerProfileRequest request, + CancellationToken cancellationToken) + { + var customerKey = NormalizePhone(request.CustomerKey); + if (string.IsNullOrWhiteSpace(customerKey)) + { + return ApiResponse.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.Error(ErrorCodes.NotFound, "客户不存在"); + } + + return ApiResponse.Ok(MapProfile(result)); + } + + /// + /// 获取会员详情。 + /// + [HttpGet("member/detail")] + [PermissionAuthorize(ViewPermission)] + [ProducesResponseType(typeof(ApiResponse), StatusCodes.Status200OK)] + public async Task> MemberDetail( + [FromQuery] CustomerMemberDetailRequest request, + CancellationToken cancellationToken) + { + var customerKey = NormalizePhone(request.CustomerKey); + if (string.IsNullOrWhiteSpace(customerKey)) + { + return ApiResponse.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.Error(ErrorCodes.NotFound, "客户不存在"); + } + + return ApiResponse.Ok(MapMemberDetail(result)); + } + + /// + /// 导出客户分析报表。 + /// + [HttpGet("export")] + [PermissionAuthorize(ViewPermission)] + [ProducesResponseType(typeof(ApiResponse), StatusCodes.Status200OK)] + public async Task> 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.Ok(new CustomerExportResponse + { + FileName = result.FileName, + FileContentBase64 = result.FileContentBase64, + TotalCount = result.TotalCount + }); + } + + private async Task> 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); + } +} diff --git a/src/Application/TakeoutSaaS.Application/App/Customers/Dto/CustomerAnalysisDtos.cs b/src/Application/TakeoutSaaS.Application/App/Customers/Dto/CustomerAnalysisDtos.cs new file mode 100644 index 0000000..a9d6dea --- /dev/null +++ b/src/Application/TakeoutSaaS.Application/App/Customers/Dto/CustomerAnalysisDtos.cs @@ -0,0 +1,431 @@ +namespace TakeoutSaaS.Application.App.Customers.Dto; + +/// +/// 客户分析增长趋势点 DTO。 +/// +public sealed class CustomerAnalysisTrendPointDto +{ + /// + /// 维度标签。 + /// + public string Label { get; init; } = string.Empty; + + /// + /// 数量值。 + /// + public int Value { get; init; } +} + +/// +/// 客户分析新老客构成项 DTO。 +/// +public sealed class CustomerAnalysisCompositionItemDto +{ + /// + /// 分群编码。 + /// + public string SegmentCode { get; init; } = string.Empty; + + /// + /// 分群名称。 + /// + public string Label { get; init; } = string.Empty; + + /// + /// 分群人数。 + /// + public int Count { get; init; } + + /// + /// 分群占比(百分比)。 + /// + public decimal Percent { get; init; } + + /// + /// 色调(blue/green/orange/gray)。 + /// + public string Tone { get; init; } = "blue"; +} + +/// +/// 客单价分布项 DTO。 +/// +public sealed class CustomerAnalysisAmountDistributionItemDto +{ + /// + /// 分群编码。 + /// + public string SegmentCode { get; init; } = string.Empty; + + /// + /// 区间标签。 + /// + public string Label { get; init; } = string.Empty; + + /// + /// 人数。 + /// + public int Count { get; init; } + + /// + /// 占比(百分比)。 + /// + public decimal Percent { get; init; } +} + +/// +/// RFM 分层单元 DTO。 +/// +public sealed class CustomerAnalysisRfmCellDto +{ + /// + /// 分群编码。 + /// + public string SegmentCode { get; init; } = string.Empty; + + /// + /// 分层标签。 + /// + public string Label { get; init; } = string.Empty; + + /// + /// 人数。 + /// + public int Count { get; init; } + + /// + /// 温度(hot/warm/cool/cold)。 + /// + public string Tone { get; init; } = "cold"; +} + +/// +/// RFM 分层行 DTO。 +/// +public sealed class CustomerAnalysisRfmRowDto +{ + /// + /// 行标签。 + /// + public string Label { get; init; } = string.Empty; + + /// + /// 单元格集合。 + /// + public IReadOnlyList Cells { get; init; } = []; +} + +/// +/// 高价值客户 DTO。 +/// +public sealed class CustomerAnalysisTopCustomerDto +{ + /// + /// 排名。 + /// + public int Rank { get; init; } + + /// + /// 客户标识。 + /// + public string CustomerKey { get; init; } = string.Empty; + + /// + /// 客户名称。 + /// + public string Name { get; init; } = string.Empty; + + /// + /// 手机号(脱敏)。 + /// + public string PhoneMasked { get; init; } = string.Empty; + + /// + /// 累计消费。 + /// + public decimal TotalAmount { get; init; } + + /// + /// 下单次数。 + /// + public int OrderCount { get; init; } + + /// + /// 客单价。 + /// + public decimal AverageAmount { get; init; } + + /// + /// 最近下单时间。 + /// + public DateTime LastOrderAt { get; init; } + + /// + /// 客户标签。 + /// + public IReadOnlyList Tags { get; init; } = []; +} + +/// +/// 客户分析总览 DTO。 +/// +public sealed class CustomerAnalysisOverviewDto +{ + /// + /// 统计周期编码。 + /// + public string PeriodCode { get; init; } = "30d"; + + /// + /// 统计周期天数。 + /// + public int PeriodDays { get; init; } = 30; + + /// + /// 客户总数。 + /// + public int TotalCustomers { get; init; } + + /// + /// 周期新增客户数。 + /// + public int NewCustomers { get; init; } + + /// + /// 新增较上一周期增长百分比。 + /// + public decimal GrowthRatePercent { get; init; } + + /// + /// 周期内日均新增客户。 + /// + public decimal NewCustomersDailyAverage { get; init; } + + /// + /// 活跃客户数。 + /// + public int ActiveCustomers { get; init; } + + /// + /// 活跃率(百分比)。 + /// + public decimal ActiveRatePercent { get; init; } + + /// + /// 平均客户价值(累计消费均值)。 + /// + public decimal AverageLifetimeValue { get; init; } + + /// + /// 客户增长趋势。 + /// + public IReadOnlyList GrowthTrend { get; init; } = []; + + /// + /// 新老客占比。 + /// + public IReadOnlyList Composition { get; init; } = []; + + /// + /// 客单价分布。 + /// + public IReadOnlyList AmountDistribution { get; init; } = []; + + /// + /// RFM 分层。 + /// + public IReadOnlyList RfmRows { get; init; } = []; + + /// + /// 高价值客户 Top10。 + /// + public IReadOnlyList TopCustomers { get; init; } = []; +} + +/// +/// 客群明细行 DTO。 +/// +public sealed class CustomerAnalysisSegmentListItemDto +{ + /// + /// 客户标识。 + /// + public string CustomerKey { get; init; } = string.Empty; + + /// + /// 客户名称。 + /// + public string Name { get; init; } = string.Empty; + + /// + /// 手机号(脱敏)。 + /// + public string PhoneMasked { get; init; } = string.Empty; + + /// + /// 头像文案。 + /// + public string AvatarText { get; init; } = string.Empty; + + /// + /// 头像颜色。 + /// + public string AvatarColor { get; init; } = string.Empty; + + /// + /// 客户标签。 + /// + public IReadOnlyList Tags { get; init; } = []; + + /// + /// 是否会员。 + /// + public bool IsMember { get; init; } + + /// + /// 会员等级。 + /// + public string MemberTierName { get; init; } = string.Empty; + + /// + /// 累计消费。 + /// + public decimal TotalAmount { get; init; } + + /// + /// 下单次数。 + /// + public int OrderCount { get; init; } + + /// + /// 客单价。 + /// + public decimal AverageAmount { get; init; } + + /// + /// 注册时间。 + /// + public DateTime RegisteredAt { get; init; } + + /// + /// 最近下单时间。 + /// + public DateTime LastOrderAt { get; init; } + + /// + /// 是否弱化显示。 + /// + public bool IsDimmed { get; init; } +} + +/// +/// 客群明细结果 DTO。 +/// +public sealed class CustomerAnalysisSegmentListResultDto +{ + /// + /// 分群编码。 + /// + public string SegmentCode { get; init; } = string.Empty; + + /// + /// 分群标题。 + /// + public string SegmentTitle { get; init; } = string.Empty; + + /// + /// 分群说明。 + /// + public string SegmentDescription { get; init; } = string.Empty; + + /// + /// 列表项。 + /// + public IReadOnlyList Items { get; init; } = []; + + /// + /// 当前页。 + /// + public int Page { get; init; } + + /// + /// 每页条数。 + /// + public int PageSize { get; init; } + + /// + /// 总记录数。 + /// + public int TotalCount { get; init; } +} + +/// +/// 会员详情 DTO。 +/// +public sealed class CustomerMemberDetailDto +{ + /// + /// 客户标识。 + /// + public string CustomerKey { get; init; } = string.Empty; + + /// + /// 客户名称。 + /// + public string Name { get; init; } = string.Empty; + + /// + /// 手机号(脱敏)。 + /// + public string PhoneMasked { get; init; } = string.Empty; + + /// + /// 来源。 + /// + public string Source { get; init; } = string.Empty; + + /// + /// 注册时间。 + /// + public DateTime RegisteredAt { get; init; } + + /// + /// 最近下单时间。 + /// + public DateTime LastOrderAt { get; init; } + + /// + /// 会员摘要。 + /// + public CustomerMemberSummaryDto Member { get; init; } = new(); + + /// + /// 客户标签。 + /// + public IReadOnlyList Tags { get; init; } = []; + + /// + /// 累计下单次数。 + /// + public int TotalOrders { get; init; } + + /// + /// 累计消费。 + /// + public decimal TotalAmount { get; init; } + + /// + /// 客单价。 + /// + public decimal AverageAmount { get; init; } + + /// + /// 复购率。 + /// + public decimal RepurchaseRatePercent { get; init; } + + /// + /// 最近订单。 + /// + public IReadOnlyList RecentOrders { get; init; } = []; +} diff --git a/src/Application/TakeoutSaaS.Application/App/Customers/Handlers/CustomerAnalysisSegmentSupport.cs b/src/Application/TakeoutSaaS.Application/App/Customers/Handlers/CustomerAnalysisSegmentSupport.cs new file mode 100644 index 0000000..fd09a79 --- /dev/null +++ b/src/Application/TakeoutSaaS.Application/App/Customers/Handlers/CustomerAnalysisSegmentSupport.cs @@ -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 BuildComposition( + IReadOnlyList 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 BuildAmountDistribution( + IReadOnlyList 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 BuildRfmRows( + IReadOnlyList 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(3); + for (var rowIndex = 0; rowIndex < 3; rowIndex += 1) + { + var cells = new List(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 BuildGrowthTrend( + IReadOnlyList 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(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 BuildTopCustomers( + IReadOnlyList 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 FilterBySegment( + IReadOnlyList 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 ApplyKeyword( + IReadOnlyList 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); diff --git a/src/Application/TakeoutSaaS.Application/App/Customers/Handlers/ExportCustomerAnalysisCsvQueryHandler.cs b/src/Application/TakeoutSaaS.Application/App/Customers/Handlers/ExportCustomerAnalysisCsvQueryHandler.cs new file mode 100644 index 0000000..3fe1a64 --- /dev/null +++ b/src/Application/TakeoutSaaS.Application/App/Customers/Handlers/ExportCustomerAnalysisCsvQueryHandler.cs @@ -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; + +/// +/// 客户分析报表导出处理器。 +/// +public sealed class ExportCustomerAnalysisCsvQueryHandler( + IOrderRepository orderRepository, + IDapperExecutor dapperExecutor, + ITenantProvider tenantProvider) + : IRequestHandler +{ + /// + public async Task 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 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("\"", "\"\"")}\""; + } +} diff --git a/src/Application/TakeoutSaaS.Application/App/Customers/Handlers/GetCustomerAnalysisOverviewQueryHandler.cs b/src/Application/TakeoutSaaS.Application/App/Customers/Handlers/GetCustomerAnalysisOverviewQueryHandler.cs new file mode 100644 index 0000000..cbab8bd --- /dev/null +++ b/src/Application/TakeoutSaaS.Application/App/Customers/Handlers/GetCustomerAnalysisOverviewQueryHandler.cs @@ -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; + +/// +/// 客户分析总览查询处理器。 +/// +public sealed class GetCustomerAnalysisOverviewQueryHandler( + IOrderRepository orderRepository, + IDapperExecutor dapperExecutor, + ITenantProvider tenantProvider) + : IRequestHandler +{ + /// + public async Task 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 = [] + }; + } +} diff --git a/src/Application/TakeoutSaaS.Application/App/Customers/Handlers/GetCustomerAnalysisSegmentListQueryHandler.cs b/src/Application/TakeoutSaaS.Application/App/Customers/Handlers/GetCustomerAnalysisSegmentListQueryHandler.cs new file mode 100644 index 0000000..1b06b23 --- /dev/null +++ b/src/Application/TakeoutSaaS.Application/App/Customers/Handlers/GetCustomerAnalysisSegmentListQueryHandler.cs @@ -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; + +/// +/// 客群明细查询处理器。 +/// +public sealed class GetCustomerAnalysisSegmentListQueryHandler( + IOrderRepository orderRepository, + IDapperExecutor dapperExecutor, + ITenantProvider tenantProvider) + : IRequestHandler +{ + /// + public async Task 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 + }; + } +} diff --git a/src/Application/TakeoutSaaS.Application/App/Customers/Handlers/GetCustomerMemberDetailQueryHandler.cs b/src/Application/TakeoutSaaS.Application/App/Customers/Handlers/GetCustomerMemberDetailQueryHandler.cs new file mode 100644 index 0000000..854a14d --- /dev/null +++ b/src/Application/TakeoutSaaS.Application/App/Customers/Handlers/GetCustomerMemberDetailQueryHandler.cs @@ -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; + +/// +/// 会员详情查询处理器。 +/// +public sealed class GetCustomerMemberDetailQueryHandler( + IOrderRepository orderRepository, + IDapperExecutor dapperExecutor, + ITenantProvider tenantProvider) + : IRequestHandler +{ + /// + public async Task 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 + }; + } +} diff --git a/src/Application/TakeoutSaaS.Application/App/Customers/Queries/ExportCustomerAnalysisCsvQuery.cs b/src/Application/TakeoutSaaS.Application/App/Customers/Queries/ExportCustomerAnalysisCsvQuery.cs new file mode 100644 index 0000000..735981f --- /dev/null +++ b/src/Application/TakeoutSaaS.Application/App/Customers/Queries/ExportCustomerAnalysisCsvQuery.cs @@ -0,0 +1,25 @@ +using MediatR; +using TakeoutSaaS.Application.App.Customers.Dto; + +namespace TakeoutSaaS.Application.App.Customers.Queries; + +/// +/// 客户分析报表导出查询。 +/// +public sealed class ExportCustomerAnalysisCsvQuery : IRequest +{ + /// + /// 可见门店 ID 集合。 + /// + public IReadOnlyCollection VisibleStoreIds { get; init; } = []; + + /// + /// 统计周期编码(7d/30d/90d/365d)。 + /// + public string PeriodCode { get; init; } = "30d"; + + /// + /// 统计周期天数。 + /// + public int PeriodDays { get; init; } = 30; +} diff --git a/src/Application/TakeoutSaaS.Application/App/Customers/Queries/GetCustomerAnalysisOverviewQuery.cs b/src/Application/TakeoutSaaS.Application/App/Customers/Queries/GetCustomerAnalysisOverviewQuery.cs new file mode 100644 index 0000000..1b7b9e2 --- /dev/null +++ b/src/Application/TakeoutSaaS.Application/App/Customers/Queries/GetCustomerAnalysisOverviewQuery.cs @@ -0,0 +1,25 @@ +using MediatR; +using TakeoutSaaS.Application.App.Customers.Dto; + +namespace TakeoutSaaS.Application.App.Customers.Queries; + +/// +/// 客户分析总览查询。 +/// +public sealed class GetCustomerAnalysisOverviewQuery : IRequest +{ + /// + /// 可见门店 ID 集合。 + /// + public IReadOnlyCollection VisibleStoreIds { get; init; } = []; + + /// + /// 统计周期编码(7d/30d/90d/365d)。 + /// + public string PeriodCode { get; init; } = "30d"; + + /// + /// 统计周期天数。 + /// + public int PeriodDays { get; init; } = 30; +} diff --git a/src/Application/TakeoutSaaS.Application/App/Customers/Queries/GetCustomerAnalysisSegmentListQuery.cs b/src/Application/TakeoutSaaS.Application/App/Customers/Queries/GetCustomerAnalysisSegmentListQuery.cs new file mode 100644 index 0000000..4cdaad7 --- /dev/null +++ b/src/Application/TakeoutSaaS.Application/App/Customers/Queries/GetCustomerAnalysisSegmentListQuery.cs @@ -0,0 +1,45 @@ +using MediatR; +using TakeoutSaaS.Application.App.Customers.Dto; + +namespace TakeoutSaaS.Application.App.Customers.Queries; + +/// +/// 客群明细查询。 +/// +public sealed class GetCustomerAnalysisSegmentListQuery : IRequest +{ + /// + /// 可见门店 ID 集合。 + /// + public IReadOnlyCollection VisibleStoreIds { get; init; } = []; + + /// + /// 统计周期编码(7d/30d/90d/365d)。 + /// + public string PeriodCode { get; init; } = "30d"; + + /// + /// 统计周期天数。 + /// + public int PeriodDays { get; init; } = 30; + + /// + /// 分群编码。 + /// + public string SegmentCode { get; init; } = "all"; + + /// + /// 关键词(姓名/手机号)。 + /// + public string? Keyword { get; init; } + + /// + /// 页码。 + /// + public int Page { get; init; } = 1; + + /// + /// 每页条数。 + /// + public int PageSize { get; init; } = 10; +} diff --git a/src/Application/TakeoutSaaS.Application/App/Customers/Queries/GetCustomerMemberDetailQuery.cs b/src/Application/TakeoutSaaS.Application/App/Customers/Queries/GetCustomerMemberDetailQuery.cs new file mode 100644 index 0000000..73173f5 --- /dev/null +++ b/src/Application/TakeoutSaaS.Application/App/Customers/Queries/GetCustomerMemberDetailQuery.cs @@ -0,0 +1,20 @@ +using MediatR; +using TakeoutSaaS.Application.App.Customers.Dto; + +namespace TakeoutSaaS.Application.App.Customers.Queries; + +/// +/// 会员详情查询。 +/// +public sealed class GetCustomerMemberDetailQuery : IRequest +{ + /// + /// 可见门店 ID 集合。 + /// + public IReadOnlyCollection VisibleStoreIds { get; init; } = []; + + /// + /// 客户标识(手机号归一化)。 + /// + public string CustomerKey { get; init; } = string.Empty; +}