feat(customer): add customer analysis query APIs

This commit is contained in:
2026-03-03 16:45:39 +08:00
parent a993b81aeb
commit 26afffd874
12 changed files with 2540 additions and 0 deletions

View File

@@ -0,0 +1,431 @@
namespace TakeoutSaaS.Application.App.Customers.Dto;
/// <summary>
/// 客户分析增长趋势点 DTO。
/// </summary>
public sealed class CustomerAnalysisTrendPointDto
{
/// <summary>
/// 维度标签。
/// </summary>
public string Label { get; init; } = string.Empty;
/// <summary>
/// 数量值。
/// </summary>
public int Value { get; init; }
}
/// <summary>
/// 客户分析新老客构成项 DTO。
/// </summary>
public sealed class CustomerAnalysisCompositionItemDto
{
/// <summary>
/// 分群编码。
/// </summary>
public string SegmentCode { get; init; } = string.Empty;
/// <summary>
/// 分群名称。
/// </summary>
public string Label { get; init; } = string.Empty;
/// <summary>
/// 分群人数。
/// </summary>
public int Count { get; init; }
/// <summary>
/// 分群占比(百分比)。
/// </summary>
public decimal Percent { get; init; }
/// <summary>
/// 色调blue/green/orange/gray
/// </summary>
public string Tone { get; init; } = "blue";
}
/// <summary>
/// 客单价分布项 DTO。
/// </summary>
public sealed class CustomerAnalysisAmountDistributionItemDto
{
/// <summary>
/// 分群编码。
/// </summary>
public string SegmentCode { get; init; } = string.Empty;
/// <summary>
/// 区间标签。
/// </summary>
public string Label { get; init; } = string.Empty;
/// <summary>
/// 人数。
/// </summary>
public int Count { get; init; }
/// <summary>
/// 占比(百分比)。
/// </summary>
public decimal Percent { get; init; }
}
/// <summary>
/// RFM 分层单元 DTO。
/// </summary>
public sealed class CustomerAnalysisRfmCellDto
{
/// <summary>
/// 分群编码。
/// </summary>
public string SegmentCode { get; init; } = string.Empty;
/// <summary>
/// 分层标签。
/// </summary>
public string Label { get; init; } = string.Empty;
/// <summary>
/// 人数。
/// </summary>
public int Count { get; init; }
/// <summary>
/// 温度hot/warm/cool/cold
/// </summary>
public string Tone { get; init; } = "cold";
}
/// <summary>
/// RFM 分层行 DTO。
/// </summary>
public sealed class CustomerAnalysisRfmRowDto
{
/// <summary>
/// 行标签。
/// </summary>
public string Label { get; init; } = string.Empty;
/// <summary>
/// 单元格集合。
/// </summary>
public IReadOnlyList<CustomerAnalysisRfmCellDto> Cells { get; init; } = [];
}
/// <summary>
/// 高价值客户 DTO。
/// </summary>
public sealed class CustomerAnalysisTopCustomerDto
{
/// <summary>
/// 排名。
/// </summary>
public int Rank { get; init; }
/// <summary>
/// 客户标识。
/// </summary>
public string CustomerKey { get; init; } = string.Empty;
/// <summary>
/// 客户名称。
/// </summary>
public string Name { get; init; } = string.Empty;
/// <summary>
/// 手机号(脱敏)。
/// </summary>
public string PhoneMasked { get; init; } = string.Empty;
/// <summary>
/// 累计消费。
/// </summary>
public decimal TotalAmount { get; init; }
/// <summary>
/// 下单次数。
/// </summary>
public int OrderCount { get; init; }
/// <summary>
/// 客单价。
/// </summary>
public decimal AverageAmount { get; init; }
/// <summary>
/// 最近下单时间。
/// </summary>
public DateTime LastOrderAt { get; init; }
/// <summary>
/// 客户标签。
/// </summary>
public IReadOnlyList<CustomerTagDto> Tags { get; init; } = [];
}
/// <summary>
/// 客户分析总览 DTO。
/// </summary>
public sealed class CustomerAnalysisOverviewDto
{
/// <summary>
/// 统计周期编码。
/// </summary>
public string PeriodCode { get; init; } = "30d";
/// <summary>
/// 统计周期天数。
/// </summary>
public int PeriodDays { get; init; } = 30;
/// <summary>
/// 客户总数。
/// </summary>
public int TotalCustomers { get; init; }
/// <summary>
/// 周期新增客户数。
/// </summary>
public int NewCustomers { get; init; }
/// <summary>
/// 新增较上一周期增长百分比。
/// </summary>
public decimal GrowthRatePercent { get; init; }
/// <summary>
/// 周期内日均新增客户。
/// </summary>
public decimal NewCustomersDailyAverage { get; init; }
/// <summary>
/// 活跃客户数。
/// </summary>
public int ActiveCustomers { get; init; }
/// <summary>
/// 活跃率(百分比)。
/// </summary>
public decimal ActiveRatePercent { get; init; }
/// <summary>
/// 平均客户价值(累计消费均值)。
/// </summary>
public decimal AverageLifetimeValue { get; init; }
/// <summary>
/// 客户增长趋势。
/// </summary>
public IReadOnlyList<CustomerAnalysisTrendPointDto> GrowthTrend { get; init; } = [];
/// <summary>
/// 新老客占比。
/// </summary>
public IReadOnlyList<CustomerAnalysisCompositionItemDto> Composition { get; init; } = [];
/// <summary>
/// 客单价分布。
/// </summary>
public IReadOnlyList<CustomerAnalysisAmountDistributionItemDto> AmountDistribution { get; init; } = [];
/// <summary>
/// RFM 分层。
/// </summary>
public IReadOnlyList<CustomerAnalysisRfmRowDto> RfmRows { get; init; } = [];
/// <summary>
/// 高价值客户 Top10。
/// </summary>
public IReadOnlyList<CustomerAnalysisTopCustomerDto> TopCustomers { get; init; } = [];
}
/// <summary>
/// 客群明细行 DTO。
/// </summary>
public sealed class CustomerAnalysisSegmentListItemDto
{
/// <summary>
/// 客户标识。
/// </summary>
public string CustomerKey { get; init; } = string.Empty;
/// <summary>
/// 客户名称。
/// </summary>
public string Name { get; init; } = string.Empty;
/// <summary>
/// 手机号(脱敏)。
/// </summary>
public string PhoneMasked { get; init; } = string.Empty;
/// <summary>
/// 头像文案。
/// </summary>
public string AvatarText { get; init; } = string.Empty;
/// <summary>
/// 头像颜色。
/// </summary>
public string AvatarColor { get; init; } = string.Empty;
/// <summary>
/// 客户标签。
/// </summary>
public IReadOnlyList<CustomerTagDto> Tags { get; init; } = [];
/// <summary>
/// 是否会员。
/// </summary>
public bool IsMember { get; init; }
/// <summary>
/// 会员等级。
/// </summary>
public string MemberTierName { get; init; } = string.Empty;
/// <summary>
/// 累计消费。
/// </summary>
public decimal TotalAmount { get; init; }
/// <summary>
/// 下单次数。
/// </summary>
public int OrderCount { get; init; }
/// <summary>
/// 客单价。
/// </summary>
public decimal AverageAmount { get; init; }
/// <summary>
/// 注册时间。
/// </summary>
public DateTime RegisteredAt { get; init; }
/// <summary>
/// 最近下单时间。
/// </summary>
public DateTime LastOrderAt { get; init; }
/// <summary>
/// 是否弱化显示。
/// </summary>
public bool IsDimmed { get; init; }
}
/// <summary>
/// 客群明细结果 DTO。
/// </summary>
public sealed class CustomerAnalysisSegmentListResultDto
{
/// <summary>
/// 分群编码。
/// </summary>
public string SegmentCode { get; init; } = string.Empty;
/// <summary>
/// 分群标题。
/// </summary>
public string SegmentTitle { get; init; } = string.Empty;
/// <summary>
/// 分群说明。
/// </summary>
public string SegmentDescription { get; init; } = string.Empty;
/// <summary>
/// 列表项。
/// </summary>
public IReadOnlyList<CustomerAnalysisSegmentListItemDto> Items { get; init; } = [];
/// <summary>
/// 当前页。
/// </summary>
public int Page { get; init; }
/// <summary>
/// 每页条数。
/// </summary>
public int PageSize { get; init; }
/// <summary>
/// 总记录数。
/// </summary>
public int TotalCount { get; init; }
}
/// <summary>
/// 会员详情 DTO。
/// </summary>
public sealed class CustomerMemberDetailDto
{
/// <summary>
/// 客户标识。
/// </summary>
public string CustomerKey { get; init; } = string.Empty;
/// <summary>
/// 客户名称。
/// </summary>
public string Name { get; init; } = string.Empty;
/// <summary>
/// 手机号(脱敏)。
/// </summary>
public string PhoneMasked { get; init; } = string.Empty;
/// <summary>
/// 来源。
/// </summary>
public string Source { get; init; } = string.Empty;
/// <summary>
/// 注册时间。
/// </summary>
public DateTime RegisteredAt { get; init; }
/// <summary>
/// 最近下单时间。
/// </summary>
public DateTime LastOrderAt { get; init; }
/// <summary>
/// 会员摘要。
/// </summary>
public CustomerMemberSummaryDto Member { get; init; } = new();
/// <summary>
/// 客户标签。
/// </summary>
public IReadOnlyList<CustomerTagDto> Tags { get; init; } = [];
/// <summary>
/// 累计下单次数。
/// </summary>
public int TotalOrders { get; init; }
/// <summary>
/// 累计消费。
/// </summary>
public decimal TotalAmount { get; init; }
/// <summary>
/// 客单价。
/// </summary>
public decimal AverageAmount { get; init; }
/// <summary>
/// 复购率。
/// </summary>
public decimal RepurchaseRatePercent { get; init; }
/// <summary>
/// 最近订单。
/// </summary>
public IReadOnlyList<CustomerRecentOrderDto> RecentOrders { get; init; } = [];
}

