Compare commits
7
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
58cc59cf8a | ||
|
|
796441b407 | ||
|
|
f6688a740d | ||
|
|
a347e0642c | ||
|
|
333a18c37e | ||
|
|
e3a91a91f7 | ||
|
|
e01f3e7072 |
Binary file not shown.
@@ -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">
|
||||
{{ row.customer_name || row.external_userid || '(未知客户)' }}
|
||||
<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'] ?? ''));
|
||||
$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]);
|
||||
$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 ($addEnd !== '') {
|
||||
$t = strtotime($addEnd . ' 23:59:59');
|
||||
if ($t !== false) {
|
||||
$query->whereRaw($effExpr . ' > 0 AND ' . $effExpr . ' <= ?', [$t]);
|
||||
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 ($startTs !== false) {
|
||||
$query->whereRaw($effExpr . ' > 0 AND ' . $effExpr . ' >= ?', [$startTs]);
|
||||
}
|
||||
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);
|
||||
|
||||
|
||||
@@ -585,15 +585,32 @@ class CustomerLogic extends BaseLogic
|
||||
/**
|
||||
* 客户联系事件回调:按 external_userid 拉取详情并 UPSERT(不走全量同步文件锁)。
|
||||
*
|
||||
* @param bool $isAddEvent 是否 add_external_contact 事件:是且本地已有该客户(含软删行)时,
|
||||
* 新增一行 readd_flag=1(以前加过)的记录,原有行不动
|
||||
* @param string $eventUserId 本次添加人的企微 userid(add 事件回调里的 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')
|
||||
Db::name('qywx_external_contact_follow')
|
||||
->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')
|
||||
->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);
|
||||
->delete();
|
||||
}
|
||||
|
||||
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,8 +1286,16 @@ 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'));
|
||||
|
||||
// 今日进入数 = 今日收到的 add_external_contact 事件条数。
|
||||
@@ -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
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
File diff suppressed because one or more lines are too long
@@ -0,0 +1,5 @@
|
||||
-- 医生表(la_admin)新增字段:是否开启美颜功能
|
||||
-- 开启后医生端(管理后台通话)才显示美颜设置入口;关闭则不显示
|
||||
-- 若项目使用 zyt_ 前缀,请将 la_admin 替换为 zyt_admin 后执行
|
||||
|
||||
ALTER TABLE `zyt_admin` ADD COLUMN `enable_beauty` tinyint(1) NOT NULL DEFAULT 1 COMMENT '是否开启美颜功能:0-关闭 1-开启';
|
||||
@@ -0,0 +1,17 @@
|
||||
-- 企微客户表:新增「以前加过」标记
|
||||
-- 语义:该客户本次添加之前就加过企业(被删除后重新添加,或已是企业客户又被其他员工添加)。
|
||||
-- 写入:客户联系回调收到 add_external_contact 时,若本地已存在该 external_userid(含软删行)则置 1;
|
||||
-- 置 1 后不回退(历史事实)。全量同步不改此字段。
|
||||
ALTER TABLE `zyt_qywx_external_contact`
|
||||
ADD COLUMN `readd_flag` tinyint(1) unsigned NOT NULL DEFAULT 0 COMMENT '以前加过:1=本次添加前已加过企业(重加/多员工添加)' AFTER `external_first_add_time`;
|
||||
|
||||
-- 历史回填:事件流水中有 2 条及以上 add_external_contact 的客户,说明发生过重复添加
|
||||
UPDATE `zyt_qywx_external_contact` c
|
||||
INNER JOIN (
|
||||
SELECT `external_userid`
|
||||
FROM `zyt_qywx_external_contact_event`
|
||||
WHERE `change_type` = 'add_external_contact' AND `external_userid` <> ''
|
||||
GROUP BY `external_userid`
|
||||
HAVING COUNT(*) >= 2
|
||||
) e ON e.`external_userid` = c.`external_userid`
|
||||
SET c.`readd_flag` = 1;
|
||||
@@ -0,0 +1,8 @@
|
||||
-- 企微客户事件流水表:按客户维度查询的索引
|
||||
-- 背景:今日进入明细/统计需要标记「老客户」(判断某客户在今天之前是否有 add_external_contact 事件),
|
||||
-- 查询形态为 WHERE external_userid IN (...) AND change_type = ? AND event_time < ?。
|
||||
-- 现有索引均以 change_type / event_time / state 开头,无法按 external_userid 检索,
|
||||
-- 26 万行时单次判定耗时可达数十秒;补充此索引后为毫秒级。
|
||||
-- 注意:MySQL 5.7 不支持 ADD INDEX IF NOT EXISTS,重复执行会报 1061(索引已存在),可忽略。
|
||||
ALTER TABLE `zyt_qywx_external_contact_event`
|
||||
ADD INDEX `idx_ext_change_time` (`external_userid`, `change_type`, `event_time`);
|
||||
@@ -0,0 +1,10 @@
|
||||
-- 企微客户表:允许同一 external_userid 多行(每次"添加"新增一条记录)
|
||||
-- 背景:产品要求与企微「全部客户」展示对齐——客户被删除后重新添加、或被其他员工再次添加时,
|
||||
-- 不覆盖原纪录,而是新增一行并打 readd_flag=1(以前加过),原有行保持不变。
|
||||
-- 落库逻辑同步调整(CustomerLogic):
|
||||
-- * 回调 add_external_contact 且已有该客户 → INSERT 新行(readd_flag=1);
|
||||
-- * 其他事件 / 全量同步 → 只更新该客户"最新一行"(MAX(id)),不再依赖唯一键 UPSERT;
|
||||
-- * 彻底流失(84061)→ 该客户所有行一并软删。
|
||||
ALTER TABLE `zyt_qywx_external_contact`
|
||||
DROP INDEX `uk_external_userid`,
|
||||
ADD INDEX `idx_external_userid` (`external_userid`);
|
||||
@@ -0,0 +1,17 @@
|
||||
-- 企微客户「现存跟进关系」表:external_userid × follow_user_id 一行,带企微 createtime
|
||||
-- 用途:客户列表「任意添加(企微口径)」时间筛选——企微后台的时间筛选按"现存跟进关系的添加时间"
|
||||
-- 匹配(关系被删后该客户不再出现在对应日期下),follow_users JSON 无法做 SQL 范围筛选,故落关系表。
|
||||
-- 维护:CustomerLogic 在每次 UPSERT 客户时同步;软删客户时清空该客户的关系行。
|
||||
-- 回填:执行 php think 版本回填脚本或 server/backfill_follow_relations.php(从 follow_users JSON 解析)。
|
||||
CREATE TABLE IF NOT EXISTS `zyt_qywx_external_contact_follow` (
|
||||
`id` bigint(20) unsigned NOT NULL AUTO_INCREMENT COMMENT '主键ID',
|
||||
`external_userid` varchar(64) NOT NULL DEFAULT '' COMMENT '外部联系人 external_userid',
|
||||
`follow_user_id` varchar(64) NOT NULL DEFAULT '' COMMENT '跟进员工企微 userid',
|
||||
`createtime` int(10) unsigned NOT NULL DEFAULT 0 COMMENT '该员工添加此客户的时间(企微 follow_user.createtime,秒)',
|
||||
`create_time` int(10) unsigned NOT NULL DEFAULT 0 COMMENT '本行创建时间',
|
||||
`update_time` int(10) unsigned NOT NULL DEFAULT 0 COMMENT '本行更新时间',
|
||||
PRIMARY KEY (`id`),
|
||||
UNIQUE KEY `uk_ext_user` (`external_userid`, `follow_user_id`),
|
||||
KEY `idx_createtime` (`createtime`),
|
||||
KEY `idx_user` (`follow_user_id`)
|
||||
) ENGINE = InnoDB DEFAULT CHARSET = utf8mb4 COMMENT = '企微客户现存跟进关系(企微口径时间筛选用)';
|
||||
Reference in New Issue
Block a user