@@ -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' ));
$m ediaChannelCode = MediaChannelService :: normalizeStatsCode (( string ) ( $params [ 'media_channel_code' ] ? ? '' ));
$mediaChannel = $m ediaChannelCode !== '' ? MediaChannelService :: getChannelByCode ( $mediaChannelCode ) : null ;
$requestedM ediaChannelCode = trim (( string )( $params [ 'media_channel_code' ] ? ? '' ));
$mediaChannel = $requestedM ediaChannelCode !== ''
? 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 ;
$restrictStatsBy Dept = $supportsDeptBinding && $mediaChannelCode !== '' ;
$scopeDeptIds = $restrictStatsByDept
$channelBound DeptIds = $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 ) {
$admin Id = ( int )( $row [ 'admin _id' ] ? ? 0 );
$user Id = ( 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_ord er_ 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_us erid', $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_ord er_ 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_us erid', $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 ( $r ows as $row ) {
$admin Id = ( int )( $row [ 'admin _id' ] ? ? 0 );
foreach ( $fanR ows as $row ) {
$user Id = ( 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