View File

@@ -0,0 +1,478 @@
using TakeoutSaaS.Application.App.Customers.Dto;
using TakeoutSaaS.Shared.Abstractions.Constants;
using TakeoutSaaS.Shared.Abstractions.Exceptions;
namespace TakeoutSaaS.Application.App.Customers.Handlers;
internal static class CustomerAnalysisSegmentSupport
{
internal const string SegmentAll = "all";
internal const string SegmentRepeatLoyal = "repeat_loyal";
internal const string SegmentActiveNew = "active_new";
internal const string SegmentActiveRecent = "active_recent";
internal const string SegmentDormant = "dormant";
internal const string SegmentChurn = "churn";
internal const string SegmentHighValueTop = "high_value_top";
private static readonly string[] CompositionSegmentOrder =
[
SegmentRepeatLoyal,
SegmentActiveNew,
SegmentDormant,
SegmentChurn
];
private static readonly (string SegmentCode, string Label, string Tone)[] CompositionDefinitions =
[
(SegmentRepeatLoyal, "老客户复购2次+", "blue"),
(SegmentActiveNew, "活跃新客", "green"),
(SegmentDormant, "沉睡客户", "orange"),
(SegmentChurn, "流失客户", "gray")
];
private static readonly AmountDistributionDefinition[] AmountDistributionDefinitions =
[
new("amount_0_30", "0 - 30元", 0m, 30m),
new("amount_30_60", "30 - 60元", 30m, 60m),
new("amount_60_100", "60 - 100元", 60m, 100m),
new("amount_100_150", "100 - 150元", 100m, 150m),
new("amount_150_plus", "150元以上", 150m, null)
];
private static readonly string[] RfmRowLabels =
[
"近期活跃",
"中期沉默",
"长期流失"
];
private static readonly string[] RfmColumnLabels =
[
"高频高额",
"高频低额",
"低频高额",
"低频低额"
];
private static readonly string[,] RfmCellLabels =
{
{ "重要价值", "潜力客户", "新客培育", "一般维护" },
{ "重要挽留", "一般发展", "一般保持", "低优先级" },
{ "重要召回", "即将流失", "基本流失", "已流失" }
};
private static readonly string[,] RfmCellTones =
{
{ "hot", "warm", "warm", "cool" },
{ "warm", "cool", "cool", "cold" },
{ "cool", "cold", "cold", "cold" }
};
internal static string NormalizeSegmentCode(string? segmentCode)
{
var normalized = (segmentCode ?? string.Empty).Trim().ToLowerInvariant();
return string.IsNullOrWhiteSpace(normalized) ? SegmentAll : normalized;
}
internal static SegmentMeta ResolveSegmentMeta(string normalizedSegmentCode)
{
var amountDefinition = ResolveAmountDistributionDefinition(normalizedSegmentCode);
if (amountDefinition is not null)
{
return new SegmentMeta(
normalizedSegmentCode,
$"客单价分布 · {amountDefinition.Label}",
$"筛选客单价位于 {amountDefinition.Label} 区间的客户");
}
if (TryParseRfmSegmentCode(normalizedSegmentCode, out var rowIndex, out var columnIndex))
{
return new SegmentMeta(
normalizedSegmentCode,
$"RFM分层 · {RfmRowLabels[rowIndex]} / {RfmColumnLabels[columnIndex]}",
$"当前客群标签:{RfmCellLabels[rowIndex, columnIndex]}");
}
return normalizedSegmentCode switch
{
SegmentAll => new SegmentMeta(SegmentAll, "全部客户", "当前门店下全部客户明细"),
SegmentRepeatLoyal => new SegmentMeta(SegmentRepeatLoyal, "老客户复购2次+", "非流失/沉睡且非新客的稳定复购客户"),
SegmentActiveNew => new SegmentMeta(SegmentActiveNew, "活跃新客", "统计周期内注册且有消费的客户"),
SegmentActiveRecent => new SegmentMeta(SegmentActiveRecent, "周期活跃客户", "统计周期内发生过消费行为的客户"),
SegmentDormant => new SegmentMeta(SegmentDormant, "沉睡客户", "31-60天未消费的客户"),
SegmentChurn => new SegmentMeta(SegmentChurn, "流失客户", "超过60天未消费的客户"),
SegmentHighValueTop => new SegmentMeta(SegmentHighValueTop, "高价值客户", "累计消费或消费能力达到高价值阈值的客户"),
_ => throw new BusinessException(ErrorCodes.BadRequest, "segmentCode 参数不合法")
};
}
internal static IReadOnlyList<CustomerAnalysisCompositionItemDto> BuildComposition(
IReadOnlyList<CustomerAggregate> customers,
DateTime nowUtc,
int periodDays)
{
if (customers.Count == 0)
{
return CompositionDefinitions
.Select(item => new CustomerAnalysisCompositionItemDto
{
SegmentCode = item.SegmentCode,
Label = item.Label,
Count = 0,
Percent = 0,
Tone = item.Tone
})
.ToList();
}
var counter = CompositionSegmentOrder.ToDictionary(item => item, _ => 0, StringComparer.Ordinal);
foreach (var customer in customers)
{
var segmentCode = ResolveCompositionSegmentCode(customer, nowUtc, periodDays);
counter[segmentCode] += 1;
}
return CompositionDefinitions
.Select(item => new CustomerAnalysisCompositionItemDto
{
SegmentCode = item.SegmentCode,
Label = item.Label,
Count = counter[item.SegmentCode],
Percent = CustomerAnalyticsSupport.ToRatePercent(counter[item.SegmentCode], customers.Count),
Tone = item.Tone
})
.ToList();
}
internal static IReadOnlyList<CustomerAnalysisAmountDistributionItemDto> BuildAmountDistribution(
IReadOnlyList<CustomerAggregate> customers)
{
if (customers.Count == 0)
{
return AmountDistributionDefinitions
.Select(item => new CustomerAnalysisAmountDistributionItemDto
{
SegmentCode = item.SegmentCode,
Label = item.Label,
Count = 0,
Percent = 0
})
.ToList();
}
return AmountDistributionDefinitions
.Select(item =>
{
var count = customers.Count(customer => MatchAmountDistribution(customer.AverageAmount, item));
return new CustomerAnalysisAmountDistributionItemDto
{
SegmentCode = item.SegmentCode,
Label = item.Label,
Count = count,
Percent = CustomerAnalyticsSupport.ToRatePercent(count, customers.Count)
};
})
.ToList();
}
internal static IReadOnlyList<CustomerAnalysisRfmRowDto> BuildRfmRows(
IReadOnlyList<CustomerAggregate> customers,
DateTime nowUtc)
{
var counters = new int[3, 4];
foreach (var customer in customers)
{
var rowIndex = ResolveRfmRecencyRow(customer, nowUtc);
var columnIndex = ResolveRfmFrequencyAmountColumn(customer);
counters[rowIndex, columnIndex] += 1;
}
var rows = new List<CustomerAnalysisRfmRowDto>(3);
for (var rowIndex = 0; rowIndex < 3; rowIndex += 1)
{
var cells = new List<CustomerAnalysisRfmCellDto>(4);
for (var columnIndex = 0; columnIndex < 4; columnIndex += 1)
{
cells.Add(new CustomerAnalysisRfmCellDto
{
SegmentCode = BuildRfmSegmentCode(rowIndex, columnIndex),
Label = RfmCellLabels[rowIndex, columnIndex],
Count = counters[rowIndex, columnIndex],
Tone = RfmCellTones[rowIndex, columnIndex]
});
}
rows.Add(new CustomerAnalysisRfmRowDto
{
Label = RfmRowLabels[rowIndex],
Cells = cells
});
}
return rows;
}
internal static IReadOnlyList<CustomerAnalysisTrendPointDto> BuildGrowthTrend(
IReadOnlyList<CustomerAggregate> customers,
DateTime nowUtc,
int monthCount)
{
var normalizedMonthCount = Math.Clamp(monthCount, 1, 24);
var monthStart = new DateTime(nowUtc.Year, nowUtc.Month, 1, 0, 0, 0, DateTimeKind.Utc);
var windowStart = monthStart.AddMonths(-normalizedMonthCount + 1);
var countLookup = customers
.Where(item => item.RegisteredAt >= windowStart && item.RegisteredAt < monthStart.AddMonths(1))
.GroupBy(item => new DateTime(item.RegisteredAt.Year, item.RegisteredAt.Month, 1, 0, 0, 0, DateTimeKind.Utc))
.ToDictionary(group => group.Key, group => group.Count());
var trend = new List<CustomerAnalysisTrendPointDto>(normalizedMonthCount);
for (var index = 0; index < normalizedMonthCount; index += 1)
{
var currentMonth = windowStart.AddMonths(index);
countLookup.TryGetValue(currentMonth, out var value);
trend.Add(new CustomerAnalysisTrendPointDto
{
Label = $"{currentMonth.Month}月",
Value = value
});
}
return trend;
}
internal static IReadOnlyList<CustomerAnalysisTopCustomerDto> BuildTopCustomers(
IReadOnlyList<CustomerAggregate> customers,
int takeCount)
{
var normalizedTakeCount = Math.Clamp(takeCount, 1, 200);
return customers
.OrderByDescending(item => item.TotalAmount)
.ThenByDescending(item => item.OrderCount)
.ThenByDescending(item => item.LastOrderAt)
.ThenBy(item => item.CustomerKey, StringComparer.Ordinal)
.Take(normalizedTakeCount)
.Select((item, index) => new CustomerAnalysisTopCustomerDto
{
Rank = index + 1,
CustomerKey = item.CustomerKey,
Name = item.Name,
PhoneMasked = item.PhoneMasked,
TotalAmount = item.TotalAmount,
OrderCount = item.OrderCount,
AverageAmount = item.AverageAmount,
LastOrderAt = item.LastOrderAt,
Tags = item.Tags
})
.ToList();
}
internal static IReadOnlyList<CustomerAggregate> FilterBySegment(
IReadOnlyList<CustomerAggregate> customers,
string normalizedSegmentCode,
DateTime nowUtc,
int periodDays)
{
if (normalizedSegmentCode == SegmentAll)
{
return customers;
}
if (TryParseRfmSegmentCode(normalizedSegmentCode, out var rowIndex, out var columnIndex))
{
return customers
.Where(customer =>
ResolveRfmRecencyRow(customer, nowUtc) == rowIndex &&
ResolveRfmFrequencyAmountColumn(customer) == columnIndex)
.ToList();
}
var amountDefinition = ResolveAmountDistributionDefinition(normalizedSegmentCode);
if (amountDefinition is not null)
{
return customers
.Where(customer => MatchAmountDistribution(customer.AverageAmount, amountDefinition))
.ToList();
}
return normalizedSegmentCode switch
{
SegmentRepeatLoyal => customers
.Where(customer => string.Equals(
ResolveCompositionSegmentCode(customer, nowUtc, periodDays),
SegmentRepeatLoyal,
StringComparison.Ordinal))
.ToList(),
SegmentActiveNew => customers
.Where(customer => string.Equals(
ResolveCompositionSegmentCode(customer, nowUtc, periodDays),
SegmentActiveNew,
StringComparison.Ordinal))
.ToList(),
SegmentActiveRecent => customers
.Where(customer => customer.LastOrderAt >= nowUtc.AddDays(-Math.Clamp(periodDays, 7, 365)))
.ToList(),
SegmentDormant => customers
.Where(customer => string.Equals(
ResolveCompositionSegmentCode(customer, nowUtc, periodDays),
SegmentDormant,
StringComparison.Ordinal))
.ToList(),
SegmentChurn => customers
.Where(customer => string.Equals(
ResolveCompositionSegmentCode(customer, nowUtc, periodDays),
SegmentChurn,
StringComparison.Ordinal))
.ToList(),
SegmentHighValueTop => customers
.Where(customer =>
customer.Tags.Any(tag => string.Equals(tag.Code, CustomerAnalyticsSupport.TagHighValue, StringComparison.Ordinal)))
.ToList(),
_ => throw new BusinessException(ErrorCodes.BadRequest, "segmentCode 参数不合法")
};
}
internal static IReadOnlyList<CustomerAggregate> ApplyKeyword(
IReadOnlyList<CustomerAggregate> customers,
string? keyword)
{
var normalizedKeyword = (keyword ?? string.Empty).Trim();
if (string.IsNullOrWhiteSpace(normalizedKeyword))
{
return customers;
}
var keywordDigits = CustomerAnalyticsSupport.NormalizePhone(normalizedKeyword);
return customers
.Where(customer =>
{
var matchedByName = customer.Name.Contains(normalizedKeyword, StringComparison.OrdinalIgnoreCase);
var matchedByPhone = !string.IsNullOrWhiteSpace(keywordDigits) &&
customer.CustomerKey.Contains(keywordDigits, StringComparison.Ordinal);
return matchedByName || matchedByPhone;
})
.ToList();
}
internal static bool TryParseRfmSegmentCode(
string normalizedSegmentCode,
out int rowIndex,
out int columnIndex)
{
rowIndex = -1;
columnIndex = -1;
if (!normalizedSegmentCode.StartsWith("rfm_r", StringComparison.Ordinal) ||
normalizedSegmentCode.Length != 8)
{
return false;
}
var rowChar = normalizedSegmentCode[5];
var separator = normalizedSegmentCode[6];
var columnChar = normalizedSegmentCode[7];
if (separator != 'c' || !char.IsDigit(rowChar) || !char.IsDigit(columnChar))
{
return false;
}
rowIndex = rowChar - '1';
columnIndex = columnChar - '1';
return rowIndex is >= 0 and < 3 && columnIndex is >= 0 and < 4;
}
internal static string BuildRfmSegmentCode(int rowIndex, int columnIndex)
{
return $"rfm_r{rowIndex + 1}c{columnIndex + 1}";
}
private static string ResolveCompositionSegmentCode(
CustomerAggregate customer,
DateTime nowUtc,
int periodDays)
{
var silentDays = (nowUtc.Date - customer.LastOrderAt.Date).TotalDays;
if (silentDays > 60)
{
return SegmentChurn;
}
if (silentDays > 30)
{
return SegmentDormant;
}
if (customer.RegisteredAt >= nowUtc.AddDays(-Math.Clamp(periodDays, 7, 365)))
{
return SegmentActiveNew;
}
return SegmentRepeatLoyal;
}
private static AmountDistributionDefinition? ResolveAmountDistributionDefinition(string normalizedSegmentCode)
{
return AmountDistributionDefinitions.FirstOrDefault(item =>
string.Equals(item.SegmentCode, normalizedSegmentCode, StringComparison.Ordinal));
}
private static bool MatchAmountDistribution(decimal amount, AmountDistributionDefinition definition)
{
if (amount < definition.MinInclusive)
{
return false;
}
if (definition.MaxExclusive.HasValue && amount >= definition.MaxExclusive.Value)
{
return false;
}
return true;
}
private static int ResolveRfmRecencyRow(CustomerAggregate customer, DateTime nowUtc)
{
var silentDays = (nowUtc.Date - customer.LastOrderAt.Date).TotalDays;
if (silentDays <= 30)
{
return 0;
}
if (silentDays <= 60)
{
return 1;
}
return 2;
}
private static int ResolveRfmFrequencyAmountColumn(CustomerAggregate customer)
{
var highFrequency = customer.OrderCount >= 10;
var highAmount = customer.AverageAmount >= 100m;
return (highFrequency, highAmount) switch
{
(true, true) => 0,
(true, false) => 1,
(false, true) => 2,
_ => 3
};
}
private sealed record AmountDistributionDefinition(
string SegmentCode,
string Label,
decimal MinInclusive,
decimal? MaxExclusive);
}
internal sealed record SegmentMeta(
string SegmentCode,
string SegmentTitle,
string SegmentDescription);

