This commit is contained in:
Your Name
2026-07-15 09:07:05 +08:00
parent f6688a740d
commit 796441b407
4 changed files with 398 additions and 161 deletions
+79 -4
View File
@@ -39,8 +39,16 @@
<div class="grid grid-cols-4 gap-4 mb-4">
<el-card shadow="hover">
<div class="text-gray-500 text-sm mb-1">总客户数</div>
<div class="text-gray-500 text-sm mb-1">总客户数去重</div>
<div class="text-2xl font-bold text-primary">{{ stats.total }}</div>
<el-tooltip
content="企微管理后台「全部客户」按客户×添加人关系计数:同一客户被 N 名员工添加计 N 条。与企微对账请看此数字。"
placement="bottom"
>
<div class="text-xs text-gray-400 mt-1 cursor-help">
跟进关系数企微口径{{ stats.relation_total }}
</div>
</el-tooltip>
</el-card>
<el-card shadow="hover" class="today-arrival-card cursor-pointer" @click="openArrivalDrawer">
<div class="flex items-center justify-between mb-1">
@@ -52,6 +60,9 @@
<div class="flex items-end gap-2">
<div class="text-2xl font-bold text-success leading-none">{{ stats.today }}</div>
<div class="text-xs text-gray-400 pb-1"></div>
<div v-if="arrival.returning > 0" class="text-xs text-warning pb-1">
含老客户 {{ arrival.returning }}
</div>
</div>
<!-- 24 小时分布迷你柱高度相对今日峰值等比 -->
<div class="hourly-bars mt-2" :title="hourlyTooltip">
@@ -114,6 +125,24 @@
@change="onAddTimeRangeChange"
/>
</el-form-item>
<el-form-item>
<template #label>
<el-tooltip
content="首次添加:客户第一次加入企业的时间在所选范围内。任意添加(企微口径):范围内发生过添加动作即算,含老客户被重加/被其他员工添加,与企微后台时间筛选一致。"
placement="top"
>
<span class="cursor-help">时间口径</span>
</el-tooltip>
</template>
<el-select
v-model="queryParams.add_time_mode"
class="!w-[180px]"
@change="onAddTimeModeChange"
>
<el-option label="首次添加" value="first" />
<el-option label="任意添加(企微口径)" value="any" />
</el-select>
</el-form-item>
<el-form-item label="标签">
<el-select
v-model="queryParams.tag_ids"
@@ -158,7 +187,23 @@
<div class="flex items-center gap-2">
<el-avatar :size="40" :src="row.avatar" />
<div>
<div class="font-medium">{{ row.name }}</div>
<div class="font-medium flex items-center gap-1">
<span>{{ row.name }}</span>
<el-tooltip
v-if="row.readd_flag"
content="这个人以前加过企业:被删除后重新添加,或已是企业客户又被其他员工添加"
placement="top"
>
<el-tag size="small" type="warning" effect="plain">以前加过</el-tag>
</el-tooltip>
<el-tooltip
v-if="row.is_deleted"
content="该客户已被删除/流失(企微中已不是企业客户),仅在「任意添加」口径下展示"
placement="top"
>
<el-tag size="small" type="danger" effect="plain">已删除</el-tag>
</el-tooltip>
</div>
<div class="text-xs text-gray-400">{{ row.external_userid }}</div>
</div>
</div>
@@ -278,6 +323,9 @@
<div class="flex items-center gap-3">
<span class="text-base font-semibold">今日进入明细</span>
<el-tag type="success" effect="plain"> {{ arrivalList.total }} </el-tag>
<el-tag v-if="arrival.returning > 0" type="warning" effect="plain">
老客户 {{ arrival.returning }}
</el-tag>
</div>
<el-button link type="primary" :loading="arrivalListLoading" @click="loadArrival(true)">
<template #icon><Refresh /></template>
@@ -331,8 +379,17 @@
{{ (row.customer_name || '?').slice(0, 1) }}
</el-avatar>
<div class="flex-1 min-w-0">
<div class="text-sm font-medium truncate">
<div class="text-sm font-medium truncate flex items-center gap-1">
<span class="truncate">
{{ row.customer_name || row.external_userid || '(未知客户)' }}
</span>
<el-tooltip
v-if="row.is_old_customer"
content="老客户回流:今天之前就加过企业(删除后重加,或已是其他员工的客户)"
placement="top"
>
<el-tag size="small" type="warning" effect="plain">老客户</el-tag>
</el-tooltip>
</div>
<div class="text-xs text-gray-500 mt-0.5">
<span>接待{{ row.admin_name || row.user_id || '—' }}</span>
@@ -469,6 +526,9 @@
</el-descriptions-item>
<el-descriptions-item label="类型">
{{ currentCustomer.type === 1 ? '微信用户' : '企业微信用户' }}
<el-tag v-if="currentCustomer.readd_flag" size="small" type="warning" effect="plain" class="ml-1">
以前加过
</el-tag>
</el-descriptions-item>
<el-descriptions-item label="企业名称" :span="2">
{{ currentCustomer.corp_name || '—' }}
@@ -544,6 +604,7 @@ const currentCustomer = ref<any>(null)
const stats = reactive({
total: 0,
relation_total: 0,
today: 0,
today_follow_staff: 0,
lastSync: '',
@@ -575,12 +636,14 @@ const queryParams = reactive<{
tag_ids: string[]
add_time_start: string
add_time_end: string
add_time_mode: 'first' | 'any'
}>({
name: '',
follow_user: '',
tag_ids: [],
add_time_start: '',
add_time_end: ''
add_time_end: '',
add_time_mode: 'first'
})
/** 与列表「添加时间」列口径一致(库内 external_first_add_time / create_time */
@@ -597,6 +660,13 @@ function onAddTimeRangeChange(val: [string, string] | null) {
resetPage()
}
/** 口径切换只在已选时间范围时才需要重查 */
function onAddTimeModeChange() {
if (queryParams.add_time_start || queryParams.add_time_end) {
resetPage()
}
}
// ── 标签维度(筛选下拉 + 抽屉面板共用同一份数据) ──────────────────────────
interface TagItem {
tag_id: string
@@ -689,6 +759,7 @@ function filterByTag(tagId: string) {
interface ArrivalStats {
total: number
recent_time: number
returning: number
hourly: number[]
by_state: { state: string; count: number }[]
}
@@ -702,11 +773,13 @@ interface ArrivalItem {
customer_avatar: string
state: string
welcome_code: number
is_old_customer: number
}
const arrival = reactive<ArrivalStats>({
total: 0,
recent_time: 0,
returning: 0,
hourly: new Array(24).fill(0),
by_state: []
})
@@ -740,6 +813,7 @@ async function loadArrival(refreshList = false) {
if (res) {
arrival.total = Number(res.total ?? 0)
arrival.recent_time = Number(res.recent_time ?? 0)
arrival.returning = Number(res.returning ?? 0)
arrival.hourly = Array.isArray(res.hourly) && res.hourly.length === 24
? res.hourly.map((n: any) => Number(n) || 0)
: new Array(24).fill(0)
@@ -829,6 +903,7 @@ function handleReset() {
queryParams.tag_ids = []
queryParams.add_time_start = ''
queryParams.add_time_end = ''
queryParams.add_time_mode = 'first'
addTimeRange.value = null
resetParams()
}
@@ -77,20 +77,37 @@ class CustomerLists extends BaseAdminDataLists implements ListsSearchInterface
}
}
// 添加时间:与 lists 排序口径一致(external_first_add_time 优先,0 则 create_time
// 添加时间筛选,两种口径:
// first(默认)= 首次添加时间在窗口内(external_first_add_time 优先,0 则 create_time
// any = 企微「全部客户+时间筛选」口径:存在"添加时间在窗口内的现存跟进关系"即命中
// (含老客户被重加/被其他员工添加;关系已解除的不算),数据源为现存关系表
$addStart = trim((string) ($this->params['add_time_start'] ?? ''));
$addEnd = trim((string) ($this->params['add_time_end'] ?? ''));
$addMode = trim((string) ($this->params['add_time_mode'] ?? 'first'));
$startTs = $addStart !== '' ? strtotime($addStart . ' 00:00:00') : false;
$endTs = $addEnd !== '' ? strtotime($addEnd . ' 23:59:59') : false;
if ($addMode === 'any' && ($startTs !== false || $endTs !== false)) {
$followQuery = Db::name('qywx_external_contact_follow')->where('createtime', '>', 0);
if ($startTs !== false) {
$followQuery->where('createtime', '>=', $startTs);
}
if ($endTs !== false) {
$followQuery->where('createtime', '<=', $endTs);
}
$matchedExtIds = $followQuery->group('external_userid')->column('external_userid');
if ($matchedExtIds === []) {
$query->whereRaw('1=0');
} else {
$query->whereIn('external_userid', $matchedExtIds);
}
} else {
$effExpr = 'COALESCE(NULLIF(external_first_add_time, 0), create_time)';
if ($addStart !== '') {
$t = strtotime($addStart . ' 00:00:00');
if ($t !== false) {
$query->whereRaw($effExpr . ' > 0 AND ' . $effExpr . ' >= ?', [$t]);
if ($startTs !== false) {
$query->whereRaw($effExpr . ' > 0 AND ' . $effExpr . ' >= ?', [$startTs]);
}
}
if ($addEnd !== '') {
$t = strtotime($addEnd . ' 23:59:59');
if ($t !== false) {
$query->whereRaw($effExpr . ' > 0 AND ' . $effExpr . ' <= ?', [$t]);
if ($endTs !== false) {
$query->whereRaw($effExpr . ' > 0 AND ' . $effExpr . ' <= ?', [$endTs]);
}
}
@@ -158,6 +175,9 @@ class CustomerLists extends BaseAdminDataLists implements ListsSearchInterface
$fromDb = (int) ($item['external_first_add_time'] ?? 0);
$fromJson = CustomerLogic::minFollowCreatetime($followUsers);
$item['external_first_add_time'] = $fromDb > 0 ? $fromDb : $fromJson;
// 「任意添加」口径会带出软删行,前端据此显示"已删除"标记
$item['is_deleted'] = !empty($item['delete_time']) ? 1 : 0;
}
unset($item);
+273 -125
View File
@@ -585,15 +585,32 @@ class CustomerLogic extends BaseLogic
/**
* 客户联系事件回调:按 external_userid 拉取详情并 UPSERT(不走全量同步文件锁)。
*
* @param bool $isAddEvent 是否 add_external_contact 事件:是且本地已有该客户(含软删行)时,
* 新增一行 readd_flag=1(以前加过)的记录,原有行不动
* @param string $eventUserId 本次添加人的企微 useridadd 事件回调里的 UserID,用于取本次添加时间)
* @param int $eventTime 回调事件时间(企微 CreateTime,秒)
* @see https://developer.work.weixin.qq.com/document/path/92130
*/
public static function upsertSingleExternalContactFromApi(string $externalUserId): void
{
public static function upsertSingleExternalContactFromApi(
string $externalUserId,
bool $isAddEvent = false,
string $eventUserId = '',
int $eventTime = 0
): void {
$externalUserId = trim($externalUserId);
$eventUserId = trim($eventUserId);
if ($externalUserId === '') {
return;
}
// 必须在写库之前判断"是否已存在",否则无法区分新客户和重加客户
$existedBefore = false;
if ($isAddEvent) {
$existedBefore = Db::name('qywx_external_contact')
->where('external_userid', $externalUserId)
->count() > 0;
}
$service = new WechatWorkService();
$detail = [];
$lastError = null;
@@ -619,6 +636,14 @@ class CustomerLogic extends BaseLogic
if (empty($detail['external_contact']) || !is_array($detail['external_contact'])) {
$errcode = is_array($lastError) ? ($lastError['errcode'] ?? null) : null;
$errmsg = is_array($lastError) ? (string) ($lastError['errmsg'] ?? '') : '';
// 84061 = 已不是企业客户(双向解除/员工删除后无人跟进):与企微对齐,本地软删。
// 注意:客户单方删除员工(del_follow_user)时企微仍保留客户,get 会成功返回,不会走到这里。
if ($errcode === 84061) {
self::softDeleteExternalContactRow($externalUserId);
Log::info('qywx external contact callback: 企微侧已不存在(84061),本地软删 ext=' . $externalUserId);
return;
}
Log::warning(sprintf(
'qywx external contact callback: 拉取详情为空,跳过 UPSERT errcode=%s errmsg=%s ext=%s',
$errcode === null ? '?' : (string) $errcode,
@@ -634,6 +659,18 @@ class CustomerLogic extends BaseLogic
$followUsers = [];
}
// 重复添加:不动原有行,新增一行并打「以前加过」标记(与企微"每次添加都算一条"对齐)
if ($isAddEvent && $existedBefore) {
self::insertReaddContactRow(
$detail['external_contact'],
$followUsers,
$eventUserId,
$eventTime
);
return;
}
$syncCount = 0;
$newCount = 0;
$updateCount = 0;
@@ -652,6 +689,48 @@ class CustomerLogic extends BaseLogic
);
}
/**
* 重复添加:为已存在的客户新增一行记录(原有行保持不变)。
*
* 新行的「添加时间」取本次添加动作的时间:优先本次添加人($eventUserId)在 follow_user
* 里的 createtime,其次回调事件时间,最后兜底当前时间——保证这行出现在"本次添加"的日期下。
*
* @param array<string, mixed> $externalContact
* @param array<int, array<string, mixed>> $followUsers
*/
private static function insertReaddContactRow(
array $externalContact,
array $followUsers,
string $eventUserId,
int $eventTime
): void {
$row = self::buildContactRow($externalContact, $followUsers);
$addTime = 0;
if ($eventUserId !== '') {
foreach ($followUsers as $fu) {
if (is_array($fu) && trim((string) ($fu['userid'] ?? '')) === $eventUserId) {
$addTime = self::normalizeFollowUserCreatetime($fu);
break;
}
}
}
if ($addTime <= 0) {
$addTime = $eventTime > 0 ? $eventTime : time();
}
$row['external_first_add_time'] = $addTime;
$row['readd_flag'] = 1;
Db::name('qywx_external_contact')->insert($row);
$extId = (string) $row['external_userid'];
self::syncContactTagsRelation($extId, $followUsers);
self::syncContactFollowRelation($extId, $followUsers);
Log::info('qywx external contact callback: 重复添加,新增一行(readd_flag=1) ext=' . $extId);
}
/**
* 事件流水(零误差进入计数)
*
@@ -720,7 +799,7 @@ class CustomerLogic extends BaseLogic
}
/**
* 客户联系「删除企业客户」等事件:本地软删除一行
* 客户彻底流失(企微 get 返回 84061):该客户的所有行(含历史重加行)一并软删
*/
public static function softDeleteExternalContactRow(string $externalUserId): void
{
@@ -739,84 +818,9 @@ class CustomerLogic extends BaseLogic
Db::name('qywx_external_contact_tag')
->where('external_userid', $externalUserId)
->delete();
}
/**
* `del_follow_user` 事件:某员工不再跟进该客户。
* 只更新本地 `follow_users` JSON:移除匹配的 userid;若已无跟进人则软删该行。
* 不再回调 /externalcontact/get,避免 84061「not external contact」刷 warning。
*/
public static function removeFollowUserFromLocal(string $externalUserId, string $userId): void
{
$externalUserId = trim($externalUserId);
$userId = trim($userId);
if ($externalUserId === '' || $userId === '') {
return;
}
$row = Db::name('qywx_external_contact')
->where('external_userid', $externalUserId)
->find();
if (!$row) {
return;
}
$followUsers = json_decode((string) ($row['follow_users'] ?? '[]'), true);
$followUsers = is_array($followUsers) ? $followUsers : [];
$kept = [];
$removed = false;
foreach ($followUsers as $fu) {
if (!is_array($fu)) {
continue;
}
$uid = trim((string) ($fu['userid'] ?? ''));
if ($uid === $userId) {
$removed = true;
continue;
}
$kept[] = $fu;
}
if (!$removed) {
return;
}
$now = time();
if ($kept === []) {
Db::name('qywx_external_contact')
->where('id', (int) $row['id'])
->update([
'follow_users' => json_encode([], JSON_UNESCAPED_UNICODE),
'tags' => '[]',
'delete_time' => $now,
'update_time' => $now,
]);
// 整客户已无人跟进 → 关系表清空
Db::name('qywx_external_contact_tag')
Db::name('qywx_external_contact_follow')
->where('external_userid', $externalUserId)
->delete();
return;
}
$minCreate = self::minFollowCreatetime($kept);
$update = [
'follow_users' => json_encode($kept, JSON_UNESCAPED_UNICODE),
'tags' => self::extractFollowUserTags($kept),
'update_time' => $now,
];
if ($minCreate > 0) {
// 移除最早那条跟进人后,首次添加时间可能后移;重新刷新字段以与 JSON 保持一致
$update['external_first_add_time'] = $minCreate;
}
Db::name('qywx_external_contact')
->where('id', (int) $row['id'])
->update($update);
// 关系表按剩余 kept follow_users 同步(自动清掉离开员工那行 + 保留其他员工的标签)
self::syncContactTagsRelation($externalUserId, $kept);
}
private static function upsertOneExternalContactBundle(
@@ -845,32 +849,16 @@ class CustomerLogic extends BaseLogic
}
}
$now = time();
$row = [
'external_userid' => $externalContact['external_userid'] ?? '',
'name' => $externalContact['name'] ?? '',
'avatar' => $externalContact['avatar'] ?? '',
'type' => $externalContact['type'] ?? 1,
'gender' => $externalContact['gender'] ?? 0,
'unionid' => $externalContact['unionid'] ?? '',
'position' => $externalContact['position'] ?? '',
'corp_name' => $externalContact['corp_name'] ?? '',
'corp_full_name' => $externalContact['corp_full_name'] ?? '',
'external_profile' => json_encode($externalContact['external_profile'] ?? [], JSON_UNESCAPED_UNICODE),
'follow_users' => json_encode($followUsers, JSON_UNESCAPED_UNICODE),
'tags' => self::extractFollowUserTags($followUsers),
'follow_admin_ids' => self::resolveFollowAdminIds($followUsers),
'external_first_add_time' => self::minFollowCreatetime($followUsers),
'create_time' => $now,
'update_time' => $now,
'delete_time' => null,
];
$row = self::buildContactRow($externalContact, $followUsers);
self::upsertExternalContactRow($row, $syncCount, $newCount, $updateCount);
// 同步「客户↔员工↔标签」关系表(用于检索/统计;列表筛选/聚合不必再解析 follow_users JSON
self::syncContactTagsRelation((string) $row['external_userid'], $followUsers);
// 同步「客户↔员工」现存跟进关系表(企微口径的添加时间筛选用)
self::syncContactFollowRelation((string) $row['external_userid'], $followUsers);
if (($syncCount % 25) === 0) {
usleep(8000);
}
@@ -982,6 +970,67 @@ class CustomerLogic extends BaseLogic
}
}
/**
* 同步「客户↔员工」现存跟进关系表(zyt_qywx_external_contact_follow)。
*
* 与企微后台「全部客户 + 时间筛选」同口径:只保留当前仍存在的跟进关系及其 createtime
* 关系被解除(员工删客户且未重加)后行随之删除,客户在对应日期的筛选下即不再出现。
*
* @param array<int, mixed> $followUsers /externalcontact/get 返回的 follow_user[]
* @internal 仅供 UPSERT / 回填复用
*/
public static function syncContactFollowRelation(string $externalUserId, array $followUsers): void
{
$externalUserId = trim($externalUserId);
if ($externalUserId === '') {
return;
}
$rows = [];
foreach ($followUsers as $fu) {
if (!is_array($fu)) {
continue;
}
$uid = trim((string) ($fu['userid'] ?? ''));
if ($uid === '') {
continue;
}
$rows[$uid] = self::normalizeFollowUserCreatetime($fu);
}
try {
if ($rows === []) {
Db::name('qywx_external_contact_follow')
->where('external_userid', $externalUserId)
->delete();
return;
}
Db::name('qywx_external_contact_follow')
->where('external_userid', $externalUserId)
->whereNotIn('follow_user_id', array_keys($rows))
->delete();
$now = time();
foreach ($rows as $uid => $createtime) {
Db::name('qywx_external_contact_follow')
->duplicate(['createtime' => $createtime, 'update_time' => $now])
->insert([
'external_userid' => $externalUserId,
'follow_user_id' => $uid,
'createtime' => $createtime,
'create_time' => $now,
'update_time' => $now,
]);
}
} catch (\Throwable $e) {
Log::warning('qywx contact follow relation sync failed: ' . $e->getMessage(), [
'ext' => $externalUserId,
]);
}
}
/**
* 把所有 follow_user[].tags 合并去重(按 tag_id),返回 JSON 字符串。
*
@@ -1062,6 +1111,38 @@ class CustomerLogic extends BaseLogic
return json_encode(array_values(array_unique($adminIds)), JSON_UNESCAPED_UNICODE);
}
/**
* 由企微 API 返回的 external_contact + follow_user 组装一行客户表数据
*
* @param array<string, mixed> $externalContact
* @param array<int, array<string, mixed>> $followUsers
* @return array<string, mixed>
*/
private static function buildContactRow(array $externalContact, array $followUsers): array
{
$now = time();
return [
'external_userid' => $externalContact['external_userid'] ?? '',
'name' => $externalContact['name'] ?? '',
'avatar' => $externalContact['avatar'] ?? '',
'type' => $externalContact['type'] ?? 1,
'gender' => $externalContact['gender'] ?? 0,
'unionid' => $externalContact['unionid'] ?? '',
'position' => $externalContact['position'] ?? '',
'corp_name' => $externalContact['corp_name'] ?? '',
'corp_full_name' => $externalContact['corp_full_name'] ?? '',
'external_profile' => json_encode($externalContact['external_profile'] ?? [], JSON_UNESCAPED_UNICODE),
'follow_users' => json_encode($followUsers, JSON_UNESCAPED_UNICODE),
'tags' => self::extractFollowUserTags($followUsers),
'follow_admin_ids' => self::resolveFollowAdminIds($followUsers),
'external_first_add_time' => self::minFollowCreatetime($followUsers),
'create_time' => $now,
'update_time' => $now,
'delete_time' => null,
];
}
/**
* 是否 MySQL 锁等待超时 / 死锁(可短重试)
*/
@@ -1073,7 +1154,13 @@ class CustomerLogic extends BaseLogic
}
/**
* INSERT ... ON DUPLICATE KEY UPDATE + 锁冲突重试
* 客户行写入 + 锁冲突重试
*
* 表允许同一 external_userid 多行(重复添加会新增行),不再依赖唯一键 UPSERT:
* - 已有行 → 只更新"最新一行"(MAX(id)),历史重加行保持原样;
* - 无行 → 插入新行。
* 更新时不覆盖 create_time / readd_flag / external_first_add_time
* (最新行的添加时间代表"该行那次添加"的时间,由插入时决定,后续详情刷新不应改动)。
*
* @param array<string, mixed> $row
*/
@@ -1084,33 +1171,21 @@ class CustomerLogic extends BaseLogic
while (true) {
$attempt++;
try {
$affected = Db::name('qywx_external_contact')->duplicate([
'name',
'avatar',
'type',
'gender',
'unionid',
'position',
'corp_name',
'corp_full_name',
'external_profile',
'follow_users',
'tags',
'follow_admin_ids',
'external_first_add_time',
'update_time',
'delete_time',
])->insert($row);
$latestId = (int) Db::name('qywx_external_contact')
->where('external_userid', (string) $row['external_userid'])
->order('id', 'desc')
->value('id');
$syncCount++;
// PDO MySQL:新插入 1;更新 2;值完全相同 0
if ($affected === 1) {
if ($latestId > 0) {
$update = $row;
unset($update['external_userid'], $update['create_time'], $update['external_first_add_time']);
Db::name('qywx_external_contact')->where('id', $latestId)->update($update);
$updateCount++;
} else {
Db::name('qywx_external_contact')->insert($row);
$newCount++;
} elseif ($affected === 2) {
$updateCount++;
} elseif ($affected === 0) {
$updateCount++;
}
$syncCount++;
return;
} catch (\Throwable $e) {
@@ -1211,7 +1286,15 @@ class CustomerLogic extends BaseLogic
*/
public static function getStats()
{
$total = QywxExternalContact::whereNull('delete_time')->count();
// 表内同一客户可能多行(重复添加各占一行),去重后才是真实客户数
$total = (int) Db::name('qywx_external_contact')
->whereNull('delete_time')
->fieldRaw('COUNT(DISTINCT external_userid) AS c')
->find()['c'];
// 企微管理后台「全部客户」按"客户×添加人"关系计数(同一客户被 N 名员工添加计 N 条),
// 现存跟进关系表与其同口径,便于对账。
$relationTotal = (int) Db::name('qywx_external_contact_follow')->count();
$todayStart = strtotime(date('Y-m-d 00:00:00'));
@@ -1254,6 +1337,7 @@ class CustomerLogic extends BaseLogic
return [
'total' => $total,
'relation_total' => $relationTotal,
'today' => $today,
'today_follow_staff' => $todayFollowStaff,
'lastSync' => $lastSync,
@@ -1332,14 +1416,74 @@ class CustomerLogic extends BaseLogic
}
}
// 老客户回流数(去重客户维度):今天有 add 事件,但今天之前就加过——
// 依据①事件流水里有更早的 add 事件,或②客户表首次添加时间早于今天(覆盖流水表上线前的老客户)
$returning = 0;
if ($total > 0) {
$todayExtIds = Db::name('qywx_external_contact_event')
->where('change_type', 'add_external_contact')
->where('event_time', '>=', $todayStart)
->where('event_time', '<', $todayEnd)
->group('external_userid')
->column('external_userid');
$todayExtIds = array_values(array_filter($todayExtIds));
if ($todayExtIds !== []) {
$oldSet = self::resolveOldCustomerSet($todayExtIds, $todayStart);
$returning = count($oldSet);
}
}
return [
'total' => $total,
'recent_time' => $recent,
'returning' => $returning,
'hourly' => array_values($hourly),
'by_state' => $byState,
];
}
/**
* 判定"老客户":在 $beforeTs 之前就已加过企业的客户集合。
*
* 依据(满足其一即算老客户):
* ① 事件流水表在 $beforeTs 之前存在该客户的 add_external_contact 记录;
* ② 客户表 external_first_add_time(企微 follow_user 最早 createtime)早于 $beforeTs——
* 覆盖事件流水表上线之前就已存在的客户。
*
* @param string[] $extIds
* @return array<string, true> external_userid 为键的集合
*/
private static function resolveOldCustomerSet(array $extIds, int $beforeTs): array
{
$extIds = array_values(array_unique(array_filter($extIds)));
if ($extIds === []) {
return [];
}
$old = [];
$earlierEventIds = Db::name('qywx_external_contact_event')
->whereIn('external_userid', $extIds)
->where('change_type', 'add_external_contact')
->where('event_time', '<', $beforeTs)
->group('external_userid')
->column('external_userid');
foreach ($earlierEventIds as $id) {
$old[(string) $id] = true;
}
$earlierFirstAddIds = Db::name('qywx_external_contact')
->whereIn('external_userid', $extIds)
->where('external_first_add_time', '>', 0)
->where('external_first_add_time', '<', $beforeTs)
->column('external_userid');
foreach ($earlierFirstAddIds as $id) {
$old[(string) $id] = true;
}
return $old;
}
/**
* 今日"进入明细"流水(分页,按时间倒序)
*
@@ -1391,6 +1535,9 @@ class CustomerLogic extends BaseLogic
$adminMap = Admin::whereIn('work_wechat_userid', $userIds)->column('name', 'work_wechat_userid');
}
// 标记老客户:今天之前就加过企业(重加 / 被另一名员工添加的回流客户)
$oldSet = self::resolveOldCustomerSet($extIds, $todayStart);
$lists = [];
foreach ($rows as $r) {
$ext = (string) ($r['external_userid'] ?? '');
@@ -1405,6 +1552,7 @@ class CustomerLogic extends BaseLogic
'customer_avatar' => (string) ($customerMap[$ext]['avatar'] ?? ''),
'state' => (string) ($r['state'] ?? ''),
'welcome_code' => (int) ($r['welcome_code'] ?? 0),
'is_old_customer' => isset($oldSet[$ext]) ? 1 : 0,
];
}
@@ -133,12 +133,6 @@ class QywxExternalContactCallbackController extends BaseApiController
$failReason !== '' ? $failReason : '-'
));
if ($changeType === 'del_external_contact') {
CustomerLogic::softDeleteExternalContactRow($extId);
return;
}
if ($changeType === 'add_half_external_contact') {
// 半客户:客户尚未通过验证,/externalcontact/get 通常返回 84061「客户尚未通过」之类,
// 这里只 log 不写库,避免产生 noise;客户通过后会再触发 add_external_contact 事件再走 UPSERT。
@@ -153,17 +147,17 @@ class QywxExternalContactCallbackController extends BaseApiController
return;
}
if ($changeType === 'del_follow_user') {
// 某员工不再跟进该客户:只需把本地 follow_users 里对应 userid 移除
// 若已无跟进人则软删;不再回调 /externalcontact/get(最后一个跟进人被删时会稳定返回 84061)
if ($userId !== '') {
CustomerLogic::removeFollowUserFromLocal($extId, $userId);
}
return;
}
// 其余变更(添加/编辑/转接成功/标签变化等):以 get 详情为准 UPSERT,避免遗漏未枚举的 ChangeType
CustomerLogic::upsertSingleExternalContactFromApi($extId);
// 其余全部变更(添加/编辑/删除/被删/转接/标签变化等):一律以 /externalcontact/get 的真实状态为准。
// - 仍是企业客户(含"客户单方删除员工",企微会保留客户与跟进关系)→ UPSERT 最新数据
// - 已彻底不是企业客户(84061)→ 本地软删
// 实测验证:del_follow_user(客户删员工)后 get 依然成功返回且 follow_user 保留,
// 本地自行推断移除跟进人/软删会导致客户数持续少于企微,故删除类事件不再走本地推断。
// 添加事件若命中已有客户(含软删行):不动原有行,新增一行 readd_flag=1(以前加过)的记录。
CustomerLogic::upsertSingleExternalContactFromApi(
$extId,
$changeType === 'add_external_contact',
$userId,
$eventTime
);
}
}