*/ public static function overview(int $adminId, array $adminInfo, array $params = []): array { $today = date('Y-m-d'); $yesterday = date('Y-m-d', strtotime('-1 day')); $dayBeforeYesterday = date('Y-m-d', strtotime('-2 days')); $monthStart = date('Y-m-01'); $previousMonthStart = date('Y-m-01', strtotime('first day of previous month')); $previousMonthLastDay = (int) date('t', strtotime($previousMonthStart)); $comparisonDay = min((int) date('j'), $previousMonthLastDay); $previousMonthComparableEnd = date( 'Y-m-d', strtotime($previousMonthStart . ' +' . max(0, $comparisonDay - 1) . ' days') ); $trendStart = date('Y-m-d', strtotime('-' . (self::TREND_DAYS - 1) . ' days')); $scope = self::buildScopeContext($adminId, $adminInfo); $roleScope = PerformanceDashboardScope::resolve($adminId, $adminInfo); $scope['kind'] = $roleScope['kind']; $scope['label'] = $roleScope['label']; $scope['viewer_id'] = $adminId; /** @var array|null $rankingVisibleAdminIds */ $rankingVisibleAdminIds = $scope['_visible_admin_ids']; unset($scope['_visible_admin_ids']); /** @var array|null $metricAdminIds */ $metricAdminIds = $roleScope['metric_admin_ids']; $isGroupLeader = ($roleScope['kind'] ?? '') === PerformanceDashboardScope::KIND_GROUP_LEADER; $scope['group_admin_ids'] = $isGroupLeader ? $metricAdminIds : null; $scope['is_limited'] = $metricAdminIds !== null; if ($metricAdminIds === null) { $scope['visible_member_count'] = (int) Db::name('admin')->whereNull('delete_time')->count(); } else { $scope['visible_member_count'] = count($metricAdminIds); } $centerRanking = self::resolveViewerCenterRankingScope($adminId); $centerDeptIds = $centerRanking['dept_ids']; $centerLocked = $centerDeptIds !== []; $isErCenter = (bool) ($centerRanking['is_er'] ?? false); $requestedRankingDeptId = max(0, (int) ($params['ranking_dept_id'] ?? 0)); $hasRankingDeptParam = array_key_exists('ranking_dept_id', $params) && $params['ranking_dept_id'] !== '' && $params['ranking_dept_id'] !== null; if ($centerLocked && $isErCenter && !$hasRankingDeptParam && !$isGroupLeader) { $requestedRankingDeptId = (int) ($centerRanking['own_dept_id'] ?? 0); } $rankingDeptId = self::resolveRankingDeptId( $requestedRankingDeptId, $adminId, $adminInfo, $centerDeptIds ); $orderDaily = self::loadPerformanceOrderDaily($previousMonthStart, $today, $metricAdminIds); $personalOrderDaily = self::loadPerformanceOrderDaily($monthStart, $today, [$adminId]); $registrationDaily = self::loadRegistrationDaily($trendStart, $today, $metricAdminIds); $monthAmount = self::sumDailyMetric($orderDaily, $monthStart, $today, 'amount'); $previousMonthAmount = self::sumDailyMetric( $orderDaily, $previousMonthStart, $previousMonthComparableEnd, 'amount' ); $yesterdayAmount = self::dailyMetric($orderDaily, $yesterday, 'amount'); $dayBeforeAmount = self::dailyMetric($orderDaily, $dayBeforeYesterday, 'amount'); $personalMonthAmount = self::sumDailyMetric($personalOrderDaily, $monthStart, $today, 'amount'); $todayOverview = ConversionLogic::overview([ 'dimension' => 'dept', 'time_type' => 'today', 'include_members' => 0, 'include_filters' => 0, 'exclude_cancelled_appointments' => 1, 'page_no' => 1, 'page_size' => 100, ], $adminId, $adminInfo, $metricAdminIds); $todaySummary = is_array($todayOverview['summary'] ?? null) ? $todayOverview['summary'] : []; $yesterdayOverview = ConversionLogic::overview([ 'dimension' => 'dept', 'time_type' => 'yesterday', 'include_members' => 0, 'include_filters' => 0, 'exclude_cancelled_appointments' => 1, 'page_no' => 1, 'page_size' => 100, ], $adminId, $adminInfo, $metricAdminIds); $yesterdaySummary = is_array($yesterdayOverview['summary'] ?? null) ? $yesterdayOverview['summary'] : []; // 业绩指标必须直接复用业绩页的权威聚合,不能使用 ConversionLogic 的“双审完成单”。 $todayPerformanceOverview = YejiStatsLogic::overview([ 'start_date' => $today, 'end_date' => $today, ], $adminId, $adminInfo); $appointmentRanking = self::buildRegistrationRanking( $adminId, $scope, $rankingVisibleAdminIds, $rankingDeptId, $centerRanking ); $centerPeopleIds = $centerLocked ? self::adminsInDeptIds($centerDeptIds) : null; $rankingPeopleIds = $centerPeopleIds; $rankingPeopleLabel = (string) ($centerRanking['label'] ?? $scope['label'] ?? ''); if (!$isGroupLeader && $rankingDeptId > 0) { $deptAdminIds = self::departmentAdminIds($rankingDeptId); $rankingPeopleIds = $rankingPeopleIds === null ? $deptAdminIds : array_values(array_intersect($rankingPeopleIds, $deptAdminIds)); $rankingPeopleLabel = (string) (Dept::where('id', $rankingDeptId)->value('name') ?? $rankingPeopleLabel); } if ($isGroupLeader) { $performanceRanking = self::loadPersonalPerformanceRanking( $metricAdminIds, $adminId, (string) ($scope['viewer_name'] ?? ''), $today, true ); $performanceTitle = '今日组员业绩排行'; $performanceKind = 'person'; $performanceScopeLabel = (string) ($scope['label'] ?? '本小组'); } elseif ($centerLocked) { $performanceRanking = self::loadPersonalPerformanceRanking( $rankingPeopleIds ?? [], $adminId, (string) ($scope['viewer_name'] ?? ''), $today ); $performanceTitle = $rankingPeopleLabel !== '' ? ('今日' . $rankingPeopleLabel . '业绩排行') : '今日部门业绩排行'; $performanceKind = 'person'; $performanceScopeLabel = $rankingPeopleLabel; } else { $performanceRanking = self::buildPerformanceRanking( is_array($todayPerformanceOverview['rows'] ?? null) ? $todayPerformanceOverview['rows'] : [] ); $performanceTitle = '今日部门业绩排行'; $performanceKind = 'dept'; $performanceScopeLabel = (string) ($scope['label'] ?? ''); } $trendContext = YejiStatsLogic::resolveSharedYejiFilterContext([ 'start_date' => $trendStart, 'end_date' => $today, ], $adminId, $adminInfo); $trendContext = self::narrowTrendContext($trendContext, $metricAdminIds); $trend = self::buildTrend( $trendStart, $today, $metricAdminIds, $orderDaily, $registrationDaily, $trendContext ); $todayTrendIndex = max(0, count($trend['dates'] ?? []) - 1); $yesterdayTrendIndex = max(0, $todayTrendIndex - 1); $todayAddFansCount = (int) ($trend['leads'][$todayTrendIndex] ?? 0); $yesterdayAddFansCount = (int) ($trend['leads'][$yesterdayTrendIndex] ?? 0); $todayAppointmentCount = (int) ($trend['appointments'][$todayTrendIndex] ?? 0); $yesterdayAppointmentCount = (int) ($trend['appointments'][$yesterdayTrendIndex] ?? 0); $todayInterviewCount = (int) ($todaySummary['interview_count'] ?? 0); $yesterdayInterviewCount = (int) ($yesterdaySummary['interview_count'] ?? 0); $todayOrderCount = (int) self::dailyMetric($orderDaily, $today, 'count'); $yesterdayOrderCount = (int) self::dailyMetric($orderDaily, $yesterday, 'count'); $todayOrderAmount = self::dailyMetric($orderDaily, $today, 'amount'); $yesterdayOrderAmount = self::dailyMetric($orderDaily, $yesterday, 'amount'); $todayLowAmountPaymentCount = (int) self::dailyMetric($registrationDaily, $today, 'count'); $yesterdayLowAmountPaymentCount = (int) self::dailyMetric($registrationDaily, $yesterday, 'count'); $todayPaidAppointmentCount = $todayLowAmountPaymentCount; $yesterdayPaidAppointmentCount = $yesterdayLowAmountPaymentCount; $todayPaidAppointmentRate = self::percent($todayPaidAppointmentCount, $todayAddFansCount); $yesterdayPaidAppointmentRate = self::percent($yesterdayPaidAppointmentCount, $yesterdayAddFansCount); // 接诊卡片使用的是有效业务订单,接诊率必须使用同一订单口径,不能继续读取旧的双审完成单。 $todayInterviewReceiveRate = self::percent($todayOrderCount, $todayInterviewCount); $yesterdayInterviewReceiveRate = self::percent($yesterdayOrderCount, $yesterdayInterviewCount); $target = self::buildTargetProgress( $adminId, (string) ($roleScope['kind'] ?? PerformanceDashboardScope::KIND_ASSISTANT), date('Y-m'), $monthAmount, $personalMonthAmount ); return [ 'scope' => $scope, 'performance' => [ 'month_amount' => round($monthAmount, 2), 'month_compare_rate' => self::relativeChange($monthAmount, $previousMonthAmount), 'month_compare_label' => '较上月同期', 'today_amount' => round($todayOrderAmount, 2), 'today_compare_rate' => self::relativeChange($todayOrderAmount, $yesterdayOrderAmount), 'today_compare_label' => '较昨日', 'yesterday_amount' => round($yesterdayAmount, 2), 'yesterday_compare_rate' => self::relativeChange($yesterdayAmount, $dayBeforeAmount), 'yesterday_compare_label' => '较前一日', 'personal_month_amount' => round($personalMonthAmount, 2), ], 'today' => [ 'add_fans_count' => $todayAddFansCount, 'appointment_total_count' => $todayAppointmentCount, 'low_amount_payment_count' => $todayLowAmountPaymentCount, 'interview_count' => $todayInterviewCount, // 保留原响应字段名以兼容已发布前端,数值含义已统一为“计入业绩的业务订单”。 'completed_order_count' => $todayOrderCount, 'completed_order_amount' => $todayOrderAmount, 'paid_appointment_count' => $todayPaidAppointmentCount, 'paid_appointment_rate' => $todayPaidAppointmentRate, 'interview_receive_rate' => $todayInterviewReceiveRate, 'comparisons' => [ 'add_fans_count' => self::buildComparison($todayAddFansCount, $yesterdayAddFansCount), 'appointment_total_count' => self::buildComparison( $todayAppointmentCount, $yesterdayAppointmentCount ), 'low_amount_payment_count' => self::buildComparison( $todayLowAmountPaymentCount, $yesterdayLowAmountPaymentCount ), 'interview_count' => self::buildComparison($todayInterviewCount, $yesterdayInterviewCount), 'completed_order_count' => self::buildComparison($todayOrderCount, $yesterdayOrderCount), 'completed_order_amount' => self::buildComparison($todayOrderAmount, $yesterdayOrderAmount), 'paid_appointment_rate' => self::buildComparison( $todayPaidAppointmentRate, $yesterdayPaidAppointmentRate ), 'interview_receive_rate' => self::buildComparison( $todayInterviewReceiveRate, $yesterdayInterviewReceiveRate ), ], ], 'rankings' => [ 'appointments' => $appointmentRanking, 'performance' => [ 'title' => $performanceTitle, 'kind' => $performanceKind, 'scope_label' => $performanceScopeLabel, 'items' => $performanceRanking, ], ], 'filters' => [ 'ranking_departments' => $isGroupLeader ? [] : self::rankingDepartmentOptions($adminId, $adminInfo, $centerDeptIds), 'ranking_dept_id' => $isGroupLeader ? 0 : $rankingDeptId, 'ranking_selectable' => !$isGroupLeader, ], 'trend' => $trend, 'target' => $target, 'meta' => [ 'generated_at' => date('Y-m-d H:i:s'), 'timezone' => date_default_timezone_get(), 'commission_note' => '本人业绩按当前账号创建的业务订单统计,排除已取消、拒收和退款订单。', 'rate_note' => '挂号及挂号率:按支付时间统计已支付且 0<实收金额<10 元的订单,每笔订单计 1 个挂号,并按订单创建人归属;预约按预约日期统计有效预约记录。接诊率:有效业务诊单数 / 已完成面诊数。', ], ]; } /** * @return array */ private static function buildScopeContext(int $adminId, array $adminInfo): array { $scopeValue = DataScopeService::getEffectiveScope($adminInfo); $visibleAdminIds = DataScopeService::getVisibleAdminIds($adminId, $adminInfo); $roleIds = AdminRole::where('admin_id', $adminId)->column('role_id'); $roleIds = array_values(array_unique(array_filter(array_map('intval', $roleIds), static fn (int $id): bool => $id > 0))); $roleNames = []; if ($roleIds !== []) { $roleNames = SystemRole::whereIn('id', $roleIds) ->whereNull('delete_time') ->order('sort', 'desc') ->column('name'); $roleNames = array_values(array_filter(array_map('strval', $roleNames))); } $deptIds = AdminDept::where('admin_id', $adminId)->column('dept_id'); $deptIds = array_values(array_unique(array_filter(array_map('intval', $deptIds), static fn (int $id): bool => $id > 0))); $deptNames = []; if ($deptIds !== []) { $deptNames = Dept::whereIn('id', $deptIds) ->whereNull('delete_time') ->order('id', 'asc') ->column('name'); $deptNames = array_values(array_filter(array_map('strval', $deptNames))); } $isRoot = (int) ($adminInfo['root'] ?? 0) === 1; $scopeKey = $isRoot ? 'root' : [ DataScopeService::SCOPE_ALL => 'all', DataScopeService::SCOPE_DEPT_AND_CHILD => 'dept_children', DataScopeService::SCOPE_DEPT => 'dept', DataScopeService::SCOPE_SELF => 'self', ][$scopeValue] ?? 'self'; $scopeLabel = $isRoot ? '全部数据' : DataScopeService::scopeLabel($scopeValue); if ($visibleAdminIds === null) { $visibleMemberCount = (int) Db::name('admin')->whereNull('delete_time')->count(); } else { $visibleMemberCount = count($visibleAdminIds); } return [ 'key' => $scopeKey, 'scope_value' => $scopeValue, 'label' => $scopeLabel, 'is_limited' => $visibleAdminIds !== null, 'viewer_name' => (string) ($adminInfo['name'] ?? $adminInfo['account'] ?? ''), 'role_ids' => $roleIds, 'role_names' => $roleNames, 'department_names' => $deptNames, 'visible_member_count' => $visibleMemberCount, '_visible_admin_ids' => $visibleAdminIds, ]; } private static function resolveRankingDeptId( int $requestedDeptId, int $adminId, array $adminInfo, array $centerDeptIds = [] ): int { if ($requestedDeptId <= 0) { return 0; } $exists = Dept::where('id', $requestedDeptId)->whereNull('delete_time')->count() > 0; if (!$exists) { return 0; } if ($centerDeptIds !== []) { $allowedInCenter = array_fill_keys($centerDeptIds, true); return isset($allowedInCenter[$requestedDeptId]) ? $requestedDeptId : 0; } $allowedDeptSet = DataScopeService::getAllowedDeptIdSet($adminId, $adminInfo); if ($allowedDeptSet !== null && !isset($allowedDeptSet[$requestedDeptId])) { return 0; } return $requestedDeptId; } /** * @param int[] $centerDeptIds * @return array> */ private static function rankingDepartmentOptions(int $adminId, array $adminInfo, array $centerDeptIds = []): array { if ($centerDeptIds !== []) { $tree = DeptLogic::getAllData(); if (!is_array($tree) || $tree === []) { return []; } return self::filterDeptTreeByAllowedIds($tree, array_fill_keys($centerDeptIds, true)); } return DeptLogic::getAllDataScoped($adminId, $adminInfo); } /** * 只保留允许的部门节点;不允许的祖先只用来托举子树,不会出现在可选项里。 * * @param array> $nodes * @param array $allowedIdMap * @return array> */ private static function filterDeptTreeByAllowedIds(array $nodes, array $allowedIdMap): array { $out = []; foreach ($nodes as $node) { $id = (int) ($node['id'] ?? 0); $rawChildren = $node['children'] ?? []; $children = is_array($rawChildren) && $rawChildren !== [] ? self::filterDeptTreeByAllowedIds($rawChildren, $allowedIdMap) : []; if (isset($allowedIdMap[$id])) { $row = $node; $row['children'] = $children; $out[] = $row; } else { foreach ($children as $child) { $out[] = $child; } } } return $out; } /** * @param array|null $visibleAdminIds * @return array */ private static function loadPerformanceOrderDaily(string $startDate, string $endDate, ?array $visibleAdminIds): array { if ($visibleAdminIds === []) { return []; } $startTs = (int) strtotime($startDate . ' 00:00:00'); $endTs = (int) strtotime($endDate . ' 23:59:59'); $query = Db::name('tcm_prescription_order') ->alias('po') ->whereNull('po.delete_time') ->where('po.create_time', 'between', [$startTs, $endTs]); YejiStatsLogic::applyPrescriptionOrderNotCancelledForPerformanceQuery($query, 'po'); if ($visibleAdminIds !== null) { $query->whereIn('po.creator_id', $visibleAdminIds); } $rows = $query ->fieldRaw("FROM_UNIXTIME(po.create_time, '%Y-%m-%d') AS date_label, SUM(po.amount) AS amount_sum, COUNT(*) AS order_count") ->group('date_label') ->order('date_label', 'asc') ->select() ->toArray(); $out = []; foreach ($rows as $row) { $date = (string) ($row['date_label'] ?? ''); if ($date === '') { continue; } $out[$date] = [ 'amount' => round((float) ($row['amount_sum'] ?? 0), 2), 'count' => (int) ($row['order_count'] ?? 0), ]; } return $out; } /** * 0 < 实收金额 < 10 元的已支付订单笔数;退款订单状态为 4,不会进入统计。 * * @param array|null $visibleAdminIds * @return array */ private static function loadRegistrationDaily( string $startDate, string $endDate, ?array $visibleAdminIds ): array { if ($visibleAdminIds === []) { return []; } $query = Db::name('order') ->whereNull('delete_time') ->where('status', 2) ->where('amount', '>', 0) ->where('amount', '<', 10) // payment_time 是 DATETIME NULL;MySQL 8 严格模式下不能与空字符串比较。 ->whereNotNull('payment_time') ->whereBetweenTime( 'payment_time', $startDate . ' 00:00:00', $endDate . ' 23:59:59' ); if ($visibleAdminIds !== null) { $query->whereIn('creator_id', $visibleAdminIds); } $rows = $query ->fieldRaw("DATE(payment_time) AS date_label, COUNT(*) AS item_count") ->group('date_label') ->select() ->toArray(); $out = []; foreach ($rows as $row) { $date = (string) ($row['date_label'] ?? ''); if ($date !== '') { $out[$date] = ['count' => (int) ($row['item_count'] ?? 0)]; } } return $out; } /** * @param array $daily */ private static function sumDailyMetric(array $daily, string $startDate, string $endDate, string $metric): float { $sum = 0.0; foreach ($daily as $date => $values) { if ($date < $startDate || $date > $endDate) { continue; } $sum += (float) ($values[$metric] ?? 0); } return round($sum, 2); } /** * @param array $daily */ private static function dailyMetric(array $daily, string $date, string $metric): float { return round((float) ($daily[$date][$metric] ?? 0), 2); } private static function percent(float $numerator, int $denominator): float { if ($denominator <= 0) { return 0.0; } return round(($numerator / $denominator) * 100, 2); } private static function relativeChange(float $current, float $previous): ?float { if (abs($previous) < 0.00001) { return null; } return round((($current - $previous) / $previous) * 100, 2); } /** * @return array{direction: string, rate: float|null, previous: float} */ private static function buildComparison(float $current, float $previous): array { $difference = $current - $previous; $direction = abs($difference) < 0.00001 ? 'flat' : ($difference > 0 ? 'up' : 'down'); return [ 'direction' => $direction, 'rate' => self::relativeChange($current, $previous), 'previous' => round($previous, 2), ]; } /** * 登录人若挂在一中心 / 二中心子树内,实时排行默认看该中心成员。 * 例如挂在洛阳二中心一组,默认看该组,筛选可切换到二中心其它部门,不会混入一中心。 * * @return array{dept_ids: int[], label: string, is_er: bool, own_dept_id: int} */ private static function resolveViewerCenterRankingScope(int $adminId): array { $empty = [ 'dept_ids' => [], 'label' => '', 'is_er' => false, 'own_dept_id' => 0, ]; if ($adminId <= 0) { return $empty; } $ownDeptIds = array_values(array_unique(array_filter( array_map('intval', AdminDept::where('admin_id', $adminId)->column('dept_id')), static fn (int $id): bool => $id > 0 ))); if ($ownDeptIds === []) { return $empty; } $deptById = []; $rows = Dept::whereNull('delete_time')->field(['id', 'pid', 'name'])->select()->toArray(); foreach ($rows as $row) { $id = (int) ($row['id'] ?? 0); if ($id <= 0) { continue; } $deptById[$id] = [ 'pid' => (int) ($row['pid'] ?? 0), 'name' => (string) ($row['name'] ?? ''), ]; } $centerRoots = []; foreach ($ownDeptIds as $deptId) { $rootId = self::highestCenterAncestorId($deptId, $deptById); if ($rootId > 0 && isset($deptById[$rootId])) { $centerRoots[$rootId] = $deptById[$rootId]['name']; } } if ($centerRoots === []) { return $empty; } $deptIds = []; $labels = []; $isEr = false; foreach ($centerRoots as $rootId => $name) { foreach (DeptLogic::getSelfAndDescendantIds((int) $rootId) as $id) { $id = (int) $id; if ($id > 0) { $deptIds[] = $id; } } $label = self::centerRankingLabel($name); if ($label !== '' && !in_array($label, $labels, true)) { $labels[] = $label; } if (mb_strpos($name, '二中心') !== false) { $isEr = true; } } $deptIds = array_values(array_unique($deptIds)); return [ 'dept_ids' => $deptIds, 'label' => implode('、', $labels), 'is_er' => $isEr, 'own_dept_id' => self::pickOwnDeptInCenter($ownDeptIds, $deptIds, $deptById), ]; } /** * @param int[] $ownDeptIds * @param int[] $centerDeptIds * @param array $deptById */ private static function pickOwnDeptInCenter(array $ownDeptIds, array $centerDeptIds, array $deptById): int { $centerFlip = array_fill_keys($centerDeptIds, true); $candidates = []; foreach ($ownDeptIds as $id) { if (isset($centerFlip[$id])) { $candidates[] = $id; } } if ($candidates === []) { return 0; } $best = $candidates[0]; $bestDepth = -1; foreach ($candidates as $id) { $depth = 0; $current = $id; $seen = []; while ($current > 0 && isset($deptById[$current]) && !isset($seen[$current])) { $seen[$current] = true; $depth++; $current = $deptById[$current]['pid']; } if ($depth > $bestDepth || ($depth === $bestDepth && $id < $best)) { $bestDepth = $depth; $best = $id; } } return $best; } /** * @param array $deptById */ private static function highestCenterAncestorId(int $deptId, array $deptById): int { $current = $deptId; $seen = []; $highest = 0; while ($current > 0 && isset($deptById[$current]) && !isset($seen[$current])) { $seen[$current] = true; if (self::isCenterDeptName($deptById[$current]['name'])) { $highest = $current; } $current = $deptById[$current]['pid']; } return $highest; } private static function isCenterDeptName(string $name): bool { return $name !== '' && (mb_strpos($name, '一中心') !== false || mb_strpos($name, '二中心') !== false); } private static function deptBelongsToErCenter(int $deptId): bool { if ($deptId <= 0) { return false; } $erSet = DeptLogic::getErCenterSubtreeDeptIdSet(); return isset($erSet[$deptId]); } private static function centerRankingLabel(string $name): string { if (mb_strpos($name, '二中心') !== false) { return '二中心'; } if (mb_strpos($name, '一中心') !== false) { return '一中心'; } return $name; } /** * @param array $scope * @param array|null $baseVisibleAdminIds * @param array{dept_ids: int[], label: string, is_er?: bool, own_dept_id?: int} $centerRanking * @return array */ private static function buildRegistrationRanking( int $adminId, array $scope, ?array $baseVisibleAdminIds, int $rankingDeptId, array $centerRanking ): array { $roleIds = array_map('intval', $scope['role_ids'] ?? []); $isGroupLeader = ($scope['kind'] ?? '') === PerformanceDashboardScope::KIND_GROUP_LEADER; $centerDeptIds = $centerRanking['dept_ids'] ?? []; $centerLocked = $centerDeptIds !== []; $isDoctorSelf = !$centerLocked && !$isGroupLeader && ($scope['key'] ?? '') === 'self' && in_array(1, $roleIds, true) && !in_array(2, $roleIds, true); $rankingVisibleAdminIds = $baseVisibleAdminIds; $rankingScopeLabel = (string) ($scope['label'] ?? ''); if ($isGroupLeader) { $groupIds = $scope['group_admin_ids'] ?? null; $rankingVisibleAdminIds = is_array($groupIds) ? array_values(array_filter( array_map('intval', $groupIds), static fn (int $id): bool => $id > 0 )) : []; $rankingScopeLabel = '本小组'; } elseif ($centerLocked) { $rankingVisibleAdminIds = self::adminsInDeptIds($centerDeptIds); $rankingScopeLabel = (string) ($centerRanking['label'] ?? '本中心'); } if (!$isGroupLeader && $rankingDeptId > 0) { $deptAdminIds = self::departmentAdminIds($rankingDeptId); $rankingVisibleAdminIds = $rankingVisibleAdminIds === null ? $deptAdminIds : array_values(array_intersect($rankingVisibleAdminIds, $deptAdminIds)); $rankingScopeLabel = (string) (Dept::where('id', $rankingDeptId)->value('name') ?? $rankingScopeLabel); } $isErRanking = $rankingDeptId > 0 ? self::deptBelongsToErCenter($rankingDeptId) : (bool) ($centerRanking['is_er'] ?? false); $items = $isErRanking ? self::loadAppointmentRankingItems($rankingVisibleAdminIds) : self::loadRegistrationRankingItems($rankingVisibleAdminIds); $items = self::finalizePersonRanking( $items, $rankingVisibleAdminIds, $adminId, (string) ($scope['viewer_name'] ?? ''), $isGroupLeader ); return [ 'title' => $isErRanking ? '实时预约排行' : '实时挂号排行', 'kind' => $isDoctorSelf ? 'doctor' : 'member', 'metric' => $isErRanking ? 'appointment' : 'registration', 'scope_label' => $rankingScopeLabel, 'items' => $items, ]; } /** * @param array|null $rankingVisibleAdminIds * @return array> */ private static function loadRegistrationRankingItems(?array $rankingVisibleAdminIds): array { $items = []; if ($rankingVisibleAdminIds === []) { return $items; } $query = Db::name('order') ->alias('o') ->join('admin a', 'a.id = o.creator_id AND a.delete_time IS NULL', 'INNER') ->whereNull('o.delete_time') ->where('o.status', 2) ->where('o.amount', '>', 0) ->where('o.amount', '<', 10) ->whereNotNull('o.payment_time') ->whereBetweenTime('o.payment_time', date('Y-m-d 00:00:00'), date('Y-m-d 23:59:59')); if ($rankingVisibleAdminIds !== null) { $query->whereIn('o.creator_id', $rankingVisibleAdminIds); } $rows = $query ->fieldRaw('o.creator_id AS id, a.name, COUNT(*) AS item_count, SUM(o.amount) AS amount_sum') ->group(['o.creator_id', 'a.name']) ->orderRaw('item_count DESC, amount_sum DESC, o.creator_id ASC') ->select() ->toArray(); foreach ($rows as $row) { $items[] = [ 'id' => (int) ($row['id'] ?? 0), 'name' => (string) ($row['name'] ?? ''), 'count' => (int) ($row['item_count'] ?? 0), 'amount' => round((float) ($row['amount_sum'] ?? 0), 2), ]; } return $items; } /** * 二中心实时预约排行:与驾驶舱「今日预约」同口径(预约日、状态已预约/已完成/改期)。 * 归属优先挂号医助、再诊单医助,缺失时回退医生。 * * @param array|null $rankingVisibleAdminIds * @return array> */ private static function loadAppointmentRankingItems(?array $rankingVisibleAdminIds): array { if ($rankingVisibleAdminIds === []) { return []; } $today = date('Y-m-d'); $effectiveAssistantSql = 'COALESCE(NULLIF(da.assistant_id, 0), NULLIF(dg.assistant_id, 0))'; $rows = Db::name('doctor_appointment') ->alias('da') ->leftJoin('tcm_diagnosis dg', 'da.patient_id = dg.id') ->where('da.appointment_date', $today) ->whereIn('da.status', [1, 3, 4]) ->whereRaw('(dg.id IS NULL OR dg.delete_time IS NULL)') ->field([ Db::raw("({$effectiveAssistantSql}) AS effective_assistant_id"), 'da.doctor_id', Db::raw('COUNT(*) AS item_count'), ]) ->group([$effectiveAssistantSql, 'da.doctor_id']) ->select() ->toArray(); $visibleFlip = $rankingVisibleAdminIds !== null ? array_flip($rankingVisibleAdminIds) : null; $counts = []; foreach ($rows as $row) { $assistantId = (int) ($row['effective_assistant_id'] ?? 0); $doctorId = (int) ($row['doctor_id'] ?? 0); $ownerId = $assistantId > 0 ? $assistantId : $doctorId; if ($ownerId <= 0) { continue; } if ($visibleFlip !== null && !isset($visibleFlip[$ownerId])) { continue; } $counts[$ownerId] = ($counts[$ownerId] ?? 0) + (int) ($row['item_count'] ?? 0); } if ($counts === []) { return []; } arsort($counts, SORT_NUMERIC); $ownerIds = array_keys($counts); $nameMap = []; if ($ownerIds !== []) { $nameRows = Db::name('admin') ->whereIn('id', $ownerIds) ->whereNull('delete_time') ->field(['id', 'name']) ->select() ->toArray(); foreach ($nameRows as $nameRow) { $nameMap[(int) ($nameRow['id'] ?? 0)] = (string) ($nameRow['name'] ?? ''); } } $items = []; foreach ($ownerIds as $ownerId) { $items[] = [ 'id' => (int) $ownerId, 'name' => $nameMap[(int) $ownerId] ?? '', 'count' => (int) ($counts[$ownerId] ?? 0), 'amount' => 0.0, ]; } return $items; } /** * 把范围内所有成员补进排行(没单的记 0)。组长看全组;其他人仍只留前 5 + 本人。 * * @param array> $items * @param array|null $memberIds * @return array> */ private static function finalizePersonRanking( array $items, ?array $memberIds, int $viewerId, string $viewerName, bool $showAllMembers = false ): array { if ($memberIds !== null) { $memberIds = array_values(array_unique(array_filter( array_map('intval', $memberIds), static fn (int $id): bool => $id > 0 ))); $byId = []; foreach ($items as $item) { $id = (int) ($item['id'] ?? 0); if ($id > 0) { $byId[$id] = $item; } } $missing = []; foreach ($memberIds as $id) { if (!isset($byId[$id])) { $missing[] = $id; } } $nameMap = []; $lookupIds = $missing; if ($viewerId > 0 && ($viewerName === '' || isset($byId[$viewerId]) === false)) { $lookupIds[] = $viewerId; } $lookupIds = array_values(array_unique($lookupIds)); if ($lookupIds !== []) { $nameMap = Db::name('admin') ->whereIn('id', $lookupIds) ->whereNull('delete_time') ->column('name', 'id'); } $activeFlip = []; if ($memberIds !== []) { $activeIds = Db::name('admin') ->whereIn('id', $memberIds) ->whereNull('delete_time') ->column('id'); $activeFlip = array_fill_keys(array_map('intval', $activeIds), true); } foreach ($memberIds as $id) { if (!isset($activeFlip[$id]) && $id !== $viewerId) { continue; } if (!isset($byId[$id])) { $byId[$id] = [ 'id' => $id, 'name' => (string) ($nameMap[$id] ?? ''), 'count' => 0, 'amount' => 0.0, ]; } } $items = array_values($byId); usort($items, static function (array $a, array $b): int { $byAmount = (float) ($b['amount'] ?? 0) <=> (float) ($a['amount'] ?? 0); if ($byAmount !== 0) { return $byAmount; } $byCount = (int) ($b['count'] ?? 0) <=> (int) ($a['count'] ?? 0); if ($byCount !== 0) { return $byCount; } return (int) ($a['id'] ?? 0) <=> (int) ($b['id'] ?? 0); }); } if ($showAllMembers) { foreach ($items as &$item) { $item['is_self'] = (int) ($item['id'] ?? 0) === $viewerId; if ($item['is_self'] && $viewerName !== '' && (string) ($item['name'] ?? '') === '') { $item['name'] = $viewerName; } } unset($item); if ($viewerId > 0) { $hasSelf = false; foreach ($items as $item) { if (!empty($item['is_self'])) { $hasSelf = true; break; } } if (!$hasSelf) { $items[] = [ 'id' => $viewerId, 'name' => $viewerName, 'count' => 0, 'amount' => 0.0, 'is_self' => true, ]; } } return $items; } return self::ensureViewerInRanking($items, $viewerId, $viewerName); } /** * 排行保留前 5 名,若当前登录人不在榜内则追加到底部,并用 is_self 标识。 * * @param array> $items * @return array> */ private static function ensureViewerInRanking(array $items, int $viewerId, string $viewerName, int $limit = 5): array { if ($viewerId <= 0) { return array_slice($items, 0, $limit); } $selfItem = null; foreach ($items as &$item) { $isSelf = (int) ($item['id'] ?? 0) === $viewerId; $item['is_self'] = $isSelf; if ($isSelf) { $selfItem = $item; } } unset($item); $top = array_slice($items, 0, $limit); foreach ($top as $item) { if (!empty($item['is_self'])) { return $top; } } $top[] = $selfItem ?? [ 'id' => $viewerId, 'name' => $viewerName, 'count' => 0, 'amount' => 0.0, 'is_self' => true, ]; return $top; } /** * 按个人今日业绩排行。组长会补齐组内全部成员(没单也列出)。 * * @param array|null $adminIds * @return array> */ private static function loadPersonalPerformanceRanking( ?array $adminIds, int $viewerId, string $viewerName, string $today, bool $showAllMembers = false ): array { if ($adminIds === []) { return self::finalizePersonRanking([], $adminIds, $viewerId, $viewerName, $showAllMembers); } $startTs = (int) strtotime($today . ' 00:00:00'); $endTs = (int) strtotime($today . ' 23:59:59'); $query = Db::name('tcm_prescription_order') ->alias('po') ->join('admin a', 'a.id = po.creator_id AND a.delete_time IS NULL', 'INNER') ->whereNull('po.delete_time') ->where('po.create_time', 'between', [$startTs, $endTs]); YejiStatsLogic::applyPrescriptionOrderNotCancelledForPerformanceQuery($query, 'po'); if ($adminIds !== null) { $query->whereIn('po.creator_id', $adminIds); } $rows = $query ->fieldRaw('po.creator_id AS id, a.name, COUNT(*) AS order_count, SUM(po.amount) AS amount_sum') ->group(['po.creator_id', 'a.name']) ->orderRaw('amount_sum DESC, order_count DESC, po.creator_id ASC') ->select() ->toArray(); $items = []; foreach ($rows as $row) { $items[] = [ 'id' => (int) ($row['id'] ?? 0), 'name' => (string) ($row['name'] ?? ''), 'count' => (int) ($row['order_count'] ?? 0), 'amount' => round((float) ($row['amount_sum'] ?? 0), 2), ]; } return self::finalizePersonRanking($items, $adminIds, $viewerId, $viewerName, $showAllMembers); } /** @return int[] */ private static function departmentAdminIds(int $deptId): array { return self::adminsInDeptIds(DeptLogic::getSelfAndDescendantIds($deptId)); } /** * @param int[] $deptIds * @return int[] */ private static function adminsInDeptIds(array $deptIds): array { $deptIds = array_values(array_unique(array_filter( array_map('intval', $deptIds), static fn (int $id): bool => $id > 0 ))); if ($deptIds === []) { return []; } return array_values(array_unique(array_filter( array_map('intval', AdminDept::whereIn('dept_id', $deptIds)->column('admin_id')), static fn (int $id): bool => $id > 0 ))); } /** * @param array> $rows * @return array> */ private static function buildPerformanceRanking(array $rows): array { usort($rows, static function (array $a, array $b): int { $byAmount = (float) ($b['performance_amount'] ?? 0) <=> (float) ($a['performance_amount'] ?? 0); if ($byAmount !== 0) { return $byAmount; } $byCount = (int) ($b['deal_order_count'] ?? 0) <=> (int) ($a['deal_order_count'] ?? 0); if ($byCount !== 0) { return $byCount; } return (int) ($a['dept_id'] ?? 0) <=> (int) ($b['dept_id'] ?? 0); }); $items = []; foreach (array_slice($rows, 0, 5) as $row) { $items[] = [ 'id' => (int) ($row['dept_id'] ?? 0), 'name' => (string) ($row['dept_name'] ?? '未归属中心'), 'amount' => round((float) ($row['performance_amount'] ?? 0), 2), 'count' => (int) ($row['deal_order_count'] ?? 0), ]; } return $items; } /** * @param array $trendContext * @param array|null $visibleAdminIds * @return array */ private static function narrowTrendContext(array $trendContext, ?array $visibleAdminIds): array { if ($visibleAdminIds === null) { return $trendContext; } $flip = array_flip($visibleAdminIds); $adminToPrimary = is_array($trendContext['adminToPrimary'] ?? null) ? $trendContext['adminToPrimary'] : []; $filtered = []; $extraDeptIds = []; foreach ($adminToPrimary as $adminId => $deptId) { $adminId = (int) $adminId; $deptId = (int) $deptId; if (!isset($flip[$adminId])) { continue; } $filtered[$adminId] = $deptId; if ($deptId > 0) { $extraDeptIds[$deptId] = true; } } $trendContext['adminToPrimary'] = $filtered; $tableRowDeptIds = is_array($trendContext['tableRowDeptIds'] ?? null) ? array_map('intval', $trendContext['tableRowDeptIds']) : []; foreach (array_keys($extraDeptIds) as $deptId) { $tableRowDeptIds[] = (int) $deptId; } $trendContext['tableRowDeptIds'] = array_values(array_unique(array_filter( $tableRowDeptIds, static fn (int $id): bool => $id > 0 ))); return $trendContext; } /** * @param array|null $visibleAdminIds * @param array $orderDaily * @param array $registrationDaily * @param array $trendContext * @return array */ private static function buildTrend( string $startDate, string $endDate, ?array $visibleAdminIds, array $orderDaily, array $registrationDaily, array $trendContext ): array { $adminToPrimary = is_array($trendContext['adminToPrimary'] ?? null) ? $trendContext['adminToPrimary'] : []; $tableRowDeptIds = is_array($trendContext['tableRowDeptIds'] ?? null) ? array_values(array_map('intval', $trendContext['tableRowDeptIds'])) : []; $leadDaily = self::loadLeadDaily($startDate, $endDate, $adminToPrimary, $tableRowDeptIds); $appointmentDaily = self::loadAppointmentDaily( $startDate, $endDate, $visibleAdminIds, $adminToPrimary, $tableRowDeptIds ); $dates = []; $registrations = []; $appointments = []; $leads = []; $orders = []; $cursor = strtotime($startDate); $end = strtotime($endDate); while ($cursor <= $end) { $date = date('Y-m-d', $cursor); $dates[] = date('m-d', $cursor); $registrations[] = (int) ($registrationDaily[$date]['count'] ?? 0); $appointments[] = (int) ($appointmentDaily[$date] ?? 0); $leads[] = (int) ($leadDaily[$date] ?? 0); $orders[] = (int) ($orderDaily[$date]['count'] ?? 0); $cursor = strtotime('+1 day', $cursor); } return [ 'date_range' => [$startDate, $endDate], 'dates' => $dates, 'registrations' => $registrations, 'appointments' => $appointments, 'leads' => $leads, 'orders' => $orders, ]; } /** * 与 YejiStatsLogic 的“进线”一致:只有能映射到当前业绩中心展示行的管理员事件才计入。 * * @param array $adminToPrimary * @param int[] $tableRowDeptIds * @return array */ private static function loadLeadDaily( string $startDate, string $endDate, array $adminToPrimary, array $tableRowDeptIds ): array { $rowFlip = array_flip($tableRowDeptIds); $mappedAdminIds = []; foreach ($adminToPrimary as $adminId => $deptId) { $adminId = (int) $adminId; $deptId = (int) $deptId; if ($adminId > 0 && isset($rowFlip[$deptId])) { $mappedAdminIds[] = $adminId; } } $mappedAdminIds = array_values(array_unique($mappedAdminIds)); if ($mappedAdminIds === []) { return []; } $query = Db::name('qywx_external_contact_event') ->alias('e') ->join('admin a', 'a.work_wechat_userid = e.user_id AND a.delete_time IS NULL', 'INNER') ->where('e.change_type', 'add_external_contact') ->whereIn('a.id', $mappedAdminIds) ->where('e.event_time', 'between', [ strtotime($startDate . ' 00:00:00'), strtotime($endDate . ' 23:59:59'), ]); $rows = $query ->fieldRaw("FROM_UNIXTIME(e.event_time, '%Y-%m-%d') AS date_label, COUNT(*) AS item_count") ->group('date_label') ->select() ->toArray(); $out = []; foreach ($rows as $row) { $date = (string) ($row['date_label'] ?? ''); if ($date !== '') { $out[$date] = (int) ($row['item_count'] ?? 0); } } return $out; } /** * @param array|null $visibleAdminIds * 与 YejiStatsLogic 的“预约诊单”一致:appointment_date,状态 1/3/4, * 归属优先挂号医助、再诊单医助,缺失时回退医生;受限账号只保留当前业绩中心展示行。 * * @param array|null $visibleAdminIds * @param array $adminToPrimary * @param int[] $tableRowDeptIds * @return array */ private static function loadAppointmentDaily( string $startDate, string $endDate, ?array $visibleAdminIds, array $adminToPrimary, array $tableRowDeptIds ): array { if ($visibleAdminIds === []) { return []; } $effectiveAssistantSql = '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', 'between', [$startDate, $endDate]) ->whereIn('a.status', [1, 3, 4]) ->whereRaw('(u.id IS NULL OR u.delete_time IS NULL)'); $rows = $query ->field([ 'a.appointment_date AS date_label', Db::raw("({$effectiveAssistantSql}) AS effective_assistant_id"), 'a.doctor_id', Db::raw('COUNT(*) AS appointment_count'), ]) ->group(['a.appointment_date', $effectiveAssistantSql, 'a.doctor_id']) ->select() ->toArray(); $visibleFlip = $visibleAdminIds !== null ? array_flip($visibleAdminIds) : null; $rowFlip = array_flip($tableRowDeptIds); $out = []; foreach ($rows as $row) { $date = (string) ($row['date_label'] ?? ''); if ($date === '') { continue; } $effectiveAssistantId = (int) ($row['effective_assistant_id'] ?? 0); $doctorId = (int) ($row['doctor_id'] ?? 0); if ($visibleFlip !== null) { if ($effectiveAssistantId > 0) { if (!isset($visibleFlip[$effectiveAssistantId])) { continue; } } elseif ($doctorId <= 0 || !isset($visibleFlip[$doctorId])) { continue; } } $deptId = $effectiveAssistantId > 0 ? (int) ($adminToPrimary[$effectiveAssistantId] ?? 0) : 0; if ($deptId <= 0 && $doctorId > 0) { $deptId = (int) ($adminToPrimary[$doctorId] ?? 0); } if ($visibleFlip !== null && !isset($rowFlip[$deptId])) { continue; } $out[$date] = ($out[$date] ?? 0) + (int) ($row['appointment_count'] ?? 0); } return $out; } /** * @return array */ private static function buildTargetProgress( int $adminId, string $kind, string $yearMonth, float $completedAmount, float $personalAmount ): array { $deptIds = self::targetDeptIds($adminId, $kind); $query = Db::name('dept_performance_target')->where('year_month', $yearMonth); if ($deptIds !== null) { if ($deptIds === []) { return self::emptyTarget($yearMonth, $completedAmount, $personalAmount); } $query->whereIn('dept_id', $deptIds); } $rows = $query->field('dept_id, dept_name, target_amount')->select()->toArray(); $targetAmount = 0.0; foreach ($rows as $row) { $targetAmount += (float) ($row['target_amount'] ?? 0); } $targetAmount = round($targetAmount, 2); return [ 'year_month' => $yearMonth, 'target_amount' => $targetAmount, 'completed_amount' => round($completedAmount, 2), 'completion_rate' => $targetAmount > 0 ? round($completedAmount / $targetAmount * 100, 2) : null, 'personal_amount' => round($personalAmount, 2), 'personal_contribution_rate' => $completedAmount > 0 ? round($personalAmount / $completedAmount * 100, 2) : null, 'department_count' => count($rows), ]; } /** * @return array|null null 表示全部部门。 */ private static function targetDeptIds(int $adminId, string $kind): ?array { if ($kind === PerformanceDashboardScope::KIND_ADMIN) { return null; } $ownDeptIds = AdminDept::where('admin_id', $adminId)->column('dept_id'); $ownDeptIds = array_values(array_unique(array_filter( array_map('intval', $ownDeptIds), static fn (int $id): bool => $id > 0 ))); if ($ownDeptIds === [] || $kind === PerformanceDashboardScope::KIND_ASSISTANT) { return $ownDeptIds; } $out = []; foreach ($ownDeptIds as $deptId) { foreach (DeptLogic::getSelfAndDescendantIds($deptId) as $id) { $id = (int) $id; if ($id > 0) { $out[$id] = true; } } } return array_keys($out); } /** * @return array */ private static function emptyTarget(string $yearMonth, float $completedAmount, float $personalAmount): array { return [ 'year_month' => $yearMonth, 'target_amount' => 0.0, 'completed_amount' => round($completedAmount, 2), 'completion_rate' => null, 'personal_amount' => round($personalAmount, 2), 'personal_contribution_rate' => $completedAmount > 0 ? round($personalAmount / $completedAmount * 100, 2) : null, 'department_count' => 0, ]; } }