View File

@@ -0,0 +1,187 @@
using System.Globalization;
using System.Text;
using MediatR;
using TakeoutSaaS.Application.App.Customers.Dto;
using TakeoutSaaS.Application.App.Customers.Queries;
using TakeoutSaaS.Domain.Orders.Repositories;
using TakeoutSaaS.Shared.Abstractions.Data;
using TakeoutSaaS.Shared.Abstractions.Tenancy;
namespace TakeoutSaaS.Application.App.Customers.Handlers;
/// <summary>
/// 客户分析报表导出处理器。
/// </summary>
public sealed class ExportCustomerAnalysisCsvQueryHandler(
IOrderRepository orderRepository,
IDapperExecutor dapperExecutor,
ITenantProvider tenantProvider)
: IRequestHandler<ExportCustomerAnalysisCsvQuery, CustomerExportDto>
{
/// <inheritdoc />
public async Task<CustomerExportDto> Handle(
ExportCustomerAnalysisCsvQuery request,
CancellationToken cancellationToken)
{
var periodCode = string.IsNullOrWhiteSpace(request.PeriodCode)
? "30d"
: request.PeriodCode.Trim().ToLowerInvariant();
var periodDays = Math.Clamp(request.PeriodDays, 7, 365);
if (request.VisibleStoreIds.Count == 0)
{
return BuildExport(
BuildCsv(periodCode, periodDays, [], DateTime.UtcNow),
0);
}
var customers = await CustomerAnalyticsSupport.LoadCustomersAsync(
orderRepository,
dapperExecutor,
tenantProvider,
request.VisibleStoreIds,
cancellationToken);
return BuildExport(
BuildCsv(periodCode, periodDays, customers, DateTime.UtcNow),
Math.Min(10, customers.Count));
}
private static CustomerExportDto BuildExport(string csv, int totalCount)
{
var bytes = Encoding.UTF8.GetPreamble().Concat(Encoding.UTF8.GetBytes(csv)).ToArray();
return new CustomerExportDto
{
FileName = $"客户分析报表_{DateTime.UtcNow:yyyyMMddHHmmss}.csv",
FileContentBase64 = Convert.ToBase64String(bytes),
TotalCount = totalCount
};
}
private static string BuildCsv(
string periodCode,
int periodDays,
IReadOnlyList<CustomerAggregate> customers,
DateTime nowUtc)
{
var currentStart = nowUtc.AddDays(-periodDays);
var previousStart = currentStart.AddDays(-periodDays);
var totalCustomers = customers.Count;
var newCustomers = customers.Count(item => item.RegisteredAt >= currentStart);
var previousNewCustomers = customers.Count(item =>
item.RegisteredAt >= previousStart &&
item.RegisteredAt < currentStart);
var activeCustomers = customers.Count(item => item.LastOrderAt >= currentStart);
var averageLifetimeValue = totalCustomers <= 0
? 0
: decimal.Round(
customers.Sum(item => item.TotalAmount) / totalCustomers,
2,
MidpointRounding.AwayFromZero);
var composition = CustomerAnalysisSegmentSupport.BuildComposition(customers, nowUtc, periodDays);
var amountDistribution = CustomerAnalysisSegmentSupport.BuildAmountDistribution(customers);
var rfmRows = CustomerAnalysisSegmentSupport.BuildRfmRows(customers, nowUtc);
var topCustomers = CustomerAnalysisSegmentSupport.BuildTopCustomers(customers, 10);
var sb = new StringBuilder();
sb.AppendLine($"统计周期,{Escape(ResolvePeriodLabel(periodCode, periodDays))}");
sb.AppendLine();
sb.AppendLine("核心指标");
sb.AppendLine("指标,值");
sb.AppendLine($"客户总数,{totalCustomers}");
sb.AppendLine($"周期新增,{newCustomers}");
sb.AppendLine($"新增增长率,{CustomerAnalyticsSupport.ToGrowthRatePercent(newCustomers, previousNewCustomers):0.#}%");
sb.AppendLine($"周期活跃客户,{activeCustomers}");
sb.AppendLine($"活跃率,{CustomerAnalyticsSupport.ToRatePercent(activeCustomers, totalCustomers):0.#}%");
sb.AppendLine($"平均客户价值,{averageLifetimeValue.ToString("0.00", CultureInfo.InvariantCulture)}");
sb.AppendLine();
sb.AppendLine("新老客占比");
sb.AppendLine("分层,人数,占比");
foreach (var item in composition)
{
sb.AppendLine($"{Escape(item.Label)},{item.Count},{item.Percent:0.#}%");
}
sb.AppendLine();
sb.AppendLine("客单价分布");
sb.AppendLine("区间,人数,占比");
foreach (var item in amountDistribution)
{
sb.AppendLine($"{Escape(item.Label)},{item.Count},{item.Percent:0.#}%");
}
sb.AppendLine();
sb.AppendLine("RFM客户分层");
sb.AppendLine("活跃度,分群,标签,人数");
for (var rowIndex = 0; rowIndex < rfmRows.Count; rowIndex += 1)
{
var row = rfmRows[rowIndex];
for (var columnIndex = 0; columnIndex < row.Cells.Count; columnIndex += 1)
{
var cell = row.Cells[columnIndex];
sb.AppendLine($"{Escape(row.Label)},{Escape(ResolveRfmColumnLabel(columnIndex))},{Escape(cell.Label)},{cell.Count}");
}
}
sb.AppendLine();
sb.AppendLine("高价值客户TOP10");
sb.AppendLine("排名,客户,手机号,累计消费,下单次数,客单价,最近下单,标签");
foreach (var item in topCustomers)
{
var tags = item.Tags.Count == 0
? string.Empty
: string.Join('、', item.Tags.Select(tag => tag.Label));
sb.AppendLine(string.Join(',',
[
item.Rank.ToString(CultureInfo.InvariantCulture),
Escape(item.Name),
Escape(item.PhoneMasked),
Escape(item.TotalAmount.ToString("0.00", CultureInfo.InvariantCulture)),
Escape(item.OrderCount.ToString(CultureInfo.InvariantCulture)),
Escape(item.AverageAmount.ToString("0.00", CultureInfo.InvariantCulture)),
Escape(item.LastOrderAt.ToString("yyyy-MM-dd", CultureInfo.InvariantCulture)),
Escape(tags)
]));
}
return sb.ToString();
}
private static string ResolvePeriodLabel(string periodCode, int periodDays)
{
return periodCode switch
{
"7d" => "近7天",
"30d" => "近30天",
"90d" => "近90天",
"365d" => "近1年",
_ => $"近{periodDays}天"
};
}
private static string ResolveRfmColumnLabel(int columnIndex)
{
return columnIndex switch
{
0 => "高频高额",
1 => "高频低额",
2 => "低频高额",
_ => "低频低额"
};
}
private static string Escape(string input)
{
if (!input.Contains('"') && !input.Contains(',') && !input.Contains('\n') && !input.Contains('\r'))
{
return input;
}
return $"\"{input.Replace("\"", "\"\"")}\"";
}
}

