*/ public static function overview(array $params, int $adminId, array $adminInfo): array { $context = self::resolveScopeContext($params, $adminId, $adminInfo); $startDate = $context['start_date']; $endDate = $context['end_date']; $timeType = $context['time_type']; $timeLabel = $context['time_label']; $allowedDeptSet = $context['allowed_dept_set']; $baseVisibleAdminIds = $context['base_visible_admin_ids']; $scopeValue = $context['scope_value']; $selectedDeptId = $context['selected_dept_id']; $selectedAssistantId = $context['selected_assistant_id']; $selectedMediaChannel = $context['selected_media_channel']; $selectedMediaChannelCode = $context['selected_media_channel_code']; $deptSelectionValid = $context['dept_selection_valid']; $selectedDeptIds = $context['selected_dept_ids']; $effectiveAdminIds = $context['effective_admin_ids']; $selectedAssistantValid = $context['selected_assistant_valid']; $costAllocationAdminIds = self::costAllocationAdminIds( $effectiveAdminIds, $scopeValue === DataScopeService::SCOPE_SELF || $selectedAssistantId > 0 ); $conversionParams = [ 'dimension' => 'dept', 'time_type' => 'custom', 'start_date' => $startDate, 'end_date' => $endDate, // 一诊筛选项在本层按自身权限和“当前企微标签”口径生成,不再让通用 // Conversion 额外加载一套包含历史渠道的筛选器。 'include_filters' => 0, 'include_members' => 1, '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, $selectedMediaChannel ); $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); $openCounts = self::loadOpenCounts( $startDate, $endDate, $effectiveAdminIds, array_fill_keys(array_keys($rowDeptIdSet), true), self::personalYejiMediaSources($selectedMediaChannelCode, $selectedMediaChannel) ); $openDirect = $openCounts['dept']; self::applyOpenCounts($rows, $openDirect, $openCounts['admin']); $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'] ); $rankingKind = self::rankingKind($scopeValue, $selectedAssistantId); $rankingRows = self::rankingRows($rows, $rankingKind); // 目前只维护了部门月度目标;本人范围或筛选单个员工时不能拿整个部门目标冒充个人目标。 $targetDeptIds = ($scopeValue === DataScopeService::SCOPE_SELF || $selectedAssistantId > 0) ? [] : self::resolveTargetDeptIds($allowedDeptSet, $selectedDeptIds, $selectedDeptId); $target = self::buildTargetProgress((int) date('Y'), $effectiveAdminIds, $targetDeptIds); $selectedDeptName = $selectedDeptId > 0 && $deptSelectionValid ? (string) (Db::name('dept')->where('id', $selectedDeptId)->whereNull('delete_time')->value('name') ?? '') : ''; $selectedAssistantName = $selectedAssistantId > 0 && $selectedAssistantValid ? (string) (Admin::where('id', $selectedAssistantId)->whereNull('delete_time')->value('name') ?? '') : ''; $selectedMediaChannelName = $selectedMediaChannelCode !== '' ? (string) ($selectedMediaChannel['channel_name'] ?? $selectedMediaChannelCode) : ''; if ($selectedMediaChannelName !== '' && !empty($selectedMediaChannel['is_group'])) { $selectedMediaChannelName .= '(全部)'; } $canViewFinance = self::canViewFinance($adminId, $adminInfo); if (!$canViewFinance) { $summary = self::maskFinanceFields($summary); foreach ($rows as &$row) { if (is_array($row)) { $row = self::maskFinanceFields($row); } } unset($row); } return self::withDeletedFansVisibility([ '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), 'ranking_kind' => $rankingKind, 'selected_dept_name' => $selectedDeptName, 'selected_assistant_name' => $selectedAssistantName, 'selected_media_channel_code' => $selectedMediaChannelCode, 'selected_media_channel_name' => $selectedMediaChannelName, 'open_count_source' => $selectedMediaChannelCode === '' ? '个人业绩录入' : '个人业绩录入(按渠道名称匹配)', 'can_view_finance' => $canViewFinance, 'appointment_rule' => '按预约日期统计,归属优先挂号医助、再回退诊单医助;仅含已预约、已完成和已过号', 'registration_rule' => '按支付时间统计已支付且实收金额大于 0、低于 10 元的订单,每笔计 1 个挂号并按订单创建人归属', 'performance_rule' => '按业务订单创建时间和创建人统计,排除取消、拒收、退款及已发生退款的订单', ], 'filters' => [ 'departments' => DeptLogic::getAllDataScoped($adminId, $adminInfo), 'assistants' => self::assistantOptions($baseVisibleAdminIds, $selectedDeptIds, $selectedDeptId), 'media_channels' => MediaChannelService::getCurrentTagOptions(), ], 'summary' => $summary, 'rankings' => [ 'orders' => self::topRows($rankingRows, 'completed_order_count'), 'amounts' => self::topRows($rankingRows, 'completed_order_amount'), ], 'rows' => $rows, 'target' => $target, ], $adminInfo); } /** @return array */ public static function fansDetail(array $params, int $adminId, array $adminInfo): array { $context = self::resolveScopeContext($params, $adminId, $adminInfo); $context['channel_dept_ids'] = ConversionLogic::fanDetailChannelDeptIds( $context['selected_media_channel'] ); $pageNo = max(1, (int) ($params['page_no'] ?? 1)); $pageSize = max(1, min(100, (int) ($params['page_size'] ?? 20))); $empty = self::withDeletedFansVisibility([ 'lists' => [], 'count' => 0, 'page_no' => $pageNo, 'page_size' => $pageSize, 'date_range' => [$context['start_date'], $context['end_date']], 'entity' => null, ], $adminInfo, true); $entityType = strtolower(trim((string) ($params['entity_type'] ?? ''))); if (!in_array($entityType, ['dept', 'member'], true)) { return $empty; } $entityId = trim((string) ($params['entity_id'] ?? '')); $requestedAdminId = max(0, (int) ($params['admin_id'] ?? 0)); $resolved = self::resolveFanDetailEntity( $entityType, $entityId, $requestedAdminId, $context ); if ($resolved === null) { return $empty; } $target = $resolved['target']; $conversionParams = [ 'dimension' => 'dept', 'time_type' => 'custom', 'start_date' => $context['start_date'], 'end_date' => $context['end_date'], 'page_no' => $pageNo, 'page_size' => $pageSize, ]; if ((int) $context['selected_dept_id'] > 0 && !empty($context['dept_selection_valid'])) { $conversionParams['dept_id'] = (int) $context['selected_dept_id']; } if ((string) $context['selected_media_channel_code'] !== '') { $conversionParams['media_channel_code'] = (string) $context['selected_media_channel_code']; } $result = ConversionLogic::fanDetails( $conversionParams, $target, $adminId, $adminInfo, $context['effective_admin_ids'], $context['selected_media_channel'] ); if ((int) ($result['count'] ?? 0) <= 0) { return $empty; } $result['entity'] = [ 'type' => $entityType, 'id' => $resolved['id'], 'admin_id' => (int) $resolved['admin_id'], 'name' => (string) $resolved['name'], 'add_fans_count' => (int) $result['count'], 'deleted_fans_count' => (int) ($result['deleted_count'] ?? 0), ]; unset($result['deleted_count']); return self::withDeletedFansVisibility($result, $adminInfo, true); } /** * 删除客户统计是账号专属能力,与root、角色、财务权限和DataScope无关。 * adminInfo由认证token缓存提供;缺失账号时拒绝,不能从HTTP参数补齐或标准化账号。 */ private static function canViewDeletedFans(array $adminInfo): bool { return ($adminInfo['account'] ?? null) === 'admin'; } /** * 只裁剪本页响应,不改变通用统计口径、加粉客户集合、排序或分页。 * 对整个响应递归处理,避免嵌套成员、排名或未来新增位置泄露同一敏感指标。 */ private static function withDeletedFansVisibility(array $result, array $adminInfo, bool $detail = false): array { $canView = self::canViewDeletedFans($adminInfo); if (!$canView) { $fields = $detail ? ['deleted_fans_count', 'deleted_count', 'is_deleted', 'delete_time'] : ['deleted_fans_count', 'deleted_count']; $result = self::removeDeletedFansFields($result, $fields); } if ($detail) { $result['can_view_deleted_fans'] = $canView; } else { $result['meta']['can_view_deleted_fans'] = $canView; } return $result; } /** @param string[] $fields */ private static function removeDeletedFansFields(array $value, array $fields): array { foreach ($fields as $field) { unset($value[$field]); } foreach ($value as &$item) { if (is_array($item)) { $item = self::removeDeletedFansFields($item, $fields); } } unset($item); return $value; } /** * Resolve only the clicked entity and its authorized target range. This is * deliberately structural: it avoids recomputing all overview metrics, * while still rejecting ids that do not exist in the current DataScope. * * @param array $context * @return array{id:string|int,admin_id:int,name:string,target:array}|null */ private static function resolveFanDetailEntity( string $entityType, string $entityId, int $requestedAdminId, array $context ): ?array { $visibleAdminIds = $context['effective_admin_ids']; $selectedDeptId = (int) $context['selected_dept_id']; $channelDeptIds = $context['channel_dept_ids'] ?? null; if ($entityType === 'member' && str_starts_with($entityId, 'U_')) { $wecomUserId = trim(substr($entityId, 2)); if ($wecomUserId === '' || $requestedAdminId > 0 || $visibleAdminIds !== null || $selectedDeptId > 0) { return null; } $isBound = Db::name('admin') ->whereNull('delete_time') ->where('work_wechat_userid', $wecomUserId) ->count() > 0; if ($isBound) { return null; } $historicalName = trim((string) (Db::name('admin') ->where('work_wechat_userid', $wecomUserId) ->order('id', 'desc') ->value('name') ?? '')); return [ 'id' => $entityId, 'admin_id' => 0, 'name' => $historicalName !== '' ? $historicalName . '(' . $wecomUserId . ')' : $wecomUserId, 'target' => ['type' => 'wecom_user', 'wecom_userid' => $wecomUserId], ]; } if ($entityType === 'member') { if (!preg_match('/^M([1-9]\d*)_(-?\d+)$/', $entityId, $matches)) { return null; } $targetAdminId = (int) $matches[1]; $rowDeptId = (int) $matches[2]; if (($requestedAdminId > 0 && $requestedAdminId !== $targetAdminId) || ($visibleAdminIds !== null && !in_array($targetAdminId, $visibleAdminIds, true)) ) { return null; } $admin = Db::name('admin') ->where('id', $targetAdminId) ->whereNull('delete_time') ->where('work_wechat_userid', '<>', '') ->field('id,name') ->find(); $resolvedDeptId = self::resolveFanDetailMemberDeptId($targetAdminId, $context); if (!$admin || $resolvedDeptId !== $rowDeptId || ($resolvedDeptId === -2 && $selectedDeptId > 0) ) { return null; } return [ 'id' => $entityId, 'admin_id' => $targetAdminId, 'name' => trim((string) ($admin['name'] ?? '')), 'target' => ['type' => 'member', 'admin_id' => $targetAdminId], ]; } if ($entityType !== 'dept' || !preg_match('/^-?\d+$/', $entityId)) { return null; } $deptId = (int) $entityId; if (in_array($deptId, [-1, -2], true)) { if ($selectedDeptId > 0 || $visibleAdminIds !== null) { return null; } $name = $deptId === -1 ? '未绑定后台账号' : '未分配部门'; return [ 'id' => $deptId, 'admin_id' => 0, 'name' => $name, 'target' => ['type' => 'dept', 'dept_ids' => [$deptId]], ]; } if ($deptId <= 0) { return null; } $dept = Db::name('dept')->where('id', $deptId)->whereNull('delete_time')->field('id,name')->find(); if (!$dept) { return null; } $universeDeptIds = $selectedDeptId > 0 ? self::normalizeIds(DeptLogic::getSelfAndDescendantIds($selectedDeptId)) : self::normalizeIds(Db::name('dept')->whereNull('delete_time')->column('id')); if ($channelDeptIds !== null) { $universeDeptIds = array_values(array_intersect($universeDeptIds, $channelDeptIds)); } $rowDeptIds = self::visibleRowDeptIds($visibleAdminIds); if (!in_array($deptId, $universeDeptIds, true) || ($rowDeptIds !== null && !in_array($deptId, $rowDeptIds, true)) ) { return null; } $targetDeptIds = array_values(array_intersect( self::normalizeIds(DeptLogic::getSelfAndDescendantIds($deptId)), $universeDeptIds, $rowDeptIds ?? $universeDeptIds )); if ($targetDeptIds === []) { return null; } return [ 'id' => $deptId, 'admin_id' => 0, 'name' => trim((string) ($dept['name'] ?? '')), 'target' => ['type' => 'dept', 'dept_ids' => $targetDeptIds], ]; } /** @param array $context */ private static function resolveFanDetailMemberDeptId(int $adminId, array $context): int { // Other roles are rendered by ConversionLogic in the unassigned bucket. $hasMedicalRole = Db::name('admin_role') ->where('admin_id', $adminId) ->whereIn('role_id', [1, self::ASSISTANT_ROLE_ID]) ->count() > 0; if (!$hasMedicalRole) { return -2; } $selectedDeptId = (int) $context['selected_dept_id']; $validDeptIds = $selectedDeptId > 0 ? self::normalizeIds(DeptLogic::getSelfAndDescendantIds($selectedDeptId)) : self::normalizeIds(Db::name('dept')->whereNull('delete_time')->column('id')); if (($context['channel_dept_ids'] ?? null) !== null) { $validDeptIds = array_values(array_intersect($validDeptIds, $context['channel_dept_ids'])); } $validDeptSet = array_fill_keys($validDeptIds, true); $deptRows = Db::name('dept')->whereNull('delete_time')->field('id,pid,sort')->select()->toArray(); $deptMeta = []; foreach ($deptRows as $deptRow) { $id = (int) ($deptRow['id'] ?? 0); if ($id > 0) { $deptMeta[$id] = ['pid' => (int) ($deptRow['pid'] ?? 0), 'sort' => (int) ($deptRow['sort'] ?? 0)]; } } $depthCache = []; $depthOf = static function (int $id) use (&$depthOf, &$depthCache, $deptMeta): int { if ($id <= 0 || !isset($deptMeta[$id])) { return 0; } if (isset($depthCache[$id])) { return $depthCache[$id]; } $pid = (int) ($deptMeta[$id]['pid'] ?? 0); if ($pid <= 0 || $pid === $id || !isset($deptMeta[$pid])) { return $depthCache[$id] = 0; } return $depthCache[$id] = $depthOf($pid) + 1; }; $adminDeptIds = self::normalizeIds(AdminDept::where('admin_id', $adminId)->column('dept_id')); usort($adminDeptIds, static function (int $left, int $right) use ($depthOf, $deptMeta): int { return ($depthOf($right) <=> $depthOf($left)) ?: ((int) ($deptMeta[$right]['sort'] ?? 0) <=> (int) ($deptMeta[$left]['sort'] ?? 0)) ?: ($left <=> $right); }); foreach ($adminDeptIds as $adminDeptId) { if (isset($validDeptSet[$adminDeptId])) { return $adminDeptId; } } return -2; } /** @return array */ private static function resolveScopeContext(array $params, int $adminId, array $adminInfo): array { [$startDate, $endDate, $timeType, $timeLabel] = self::resolveTimeRange($params); $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)); $requestedMediaChannelCode = trim((string) ($params['media_channel_code'] ?? '')); $selectedMediaChannel = $requestedMediaChannelCode !== '' ? MediaChannelService::getCurrentTagChannelByCode($requestedMediaChannelCode) : null; $selectedMediaChannelCode = $selectedMediaChannel !== null ? $requestedMediaChannelCode : ''; $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); } $selectedAssistantValid = $selectedAssistantId <= 0; if ($selectedAssistantId > 0) { $selectedAssistantValid = self::isActiveAssistant($selectedAssistantId) && ($effectiveAdminIds === null || in_array($selectedAssistantId, $effectiveAdminIds, true)); $effectiveAdminIds = $selectedAssistantValid ? [$selectedAssistantId] : []; } return [ 'start_date' => $startDate, 'end_date' => $endDate, 'time_type' => $timeType, 'time_label' => $timeLabel, 'base_visible_admin_ids' => $baseVisibleAdminIds, 'allowed_dept_set' => $allowedDeptSet, 'scope_value' => $scopeValue, 'selected_dept_id' => $selectedDeptId, 'selected_assistant_id' => $selectedAssistantId, 'selected_media_channel' => $selectedMediaChannel, 'selected_media_channel_code' => $selectedMediaChannelCode, 'dept_selection_valid' => $deptSelectionValid, 'selected_dept_ids' => $selectedDeptIds, 'effective_admin_ids' => $effectiveAdminIds, 'selected_assistant_valid' => $selectedAssistantValid, ]; } /** @return array{0:string,1:string,2:string,3:string} */ private static function resolveTimeRange(array $params): array { $today = date('Y-m-d'); $timeType = (string) ($params['time_type'] ?? 'today'); if (!in_array($timeType, ['today', 'yesterday', 'week', 'month', 'quarter', 'year', 'custom'], true)) { $timeType = 'today'; } if ($timeType === '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]; } return [$startDate, $endDate, 'custom', $startDate . ' 至 ' . $endDate]; } 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; } if (in_array((string) ($row['type'] ?? ''), ['member', 'unbound'], true)) { $out[] = $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{dept:array,admin:array} */ private static function loadOpenCounts( string $startDate, string $endDate, ?array $effectiveAdminIds, array $rowDeptSet, ?array $mediaSources = null ): array { if ($effectiveAdminIds === [] || $rowDeptSet === [] || $mediaSources === []) { return ['dept' => [], 'admin' => []]; } $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 ['dept' => [], 'admin' => []]; } $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 = []; $adminDirect = []; 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) { $openCount = (int) ($row['open_count'] ?? 0); $direct[$targetDeptId] = ($direct[$targetDeptId] ?? 0) + $openCount; $adminDirect[$adminId] = ($adminDirect[$adminId] ?? 0) + $openCount; } } return ['dept' => $direct, 'admin' => $adminDirect]; } /** * @param array> $rows * @param array $deptDirect * @param array $adminDirect */ private static function applyOpenCounts(array &$rows, array $deptDirect, array $adminDirect): int { $sum = 0; foreach ($rows as &$row) { $rowType = (string) ($row['type'] ?? ''); if (in_array($rowType, ['member', 'unbound'], true)) { $count = $rowType === 'member' ? (int) ($adminDirect[(int) ($row['admin_id'] ?? 0)] ?? 0) : 0; $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 ); continue; } $children = is_array($row['children'] ?? null) ? $row['children'] : []; $childTotal = self::applyOpenCounts($children, $deptDirect, $adminDirect); if ($children !== []) { $row['children'] = $children; } $directCount = (int) ($deptDirect[(int) ($row['id'] ?? 0)] ?? 0); $count = $directCount + $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 += $directCount + $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 []; } $values = [ $channelCode, $channel['channel_name'] ?? '', $channel['source_tag_name'] ?? '', $channel['legacy_channel_name'] ?? '', $channel['legacy_source_tag_name'] ?? '', ]; foreach (['channel_codes', 'channel_names', 'source_tag_names'] as $listKey) { if (!isset($channel[$listKey]) || !is_array($channel[$listKey])) { continue; } foreach ($channel[$listKey] as $item) { $values[] = $item; } } return array_values(array_unique(array_filter(array_map( static fn ($value): string => trim((string) $value), $values ), static fn (string $value): bool => $value !== '' && !str_starts_with($value, MediaChannelService::GROUP_CODE_PREFIX)))); } private static function canViewFinance(int $adminId, array $adminInfo): bool { if ((int) ($adminInfo['root'] ?? 0) === 1) { return true; } foreach (self::roleNamesFromAdminInfo($adminInfo) as $roleName) { if (in_array($roleName, self::FINANCE_ALWAYS_ROLE_NAMES, true)) { return true; } } if ($adminId <= 0) { return false; } return in_array(self::FINANCE_PERMISSION, AuthLogic::getAuthByAdminId($adminId), true); } /** @return string[] */ private static function roleNamesFromAdminInfo(array $adminInfo): array { $names = preg_split('/[\/,,、]/u', (string) ($adminInfo['role_name'] ?? '')) ?: []; return array_values(array_filter(array_map('trim', $names), static fn (string $name): bool => $name !== '')); } /** * @param array $entity * @return array */ private static function maskFinanceFields(array $entity): array { foreach (self::FINANCE_FIELD_KEYS as $key) { unset($entity[$key]); } if (isset($entity['children']) && is_array($entity['children'])) { foreach ($entity['children'] as &$child) { if (is_array($child)) { $child = self::maskFinanceFields($child); } } unset($child); } return $entity; } /** 根据生效数据范围返回排行榜展示维度,不能把 scope_value 当作角色枚举。 */ private static function rankingKind(int $scopeValue, int $selectedAssistantId = 0): string { if ($scopeValue === DataScopeService::SCOPE_SELF || $selectedAssistantId > 0) { return 'hidden'; } return $scopeValue === DataScopeService::SCOPE_DEPT ? 'member' : 'group'; } /** @param array> $rows @return array> */ private static function rankingRows(array $rows, string $rankingKind): array { if ($rankingKind === 'hidden') { return []; } // “仅本部门”范围使用可见成员维度;更大范围使用当前可见组织根节点的 // 直属下级,避免父子汇总同时参与占比。 if ($rankingKind === 'member') { $members = []; self::collectRankingMembers($rows, $members); return array_values($members); } // 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 * @param array> $members */ private static function collectRankingMembers(array $rows, array &$members): void { foreach ($rows as $row) { if ((string) ($row['type'] ?? '') === 'member') { $adminId = (int) ($row['admin_id'] ?? 0); if ($adminId > 0) { $members[$adminId] = $row; } continue; } self::collectRankingMembers( is_array($row['children'] ?? null) ? $row['children'] : [], $members ); } } /** @param array> $rows @return array> */ private static function topRows(array $rows, string $metric): array { $rows = array_values(array_filter($rows, static function (array $row): bool { if ((string) ($row['type'] ?? '') === 'member') { return (int) ($row['admin_id'] ?? 0) > 0; } return (int) ($row['id'] ?? 0) > 0; })); usort($rows, static function (array $left, array $right) use ($metric): int { $valueCompare = (float) ($right[$metric] ?? 0) <=> (float) ($left[$metric] ?? 0); if ($valueCompare !== 0) { return $valueCompare; } $nameCompare = strnatcasecmp((string) ($left['name'] ?? ''), (string) ($right['name'] ?? '')); if ($nameCompare !== 0) { return $nameCompare; } return strcmp((string) ($left['id'] ?? ''), (string) ($right['id'] ?? '')); }); return array_map(static fn (array $row): array => [ 'id' => $row['id'] ?? 0, 'name' => (string) ($row['name'] ?? ''), 'value' => round((float) ($row[$metric] ?? 0), 2), ], $rows); } /** @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; } }