Files
cunkebao_v3/Server/application/cunkebao/controller/RFMController.php

402 lines
15 KiB
PHP
Raw Normal View History

2025-10-14 17:03:05 +08:00
<?php
namespace app\cunkebao\controller;
2025-11-07 15:25:50 +08:00
use think\Db;
use app\store\model\TrafficOrderModel;
use app\common\model\TrafficSource;
use app\store\model\WechatFriendModel;
/**
* RFM 客户价值评分控制器
* 基于 RFM 客户价值评分体系技术实施文档实现
*/
2025-10-14 17:03:05 +08:00
class RFMController extends BaseController
{
2025-11-07 15:25:50 +08:00
// 默认配置参数
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' => []
];
}
}
2025-10-14 17:03:05 +08:00
/**
2025-11-07 15:25:50 +08:00
* 计算 RFM 评分(兼容旧方法,使用固定阈值)
2025-10-14 17:03:05 +08:00
* @param int|null $recencyDays 最近购买天数
* @param int $frequency 购买次数
* @param float $monetary 购买金额
* @return array{R:int,F:int,M:int}
*/
2025-10-20 14:22:20 +08:00
public static function calcRfmScores($recencyDays = 30, $frequency, $monetary)
2025-10-14 17:03:05 +08:00
{
$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),
];
}
2025-11-07 15:25:50 +08:00
/**
* 使用固定阈值计算R得分保留兼容性
*/
2025-10-14 17:03:05 +08:00
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;
}
2025-11-07 15:25:50 +08:00
/**
* 使用固定阈值计算F得分保留兼容性
*/
2025-10-14 17:03:05 +08:00
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;
}
2025-11-07 15:25:50 +08:00
/**
* 使用固定阈值计算M得分保留兼容性
*/
2025-10-14 17:03:05 +08:00
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;
}
2025-11-07 15:25:50 +08:00
/**
* 计算百分位数(五分位法)
* @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());
}
}
2025-10-14 17:03:05 +08:00
}