View File

@@ -0,0 +1,94 @@
using MediatR;
using TakeoutSaaS.Application.App.Customers.Dto;
using TakeoutSaaS.Application.App.Customers.Queries;
using TakeoutSaaS.Domain.Orders.Repositories;
using TakeoutSaaS.Shared.Abstractions.Data;
using TakeoutSaaS.Shared.Abstractions.Tenancy;
namespace TakeoutSaaS.Application.App.Customers.Handlers;
/// <summary>
/// 客户分析总览查询处理器。
/// </summary>
public sealed class GetCustomerAnalysisOverviewQueryHandler(
IOrderRepository orderRepository,
IDapperExecutor dapperExecutor,
ITenantProvider tenantProvider)
: IRequestHandler<GetCustomerAnalysisOverviewQuery, CustomerAnalysisOverviewDto>
{
/// <inheritdoc />
public async Task<CustomerAnalysisOverviewDto> Handle(
GetCustomerAnalysisOverviewQuery request,
CancellationToken cancellationToken)
{
var periodDays = Math.Clamp(request.PeriodDays, 7, 365);
var periodCode = string.IsNullOrWhiteSpace(request.PeriodCode)
? "30d"
: request.PeriodCode.Trim().ToLowerInvariant();
if (request.VisibleStoreIds.Count == 0)
{
return BuildEmptyOverview(periodCode, periodDays);
}
var customers = await CustomerAnalyticsSupport.LoadCustomersAsync(
orderRepository,
dapperExecutor,
tenantProvider,
request.VisibleStoreIds,
cancellationToken);
var nowUtc = DateTime.UtcNow;
var currentStart = nowUtc.AddDays(-periodDays);
var previousStart = currentStart.AddDays(-periodDays);
var totalCustomers = customers.Count;
var newCustomers = customers.Count(item => item.RegisteredAt >= currentStart);
var previousNewCustomers = customers.Count(item =>
item.RegisteredAt >= previousStart &&
item.RegisteredAt < currentStart);
var activeCustomers = customers.Count(item => item.LastOrderAt >= currentStart);
var averageLifetimeValue = totalCustomers <= 0
? 0
: decimal.Round(
customers.Sum(item => item.TotalAmount) / totalCustomers,
2,
MidpointRounding.AwayFromZero);
return new CustomerAnalysisOverviewDto
{
PeriodCode = periodCode,
PeriodDays = periodDays,
TotalCustomers = totalCustomers,
NewCustomers = newCustomers,
GrowthRatePercent = CustomerAnalyticsSupport.ToGrowthRatePercent(newCustomers, previousNewCustomers),
NewCustomersDailyAverage = decimal.Round(
newCustomers / Math.Max(1m, periodDays),
1,
MidpointRounding.AwayFromZero),
ActiveCustomers = activeCustomers,
ActiveRatePercent = CustomerAnalyticsSupport.ToRatePercent(activeCustomers, totalCustomers),
AverageLifetimeValue = averageLifetimeValue,
GrowthTrend = CustomerAnalysisSegmentSupport.BuildGrowthTrend(customers, nowUtc, 12),
Composition = CustomerAnalysisSegmentSupport.BuildComposition(customers, nowUtc, periodDays),
AmountDistribution = CustomerAnalysisSegmentSupport.BuildAmountDistribution(customers),
RfmRows = CustomerAnalysisSegmentSupport.BuildRfmRows(customers, nowUtc),
TopCustomers = CustomerAnalysisSegmentSupport.BuildTopCustomers(customers, 10)
};
}
private static CustomerAnalysisOverviewDto BuildEmptyOverview(string periodCode, int periodDays)
{
return new CustomerAnalysisOverviewDto
{
PeriodCode = periodCode,
PeriodDays = periodDays,
GrowthTrend = CustomerAnalysisSegmentSupport.BuildGrowthTrend([], DateTime.UtcNow, 12),
Composition = CustomerAnalysisSegmentSupport.BuildComposition([], DateTime.UtcNow, periodDays),
AmountDistribution = CustomerAnalysisSegmentSupport.BuildAmountDistribution([]),
RfmRows = CustomerAnalysisSegmentSupport.BuildRfmRows([], DateTime.UtcNow),
TopCustomers = []
};
}
}

