Files
cunkebao_v3/Server/application/cunkebao/controller/RFMController.php
2025-11-07 15:25:50 +08:00

402 lines
15 KiB
PHP
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

<?php
namespace app\cunkebao\controller;
use think\Db;
use app\store\model\TrafficOrderModel;
use app\common\model\TrafficSource;
use app\store\model\WechatFriendModel;
/**
* RFM 客户价值评分控制器
* 基于 RFM 客户价值评分体系技术实施文档实现
*/
class RFMController extends BaseController
{
// 默认配置参数
const DEFAULT_CYCLE_DAYS = 180; // 默认统计周期(天)
const DEFAULT_WEIGHT_R = 0.4; // R维度权重
const DEFAULT_WEIGHT_F = 0.3; // F维度权重
const DEFAULT_WEIGHT_M = 0.3; // M维度权重
const DEFAULT_ABNORMAL_MONEY_RATIO = 3.0; // 异常金额阈值倍数
const DEFAULT_SCORE_SCALE = 5; // 默认5分制
/**
* 从 traffic_order 表计算客户 RFM 评分
*
* @param string|null $identifier 流量池用户标识
* @param string|null $ownerWechatId 微信ID为空则统计所有数据
* @param array $config 配置参数
* - cycle_days: 统计周期默认180
* - weight_R: R维度权重默认0.4
* - weight_F: F维度权重默认0.3
* - weight_M: M维度权重默认0.3
* - abnormal_money_ratio: 异常金额阈值倍数默认3.0
* - score_scale: 评分分制5或100默认5
* - missing_strategy: 缺失值处理策略('score_1'或'exclude'),默认'score_1'
* @return array
*/
public function calculateRfmFromTrafficOrder($identifier = null, $ownerWechatId = null, $config = [])
{
try {
// 合并配置参数
$cycleDays = isset($config['cycle_days']) ? (int)$config['cycle_days'] : self::DEFAULT_CYCLE_DAYS;
$weightR = isset($config['weight_R']) ? (float)$config['weight_R'] : self::DEFAULT_WEIGHT_R;
$weightF = isset($config['weight_F']) ? (float)$config['weight_F'] : self::DEFAULT_WEIGHT_F;
$weightM = isset($config['weight_M']) ? (float)$config['weight_M'] : self::DEFAULT_WEIGHT_M;
$abnormalMoneyRatio = isset($config['abnormal_money_ratio']) ? (float)$config['abnormal_money_ratio'] : self::DEFAULT_ABNORMAL_MONEY_RATIO;
$scoreScale = isset($config['score_scale']) ? (int)$config['score_scale'] : self::DEFAULT_SCORE_SCALE;
$missingStrategy = isset($config['missing_strategy']) ? $config['missing_strategy'] : 'score_1';
// 权重归一化处理
$weightSum = $weightR + $weightF + $weightM;
if ($weightSum != 1.0) {
$weightR = $weightR / $weightSum;
$weightF = $weightF / $weightSum;
$weightM = $weightM / $weightSum;
}
// 计算时间范围
$endTime = time(); // 统计截止时间(当前时间)
$startTime = $endTime - ($cycleDays * 24 * 3600); // 统计起始时间
// 构建查询条件
$where = [
['isDel', '=', 0],
['createTime', '>=', $startTime],
['createTime', '<', $endTime],
];
// identifier 条件
if (!empty($identifier)) {
$where[] = ['identifier', '=', $identifier];
}
// ownerWechatId 条件
if (!empty($ownerWechatId)) {
$where[] = ['ownerWechatId', '=', $ownerWechatId];
}
// 1. 数据过滤和聚合 - 获取每个客户的R、F、M原始值
$orderModel = new TrafficOrderModel();
$customers = $orderModel
->where($where)
->where(function ($query) {
// 只统计有效订单actualPay大于0
$query->where('actualPay', '>', 0);
})
->field('identifier, MAX(createTime) as lastOrderTime, COUNT(DISTINCT id) as orderCount, SUM(CAST(actualPay AS DECIMAL(18,2))) as totalAmount')
->group('identifier')
->select();
if (empty($customers)) {
return [
'code' => 200,
'msg' => '暂无数据',
'data' => []
];
}
// 2. 计算每个客户的R值最近消费天数
$customerData = [];
foreach ($customers as $customer) {
$recencyDays = floor(($endTime - $customer['lastOrderTime']) / (24 * 3600));
$customerData[] = [
'identifier' => $customer['identifier'],
'R' => $recencyDays,
'F' => (int)$customer['orderCount'],
'M' => (float)$customer['totalAmount'],
];
}
// 3. 异常值处理 - 剔除大额异常订单
$mValues = array_column($customerData, 'M');
if (!empty($mValues)) {
sort($mValues);
$m99Percentile = $this->percentile($mValues, 0.99);
$abnormalThreshold = $m99Percentile * $abnormalMoneyRatio;
// 标记异常客户但不删除仅在计算M维度区间时考虑
foreach ($customerData as &$customer) {
$customer['isAbnormal'] = $customer['M'] > $abnormalThreshold;
}
}
// 4. 使用五分位法计算各维度的区间阈值
$rThresholds = $this->calculatePercentiles(array_column($customerData, 'R'), true); // R是反向的
$fThresholds = $this->calculatePercentiles(array_column($customerData, 'F'), false);
// M维度排除异常值计算区间
$mValuesForPercentile = array_filter(array_column($customerData, 'M'), function($m) use ($abnormalThreshold) {
return isset($abnormalThreshold) ? $m <= $abnormalThreshold : true;
});
$mThresholds = $this->calculatePercentiles(array_values($mValuesForPercentile), false);
// 5. 计算每个客户的RFM分项得分
$results = [];
foreach ($customerData as $customer) {
$rScore = $this->scoreByPercentile($customer['R'], $rThresholds, true); // R是反向的
$fScore = $this->scoreByPercentile($customer['F'], $fThresholds, false);
$mScore = $customer['isAbnormal'] ? 5 : $this->scoreByPercentile($customer['M'], $mThresholds, false); // 异常值给最高分
// 计算RFM总分加权求和
$rfmScore = $rScore * $weightR + $fScore * $weightF + $mScore * $weightM;
// 可选标准化为1-100分
$standardScore = null;
if ($scoreScale == 100) {
$rfmMin = $weightR * 1 + $weightF * 1 + $weightM * 1;
$rfmMax = $weightR * 5 + $weightF * 5 + $weightM * 5;
$standardScore = (int)round(($rfmScore - $rfmMin) / ($rfmMax - $rfmMin) * 99 + 1);
}
$results[] = [
'identifier' => $customer['identifier'],
'R_raw' => $customer['R'],
'R_score' => $rScore,
'F_raw' => $customer['F'],
'F_score' => $fScore,
'M_raw' => round($customer['M'], 2),
'M_score' => $mScore,
'RFM_score' => round($rfmScore, 2),
'RFM_standard_score' => $standardScore,
'cycle_start' => date('Y-m-d H:i:s', $startTime),
'cycle_end' => date('Y-m-d H:i:s', $endTime),
'calculate_time' => date('Y-m-d H:i:s'),
];
}
// 按RFM总分降序排序
usort($results, function($a, $b) {
return $b['RFM_score'] <=> $a['RFM_score'];
});
// 6. 更新 ck_traffic_source 和 s2_wechat_friend 表的RFM值
$this->updateRfmToTables($results, $ownerWechatId);
return [
'code' => 200,
'msg' => '计算成功',
'data' => [
'results' => $results,
'config' => [
'cycle_days' => $cycleDays,
'weight_R' => $weightR,
'weight_F' => $weightF,
'weight_M' => $weightM,
'score_scale' => $scoreScale,
],
'statistics' => [
'total_customers' => count($results),
'avg_rfm_score' => round(array_sum(array_column($results, 'RFM_score')) / count($results), 2),
]
]
];
} catch (\Exception $e) {
return [
'code' => 500,
'msg' => '计算失败:' . $e->getMessage(),
'data' => []
];
}
}
/**
* 计算 RFM 评分(兼容旧方法,使用固定阈值)
* @param int|null $recencyDays 最近购买天数
* @param int $frequency 购买次数
* @param float $monetary 购买金额
* @return array{R:int,F:int,M:int}
*/
public static function calcRfmScores($recencyDays = 30, $frequency, $monetary)
{
$recencyDays = is_numeric($recencyDays) ? (int)$recencyDays : 9999;
$frequency = max(0, (int)$frequency);
$monetary = max(0, (float)$monetary);
return [
'R' => self::scoreR_Default($recencyDays),
'F' => self::scoreF_Default($frequency),
'M' => self::scoreM_Default($monetary),
];
}
/**
* 使用固定阈值计算R得分保留兼容性
*/
protected static function scoreR_Default(int $days): int
{
if ($days <= 30) return 5;
if ($days <= 60) return 4;
if ($days <= 90) return 3;
if ($days <= 120) return 2;
return 1;
}
/**
* 使用固定阈值计算F得分保留兼容性
*/
protected static function scoreF_Default(int $times): int
{
if ($times >= 10) return 5;
if ($times >= 6) return 4;
if ($times >= 3) return 3;
if ($times >= 2) return 2;
if ($times >= 1) return 1;
return 0;
}
/**
* 使用固定阈值计算M得分保留兼容性
*/
protected static function scoreM_Default(float $amount): int
{
if ($amount >= 2000) return 5;
if ($amount >= 1000) return 4;
if ($amount >= 500) return 3;
if ($amount >= 200) return 2;
if ($amount > 0) return 1;
return 0;
}
/**
* 计算百分位数(五分位法)
* @param array $values 数值数组
* @param bool $reverse 是否反向R维度需要反向值越小得分越高
* @return array 返回[0.2, 0.4, 0.6, 0.8]分位数的阈值数组
*/
private function calculatePercentiles($values, $reverse = false)
{
if (empty($values)) {
return [0, 0, 0, 0];
}
// 去重并排序
$uniqueValues = array_unique($values);
sort($uniqueValues);
// 如果所有值相同强制均分5个区间
if (count($uniqueValues) == 1) {
$singleValue = $uniqueValues[0];
if ($reverse) {
return [$singleValue, $singleValue, $singleValue, $singleValue];
} else {
return [$singleValue, $singleValue, $singleValue, $singleValue];
}
}
$percentiles = [0.2, 0.4, 0.6, 0.8];
$thresholds = [];
foreach ($percentiles as $p) {
$thresholds[] = $this->percentile($uniqueValues, $p);
}
return $thresholds;
}
/**
* 计算百分位数
* @param array $sortedArray 已排序的数组
* @param float $percentile 百分位数0-1之间
* @return float
*/
private function percentile($sortedArray, $percentile)
{
if (empty($sortedArray)) {
return 0;
}
$count = count($sortedArray);
$index = ($count - 1) * $percentile;
$floor = floor($index);
$ceil = ceil($index);
if ($floor == $ceil) {
return $sortedArray[(int)$index];
}
$weight = $index - $floor;
return $sortedArray[(int)$floor] * (1 - $weight) + $sortedArray[(int)$ceil] * $weight;
}
/**
* 根据五分位法阈值计算得分
* @param float $value 当前值
* @param array $thresholds 阈值数组[T1, T2, T3, T4]
* @param bool $reverse 是否反向R维度反向值越小得分越高
* @return int 得分1-5
*/
private function scoreByPercentile($value, $thresholds, $reverse = false)
{
if (empty($thresholds) || count($thresholds) < 4) {
return 1;
}
list($t1, $t2, $t3, $t4) = $thresholds;
if ($reverse) {
// R维度值越小得分越高
if ($value <= $t1) return 5;
if ($value <= $t2) return 4;
if ($value <= $t3) return 3;
if ($value <= $t4) return 2;
return 1;
} else {
// F和M维度值越大得分越高
if ($value >= $t4) return 5;
if ($value >= $t3) return 4;
if ($value >= $t2) return 3;
if ($value >= $t1) return 2;
return 1;
}
}
/**
* 更新RFM值到 ck_traffic_source 和 s2_wechat_friend 表
*
* @param array $results RFM计算结果数组
* @param string|null $ownerWechatId 微信ID用于过滤更新范围
*/
private function updateRfmToTables($results, $ownerWechatId = null)
{
try {
foreach ($results as $result) {
$identifier = $result['identifier'];
$rScore = (string)$result['R_score'];
$fScore = (string)$result['F_score'];
$mScore = (string)$result['M_score'];
// 更新 ck_traffic_source 表
// 根据 identifier 更新所有匹配的记录
$trafficSourceUpdate = [
'R' => $rScore,
'F' => $fScore,
'M' => $mScore,
'updateTime' => time()
];
TrafficSource::where('identifier', $identifier)->update($trafficSourceUpdate);
// 更新 s2_wechat_friend 表
// wechatId 对应 identifier
$wechatFriendUpdate = [
'R' => $rScore,
'F' => $fScore,
'M' => $mScore,
'updateTime' => time()
];
$wechatFriendWhere = ['wechatId' => $identifier];
if (!empty($ownerWechatId)) {
$wechatFriendWhere['ownerWechatId'] = $ownerWechatId;
}
WechatFriendModel::where($wechatFriendWhere)->update($wechatFriendUpdate);
}
} catch (\Exception $e) {
// 记录错误但不影响主流程
\think\Log::error('更新RFM值失败' . $e->getMessage());
}
}
}