更新
This commit is contained in:
@@ -18,6 +18,14 @@ class ConversionLogic
|
||||
private const VIRTUAL_DEPT_UNBOUND_ADMIN_ID = -1;
|
||||
private const VIRTUAL_DEPT_UNASSIGNED_ID = -2;
|
||||
|
||||
/**
|
||||
* Per-overview raw aggregate cache. It is reset at the beginning of every
|
||||
* overview call so long-running workers never reuse stale business data.
|
||||
*
|
||||
* @var array<string, array<int, array<string, mixed>>>
|
||||
*/
|
||||
private static array $requestRowsCache = [];
|
||||
|
||||
/**
|
||||
* @param array $params
|
||||
* @param int $adminId 当前操作 admin(来自 BaseAdminController)
|
||||
@@ -39,14 +47,19 @@ class ConversionLogic
|
||||
?array $trustedCostAllocationAdminIdsOverride = null
|
||||
): array
|
||||
{
|
||||
self::$requestRowsCache = [];
|
||||
|
||||
$includeFilters = (int)($params['include_filters'] ?? 0) === 1;
|
||||
// 仅供需要“有效挂号”口径的内部看板调用;默认保持转换统计历史口径不变。
|
||||
$excludeCancelledAppointments = (int)($params['exclude_cancelled_appointments'] ?? 0) === 1;
|
||||
// 一诊综合转化复用处方订单页的业绩口径;其它调用方继续保留历史“双审完成单”口径。
|
||||
$usePerformanceOrderMetrics = strtolower(trim((string)($params['order_metric_mode'] ?? ''))) === 'performance';
|
||||
$dimension = self::normalizeDimension((string)($params['dimension'] ?? 'dept'));
|
||||
$mediaChannelCode = MediaChannelService::normalizeStatsCode((string) ($params['media_channel_code'] ?? ''));
|
||||
$mediaChannel = $mediaChannelCode !== '' ? MediaChannelService::getChannelByCode($mediaChannelCode) : null;
|
||||
$requestedMediaChannelCode = trim((string)($params['media_channel_code'] ?? ''));
|
||||
$mediaChannel = $requestedMediaChannelCode !== ''
|
||||
? MediaChannelService::getChannelByCode($requestedMediaChannelCode)
|
||||
: null;
|
||||
$mediaChannelCode = $mediaChannel !== null ? $requestedMediaChannelCode : '';
|
||||
$filterEmptyEntities = $mediaChannel !== null;
|
||||
[$startTimestamp, $endTimestamp, $startDate, $endDate] = self::resolveTimeRange($params);
|
||||
$pageNo = max(1, (int)($params['page_no'] ?? 1));
|
||||
@@ -175,9 +188,14 @@ class ConversionLogic
|
||||
[$globalAccountCost, $accountCostDeptIds] = self::hydrateAccountCostStats($entities, $startDate, $endDate, $mediaChannelCode, $visibleDeptIds);
|
||||
$supportsDeptBinding = AccountCost::supportsDeptBinding();
|
||||
$restrictAccountCostByDept = $supportsDeptBinding;
|
||||
$restrictStatsByDept = $supportsDeptBinding && $mediaChannelCode !== '';
|
||||
$scopeDeptIds = $restrictStatsByDept
|
||||
$channelBoundDeptIds = $supportsDeptBinding && $mediaChannelCode !== ''
|
||||
? self::loadChannelBoundDeptIds($mediaChannelCode)
|
||||
: [];
|
||||
// 渠道尚未维护投放成本时,不能把真实的加粉、挂号和订单一并过滤为空。
|
||||
// 已维护绑定关系的渠道继续按绑定部门收窄;成本本身仍只在实际成本部门内分摊。
|
||||
$restrictStatsByDept = $channelBoundDeptIds !== [];
|
||||
$scopeDeptIds = $restrictStatsByDept
|
||||
? $channelBoundDeptIds
|
||||
: $accountCostDeptIds;
|
||||
$eligibleDeptIds = $restrictAccountCostByDept ? self::expandDeptIdsWithDescendants($scopeDeptIds) : $scopeDeptIds;
|
||||
|
||||
@@ -909,29 +927,16 @@ class ConversionLogic
|
||||
?array $mediaChannel,
|
||||
?array $visibleAdminIds = null
|
||||
): void {
|
||||
$query = Db::name('qywx_external_contact_event')
|
||||
->alias('e')
|
||||
->leftJoin('admin a', 'a.work_wechat_userid = e.user_id AND a.delete_time IS NULL')
|
||||
->where('e.change_type', 'add_external_contact')
|
||||
->where('e.event_time', 'between', [$startTimestamp, $endTimestamp])
|
||||
->fieldRaw('a.id AS admin_id, COUNT(*) AS add_fans_count')
|
||||
->group('a.id');
|
||||
|
||||
if ($visibleAdminIds !== null) {
|
||||
$query->whereIn('a.id', $visibleAdminIds);
|
||||
} elseif ($dimension !== 'dept' && $entityIds !== []) {
|
||||
$query->whereIn('a.id', $entityIds);
|
||||
$queryAdminIds = $visibleAdminIds;
|
||||
if ($queryAdminIds === null && $dimension !== 'dept') {
|
||||
$queryAdminIds = $entityIds;
|
||||
}
|
||||
|
||||
if ($mediaChannel !== null) {
|
||||
$query->leftJoin('qywx_external_contact q', 'q.external_userid = e.external_userid');
|
||||
MediaChannelService::applyFollowUsersChannelFilter($query, 'q.follow_users', $mediaChannel);
|
||||
}
|
||||
|
||||
$rows = $query->select()->toArray();
|
||||
$rows = self::loadFanRows($startTimestamp, $endTimestamp, $mediaChannel, $queryAdminIds);
|
||||
$adminByUserId = self::loadActiveAdminByWorkWechatUserIds(array_column($rows, 'user_id'));
|
||||
|
||||
foreach ($rows as $row) {
|
||||
$adminId = (int)($row['admin_id'] ?? 0);
|
||||
$userId = (string)($row['user_id'] ?? '');
|
||||
$adminId = (int)($adminByUserId[$userId]['id'] ?? 0);
|
||||
$addFansCount = (int)($row['add_fans_count'] ?? 0);
|
||||
|
||||
if ($dimension === 'dept' && $adminId <= 0) {
|
||||
@@ -968,6 +973,123 @@ class ConversionLogic
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Aggregate add-contact events by WeCom user once, then project that raw
|
||||
* snapshot to departments, members and virtual buckets in PHP.
|
||||
*
|
||||
* @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}>
|
||||
*/
|
||||
private static function loadFanRows(
|
||||
int $startTimestamp,
|
||||
int $endTimestamp,
|
||||
?array $mediaChannel,
|
||||
?array $adminIds = null
|
||||
): array {
|
||||
if ($adminIds !== null) {
|
||||
$adminIds = array_values(array_unique(array_filter(
|
||||
array_map('intval', $adminIds),
|
||||
static fn (int $id): bool => $id > 0
|
||||
)));
|
||||
sort($adminIds);
|
||||
if ($adminIds === []) {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
$baseKey = self::requestRowsCacheKey('fans', [
|
||||
$startTimestamp,
|
||||
$endTimestamp,
|
||||
self::mediaChannelCacheKey($mediaChannel),
|
||||
]);
|
||||
$allKey = $baseKey . ':all';
|
||||
$cacheKey = $adminIds === null
|
||||
? $allKey
|
||||
: $baseKey . ':admins:' . implode(',', $adminIds);
|
||||
if (isset(self::$requestRowsCache[$cacheKey])) {
|
||||
return self::$requestRowsCache[$cacheKey];
|
||||
}
|
||||
|
||||
$workWechatUserIds = null;
|
||||
if ($adminIds !== null) {
|
||||
$workWechatUserIds = Db::name('admin')
|
||||
->whereIn('id', $adminIds)
|
||||
->whereNull('delete_time')
|
||||
->where('work_wechat_userid', '<>', '')
|
||||
->column('work_wechat_userid');
|
||||
$workWechatUserIds = array_values(array_unique(array_filter(array_map('strval', $workWechatUserIds))));
|
||||
if ($workWechatUserIds === []) {
|
||||
self::$requestRowsCache[$cacheKey] = [];
|
||||
|
||||
return [];
|
||||
}
|
||||
|
||||
if (isset(self::$requestRowsCache[$allKey])) {
|
||||
$allowed = array_fill_keys($workWechatUserIds, true);
|
||||
self::$requestRowsCache[$cacheKey] = array_values(array_filter(
|
||||
self::$requestRowsCache[$allKey],
|
||||
static fn (array $row): bool => isset($allowed[(string)($row['user_id'] ?? '')])
|
||||
));
|
||||
|
||||
return self::$requestRowsCache[$cacheKey];
|
||||
}
|
||||
}
|
||||
|
||||
$query = Db::name('qywx_external_contact_event')
|
||||
->alias('e')
|
||||
->where('e.change_type', 'add_external_contact')
|
||||
->where('e.event_time', 'between', [$startTimestamp, $endTimestamp])
|
||||
->fieldRaw('e.user_id, COUNT(*) AS add_fans_count')
|
||||
->group('e.user_id');
|
||||
if ($workWechatUserIds !== null) {
|
||||
$query->whereIn('e.user_id', $workWechatUserIds);
|
||||
}
|
||||
if ($mediaChannel !== null) {
|
||||
MediaChannelService::applyExternalUserChannelFilter($query, 'e.external_userid', $mediaChannel);
|
||||
}
|
||||
|
||||
self::$requestRowsCache[$cacheKey] = $query->select()->toArray();
|
||||
|
||||
return self::$requestRowsCache[$cacheKey];
|
||||
}
|
||||
|
||||
/**
|
||||
* @param string[] $userIds
|
||||
* @return array<string, array{id: int|string, name: string}>
|
||||
*/
|
||||
private static function loadActiveAdminByWorkWechatUserIds(array $userIds): array
|
||||
{
|
||||
$userIds = array_values(array_unique(array_filter(array_map('strval', $userIds))));
|
||||
sort($userIds);
|
||||
if ($userIds === []) {
|
||||
return [];
|
||||
}
|
||||
|
||||
$cacheKey = self::requestRowsCacheKey('active-admin-by-wecom-user', $userIds);
|
||||
if (!isset(self::$requestRowsCache[$cacheKey])) {
|
||||
self::$requestRowsCache[$cacheKey] = Db::name('admin')
|
||||
->whereIn('work_wechat_userid', $userIds)
|
||||
->whereNull('delete_time')
|
||||
->field('id, name, work_wechat_userid')
|
||||
->select()
|
||||
->toArray();
|
||||
}
|
||||
|
||||
$result = [];
|
||||
foreach (self::$requestRowsCache[$cacheKey] as $row) {
|
||||
$userId = (string)($row['work_wechat_userid'] ?? '');
|
||||
if ($userId !== '' && !isset($result[$userId])) {
|
||||
$result[$userId] = [
|
||||
'id' => (int)($row['id'] ?? 0),
|
||||
'name' => (string)($row['name'] ?? ''),
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
return $result;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<int, array<string, mixed>> $entities
|
||||
* @param int[] $entityIds
|
||||
@@ -989,46 +1111,56 @@ class ConversionLogic
|
||||
bool $excludeCancelledAppointments = false,
|
||||
bool $useRegistrationMetric = false
|
||||
): void {
|
||||
$sourceExpr = $dimension === 'doctor'
|
||||
? 'a.doctor_id'
|
||||
: 'COALESCE(NULLIF(a.assistant_id, 0), NULLIF(u.assistant_id, 0))';
|
||||
$query = Db::name('doctor_appointment')
|
||||
->alias('a')
|
||||
->leftJoin('tcm_diagnosis u', 'a.patient_id = u.id')
|
||||
->where('a.appointment_date', '>=', $startDate)
|
||||
->where('a.appointment_date', '<=', $endDate)
|
||||
->fieldRaw("{$sourceExpr} AS source_admin_id, a.patient_id AS diagnosis_id, COUNT(*) AS appointment_count, SUM(CASE WHEN a.status = 3 THEN 1 ELSE 0 END) AS interview_count")
|
||||
->group("{$sourceExpr}, a.patient_id");
|
||||
$sourceType = $dimension === 'doctor' ? 'doctor' : 'assistant';
|
||||
$rows = self::cachedRequestRows('appointments', [
|
||||
$sourceType,
|
||||
$startDate,
|
||||
$endDate,
|
||||
self::mediaChannelCacheKey($mediaChannel),
|
||||
$excludeCancelledAppointments,
|
||||
], static function () use (
|
||||
$sourceType,
|
||||
$startDate,
|
||||
$endDate,
|
||||
$mediaChannel,
|
||||
$excludeCancelledAppointments
|
||||
): array {
|
||||
$sourceExpr = $sourceType === 'doctor'
|
||||
? 'a.doctor_id'
|
||||
: 'COALESCE(NULLIF(a.assistant_id, 0), NULLIF(u.assistant_id, 0))';
|
||||
$query = Db::name('doctor_appointment')
|
||||
->alias('a')
|
||||
->leftJoin('tcm_diagnosis u', 'a.patient_id = u.id')
|
||||
->where('a.appointment_date', '>=', $startDate)
|
||||
->where('a.appointment_date', '<=', $endDate)
|
||||
->where('a.patient_id', '>', 0)
|
||||
->fieldRaw("{$sourceExpr} AS source_admin_id, COUNT(*) AS appointment_count, SUM(CASE WHEN a.status = 3 THEN 1 ELSE 0 END) AS interview_count")
|
||||
->group($sourceExpr);
|
||||
|
||||
if ($excludeCancelledAppointments) {
|
||||
$query->whereIn('a.status', [1, 3, 4]);
|
||||
}
|
||||
if ($excludeCancelledAppointments) {
|
||||
$query->whereIn('a.status', [1, 3, 4]);
|
||||
}
|
||||
|
||||
$query->where(static function (Query $subQuery): void {
|
||||
$subQuery->whereNull('u.id')
|
||||
->whereOr(static function (Query $orQuery): void {
|
||||
$orQuery->whereNull('u.delete_time');
|
||||
});
|
||||
$query->where(static function (Query $subQuery): void {
|
||||
$subQuery->whereNull('u.id')
|
||||
->whereOr(static function (Query $orQuery): void {
|
||||
$orQuery->whereNull('u.delete_time');
|
||||
});
|
||||
});
|
||||
|
||||
if ($mediaChannel !== null) {
|
||||
$legacyChannelValues = MediaChannelService::getLegacyAppointmentChannelValues($mediaChannel);
|
||||
if ($legacyChannelValues !== []) {
|
||||
self::applyAppointmentChannelFilter($query, $legacyChannelValues);
|
||||
} else {
|
||||
MediaChannelService::applyExternalUserChannelFilter($query, 'u.external_userid', $mediaChannel);
|
||||
}
|
||||
}
|
||||
|
||||
return $query->select()->toArray();
|
||||
});
|
||||
|
||||
if ($mediaChannel !== null) {
|
||||
$legacyChannelValues = MediaChannelService::getLegacyAppointmentChannelValues($mediaChannel);
|
||||
if ($legacyChannelValues !== []) {
|
||||
$query->whereIn('a.channels', $legacyChannelValues);
|
||||
} else {
|
||||
$query->leftJoin('qywx_external_contact q', 'q.external_userid = u.external_userid');
|
||||
MediaChannelService::applyFollowUsersChannelFilter($query, 'q.follow_users', $mediaChannel);
|
||||
}
|
||||
}
|
||||
|
||||
$rows = $query->select()->toArray();
|
||||
|
||||
foreach ($rows as $row) {
|
||||
$diagnosisId = (int)($row['diagnosis_id'] ?? 0);
|
||||
if ($diagnosisId <= 0) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$sourceAdminId = (int)($row['source_admin_id'] ?? 0);
|
||||
|
||||
$mappedEntityIds = self::mapEntityIds($dimension, $sourceAdminId, $entityIds, $adminToDeptIds, $visibleAdminIds);
|
||||
@@ -1084,30 +1216,41 @@ class ConversionLogic
|
||||
): void {
|
||||
$startDateTime = date('Y-m-d H:i:s', $startTimestamp);
|
||||
$endDateTime = date('Y-m-d H:i:s', $endTimestamp);
|
||||
$query = Db::name('order')
|
||||
->alias('o')
|
||||
->whereNull('o.delete_time')
|
||||
->where('o.status', 2)
|
||||
// payment_time 是 DATETIME NULL;MySQL 8 严格模式下不能与空字符串比较。
|
||||
->whereNotNull('o.payment_time')
|
||||
->whereBetweenTime('o.payment_time', $startDateTime, $endDateTime)
|
||||
->fieldRaw('o.creator_id AS source_admin_id, COUNT(*) AS paid_appointment_count')
|
||||
->group('o.creator_id');
|
||||
$rows = self::cachedRequestRows('paid-appointments', [
|
||||
$startDateTime,
|
||||
$endDateTime,
|
||||
self::mediaChannelCacheKey($mediaChannel),
|
||||
$useRegistrationMetric,
|
||||
], static function () use (
|
||||
$startDateTime,
|
||||
$endDateTime,
|
||||
$mediaChannel,
|
||||
$useRegistrationMetric
|
||||
): array {
|
||||
$query = Db::name('order')
|
||||
->alias('o')
|
||||
->whereNull('o.delete_time')
|
||||
->where('o.status', 2)
|
||||
// payment_time 是 DATETIME NULL;MySQL 8 严格模式下不能与空字符串比较。
|
||||
->whereNotNull('o.payment_time')
|
||||
->whereBetweenTime('o.payment_time', $startDateTime, $endDateTime)
|
||||
->fieldRaw('o.creator_id AS source_admin_id, COUNT(*) AS paid_appointment_count')
|
||||
->group('o.creator_id');
|
||||
|
||||
if ($useRegistrationMetric) {
|
||||
// 一诊页面的新“挂号”:已支付且 0 < 实收金额 < 10 元,每笔支付订单计 1 个。
|
||||
$query->where('o.amount', '>', 0)->where('o.amount', '<', 10);
|
||||
} else {
|
||||
// 保留旧统计页面的历史“付费挂号”字段,避免本次一诊改造改变其它模块口径。
|
||||
$query->where('o.order_type', 1)->where('o.amount', 5);
|
||||
}
|
||||
if ($useRegistrationMetric) {
|
||||
// 一诊页面的新“挂号”:已支付且 0 < 实收金额 < 10 元,每笔支付订单计 1 个。
|
||||
$query->where('o.amount', '>', 0)->where('o.amount', '<', 10);
|
||||
} else {
|
||||
// 保留旧统计页面的历史“付费挂号”字段,避免本次一诊改造改变其它模块口径。
|
||||
$query->where('o.order_type', 1)->where('o.amount', 5);
|
||||
}
|
||||
|
||||
if ($mediaChannel !== null) {
|
||||
$query->leftJoin('qywx_external_contact q', 'q.external_userid = o.payer_external_userid');
|
||||
MediaChannelService::applyFollowUsersChannelFilter($query, 'q.follow_users', $mediaChannel);
|
||||
}
|
||||
if ($mediaChannel !== null) {
|
||||
MediaChannelService::applyExternalUserChannelFilter($query, 'o.payer_external_userid', $mediaChannel);
|
||||
}
|
||||
|
||||
$rows = $query->select()->toArray();
|
||||
return $query->select()->toArray();
|
||||
});
|
||||
foreach ($rows as $row) {
|
||||
$sourceAdminId = (int)($row['source_admin_id'] ?? 0);
|
||||
$mappedEntityIds = self::mapEntityIds($dimension, $sourceAdminId, $entityIds, $adminToDeptIds, $visibleAdminIds);
|
||||
@@ -1122,6 +1265,54 @@ class ConversionLogic
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 挂号渠道兼容:新表写 channel_source(varchar),旧表写 channels(int),
|
||||
* 过渡库可能两列同时存在。不能因为运行库采用其中一种结构而漏统或报错。
|
||||
*
|
||||
* @param int[] $channelValues
|
||||
*/
|
||||
private static function applyAppointmentChannelFilter(Query $query, array $channelValues): void
|
||||
{
|
||||
$channelValues = array_values(array_unique(array_filter(
|
||||
array_map('intval', $channelValues),
|
||||
static fn (int $value): bool => $value > 0
|
||||
)));
|
||||
if ($channelValues === []) {
|
||||
$query->whereRaw('0 = 1');
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
$fields = Db::name('doctor_appointment')->getTableFields();
|
||||
} catch (\Throwable) {
|
||||
$fields = [];
|
||||
}
|
||||
$fields = is_array($fields) ? $fields : [];
|
||||
$hasChannelSource = in_array('channel_source', $fields, true);
|
||||
$hasChannels = in_array('channels', $fields, true);
|
||||
|
||||
if (!$hasChannelSource && !$hasChannels) {
|
||||
$query->whereRaw('0 = 1');
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
$placeholders = implode(',', array_fill(0, count($channelValues), '?'));
|
||||
$parts = [];
|
||||
$bindings = [];
|
||||
if ($hasChannelSource) {
|
||||
$parts[] = "a.channel_source IN ({$placeholders})";
|
||||
array_push($bindings, ...array_map('strval', $channelValues));
|
||||
}
|
||||
if ($hasChannels) {
|
||||
$parts[] = "a.channels IN ({$placeholders})";
|
||||
array_push($bindings, ...$channelValues);
|
||||
}
|
||||
|
||||
$query->whereRaw('(' . implode(' OR ', $parts) . ')', $bindings);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<int, array<string, mixed>> $entities
|
||||
* @param int[] $entityIds
|
||||
@@ -1141,26 +1332,40 @@ class ConversionLogic
|
||||
bool $usePerformanceOrderMetrics = false
|
||||
): void {
|
||||
if ($usePerformanceOrderMetrics) {
|
||||
$sourceExpr = $dimension === 'doctor' ? 'rx.creator_id' : 'po.creator_id';
|
||||
$query = Db::name('tcm_prescription_order')
|
||||
->alias('po')
|
||||
->leftJoin('tcm_prescription rx', 'rx.id = po.prescription_id AND rx.delete_time IS NULL')
|
||||
->leftJoin('tcm_diagnosis dg', 'dg.id = po.diagnosis_id AND dg.delete_time IS NULL')
|
||||
->whereNull('po.delete_time')
|
||||
->where('po.create_time', 'between', [$startTimestamp, $endTimestamp]);
|
||||
YejiStatsLogic::applyPrescriptionOrderEffectiveAmountQuery($query, 'po');
|
||||
$query
|
||||
->fieldRaw("{$sourceExpr} AS source_admin_id, COUNT(*) AS order_count, SUM(po.amount) AS total_amount")
|
||||
->group($sourceExpr);
|
||||
|
||||
if ($mediaChannel !== null) {
|
||||
$isDoctorDimension = $dimension === 'doctor';
|
||||
$rows = self::cachedRequestRows('performance-orders', [
|
||||
$isDoctorDimension ? 'doctor' : 'assistant',
|
||||
$startTimestamp,
|
||||
$endTimestamp,
|
||||
self::mediaChannelCacheKey($mediaChannel),
|
||||
], static function () use (
|
||||
$isDoctorDimension,
|
||||
$startTimestamp,
|
||||
$endTimestamp,
|
||||
$mediaChannel
|
||||
): array {
|
||||
$sourceExpr = $isDoctorDimension ? 'rx.creator_id' : 'po.creator_id';
|
||||
$query = Db::name('tcm_prescription_order')
|
||||
->alias('po')
|
||||
->whereNull('po.delete_time')
|
||||
->where('po.create_time', 'between', [$startTimestamp, $endTimestamp]);
|
||||
if ($isDoctorDimension) {
|
||||
$query->leftJoin('tcm_prescription rx', 'rx.id = po.prescription_id AND rx.delete_time IS NULL');
|
||||
}
|
||||
YejiStatsLogic::applyPrescriptionOrderEffectiveAmountQuery($query, 'po');
|
||||
$query
|
||||
->leftJoin('order o', 'o.id = po.linked_pay_order_id')
|
||||
->leftJoin('qywx_external_contact q', 'q.external_userid = o.payer_external_userid');
|
||||
MediaChannelService::applyFollowUsersChannelFilter($query, 'q.follow_users', $mediaChannel);
|
||||
}
|
||||
->fieldRaw("{$sourceExpr} AS source_admin_id, COUNT(*) AS order_count, SUM(po.amount) AS total_amount")
|
||||
->group($sourceExpr);
|
||||
|
||||
foreach ($query->select()->toArray() as $row) {
|
||||
if ($mediaChannel !== null) {
|
||||
$query->leftJoin('order o', 'o.id = po.linked_pay_order_id');
|
||||
MediaChannelService::applyExternalUserChannelFilter($query, 'o.payer_external_userid', $mediaChannel);
|
||||
}
|
||||
|
||||
return $query->select()->toArray();
|
||||
});
|
||||
|
||||
foreach ($rows as $row) {
|
||||
$sourceAdminId = (int)($row['source_admin_id'] ?? 0);
|
||||
$mappedEntityIds = self::mapEntityIds(
|
||||
$dimension,
|
||||
@@ -1204,10 +1409,8 @@ class ConversionLogic
|
||||
->group($completedSourceExpr);
|
||||
|
||||
if ($mediaChannel !== null) {
|
||||
$completedQuery
|
||||
->leftJoin('order o', 'o.id = po.linked_pay_order_id')
|
||||
->leftJoin('qywx_external_contact q', 'q.external_userid = o.payer_external_userid');
|
||||
MediaChannelService::applyFollowUsersChannelFilter($completedQuery, 'q.follow_users', $mediaChannel);
|
||||
$completedQuery->leftJoin('order o', 'o.id = po.linked_pay_order_id');
|
||||
MediaChannelService::applyExternalUserChannelFilter($completedQuery, 'o.payer_external_userid', $mediaChannel);
|
||||
}
|
||||
|
||||
$completedRows = $completedQuery->select()->toArray();
|
||||
@@ -1241,10 +1444,8 @@ class ConversionLogic
|
||||
->group($businessSourceExpr);
|
||||
|
||||
if ($mediaChannel !== null) {
|
||||
$businessQuery
|
||||
->leftJoin('order o2', 'o2.id = po.linked_pay_order_id')
|
||||
->leftJoin('qywx_external_contact q2', 'q2.external_userid = o2.payer_external_userid');
|
||||
MediaChannelService::applyFollowUsersChannelFilter($businessQuery, 'q2.follow_users', $mediaChannel);
|
||||
$businessQuery->leftJoin('order o2', 'o2.id = po.linked_pay_order_id');
|
||||
MediaChannelService::applyExternalUserChannelFilter($businessQuery, 'o2.payer_external_userid', $mediaChannel);
|
||||
}
|
||||
|
||||
$businessRows = $businessQuery->select()->toArray();
|
||||
@@ -1911,22 +2112,13 @@ class ConversionLogic
|
||||
*/
|
||||
private static function buildUnboundFansRows(int $startTimestamp, int $endTimestamp, ?array $mediaChannel): array
|
||||
{
|
||||
$query = Db::name('qywx_external_contact_event')
|
||||
->alias('e')
|
||||
->leftJoin('admin a', 'a.work_wechat_userid = e.user_id AND a.delete_time IS NULL')
|
||||
->where('e.change_type', 'add_external_contact')
|
||||
->where('e.event_time', 'between', [$startTimestamp, $endTimestamp])
|
||||
->whereNull('a.id')
|
||||
->where('e.user_id', '<>', '')
|
||||
->fieldRaw('e.user_id, COUNT(*) AS add_fans_count')
|
||||
->group('e.user_id');
|
||||
$fanRows = self::loadFanRows($startTimestamp, $endTimestamp, $mediaChannel);
|
||||
$adminByUserId = self::loadActiveAdminByWorkWechatUserIds(array_column($fanRows, 'user_id'));
|
||||
$rows = array_values(array_filter($fanRows, static function (array $row) use ($adminByUserId): bool {
|
||||
$userId = (string)($row['user_id'] ?? '');
|
||||
|
||||
if ($mediaChannel !== null) {
|
||||
$query->leftJoin('qywx_external_contact q', 'q.external_userid = e.external_userid');
|
||||
MediaChannelService::applyFollowUsersChannelFilter($query, 'q.follow_users', $mediaChannel);
|
||||
}
|
||||
|
||||
$rows = $query->select()->toArray();
|
||||
return $userId !== '' && !isset($adminByUserId[$userId]);
|
||||
}));
|
||||
if ($rows === []) {
|
||||
return [];
|
||||
}
|
||||
@@ -1982,36 +2174,17 @@ class ConversionLogic
|
||||
?array $visibleAdminIds = null
|
||||
): array
|
||||
{
|
||||
$query = Db::name('qywx_external_contact_event')
|
||||
->alias('e')
|
||||
->join('admin a', 'a.work_wechat_userid = e.user_id AND a.delete_time IS NULL')
|
||||
->where('e.change_type', 'add_external_contact')
|
||||
->where('e.event_time', 'between', [$startTimestamp, $endTimestamp])
|
||||
->fieldRaw('a.id AS admin_id, a.name AS admin_name, COUNT(*) AS add_fans_count')
|
||||
->group('a.id, a.name');
|
||||
|
||||
if ($visibleAdminIds !== null) {
|
||||
if ($visibleAdminIds === []) {
|
||||
// 与 HasDataScopeFilter::applyDataScopeByOwner 对齐:空集合用 0=1 闸门让 SQL 自然返回空。
|
||||
$query->whereRaw('0 = 1');
|
||||
} else {
|
||||
$query->whereIn('a.id', $visibleAdminIds);
|
||||
}
|
||||
}
|
||||
|
||||
if ($mediaChannel !== null) {
|
||||
$query->leftJoin('qywx_external_contact q', 'q.external_userid = e.external_userid');
|
||||
MediaChannelService::applyFollowUsersChannelFilter($query, 'q.follow_users', $mediaChannel);
|
||||
}
|
||||
|
||||
$rows = $query->select()->toArray();
|
||||
if ($rows === []) {
|
||||
$fanRows = self::loadFanRows($startTimestamp, $endTimestamp, $mediaChannel, $visibleAdminIds);
|
||||
if ($fanRows === []) {
|
||||
return [];
|
||||
}
|
||||
$adminByUserId = self::loadActiveAdminByWorkWechatUserIds(array_column($fanRows, 'user_id'));
|
||||
|
||||
$result = [];
|
||||
foreach ($rows as $row) {
|
||||
$adminId = (int)($row['admin_id'] ?? 0);
|
||||
foreach ($fanRows as $row) {
|
||||
$userId = (string)($row['user_id'] ?? '');
|
||||
$admin = $adminByUserId[$userId] ?? null;
|
||||
$adminId = (int)($admin['id'] ?? 0);
|
||||
$addFansCount = (int)($row['add_fans_count'] ?? 0);
|
||||
if ($adminId <= 0 || $addFansCount <= 0) {
|
||||
continue;
|
||||
@@ -2019,7 +2192,7 @@ class ConversionLogic
|
||||
if (isset($assignedAdminIds[$adminId])) {
|
||||
continue;
|
||||
}
|
||||
$name = trim((string)($row['admin_name'] ?? ''));
|
||||
$name = trim((string)($admin['name'] ?? ''));
|
||||
if ($name === '') {
|
||||
$name = 'admin#' . $adminId;
|
||||
}
|
||||
@@ -2039,7 +2212,8 @@ class ConversionLogic
|
||||
|
||||
/**
|
||||
* 反查企微员工 user_id(如 CaoTaDuo)对应的中文名。
|
||||
* 来源依次:admin 表(含已软删的,避免离职后丢失映射)→ qywx_external_contact.follow_users JSON 中的 remark 字段。
|
||||
* 来源:admin 表(含已软删的,避免离职后丢失映射)。未命中时直接展示原始 userid,
|
||||
* 避免仅为展示名称对十几万行 follow_users TEXT 做前导通配全表扫描。
|
||||
*
|
||||
* @param string[] $userIds
|
||||
* @return array<string, string>
|
||||
@@ -2068,61 +2242,6 @@ class ConversionLogic
|
||||
$result[$userId] = $name;
|
||||
}
|
||||
|
||||
$remaining = array_values(array_diff($userIds, array_keys($result)));
|
||||
if ($remaining === []) {
|
||||
return $result;
|
||||
}
|
||||
|
||||
// 从 qywx_external_contact.follow_users JSON 的 remark/description 字段尽力反查。
|
||||
$followRows = Db::name('qywx_external_contact')
|
||||
->whereNull('delete_time')
|
||||
->where('follow_users', 'like', '%' . $remaining[0] . '%')
|
||||
->limit(0)
|
||||
->field('follow_users')
|
||||
->select()
|
||||
->toArray();
|
||||
if ($followRows === []) {
|
||||
// 单条 LIKE 没命中再退化全表(量大时会慢,因此仅在极少数员工场景下兜底)。
|
||||
$followRows = Db::name('qywx_external_contact')
|
||||
->whereNull('delete_time')
|
||||
->whereNotNull('follow_users')
|
||||
->where('follow_users', '<>', '')
|
||||
->limit(2000)
|
||||
->field('follow_users')
|
||||
->select()
|
||||
->toArray();
|
||||
}
|
||||
$remainingMap = array_fill_keys($remaining, true);
|
||||
foreach ($followRows as $row) {
|
||||
if ($remainingMap === []) {
|
||||
break;
|
||||
}
|
||||
$followUsers = json_decode((string)($row['follow_users'] ?? '[]'), true);
|
||||
if (!is_array($followUsers)) {
|
||||
continue;
|
||||
}
|
||||
foreach ($followUsers as $fu) {
|
||||
if (!is_array($fu)) {
|
||||
continue;
|
||||
}
|
||||
$uid = trim((string)($fu['userid'] ?? ''));
|
||||
if ($uid === '' || !isset($remainingMap[$uid])) {
|
||||
continue;
|
||||
}
|
||||
$name = trim((string)($fu['remark_corp_name'] ?? ''));
|
||||
if ($name === '') {
|
||||
$name = trim((string)($fu['remark'] ?? ''));
|
||||
}
|
||||
if ($name === '') {
|
||||
$name = trim((string)($fu['description'] ?? ''));
|
||||
}
|
||||
if ($name !== '') {
|
||||
$result[$uid] = $name;
|
||||
unset($remainingMap[$uid]);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return $result;
|
||||
}
|
||||
|
||||
@@ -2613,6 +2732,42 @@ class ConversionLogic
|
||||
return round($numerator / $denominator, 2);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<int, mixed> $parts
|
||||
*/
|
||||
private static function requestRowsCacheKey(string $namespace, array $parts): string
|
||||
{
|
||||
return $namespace . ':' . hash('sha256', serialize($parts));
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<int, mixed> $parts
|
||||
* @param callable(): array<int, array<string, mixed>> $loader
|
||||
* @return array<int, array<string, mixed>>
|
||||
*/
|
||||
private static function cachedRequestRows(string $namespace, array $parts, callable $loader): array
|
||||
{
|
||||
$cacheKey = self::requestRowsCacheKey($namespace, $parts);
|
||||
if (!isset(self::$requestRowsCache[$cacheKey])) {
|
||||
self::$requestRowsCache[$cacheKey] = $loader();
|
||||
}
|
||||
|
||||
return self::$requestRowsCache[$cacheKey];
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, mixed>|null $mediaChannel
|
||||
* @return array{code: string, tag_id: string, tag_name: string}
|
||||
*/
|
||||
private static function mediaChannelCacheKey(?array $mediaChannel): array
|
||||
{
|
||||
return [
|
||||
'code' => (string)($mediaChannel['channel_code'] ?? ''),
|
||||
'tag_id' => (string)($mediaChannel['source_tag_id'] ?? ''),
|
||||
'tag_name' => (string)($mediaChannel['source_tag_name'] ?? ''),
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* @param int[]|null $visibleAdminIds
|
||||
* @param int[] $eligibleDeptIds
|
||||
|
||||
Reference in New Issue
Block a user