View File

@@ -0,0 +1,102 @@
using MediatR;
using TakeoutSaaS.Application.App.Customers.Dto;
using TakeoutSaaS.Application.App.Customers.Queries;
using TakeoutSaaS.Domain.Orders.Repositories;
using TakeoutSaaS.Shared.Abstractions.Data;
using TakeoutSaaS.Shared.Abstractions.Tenancy;
namespace TakeoutSaaS.Application.App.Customers.Handlers;
/// <summary>
/// 客群明细查询处理器。
/// </summary>
public sealed class GetCustomerAnalysisSegmentListQueryHandler(
IOrderRepository orderRepository,
IDapperExecutor dapperExecutor,
ITenantProvider tenantProvider)
: IRequestHandler<GetCustomerAnalysisSegmentListQuery, CustomerAnalysisSegmentListResultDto>
{
/// <inheritdoc />
public async Task<CustomerAnalysisSegmentListResultDto> Handle(
GetCustomerAnalysisSegmentListQuery request,
CancellationToken cancellationToken)
{
var page = Math.Max(1, request.Page);
var pageSize = Math.Clamp(request.PageSize, 1, 200);
var periodDays = Math.Clamp(request.PeriodDays, 7, 365);
var normalizedSegmentCode = CustomerAnalysisSegmentSupport.NormalizeSegmentCode(request.SegmentCode);
var segmentMeta = CustomerAnalysisSegmentSupport.ResolveSegmentMeta(normalizedSegmentCode);
if (request.VisibleStoreIds.Count == 0)
{
return new CustomerAnalysisSegmentListResultDto
{
SegmentCode = segmentMeta.SegmentCode,
SegmentTitle = segmentMeta.SegmentTitle,
SegmentDescription = segmentMeta.SegmentDescription,
Page = page,
PageSize = pageSize,
TotalCount = 0,
Items = []
};
}
var customers = await CustomerAnalyticsSupport.LoadCustomersAsync(
orderRepository,
dapperExecutor,
tenantProvider,
request.VisibleStoreIds,
cancellationToken);
var nowUtc = DateTime.UtcNow;
var segmentCustomers = CustomerAnalysisSegmentSupport.FilterBySegment(
customers,
normalizedSegmentCode,
nowUtc,
periodDays);
var keywordFiltered = CustomerAnalysisSegmentSupport.ApplyKeyword(
segmentCustomers,
request.Keyword);
var sortedCustomers = keywordFiltered
.OrderByDescending(item => item.TotalAmount)
.ThenByDescending(item => item.OrderCount)
.ThenByDescending(item => item.LastOrderAt)
.ThenBy(item => item.CustomerKey, StringComparer.Ordinal)
.ToList();
var pagedItems = sortedCustomers
.Skip((page - 1) * pageSize)
.Take(pageSize)
.Select(item => new CustomerAnalysisSegmentListItemDto
{
CustomerKey = item.CustomerKey,
Name = item.Name,
PhoneMasked = item.PhoneMasked,
AvatarText = item.AvatarText,
AvatarColor = item.AvatarColor,
Tags = item.Tags,
IsMember = item.Member.IsMember,
MemberTierName = item.Member.TierName,
TotalAmount = item.TotalAmount,
OrderCount = item.OrderCount,
AverageAmount = item.AverageAmount,
RegisteredAt = item.RegisteredAt,
LastOrderAt = item.LastOrderAt,
IsDimmed = item.IsDimmed
})
.ToList();
return new CustomerAnalysisSegmentListResultDto
{
SegmentCode = segmentMeta.SegmentCode,
SegmentTitle = segmentMeta.SegmentTitle,
SegmentDescription = segmentMeta.SegmentDescription,
Items = pagedItems,
Page = page,
PageSize = pageSize,
TotalCount = sortedCustomers.Count
};
}
}

