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