This commit is contained in:
Your Name
2026-08-08 15:42:45 +08:00
parent a968945057
commit d10f213573
21 changed files with 2610 additions and 279 deletions
@@ -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 NULLMySQL 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 NULLMySQL 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_sourcevarchar),旧表写 channelsint),
* 过渡库可能两列同时存在。不能因为运行库采用其中一种结构而漏统或报错。
*
* @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