View File

@@ -0,0 +1,77 @@
using MediatR;
using TakeoutSaaS.Application.App.Customers.Dto;
using TakeoutSaaS.Application.App.Customers.Queries;
using TakeoutSaaS.Domain.Orders.Repositories;
using TakeoutSaaS.Shared.Abstractions.Data;
using TakeoutSaaS.Shared.Abstractions.Tenancy;
namespace TakeoutSaaS.Application.App.Customers.Handlers;
/// <summary>
/// 会员详情查询处理器。
/// </summary>
public sealed class GetCustomerMemberDetailQueryHandler(
IOrderRepository orderRepository,
IDapperExecutor dapperExecutor,
ITenantProvider tenantProvider)
: IRequestHandler<GetCustomerMemberDetailQuery, CustomerMemberDetailDto?>
{
/// <inheritdoc />
public async Task<CustomerMemberDetailDto?> Handle(
GetCustomerMemberDetailQuery request,
CancellationToken cancellationToken)
{
var customerKey = CustomerAnalyticsSupport.NormalizePhone(request.CustomerKey);
if (request.VisibleStoreIds.Count == 0 || string.IsNullOrWhiteSpace(customerKey))
{
return null;
}
var customers = await CustomerAnalyticsSupport.LoadCustomersAsync(
orderRepository,
dapperExecutor,
tenantProvider,
request.VisibleStoreIds,
cancellationToken);
var customer = customers.FirstOrDefault(item =>
string.Equals(item.CustomerKey, customerKey, StringComparison.Ordinal));
if (customer is null)
{
return null;
}
var tenantId = tenantProvider.GetCurrentTenantId();
if (tenantId <= 0)
{
return null;
}
var orderIds = customer.Orders
.Select(item => item.OrderId)
.ToList();
var itemLookup = await orderRepository.GetItemsByOrderIdsAsync(orderIds, tenantId, cancellationToken);
var recentOrders = CustomerAnalyticsSupport.BuildRecentOrders(customer.Orders, itemLookup, 5);
var repurchaseRatePercent = CustomerAnalyticsSupport.ToRatePercent(
Math.Max(0, customer.OrderCount - 1),
customer.OrderCount);
return new CustomerMemberDetailDto
{
CustomerKey = customer.CustomerKey,
Name = customer.Name,
PhoneMasked = customer.PhoneMasked,
Source = customer.Source,
RegisteredAt = customer.RegisteredAt,
LastOrderAt = customer.LastOrderAt,
Member = customer.Member,
Tags = customer.Tags,
TotalOrders = customer.OrderCount,
TotalAmount = customer.TotalAmount,
AverageAmount = customer.AverageAmount,
RepurchaseRatePercent = repurchaseRatePercent,
RecentOrders = recentOrders
};
}
}

