toArray(); if ($withCount) { foreach ($groups as &$group) { if ($group['ruleType'] == TrafficPoolGroup::RULE_TYPE_DYNAMIC) { // 动态规则分组,实时计算成员数量 $group['memberCount'] = $this->countGroupMembers($group['id'], $companyId); } // 手动分组使用缓存的 memberCount // 计算分组的 RFM 平均值 $rfmStats = $this->getGroupRfmStats($group['id'], $companyId); $group['avgRfmR'] = $rfmStats['avgR']; $group['avgRfmF'] = $rfmStats['avgF']; $group['avgRfmM'] = $rfmStats['avgM']; $group['avgRfmScore'] = $rfmStats['avgScore']; } } return $groups; } /** * 获取分组详情 * * @param int $groupId 分组ID * @param int $companyId 公司ID * @return array|null */ public function getGroupDetail(int $groupId, int $companyId) { $group = TrafficPoolGroup::where('id', $groupId) ->whereIn('companyId', [0, $companyId]) ->where('isDel', 0) ->find(); if (!$group) { return null; } $data = $group->toArray(); // 计算成员数量 $data['memberCount'] = $this->countGroupMembers($groupId, $companyId); // 计算 RFM 统计 $rfmStats = $this->getGroupRfmStats($groupId, $companyId); $data['rfmStats'] = $rfmStats; return $data; } /** * 创建分组 * * @param int $companyId 公司ID * @param array $data 分组数据 * @param int $userId 创建用户ID * @return TrafficPoolGroup */ public function createGroup(int $companyId, array $data, int $userId = null) { // 生成分组编码 $groupCode = $data['groupCode'] ?? 'custom_' . uniqid(); // 检查编码是否已存在 $existGroup = TrafficPoolGroup::where('companyId', $companyId) ->where('groupCode', $groupCode) ->where('isDel', 0) ->find(); if ($existGroup) { throw new \Exception('分组编码已存在'); } $group = TrafficPoolGroup::create([ 'companyId' => $companyId, 'groupCode' => $groupCode, 'groupName' => $data['groupName'], 'groupIcon' => $data['groupIcon'] ?? null, 'groupColor' => $data['groupColor'] ?? null, 'description' => $data['description'] ?? null, 'isSystem' => 0, 'isDefault' => $data['isDefault'] ?? 0, 'ruleType' => $data['ruleType'] ?? TrafficPoolGroup::RULE_TYPE_DYNAMIC, 'ruleConfig' => $data['ruleConfig'] ?? null, 'sort' => $data['sort'] ?? 100, 'status' => TrafficPoolGroup::STATUS_ENABLED, 'userId' => $userId, 'createTime' => time() ]); return $group; } /** * 更新分组 * * @param int $groupId 分组ID * @param int $companyId 公司ID * @param array $data 更新数据 * @return bool */ public function updateGroup(int $groupId, int $companyId, array $data) { $group = TrafficPoolGroup::where('id', $groupId) ->where('companyId', $companyId) ->where('isDel', 0) ->find(); if (!$group) { throw new \Exception('分组不存在'); } if ($group->isSystem) { throw new \Exception('系统分组不允许修改'); } $allowFields = [ 'groupName', 'groupIcon', 'groupColor', 'description', 'isDefault', 'ruleType', 'ruleConfig', 'sort', 'status' ]; $updateData = array_intersect_key($data, array_flip($allowFields)); $updateData['updateTime'] = time(); return $group->save($updateData); } /** * 删除分组 * * @param int $groupId 分组ID * @param int $companyId 公司ID * @return bool */ public function deleteGroup(int $groupId, int $companyId) { $group = TrafficPoolGroup::where('id', $groupId) ->where('companyId', $companyId) ->where('isDel', 0) ->find(); if (!$group) { throw new \Exception('分组不存在'); } if ($group->isSystem) { throw new \Exception('系统分组不允许删除'); } Db::startTrans(); try { // 删除分组 $group->save([ 'isDel' => 1, 'deleteTime' => time() ]); // 删除分组成员 TrafficPoolGroupMember::where('groupId', $groupId) ->where('isDel', 0) ->update([ 'isDel' => 1, 'deleteTime' => time() ]); Db::commit(); return true; } catch (\Exception $e) { Db::rollback(); throw $e; } } /** * 获取分组成员列表 * * @param int $groupId 分组ID * @param int $companyId 公司ID * @param int $page 页码 * @param int $pageSize 每页数量 * @param array $filters 筛选条件 * @return array */ public function getGroupMembers(int $groupId, int $companyId, int $page = 1, int $pageSize = 10, array $filters = []) { $group = TrafficPoolGroup::where('id', $groupId) ->whereIn('companyId', [0, $companyId]) ->where('isDel', 0) ->find(); if (!$group) { throw new \Exception('分组不存在'); } if ($group->ruleType == TrafficPoolGroup::RULE_TYPE_MANUAL) { // 手动分组,从成员表查询 return $this->getManualGroupMembers($groupId, $companyId, $page, $pageSize, $filters); } else { // 动态规则分组,根据规则查询 return $this->getDynamicGroupMembers($group, $companyId, $page, $pageSize, $filters); } } /** * 获取手动分组成员 */ protected function getManualGroupMembers(int $groupId, int $companyId, int $page, int $pageSize, array $filters) { $query = TrafficPoolGroupMember::alias('tpgm') ->join('ck_traffic_pool_company tpc', 'tpc.id = tpgm.poolCompanyId', 'LEFT') ->join('ck_traffic_pool tp', 'tp.id = tpc.poolId', 'LEFT') ->where('tpgm.groupId', $groupId) ->where('tpgm.companyId', $companyId) ->where('tpgm.isDel', 0) ->where('tpc.isDel', 0); // 应用关键字筛选 if (!empty($filters['keyword'])) { $keyword = $filters['keyword']; $query->where(function($q) use ($keyword) { $q->where('tp.nickname', 'like', "%{$keyword}%") ->whereOr('tp.wechatId', 'like', "%{$keyword}%") ->whereOr('tpc.realName', 'like', "%{$keyword}%") ->whereOr('tpc.phone', 'like', "%{$keyword}%"); }); } $total = $query->count(); $list = $query->field([ 'tpc.id', 'tpc.poolId', 'tpc.identifier', 'tpc.companyId', 'tpc.friendStatus', 'tpc.level', 'tpc.intentionLevel', 'tpc.lastInteractTime', 'tpc.rfmF', 'tpc.rfmM', 'tpc.totalMsgCount', 'tpc.totalOrderAmount', 'tpc.lastMsgTime', 'tpc.firstSourceType', 'tpc.firstSourceTime', 'tpc.lifecycle', 'tpc.createTime', 'tpc.realName', 'tpc.phone', 'tp.nickname', 'tp.avatar', 'tp.wechatId', 'tp.wechatAlias', 'tp.gender', 'tp.region', 'tp.country', 'tp.province', 'tp.city', 'tpgm.createTime as addTime' ]) ->order('tpgm.createTime DESC') ->page($page, $pageSize) ->select(); return $this->formatMemberList($list, $total, $page, $pageSize); } /** * 获取动态规则分组成员 */ protected function getDynamicGroupMembers(TrafficPoolGroup $group, int $companyId, int $page, int $pageSize, array $filters) { $query = TrafficPoolCompany::alias('tpc') ->join('ck_traffic_pool tp', 'tp.id = tpc.poolId', 'LEFT') ->where('tpc.companyId', $companyId) ->where('tpc.isDel', 0); // 解析并应用规则 $ruleConfig = $group->ruleConfig; if (!empty($ruleConfig)) { $this->applyRuleConditions($query, $ruleConfig, $companyId); } // 应用额外筛选 if (!empty($filters['keyword'])) { $keyword = $filters['keyword']; $query->where(function($q) use ($keyword) { $q->where('tp.nickname', 'like', "%{$keyword}%") ->whereOr('tp.wechatId', 'like', "%{$keyword}%") ->whereOr('tpc.realName', 'like', "%{$keyword}%") ->whereOr('tpc.phone', 'like', "%{$keyword}%"); }); } $total = $query->count(); $list = $query->field([ 'tpc.id', 'tpc.poolId', 'tpc.identifier', 'tpc.companyId', 'tpc.friendStatus', 'tpc.level', 'tpc.intentionLevel', 'tpc.lastInteractTime', 'tpc.rfmF', 'tpc.rfmM', 'tpc.totalMsgCount', 'tpc.totalOrderAmount', 'tpc.lastMsgTime', 'tpc.firstSourceType', 'tpc.firstSourceTime', 'tpc.lifecycle', 'tpc.createTime', 'tpc.realName', 'tpc.phone', 'tpc.createTime as addTime', 'tp.nickname', 'tp.avatar', 'tp.wechatId', 'tp.wechatAlias', 'tp.gender', 'tp.region', 'tp.country', 'tp.province', 'tp.city' ]) ->order('tpc.id DESC') ->page($page, $pageSize) ->select(); return $this->formatMemberList($list, $total, $page, $pageSize); } /** * 应用规则条件到查询 */ protected function applyRuleConditions($query, array $ruleConfig, int $companyId) { if (empty($ruleConfig['conditions'])) { return; } $logic = strtoupper($ruleConfig['logic'] ?? 'AND'); $conditions = $ruleConfig['conditions']; if ($logic === 'AND') { foreach ($conditions as $condition) { $this->applyCondition($query, $condition, $companyId, 'AND'); } } else { $query->where(function($q) use ($conditions, $companyId) { foreach ($conditions as $condition) { $this->applyCondition($q, $condition, $companyId, 'OR'); } }); } } /** * 应用单个条件 */ protected function applyCondition($query, array $condition, int $companyId, string $logic = 'AND') { $method = $logic === 'OR' ? 'whereOr' : 'where'; if ($condition['type'] === 'group') { // 嵌套分组 $subLogic = strtoupper($condition['logic'] ?? 'AND'); $subConditions = $condition['conditions'] ?? []; $query->$method(function($q) use ($subConditions, $companyId, $subLogic) { foreach ($subConditions as $subCond) { $subMethod = $subLogic === 'OR' ? 'whereOr' : 'where'; $this->applyCondition($q, $subCond, $companyId, $subLogic); } }); } elseif ($condition['type'] === 'field') { // 字段条件 - 根据字段所属表使用正确的别名 $fieldName = $condition['field']; $operator = $condition['operator']; $value = $condition['value']; // 特殊处理:keyword 字段用于多字段搜索 if ($fieldName === 'keyword') { $keyword = $value; $query->$method(function($q) use ($keyword) { $q->where('tp.nickname', 'like', "%{$keyword}%") ->whereOr('tp.wechatId', 'like', "%{$keyword}%") ->whereOr('tp.wechatAlias', 'like', "%{$keyword}%") ->whereOr('tpc.realName', 'like', "%{$keyword}%") ->whereOr('tpc.phone', 'like', "%{$keyword}%"); }); return; } // 特殊处理:friendIds 字段用于指定好友ID列表 if ($fieldName === 'friendIds') { if (is_array($value) && !empty($value)) { $query->$method('tpc.id', 'in', $value); } return; } // ck_traffic_pool 表的字段(基础用户信息) $tpFields = ['nickname', 'avatar', 'wechatId', 'wechatAlias', 'gender', 'region', 'country', 'province', 'city', 'signature']; // 判断字段属于哪个表 if (in_array($fieldName, $tpFields)) { $field = 'tp.' . $fieldName; } else { // ck_traffic_pool_company 表的字段(公司维度信息) $field = 'tpc.' . $fieldName; } // 特殊处理:地区字段(province) // 前端可能传递 "广东" 或 "广东 广州市" if ($fieldName === 'province' && strpos($value, ' ') !== false) { // 包含空格,说明是 "省份 城市" 格式 $parts = explode(' ', $value, 2); $provinceName = trim($parts[0]); $cityName = trim($parts[1]); $query->$method(function($q) use ($provinceName, $cityName) { $q->where('tp.province', '=', $provinceName) ->where('tp.city', 'like', "%{$cityName}%"); }); return; } switch ($operator) { case '=': case '!=': case '>': case '<': case '>=': case '<=': $query->$method($field, $operator, $value); break; case 'in': $query->$method($field, 'in', $value); break; case 'not_in': $query->$method($field, 'not in', $value); break; case 'between': $query->$method($field, 'between', $value); break; case 'like': $query->$method($field, 'like', "%{$value}%"); break; } } elseif ($condition['type'] === 'tag') { // 标签条件 $tagNames = $condition['value']; $operator = $condition['operator']; if ($operator === 'contains') { $query->$method(function($q) use ($tagNames, $companyId) { $q->whereExists(function($subQuery) use ($tagNames, $companyId) { $subQuery->table('ck_traffic_pool_tag') ->where('ck_traffic_pool_tag.poolCompanyId = tpc.id') ->where('ck_traffic_pool_tag.companyId', $companyId) ->where('ck_traffic_pool_tag.tagName', 'in', $tagNames) ->where('ck_traffic_pool_tag.isDel', 0); }); }); } elseif ($operator === 'not_contains') { $query->$method(function($q) use ($tagNames, $companyId) { $q->whereNotExists(function($subQuery) use ($tagNames, $companyId) { $subQuery->table('ck_traffic_pool_tag') ->where('ck_traffic_pool_tag.poolCompanyId = tpc.id') ->where('ck_traffic_pool_tag.companyId', $companyId) ->where('ck_traffic_pool_tag.tagName', 'in', $tagNames) ->where('ck_traffic_pool_tag.isDel', 0); }); }); } } } /** * 格式化成员列表 */ protected function formatMemberList($list, int $total, int $page, int $pageSize) { $result = []; $poolCompanyIds = []; // 收集所有的poolCompanyId foreach ($list as $item) { $poolCompanyIds[] = $item['id']; } // 批量查询标签 $tagsMap = []; if (!empty($poolCompanyIds)) { $tags = \think\Db::table('ck_traffic_pool_tag') ->alias('tpt') ->join('ck_traffic_pool_tag_define tptd', 'tpt.tagDefineId = tptd.id', 'LEFT') ->where('tpt.poolCompanyId', 'in', $poolCompanyIds) ->where('tpt.isDel', 0) ->where('tptd.isDel', 0) ->field('tpt.poolCompanyId, tptd.tagName, tptd.tagType') ->select(); foreach ($tags as $tag) { $poolCompanyId = $tag['poolCompanyId']; if (!isset($tagsMap[$poolCompanyId])) { $tagsMap[$poolCompanyId] = []; } $tagsMap[$poolCompanyId][] = [ 'tagName' => $tag['tagName'], 'tagType' => $tag['tagType'] ]; } } foreach ($list as $item) { $data = $item->toArray(); // 计算 RFM R 值 $data['rfmR'] = $item->lastInteractTime ? (int)floor((time() - $item->lastInteractTime) / 86400) : 9999; // 计算 RFM 总分 $data['rfmScore'] = $this->calculateRfmScore($data['rfmR'], $data['rfmF'] ?? 0, $data['rfmM'] ?? 0); // 添加标签 $data['tags'] = $tagsMap[$item['id']] ?? []; $result[] = $data; } return [ 'list' => $result, 'total' => $total, 'page' => $page, 'pageSize' => $pageSize ]; } /** * 计算分组成员数量 */ public function countGroupMembers(int $groupId, int $companyId) { $group = TrafficPoolGroup::where('id', $groupId) ->whereIn('companyId', [0, $companyId]) ->where('isDel', 0) ->find(); if (!$group) { return 0; } if ($group->ruleType == TrafficPoolGroup::RULE_TYPE_MANUAL) { return TrafficPoolGroupMember::where('groupId', $groupId) ->where('companyId', $companyId) ->where('isDel', 0) ->count(); } // 动态规则分组 $query = TrafficPoolCompany::alias('tpc') ->join('ck_traffic_pool tp', 'tp.id = tpc.poolId', 'LEFT') ->where('tpc.companyId', $companyId) ->where('tpc.isDel', 0); $ruleConfig = $group->ruleConfig; if (!empty($ruleConfig)) { $this->applyRuleConditions($query, $ruleConfig, $companyId); } return $query->count(); } /** * 获取分组 RFM 统计 */ protected function getGroupRfmStats(int $groupId, int $companyId) { $group = TrafficPoolGroup::where('id', $groupId) ->whereIn('companyId', [0, $companyId]) ->where('isDel', 0) ->find(); if (!$group) { return ['avgR' => 0, 'avgF' => 0, 'avgM' => 0, 'avgScore' => 0]; } if ($group->ruleType == TrafficPoolGroup::RULE_TYPE_MANUAL) { $query = TrafficPoolCompany::alias('tpc') ->join('ck_traffic_pool_group_member tpgm', 'tpgm.poolCompanyId = tpc.id', 'INNER') ->where('tpgm.groupId', $groupId) ->where('tpgm.isDel', 0) ->where('tpc.isDel', 0); } else { $query = TrafficPoolCompany::alias('tpc') ->join('ck_traffic_pool tp', 'tp.id = tpc.poolId', 'LEFT') ->where('tpc.companyId', $companyId) ->where('tpc.isDel', 0); $ruleConfig = $group->ruleConfig; if (!empty($ruleConfig)) { $this->applyRuleConditions($query, $ruleConfig, $companyId); } } $stats = $query->field([ 'AVG(DATEDIFF(NOW(), FROM_UNIXTIME(IFNULL(tpc.lastInteractTime, tpc.createTime)))) as avgR', 'AVG(tpc.rfmF) as avgF', 'AVG(tpc.rfmM) as avgM' ])->find(); $avgR = round($stats['avgR'] ?? 0, 1); $avgF = round($stats['avgF'] ?? 0, 1); $avgM = round($stats['avgM'] ?? 0, 2); $rfmScore = $this->calculateRfmScore($avgR, $avgF, $avgM); return [ 'avgR' => $avgR, 'avgF' => $avgF, 'avgM' => $avgM, 'avgScore' => $rfmScore['total'] ]; } /** * 添加成员到分组 * * @param int $groupId 分组ID * @param array $poolCompanyIds 成员ID数组 * @param int $companyId 公司ID * @param int $operatorId 操作人ID * @return int 成功添加数量 */ public function addMembers(int $groupId, array $poolCompanyIds, int $companyId, int $operatorId = null) { $group = TrafficPoolGroup::where('id', $groupId) ->where('companyId', $companyId) ->where('isDel', 0) ->find(); if (!$group) { throw new \Exception('分组不存在'); } if ($group->ruleType != TrafficPoolGroup::RULE_TYPE_MANUAL) { throw new \Exception('动态规则分组不支持手动添加成员'); } return TrafficPoolGroupMember::batchAddMembers($groupId, $poolCompanyIds, $companyId, $operatorId); } /** * 从分组移除成员 * * @param int $groupId 分组ID * @param array $poolCompanyIds 成员ID数组 * @param int $companyId 公司ID * @return int 成功移除数量 */ public function removeMembers(int $groupId, array $poolCompanyIds, int $companyId) { $group = TrafficPoolGroup::where('id', $groupId) ->where('companyId', $companyId) ->where('isDel', 0) ->find(); if (!$group) { throw new \Exception('分组不存在'); } if ($group->ruleType != TrafficPoolGroup::RULE_TYPE_MANUAL) { throw new \Exception('动态规则分组不支持手动移除成员'); } return TrafficPoolGroupMember::batchRemoveMembers($groupId, $poolCompanyIds); } /** * 计算 RFM 评分 */ protected function calculateRfmScore($r, $f, $m) { // R 评分 if ($r <= 7) { $rScore = 5; } elseif ($r <= 30) { $rScore = 4; } elseif ($r <= 90) { $rScore = 3; } elseif ($r <= 180) { $rScore = 2; } else { $rScore = 1; } // F 评分 if ($f >= 100) { $fScore = 5; } elseif ($f >= 50) { $fScore = 4; } elseif ($f >= 20) { $fScore = 3; } elseif ($f >= 5) { $fScore = 2; } else { $fScore = 1; } // M 评分 if ($m >= 10000) { $mScore = 5; } elseif ($m >= 5000) { $mScore = 4; } elseif ($m >= 1000) { $mScore = 3; } elseif ($m >= 100) { $mScore = 2; } else { $mScore = 1; } return [ 'R' => $rScore, 'F' => $fScore, 'M' => $mScore, 'total' => $rScore + $fScore + $mScore ]; } /** * 预览动态分组成员(不创建分组,只预览符合条件的用户) * * @param int $companyId 公司ID * @param array $ruleConfig 规则配置 * @param int $page 页码 * @param int $pageSize 每页数量 * @param string $keyword 搜索关键词 * @return array */ public function previewGroupMembers(int $companyId, array $ruleConfig, int $page = 1, int $pageSize = 20, string $keyword = '') { $query = TrafficPoolCompany::alias('tpc') ->join('ck_traffic_pool tp', 'tp.id = tpc.poolId', 'LEFT') ->where('tpc.companyId', $companyId) ->where('tpc.isDel', 0); // 应用规则条件 if (!empty($ruleConfig)) { $this->applyRuleConditions($query, $ruleConfig, $companyId); } // 应用关键字搜索 if (!empty($keyword)) { $query->where(function($q) use ($keyword) { $q->where('tp.nickname', 'like', "%{$keyword}%") ->whereOr('tp.wechatId', 'like', "%{$keyword}%") ->whereOr('tp.wechatAlias', 'like', "%{$keyword}%") ->whereOr('tpc.realName', 'like', "%{$keyword}%") ->whereOr('tpc.phone', 'like', "%{$keyword}%"); }); } $total = $query->count(); $list = $query->field([ 'tpc.id', 'tpc.poolId', 'tpc.identifier', 'tpc.companyId', 'tpc.friendStatus', 'tpc.level', 'tpc.intentionLevel', 'tpc.lastInteractTime', 'tpc.rfmF', 'tpc.rfmM', 'tpc.totalMsgCount', 'tpc.totalOrderAmount', 'tpc.lastMsgTime', 'tpc.firstSourceType', 'tpc.firstSourceTime', 'tpc.lifecycle', 'tpc.createTime', 'tpc.realName', 'tpc.phone', 'tp.nickname', 'tp.avatar', 'tp.wechatId', 'tp.wechatAlias', 'tp.gender', 'tp.region', 'tp.country', 'tp.province', 'tp.city' ]) ->order('tpc.id DESC') ->page($page, $pageSize) ->select(); return $this->formatMemberList($list, $total, $page, $pageSize); } }