>> */ private static array $requestRowsCache = []; /** * @param array $params * @param int $adminId 当前操作 admin(来自 BaseAdminController) * @param array $adminInfo 当前 admin 完整信息(含 root / role_id 数组等) * @param int[]|null $trustedVisibleAdminIdsOverride 仅供服务端内部可信调用覆盖本次可见管理员;不从 HTTP 参数读取 * @param int[]|null $trustedCostAllocationAdminIdsOverride 仅用于成本按加粉占比分摊的分母,不会放大任何业务指标 * @param array|null $trustedMediaChannelOverride 仅供服务端内部传入已校验渠道,避免再次按全局历史渠道口径解析 * @return array * * 数据权限:通过 DataScopeService::getVisibleAdminIds 拿到当前用户的"可见 admin id 集合"。 * - null:SCOPE_ALL,全数据放行(与历史行为一致) * - []:可见为空(SCOPE_SELF 且无绑定且关闭 fallback),返回空数据 * - 其他:用 visibleAdminIds 收窄 entities 加载、hydrate 数据归属、虚拟桶可见性、filters 选项 */ public static function overview( array $params = [], int $adminId = 0, ?array $adminInfo = null, ?array $trustedVisibleAdminIdsOverride = null, ?array $trustedCostAllocationAdminIdsOverride = null, ?array $trustedMediaChannelOverride = 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')); $requestedMediaChannelCode = trim((string)($params['media_channel_code'] ?? '')); $mediaChannel = $trustedMediaChannelOverride; if ($mediaChannel === null && $requestedMediaChannelCode !== '') { $mediaChannel = MediaChannelService::getChannelByCode($requestedMediaChannelCode); } $mediaChannelCodes = $mediaChannel !== null ? MediaChannelService::getChannelCodesForStats($mediaChannel) : null; $filterEmptyEntities = $mediaChannel !== null; [$startTimestamp, $endTimestamp, $startDate, $endDate] = self::resolveTimeRange($params); $pageNo = max(1, (int)($params['page_no'] ?? 1)); if ($trustedVisibleAdminIdsOverride !== null) { $trustedVisibleAdminIdsOverride = array_values(array_unique(array_filter( array_map('intval', $trustedVisibleAdminIdsOverride), static fn (int $id): bool => $id > 0 ))); } if ($trustedCostAllocationAdminIdsOverride !== null) { $trustedCostAllocationAdminIdsOverride = array_values(array_unique(array_filter( array_map('intval', $trustedCostAllocationAdminIdsOverride), static fn (int $id): bool => $id > 0 ))); } $pageSizeLimit = $trustedVisibleAdminIdsOverride !== null ? max(100, count($trustedVisibleAdminIdsOverride)) : 100; $pageSize = max(1, min($pageSizeLimit, (int)($params['page_size'] ?? 15))); $visibleAdminIds = $trustedVisibleAdminIdsOverride; if ($trustedVisibleAdminIdsOverride === null) { $visibleAdminIds = ($adminInfo !== null && $adminId > 0) ? DataScopeService::getVisibleAdminIds($adminId, $adminInfo) : null; } // 严格隔离:可见 admin 集合为空时直接返回空骨架,避免下游误以为是"全部"。 if ($visibleAdminIds === []) { $emptyResult = [ 'dimension' => $dimension, 'date_range' => [$startDate, $endDate], 'summary' => self::buildSummary([], $dimension), 'charts' => self::buildCharts([], $dimension), 'lists' => [], 'count' => 0, 'page_no' => $pageNo, 'page_size' => $pageSize, 'extend' => [ 'dimension' => $dimension, 'date_range' => [$startDate, $endDate], 'summary' => self::buildSummary([], $dimension), 'charts' => self::buildCharts([], $dimension), ], ]; if ($includeFilters) { $emptyResult['extend']['filters'] = self::buildFilterOptions([], []); } return $emptyResult; } $entities = self::loadEntities($dimension, $params, $visibleAdminIds); $entityIds = array_keys($entities); if ($entityIds === []) { $result = [ 'dimension' => $dimension, 'date_range' => [$startDate, $endDate], 'summary' => self::buildSummary([], $dimension), 'charts' => self::buildCharts([], $dimension), 'lists' => [], 'count' => 0, 'page_no' => $pageNo, 'page_size' => $pageSize, 'extend' => [ 'dimension' => $dimension, 'date_range' => [$startDate, $endDate], 'summary' => self::buildSummary([], $dimension), 'charts' => self::buildCharts([], $dimension), ], ]; if ($includeFilters) { $result['extend']['filters'] = self::buildFilterOptions($visibleAdminIds, [], $adminId, $adminInfo); } return $result; } $adminToDeptIds = self::loadAdminDeptMap(); self::hydrateFanStats($entities, $dimension, $entityIds, $adminToDeptIds, $startTimestamp, $endTimestamp, $mediaChannel, $visibleAdminIds); $allocationEntities = $entities; if ($trustedCostAllocationAdminIdsOverride !== null) { // 个人口径下,仍以同部门全员加粉作为成本分摊分母,避免将整个部门成本全部计到一个人。 $allocationEntities = self::loadEntities($dimension, $params, $trustedCostAllocationAdminIdsOverride); // 分摊只能使用实际响应中已允许的部门,防止同事的多部门绑定扩大成本范围。 $allocationEntities = array_intersect_key($allocationEntities, $entities); $allocationEntityIds = array_keys($allocationEntities); self::hydrateFanStats( $allocationEntities, $dimension, $allocationEntityIds, $adminToDeptIds, $startTimestamp, $endTimestamp, $mediaChannel, $trustedCostAllocationAdminIdsOverride ); } self::hydrateAppointmentStats( $entities, $dimension, $entityIds, $adminToDeptIds, $startTimestamp, $endTimestamp, $startDate, $endDate, $mediaChannel, $visibleAdminIds, $excludeCancelledAppointments, $usePerformanceOrderMetrics ); self::hydrateOrderAndAmountStats( $entities, $dimension, $entityIds, $adminToDeptIds, $startTimestamp, $endTimestamp, $mediaChannel, $visibleAdminIds, $usePerformanceOrderMetrics ); // 数据隔离:可见部门 = 可见 admin 所属部门并集;用于 account_cost 与下游 cost 分摊。 $visibleDeptIds = self::resolveVisibleDeptIds($visibleAdminIds); [$globalAccountCost, $accountCostDeptIds] = self::hydrateAccountCostStats($entities, $startDate, $endDate, $mediaChannelCodes, $visibleDeptIds); $supportsDeptBinding = AccountCost::supportsDeptBinding(); $restrictAccountCostByDept = $supportsDeptBinding; $channelBoundDeptIds = $supportsDeptBinding && $mediaChannelCodes !== null && $mediaChannelCodes !== [] ? self::loadChannelBoundDeptIds($mediaChannelCodes) : []; // 渠道尚未维护投放成本时,不能把真实的加粉、挂号和订单一并过滤为空。 // 已维护绑定关系的渠道继续按绑定部门收窄;成本本身仍只在实际成本部门内分摊。 $restrictStatsByDept = $channelBoundDeptIds !== []; $scopeDeptIds = $restrictStatsByDept ? $channelBoundDeptIds : $accountCostDeptIds; $eligibleDeptIds = $restrictAccountCostByDept ? self::expandDeptIdsWithDescendants($scopeDeptIds) : $scopeDeptIds; if ($restrictStatsByDept) { $entities = self::filterEntitiesByChannelDeptScope($entities, $dimension, $eligibleDeptIds, $adminToDeptIds); $allocationEntities = self::filterEntitiesByChannelDeptScope($allocationEntities, $dimension, $eligibleDeptIds, $adminToDeptIds); } $entityIds = array_keys($entities); $allocationEntityIds = array_keys($allocationEntities); if ($entityIds === []) { $result = [ 'dimension' => $dimension, 'date_range' => [$startDate, $endDate], 'summary' => self::buildSummary([], $dimension), 'charts' => self::buildCharts([], $dimension), 'lists' => [], 'count' => 0, 'page_no' => $pageNo, 'page_size' => $pageSize, 'extend' => [ 'dimension' => $dimension, 'date_range' => [$startDate, $endDate], 'summary' => self::buildSummary([], $dimension), 'charts' => self::buildCharts([], $dimension), ], ]; if ($includeFilters) { $result['extend']['filters'] = self::buildFilterOptions($visibleAdminIds, $eligibleDeptIds, $adminId, $adminInfo); } return $result; } $allocationAddFansCount = self::sumEntityAddFansForAccountCost( array_values($allocationEntities), $dimension, $restrictAccountCostByDept, $eligibleDeptIds, $adminToDeptIds ); if ($dimension === 'dept') { [$allRows, $pagedRows, $chartRows] = self::buildDeptTreeRows( $entities, isset($params['dept_id']) ? (int)$params['dept_id'] : 0, $pageNo, $pageSize, $filterEmptyEntities, $allocationAddFansCount, $restrictAccountCostByDept, $eligibleDeptIds, $adminToDeptIds ); $includeMembers = (int)($params['include_members'] ?? 1) === 1; if ($includeMembers) { $validDeptIds = array_values(array_filter( array_map('intval', array_keys($entities)), static fn (int $id): bool => $id > 0 )); $memberRowsByDeptId = self::buildMemberRowsByDept( $startTimestamp, $endTimestamp, $startDate, $endDate, $mediaChannel, $mediaChannelCodes, $restrictAccountCostByDept, $eligibleDeptIds, $adminToDeptIds, $validDeptIds, $globalAccountCost, $visibleAdminIds, $excludeCancelledAppointments, $usePerformanceOrderMetrics ); $pagedRows = self::attachDeptMembers($pagedRows, $memberRowsByDeptId); } $result = [ 'dimension' => $dimension, 'date_range' => [$startDate, $endDate], 'summary' => self::buildSummary($allRows, $dimension), 'charts' => self::buildCharts($chartRows, $dimension), 'lists' => $pagedRows, 'count' => count($allRows), 'page_no' => $pageNo, 'page_size' => $pageSize, 'extend' => [ 'dimension' => $dimension, 'date_range' => [$startDate, $endDate], 'summary' => self::buildSummary($allRows, $dimension), 'charts' => self::buildCharts($chartRows, $dimension), ], ]; if ($includeFilters) { $result['extend']['filters'] = self::buildFilterOptions($visibleAdminIds, $eligibleDeptIds, $adminId, $adminInfo); } return $result; } $rows = self::finalizeRows( $entities, $filterEmptyEntities, $allocationAddFansCount, $restrictAccountCostByDept, $eligibleDeptIds, $adminToDeptIds, $dimension ); $count = count($rows); $offset = ($pageNo - 1) * $pageSize; $result = [ 'dimension' => $dimension, 'date_range' => [$startDate, $endDate], 'summary' => self::buildSummary($rows, $dimension), 'charts' => self::buildCharts($rows, $dimension), 'lists' => array_slice($rows, $offset, $pageSize), 'count' => $count, 'page_no' => $pageNo, 'page_size' => $pageSize, 'extend' => [ 'dimension' => $dimension, 'date_range' => [$startDate, $endDate], 'summary' => self::buildSummary($rows, $dimension), 'charts' => self::buildCharts($rows, $dimension), ], ]; if ($includeFilters) { $result['extend']['filters'] = self::buildFilterOptions($visibleAdminIds, $eligibleDeptIds, $adminId, $adminInfo); } return $result; } /** * Return the distinct external contacts behind an add_fans_count row. * * The caller must pass the already-authorized admin range and the exact * department ids represented by the clicked tree node. This keeps the * detail endpoint on the same data-scope and department-primary-mapping * rules as overview(), including descendant departments and virtual * buckets. * * @param array $params * @param array $target * @param int[]|null $trustedVisibleAdminIdsOverride * @param array|null $trustedMediaChannelOverride * @return array */ public static function fanDetails( array $params, array $target, int $adminId = 0, ?array $adminInfo = null, ?array $trustedVisibleAdminIdsOverride = null, ?array $trustedMediaChannelOverride = null ): array { self::$requestRowsCache = []; [$startTimestamp, $endTimestamp, $startDate, $endDate] = self::resolveTimeRange($params); $pageNo = max(1, (int) ($params['page_no'] ?? 1)); $pageSize = max(1, min(100, (int) ($params['page_size'] ?? 20))); $visibleAdminIds = $trustedVisibleAdminIdsOverride; if ($trustedVisibleAdminIdsOverride === null && $adminInfo !== null && $adminId > 0) { $visibleAdminIds = DataScopeService::getVisibleAdminIds($adminId, $adminInfo); } if ($visibleAdminIds !== null) { $visibleAdminIds = array_values(array_unique(array_filter( array_map('intval', $visibleAdminIds), static fn (int $id): bool => $id > 0 ))); } $empty = [ 'lists' => [], 'count' => 0, 'page_no' => $pageNo, 'page_size' => $pageSize, 'date_range' => [$startDate, $endDate], ]; if ($visibleAdminIds === []) { return $empty; } $mediaChannel = $trustedMediaChannelOverride; $requestedMediaChannelCode = trim((string) ($params['media_channel_code'] ?? '')); if ($mediaChannel === null && $requestedMediaChannelCode !== '') { $mediaChannel = MediaChannelService::getChannelByCode($requestedMediaChannelCode); } $targetType = (string) ($target['type'] ?? ''); $targetAdminId = max(0, (int) ($target['admin_id'] ?? 0)); $targetWecomUserId = trim((string) ($target['wecom_userid'] ?? '')); $targetDeptIds = array_values(array_unique(array_map('intval', (array) ($target['dept_ids'] ?? [])))); $targetDeptSet = array_fill_keys($targetDeptIds, true); if (($targetType === 'member' && $targetAdminId <= 0) || ($targetType === 'wecom_user' && $targetWecomUserId === '') || ($targetType === 'dept' && $targetDeptSet === []) ) { return $empty; } $allEntities = []; $entityIds = []; $adminToDeptIds = []; $targetWorkWechatUserIds = []; $unboundOnly = false; if ($targetType === 'dept') { $allEntities = self::loadEntities('dept', $params, $visibleAdminIds); $adminToDeptIds = self::loadAdminDeptMap(); $channelDeptIds = self::fanDetailChannelDeptIds($mediaChannel); if ($channelDeptIds !== null) { $allEntities = self::filterEntitiesByChannelDeptScope( $allEntities, 'dept', $channelDeptIds, $adminToDeptIds ); } $entityIds = array_keys($allEntities); foreach ($targetDeptIds as $targetDeptId) { if (!isset($allEntities[$targetDeptId])) { return $empty; } } if (isset($targetDeptSet[self::VIRTUAL_DEPT_UNBOUND_ADMIN_ID])) { // The unbound bucket exists only under SCOPE_ALL. Keep the // exclusion in SQL so this bucket does not first load every // fan pair in the installation. if ($visibleAdminIds !== null || count($targetDeptSet) !== 1) { return $empty; } $unboundOnly = true; } else { $candidateAdminQuery = Db::name('admin') ->whereNull('delete_time') ->where('work_wechat_userid', '<>', '') ->field('id,work_wechat_userid'); if ($visibleAdminIds !== null) { $candidateAdminQuery->whereIn('id', $visibleAdminIds); } foreach ($candidateAdminQuery->select()->toArray() as $candidateAdmin) { $candidateAdminId = (int) ($candidateAdmin['id'] ?? 0); $mappedIds = self::mapEntityIds( 'dept', $candidateAdminId, $entityIds, $adminToDeptIds, $visibleAdminIds ); $mappedDeptId = $mappedIds !== [] ? (int) $mappedIds[0] : self::VIRTUAL_DEPT_UNASSIGNED_ID; if (isset($targetDeptSet[$mappedDeptId])) { $workWechatUserId = trim((string) ($candidateAdmin['work_wechat_userid'] ?? '')); if ($workWechatUserId !== '') { $targetWorkWechatUserIds[$workWechatUserId] = $workWechatUserId; } } } } } elseif ($targetType === 'member') { if ($visibleAdminIds !== null && !in_array($targetAdminId, $visibleAdminIds, true)) { return $empty; } $targetAdmin = Db::name('admin') ->where('id', $targetAdminId) ->whereNull('delete_time') ->where('work_wechat_userid', '<>', '') ->field('id,work_wechat_userid') ->find(); if (!$targetAdmin) { return $empty; } $targetWorkWechatUserIds[] = trim((string) $targetAdmin['work_wechat_userid']); } else { // Unbound WeCom rows are never visible in a restricted scope. if ($visibleAdminIds !== null) { return $empty; } $boundAdminExists = Db::name('admin') ->whereNull('delete_time') ->where('work_wechat_userid', $targetWecomUserId) ->count() > 0; if ($boundAdminExists) { return $empty; } $targetWorkWechatUserIds[] = $targetWecomUserId; } $targetWorkWechatUserIds = array_values(array_unique(array_filter( array_map('strval', $targetWorkWechatUserIds), static fn (string $userId): bool => $userId !== '' ))); if (!$unboundOnly && $targetWorkWechatUserIds === []) { return $empty; } $pairs = self::loadFanDetailRows( $startTimestamp, $endTimestamp, $mediaChannel, null, $unboundOnly ? null : $targetWorkWechatUserIds, $unboundOnly ); // Target membership has already been resolved before the event query, // so sorting and pagination operate on the clicked row's small set. $matched = $pairs; usort($matched, static function (array $left, array $right): int { $timeCompare = ((int) ($right['add_time'] ?? 0)) <=> ((int) ($left['add_time'] ?? 0)); if ($timeCompare !== 0) { return $timeCompare; } return strcmp( (string) ($left['external_userid'] ?? ''), (string) ($right['external_userid'] ?? '') ) ?: strcmp( (string) ($left['user_id'] ?? ''), (string) ($right['user_id'] ?? '') ); }); $count = count($matched); $deletedCount = count(array_filter( $matched, static fn (array $row): bool => !empty($row['is_deleted']) )); $offset = ($pageNo - 1) * $pageSize; $pageRows = array_slice($matched, $offset, $pageSize); $externalUserIds = array_values(array_unique(array_filter(array_map( static fn (array $row): string => trim((string) ($row['external_userid'] ?? '')), $pageRows )))); $customerNames = $externalUserIds === [] ? [] : Db::name('qywx_external_contact') ->whereIn('external_userid', $externalUserIds) ->column('name', 'external_userid'); $deletedPageRows = array_values(array_filter( $pageRows, static fn (array $row): bool => !empty($row['is_deleted']) )); $deleteTimesByPair = []; if ($deletedPageRows !== []) { $deletedUserIds = array_values(array_unique(array_filter(array_map( static fn (array $row): string => trim((string) ($row['user_id'] ?? '')), $deletedPageRows )))); $deletedExternalUserIds = array_values(array_unique(array_filter(array_map( static fn (array $row): string => trim((string) ($row['external_userid'] ?? '')), $deletedPageRows )))); $deletedEvents = Db::name('qywx_external_contact_event') ->where('change_type', 'del_external_contact') ->where('event_time', '<=', $endTimestamp) ->whereIn('user_id', $deletedUserIds) ->whereIn('external_userid', $deletedExternalUserIds) ->field('user_id,external_userid,MAX(event_time) AS delete_time') ->group('user_id,external_userid') ->select() ->toArray(); foreach ($deletedEvents as $deletedEvent) { $key = trim((string) ($deletedEvent['user_id'] ?? '')) . "\0" . trim((string) ($deletedEvent['external_userid'] ?? '')); $deleteTimesByPair[$key] = max(0, (int) ($deletedEvent['delete_time'] ?? 0)); } } $staffNames = self::resolveQywxUserNames(array_values(array_unique(array_filter(array_map( static fn (array $row): string => trim((string) ($row['user_id'] ?? '')), $pageRows ))))); $lists = array_map(static function (array $row) use ($customerNames, $staffNames, $deleteTimesByPair): array { $externalUserId = trim((string) ($row['external_userid'] ?? '')); $wecomUserId = trim((string) ($row['user_id'] ?? '')); $addTime = max(0, (int) ($row['add_time'] ?? 0)); $deleted = !empty($row['is_deleted']); $deleteTime = $deleted ? max(0, (int) ($deleteTimesByPair[$wecomUserId . "\0" . $externalUserId] ?? 0)) : 0; if ($deleteTime < $addTime) { $deleteTime = 0; } return [ 'external_userid' => $externalUserId, 'customer_name' => trim((string) ($customerNames[$externalUserId] ?? '')), 'wecom_userid' => $wecomUserId, 'wecom_staff_name' => trim((string) ($staffNames[$wecomUserId] ?? '')), 'add_time' => $addTime > 0 ? date('Y-m-d H:i:s', $addTime) : null, 'is_deleted' => $deleted, 'delete_time' => $deleted && $deleteTime > 0 ? date('Y-m-d H:i:s', $deleteTime) : null, ]; }, $pageRows); return [ 'lists' => $lists, 'count' => $count, 'deleted_count' => $deletedCount, 'page_no' => $pageNo, 'page_size' => $pageSize, 'date_range' => [$startDate, $endDate], ]; } /** * Department scope imposed by an actively maintained media-channel cost * binding. null means the channel has no binding and therefore does not * restrict business statistics. * * @param array|null $mediaChannel * @return int[]|null */ public static function fanDetailChannelDeptIds(?array $mediaChannel): ?array { if ($mediaChannel === null) { return null; } $mediaChannelCodes = MediaChannelService::getChannelCodesForStats($mediaChannel); if ($mediaChannelCodes === []) { return null; } $boundDeptIds = self::loadChannelBoundDeptIds($mediaChannelCodes); return $boundDeptIds === [] ? null : self::expandDeptIdsWithDescendants($boundDeptIds); } /** * @param int[]|null $visibleAdminIds 可见 admin 集合(null = SCOPE_ALL) * @param int[] $eligibleDeptIds 当媒体渠道筛选生效时收窄过的部门作用域 * @return array */ private static function buildFilterOptions( ?array $visibleAdminIds = null, array $eligibleDeptIds = [], int $adminId = 0, ?array $adminInfo = null ): array { $allowedDeptIds = self::resolveFilterAllowedDeptIds($visibleAdminIds, $eligibleDeptIds); return [ 'departments' => self::buildDepartmentOptions($visibleAdminIds, $eligibleDeptIds, $allowedDeptIds), 'assistants' => DiagnosisLogic::getAssistants($adminId, $adminInfo), 'doctors' => self::buildDoctorOptions($visibleAdminIds), 'media_channels' => self::buildMediaChannelOptions(), ]; } /** * 部门下拉: * - SCOPE_ALL(visibleAdminIds = null):全部启用部门树 * - 其他:仅展示"可见 admin 所属部门 + 媒体渠道作用域部门"的并集,并保留祖先链以维持树形结构 * * @return array> */ private static function buildDepartmentOptions(?array $visibleAdminIds, array $eligibleDeptIds, ?array $allowedDeptIds = null): array { $allTree = DeptLogic::getAllData(); if ($visibleAdminIds === null) { return $allTree; } $allowedDeptIds ??= self::resolveFilterAllowedDeptIds($visibleAdminIds, $eligibleDeptIds); if ($allowedDeptIds === []) { return []; } return self::filterDeptTreeByIds($allTree, $allowedDeptIds); } /** * 医生下拉:按 visibleAdminIds 收窄。 * * @return array> */ private static function buildDoctorOptions(?array $visibleAdminIds): array { $query = Db::name('admin') ->alias('a') ->join('admin_role ar', 'a.id = ar.admin_id') ->where('ar.role_id', 1) ->where('a.disable', 0) ->whereNull('a.delete_time'); if ($visibleAdminIds !== null) { if ($visibleAdminIds === []) { // 与 HasDataScopeFilter::applyDataScopeByOwner 对齐:空集合用 0=1 闸门让 SQL 自然返回空, // 避免和"未传 visibleAdminIds (=SCOPE_ALL)"在调用侧产生混淆。 $query->whereRaw('0 = 1'); } else { $query->whereIn('a.id', $visibleAdminIds); } } return $query ->field(['a.id', 'a.name', 'a.account']) ->order('a.id', 'asc') ->distinct(true) ->select() ->toArray(); } /** * 媒体渠道下拉:返回全局启用渠道;事实数据仍由 DataScope 单独约束。 * * @return array> */ private static function buildMediaChannelOptions(): array { // Channels are dimension values, while account_cost rows are optional // facts. Using cost bindings as an option whitelist hid valid labels and // made the dropdown change when department/assistant filters changed. return MediaChannelService::getOptions(); } /** * 按 dept_id 集合裁剪部门树,自动保留命中节点的祖先链以保证树形完整。 * * @param array> $tree * @param int[] $allowedIds * @return array> */ private static function filterDeptTreeByIds(array $tree, array $allowedIds): array { $allowedSet = array_fill_keys($allowedIds, true); $walker = function (array $node) use (&$walker, $allowedSet): ?array { $children = []; foreach ($node['children'] ?? [] as $child) { if (!is_array($child)) { continue; } $kept = $walker($child); if ($kept !== null) { $children[] = $kept; } } $selfHit = isset($allowedSet[(int)($node['id'] ?? 0)]); if ($selfHit || $children !== []) { $node['children'] = $children; return $node; } return null; }; $result = []; foreach ($tree as $node) { $kept = $walker($node); if ($kept !== null) { $result[] = $kept; } } return $result; } private static function normalizeDimension(string $dimension): string { return in_array($dimension, ['dept', 'assistant', 'doctor'], true) ? $dimension : 'dept'; } /** * @return array{0:int,1:int,2:string,3:string} */ private static function resolveTimeRange(array $params): array { $today = date('Y-m-d'); $timeType = (string)($params['time_type'] ?? 'today'); switch ($timeType) { case 'yesterday': $startDate = date('Y-m-d', strtotime('-1 day')); $endDate = $startDate; break; case 'week': $startDate = date('Y-m-d', strtotime('-6 days')); $endDate = $today; break; case 'month': $startDate = date('Y-m-d', strtotime('-29 days')); $endDate = $today; break; case 'custom': $startDate = trim((string)($params['start_date'] ?? '')); $endDate = trim((string)($params['end_date'] ?? '')); if ($startDate === '' || $endDate === '' || strtotime($startDate) === false || strtotime($endDate) === false) { $startDate = $today; $endDate = $today; } if ($startDate > $endDate) { [$startDate, $endDate] = [$endDate, $startDate]; } break; case 'today': default: $startDate = $today; $endDate = $today; break; } return [ strtotime($startDate . ' 00:00:00'), strtotime($endDate . ' 23:59:59'), $startDate, $endDate, ]; } /** * @param int[]|null $visibleAdminIds 可见 admin 集合(null = SCOPE_ALL);用于 entities 加载与虚拟桶可见性 * @return array> */ private static function loadEntities(string $dimension, array $params, ?array $visibleAdminIds = null): array { $entities = match ($dimension) { 'assistant' => self::loadAdminEntities(2, isset($params['assistant_id']) ? (int)$params['assistant_id'] : 0, $visibleAdminIds), 'doctor' => self::loadAdminEntities(1, isset($params['doctor_id']) ? (int)$params['doctor_id'] : 0, $visibleAdminIds), default => self::loadDeptEntities(isset($params['dept_id']) ? (int)$params['dept_id'] : 0), }; if ($dimension === 'dept' && (int)($params['dept_id'] ?? 0) <= 0) { self::appendVirtualDeptEntities($entities, $visibleAdminIds); } return $entities; } /** * @return array> */ private static function loadDeptEntities(int $deptId = 0): array { $query = Db::name('dept') ->whereNull('delete_time') ->field('id, pid, name, sort, leader'); $rows = $query->order('sort desc, id asc')->select()->toArray(); if ($deptId > 0) { $allowedIds = self::collectDeptSubtreeIds($rows, $deptId); $rows = array_values(array_filter($rows, static fn (array $row): bool => in_array((int)$row['id'], $allowedIds, true))); } $entities = []; foreach ($rows as $row) { $id = (int)($row['id'] ?? 0); if ($id <= 0) { continue; } $entities[$id] = self::newEntityRow($id, (string)($row['name'] ?? '')); $entities[$id]['pid'] = (int)($row['pid'] ?? 0); $entities[$id]['sort'] = (int)($row['sort'] ?? 0); // 明细列表「组」后展示组长归属;优先规范化姓名(去职务后缀),空则回退原文 $rawLeader = trim((string)($row['leader'] ?? '')); $normLeader = $rawLeader !== '' ? self::normalizeLeaderName($rawLeader) : ''; $entities[$id]['leader_name'] = $normLeader !== '' ? $normLeader : $rawLeader; } return $entities; } /** * @param array> $rows * @return int[] */ private static function collectDeptSubtreeIds(array $rows, int $rootId): array { $childrenByPid = []; foreach ($rows as $row) { $pid = (int)($row['pid'] ?? 0); $id = (int)($row['id'] ?? 0); $childrenByPid[$pid] ??= []; $childrenByPid[$pid][] = $id; } $queue = [$rootId]; $result = []; while ($queue !== []) { $current = array_shift($queue); if (!$current || isset($result[$current])) { continue; } $result[$current] = $current; foreach ($childrenByPid[$current] ?? [] as $childId) { $queue[] = (int)$childId; } } return array_values($result); } /** * @param int[]|null $visibleAdminIds 可见 admin 集合(null = SCOPE_ALL,不收窄) * @return array> */ private static function loadAdminEntities(int $roleId, int $adminId = 0, ?array $visibleAdminIds = null): array { $query = Db::name('admin') ->alias('a') ->join('admin_role ar', 'ar.admin_id = a.id') ->where('ar.role_id', $roleId) ->whereNull('a.delete_time') ->field('a.id, a.name'); if ($adminId > 0) { $query->where('a.id', $adminId); } if ($visibleAdminIds !== null) { if ($visibleAdminIds === []) { // 与 HasDataScopeFilter::applyDataScopeByOwner 对齐:空集合用 0=1 闸门让 SQL 自然返回空。 $query->whereRaw('0 = 1'); } else { $query->whereIn('a.id', $visibleAdminIds); } } $rows = $query->order('a.id asc')->select()->toArray(); $entities = []; foreach ($rows as $row) { $id = (int)($row['id'] ?? 0); if ($id <= 0) { continue; } $entities[$id] = self::newEntityRow($id, (string)($row['name'] ?? '')); } return $entities; } /** * @return array * * 字段说明: * - total_open_count / unreplied_count:预留字段,对应"总开口数 / 未回复数"。当前 IM * 会话归档系统未上线,没有数据源可以填充,前端按 placeholder='—' 显示。等接入后 * 在 hydrateXxx 系列里追加 SQL 写入即可,无需调整下游 finalize / chart / summary。 */ private static function newEntityRow(int $id, string $name): array { return [ 'id' => $id, 'name' => $name, 'add_fans_count' => 0, 'deleted_fans_count' => 0, 'total_open_count' => 0, // TODO: 接入 IM 会话统计后回填 'unreplied_count' => 0, // TODO: 接入 IM 会话统计后回填 'paid_appointment_count' => 0, 'free_appointment_count' => 0, 'appointment_total_count' => 0, 'interview_count' => 0, 'business_order_amount' => 0.0, 'completed_order_amount' => 0.0, 'completed_order_count' => 0, 'account_cost' => 0.0, '_virtual_bucket' => false, ]; } /** * @param array> $entities * @param int[]|null $visibleAdminIds 可见 admin 集合;非 null(即非 SCOPE_ALL)时不挂"未绑定后台账号"虚拟桶, * 因为这些加粉无主,不属于任何 admin 的可见范围。 */ private static function appendVirtualDeptEntities(array &$entities, ?array $visibleAdminIds = null): void { if ($visibleAdminIds === null) { $entities[self::VIRTUAL_DEPT_UNBOUND_ADMIN_ID] = array_merge( self::newEntityRow(self::VIRTUAL_DEPT_UNBOUND_ADMIN_ID, '未绑定后台账号'), [ 'pid' => 0, 'sort' => -999998, '_virtual_bucket' => true, ] ); } $entities[self::VIRTUAL_DEPT_UNASSIGNED_ID] = array_merge( self::newEntityRow(self::VIRTUAL_DEPT_UNASSIGNED_ID, '未分配部门'), [ 'pid' => 0, 'sort' => -999999, '_virtual_bucket' => true, ] ); } /** * 把"可见 admin 集合"转换成"可见部门集合":取所有 admin 的 admin_dept 并集。 * - $visibleAdminIds === null(SCOPE_ALL)→ 返回 null(下游不收窄) * - $visibleAdminIds === [] → 返回 [](无可见部门) * * @param int[]|null $visibleAdminIds * @return int[]|null */ private static function resolveVisibleDeptIds(?array $visibleAdminIds): ?array { if ($visibleAdminIds === null) { return null; } if ($visibleAdminIds === []) { return []; } $deptIds = Db::name('admin_dept') ->whereIn('admin_id', $visibleAdminIds) ->column('dept_id'); $result = []; foreach ($deptIds as $deptId) { $deptId = (int)$deptId; if ($deptId > 0) { $result[$deptId] = $deptId; } } return array_values($result); } /** * @return array * * 返回 admin_id => [dept_id, ...]。跨部门时优先最深层、再按部门排序, * 与一诊挂号统计和业绩看板的人员归属规则保持一致。 * 当 admin 跨部门时,下游 mapEntityIds 只取列表中第一个落在当前 entityIds 内的部门, * 避免同一笔加粉/挂号/接诊被多次累加到不同部门。 */ private static function loadAdminDeptMap(): array { $rows = Db::name('admin_dept') ->field('admin_id, dept_id') ->select() ->toArray(); $deptRows = Db::name('dept') ->whereNull('delete_time') ->field('id, pid, sort') ->select() ->toArray(); $deptById = []; foreach ($deptRows as $deptRow) { $deptId = (int)($deptRow['id'] ?? 0); if ($deptId > 0) { $deptById[$deptId] = [ 'pid' => (int)($deptRow['pid'] ?? 0), 'sort' => (int)($deptRow['sort'] ?? 0), ]; } } $depthCache = []; $depthOf = static function (int $deptId) use (&$depthOf, &$depthCache, $deptById): int { if ($deptId <= 0 || !isset($deptById[$deptId])) { return 0; } if (isset($depthCache[$deptId])) { return $depthCache[$deptId]; } $parentId = (int)($deptById[$deptId]['pid'] ?? 0); if ($parentId <= 0 || $parentId === $deptId || !isset($deptById[$parentId])) { return $depthCache[$deptId] = 0; } return $depthCache[$deptId] = $depthOf($parentId) + 1; }; $map = []; foreach ($rows as $row) { $adminId = (int)($row['admin_id'] ?? 0); $deptId = (int)($row['dept_id'] ?? 0); if ($adminId <= 0 || $deptId <= 0) { continue; } $map[$adminId] ??= []; $map[$adminId][] = $deptId; } foreach ($map as &$deptIds) { usort($deptIds, static function (int $left, int $right) use ($depthOf, $deptById): int { $depthCompare = $depthOf($right) <=> $depthOf($left); if ($depthCompare !== 0) { return $depthCompare; } $sortCompare = (int)($deptById[$right]['sort'] ?? 0) <=> (int)($deptById[$left]['sort'] ?? 0); if ($sortCompare !== 0) { return $sortCompare; } return $left <=> $right; }); } unset($deptIds); return $map; } /** * @param int[] $deptIds * @return int[] */ private static function expandDeptIdsWithDescendants(array $deptIds): array { if ($deptIds === []) { return []; } $expanded = []; foreach (array_values(array_unique(array_filter(array_map('intval', $deptIds), static fn (int $deptId): bool => $deptId > 0))) as $deptId) { foreach (DeptLogic::getSelfAndDescendantIds($deptId) as $expandedDeptId) { if ($expandedDeptId > 0) { $expanded[$expandedDeptId] = $expandedDeptId; } } } return array_values($expanded); } /** * 渠道绑定部门不依赖当前统计区间,避免某天没有录入成本时把统计实体过滤为空。 * * @param string[] $mediaChannelCodes * @return int[] */ private static function loadChannelBoundDeptIds(array $mediaChannelCodes): array { $mediaChannelCodes = self::normalizeMediaChannelCodes($mediaChannelCodes); if ($mediaChannelCodes === [] || !AccountCost::supportsDeptBinding()) { return []; } $deptIds = Db::name('account_cost') ->whereIn('media_channel_code', $mediaChannelCodes) ->where('dept_id', '>', 0) ->distinct(true) ->column('dept_id'); return array_values(array_unique(array_filter(array_map('intval', $deptIds), static fn (int $deptId): bool => $deptId > 0))); } /** * 自媒体渠道选中时,统计范围只允许落在渠道绑定部门及其子部门内。 * * @param array> $entities * @param array $adminToDeptIds * @return array> */ private static function filterEntitiesByChannelDeptScope(array $entities, string $dimension, array $eligibleDeptIds, array $adminToDeptIds): array { if ($eligibleDeptIds === []) { return []; } if ($dimension === 'doctor') { return $entities; } $eligibleDeptMap = array_fill_keys(array_map('intval', $eligibleDeptIds), true); $filtered = []; foreach ($entities as $key => $entity) { $entityId = (int)($entity['id'] ?? 0); // 虚拟桶(未绑定 admin / 未分配部门)不参与渠道-部门作用域过滤, // 否则会丢失"未绑定 admin 的加粉"和"已绑定 admin 但无部门的加粉"统计。 if ($entityId === self::VIRTUAL_DEPT_UNBOUND_ADMIN_ID || $entityId === self::VIRTUAL_DEPT_UNASSIGNED_ID ) { $filtered[$key] = $entity; continue; } if ($entityId <= 0) { continue; } if ($dimension === 'dept') { if (isset($eligibleDeptMap[$entityId])) { $filtered[$key] = $entity; } continue; } foreach (($adminToDeptIds[$entityId] ?? []) as $deptId) { if (isset($eligibleDeptMap[(int)$deptId])) { $filtered[$key] = $entity; break; } } } return $filtered; } /** * @param array> $entities * @param int[] $entityIds * @param array $adminToDeptIds * @param array|null $mediaChannel * @param int[]|null $visibleAdminIds 可见 admin 集合 */ private static function hydrateFanStats( array &$entities, string $dimension, array $entityIds, array $adminToDeptIds, int $startTimestamp, int $endTimestamp, ?array $mediaChannel, ?array $visibleAdminIds = null ): void { $queryAdminIds = $visibleAdminIds; if ($queryAdminIds === null && $dimension !== 'dept') { $queryAdminIds = $entityIds; } $rows = self::loadFanRows($startTimestamp, $endTimestamp, $mediaChannel, $queryAdminIds); $adminByUserId = self::loadActiveAdminByWorkWechatUserIds(array_column($rows, 'user_id')); foreach ($rows as $row) { $userId = (string)($row['user_id'] ?? ''); $adminId = (int)($adminByUserId[$userId]['id'] ?? 0); $addFansCount = (int)($row['add_fans_count'] ?? 0); $deletedFansCount = (int)($row['deleted_fans_count'] ?? 0); if ($dimension === 'dept' && $adminId <= 0) { // -1 桶仅在 SCOPE_ALL(visibleAdminIds === null)时存在;其他范围下未绑定 admin 的加粉不可归属,跳过。 if ($visibleAdminIds === null && isset($entities[self::VIRTUAL_DEPT_UNBOUND_ADMIN_ID])) { $entities[self::VIRTUAL_DEPT_UNBOUND_ADMIN_ID]['add_fans_count'] += $addFansCount; $entities[self::VIRTUAL_DEPT_UNBOUND_ADMIN_ID]['deleted_fans_count'] += $deletedFansCount; } continue; } if ($adminId <= 0) { continue; } $hitEntityIds = self::mapEntityIds($dimension, $adminId, $entityIds, $adminToDeptIds, $visibleAdminIds); if ($dimension === 'dept' && $hitEntityIds === []) { // admin 已通过 visibleAdminIds 校验但没找到归属部门 → 落入"未分配部门"。 if ($visibleAdminIds !== null && !in_array($adminId, $visibleAdminIds, true)) { continue; } if (isset($entities[self::VIRTUAL_DEPT_UNASSIGNED_ID])) { $entities[self::VIRTUAL_DEPT_UNASSIGNED_ID]['add_fans_count'] += $addFansCount; $entities[self::VIRTUAL_DEPT_UNASSIGNED_ID]['deleted_fans_count'] += $deletedFansCount; } continue; } if ($hitEntityIds === []) { continue; } foreach ($hitEntityIds as $entityId) { $entities[$entityId]['add_fans_count'] += $addFansCount; $entities[$entityId]['deleted_fans_count'] += $deletedFansCount; } } } /** * 区间新增加粉:按企微员工聚合后,再投影到部门/成员/虚拟桶。 * * 口径(对齐企微客户列表 / 官方「新增客户」不含继承,而非原始回调条数): * - 统计区间内 add_external_contact,按 (user_id, external_userid) 去重; * - 同一员工在区间开始前已加过该客户的重加不计(企微「添加时间」仍是首次跟进时间, * 删后再加会再推 add_external_contact,但不能当当天新客,否则会跨日重复计); * - add_external_contact 是企微确认客户关系已建立后的权威事件;会话存档同意 * msg_audit_approved 属于独立能力,不能作为加粉前置条件,否则未开通会话存档的员工会被整批清零; * - 加粉之后、统计结束前若出现 del_external_contact(客户从企微侧删除),仍计入加粉总数, * 并额外计入已删提示子集;不处理 del_follow_user:企微客户列表中改名带「删」的客户往往仍在列表中; * - 剔除非投放加粉:跟进人 add_way∈{1 扫一扫, 2 搜索手机号, 3 名片分享}; * - 剔除继承客户:跟进人 add_way∈{201 内部成员共享, 202 管理员/负责人分配}(含在职/离职继承)。 * * @param array|null $mediaChannel * @param int[]|null $adminIds null means all active/unbound WeCom users * @return array */ private static function loadFanRows( int $startTimestamp, int $endTimestamp, ?array $mediaChannel, ?array $adminIds = null ): array { $detailRows = self::loadFanDetailRows($startTimestamp, $endTimestamp, $mediaChannel, $adminIds); $effectivePairs = array_values(array_filter( $detailRows, static fn (array $row): bool => empty($row['is_deleted']) )); return self::buildFanCountRows($detailRows, $effectivePairs); } /** * Load the exact distinct (user_id, external_userid) pairs represented by * add_fans_count. Both aggregate statistics and the detail endpoint consume * this method, so channel, add-way, prior-add and deletion rules cannot * drift apart. * * @param array|null $mediaChannel * @param int[]|null $adminIds * @return array */ private static function loadFanDetailRows( int $startTimestamp, int $endTimestamp, ?array $mediaChannel, ?array $adminIds = null, ?array $exactWorkWechatUserIds = null, bool $unboundOnly = false ): 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 []; } } if ($exactWorkWechatUserIds !== null) { $exactWorkWechatUserIds = array_values(array_unique(array_filter( array_map('strval', $exactWorkWechatUserIds), static fn (string $userId): bool => $userId !== '' ))); sort($exactWorkWechatUserIds); if ($exactWorkWechatUserIds === [] && !$unboundOnly) { return []; } } $baseKey = self::requestRowsCacheKey('fans-detail-v1', [ $startTimestamp, $endTimestamp, self::mediaChannelCacheKey($mediaChannel), ]); $allKey = $baseKey . ':all'; if ($unboundOnly) { $cacheKey = $baseKey . ':unbound'; } elseif ($exactWorkWechatUserIds !== null) { $cacheKey = $baseKey . ':wecom:' . implode(',', $exactWorkWechatUserIds); } else { $cacheKey = $adminIds === null ? $allKey : $baseKey . ':admins:' . implode(',', $adminIds); } if (isset(self::$requestRowsCache[$cacheKey])) { return self::$requestRowsCache[$cacheKey]; } $workWechatUserIds = $exactWorkWechatUserIds; if ($workWechatUserIds === null && $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]; } } $eventTable = config('database.connections.mysql.prefix') . 'qywx_external_contact_event'; $query = Db::name('qywx_external_contact_event') ->alias('e') ->where('e.change_type', 'add_external_contact') ->where('e.event_time', 'between', [$startTimestamp, $endTimestamp]) ->where('e.user_id', '<>', '') ->where('e.external_userid', '<>', '') ->whereRaw( 'NOT EXISTS (SELECT 1 FROM `' . $eventTable . '` prev_e' . ' WHERE prev_e.user_id = e.user_id' . ' AND prev_e.external_userid = e.external_userid' . ' AND prev_e.change_type = ?' . ' AND prev_e.event_time < ?)', ['add_external_contact', $startTimestamp] ) ->field('e.user_id,e.external_userid,MIN(e.event_time) AS add_time') ->group('e.user_id, e.external_userid'); if ($workWechatUserIds !== null) { $query->whereIn('e.user_id', $workWechatUserIds); } elseif ($unboundOnly) { $adminTable = config('database.connections.mysql.prefix') . 'admin'; $query->whereRaw( 'NOT EXISTS (SELECT 1 FROM `' . $adminTable . '` bound_a' . ' WHERE bound_a.work_wechat_userid = e.user_id' . ' AND bound_a.delete_time IS NULL)' ); } $effectiveQuery = clone $query; if ($mediaChannel !== null) { // 有效加粉保持原口径:只按未删客户的当前标签/渠道关系筛选。 MediaChannelService::applyExternalUserChannelFilter($effectiveQuery, 'e.external_userid', $mediaChannel); } $effectivePairs = $effectiveQuery ->whereRaw( 'EXISTS (SELECT 1 FROM `' . $eventTable . '` surviving_e' . ' WHERE surviving_e.user_id = e.user_id' . ' AND surviving_e.external_userid = e.external_userid' . ' AND surviving_e.change_type = ?' . ' AND surviving_e.event_time >= ?' . ' AND surviving_e.event_time <= ?' . ' AND NOT EXISTS (SELECT 1 FROM `' . $eventTable . '` surviving_del' . ' WHERE surviving_del.user_id = surviving_e.user_id' . ' AND surviving_del.external_userid = surviving_e.external_userid' . ' AND surviving_del.change_type = ?' . ' AND surviving_del.event_time >= surviving_e.event_time' . ' AND surviving_del.event_time <= ?))', ['add_external_contact', $startTimestamp, $endTimestamp, 'del_external_contact', $endTimestamp] ) ->select() ->toArray(); $deletedQuery = clone $query; if ($mediaChannel !== null) { // del_external_contact 会软删 contact 并清掉关系表,已删归属改用保留的 follow_users 快照。 MediaChannelService::applyHistoricalExternalUserChannelFilter($deletedQuery, 'e.external_userid', $mediaChannel); } $deletedPairs = $deletedQuery ->whereRaw( 'NOT EXISTS (SELECT 1 FROM `' . $eventTable . '` surviving_e' . ' WHERE surviving_e.user_id = e.user_id' . ' AND surviving_e.external_userid = e.external_userid' . ' AND surviving_e.change_type = ?' . ' AND surviving_e.event_time >= ?' . ' AND surviving_e.event_time <= ?' . ' AND NOT EXISTS (SELECT 1 FROM `' . $eventTable . '` surviving_del' . ' WHERE surviving_del.user_id = surviving_e.user_id' . ' AND surviving_del.external_userid = surviving_e.external_userid' . ' AND surviving_del.change_type = ?' . ' AND surviving_del.event_time >= surviving_e.event_time' . ' AND surviving_del.event_time <= ?))', ['add_external_contact', $startTimestamp, $endTimestamp, 'del_external_contact', $endTimestamp] ) ->select() ->toArray(); // add_way 筛选对有效与已删粉丝使用同一口径。 $candidatePairs = array_merge($effectivePairs, $deletedPairs); $candidatePairs = self::excludeUncountedFanPairs($candidatePairs); $rows = self::buildFanDetailRows($candidatePairs, $effectivePairs); self::$requestRowsCache[$cacheKey] = $rows; return self::$requestRowsCache[$cacheKey]; } /** * @param array> $candidatePairs * @param array> $effectivePairs * @return array */ private static function buildFanDetailRows( array $candidatePairs, array $effectivePairs ): array { $effectiveKeys = []; foreach ($effectivePairs as $pair) { $userId = trim((string) ($pair['user_id'] ?? '')); $externalUserId = trim((string) ($pair['external_userid'] ?? '')); if ($userId !== '' && $externalUserId !== '') { $effectiveKeys[$userId . "\0" . $externalUserId] = true; } } $detailsByKey = []; foreach ($candidatePairs as $pair) { $userId = trim((string) ($pair['user_id'] ?? '')); $externalUserId = trim((string) ($pair['external_userid'] ?? '')); if ($userId === '' || $externalUserId === '') { continue; } $key = $userId . "\0" . $externalUserId; $addTime = max(0, (int) ($pair['add_time'] ?? 0)); if (isset($detailsByKey[$key])) { $existingTime = (int) ($detailsByKey[$key]['add_time'] ?? 0); if ($addTime > 0 && ($existingTime <= 0 || $addTime < $existingTime)) { $detailsByKey[$key]['add_time'] = $addTime; } continue; } $detailsByKey[$key] = [ 'user_id' => $userId, 'external_userid' => $externalUserId, 'add_time' => $addTime, 'is_deleted' => !isset($effectiveKeys[$key]), 'delete_time' => 0, ]; } return array_values($detailsByKey); } /** * 按员工聚合全部候选新增,并标记其中期末已删除的子集。 * * 候选对已经过“区间前未添加”、渠道与 add_way 规则,每个去重候选对都计入 * add_fans_count;有效对是期末未删除的子集,因此候选对与有效对的差集另计入 * deleted_fans_count。deleted_fans_count 只是 add_fans_count 的提示子集,不再扣减或重复累计。 * 若区间内删除后又重加且期末仍有效,该对仍在有效子集中,不会误计为已删。 * * @param array> $candidatePairs * @param array> $effectivePairs * @return array */ private static function buildFanCountRows(array $candidatePairs, array $effectivePairs): array { $effectiveKeys = []; foreach ($effectivePairs as $pair) { $userId = trim((string) ($pair['user_id'] ?? '')); $externalUserId = trim((string) ($pair['external_userid'] ?? '')); if ($userId === '' || $externalUserId === '') { continue; } $effectiveKeys[$userId . "\0" . $externalUserId] = true; } $countsByUser = []; $seenCandidateKeys = []; foreach ($candidatePairs as $pair) { $userId = trim((string) ($pair['user_id'] ?? '')); $externalUserId = trim((string) ($pair['external_userid'] ?? '')); if ($userId === '' || $externalUserId === '') { continue; } $key = $userId . "\0" . $externalUserId; if (isset($seenCandidateKeys[$key])) { continue; } $seenCandidateKeys[$key] = true; $countsByUser[$userId] ??= [ 'user_id' => $userId, 'add_fans_count' => 0, 'deleted_fans_count' => 0, ]; ++$countsByUser[$userId]['add_fans_count']; if (!isset($effectiveKeys[$key])) { ++$countsByUser[$userId]['deleted_fans_count']; } } return array_values($countsByUser); } /** * 剔除不应计入加粉的跟进来源: * - add_way=1 扫一扫(客户通过扫一扫添加); * - add_way=2 搜索手机号(成员通过搜索手机号添加); * - add_way=3 名片分享(客户通过名片分享添加); * - add_way=201/202 继承/分配(内部成员共享、管理员/负责人分配,含在职/离职继承)。 * 无本地客户档案或跟进信息不含该员工时保守保留(无法判定则仍计加粉)。 * * @param array> $pairs * @return array> */ private static function excludeUncountedFanPairs(array $pairs): array { if ($pairs === []) { return []; } $externalUserIds = []; foreach ($pairs as $pair) { $extId = trim((string) ($pair['external_userid'] ?? '')); if ($extId !== '') { $externalUserIds[$extId] = true; } } $externalIdList = array_keys($externalUserIds); if ($externalIdList === []) { return $pairs; } /** @var array $excludedKeys user_id\0external_userid */ $excludedKeys = []; foreach (array_chunk($externalIdList, 500) as $chunk) { $contactRows = Db::name('qywx_external_contact') ->whereIn('external_userid', $chunk) ->field(['external_userid', 'follow_users']) ->select() ->toArray(); foreach ($contactRows as $contact) { $extId = trim((string) ($contact['external_userid'] ?? '')); if ($extId === '') { continue; } $followUsers = $contact['follow_users'] ?? null; if (\is_string($followUsers) && $followUsers !== '') { $decoded = json_decode($followUsers, true); $followUsers = \is_array($decoded) ? $decoded : []; } if (!\is_array($followUsers)) { continue; } foreach ($followUsers as $fu) { if (!\is_array($fu)) { continue; } $addWay = (int) ($fu['add_way'] ?? $fu['AddWay'] ?? 0); if (!in_array($addWay, [1, 2, 3, 201, 202], true)) { continue; } $followUserId = trim((string) ($fu['userid'] ?? $fu['UserId'] ?? '')); if ($followUserId === '') { continue; } $excludedKeys[$followUserId . "\0" . $extId] = true; } } } if ($excludedKeys === []) { return $pairs; } $kept = []; foreach ($pairs as $pair) { $userId = trim((string) ($pair['user_id'] ?? '')); $extId = trim((string) ($pair['external_userid'] ?? '')); if ($userId === '' || $extId === '') { continue; } if (isset($excludedKeys[$userId . "\0" . $extId])) { continue; } $kept[] = $pair; } return $kept; } /** * @param string[] $userIds * @return array */ 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> $entities * @param int[] $entityIds * @param array $adminToDeptIds * @param array|null $mediaChannel * @param int[]|null $visibleAdminIds */ private static function hydrateAppointmentStats( array &$entities, string $dimension, array $entityIds, array $adminToDeptIds, int $startTimestamp, int $endTimestamp, string $startDate, string $endDate, ?array $mediaChannel, ?array $visibleAdminIds = null, bool $excludeCancelledAppointments = false, bool $useRegistrationMetric = false ): void { $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]); } $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(); }); foreach ($rows as $row) { $sourceAdminId = (int)($row['source_admin_id'] ?? 0); $mappedEntityIds = self::mapEntityIds($dimension, $sourceAdminId, $entityIds, $adminToDeptIds, $visibleAdminIds); if ($mappedEntityIds === []) { continue; } $appointmentCount = (int)($row['appointment_count'] ?? 0); $interviewCount = (int)($row['interview_count'] ?? 0); foreach ($mappedEntityIds as $entityId) { $entities[$entityId]['appointment_total_count'] += $appointmentCount; $entities[$entityId]['interview_count'] += $interviewCount; } } self::hydratePaidAppointmentStats( $entities, $dimension, $entityIds, $adminToDeptIds, $startTimestamp, $endTimestamp, $mediaChannel, $visibleAdminIds, $useRegistrationMetric ); foreach ($entities as &$entity) { $appointmentTotalCount = (int)($entity['appointment_total_count'] ?? 0); $paidAppointmentCount = (int)($entity['paid_appointment_count'] ?? 0); $entity['free_appointment_count'] = max($appointmentTotalCount - $paidAppointmentCount, 0); } unset($entity); } /** * @param array> $entities * @param int[] $entityIds * @param array $adminToDeptIds * @param array|null $mediaChannel * @param int[]|null $visibleAdminIds */ private static function hydratePaidAppointmentStats( array &$entities, string $dimension, array $entityIds, array $adminToDeptIds, int $startTimestamp, int $endTimestamp, ?array $mediaChannel, ?array $visibleAdminIds = null, bool $useRegistrationMetric = false ): void { $startDateTime = date('Y-m-d H:i:s', $startTimestamp); $endDateTime = date('Y-m-d H:i:s', $endTimestamp); $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 ($mediaChannel !== null) { 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, $sourceAdminId, $entityIds, $adminToDeptIds, $visibleAdminIds); if ($mappedEntityIds === []) { continue; } $count = (int)($row['paid_appointment_count'] ?? 0); foreach ($mappedEntityIds as $entityId) { $entities[$entityId]['paid_appointment_count'] += $count; } } } /** * 挂号渠道兼容:新表写 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> $entities * @param int[] $entityIds * @param array $adminToDeptIds * @param array|null $mediaChannel * @param int[]|null $visibleAdminIds */ private static function hydrateOrderAndAmountStats( array &$entities, string $dimension, array $entityIds, array $adminToDeptIds, int $startTimestamp, int $endTimestamp, ?array $mediaChannel, ?array $visibleAdminIds = null, bool $usePerformanceOrderMetrics = false ): void { if ($usePerformanceOrderMetrics) { $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 ->fieldRaw("{$sourceExpr} AS source_admin_id, COUNT(*) AS order_count, SUM(po.amount) AS total_amount") ->group($sourceExpr); 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, $sourceAdminId, $entityIds, $adminToDeptIds, $visibleAdminIds ); if ($mappedEntityIds === []) { continue; } $orderCount = (int)($row['order_count'] ?? 0); $amount = round((float)($row['total_amount'] ?? 0), 2); foreach ($mappedEntityIds as $entityId) { $entities[$entityId]['completed_order_count'] += $orderCount; $entities[$entityId]['completed_order_amount'] = round( (float)$entities[$entityId]['completed_order_amount'] + $amount, 2 ); $entities[$entityId]['business_order_amount'] = round( (float)$entities[$entityId]['business_order_amount'] + $amount, 2 ); } } return; } $completedSourceExpr = $dimension === 'doctor' ? 'rx.creator_id' : 'rx.assistant_id'; $completedQuery = Db::name('tcm_prescription_order') ->alias('po') ->leftJoin('tcm_prescription rx', 'rx.id = po.prescription_id') ->leftJoin('tcm_diagnosis dg', 'dg.id = po.diagnosis_id') ->whereNull('po.delete_time') ->where('po.payment_slip_audit_status', 1) ->where('po.create_time', 'between', [$startTimestamp, $endTimestamp]); YejiStatsLogic::applyPrescriptionOrderEffectiveAmountQuery($completedQuery, 'po'); $completedQuery ->fieldRaw("{$completedSourceExpr} AS source_admin_id, SUM(CASE WHEN po.prescription_audit_status = 1 THEN 1 ELSE 0 END) AS order_count, SUM(CASE WHEN po.prescription_audit_status = 1 THEN po.amount ELSE 0 END) AS total_amount") ->group($completedSourceExpr); if ($mediaChannel !== null) { $completedQuery->leftJoin('order o', 'o.id = po.linked_pay_order_id'); MediaChannelService::applyExternalUserChannelFilter($completedQuery, 'o.payer_external_userid', $mediaChannel); } $completedRows = $completedQuery->select()->toArray(); foreach ($completedRows as $row) { $sourceAdminId = (int)($row['source_admin_id'] ?? 0); $mappedEntityIds = self::mapEntityIds($dimension, $sourceAdminId, $entityIds, $adminToDeptIds, $visibleAdminIds); if ($mappedEntityIds === []) { continue; } $orderCount = (int)($row['order_count'] ?? 0); $amount = round((float)($row['total_amount'] ?? 0), 2); foreach ($mappedEntityIds as $entityId) { $entities[$entityId]['completed_order_count'] += $orderCount; $entities[$entityId]['completed_order_amount'] = round($entities[$entityId]['completed_order_amount'] + $amount, 2); } } $businessSourceExpr = $dimension === 'doctor' ? 'po.creator_id' : 'dg.assistant_id'; $businessQuery = Db::name('tcm_prescription_order') ->alias('po') ->leftJoin('tcm_diagnosis dg', 'dg.id = po.diagnosis_id') ->whereNull('po.delete_time') ->where('po.create_time', 'between', [$startTimestamp, $endTimestamp]); YejiStatsLogic::applyPrescriptionOrderEffectiveAmountQuery($businessQuery, 'po'); $businessQuery ->fieldRaw("{$businessSourceExpr} AS source_admin_id, SUM(po.amount) AS total_amount") ->group($businessSourceExpr); if ($mediaChannel !== null) { $businessQuery->leftJoin('order o2', 'o2.id = po.linked_pay_order_id'); MediaChannelService::applyExternalUserChannelFilter($businessQuery, 'o2.payer_external_userid', $mediaChannel); } $businessRows = $businessQuery->select()->toArray(); foreach ($businessRows as $row) { $sourceAdminId = (int)($row['source_admin_id'] ?? 0); $mappedEntityIds = self::mapEntityIds($dimension, $sourceAdminId, $entityIds, $adminToDeptIds, $visibleAdminIds); if ($mappedEntityIds === []) { continue; } $amount = round((float)($row['total_amount'] ?? 0), 2); foreach ($mappedEntityIds as $entityId) { $entities[$entityId]['business_order_amount'] = round($entities[$entityId]['business_order_amount'] + $amount, 2); } } } /** * 账户消耗:来源于独立维护表 zyt_account_cost。 * * @param array> $entities * @param string[]|null $mediaChannelCodes null=不按渠道过滤;[]=已选渠道但无匹配 code,成本记 0 * @param int[]|null $visibleDeptIds 可见部门集合(null = SCOPE_ALL,不收窄) * @return array{0: float, 1: int[]} */ private static function hydrateAccountCostStats( array &$entities, string $startDate, string $endDate, ?array $mediaChannelCodes, ?array $visibleDeptIds = null ): array { $mediaChannelCodes = $mediaChannelCodes === null ? null : self::normalizeMediaChannelCodes($mediaChannelCodes); if ($mediaChannelCodes === []) { foreach ($entities as &$entity) { $entity['account_cost'] = 0.0; $entity['_global_account_cost'] = 0.0; } unset($entity); return [0.0, []]; } $supportsDeptBinding = AccountCost::supportsDeptBinding(); $query = Db::name('account_cost') ->where('cost_date', '>=', $startDate) ->where('cost_date', '<=', $endDate); if ($supportsDeptBinding) { $query->field('dept_id, amount'); } else { $query->field('amount'); } if ($mediaChannelCodes !== null) { $query->whereIn('media_channel_code', $mediaChannelCodes); } if ($supportsDeptBinding) { $query->where('dept_id', '>', 0); // 数据隔离:仅统计可见部门的账户消耗;不支持 dept_binding 时无法按部门收窄,跳过。 if ($visibleDeptIds !== null) { if ($visibleDeptIds === []) { foreach ($entities as &$entity) { $entity['account_cost'] = 0.0; $entity['_global_account_cost'] = 0.0; } unset($entity); return [0.0, []]; } $query->whereIn('dept_id', $visibleDeptIds); } } $rows = $query->select()->toArray(); $totalAmount = 0.0; $deptIds = []; foreach ($rows as $row) { $amount = round((float)($row['amount'] ?? 0), 2); $totalAmount = round($totalAmount + $amount, 2); $deptId = $supportsDeptBinding ? (int)($row['dept_id'] ?? 0) : 0; if ($supportsDeptBinding && $deptId > 0) { $deptIds[$deptId] = $deptId; } } foreach ($entities as &$entity) { $entity['account_cost'] = 0.0; $entity['_global_account_cost'] = $totalAmount; } unset($entity); return [$totalAmount, array_values($deptIds)]; } /** * @param int[] $entityIds * @param array $adminToDeptIds * @param int[]|null $visibleAdminIds 可见 admin 集合;非 null 时 admin 不在集合中直接拒绝(不归到任何 entity) * @return int[] * * dept 维度下 admin 跨部门时,仅返回 admin_dept 中第一个落在当前 entityIds 内的部门 * (由 loadAdminDeptMap 排序决定),保证同一笔指标只累加到一个部门,避免重复计数。 */ private static function mapEntityIds( string $dimension, int $adminId, array $entityIds, array $adminToDeptIds, ?array $visibleAdminIds = null ): array { if ($adminId <= 0) { return []; } if ($visibleAdminIds !== null && !in_array($adminId, $visibleAdminIds, true)) { return []; } if ($dimension !== 'dept') { return in_array($adminId, $entityIds, true) ? [$adminId] : []; } $deptIds = $adminToDeptIds[$adminId] ?? []; if ($deptIds === []) { return []; } foreach ($deptIds as $deptId) { if (in_array((int)$deptId, $entityIds, true)) { return [(int)$deptId]; } } return []; } /** * @param array> $entities * @return array> */ private static function finalizeRows( array $entities, bool $excludeEmpty = false, ?int $allocationAddFansCount = null, bool $restrictAccountCostByDept = false, array $eligibleDeptIds = [], array $adminToDeptIds = [], string $dimension = 'dept' ): array { $rows = []; $filteredEntities = $excludeEmpty ? array_values(array_filter($entities, static fn (array $entity): bool => self::hasBusinessMetrics($entity))) : array_values($entities); $globalAccountCost = self::extractGlobalAccountCost($filteredEntities); $globalAddFansCount = $allocationAddFansCount ?? self::sumEntityAddFans($filteredEntities); foreach ($filteredEntities as $entity) { $paidAppointmentCount = (int)($entity['paid_appointment_count'] ?? 0); $freeAppointmentCount = (int)($entity['free_appointment_count'] ?? 0); $appointmentTotalCount = (int)($entity['appointment_total_count'] ?? 0); $interviewCount = (int)$entity['interview_count']; $addFansCount = (int)$entity['add_fans_count']; $deletedFansCount = (int)($entity['deleted_fans_count'] ?? 0); $totalOpenCount = (int)$entity['total_open_count']; $completedOrderCount = (int)$entity['completed_order_count']; $businessOrderAmount = round((float)$entity['business_order_amount'], 2); $completedOrderAmount = round((float)$entity['completed_order_amount'], 2); $isEligibleForAccountCost = self::isAccountCostEligible($dimension, $entity, $restrictAccountCostByDept, $eligibleDeptIds, $adminToDeptIds); $effectiveAccountCost = self::allocateAccountCost($globalAccountCost, $globalAddFansCount, $addFansCount, $isEligibleForAccountCost); $entity['paid_appointment_count'] = $paidAppointmentCount; $entity['deleted_fans_count'] = $deletedFansCount; $entity['free_appointment_count'] = $freeAppointmentCount; $entity['appointment_total_count'] = $appointmentTotalCount; $entity['business_order_amount'] = $businessOrderAmount; $entity['completed_order_amount'] = $completedOrderAmount; $entity['account_cost'] = $effectiveAccountCost; $entity['paid_appointment_rate'] = self::percent($paidAppointmentCount, $addFansCount); $entity['open_appointment_rate'] = self::percent($paidAppointmentCount, $totalOpenCount); // 预约率:面诊 / 预约(看预约后未到面) $entity['interview_rate'] = self::percent($interviewCount, $appointmentTotalCount); // 接诊率:接诊诊单 / 总进线(加粉);医生维度仍用面诊作分母 $entity['receive_rate'] = self::receiveRate($completedOrderCount, $addFansCount, $interviewCount, $dimension); // 面诊接诊率:接诊诊单 / 面诊 $entity['interview_receive_rate'] = self::percent($completedOrderCount, $interviewCount); // 面诊率:面诊 / 挂号(看挂号后流失) $entity['interview_paid_rate'] = self::percent($interviewCount, $paidAppointmentCount); $entity['open_receive_rate'] = self::percent($completedOrderCount, $totalOpenCount); $entity['avg_unit_price'] = self::safeDivideMoney($completedOrderAmount, $completedOrderCount); $entity['cash_cost'] = self::safeDivideMoney($effectiveAccountCost, $addFansCount); $entity['roi'] = self::safeDivideRatio($completedOrderAmount, $effectiveAccountCost); if ($globalAccountCost > 0) { $entity['_global_account_cost'] = $globalAccountCost; } $rows[] = $entity; } usort($rows, static function (array $left, array $right): int { $order = $right['completed_order_amount'] <=> $left['completed_order_amount']; if ($order !== 0) { return $order; } $order = $right['completed_order_count'] <=> $left['completed_order_count']; if ($order !== 0) { return $order; } return $right['add_fans_count'] <=> $left['add_fans_count']; }); return $rows; } /** * @param array> $entities * @return array{0: array>, 1: array>, 2: array>} */ private static function buildDeptTreeRows( array $entities, int $selectedDeptId, int $pageNo, int $pageSize, bool $excludeEmpty = false, ?int $allocationAddFansCount = null, bool $restrictAccountCostByDept = false, array $eligibleDeptIds = [], array $adminToDeptIds = [] ): array { $childrenByPid = []; foreach ($entities as $entity) { $pid = (int)($entity['pid'] ?? 0); $childrenByPid[$pid] ??= []; $childrenByPid[$pid][] = (int)$entity['id']; } foreach ($childrenByPid as &$ids) { usort($ids, static function (int $left, int $right) use ($entities): int { $sortCompare = ((int)($entities[$right]['sort'] ?? 0)) <=> ((int)($entities[$left]['sort'] ?? 0)); if ($sortCompare !== 0) { return $sortCompare; } return $left <=> $right; }); } unset($ids); $buildNode = function (int $deptId) use (&$buildNode, &$entities, $childrenByPid): array { $node = $entities[$deptId]; $children = []; foreach ($childrenByPid[$deptId] ?? [] as $childId) { $childNode = $buildNode((int)$childId); $children[] = $childNode; $node['add_fans_count'] += (int)$childNode['add_fans_count']; $node['deleted_fans_count'] += (int)($childNode['deleted_fans_count'] ?? 0); $node['total_open_count'] += (int)$childNode['total_open_count']; $node['unreplied_count'] += (int)$childNode['unreplied_count']; $node['paid_appointment_count'] += (int)$childNode['paid_appointment_count']; $node['free_appointment_count'] += (int)$childNode['free_appointment_count']; $node['appointment_total_count'] += (int)$childNode['appointment_total_count']; $node['interview_count'] += (int)$childNode['interview_count']; $node['completed_order_count'] += (int)$childNode['completed_order_count']; $node['business_order_amount'] = round((float)$node['business_order_amount'] + (float)$childNode['business_order_amount'], 2); $node['completed_order_amount'] = round((float)$node['completed_order_amount'] + (float)$childNode['completed_order_amount'], 2); $node['account_cost'] = round((float)$node['account_cost'] + (float)$childNode['account_cost'], 2); } $node['children'] = $children; return $node; }; $rootIds = []; foreach ($entities as $entity) { $pid = (int)($entity['pid'] ?? 0); if (!isset($entities[$pid])) { $rootIds[] = (int)$entity['id']; } } usort($rootIds, static function (int $left, int $right) use ($entities): int { $sortCompare = ((int)($entities[$right]['sort'] ?? 0)) <=> ((int)($entities[$left]['sort'] ?? 0)); if ($sortCompare !== 0) { return $sortCompare; } return $left <=> $right; }); $rootRows = array_map($buildNode, $rootIds); if ($excludeEmpty) { $rootRows = array_values(array_filter(array_map( static fn (array $row): ?array => self::pruneDeptNode($row), $rootRows ))); } $realRootRows = array_values(array_filter($rootRows, static fn (array $row): bool => !((bool)($row['_virtual_bucket'] ?? false)))); $virtualRoot = count($realRootRows) === 1 && !empty($realRootRows[0]['children']) ? $realRootRows[0] : null; if ($selectedDeptId > 0) { $selectedNode = self::findDeptNode($rootRows, $selectedDeptId); if ($selectedNode !== null) { $allRowsRaw = [$selectedNode]; $chartRowsRaw = !empty($selectedNode['children']) ? $selectedNode['children'] : [$selectedNode]; } else { $allRowsRaw = $rootRows; $chartRowsRaw = $virtualRoot !== null ? ($virtualRoot['children'] ?? [$virtualRoot]) : ($realRootRows ?: $rootRows); } } else { $allRowsRaw = $rootRows; $chartRowsRaw = $virtualRoot !== null ? ($virtualRoot['children'] ?? [$virtualRoot]) : ($realRootRows ?: $rootRows); } $globalAccountCost = self::extractGlobalAccountCost($allRowsRaw); $globalAddFansCount = $allocationAddFansCount ?? self::sumNodeAddFans($allRowsRaw); $allRows = array_map( static fn (array $row): array => self::finalizeDeptNode($row, $globalAccountCost, $globalAddFansCount, $restrictAccountCostByDept, $eligibleDeptIds, $adminToDeptIds), $allRowsRaw ); $chartRows = array_map( static fn (array $row): array => self::finalizeDeptNode($row, $globalAccountCost, $globalAddFansCount, $restrictAccountCostByDept, $eligibleDeptIds, $adminToDeptIds), $chartRowsRaw ); $offset = ($pageNo - 1) * $pageSize; return [$allRows, array_slice($allRows, $offset, $pageSize), $chartRows]; } /** * @param array> $rows * @return array|null */ private static function findDeptNode(array $rows, int $deptId): ?array { foreach ($rows as $row) { if ((int)($row['id'] ?? 0) === $deptId) { return $row; } $children = $row['children'] ?? []; if (!is_array($children) || $children === []) { continue; } $matched = self::findDeptNode($children, $deptId); if ($matched !== null) { return $matched; } } return null; } /** * @param array $node * @return array|null */ private static function pruneDeptNode(array $node): ?array { $children = []; foreach ($node['children'] ?? [] as $child) { if (!is_array($child)) { continue; } $pruned = self::pruneDeptNode($child); if ($pruned !== null) { $children[] = $pruned; } } $node['children'] = $children; if ($children !== []) { return $node; } return self::hasBusinessMetrics($node) ? $node : null; } /** * @param array $node * @return array */ private static function finalizeDeptNode( array $node, float $globalAccountCost, int $globalAddFansCount, bool $restrictAccountCostByDept = false, array $eligibleDeptIds = [], array $adminToDeptIds = [] ): array { $node['children'] = array_map( static fn (array $child): array => self::finalizeDeptNode($child, $globalAccountCost, $globalAddFansCount, $restrictAccountCostByDept, $eligibleDeptIds, $adminToDeptIds), $node['children'] ?? [] ); $paidAppointmentCount = (int)($node['paid_appointment_count'] ?? 0); $freeAppointmentCount = (int)($node['free_appointment_count'] ?? 0); $appointmentTotalCount = (int)($node['appointment_total_count'] ?? 0); $interviewCount = (int)$node['interview_count']; $addFansCount = (int)$node['add_fans_count']; $deletedFansCount = (int)($node['deleted_fans_count'] ?? 0); $totalOpenCount = (int)$node['total_open_count']; $completedOrderCount = (int)$node['completed_order_count']; $businessOrderAmount = round((float)$node['business_order_amount'], 2); $completedOrderAmount = round((float)$node['completed_order_amount'], 2); $isEligibleForAccountCost = self::isDeptAccountCostEligible((int) ($node['id'] ?? 0), $restrictAccountCostByDept, $eligibleDeptIds); $effectiveAccountCost = self::allocateAccountCost($globalAccountCost, $globalAddFansCount, $addFansCount, $isEligibleForAccountCost); $childrenAccountCost = 0.0; foreach ($node['children'] as $child) { $childrenAccountCost = round($childrenAccountCost + (float)($child['account_cost'] ?? 0), 2); } $node['paid_appointment_count'] = $paidAppointmentCount; $node['deleted_fans_count'] = $deletedFansCount; $node['free_appointment_count'] = $freeAppointmentCount; $node['appointment_total_count'] = $appointmentTotalCount; $node['business_order_amount'] = $businessOrderAmount; $node['completed_order_amount'] = $completedOrderAmount; $node['account_cost'] = $isEligibleForAccountCost ? $effectiveAccountCost : $childrenAccountCost; $node['paid_appointment_rate'] = self::percent($paidAppointmentCount, $addFansCount); $node['open_appointment_rate'] = self::percent($paidAppointmentCount, $totalOpenCount); $node['interview_rate'] = self::percent($interviewCount, $appointmentTotalCount); $node['receive_rate'] = self::receiveRate($completedOrderCount, $addFansCount, $interviewCount, 'dept'); $node['interview_receive_rate'] = self::percent($completedOrderCount, $interviewCount); $node['interview_paid_rate'] = self::percent($interviewCount, $paidAppointmentCount); $node['open_receive_rate'] = self::percent($completedOrderCount, $totalOpenCount); $node['avg_unit_price'] = self::safeDivideMoney($completedOrderAmount, $completedOrderCount); $node['cash_cost'] = self::safeDivideMoney($effectiveAccountCost, $addFansCount); $node['roi'] = self::safeDivideRatio($completedOrderAmount, $effectiveAccountCost); if ($globalAccountCost > 0) { $node['_global_account_cost'] = $globalAccountCost; } unset($node['sort'], $node['pid']); return $node; } /** * 在部门树节点的 children 末尾追加该部门下属的成员明细行(不参与部门指标累加)。 * * @param array> $deptRows * @param array>> $memberRowsByDeptId * @return array> */ private static function attachDeptMembers(array $deptRows, array $memberRowsByDeptId): array { foreach ($deptRows as &$node) { $deptId = (int)($node['id'] ?? 0); $children = $node['children'] ?? []; if (!is_array($children)) { $children = []; } if ($children !== []) { $children = self::attachDeptMembers($children, $memberRowsByDeptId); } $members = $memberRowsByDeptId[$deptId] ?? []; if ($members !== []) { $children = array_merge($children, $members); } $node['children'] = $children; } unset($node); return $deptRows; } /** * 计算 dept 维度下每个部门下属成员(医助 / 医生)的明细行,并按 dept_id 分组返回。 * 成员节点的 id 使用 "M{adminId}_{deptId}" 形式以保证 row-key 唯一。 * * @param int[] $validDeptIds 当前有效的 dept_id 集合(来自 dept 树构建后的 entities), * 用于过滤 admin_dept 表中残留的已删除部门关联,避免把成员 * 挂到不存在的部门上、错过"未分配部门"虚拟桶。 * @param float $globalAccountCost 由调用方提前计算好的本期总账户消耗(zyt_account_cost SUM)。 * -1 表示让本函数自行 hydrate;>= 0 时直接复用,避免重复 SQL。 * @param int[]|null $visibleAdminIds 可见 admin 集合(null = SCOPE_ALL);用于成员明细的隔离 * @param bool $excludeCancelledAppointments 是否排除已取消挂号,须与部门汇总口径一致 * @param bool $usePerformanceOrderMetrics 是否使用有效业绩订单口径,须与部门汇总口径一致 * @return array>> dept_id => [member_row, ...] */ private static function buildMemberRowsByDept( int $startTimestamp, int $endTimestamp, string $startDate, string $endDate, ?array $mediaChannel, ?array $mediaChannelCodes, bool $restrictAccountCostByDept, array $eligibleDeptIds, array $adminToDeptIds, array $validDeptIds = [], float $globalAccountCost = -1.0, ?array $visibleAdminIds = null, bool $excludeCancelledAppointments = false, bool $usePerformanceOrderMetrics = false ): array { $assistantEntities = self::loadAdminEntities(2, 0, $visibleAdminIds); $doctorEntities = self::loadAdminEntities(1, 0, $visibleAdminIds); $assistantIds = array_keys($assistantEntities); $doctorIds = array_keys($doctorEntities); if ($assistantIds !== [] && !$usePerformanceOrderMetrics) { self::hydrateFanStats($assistantEntities, 'assistant', $assistantIds, $adminToDeptIds, $startTimestamp, $endTimestamp, $mediaChannel, $visibleAdminIds); self::hydrateAppointmentStats($assistantEntities, 'assistant', $assistantIds, $adminToDeptIds, $startTimestamp, $endTimestamp, $startDate, $endDate, $mediaChannel, $visibleAdminIds, $excludeCancelledAppointments); self::hydrateOrderAndAmountStats($assistantEntities, 'assistant', $assistantIds, $adminToDeptIds, $startTimestamp, $endTimestamp, $mediaChannel, $visibleAdminIds, $usePerformanceOrderMetrics); } if ($doctorIds !== [] && !$usePerformanceOrderMetrics) { self::hydrateFanStats($doctorEntities, 'doctor', $doctorIds, $adminToDeptIds, $startTimestamp, $endTimestamp, $mediaChannel, $visibleAdminIds); self::hydrateAppointmentStats($doctorEntities, 'doctor', $doctorIds, $adminToDeptIds, $startTimestamp, $endTimestamp, $startDate, $endDate, $mediaChannel, $visibleAdminIds, $excludeCancelledAppointments); self::hydrateOrderAndAmountStats($doctorEntities, 'doctor', $doctorIds, $adminToDeptIds, $startTimestamp, $endTimestamp, $mediaChannel, $visibleAdminIds, $usePerformanceOrderMetrics); } // 复用调用方传入的 global account cost;只有兜底未传时才回查一次(保留向后兼容)。 if ($globalAccountCost < 0) { // 任选一个非空 entity 集合查一次即可——查询本身只与日期 / 渠道相关。 if ($assistantIds !== []) { [$globalAccountCost] = self::hydrateAccountCostStats($assistantEntities, $startDate, $endDate, $mediaChannelCodes); } elseif ($doctorIds !== []) { [$globalAccountCost] = self::hydrateAccountCostStats($doctorEntities, $startDate, $endDate, $mediaChannelCodes); } else { $globalAccountCost = 0.0; } } $combined = []; foreach ($assistantEntities as $adminId => $entity) { $entity['_role'] = '医助'; $combined[$adminId] = $entity; } foreach ($doctorEntities as $adminId => $entity) { if (isset($combined[$adminId])) { foreach (['add_fans_count', 'deleted_fans_count', 'total_open_count', 'unreplied_count', 'paid_appointment_count', 'free_appointment_count', 'appointment_total_count', 'interview_count', 'completed_order_count'] as $intKey) { $combined[$adminId][$intKey] = (int)($combined[$adminId][$intKey] ?? 0) + (int)($entity[$intKey] ?? 0); } foreach (['business_order_amount', 'completed_order_amount'] as $moneyKey) { $combined[$adminId][$moneyKey] = round((float)($combined[$adminId][$moneyKey] ?? 0) + (float)($entity[$moneyKey] ?? 0), 2); } $combined[$adminId]['_role'] = '医助/医生'; } else { $entity['_role'] = '医生'; $combined[$adminId] = $entity; } } if ($usePerformanceOrderMetrics && $combined !== []) { // 一诊综合转化的部门指标均按业务归属人统计。成员明细也必须沿用同一归属, // 不能再分别按“医助/医生”统计后相加,否则双角色员工会重复、人员合计也无法与部门汇总对齐。 $combinedIds = array_keys($combined); self::hydrateFanStats($combined, 'assistant', $combinedIds, $adminToDeptIds, $startTimestamp, $endTimestamp, $mediaChannel, $visibleAdminIds); self::hydrateAppointmentStats($combined, 'assistant', $combinedIds, $adminToDeptIds, $startTimestamp, $endTimestamp, $startDate, $endDate, $mediaChannel, $visibleAdminIds, $excludeCancelledAppointments, true); self::hydrateOrderAndAmountStats($combined, 'assistant', $combinedIds, $adminToDeptIds, $startTimestamp, $endTimestamp, $mediaChannel, $visibleAdminIds, true); } if ($combined === []) { return []; } // $globalAccountCost 已经由调用方传入(或上面兜底查得),不再从 entities 中重复提取。 $globalAddFansCount = 0; foreach ($combined as $entity) { $globalAddFansCount += (int)($entity['add_fans_count'] ?? 0); } $leaderAdminByDept = self::loadDeptLeaderAdminMap($adminToDeptIds); $validDeptIdSet = array_fill_keys(array_map('intval', $validDeptIds), true); $memberRowsByDeptId = []; foreach ($combined as $adminId => $entity) { $adminIdInt = (int)$adminId; if ($adminIdInt <= 0) { continue; } if (!self::hasBusinessMetrics($entity)) { continue; } $rawDeptIds = $adminToDeptIds[$adminIdInt] ?? []; // 与 mapEntityIds 保持一致:admin 跨部门时只挂到 admin_dept 中第一个落在 valid 集合的部门, // 避免同一 admin 在多个部门下重复出现,导致 dept 指标累加到 summary 时双倍计数。 $primaryDeptId = 0; if ($validDeptIdSet === []) { foreach ($rawDeptIds as $rawDeptId) { $candidate = (int)$rawDeptId; if ($candidate > 0) { $primaryDeptId = $candidate; break; } } } else { foreach ($rawDeptIds as $rawDeptId) { $candidate = (int)$rawDeptId; if ($candidate > 0 && isset($validDeptIdSet[$candidate])) { $primaryDeptId = $candidate; break; } } } if ($primaryDeptId <= 0) { $row = self::finalizeAdminMemberRow( $entity, $globalAccountCost, $globalAddFansCount, $restrictAccountCostByDept, $eligibleDeptIds, $adminToDeptIds, (string)($entity['_role'] ?? ''), false, self::VIRTUAL_DEPT_UNASSIGNED_ID ); $memberRowsByDeptId[self::VIRTUAL_DEPT_UNASSIGNED_ID][] = $row; continue; } $isLeader = isset($leaderAdminByDept[$primaryDeptId]) && (int)$leaderAdminByDept[$primaryDeptId] === $adminIdInt; $row = self::finalizeAdminMemberRow( $entity, $globalAccountCost, $globalAddFansCount, $restrictAccountCostByDept, $eligibleDeptIds, $adminToDeptIds, (string)($entity['_role'] ?? ''), $isLeader, $primaryDeptId ); $memberRowsByDeptId[$primaryDeptId][] = $row; } // -1 桶仅在 SCOPE_ALL 下展示,因此非 SCOPE_ALL 时不再查未绑定 admin 的加粉。 if ($visibleAdminIds === null) { $unboundRows = self::buildUnboundFansRows($startTimestamp, $endTimestamp, $mediaChannel); if ($unboundRows !== []) { $memberRowsByDeptId[self::VIRTUAL_DEPT_UNBOUND_ADMIN_ID] = $unboundRows; } } // 补充:dept 维度 hydrate 把任意角色的 admin 加粉都归到 -2 桶, // 但 combined 仅含医助/医生角色 admin。此处把"非医助/医生"或"无 admin_role" // 但有加粉事件的 admin 也挂到"未分配部门"虚拟桶下,避免 children 为空。 $assignedAdminIds = array_fill_keys(array_map('intval', array_keys($combined)), true); $leftoverRows = self::buildLeftoverAdminRowsForUnassigned( $startTimestamp, $endTimestamp, $mediaChannel, $assignedAdminIds, $visibleAdminIds ); if ($leftoverRows !== []) { $existing = $memberRowsByDeptId[self::VIRTUAL_DEPT_UNASSIGNED_ID] ?? []; $memberRowsByDeptId[self::VIRTUAL_DEPT_UNASSIGNED_ID] = array_merge($existing, $leftoverRows); } foreach ($memberRowsByDeptId as &$rows) { usort($rows, static function (array $left, array $right): int { $leaderCompare = ((int)($right['is_leader'] ?? 0)) <=> ((int)($left['is_leader'] ?? 0)); if ($leaderCompare !== 0) { return $leaderCompare; } $amountCompare = $right['completed_order_amount'] <=> $left['completed_order_amount']; if ($amountCompare !== 0) { return $amountCompare; } $countCompare = $right['completed_order_count'] <=> $left['completed_order_count']; if ($countCompare !== 0) { return $countCompare; } return $right['add_fans_count'] <=> $left['add_fans_count']; }); } unset($rows); return $memberRowsByDeptId; } /** * "未绑定后台账号"虚拟桶的展开行:从加粉事件中按企微 user_id 聚合,过滤掉已绑定 admin 的部分。 * 这些 user_id 没有对应的 admin 记录,因此只能展示加粉数。 * * @return array> */ private static function buildUnboundFansRows(int $startTimestamp, int $endTimestamp, ?array $mediaChannel): array { $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'] ?? ''); return $userId !== '' && !isset($adminByUserId[$userId]); })); if ($rows === []) { return []; } $userIds = []; foreach ($rows as $row) { $userId = (string)($row['user_id'] ?? ''); if ($userId !== '') { $userIds[$userId] = true; } } $nameByUserId = self::resolveQywxUserNames(array_keys($userIds)); $result = []; foreach ($rows as $row) { $userId = (string)($row['user_id'] ?? ''); $addFansCount = (int)($row['add_fans_count'] ?? 0); $deletedFansCount = (int)($row['deleted_fans_count'] ?? 0); if ($userId === '' || ($addFansCount <= 0 && $deletedFansCount <= 0)) { continue; } $resolvedName = $nameByUserId[$userId] ?? ''; $displayName = $resolvedName !== '' ? $resolvedName . '(' . $userId . ')' : $userId; $memberRow = self::buildEmptyMemberRow( 'U_' . $userId, $displayName, 'unbound', '未绑定 admin' ); $memberRow['add_fans_count'] = $addFansCount; $memberRow['deleted_fans_count'] = $deletedFansCount; $result[] = $memberRow; } return $result; } /** * "未分配部门"虚拟桶的补充行:把医助/医生主流程没覆盖到的 admin 也挂上。 * 适用场景:admin 有加粉事件、能匹配到 admin(即不属于"未绑定 admin"), * 但因为他不是医助/医生(admin_role 不是 1/2,或没有 admin_role 记录)所以 * 不在 buildMemberRowsByDept 的主循环里。 * * 这些 admin 的业务指标无法从医助/医生口径得出,因此仅展示加粉数。 * * @param array $assignedAdminIds 已挂过部门或 -2 桶的 admin_id 集合 * @param int[]|null $visibleAdminIds 可见 admin 集合(null = SCOPE_ALL,不收窄) * @return array> */ private static function buildLeftoverAdminRowsForUnassigned( int $startTimestamp, int $endTimestamp, ?array $mediaChannel, array $assignedAdminIds, ?array $visibleAdminIds = null ): array { $fanRows = self::loadFanRows($startTimestamp, $endTimestamp, $mediaChannel, $visibleAdminIds); if ($fanRows === []) { return []; } $adminByUserId = self::loadActiveAdminByWorkWechatUserIds(array_column($fanRows, 'user_id')); $result = []; 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); $deletedFansCount = (int)($row['deleted_fans_count'] ?? 0); if ($adminId <= 0 || ($addFansCount <= 0 && $deletedFansCount <= 0)) { continue; } if (isset($assignedAdminIds[$adminId])) { continue; } $name = trim((string)($admin['name'] ?? '')); if ($name === '') { $name = 'admin#' . $adminId; } $memberRow = self::buildEmptyMemberRow( 'M' . $adminId . '_' . self::VIRTUAL_DEPT_UNASSIGNED_ID, $name, 'member', '其他角色' ); $memberRow['admin_id'] = $adminId; $memberRow['add_fans_count'] = $addFansCount; $memberRow['deleted_fans_count'] = $deletedFansCount; $result[] = $memberRow; } return $result; } /** * 反查企微员工 user_id(如 CaoTaDuo)对应的中文名。 * 来源:admin 表(含已软删的,避免离职后丢失映射)。未命中时直接展示原始 userid, * 避免仅为展示名称对十几万行 follow_users TEXT 做前导通配全表扫描。 * * @param string[] $userIds * @return array */ private static function resolveQywxUserNames(array $userIds): array { $userIds = array_values(array_unique(array_filter($userIds, static fn (string $id): bool => $id !== ''))); if ($userIds === []) { return []; } $result = []; // admin 表(包含已软删账户)反查;company-side ALL admin 即使删除也保留 work_wechat_userid 映射可用。 $adminRows = Db::name('admin') ->whereIn('work_wechat_userid', $userIds) ->field('work_wechat_userid, name') ->select() ->toArray(); foreach ($adminRows as $row) { $userId = (string)($row['work_wechat_userid'] ?? ''); $name = trim((string)($row['name'] ?? '')); if ($userId === '' || $name === '' || isset($result[$userId])) { continue; } $result[$userId] = $name; } return $result; } /** * 生成一个全字段为 0 的成员行模板(适用于"未绑定后台账号"等无业务关联的虚拟成员)。 * * @return array */ private static function buildEmptyMemberRow(string $id, string $name, string $type, string $role): array { return [ 'id' => $id, 'admin_id' => 0, 'name' => $name, 'type' => $type, 'role' => $role, 'is_leader' => false, 'add_fans_count' => 0, 'deleted_fans_count' => 0, 'total_open_count' => 0, 'unreplied_count' => 0, 'paid_appointment_count' => 0, 'free_appointment_count' => 0, 'appointment_total_count' => 0, 'interview_count' => 0, 'completed_order_count' => 0, 'business_order_amount' => 0.0, 'completed_order_amount' => 0.0, 'account_cost' => 0.0, 'paid_appointment_rate' => 0.0, 'open_appointment_rate' => 0.0, 'interview_rate' => 0.0, 'receive_rate' => 0.0, 'interview_receive_rate' => 0.0, 'interview_paid_rate' => 0.0, 'open_receive_rate' => 0.0, 'avg_unit_price' => 0.0, 'cash_cost' => 0.0, 'roi' => 0.0, 'children' => [], ]; } /** * 单个成员行的指标 finalize(不进入 charts/summary,仅作为 dept 树展开后的展示节点)。 * * @param array $entity * @return array */ private static function finalizeAdminMemberRow( array $entity, float $globalAccountCost, int $globalAddFansCount, bool $restrictAccountCostByDept, array $eligibleDeptIds, array $adminToDeptIds, string $role, bool $isLeader, int $deptId ): array { $adminId = (int)($entity['id'] ?? 0); $paidAppointmentCount = (int)($entity['paid_appointment_count'] ?? 0); $appointmentTotalCount = (int)($entity['appointment_total_count'] ?? 0); $interviewCount = (int)($entity['interview_count'] ?? 0); $addFansCount = (int)($entity['add_fans_count'] ?? 0); $deletedFansCount = (int)($entity['deleted_fans_count'] ?? 0); $totalOpenCount = (int)($entity['total_open_count'] ?? 0); $completedOrderCount = (int)($entity['completed_order_count'] ?? 0); $businessOrderAmount = round((float)($entity['business_order_amount'] ?? 0), 2); $completedOrderAmount = round((float)($entity['completed_order_amount'] ?? 0), 2); $isEligibleForAccountCost = self::isAccountCostEligible('member', $entity, $restrictAccountCostByDept, $eligibleDeptIds, $adminToDeptIds); $effectiveAccountCost = self::allocateAccountCost($globalAccountCost, $globalAddFansCount, $addFansCount, $isEligibleForAccountCost); return [ 'id' => 'M' . $adminId . '_' . $deptId, 'admin_id' => $adminId, 'name' => (string)($entity['name'] ?? ''), 'type' => 'member', 'role' => $role, 'is_leader' => $isLeader, 'add_fans_count' => $addFansCount, 'deleted_fans_count' => $deletedFansCount, 'total_open_count' => $totalOpenCount, 'unreplied_count' => (int)($entity['unreplied_count'] ?? 0), 'paid_appointment_count' => $paidAppointmentCount, 'free_appointment_count' => max($appointmentTotalCount - $paidAppointmentCount, 0), 'appointment_total_count' => $appointmentTotalCount, 'interview_count' => $interviewCount, 'completed_order_count' => $completedOrderCount, 'business_order_amount' => $businessOrderAmount, 'completed_order_amount' => $completedOrderAmount, 'account_cost' => $effectiveAccountCost, 'paid_appointment_rate' => self::percent($paidAppointmentCount, $addFansCount), 'open_appointment_rate' => self::percent($paidAppointmentCount, $totalOpenCount), 'interview_rate' => self::percent($interviewCount, $appointmentTotalCount), 'receive_rate' => self::receiveRate( $completedOrderCount, $addFansCount, $interviewCount, 'member' ), 'interview_receive_rate' => self::percent($completedOrderCount, $interviewCount), 'interview_paid_rate' => self::percent($interviewCount, $paidAppointmentCount), 'open_receive_rate' => self::percent($completedOrderCount, $totalOpenCount), 'avg_unit_price' => self::safeDivideMoney($completedOrderAmount, $completedOrderCount), 'cash_cost' => self::safeDivideMoney($effectiveAccountCost, $addFansCount), 'roi' => self::safeDivideRatio($completedOrderAmount, $effectiveAccountCost), 'children' => [], ]; } /** * 部门 leader 字段(字符串 admin name)→ admin_id 映射,便于在成员列表中标记组长。 * 匹配策略(按优先级): * 1. 仅在该部门下属成员(admin_dept 关联)中找候选,避免跨部门误命中; * 2. 归一化字符串(去空格、去括号注释、去常见职务后缀)后做完全相等; * 3. 退化为包含匹配(dept.leader 字符串包含 admin.name 或反向)。 * * @param array $adminToDeptIds admin_id => [dept_id, ...] * @return array dept_id => admin_id */ private static function loadDeptLeaderAdminMap(array $adminToDeptIds): array { $deptRows = Db::name('dept') ->whereNull('delete_time') ->field('id, leader') ->select() ->toArray(); $leaderByDept = []; foreach ($deptRows as $row) { $deptId = (int)($row['id'] ?? 0); $leader = self::normalizeLeaderName((string)($row['leader'] ?? '')); if ($deptId <= 0 || $leader === '') { continue; } $leaderByDept[$deptId] = $leader; } if ($leaderByDept === []) { return []; } $deptToAdmins = []; foreach ($adminToDeptIds as $adminId => $deptIds) { $adminIdInt = (int)$adminId; if ($adminIdInt <= 0) { continue; } foreach ($deptIds as $deptId) { $deptToAdmins[(int)$deptId][] = $adminIdInt; } } $candidateAdminIds = []; foreach (array_keys($leaderByDept) as $deptId) { foreach ($deptToAdmins[$deptId] ?? [] as $aid) { $candidateAdminIds[$aid] = $aid; } } if ($candidateAdminIds === []) { return []; } $adminNames = Db::name('admin') ->whereNull('delete_time') ->whereIn('id', array_values($candidateAdminIds)) ->column('name', 'id'); $result = []; foreach ($leaderByDept as $deptId => $leaderName) { $candidates = $deptToAdmins[$deptId] ?? []; if ($candidates === []) { continue; } $normalized = []; foreach ($candidates as $aid) { $rawName = (string)($adminNames[$aid] ?? ''); $normName = self::normalizeLeaderName($rawName); if ($normName === '') { continue; } $normalized[$aid] = $normName; } if ($normalized === []) { continue; } $matched = null; foreach ($normalized as $aid => $nm) { if ($nm === $leaderName) { $matched = $aid; break; } } if ($matched === null) { foreach ($normalized as $aid => $nm) { if (mb_strlen($nm) >= 2 && (mb_strpos($leaderName, $nm) !== false || mb_strpos($nm, $leaderName) !== false)) { $matched = $aid; break; } } } if ($matched !== null) { $result[$deptId] = (int)$matched; } } return $result; } /** * 规范化用于姓名匹配的字符串:去空格 / 全角空格 / 中英文括号注释 / 常见职务后缀。 */ private static function normalizeLeaderName(string $raw): string { $value = trim($raw); if ($value === '') { return ''; } // 去括号及其内部说明(中英文括号) $value = preg_replace('/[((][^))]*[))]/u', '', $value) ?? $value; // 去全部空白(含全角空格) $value = preg_replace('/[\s\x{3000}]+/u', '', $value) ?? $value; // 去常见职务后缀 $suffixes = ['组长', '负责人', '主管', '主任', '医师', '医生', '医助', '老师']; foreach ($suffixes as $suffix) { $len = mb_strlen($suffix); while (mb_strlen($value) > $len && mb_substr($value, -$len) === $suffix) { $value = mb_substr($value, 0, mb_strlen($value) - $len); } } return trim($value); } /** * @param array> $rows * @return array */ private static function buildSummary(array $rows, string $dimension = 'dept'): array { $summary = [ 'add_fans_count' => 0, 'deleted_fans_count' => 0, 'total_open_count' => 0, 'unreplied_count' => 0, 'paid_appointment_count' => 0, 'free_appointment_count' => 0, 'appointment_total_count' => 0, 'interview_count' => 0, 'business_order_amount' => 0.0, 'completed_order_amount' => 0.0, 'completed_order_count' => 0, 'account_cost' => 0.0, ]; foreach ($rows as $row) { $summary['add_fans_count'] += (int)$row['add_fans_count']; $summary['deleted_fans_count'] += (int)($row['deleted_fans_count'] ?? 0); $summary['total_open_count'] += (int)$row['total_open_count']; $summary['unreplied_count'] += (int)$row['unreplied_count']; $summary['paid_appointment_count'] += (int)$row['paid_appointment_count']; $summary['free_appointment_count'] += (int)$row['free_appointment_count']; $summary['appointment_total_count'] += (int)$row['appointment_total_count']; $summary['interview_count'] += (int)$row['interview_count']; $summary['completed_order_count'] += (int)$row['completed_order_count']; $summary['business_order_amount'] = round($summary['business_order_amount'] + (float)($row['business_order_amount'] ?? 0), 2); $summary['completed_order_amount'] = round($summary['completed_order_amount'] + (float)$row['completed_order_amount'], 2); $summary['account_cost'] = round($summary['account_cost'] + (float)($row['account_cost'] ?? 0), 2); } $summary['paid_appointment_rate'] = self::percent($summary['paid_appointment_count'], $summary['add_fans_count']); $summary['open_appointment_rate'] = self::percent($summary['paid_appointment_count'], $summary['total_open_count']); $summary['interview_rate'] = self::percent($summary['interview_count'], $summary['appointment_total_count']); $summary['receive_rate'] = self::receiveRate( $summary['completed_order_count'], $summary['add_fans_count'], $summary['interview_count'], $dimension ); $summary['interview_receive_rate'] = self::percent($summary['completed_order_count'], $summary['interview_count']); $summary['interview_paid_rate'] = self::percent($summary['interview_count'], $summary['paid_appointment_count']); $summary['open_receive_rate'] = self::percent($summary['completed_order_count'], $summary['total_open_count']); $summary['avg_unit_price'] = self::safeDivideMoney($summary['completed_order_amount'], $summary['completed_order_count']); $summary['cash_cost'] = self::safeDivideMoney($summary['account_cost'], $summary['add_fans_count']); $summary['roi'] = self::safeDivideRatio($summary['completed_order_amount'], $summary['account_cost']); return $summary; } /** * @param array> $entities */ private static function extractGlobalAccountCost(array $entities): float { $globalAccountCost = 0.0; foreach ($entities as $entity) { $globalAccountCost = max($globalAccountCost, round((float)($entity['_global_account_cost'] ?? 0), 2)); } return $globalAccountCost; } /** * @param array> $entities */ private static function sumEntityAddFans(array $entities): int { $total = 0; foreach ($entities as $entity) { $total += (int)($entity['add_fans_count'] ?? 0); } return $total; } /** * @param array> $nodes */ private static function sumNodeAddFans(array $nodes): int { $total = 0; foreach ($nodes as $node) { $total += (int)($node['add_fans_count'] ?? 0); } return $total; } private static function isAccountCostEligible(string $dimension, array $entity, bool $restrictAccountCostByDept, array $eligibleDeptIds, array $adminToDeptIds): bool { if (!$restrictAccountCostByDept) { return true; } if ($dimension === 'dept') { return self::isDeptAccountCostEligible((int) ($entity['id'] ?? 0), true, $eligibleDeptIds); } $adminId = (int) ($entity['id'] ?? 0); $deptIds = $adminToDeptIds[$adminId] ?? []; foreach ($deptIds as $deptId) { if (in_array((int) $deptId, $eligibleDeptIds, true)) { return true; } } return false; } private static function isDeptAccountCostEligible(int $deptId, bool $restrictAccountCostByDept, array $eligibleDeptIds): bool { if (!$restrictAccountCostByDept) { return true; } if ($deptId <= 0) { return false; } return in_array($deptId, $eligibleDeptIds, true); } private static function sumEntityAddFansForAccountCost(array $entities, string $dimension, bool $restrictAccountCostByDept, array $eligibleDeptIds, array $adminToDeptIds): int { if (!$restrictAccountCostByDept) { return self::sumEntityAddFans($entities); } $total = 0; foreach ($entities as $entity) { if (!self::isAccountCostEligible($dimension, $entity, true, $eligibleDeptIds, $adminToDeptIds)) { continue; } $total += (int) ($entity['add_fans_count'] ?? 0); } return $total; } private static function allocateAccountCost(float $globalAccountCost, int $globalAddFansCount, int $rowAddFansCount, bool $isEligible = true): float { if (!$isEligible || $globalAccountCost <= 0 || $globalAddFansCount <= 0 || $rowAddFansCount <= 0) { return 0.0; } return round(($globalAccountCost / $globalAddFansCount) * $rowAddFansCount, 2); } /** * @param array $row */ private static function hasBusinessMetrics(array $row): bool { return (int)($row['add_fans_count'] ?? 0) > 0 || (int)($row['deleted_fans_count'] ?? 0) > 0 || (int)($row['paid_appointment_count'] ?? 0) > 0 || (int)($row['free_appointment_count'] ?? 0) > 0 || (int)($row['appointment_total_count'] ?? 0) > 0 || (int)($row['interview_count'] ?? 0) > 0 || (int)($row['completed_order_count'] ?? 0) > 0 || (float)($row['business_order_amount'] ?? 0) > 0 || (float)($row['completed_order_amount'] ?? 0) > 0; } /** * @param array> $rows * @return array */ private static function buildCharts(array $rows, string $dimension = 'dept'): array { $chartableRows = array_values(array_filter($rows, static fn (array $row): bool => !((bool)($row['_virtual_bucket'] ?? false)))); $topRows = array_slice($chartableRows, 0, 10); $ranking = [ 'names' => [], 'amounts' => [], 'order_counts' => [], ]; if ($dimension === 'doctor') { $ranking['appointment_counts'] = []; $ranking['interview_counts'] = []; } else { $ranking['fan_counts'] = []; $ranking['rois'] = []; } foreach ($topRows as $row) { $ranking['names'][] = $row['name']; $ranking['amounts'][] = $row['completed_order_amount']; $ranking['order_counts'][] = $row['completed_order_count']; if ($dimension === 'doctor') { $ranking['appointment_counts'][] = $row['appointment_total_count']; $ranking['interview_counts'][] = $row['interview_count']; } else { $ranking['fan_counts'][] = $row['add_fans_count']; $ranking['rois'][] = $row['roi']; } } $charts = [ 'ranking' => $ranking, 'amount_share' => array_map( static fn (array $row): array => ['name' => $row['name'], 'value' => $row['completed_order_amount']], array_filter($topRows, static fn (array $row): bool => (float)$row['completed_order_amount'] > 0) ), ]; if ($dimension === 'doctor') { $charts['order_share'] = array_map( static fn (array $row): array => ['name' => $row['name'], 'value' => $row['completed_order_count']], array_filter($topRows, static fn (array $row): bool => (int)$row['completed_order_count'] > 0) ); } else { $charts['fan_share'] = array_map( static fn (array $row): array => ['name' => $row['name'], 'value' => $row['add_fans_count']], array_filter($topRows, static fn (array $row): bool => (int)$row['add_fans_count'] > 0) ); } return $charts; } /** * 接诊率:接诊诊单 ÷ 总进线(加粉)。 * 医生维度无加粉口径时,退化为接诊诊单 ÷ 面诊。 */ private static function receiveRate(int $completedOrderCount, int $addFansCount, int $interviewCount, string $dimension): float { $denominator = $dimension === 'doctor' ? $interviewCount : $addFansCount; return self::percent($completedOrderCount, $denominator); } private static function percent(int $numerator, int $denominator): float { if ($denominator <= 0) { return 0.0; } return round(($numerator / $denominator) * 100, 2); } private static function safeDivideMoney(float $numerator, int $denominator): float { if ($denominator <= 0) { return 0.0; } return round($numerator / $denominator, 2); } private static function safeDivideRatio(float $numerator, float $denominator): float { if ($denominator <= 0) { return 0.0; } return round($numerator / $denominator, 2); } /** * @param array $parts */ private static function requestRowsCacheKey(string $namespace, array $parts): string { return $namespace . ':' . hash('sha256', serialize($parts)); } /** * @param array $parts * @param callable(): array> $loader * @return array> */ 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|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_ids' => $mediaChannel['source_tag_ids'] ?? [], 'tag_name' => (string)($mediaChannel['source_tag_name'] ?? ''), ]; } /** * @param string|string[] $mediaChannelCode * @return string[] */ private static function normalizeMediaChannelCodes(string|array $mediaChannelCode): array { $values = is_array($mediaChannelCode) ? $mediaChannelCode : [$mediaChannelCode]; $codes = []; foreach ($values as $value) { $code = trim((string)$value); if ($code !== '' && !str_starts_with($code, MediaChannelService::GROUP_CODE_PREFIX)) { $codes[$code] = $code; } } return array_values($codes); } /** * @param int[]|null $visibleAdminIds * @param int[] $eligibleDeptIds * @return int[] */ private static function resolveFilterAllowedDeptIds(?array $visibleAdminIds, array $eligibleDeptIds): array { if ($visibleAdminIds === null) { return array_values(array_unique(array_filter(array_map('intval', $eligibleDeptIds), static fn (int $deptId): bool => $deptId > 0))); } $allowedDeptIds = []; if ($visibleAdminIds !== []) { $deptIds = Db::name('admin_dept') ->whereIn('admin_id', $visibleAdminIds) ->column('dept_id'); foreach ($deptIds as $deptId) { $deptId = (int)$deptId; if ($deptId > 0) { $allowedDeptIds[$deptId] = $deptId; } } } foreach ($eligibleDeptIds as $deptId) { $deptId = (int)$deptId; if ($deptId > 0) { $allowedDeptIds[$deptId] = $deptId; } } return array_values($allowedDeptIds); } }