View File

@@ -0,0 +1,25 @@
using MediatR;
using TakeoutSaaS.Application.App.Customers.Dto;
namespace TakeoutSaaS.Application.App.Customers.Queries;
/// <summary>
/// 客户分析报表导出查询。
/// </summary>
public sealed class ExportCustomerAnalysisCsvQuery : IRequest<CustomerExportDto>
{
/// <summary>
/// 可见门店 ID 集合。
/// </summary>
public IReadOnlyCollection<long> VisibleStoreIds { get; init; } = [];
/// <summary>
/// 统计周期编码7d/30d/90d/365d
/// </summary>
public string PeriodCode { get; init; } = "30d";
/// <summary>
/// 统计周期天数。
/// </summary>
public int PeriodDays { get; init; } = 30;
}

View File

@@ -0,0 +1,25 @@
using MediatR;
using TakeoutSaaS.Application.App.Customers.Dto;
namespace TakeoutSaaS.Application.App.Customers.Queries;
/// <summary>
/// 客户分析总览查询。
/// </summary>
public sealed class GetCustomerAnalysisOverviewQuery : IRequest<CustomerAnalysisOverviewDto>
{
/// <summary>
/// 可见门店 ID 集合。
/// </summary>
public IReadOnlyCollection<long> VisibleStoreIds { get; init; } = [];
/// <summary>
/// 统计周期编码7d/30d/90d/365d
/// </summary>
public string PeriodCode { get; init; } = "30d";
/// <summary>
/// 统计周期天数。
/// </summary>
public int PeriodDays { get; init; } = 30;
}

