*/ public static function overview(array $params = []): array { $includeFilters = (int)($params['include_filters'] ?? 0) === 1; $dimension = self::normalizeDimension((string)($params['dimension'] ?? 'dept')); $mediaChannelCode = MediaChannelService::normalizeStatsCode((string) ($params['media_channel_code'] ?? '')); $mediaChannel = $mediaChannelCode !== '' ? MediaChannelService::getChannelByCode($mediaChannelCode) : null; $filterEmptyEntities = $mediaChannel !== null; [$startTimestamp, $endTimestamp, $startDate, $endDate] = self::resolveTimeRange($params); $pageNo = max(1, (int)($params['page_no'] ?? 1)); $pageSize = max(1, min(100, (int)($params['page_size'] ?? 15))); $entities = self::loadEntities($dimension, $params); $entityIds = array_keys($entities); if ($entityIds === []) { $result = [ 'dimension' => $dimension, 'date_range' => [$startDate, $endDate], 'summary' => self::buildSummary([]), 'charts' => self::buildCharts([]), 'lists' => [], 'count' => 0, 'page_no' => $pageNo, 'page_size' => $pageSize, 'extend' => [ 'dimension' => $dimension, 'date_range' => [$startDate, $endDate], 'summary' => self::buildSummary([]), 'charts' => self::buildCharts([]), ], ]; if ($includeFilters) { $result['extend']['filters'] = self::buildFilterOptions(); } return $result; } $adminToDeptIds = self::loadAdminDeptMap(); self::hydrateFanStats($entities, $dimension, $entityIds, $adminToDeptIds, $startTimestamp, $endTimestamp, $mediaChannel); self::hydrateAppointmentStats($entities, $dimension, $entityIds, $adminToDeptIds, $startDate, $endDate, $mediaChannel); self::hydrateOrderAndAmountStats($entities, $dimension, $entityIds, $adminToDeptIds, $startTimestamp, $endTimestamp, $mediaChannel); $globalAccountCost = self::hydrateAccountCostStats($entities, $startDate, $endDate, $mediaChannelCode); if ($dimension === 'dept') { [$allRows, $pagedRows, $chartRows] = self::buildDeptTreeRows( $entities, isset($params['dept_id']) ? (int)$params['dept_id'] : 0, $pageNo, $pageSize, $filterEmptyEntities ); $result = [ 'dimension' => $dimension, 'date_range' => [$startDate, $endDate], 'summary' => self::buildSummary($allRows, $globalAccountCost), 'charts' => self::buildCharts($chartRows), 'lists' => $pagedRows, 'count' => count($allRows), 'page_no' => $pageNo, 'page_size' => $pageSize, 'extend' => [ 'dimension' => $dimension, 'date_range' => [$startDate, $endDate], 'summary' => self::buildSummary($allRows, $globalAccountCost), 'charts' => self::buildCharts($chartRows), ], ]; if ($includeFilters) { $result['extend']['filters'] = self::buildFilterOptions(); } return $result; } $rows = self::finalizeRows($entities, $filterEmptyEntities); $count = count($rows); $offset = ($pageNo - 1) * $pageSize; $result = [ 'dimension' => $dimension, 'date_range' => [$startDate, $endDate], 'summary' => self::buildSummary($rows, $globalAccountCost), 'charts' => self::buildCharts($rows), 'lists' => array_slice($rows, $offset, $pageSize), 'count' => $count, 'page_no' => $pageNo, 'page_size' => $pageSize, 'extend' => [ 'dimension' => $dimension, 'date_range' => [$startDate, $endDate], 'summary' => self::buildSummary($rows, $globalAccountCost), 'charts' => self::buildCharts($rows), ], ]; if ($includeFilters) { $result['extend']['filters'] = self::buildFilterOptions(); } return $result; } /** * @return array */ private static function buildFilterOptions(): array { return [ 'departments' => DeptLogic::getAllData(), 'assistants' => DiagnosisLogic::getAssistants(), 'doctors' => DiagnosisLogic::getDoctors(), 'media_channels' => MediaChannelService::getOptions(), ]; } private static function normalizeDimension(string $dimension): string { return in_array($dimension, ['dept', 'assistant', 'doctor'], true) ? $dimension : 'dept'; } /** * @return array{0:int,1:int,2:string,3:string} */ private static function resolveTimeRange(array $params): array { $today = date('Y-m-d'); $timeType = (string)($params['time_type'] ?? 'today'); switch ($timeType) { case 'yesterday': $startDate = date('Y-m-d', strtotime('-1 day')); $endDate = $startDate; break; case 'week': $startDate = date('Y-m-d', strtotime('-6 days')); $endDate = $today; break; case 'month': $startDate = date('Y-m-d', strtotime('-29 days')); $endDate = $today; break; case 'custom': $startDate = trim((string)($params['start_date'] ?? '')); $endDate = trim((string)($params['end_date'] ?? '')); if ($startDate === '' || $endDate === '' || strtotime($startDate) === false || strtotime($endDate) === false) { $startDate = $today; $endDate = $today; } if ($startDate > $endDate) { [$startDate, $endDate] = [$endDate, $startDate]; } break; case 'today': default: $startDate = $today; $endDate = $today; break; } return [ strtotime($startDate . ' 00:00:00'), strtotime($endDate . ' 23:59:59'), $startDate, $endDate, ]; } /** * @return array> */ private static function loadEntities(string $dimension, array $params): array { $entities = match ($dimension) { 'assistant' => self::loadAdminEntities(2, isset($params['assistant_id']) ? (int)$params['assistant_id'] : 0), 'doctor' => self::loadAdminEntities(1, isset($params['doctor_id']) ? (int)$params['doctor_id'] : 0), default => self::loadDeptEntities(isset($params['dept_id']) ? (int)$params['dept_id'] : 0), }; if ($dimension === 'dept' && (int)($params['dept_id'] ?? 0) <= 0) { self::appendVirtualDeptEntities($entities); } return $entities; } /** * @return array> */ private static function loadDeptEntities(int $deptId = 0): array { $query = Db::name('dept') ->whereNull('delete_time') ->field('id, pid, name, sort'); $rows = $query->order('sort desc, id asc')->select()->toArray(); if ($deptId > 0) { $allowedIds = self::collectDeptSubtreeIds($rows, $deptId); $rows = array_values(array_filter($rows, static fn (array $row): bool => in_array((int)$row['id'], $allowedIds, true))); } $entities = []; foreach ($rows as $row) { $id = (int)($row['id'] ?? 0); if ($id <= 0) { continue; } $entities[$id] = self::newEntityRow($id, (string)($row['name'] ?? '')); $entities[$id]['pid'] = (int)($row['pid'] ?? 0); $entities[$id]['sort'] = (int)($row['sort'] ?? 0); } return $entities; } /** * @param array> $rows * @return int[] */ private static function collectDeptSubtreeIds(array $rows, int $rootId): array { $childrenByPid = []; foreach ($rows as $row) { $pid = (int)($row['pid'] ?? 0); $id = (int)($row['id'] ?? 0); $childrenByPid[$pid] ??= []; $childrenByPid[$pid][] = $id; } $queue = [$rootId]; $result = []; while ($queue !== []) { $current = array_shift($queue); if (!$current || isset($result[$current])) { continue; } $result[$current] = $current; foreach ($childrenByPid[$current] ?? [] as $childId) { $queue[] = (int)$childId; } } return array_values($result); } /** * @return array> */ private static function loadAdminEntities(int $roleId, int $adminId = 0): array { $query = Db::name('admin') ->alias('a') ->join('admin_role ar', 'ar.admin_id = a.id') ->where('ar.role_id', $roleId) ->whereNull('a.delete_time') ->field('a.id, a.name'); if ($adminId > 0) { $query->where('a.id', $adminId); } $rows = $query->order('a.id asc')->select()->toArray(); $entities = []; foreach ($rows as $row) { $id = (int)($row['id'] ?? 0); if ($id <= 0) { continue; } $entities[$id] = self::newEntityRow($id, (string)($row['name'] ?? '')); } return $entities; } /** * @return array */ private static function newEntityRow(int $id, string $name): array { return [ 'id' => $id, 'name' => $name, 'add_fans_count' => 0, 'total_open_count' => 0, 'unreplied_count' => 0, 'paid_appointment_count' => 0, 'free_appointment_count' => 0, 'appointment_total_count' => 0, 'interview_count' => 0, 'business_order_amount' => 0.0, 'completed_order_amount' => 0.0, 'completed_order_count' => 0, 'account_cost' => 0.0, '_virtual_bucket' => false, ]; } /** * @param array> $entities */ private static function appendVirtualDeptEntities(array &$entities): void { $entities[self::VIRTUAL_DEPT_UNBOUND_ADMIN_ID] = array_merge( self::newEntityRow(self::VIRTUAL_DEPT_UNBOUND_ADMIN_ID, '未绑定后台账号'), [ 'pid' => 0, 'sort' => -999998, '_virtual_bucket' => true, ] ); $entities[self::VIRTUAL_DEPT_UNASSIGNED_ID] = array_merge( self::newEntityRow(self::VIRTUAL_DEPT_UNASSIGNED_ID, '未分配部门'), [ 'pid' => 0, 'sort' => -999999, '_virtual_bucket' => true, ] ); } /** * @return array */ private static function loadAdminDeptMap(): array { $rows = Db::name('admin_dept')->field('admin_id, dept_id')->select()->toArray(); $map = []; foreach ($rows as $row) { $adminId = (int)($row['admin_id'] ?? 0); $deptId = (int)($row['dept_id'] ?? 0); if ($adminId <= 0 || $deptId <= 0) { continue; } $map[$adminId] ??= []; $map[$adminId][] = $deptId; } return $map; } /** * @param array> $entities * @param int[] $entityIds * @param array $adminToDeptIds * @param array|null $mediaChannel */ private static function hydrateFanStats( array &$entities, string $dimension, array $entityIds, array $adminToDeptIds, int $startTimestamp, int $endTimestamp, ?array $mediaChannel ): void { $query = Db::name('qywx_external_contact_event') ->alias('e') ->leftJoin('admin a', 'a.work_wechat_userid = e.user_id AND a.delete_time IS NULL') ->where('e.change_type', 'add_external_contact') ->where('e.event_time', 'between', [$startTimestamp, $endTimestamp]) ->fieldRaw('e.user_id, a.id AS admin_id, COUNT(*) AS add_fans_count') ->group('e.user_id, a.id'); if ($mediaChannel !== null) { $query->leftJoin('qywx_external_contact q', 'q.external_userid = e.external_userid'); MediaChannelService::applyFollowUsersChannelFilter($query, 'q.follow_users', $mediaChannel); } $rows = $query->select()->toArray(); foreach ($rows as $row) { $adminId = (int)($row['admin_id'] ?? 0); $addFansCount = (int)($row['add_fans_count'] ?? 0); if ($dimension === 'dept' && $adminId <= 0) { if (isset($entities[self::VIRTUAL_DEPT_UNBOUND_ADMIN_ID])) { $entities[self::VIRTUAL_DEPT_UNBOUND_ADMIN_ID]['add_fans_count'] += $addFansCount; } continue; } if ($adminId <= 0) { continue; } $hitEntityIds = self::mapEntityIds($dimension, $adminId, $entityIds, $adminToDeptIds); if ($dimension === 'dept' && $hitEntityIds === []) { if (isset($entities[self::VIRTUAL_DEPT_UNASSIGNED_ID])) { $entities[self::VIRTUAL_DEPT_UNASSIGNED_ID]['add_fans_count'] += $addFansCount; } continue; } if ($hitEntityIds === []) { continue; } foreach ($hitEntityIds as $entityId) { $entities[$entityId]['add_fans_count'] += $addFansCount; } } } /** * @param array> $entities * @param int[] $entityIds * @param array $adminToDeptIds * @param array|null $mediaChannel */ private static function hydrateAppointmentStats( array &$entities, string $dimension, array $entityIds, array $adminToDeptIds, string $startDate, string $endDate, ?array $mediaChannel ): void { $sourceExpr = $dimension === 'doctor' ? 'a.doctor_id' : 'u.assistant_id'; $query = Db::name('doctor_appointment') ->alias('a') ->leftJoin('tcm_diagnosis u', 'a.patient_id = u.id') ->where('a.appointment_date', '>=', $startDate) ->where('a.appointment_date', '<=', $endDate) ->whereRaw('(u.id IS NULL OR u.delete_time IS NULL)') ->fieldRaw("{$sourceExpr} AS source_admin_id, a.patient_id AS diagnosis_id, COUNT(*) AS appointment_count, SUM(CASE WHEN a.status = 3 THEN 1 ELSE 0 END) AS interview_count") ->group("{$sourceExpr}, a.patient_id"); if ($mediaChannel !== null) { $query->leftJoin('qywx_external_contact q', 'q.external_userid = u.external_userid'); MediaChannelService::applyFollowUsersChannelFilter($query, 'q.follow_users', $mediaChannel); } $rows = $query->select()->toArray(); foreach ($rows as $row) { $diagnosisId = (int)($row['diagnosis_id'] ?? 0); if ($diagnosisId <= 0) { continue; } $sourceAdminId = (int)($row['source_admin_id'] ?? 0); $mappedEntityIds = self::mapEntityIds($dimension, $sourceAdminId, $entityIds, $adminToDeptIds); if ($mappedEntityIds === []) { continue; } $appointmentCount = (int)($row['appointment_count'] ?? 0); $interviewCount = (int)($row['interview_count'] ?? 0); foreach ($mappedEntityIds as $entityId) { $entities[$entityId]['appointment_total_count'] += $appointmentCount; $entities[$entityId]['paid_appointment_count'] += 1; if ($appointmentCount > 1) { $entities[$entityId]['free_appointment_count'] += ($appointmentCount - 1); } $entities[$entityId]['interview_count'] += $interviewCount; } } } /** * @param array> $entities * @param int[] $entityIds * @param array $adminToDeptIds * @param array|null $mediaChannel */ private static function hydrateOrderAndAmountStats( array &$entities, string $dimension, array $entityIds, array $adminToDeptIds, int $startTimestamp, int $endTimestamp, ?array $mediaChannel ): void { $completedSourceExpr = $dimension === 'doctor' ? 'rx.creator_id' : 'rx.assistant_id'; $completedQuery = Db::name('tcm_prescription_order') ->alias('po') ->leftJoin('tcm_prescription rx', 'rx.id = po.prescription_id') ->leftJoin('tcm_diagnosis dg', 'dg.id = po.diagnosis_id') ->whereNull('po.delete_time') ->where('po.payment_slip_audit_status', 1) ->where('po.create_time', 'between', [$startTimestamp, $endTimestamp]) ->fieldRaw("{$completedSourceExpr} AS source_admin_id, SUM(CASE WHEN po.prescription_audit_status = 1 THEN 1 ELSE 0 END) AS order_count, SUM(CASE WHEN po.prescription_audit_status = 1 THEN po.amount ELSE 0 END) AS total_amount") ->group($completedSourceExpr); if ($mediaChannel !== null) { $completedQuery->leftJoin('qywx_external_contact q', 'q.external_userid = dg.external_userid'); MediaChannelService::applyFollowUsersChannelFilter($completedQuery, 'q.follow_users', $mediaChannel); } $completedRows = $completedQuery->select()->toArray(); foreach ($completedRows as $row) { $sourceAdminId = (int)($row['source_admin_id'] ?? 0); $mappedEntityIds = self::mapEntityIds($dimension, $sourceAdminId, $entityIds, $adminToDeptIds); if ($mappedEntityIds === []) { continue; } $orderCount = (int)($row['order_count'] ?? 0); $amount = round((float)($row['total_amount'] ?? 0), 2); foreach ($mappedEntityIds as $entityId) { $entities[$entityId]['completed_order_count'] += $orderCount; $entities[$entityId]['completed_order_amount'] = round($entities[$entityId]['completed_order_amount'] + $amount, 2); } } $businessSourceExpr = $dimension === 'doctor' ? 'po.creator_id' : 'dg.assistant_id'; $businessQuery = Db::name('tcm_prescription_order') ->alias('po') ->leftJoin('tcm_diagnosis dg', 'dg.id = po.diagnosis_id') ->whereNull('po.delete_time') ->where('po.create_time', 'between', [$startTimestamp, $endTimestamp]) ->where('po.fulfillment_status', '<>', 4) ->fieldRaw("{$businessSourceExpr} AS source_admin_id, SUM(po.amount) AS total_amount") ->group($businessSourceExpr); if ($mediaChannel !== null) { $businessQuery->leftJoin('qywx_external_contact q2', 'q2.external_userid = dg.external_userid'); MediaChannelService::applyFollowUsersChannelFilter($businessQuery, 'q2.follow_users', $mediaChannel); } $businessRows = $businessQuery->select()->toArray(); foreach ($businessRows as $row) { $sourceAdminId = (int)($row['source_admin_id'] ?? 0); $mappedEntityIds = self::mapEntityIds($dimension, $sourceAdminId, $entityIds, $adminToDeptIds); if ($mappedEntityIds === []) { continue; } $amount = round((float)($row['total_amount'] ?? 0), 2); foreach ($mappedEntityIds as $entityId) { $entities[$entityId]['business_order_amount'] = round($entities[$entityId]['business_order_amount'] + $amount, 2); } } } /** * 账户消耗:来源于独立维护表 zyt_account_cost,为全局日维度数据。 * 由于当前没有部门/医助/医生拆分来源,只在当前筛选口径的汇总层使用,不向各明细行分摊。 * * @param array> $entities */ private static function hydrateAccountCostStats(array &$entities, string $startDate, string $endDate, string $mediaChannelCode): float { $query = Db::name('account_cost') ->where('cost_date', '>=', $startDate) ->where('cost_date', '<=', $endDate); if ($mediaChannelCode !== '') { $query->where('media_channel_code', $mediaChannelCode); } $totalAmount = round((float) $query->sum('amount'), 2); foreach ($entities as &$entity) { $entity['account_cost'] = 0.0; $entity['_global_account_cost'] = $totalAmount; } unset($entity); return $totalAmount; } /** * @param int[] $entityIds * @param array $adminToDeptIds * @return int[] */ private static function mapEntityIds(string $dimension, int $adminId, array $entityIds, array $adminToDeptIds): array { if ($adminId <= 0) { return []; } if ($dimension !== 'dept') { return in_array($adminId, $entityIds, true) ? [$adminId] : []; } $deptIds = $adminToDeptIds[$adminId] ?? []; if ($deptIds === []) { return []; } return array_values(array_filter($deptIds, static fn (int $deptId): bool => in_array($deptId, $entityIds, true))); } /** * @param array> $entities * @return array> */ private static function finalizeRows(array $entities, bool $excludeEmpty = false): array { $rows = []; $globalAccountCost = 0.0; foreach ($entities as $entity) { $paidAppointmentCount = (int)($entity['paid_appointment_count'] ?? 0); $freeAppointmentCount = (int)($entity['free_appointment_count'] ?? 0); $appointmentTotalCount = (int)($entity['appointment_total_count'] ?? 0); $interviewCount = (int)$entity['interview_count']; $addFansCount = (int)$entity['add_fans_count']; $totalOpenCount = (int)$entity['total_open_count']; $completedOrderCount = (int)$entity['completed_order_count']; $businessOrderAmount = round((float)$entity['business_order_amount'], 2); $completedOrderAmount = round((float)$entity['completed_order_amount'], 2); $accountCost = round((float)$entity['account_cost'], 2); $globalAccountCost = max($globalAccountCost, round((float)($entity['_global_account_cost'] ?? 0), 2)); $effectiveAccountCost = $globalAccountCost > 0 ? $globalAccountCost : $accountCost; $entity['paid_appointment_count'] = $paidAppointmentCount; $entity['free_appointment_count'] = $freeAppointmentCount; $entity['appointment_total_count'] = $appointmentTotalCount; $entity['business_order_amount'] = $businessOrderAmount; $entity['completed_order_amount'] = $completedOrderAmount; $entity['account_cost'] = $effectiveAccountCost; $entity['paid_appointment_rate'] = self::percent($paidAppointmentCount, $addFansCount); $entity['open_appointment_rate'] = self::percent($paidAppointmentCount, $totalOpenCount); $entity['interview_rate'] = self::percent($interviewCount, $appointmentTotalCount); $entity['receive_rate'] = self::percent($completedOrderCount, $addFansCount); $entity['interview_receive_rate'] = self::percent($completedOrderCount, $interviewCount); $entity['open_receive_rate'] = self::percent($completedOrderCount, $totalOpenCount); $entity['avg_unit_price'] = self::safeDivideMoney($completedOrderAmount, $completedOrderCount); $entity['cash_cost'] = self::safeDivideMoney($effectiveAccountCost, $addFansCount); $entity['roi'] = self::safeDivideRatio($completedOrderAmount, $effectiveAccountCost); if ($excludeEmpty && !self::hasBusinessMetrics($entity)) { continue; } $rows[] = $entity; } usort($rows, static function (array $left, array $right): int { $order = $right['completed_order_amount'] <=> $left['completed_order_amount']; if ($order !== 0) { return $order; } $order = $right['completed_order_count'] <=> $left['completed_order_count']; if ($order !== 0) { return $order; } return $right['add_fans_count'] <=> $left['add_fans_count']; }); if ($globalAccountCost > 0 && $rows !== []) { foreach ($rows as &$row) { $row['_global_account_cost'] = $globalAccountCost; } unset($row); } return $rows; } /** * @param array> $entities * @return array{0: array>, 1: array>, 2: array>} */ private static function buildDeptTreeRows(array $entities, int $selectedDeptId, int $pageNo, int $pageSize, bool $excludeEmpty = false): array { $childrenByPid = []; foreach ($entities as $entity) { $pid = (int)($entity['pid'] ?? 0); $childrenByPid[$pid] ??= []; $childrenByPid[$pid][] = (int)$entity['id']; } foreach ($childrenByPid as &$ids) { usort($ids, static function (int $left, int $right) use ($entities): int { $sortCompare = ((int)($entities[$right]['sort'] ?? 0)) <=> ((int)($entities[$left]['sort'] ?? 0)); if ($sortCompare !== 0) { return $sortCompare; } return $left <=> $right; }); } unset($ids); $buildNode = function (int $deptId) use (&$buildNode, &$entities, $childrenByPid): array { $node = $entities[$deptId]; $children = []; foreach ($childrenByPid[$deptId] ?? [] as $childId) { $childNode = $buildNode((int)$childId); $children[] = $childNode; $node['add_fans_count'] += (int)$childNode['add_fans_count']; $node['total_open_count'] += (int)$childNode['total_open_count']; $node['unreplied_count'] += (int)$childNode['unreplied_count']; $node['paid_appointment_count'] += (int)$childNode['paid_appointment_count']; $node['free_appointment_count'] += (int)$childNode['free_appointment_count']; $node['appointment_total_count'] += (int)$childNode['appointment_total_count']; $node['interview_count'] += (int)$childNode['interview_count']; $node['completed_order_count'] += (int)$childNode['completed_order_count']; $node['business_order_amount'] = round((float)$node['business_order_amount'] + (float)$childNode['business_order_amount'], 2); $node['completed_order_amount'] = round((float)$node['completed_order_amount'] + (float)$childNode['completed_order_amount'], 2); $node['account_cost'] = round((float)$node['account_cost'] + (float)$childNode['account_cost'], 2); } $node['children'] = $children; return $node; }; $rootIds = []; foreach ($entities as $entity) { $pid = (int)($entity['pid'] ?? 0); if (!isset($entities[$pid])) { $rootIds[] = (int)$entity['id']; } } usort($rootIds, static function (int $left, int $right) use ($entities): int { $sortCompare = ((int)($entities[$right]['sort'] ?? 0)) <=> ((int)($entities[$left]['sort'] ?? 0)); if ($sortCompare !== 0) { return $sortCompare; } return $left <=> $right; }); $rootRows = array_map(static fn (int $deptId): array => self::finalizeDeptNode($buildNode($deptId)), $rootIds); if ($excludeEmpty) { $rootRows = array_values(array_filter(array_map( static fn (array $row): ?array => self::pruneDeptNode($row), $rootRows ))); } $realRootRows = array_values(array_filter($rootRows, static fn (array $row): bool => !((bool)($row['_virtual_bucket'] ?? false)))); $virtualRoot = count($realRootRows) === 1 && !empty($realRootRows[0]['children']) ? $realRootRows[0] : null; if ($selectedDeptId > 0) { $selectedNode = self::findDeptNode($rootRows, $selectedDeptId); if ($selectedNode !== null) { $allRows = [$selectedNode]; $chartRows = !empty($selectedNode['children']) ? $selectedNode['children'] : [$selectedNode]; } else { $allRows = $rootRows; $chartRows = $virtualRoot !== null ? ($virtualRoot['children'] ?? [$virtualRoot]) : ($realRootRows ?: $rootRows); } } else { $allRows = $rootRows; $chartRows = $virtualRoot !== null ? ($virtualRoot['children'] ?? [$virtualRoot]) : ($realRootRows ?: $rootRows); } $offset = ($pageNo - 1) * $pageSize; return [$allRows, array_slice($allRows, $offset, $pageSize), $chartRows]; } /** * @param array> $rows * @return array|null */ private static function findDeptNode(array $rows, int $deptId): ?array { foreach ($rows as $row) { if ((int)($row['id'] ?? 0) === $deptId) { return $row; } $children = $row['children'] ?? []; if (!is_array($children) || $children === []) { continue; } $matched = self::findDeptNode($children, $deptId); if ($matched !== null) { return $matched; } } return null; } /** * @param array $node * @return array|null */ private static function pruneDeptNode(array $node): ?array { $children = []; foreach ($node['children'] ?? [] as $child) { if (!is_array($child)) { continue; } $pruned = self::pruneDeptNode($child); if ($pruned !== null) { $children[] = $pruned; } } $node['children'] = $children; if ($children !== []) { return $node; } return self::hasBusinessMetrics($node) ? $node : null; } /** * @param array $node * @return array */ private static function finalizeDeptNode(array $node): array { $node['children'] = array_map(static fn (array $child): array => self::finalizeDeptNode($child), $node['children'] ?? []); $paidAppointmentCount = (int)($node['paid_appointment_count'] ?? 0); $freeAppointmentCount = (int)($node['free_appointment_count'] ?? 0); $appointmentTotalCount = (int)($node['appointment_total_count'] ?? 0); $interviewCount = (int)$node['interview_count']; $addFansCount = (int)$node['add_fans_count']; $totalOpenCount = (int)$node['total_open_count']; $completedOrderCount = (int)$node['completed_order_count']; $businessOrderAmount = round((float)$node['business_order_amount'], 2); $completedOrderAmount = round((float)$node['completed_order_amount'], 2); $accountCost = round((float)$node['account_cost'], 2); $globalAccountCost = round((float)($node['_global_account_cost'] ?? 0), 2); $effectiveAccountCost = $globalAccountCost > 0 ? $globalAccountCost : $accountCost; $node['paid_appointment_count'] = $paidAppointmentCount; $node['free_appointment_count'] = $freeAppointmentCount; $node['appointment_total_count'] = $appointmentTotalCount; $node['business_order_amount'] = $businessOrderAmount; $node['completed_order_amount'] = $completedOrderAmount; $node['account_cost'] = $effectiveAccountCost; $node['paid_appointment_rate'] = self::percent($paidAppointmentCount, $addFansCount); $node['open_appointment_rate'] = self::percent($paidAppointmentCount, $totalOpenCount); $node['interview_rate'] = self::percent($interviewCount, $appointmentTotalCount); $node['receive_rate'] = self::percent($completedOrderCount, $addFansCount); $node['interview_receive_rate'] = self::percent($completedOrderCount, $interviewCount); $node['open_receive_rate'] = self::percent($completedOrderCount, $totalOpenCount); $node['avg_unit_price'] = self::safeDivideMoney($completedOrderAmount, $completedOrderCount); $node['cash_cost'] = self::safeDivideMoney($effectiveAccountCost, $addFansCount); $node['roi'] = self::safeDivideRatio($completedOrderAmount, $effectiveAccountCost); if ($globalAccountCost > 0) { $node['_global_account_cost'] = $globalAccountCost; } unset($node['sort'], $node['pid']); return $node; } /** * @param array> $rows * @return array */ private static function buildSummary(array $rows, float $forcedAccountCost = 0.0): array { $summary = [ 'add_fans_count' => 0, 'total_open_count' => 0, 'unreplied_count' => 0, 'paid_appointment_count' => 0, 'free_appointment_count' => 0, 'appointment_total_count' => 0, 'interview_count' => 0, 'business_order_amount' => 0.0, 'completed_order_amount' => 0.0, 'completed_order_count' => 0, 'account_cost' => 0.0, ]; $globalAccountCost = 0.0; foreach ($rows as $row) { $summary['add_fans_count'] += (int)$row['add_fans_count']; $summary['total_open_count'] += (int)$row['total_open_count']; $summary['unreplied_count'] += (int)$row['unreplied_count']; $summary['paid_appointment_count'] += (int)$row['paid_appointment_count']; $summary['free_appointment_count'] += (int)$row['free_appointment_count']; $summary['appointment_total_count'] += (int)$row['appointment_total_count']; $summary['interview_count'] += (int)$row['interview_count']; $summary['completed_order_count'] += (int)$row['completed_order_count']; $summary['business_order_amount'] = round($summary['business_order_amount'] + (float)($row['business_order_amount'] ?? 0), 2); $summary['completed_order_amount'] = round($summary['completed_order_amount'] + (float)$row['completed_order_amount'], 2); $globalAccountCost = max($globalAccountCost, round((float)($row['_global_account_cost'] ?? 0), 2)); } $summary['account_cost'] = max($globalAccountCost, round($forcedAccountCost, 2)); $summary['paid_appointment_rate'] = self::percent($summary['paid_appointment_count'], $summary['add_fans_count']); $summary['open_appointment_rate'] = self::percent($summary['paid_appointment_count'], $summary['total_open_count']); $summary['interview_rate'] = self::percent($summary['interview_count'], $summary['appointment_total_count']); $summary['receive_rate'] = self::percent($summary['completed_order_count'], $summary['add_fans_count']); $summary['interview_receive_rate'] = self::percent($summary['completed_order_count'], $summary['interview_count']); $summary['open_receive_rate'] = self::percent($summary['completed_order_count'], $summary['total_open_count']); $summary['avg_unit_price'] = self::safeDivideMoney($summary['completed_order_amount'], $summary['completed_order_count']); $summary['cash_cost'] = self::safeDivideMoney($summary['account_cost'], $summary['add_fans_count']); $summary['roi'] = self::safeDivideRatio($summary['completed_order_amount'], $summary['account_cost']); return $summary; } /** * @param array $row */ private static function hasBusinessMetrics(array $row): bool { return (int)($row['add_fans_count'] ?? 0) > 0 || (int)($row['paid_appointment_count'] ?? 0) > 0 || (int)($row['free_appointment_count'] ?? 0) > 0 || (int)($row['appointment_total_count'] ?? 0) > 0 || (int)($row['interview_count'] ?? 0) > 0 || (int)($row['completed_order_count'] ?? 0) > 0 || (float)($row['business_order_amount'] ?? 0) > 0 || (float)($row['completed_order_amount'] ?? 0) > 0; } /** * @param array> $rows * @return array */ private static function buildCharts(array $rows): array { $chartableRows = array_values(array_filter($rows, static fn (array $row): bool => !((bool)($row['_virtual_bucket'] ?? false)))); $topRows = array_slice($chartableRows, 0, 10); $ranking = [ 'names' => [], 'amounts' => [], 'order_counts' => [], 'fan_counts' => [], 'rois' => [], ]; foreach ($topRows as $row) { $ranking['names'][] = $row['name']; $ranking['amounts'][] = $row['completed_order_amount']; $ranking['order_counts'][] = $row['completed_order_count']; $ranking['fan_counts'][] = $row['add_fans_count']; $ranking['rois'][] = $row['roi']; } return [ 'ranking' => $ranking, 'amount_share' => array_map( static fn (array $row): array => ['name' => $row['name'], 'value' => $row['completed_order_amount']], array_filter($topRows, static fn (array $row): bool => (float)$row['completed_order_amount'] > 0) ), 'fan_share' => array_map( static fn (array $row): array => ['name' => $row['name'], 'value' => $row['add_fans_count']], array_filter($topRows, static fn (array $row): bool => (int)$row['add_fans_count'] > 0) ), ]; } private static function percent(int $numerator, int $denominator): float { if ($denominator <= 0) { return 0.0; } return round(($numerator / $denominator) * 100, 2); } private static function safeDivideMoney(float $numerator, int $denominator): float { if ($denominator <= 0) { return 0.0; } return round($numerator / $denominator, 2); } private static function safeDivideRatio(float $numerator, float $denominator): float { if ($denominator <= 0) { return 0.0; } return round($numerator / $denominator, 2); } }