This commit is contained in:
Your Name
2026-08-12 17:18:56 +08:00
parent 28cd110dae
commit f48a66b611
24 changed files with 3095 additions and 233 deletions
@@ -959,11 +959,12 @@ class ConversionLogic
/**
* 区间有效加粉:按企微员工聚合后,再投影到部门/成员/虚拟桶。
*
* 口径(对齐企微客户列表,而非原始回调条数):
* 口径(对齐企微客户列表 / 官方「新增客户」不含继承,而非原始回调条数):
* - 统计区间内 add_external_contact,按 (user_id, external_userid) 去重;
* - 加粉之后、统计结束前须有 msg_audit_approved(排除未完成链路的幽灵事件);
* - 加粉之后、统计结束前若出现 del_external_contact(客户从企微侧删除)则不计。
* 不扣减 del_follow_user:企微客户列表中改名带「删」的客户往往仍在列表中
* 不扣减 del_follow_user:企微客户列表中改名带「删」的客户往往仍在列表中
* - 剔除继承客户:跟进人 add_way∈{201 内部成员共享, 202 管理员/负责人分配}(含在职/离职继承)。
*
* @param array<string, mixed>|null $mediaChannel
* @param int[]|null $adminIds null means all active/unbound WeCom users
@@ -986,7 +987,7 @@ class ConversionLogic
}
}
$baseKey = self::requestRowsCacheKey('fans-effective-v2', [
$baseKey = self::requestRowsCacheKey('fans-effective-v3', [
$startTimestamp,
$endTimestamp,
self::mediaChannelCacheKey($mediaChannel),
@@ -1029,6 +1030,8 @@ class ConversionLogic
->alias('e')
->where('e.change_type', 'add_external_contact')
->where('e.event_time', 'between', [$startTimestamp, $endTimestamp])
->where('e.user_id', '<>', '')
->where('e.external_userid', '<>', '')
->whereRaw(
'EXISTS (SELECT 1 FROM `' . $eventTable . '` audit_e'
. ' WHERE audit_e.user_id = e.user_id'
@@ -1047,8 +1050,8 @@ class ConversionLogic
. ' AND del_e.event_time <= ?)',
['del_external_contact', $endTimestamp]
)
->fieldRaw('e.user_id, COUNT(DISTINCT e.external_userid) AS add_fans_count')
->group('e.user_id');
->field(['e.user_id', 'e.external_userid'])
->group('e.user_id, e.external_userid');
if ($workWechatUserIds !== null) {
$query->whereIn('e.user_id', $workWechatUserIds);
}
@@ -1056,11 +1059,114 @@ class ConversionLogic
MediaChannelService::applyExternalUserChannelFilter($query, 'e.external_userid', $mediaChannel);
}
self::$requestRowsCache[$cacheKey] = $query->select()->toArray();
$pairs = $query->select()->toArray();
$pairs = self::excludeInheritedFanPairs($pairs);
$countsByUser = [];
foreach ($pairs as $pair) {
$userId = trim((string) ($pair['user_id'] ?? ''));
if ($userId === '') {
continue;
}
$countsByUser[$userId] = ($countsByUser[$userId] ?? 0) + 1;
}
$rows = [];
foreach ($countsByUser as $userId => $count) {
$rows[] = [
'user_id' => $userId,
'add_fans_count' => $count,
];
}
self::$requestRowsCache[$cacheKey] = $rows;
return self::$requestRowsCache[$cacheKey];
}
/**
* 剔除企微「继承/分配」客户:跟进人 add_way 为 201(内部成员共享)或 202(管理员/负责人分配,含在职/离职继承)。
* 无本地客户档案或跟进信息不含该员工时保守保留(无法判定则仍计加粉)。
*
* @param array<int, array<string, mixed>> $pairs
* @return array<int, array<string, mixed>>
*/
private static function excludeInheritedFanPairs(array $pairs): array
{
if ($pairs === []) {
return [];
}
$externalUserIds = [];
foreach ($pairs as $pair) {
$extId = trim((string) ($pair['external_userid'] ?? ''));
if ($extId !== '') {
$externalUserIds[$extId] = true;
}
}
$externalIdList = array_keys($externalUserIds);
if ($externalIdList === []) {
return $pairs;
}
/** @var array<string, true> $inheritedKeys user_id\0external_userid */
$inheritedKeys = [];
foreach (array_chunk($externalIdList, 500) as $chunk) {
$contactRows = Db::name('qywx_external_contact')
->whereIn('external_userid', $chunk)
->field(['external_userid', 'follow_users'])
->select()
->toArray();
foreach ($contactRows as $contact) {
$extId = trim((string) ($contact['external_userid'] ?? ''));
if ($extId === '') {
continue;
}
$followUsers = $contact['follow_users'] ?? null;
if (\is_string($followUsers) && $followUsers !== '') {
$decoded = json_decode($followUsers, true);
$followUsers = \is_array($decoded) ? $decoded : [];
}
if (!\is_array($followUsers)) {
continue;
}
foreach ($followUsers as $fu) {
if (!\is_array($fu)) {
continue;
}
$addWay = (int) ($fu['add_way'] ?? $fu['AddWay'] ?? 0);
if ($addWay !== 201 && $addWay !== 202) {
continue;
}
$followUserId = trim((string) ($fu['userid'] ?? $fu['UserId'] ?? ''));
if ($followUserId === '') {
continue;
}
$inheritedKeys[$followUserId . "\0" . $extId] = true;
}
}
}
if ($inheritedKeys === []) {
return $pairs;
}
$kept = [];
foreach ($pairs as $pair) {
$userId = trim((string) ($pair['user_id'] ?? ''));
$extId = trim((string) ($pair['external_userid'] ?? ''));
if ($userId === '' || $extId === '') {
continue;
}
if (isset($inheritedKeys[$userId . "\0" . $extId])) {
continue;
}
$kept[] = $pair;
}
return $kept;
}
/**
* @param string[] $userIds
* @return array<string, array{id: int|string, name: string}>
@@ -1625,10 +1731,13 @@ class ConversionLogic
$entity['account_cost'] = $effectiveAccountCost;
$entity['paid_appointment_rate'] = self::percent($paidAppointmentCount, $addFansCount);
$entity['open_appointment_rate'] = self::percent($paidAppointmentCount, $totalOpenCount);
// 预约率:面诊 / 预约(看预约后未到面)
$entity['interview_rate'] = self::percent($interviewCount, $appointmentTotalCount);
// 接诊率:接诊诊单 / 总进线(加粉);医生维度仍用面诊作分母
$entity['receive_rate'] = self::receiveRate($completedOrderCount, $addFansCount, $interviewCount, $dimension);
// 面诊接诊率:接诊诊单 / 面诊
$entity['interview_receive_rate'] = self::percent($completedOrderCount, $interviewCount);
// 面诊接诊率:面诊 / 挂号
// 面诊率:面诊 / 挂号(看挂号后流失)
$entity['interview_paid_rate'] = self::percent($interviewCount, $paidAppointmentCount);
$entity['open_receive_rate'] = self::percent($completedOrderCount, $totalOpenCount);
$entity['avg_unit_price'] = self::safeDivideMoney($completedOrderAmount, $completedOrderCount);
@@ -2711,6 +2820,10 @@ class ConversionLogic
return $charts;
}
/**
* 接诊率:接诊诊单 ÷ 总进线(加粉)。
* 医生维度无加粉口径时,退化为接诊诊单 ÷ 面诊。
*/
private static function receiveRate(int $completedOrderCount, int $addFansCount, int $interviewCount, string $dimension): float
{
$denominator = $dimension === 'doctor' ? $interviewCount : $addFansCount;