View File

@@ -0,0 +1,45 @@
using MediatR;
using TakeoutSaaS.Application.App.Customers.Dto;
namespace TakeoutSaaS.Application.App.Customers.Queries;
/// <summary>
/// 客群明细查询。
/// </summary>
public sealed class GetCustomerAnalysisSegmentListQuery : IRequest<CustomerAnalysisSegmentListResultDto>
{
/// <summary>
/// 可见门店 ID 集合。
/// </summary>
public IReadOnlyCollection<long> VisibleStoreIds { get; init; } = [];
/// <summary>
/// 统计周期编码7d/30d/90d/365d
/// </summary>
public string PeriodCode { get; init; } = "30d";
/// <summary>
/// 统计周期天数。
/// </summary>
public int PeriodDays { get; init; } = 30;
/// <summary>
/// 分群编码。
/// </summary>
public string SegmentCode { get; init; } = "all";
/// <summary>
/// 关键词(姓名/手机号)。
/// </summary>
public string? Keyword { get; init; }
/// <summary>
/// 页码。
/// </summary>
public int Page { get; init; } = 1;
/// <summary>
/// 每页条数。
/// </summary>
public int PageSize { get; init; } = 10;
}

View File

@@ -0,0 +1,20 @@
using MediatR;
using TakeoutSaaS.Application.App.Customers.Dto;
namespace TakeoutSaaS.Application.App.Customers.Queries;
/// <summary>
/// 会员详情查询。
/// </summary>
public sealed class GetCustomerMemberDetailQuery : IRequest<CustomerMemberDetailDto?>
{
/// <summary>
/// 可见门店 ID 集合。
/// </summary>
public IReadOnlyCollection<long> VisibleStoreIds { get; init; } = [];
/// <summary>
/// 客户标识(手机号归一化)。
/// </summary>
public string CustomerKey { get; init; } = string.Empty;
}