This commit is contained in:
Your Name
2026-08-24 16:56:37 +08:00
parent d9bb94cd3f
commit 562fe0ea0e
374 changed files with 1223 additions and 785 deletions
@@ -655,6 +655,7 @@ class ConversionLogic
'id' => $id,
'name' => $name,
'add_fans_count' => 0,
'deleted_fans_count' => 0,
'total_open_count' => 0, // TODO: 接入 IM 会话统计后回填
'unreplied_count' => 0, // TODO: 接入 IM 会话统计后回填
'paid_appointment_count' => 0,
@@ -924,11 +925,13 @@ class ConversionLogic
$userId = (string)($row['user_id'] ?? '');
$adminId = (int)($adminByUserId[$userId]['id'] ?? 0);
$addFansCount = (int)($row['add_fans_count'] ?? 0);
$deletedFansCount = (int)($row['deleted_fans_count'] ?? 0);
if ($dimension === 'dept' && $adminId <= 0) {
// -1 桶仅在 SCOPE_ALLvisibleAdminIds === null)时存在;其他范围下未绑定 admin 的加粉不可归属,跳过。
if ($visibleAdminIds === null && isset($entities[self::VIRTUAL_DEPT_UNBOUND_ADMIN_ID])) {
$entities[self::VIRTUAL_DEPT_UNBOUND_ADMIN_ID]['add_fans_count'] += $addFansCount;
$entities[self::VIRTUAL_DEPT_UNBOUND_ADMIN_ID]['deleted_fans_count'] += $deletedFansCount;
}
continue;
}
@@ -945,6 +948,7 @@ class ConversionLogic
}
if (isset($entities[self::VIRTUAL_DEPT_UNASSIGNED_ID])) {
$entities[self::VIRTUAL_DEPT_UNASSIGNED_ID]['add_fans_count'] += $addFansCount;
$entities[self::VIRTUAL_DEPT_UNASSIGNED_ID]['deleted_fans_count'] += $deletedFansCount;
}
continue;
}
@@ -955,27 +959,28 @@ class ConversionLogic
foreach ($hitEntityIds as $entityId) {
$entities[$entityId]['add_fans_count'] += $addFansCount;
$entities[$entityId]['deleted_fans_count'] += $deletedFansCount;
}
}
}
/**
* 区间有效加粉:按企微员工聚合后,再投影到部门/成员/虚拟桶。
* 区间新增加粉:按企微员工聚合后,再投影到部门/成员/虚拟桶。
*
* 口径(对齐企微客户列表 / 官方「新增客户」不含继承,而非原始回调条数):
* - 统计区间内 add_external_contact,按 (user_id, external_userid) 去重;
* - 同一员工在区间开始前已加过该客户的重加不计(企微「添加时间」仍是首次跟进时间,
* 删后再加会再推 add_external_contact,但不能当当天新客,否则会跨日重复计);
* - add_external_contact 是企微确认客户关系已建立后的权威事件;会话存档同意
* msg_audit_approved 属于独立能力,不能作为加粉前置条件,否则未开通会话存档的员工会被整批清零;
* - 加粉之后、统计结束前若出现 del_external_contact(客户从企微侧删除)则不计。
* 不扣减 del_follow_user:企微客户列表中改名带「删」的客户往往仍在列表中;
* - add_external_contact 是企微确认客户关系已建立后的权威事件;会话存档同意
* msg_audit_approved 属于独立能力,不能作为加粉前置条件,否则未开通会话存档的员工会被整批清零;
* - 加粉之后、统计结束前若出现 del_external_contact(客户从企微侧删除),仍计入加粉总数,
* 并额外计入已删提示子集;不处理 del_follow_user:企微客户列表中改名带「删」的客户往往仍在列表中;
* - 剔除非投放加粉:跟进人 add_way∈{1 扫一扫, 2 搜索手机号, 3 名片分享};
* - 剔除继承客户:跟进人 add_way∈{201 内部成员共享, 202 管理员/负责人分配}(含在职/离职继承)。
*
* @param array<string, mixed>|null $mediaChannel
* @param int[]|null $adminIds null means all active/unbound WeCom users
* @return array<int, array{user_id: string, add_fans_count: int|string}>
* @return array<int, array{user_id: string, add_fans_count: int, deleted_fans_count: int}>
*/
private static function loadFanRows(
int $startTimestamp,
@@ -994,7 +999,7 @@ class ConversionLogic
}
}
$baseKey = self::requestRowsCacheKey('fans-effective-v7', [
$baseKey = self::requestRowsCacheKey('fans-effective-v9', [
$startTimestamp,
$endTimestamp,
self::mediaChannelCacheKey($mediaChannel),
@@ -1036,18 +1041,9 @@ class ConversionLogic
$query = Db::name('qywx_external_contact_event')
->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(
'NOT EXISTS (SELECT 1 FROM `' . $eventTable . '` del_e'
. ' WHERE del_e.user_id = e.user_id'
. ' AND del_e.external_userid = e.external_userid'
. ' AND del_e.change_type = ?'
. ' AND del_e.event_time >= e.event_time'
. ' AND del_e.event_time <= ?)',
['del_external_contact', $endTimestamp]
)
->where('e.event_time', 'between', [$startTimestamp, $endTimestamp])
->where('e.user_id', '<>', '')
->where('e.external_userid', '<>', '')
->whereRaw(
'NOT EXISTS (SELECT 1 FROM `' . $eventTable . '` prev_e'
. ' WHERE prev_e.user_id = e.user_id'
@@ -1061,35 +1057,109 @@ class ConversionLogic
if ($workWechatUserIds !== null) {
$query->whereIn('e.user_id', $workWechatUserIds);
}
$effectiveQuery = clone $query;
if ($mediaChannel !== null) {
MediaChannelService::applyExternalUserChannelFilter($query, 'e.external_userid', $mediaChannel);
// 有效加粉保持原口径:只按未删客户的当前标签/渠道关系筛选。
MediaChannelService::applyExternalUserChannelFilter($effectiveQuery, 'e.external_userid', $mediaChannel);
}
$effectivePairs = $effectiveQuery
->whereRaw(
'NOT EXISTS (SELECT 1 FROM `' . $eventTable . '` del_e'
. ' WHERE del_e.user_id = e.user_id'
. ' AND del_e.external_userid = e.external_userid'
. ' AND del_e.change_type = ?'
. ' AND del_e.event_time >= e.event_time'
. ' AND del_e.event_time <= ?)',
['del_external_contact', $endTimestamp]
)
->select()
->toArray();
$pairs = $query->select()->toArray();
$pairs = self::excludeUncountedFanPairs($pairs);
$countsByUser = [];
foreach ($pairs as $pair) {
$userId = trim((string) ($pair['user_id'] ?? ''));
if ($userId === '') {
continue;
}
$countsByUser[$userId] = ($countsByUser[$userId] ?? 0) + 1;
$deletedQuery = clone $query;
if ($mediaChannel !== null) {
// del_external_contact 会软删 contact 并清掉关系表,已删归属改用保留的 follow_users 快照。
MediaChannelService::applyHistoricalExternalUserChannelFilter($deletedQuery, 'e.external_userid', $mediaChannel);
}
$deletedPairs = $deletedQuery
->whereRaw(
'NOT EXISTS (SELECT 1 FROM `' . $eventTable . '` surviving_e'
. ' WHERE surviving_e.user_id = e.user_id'
. ' AND surviving_e.external_userid = e.external_userid'
. ' AND surviving_e.change_type = ?'
. ' AND surviving_e.event_time >= ?'
. ' AND surviving_e.event_time <= ?'
. ' AND NOT EXISTS (SELECT 1 FROM `' . $eventTable . '` surviving_del'
. ' WHERE surviving_del.user_id = surviving_e.user_id'
. ' AND surviving_del.external_userid = surviving_e.external_userid'
. ' AND surviving_del.change_type = ?'
. ' AND surviving_del.event_time >= surviving_e.event_time'
. ' AND surviving_del.event_time <= ?))',
['add_external_contact', $startTimestamp, $endTimestamp, 'del_external_contact', $endTimestamp]
)
->select()
->toArray();
$rows = [];
foreach ($countsByUser as $userId => $count) {
$rows[] = [
'user_id' => $userId,
'add_fans_count' => $count,
];
}
// add_way 筛选对有效与已删粉丝使用同一口径。
$candidatePairs = array_merge($effectivePairs, $deletedPairs);
$candidatePairs = self::excludeUncountedFanPairs($candidatePairs);
$rows = self::buildFanCountRows($candidatePairs, $effectivePairs);
self::$requestRowsCache[$cacheKey] = $rows;
return self::$requestRowsCache[$cacheKey];
}
/**
* 按员工聚合全部候选新增,并标记其中期末已删除的子集。
*
* 候选对已经过“区间前未添加”、渠道与 add_way 规则,每个去重候选对都计入
* add_fans_count;有效对是期末未删除的子集,因此候选对与有效对的差集另计入
* deleted_fans_count。deleted_fans_count 只是 add_fans_count 的提示子集,不再扣减或重复累计。
* 若区间内删除后又重加且期末仍有效,该对仍在有效子集中,不会误计为已删。
*
* @param array<int, array<string, mixed>> $candidatePairs
* @param array<int, array<string, mixed>> $effectivePairs
* @return array<int, array{user_id: string, add_fans_count: int, deleted_fans_count: int}>
*/
private static function buildFanCountRows(array $candidatePairs, array $effectivePairs): array
{
$effectiveKeys = [];
foreach ($effectivePairs as $pair) {
$userId = trim((string) ($pair['user_id'] ?? ''));
$externalUserId = trim((string) ($pair['external_userid'] ?? ''));
if ($userId === '' || $externalUserId === '') {
continue;
}
$effectiveKeys[$userId . "\0" . $externalUserId] = true;
}
$countsByUser = [];
$seenCandidateKeys = [];
foreach ($candidatePairs as $pair) {
$userId = trim((string) ($pair['user_id'] ?? ''));
$externalUserId = trim((string) ($pair['external_userid'] ?? ''));
if ($userId === '' || $externalUserId === '') {
continue;
}
$key = $userId . "\0" . $externalUserId;
if (isset($seenCandidateKeys[$key])) {
continue;
}
$seenCandidateKeys[$key] = true;
$countsByUser[$userId] ??= [
'user_id' => $userId,
'add_fans_count' => 0,
'deleted_fans_count' => 0,
];
++$countsByUser[$userId]['add_fans_count'];
if (!isset($effectiveKeys[$key])) {
++$countsByUser[$userId]['deleted_fans_count'];
}
}
return array_values($countsByUser);
}
/**
* 剔除不应计入加粉的跟进来源:
* - add_way=1 扫一扫(客户通过扫一扫添加);
@@ -1738,6 +1808,7 @@ class ConversionLogic
$appointmentTotalCount = (int)($entity['appointment_total_count'] ?? 0);
$interviewCount = (int)$entity['interview_count'];
$addFansCount = (int)$entity['add_fans_count'];
$deletedFansCount = (int)($entity['deleted_fans_count'] ?? 0);
$totalOpenCount = (int)$entity['total_open_count'];
$completedOrderCount = (int)$entity['completed_order_count'];
$businessOrderAmount = round((float)$entity['business_order_amount'], 2);
@@ -1746,6 +1817,7 @@ class ConversionLogic
$effectiveAccountCost = self::allocateAccountCost($globalAccountCost, $globalAddFansCount, $addFansCount, $isEligibleForAccountCost);
$entity['paid_appointment_count'] = $paidAppointmentCount;
$entity['deleted_fans_count'] = $deletedFansCount;
$entity['free_appointment_count'] = $freeAppointmentCount;
$entity['appointment_total_count'] = $appointmentTotalCount;
$entity['business_order_amount'] = $businessOrderAmount;
@@ -1830,6 +1902,7 @@ class ConversionLogic
$childNode = $buildNode((int)$childId);
$children[] = $childNode;
$node['add_fans_count'] += (int)$childNode['add_fans_count'];
$node['deleted_fans_count'] += (int)($childNode['deleted_fans_count'] ?? 0);
$node['total_open_count'] += (int)$childNode['total_open_count'];
$node['unreplied_count'] += (int)$childNode['unreplied_count'];
$node['paid_appointment_count'] += (int)$childNode['paid_appointment_count'];
@@ -1972,6 +2045,7 @@ class ConversionLogic
$appointmentTotalCount = (int)($node['appointment_total_count'] ?? 0);
$interviewCount = (int)$node['interview_count'];
$addFansCount = (int)$node['add_fans_count'];
$deletedFansCount = (int)($node['deleted_fans_count'] ?? 0);
$totalOpenCount = (int)$node['total_open_count'];
$completedOrderCount = (int)$node['completed_order_count'];
$businessOrderAmount = round((float)$node['business_order_amount'], 2);
@@ -1984,6 +2058,7 @@ class ConversionLogic
}
$node['paid_appointment_count'] = $paidAppointmentCount;
$node['deleted_fans_count'] = $deletedFansCount;
$node['free_appointment_count'] = $freeAppointmentCount;
$node['appointment_total_count'] = $appointmentTotalCount;
$node['business_order_amount'] = $businessOrderAmount;
@@ -2103,7 +2178,7 @@ class ConversionLogic
}
foreach ($doctorEntities as $adminId => $entity) {
if (isset($combined[$adminId])) {
foreach (['add_fans_count', 'total_open_count', 'unreplied_count', 'paid_appointment_count', 'free_appointment_count', 'appointment_total_count', 'interview_count', 'completed_order_count'] as $intKey) {
foreach (['add_fans_count', 'deleted_fans_count', 'total_open_count', 'unreplied_count', 'paid_appointment_count', 'free_appointment_count', 'appointment_total_count', 'interview_count', 'completed_order_count'] as $intKey) {
$combined[$adminId][$intKey] = (int)($combined[$adminId][$intKey] ?? 0) + (int)($entity[$intKey] ?? 0);
}
foreach (['business_order_amount', 'completed_order_amount'] as $moneyKey) {
@@ -2277,7 +2352,8 @@ class ConversionLogic
foreach ($rows as $row) {
$userId = (string)($row['user_id'] ?? '');
$addFansCount = (int)($row['add_fans_count'] ?? 0);
if ($userId === '' || $addFansCount <= 0) {
$deletedFansCount = (int)($row['deleted_fans_count'] ?? 0);
if ($userId === '' || ($addFansCount <= 0 && $deletedFansCount <= 0)) {
continue;
}
$resolvedName = $nameByUserId[$userId] ?? '';
@@ -2289,6 +2365,7 @@ class ConversionLogic
'未绑定 admin'
);
$memberRow['add_fans_count'] = $addFansCount;
$memberRow['deleted_fans_count'] = $deletedFansCount;
$result[] = $memberRow;
}
@@ -2327,7 +2404,8 @@ class ConversionLogic
$admin = $adminByUserId[$userId] ?? null;
$adminId = (int)($admin['id'] ?? 0);
$addFansCount = (int)($row['add_fans_count'] ?? 0);
if ($adminId <= 0 || $addFansCount <= 0) {
$deletedFansCount = (int)($row['deleted_fans_count'] ?? 0);
if ($adminId <= 0 || ($addFansCount <= 0 && $deletedFansCount <= 0)) {
continue;
}
if (isset($assignedAdminIds[$adminId])) {
@@ -2345,6 +2423,7 @@ class ConversionLogic
);
$memberRow['admin_id'] = $adminId;
$memberRow['add_fans_count'] = $addFansCount;
$memberRow['deleted_fans_count'] = $deletedFansCount;
$result[] = $memberRow;
}
@@ -2401,6 +2480,7 @@ class ConversionLogic
'role' => $role,
'is_leader' => false,
'add_fans_count' => 0,
'deleted_fans_count' => 0,
'total_open_count' => 0,
'unreplied_count' => 0,
'paid_appointment_count' => 0,
@@ -2448,6 +2528,7 @@ class ConversionLogic
$appointmentTotalCount = (int)($entity['appointment_total_count'] ?? 0);
$interviewCount = (int)($entity['interview_count'] ?? 0);
$addFansCount = (int)($entity['add_fans_count'] ?? 0);
$deletedFansCount = (int)($entity['deleted_fans_count'] ?? 0);
$totalOpenCount = (int)($entity['total_open_count'] ?? 0);
$completedOrderCount = (int)($entity['completed_order_count'] ?? 0);
$businessOrderAmount = round((float)($entity['business_order_amount'] ?? 0), 2);
@@ -2463,6 +2544,7 @@ class ConversionLogic
'role' => $role,
'is_leader' => $isLeader,
'add_fans_count' => $addFansCount,
'deleted_fans_count' => $deletedFansCount,
'total_open_count' => $totalOpenCount,
'unreplied_count' => (int)($entity['unreplied_count'] ?? 0),
'paid_appointment_count' => $paidAppointmentCount,
@@ -2625,6 +2707,7 @@ class ConversionLogic
{
$summary = [
'add_fans_count' => 0,
'deleted_fans_count' => 0,
'total_open_count' => 0,
'unreplied_count' => 0,
'paid_appointment_count' => 0,
@@ -2638,6 +2721,7 @@ class ConversionLogic
];
foreach ($rows as $row) {
$summary['add_fans_count'] += (int)$row['add_fans_count'];
$summary['deleted_fans_count'] += (int)($row['deleted_fans_count'] ?? 0);
$summary['total_open_count'] += (int)$row['total_open_count'];
$summary['unreplied_count'] += (int)$row['unreplied_count'];
$summary['paid_appointment_count'] += (int)$row['paid_appointment_count'];
@@ -2775,6 +2859,7 @@ class ConversionLogic
private static function hasBusinessMetrics(array $row): bool
{
return (int)($row['add_fans_count'] ?? 0) > 0
|| (int)($row['deleted_fans_count'] ?? 0) > 0
|| (int)($row['paid_appointment_count'] ?? 0) > 0
|| (int)($row['free_appointment_count'] ?? 0) > 0
|| (int)($row['appointment_total_count'] ?? 0) > 0