*/ public static function overview(array $params, int $adminId, array $adminInfo): array { [$startDate, $endDate, $timeType, $timeLabel] = self::resolveTimeRange((string) ($params['time_type'] ?? 'today')); $baseVisibleAdminIds = DataScopeService::getVisibleAdminIds($adminId, $adminInfo); $allowedDeptSet = DataScopeService::getAllowedDeptIdSet($adminId, $adminInfo); $scopeValue = DataScopeService::getEffectiveScope($adminInfo); $selectedDeptId = max(0, (int) ($params['dept_id'] ?? 0)); $selectedAssistantId = max(0, (int) ($params['assistant_id'] ?? 0)); $selectedMediaChannelCode = MediaChannelService::normalizeStatsCode( trim((string) ($params['media_channel_code'] ?? '')) ); $selectedMediaChannel = $selectedMediaChannelCode !== '' ? MediaChannelService::getChannelByCode($selectedMediaChannelCode) : null; $deptSelectionValid = $selectedDeptId <= 0 || $allowedDeptSet === null || isset($allowedDeptSet[$selectedDeptId]); $selectedDeptIds = []; if ($selectedDeptId > 0 && $deptSelectionValid) { $selectedDeptIds = array_values(array_unique(array_filter(array_map( 'intval', DeptLogic::getSelfAndDescendantIds($selectedDeptId) ), static fn (int $id): bool => $id > 0))); if ($allowedDeptSet !== null) { $selectedDeptIds = array_values(array_filter( $selectedDeptIds, static fn (int $id): bool => isset($allowedDeptSet[$id]) )); } } $effectiveAdminIds = $deptSelectionValid ? $baseVisibleAdminIds : []; if ($selectedDeptId > 0 && $deptSelectionValid) { $deptAdminIds = $selectedDeptIds === [] ? [] : self::normalizeIds(AdminDept::whereIn('dept_id', $selectedDeptIds)->column('admin_id')); $effectiveAdminIds = self::intersectVisibleIds($effectiveAdminIds, $deptAdminIds); } if ($selectedAssistantId > 0) { $assistantValid = self::isActiveAssistant($selectedAssistantId) && ($effectiveAdminIds === null || in_array($selectedAssistantId, $effectiveAdminIds, true)); $effectiveAdminIds = $assistantValid ? [$selectedAssistantId] : []; } $costAllocationAdminIds = self::costAllocationAdminIds( $effectiveAdminIds, $scopeValue === DataScopeService::SCOPE_SELF || $selectedAssistantId > 0 ); $conversionParams = [ 'dimension' => 'dept', 'time_type' => 'custom', 'start_date' => $startDate, 'end_date' => $endDate, 'include_filters' => 1, 'include_members' => 0, 'exclude_cancelled_appointments' => 1, 'order_metric_mode' => 'performance', 'page_no' => 1, 'page_size' => 100, ]; if ($selectedDeptId > 0 && $deptSelectionValid) { $conversionParams['dept_id'] = $selectedDeptId; } if ($selectedMediaChannelCode !== '') { $conversionParams['media_channel_code'] = $selectedMediaChannelCode; } $conversion = ConversionLogic::overview( $conversionParams, $adminId, $adminInfo, $effectiveAdminIds, $costAllocationAdminIds ); $rows = is_array($conversion['lists'] ?? null) ? $conversion['lists'] : []; $rowAllowedDeptIds = self::visibleRowDeptIds($effectiveAdminIds); if ($rowAllowedDeptIds !== null) { $rows = self::filterDeptRows($rows, array_fill_keys($rowAllowedDeptIds, true)); } $rowDeptIdSet = []; self::collectRowDeptIds($rows, $rowDeptIdSet); $openDirect = self::loadOpenCountByDept( $startDate, $endDate, $effectiveAdminIds, array_fill_keys(array_keys($rowDeptIdSet), true), self::personalYejiMediaSources($selectedMediaChannelCode, $selectedMediaChannel) ); self::applyOpenCounts($rows, $openDirect); $summary = is_array($conversion['summary'] ?? null) ? $conversion['summary'] : []; $summary['total_open_count'] = array_sum($openDirect); $summary['total_open_rate'] = self::percent( (int) $summary['total_open_count'], (int) ($summary['add_fans_count'] ?? 0) ); $summary['open_appointment_rate'] = self::percent( (int) ($summary['paid_appointment_count'] ?? 0), (int) $summary['total_open_count'] ); $summary['open_receive_rate'] = self::percent( (int) ($summary['completed_order_count'] ?? 0), (int) $summary['total_open_count'] ); $rankingRows = self::rankingRows($rows); // 目前只维护了部门月度目标;本人范围或筛选单个员工时不能拿整个部门目标冒充个人目标。 $targetDeptIds = ($scopeValue === DataScopeService::SCOPE_SELF || $selectedAssistantId > 0) ? [] : self::resolveTargetDeptIds($allowedDeptSet, $selectedDeptIds, $selectedDeptId); $target = self::buildTargetProgress((int) date('Y'), $effectiveAdminIds, $targetDeptIds); $selectedDeptName = $selectedDeptId > 0 ? (string) (Db::name('dept')->where('id', $selectedDeptId)->whereNull('delete_time')->value('name') ?? '') : ''; $selectedAssistantName = $selectedAssistantId > 0 ? (string) (Admin::where('id', $selectedAssistantId)->whereNull('delete_time')->value('name') ?? '') : ''; $selectedMediaChannelName = $selectedMediaChannelCode !== '' ? (string) ($selectedMediaChannel['channel_name'] ?? $selectedMediaChannelCode) : ''; $conversionFilters = is_array($conversion['extend']['filters'] ?? null) ? $conversion['extend']['filters'] : []; return [ 'meta' => [ 'time_type' => $timeType, 'time_label' => $timeLabel, 'start_date' => $startDate, 'end_date' => $endDate, 'generated_at' => date('Y-m-d H:i:s'), 'scope_value' => $scopeValue, 'scope_label' => DataScopeService::scopeLabel($scopeValue), 'selected_dept_name' => $selectedDeptName, 'selected_assistant_name' => $selectedAssistantName, 'selected_media_channel_code' => $selectedMediaChannelCode, 'selected_media_channel_name' => $selectedMediaChannelName, 'open_count_source' => $selectedMediaChannelCode === '' ? '个人业绩录入' : '个人业绩录入(按渠道名称匹配)', 'appointment_rule' => '按预约日期统计,归属优先挂号医助、再回退诊单医助;仅含已预约、已完成和已过号', 'performance_rule' => '按业务订单创建时间和创建人统计,排除取消、拒收、退款及已发生退款的订单', ], 'filters' => [ 'departments' => DeptLogic::getAllDataScoped($adminId, $adminInfo), 'assistants' => self::assistantOptions($baseVisibleAdminIds, $selectedDeptIds, $selectedDeptId), 'media_channels' => is_array($conversionFilters['media_channels'] ?? null) ? $conversionFilters['media_channels'] : [], ], 'summary' => $summary, 'rankings' => [ 'orders' => self::topRows($rankingRows, 'completed_order_count'), 'amounts' => self::topRows($rankingRows, 'completed_order_amount'), ], 'rows' => $rows, 'target' => $target, ]; } /** @return array{0:string,1:string,2:string,3:string} */ private static function resolveTimeRange(string $timeType): array { $today = date('Y-m-d'); $timeType = in_array($timeType, ['today', 'yesterday', 'week', 'month', 'quarter', 'year'], true) ? $timeType : 'today'; if ($timeType === 'yesterday') { $yesterday = date('Y-m-d', strtotime('-1 day')); return [$yesterday, $yesterday, $timeType, '昨天']; } if ($timeType === 'week') { return [date('Y-m-d', strtotime('monday this week')), $today, $timeType, '本周']; } if ($timeType === 'month') { return [date('Y-m-01'), $today, $timeType, '本月']; } if ($timeType === 'quarter') { $quarterMonth = ((int) floor(((int) date('n') - 1) / 3) * 3) + 1; return [date('Y-' . str_pad((string) $quarterMonth, 2, '0', STR_PAD_LEFT) . '-01'), $today, $timeType, '本季度']; } if ($timeType === 'year') { return [date('Y-01-01'), $today, $timeType, '本年']; } return [$today, $today, 'today', '今日']; } /** @param int[]|null $visibleIds @param int[] $candidateIds @return int[]|null */ private static function intersectVisibleIds(?array $visibleIds, array $candidateIds): ?array { if ($visibleIds === null) { return $candidateIds; } return array_values(array_intersect($visibleIds, $candidateIds)); } private static function isActiveAssistant(int $adminId): bool { if ($adminId <= 0) { return false; } return Db::name('admin') ->alias('a') ->join('admin_role ar', 'ar.admin_id = a.id') ->where('a.id', $adminId) ->where('ar.role_id', self::ASSISTANT_ROLE_ID) ->where('a.disable', 0) ->whereNull('a.delete_time') ->count() > 0; } /** @param int[]|null $visibleAdminIds @return int[]|null */ private static function visibleRowDeptIds(?array $visibleAdminIds): ?array { if ($visibleAdminIds === null) { return null; } if ($visibleAdminIds === []) { return []; } return self::normalizeIds(AdminDept::whereIn('admin_id', $visibleAdminIds)->column('dept_id')); } /** * 个人指标仍只查本人;成本按本人所在部门全员的加粉占比分摊。 * * @param int[]|null $effectiveAdminIds * @return int[]|null null 表示使用默认分摊范围 */ private static function costAllocationAdminIds(?array $effectiveAdminIds, bool $personalScope): ?array { if (!$personalScope) { return null; } if ($effectiveAdminIds === []) { return []; } $deptIds = self::visibleRowDeptIds($effectiveAdminIds); if ($deptIds === null || $deptIds === []) { return $effectiveAdminIds ?? []; } $ids = self::normalizeIds(AdminDept::whereIn('dept_id', $deptIds)->column('admin_id')); return $ids !== [] ? $ids : ($effectiveAdminIds ?? []); } /** @param array> $rows @param array $allowedSet @return array> */ private static function filterDeptRows(array $rows, array $allowedSet): array { if ($allowedSet === []) { return []; } $out = []; foreach ($rows as $row) { if (!is_array($row)) { continue; } $children = self::filterDeptRows(is_array($row['children'] ?? null) ? $row['children'] : [], $allowedSet); $id = (int) ($row['id'] ?? 0); if (isset($allowedSet[$id])) { $row['children'] = $children; if ($children === []) { unset($row['children']); } $out[] = $row; continue; } foreach ($children as $child) { $out[] = $child; } } return $out; } /** @param array> $rows @param array $set */ private static function collectRowDeptIds(array $rows, array &$set): void { foreach ($rows as $row) { $id = (int) ($row['id'] ?? 0); if ($id !== 0) { $set[$id] = true; } self::collectRowDeptIds(is_array($row['children'] ?? null) ? $row['children'] : [], $set); } } /** * @param int[]|null $effectiveAdminIds * @param array $rowDeptSet * @param string[]|null $mediaSources null=全部渠道;空数组=所选渠道没有可匹配的手工来源 * @return array */ private static function loadOpenCountByDept( string $startDate, string $endDate, ?array $effectiveAdminIds, array $rowDeptSet, ?array $mediaSources = null ): array { if ($effectiveAdminIds === [] || $rowDeptSet === [] || $mediaSources === []) { return []; } $query = PersonalYeji::whereBetween('yeji_date', [$startDate, $endDate]); if ($effectiveAdminIds !== null) { $query->whereIn('creator_id', $effectiveAdminIds); } if ($mediaSources !== null) { $query->whereIn('media_source', $mediaSources); } $rows = $query ->fieldRaw('creator_id, SUM(total_open_count) AS open_count') ->group('creator_id') ->select() ->toArray(); if ($rows === []) { return []; } $creatorIds = self::normalizeIds(array_column($rows, 'creator_id')); $deptRows = $creatorIds === [] ? [] : AdminDept::whereIn('admin_id', $creatorIds) ->field('admin_id, dept_id') ->order('admin_id', 'asc') ->order('dept_id', 'asc') ->select() ->toArray(); $adminDeptMap = []; foreach ($deptRows as $deptRow) { $adminDeptMap[(int) $deptRow['admin_id']][] = (int) $deptRow['dept_id']; } $deptMetaRows = Db::name('dept') ->whereNull('delete_time') ->field('id, pid, sort') ->select() ->toArray(); $deptMeta = []; foreach ($deptMetaRows as $deptMetaRow) { $deptId = (int) ($deptMetaRow['id'] ?? 0); if ($deptId > 0) { $deptMeta[$deptId] = [ 'pid' => (int) ($deptMetaRow['pid'] ?? 0), 'sort' => (int) ($deptMetaRow['sort'] ?? 0), ]; } } $depthCache = []; $depthOf = static function (int $deptId) use (&$depthOf, &$depthCache, $deptMeta): int { if ($deptId <= 0 || !isset($deptMeta[$deptId])) { return 0; } if (isset($depthCache[$deptId])) { return $depthCache[$deptId]; } $parentId = (int) ($deptMeta[$deptId]['pid'] ?? 0); if ($parentId <= 0 || $parentId === $deptId || !isset($deptMeta[$parentId])) { return $depthCache[$deptId] = 0; } return $depthCache[$deptId] = $depthOf($parentId) + 1; }; foreach ($adminDeptMap as &$deptIds) { usort($deptIds, static function (int $left, int $right) use ($depthOf, $deptMeta): int { $depthCompare = $depthOf($right) <=> $depthOf($left); if ($depthCompare !== 0) { return $depthCompare; } $sortCompare = (int) ($deptMeta[$right]['sort'] ?? 0) <=> (int) ($deptMeta[$left]['sort'] ?? 0); if ($sortCompare !== 0) { return $sortCompare; } return $left <=> $right; }); } unset($deptIds); $direct = []; foreach ($rows as $row) { $adminId = (int) ($row['creator_id'] ?? 0); $targetDeptId = 0; foreach ($adminDeptMap[$adminId] ?? [] as $deptId) { if (isset($rowDeptSet[$deptId])) { $targetDeptId = $deptId; break; } } if ($targetDeptId === 0 && isset($rowDeptSet[-2])) { $targetDeptId = -2; } if ($targetDeptId !== 0) { $direct[$targetDeptId] = ($direct[$targetDeptId] ?? 0) + (int) ($row['open_count'] ?? 0); } } return $direct; } /** @param array> $rows @param array $direct */ private static function applyOpenCounts(array &$rows, array $direct): int { $sum = 0; foreach ($rows as &$row) { $children = is_array($row['children'] ?? null) ? $row['children'] : []; $childTotal = self::applyOpenCounts($children, $direct); if ($children !== []) { $row['children'] = $children; } $count = (int) ($direct[(int) ($row['id'] ?? 0)] ?? 0) + $childTotal; $row['total_open_count'] = $count; $row['total_open_rate'] = self::percent($count, (int) ($row['add_fans_count'] ?? 0)); $row['open_appointment_rate'] = self::percent( (int) ($row['paid_appointment_count'] ?? 0), $count ); $row['open_receive_rate'] = self::percent((int) ($row['completed_order_count'] ?? 0), $count); $sum += (int) ($direct[(int) ($row['id'] ?? 0)] ?? 0) + $childTotal; } unset($row); return $sum; } /** * 手工开口按 personal_yeji.media_source 保存;渠道筛选时仅匹配该渠道自身的稳定标识和名称。 * 不使用 source_group_name,避免同组多个渠道的开口数被重复计入每个渠道。 * * @param array|null $channel * @return string[]|null */ private static function personalYejiMediaSources(string $channelCode, ?array $channel): ?array { if ($channelCode === '') { return null; } if ($channel === null) { return []; } return array_values(array_unique(array_filter(array_map( static fn ($value): string => trim((string) $value), [ $channelCode, $channel['channel_name'] ?? '', $channel['source_tag_name'] ?? '', ] ), static fn (string $value): bool => $value !== ''))); } /** @param array> $rows @return array> */ private static function rankingRows(array $rows): array { // lists 里可能同时存在“未绑定/未分配部门”等虚拟根节点。它们会让顶层节点数量 // 大于 1,导致原逻辑无法展开唯一的真实组织根节点,图表最终只显示医院汇总行。 $visibleRows = array_values(array_filter($rows, static function (array $row): bool { return (int) ($row['id'] ?? 0) > 0 && !((bool) ($row['_virtual_bucket'] ?? false)); })); // 每个可见顶层分支只展示同一层级:有权限看到下级时展示直属子部门;没有可见 // 下级时保留当前部门。这样既能按角色/DataScope 展示子部门,也不会把父子汇总 // 同时放进占比图造成重复计算。 $chartRows = []; foreach ($visibleRows as $row) { $children = array_values(array_filter( is_array($row['children'] ?? null) ? $row['children'] : [], static fn (array $child): bool => (int) ($child['id'] ?? 0) > 0 && !((bool) ($child['_virtual_bucket'] ?? false)) )); if ($children !== []) { foreach ($children as $child) { $chartRows[] = $child; } continue; } $chartRows[] = $row; } return $chartRows; } /** @param array> $rows @return array> */ private static function topRows(array $rows, string $metric): array { $rows = array_values(array_filter($rows, static fn (array $row): bool => (int) ($row['id'] ?? 0) > 0)); usort($rows, static function (array $left, array $right) use ($metric): int { return (float) ($right[$metric] ?? 0) <=> (float) ($left[$metric] ?? 0); }); return array_map(static fn (array $row): array => [ 'id' => (int) ($row['id'] ?? 0), 'name' => (string) ($row['name'] ?? ''), 'value' => round((float) ($row[$metric] ?? 0), 2), ], array_slice($rows, 0, 6)); } /** @param int[]|null $baseVisibleAdminIds @param int[] $selectedDeptIds @return array */ private static function assistantOptions(?array $baseVisibleAdminIds, array $selectedDeptIds, int $selectedDeptId): array { $query = Db::name('admin') ->alias('a') ->join('admin_role ar', 'ar.admin_id = a.id') ->where('ar.role_id', self::ASSISTANT_ROLE_ID) ->where('a.disable', 0) ->whereNull('a.delete_time'); if ($baseVisibleAdminIds !== null) { if ($baseVisibleAdminIds === []) { return []; } $query->whereIn('a.id', $baseVisibleAdminIds); } if ($selectedDeptId > 0) { if ($selectedDeptIds === []) { return []; } $query->join('admin_dept ad', 'ad.admin_id = a.id')->whereIn('ad.dept_id', $selectedDeptIds); } return $query->field('a.id, a.name')->distinct(true)->order('a.name', 'asc')->select()->toArray(); } /** @param array|null $allowedDeptSet @param int[] $selectedDeptIds @return int[]|null */ private static function resolveTargetDeptIds(?array $allowedDeptSet, array $selectedDeptIds, int $selectedDeptId): ?array { if ($selectedDeptId > 0) { return $selectedDeptIds; } if ($allowedDeptSet === null) { return null; } return array_map('intval', array_keys($allowedDeptSet)); } /** @param int[]|null $effectiveAdminIds @param int[]|null $targetDeptIds @return array */ private static function buildTargetProgress(int $year, ?array $effectiveAdminIds, ?array $targetDeptIds): array { $targetQuery = Db::name('dept_performance_target')->whereLike('year_month', $year . '-%'); if ($targetDeptIds !== null) { if ($targetDeptIds === []) { $targetRows = []; } else { $targetRows = $targetQuery->whereIn('dept_id', $targetDeptIds) ->fieldRaw('`year_month`, SUM(`target_amount`) AS target_amount, COUNT(DISTINCT `dept_id`) AS dept_count') ->group('`year_month`') ->select() ->toArray(); } } else { $targetRows = $targetQuery ->fieldRaw('`year_month`, SUM(`target_amount`) AS target_amount, COUNT(DISTINCT `dept_id`) AS dept_count') ->group('`year_month`') ->select() ->toArray(); } $actualRows = []; if ($effectiveAdminIds !== []) { $actualQuery = Db::name('tcm_prescription_order') ->alias('po') ->whereNull('po.delete_time') ->where('po.create_time', 'between', [ strtotime($year . '-01-01 00:00:00'), strtotime($year . '-12-31 23:59:59'), ]); YejiStatsLogic::applyPrescriptionOrderEffectiveAmountQuery($actualQuery, 'po'); if ($effectiveAdminIds !== null) { $actualQuery->whereIn('po.creator_id', $effectiveAdminIds); } $actualRows = $actualQuery ->fieldRaw("DATE_FORMAT(FROM_UNIXTIME(po.create_time), '%m') AS month_no, SUM(po.amount) AS actual_amount") ->group('month_no') ->select() ->toArray(); } $targets = array_fill(1, 12, 0.0); $actuals = array_fill(1, 12, 0.0); $deptCountSet = []; foreach ($targetRows as $row) { $month = (int) substr((string) ($row['year_month'] ?? ''), 5, 2); if ($month >= 1 && $month <= 12) { $targets[$month] = round((float) ($row['target_amount'] ?? 0), 2); $deptCountSet[$month] = (int) ($row['dept_count'] ?? 0); } } foreach ($actualRows as $row) { $month = (int) ($row['month_no'] ?? 0); if ($month >= 1 && $month <= 12) { $actuals[$month] = round((float) ($row['actual_amount'] ?? 0), 2); } } $targetCumulative = []; $actualCumulative = []; $targetRunning = 0.0; $actualRunning = 0.0; for ($month = 1; $month <= 12; $month++) { $targetRunning = round($targetRunning + $targets[$month], 2); $actualRunning = round($actualRunning + $actuals[$month], 2); $targetCumulative[] = $targetRunning; $actualCumulative[] = $actualRunning; } $currentMonth = (int) date('n'); return [ 'year' => $year, 'target_amount' => $targetRunning, 'actual_amount' => $actualRunning, 'completion_rate' => $targetRunning > 0 ? round($actualRunning / $targetRunning * 100, 2) : null, 'current_month_target' => $targets[$currentMonth], 'current_month_actual' => $actuals[$currentMonth], 'current_month_rate' => $targets[$currentMonth] > 0 ? round($actuals[$currentMonth] / $targets[$currentMonth] * 100, 2) : null, 'department_count' => max($deptCountSet ?: [0]), 'months' => array_map(static fn (int $month): string => str_pad((string) $month, 2, '0', STR_PAD_LEFT) . '月', range(1, 12)), 'target_cumulative' => $targetCumulative, 'actual_cumulative' => $actualCumulative, ]; } /** @param array $ids @return int[] */ private static function normalizeIds(array $ids): array { return array_values(array_unique(array_filter(array_map('intval', $ids), static fn (int $id): bool => $id > 0))); } private static function percent(int $numerator, int $denominator): float { return $denominator > 0 ? round($numerator / $denominator * 100, 2) : 0.0; } }