From 5cb970627479e3ae71ffbf4122b2b2446335b9f3 Mon Sep 17 00:00:00 2001 From: ShengLong <452591453@qq.com> Date: Fri, 24 Apr 2026 17:17:31 +0800 Subject: [PATCH] 1 --- .../adminapi/logic/stats/ConversionLogic.php | 230 ++++++++++++++---- .../service/qywx/MediaChannelService.php | 31 ++- 2 files changed, 210 insertions(+), 51 deletions(-) diff --git a/server/app/adminapi/logic/stats/ConversionLogic.php b/server/app/adminapi/logic/stats/ConversionLogic.php index a99be33a..c2a14670 100644 --- a/server/app/adminapi/logic/stats/ConversionLogic.php +++ b/server/app/adminapi/logic/stats/ConversionLogic.php @@ -28,8 +28,11 @@ class ConversionLogic $pageNo = max(1, (int)($params['page_no'] ?? 1)); $pageSize = max(1, min(100, (int)($params['page_size'] ?? 15))); + $allocationParams = self::buildAllocationScopeParams($dimension, $params); $entities = self::loadEntities($dimension, $params); $entityIds = array_keys($entities); + $allocationEntities = self::loadEntities($dimension, $allocationParams); + $allocationEntityIds = array_keys($allocationEntities); if ($entityIds === []) { $result = [ @@ -56,8 +59,10 @@ class ConversionLogic } $adminToDeptIds = self::loadAdminDeptMap(); + self::hydrateFanStats($allocationEntities, $dimension, $allocationEntityIds, $adminToDeptIds, $startTimestamp, $endTimestamp, $mediaChannel); + $allocationAddFansCount = self::sumEntityAddFans(array_values($allocationEntities)); self::hydrateFanStats($entities, $dimension, $entityIds, $adminToDeptIds, $startTimestamp, $endTimestamp, $mediaChannel); - self::hydrateAppointmentStats($entities, $dimension, $entityIds, $adminToDeptIds, $startDate, $endDate, $mediaChannel); + self::hydrateAppointmentStats($entities, $dimension, $entityIds, $adminToDeptIds, $startTimestamp, $endTimestamp, $startDate, $endDate, $mediaChannel); self::hydrateOrderAndAmountStats($entities, $dimension, $entityIds, $adminToDeptIds, $startTimestamp, $endTimestamp, $mediaChannel); $globalAccountCost = self::hydrateAccountCostStats($entities, $startDate, $endDate, $mediaChannelCode); @@ -67,13 +72,14 @@ class ConversionLogic isset($params['dept_id']) ? (int)$params['dept_id'] : 0, $pageNo, $pageSize, - $filterEmptyEntities + $filterEmptyEntities, + $allocationAddFansCount ); $result = [ 'dimension' => $dimension, 'date_range' => [$startDate, $endDate], - 'summary' => self::buildSummary($allRows, $globalAccountCost), + 'summary' => self::buildSummary($allRows), 'charts' => self::buildCharts($chartRows), 'lists' => $pagedRows, 'count' => count($allRows), @@ -82,7 +88,7 @@ class ConversionLogic 'extend' => [ 'dimension' => $dimension, 'date_range' => [$startDate, $endDate], - 'summary' => self::buildSummary($allRows, $globalAccountCost), + 'summary' => self::buildSummary($allRows), 'charts' => self::buildCharts($chartRows), ], ]; @@ -93,14 +99,14 @@ class ConversionLogic return $result; } - $rows = self::finalizeRows($entities, $filterEmptyEntities); + $rows = self::finalizeRows($entities, $filterEmptyEntities, $allocationAddFansCount); $count = count($rows); $offset = ($pageNo - 1) * $pageSize; $result = [ 'dimension' => $dimension, 'date_range' => [$startDate, $endDate], - 'summary' => self::buildSummary($rows, $globalAccountCost), + 'summary' => self::buildSummary($rows), 'charts' => self::buildCharts($rows), 'lists' => array_slice($rows, $offset, $pageSize), 'count' => $count, @@ -109,7 +115,7 @@ class ConversionLogic 'extend' => [ 'dimension' => $dimension, 'date_range' => [$startDate, $endDate], - 'summary' => self::buildSummary($rows, $globalAccountCost), + 'summary' => self::buildSummary($rows), 'charts' => self::buildCharts($rows), ], ]; @@ -120,6 +126,15 @@ class ConversionLogic return $result; } + private static function buildAllocationScopeParams(string $dimension, array $params): array + { + $allocationParams = $params; + + unset($allocationParams['dept_id'], $allocationParams['assistant_id'], $allocationParams['doctor_id']); + + return $allocationParams; + } + /** * @return array */ @@ -432,6 +447,8 @@ class ConversionLogic string $dimension, array $entityIds, array $adminToDeptIds, + int $startTimestamp, + int $endTimestamp, string $startDate, string $endDate, ?array $mediaChannel @@ -447,8 +464,13 @@ class ConversionLogic ->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); + $legacyChannelValues = MediaChannelService::getLegacyAppointmentChannelValues($mediaChannel); + if ($legacyChannelValues !== []) { + $query->whereIn('a.channels', $legacyChannelValues); + } else { + $query->leftJoin('qywx_external_contact q', 'q.external_userid = u.external_userid'); + MediaChannelService::applyFollowUsersChannelFilter($query, 'q.follow_users', $mediaChannel); + } } $rows = $query->select()->toArray(); @@ -470,13 +492,65 @@ class ConversionLogic $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; } } + + self::hydratePaidAppointmentStats($entities, $dimension, $entityIds, $adminToDeptIds, $startTimestamp, $endTimestamp, $mediaChannel); + + foreach ($entities as &$entity) { + $appointmentTotalCount = (int)($entity['appointment_total_count'] ?? 0); + $paidAppointmentCount = (int)($entity['paid_appointment_count'] ?? 0); + $entity['free_appointment_count'] = max($appointmentTotalCount - $paidAppointmentCount, 0); + } + unset($entity); + } + + /** + * @param array> $entities + * @param int[] $entityIds + * @param array $adminToDeptIds + * @param array|null $mediaChannel + */ + private static function hydratePaidAppointmentStats( + array &$entities, + string $dimension, + array $entityIds, + array $adminToDeptIds, + int $startTimestamp, + int $endTimestamp, + ?array $mediaChannel + ): void { + $startDateTime = date('Y-m-d H:i:s', $startTimestamp); + $endDateTime = date('Y-m-d H:i:s', $endTimestamp); + $query = Db::name('order') + ->alias('o') + ->whereNull('o.delete_time') + ->where('o.status', 2) + ->where('o.order_type', 1) + ->where('o.amount', 5) + ->whereRaw('(o.payment_time IS NOT NULL AND o.payment_time <> "" AND o.payment_time >= ? AND o.payment_time <= ?)', [$startDateTime, $endDateTime]) + ->fieldRaw('o.creator_id AS source_admin_id, COUNT(*) AS paid_appointment_count') + ->group('o.creator_id'); + + if ($mediaChannel !== null) { + $query->leftJoin('qywx_external_contact q', 'q.external_userid = o.payer_external_userid'); + MediaChannelService::applyFollowUsersChannelFilter($query, 'q.follow_users', $mediaChannel); + } + + $rows = $query->select()->toArray(); + foreach ($rows as $row) { + $sourceAdminId = (int)($row['source_admin_id'] ?? 0); + $mappedEntityIds = self::mapEntityIds($dimension, $sourceAdminId, $entityIds, $adminToDeptIds); + if ($mappedEntityIds === []) { + continue; + } + + $count = (int)($row['paid_appointment_count'] ?? 0); + foreach ($mappedEntityIds as $entityId) { + $entities[$entityId]['paid_appointment_count'] += $count; + } + } } /** @@ -506,7 +580,9 @@ class ConversionLogic ->group($completedSourceExpr); if ($mediaChannel !== null) { - $completedQuery->leftJoin('qywx_external_contact q', 'q.external_userid = dg.external_userid'); + $completedQuery + ->leftJoin('order o', 'o.id = po.linked_pay_order_id') + ->leftJoin('qywx_external_contact q', 'q.external_userid = o.payer_external_userid'); MediaChannelService::applyFollowUsersChannelFilter($completedQuery, 'q.follow_users', $mediaChannel); } @@ -540,7 +616,9 @@ class ConversionLogic ->group($businessSourceExpr); if ($mediaChannel !== null) { - $businessQuery->leftJoin('qywx_external_contact q2', 'q2.external_userid = dg.external_userid'); + $businessQuery + ->leftJoin('order o2', 'o2.id = po.linked_pay_order_id') + ->leftJoin('qywx_external_contact q2', 'q2.external_userid = o2.payer_external_userid'); MediaChannelService::applyFollowUsersChannelFilter($businessQuery, 'q2.follow_users', $mediaChannel); } @@ -616,11 +694,17 @@ class ConversionLogic * @param array> $entities * @return array> */ - private static function finalizeRows(array $entities, bool $excludeEmpty = false): array + private static function finalizeRows(array $entities, bool $excludeEmpty = false, ?int $allocationAddFansCount = null): array { $rows = []; - $globalAccountCost = 0.0; - foreach ($entities as $entity) { + $filteredEntities = $excludeEmpty + ? array_values(array_filter($entities, static fn (array $entity): bool => self::hasBusinessMetrics($entity))) + : array_values($entities); + + $globalAccountCost = self::extractGlobalAccountCost($filteredEntities); + $globalAddFansCount = $allocationAddFansCount ?? self::sumEntityAddFans($filteredEntities); + + foreach ($filteredEntities as $entity) { $paidAppointmentCount = (int)($entity['paid_appointment_count'] ?? 0); $freeAppointmentCount = (int)($entity['free_appointment_count'] ?? 0); $appointmentTotalCount = (int)($entity['appointment_total_count'] ?? 0); @@ -630,9 +714,7 @@ class ConversionLogic $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; + $effectiveAccountCost = self::allocateAccountCost($globalAccountCost, $globalAddFansCount, $addFansCount); $entity['paid_appointment_count'] = $paidAppointmentCount; $entity['free_appointment_count'] = $freeAppointmentCount; @@ -649,9 +731,8 @@ class ConversionLogic $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; + if ($globalAccountCost > 0) { + $entity['_global_account_cost'] = $globalAccountCost; } $rows[] = $entity; @@ -670,13 +751,6 @@ class ConversionLogic 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; } @@ -684,7 +758,7 @@ class ConversionLogic * @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 + private static function buildDeptTreeRows(array $entities, int $selectedDeptId, int $pageNo, int $pageSize, bool $excludeEmpty = false, ?int $allocationAddFansCount = null): array { $childrenByPid = []; foreach ($entities as $entity) { @@ -744,7 +818,7 @@ class ConversionLogic return $left <=> $right; }); - $rootRows = array_map(static fn (int $deptId): array => self::finalizeDeptNode($buildNode($deptId)), $rootIds); + $rootRows = array_map($buildNode, $rootIds); if ($excludeEmpty) { $rootRows = array_values(array_filter(array_map( static fn (array $row): ?array => self::pruneDeptNode($row), @@ -757,17 +831,28 @@ class ConversionLogic if ($selectedDeptId > 0) { $selectedNode = self::findDeptNode($rootRows, $selectedDeptId); if ($selectedNode !== null) { - $allRows = [$selectedNode]; - $chartRows = !empty($selectedNode['children']) ? $selectedNode['children'] : [$selectedNode]; + $allRowsRaw = [$selectedNode]; + $chartRowsRaw = !empty($selectedNode['children']) ? $selectedNode['children'] : [$selectedNode]; } else { - $allRows = $rootRows; - $chartRows = $virtualRoot !== null ? ($virtualRoot['children'] ?? [$virtualRoot]) : ($realRootRows ?: $rootRows); + $allRowsRaw = $rootRows; + $chartRowsRaw = $virtualRoot !== null ? ($virtualRoot['children'] ?? [$virtualRoot]) : ($realRootRows ?: $rootRows); } } else { - $allRows = $rootRows; - $chartRows = $virtualRoot !== null ? ($virtualRoot['children'] ?? [$virtualRoot]) : ($realRootRows ?: $rootRows); + $allRowsRaw = $rootRows; + $chartRowsRaw = $virtualRoot !== null ? ($virtualRoot['children'] ?? [$virtualRoot]) : ($realRootRows ?: $rootRows); } + $globalAccountCost = self::extractGlobalAccountCost($allRowsRaw); + $globalAddFansCount = $allocationAddFansCount ?? self::sumNodeAddFans($allRowsRaw); + $allRows = array_map( + static fn (array $row): array => self::finalizeDeptNode($row, $globalAccountCost, $globalAddFansCount), + $allRowsRaw + ); + $chartRows = array_map( + static fn (array $row): array => self::finalizeDeptNode($row, $globalAccountCost, $globalAddFansCount), + $chartRowsRaw + ); + $offset = ($pageNo - 1) * $pageSize; return [$allRows, array_slice($allRows, $offset, $pageSize), $chartRows]; @@ -825,9 +910,12 @@ class ConversionLogic * @param array $node * @return array */ - private static function finalizeDeptNode(array $node): array + private static function finalizeDeptNode(array $node, float $globalAccountCost, int $globalAddFansCount): array { - $node['children'] = array_map(static fn (array $child): array => self::finalizeDeptNode($child), $node['children'] ?? []); + $node['children'] = array_map( + static fn (array $child): array => self::finalizeDeptNode($child, $globalAccountCost, $globalAddFansCount), + $node['children'] ?? [] + ); $paidAppointmentCount = (int)($node['paid_appointment_count'] ?? 0); $freeAppointmentCount = (int)($node['free_appointment_count'] ?? 0); $appointmentTotalCount = (int)($node['appointment_total_count'] ?? 0); @@ -837,9 +925,7 @@ class ConversionLogic $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; + $effectiveAccountCost = self::allocateAccountCost($globalAccountCost, $globalAddFansCount, $addFansCount); $node['paid_appointment_count'] = $paidAppointmentCount; $node['free_appointment_count'] = $freeAppointmentCount; @@ -868,7 +954,7 @@ class ConversionLogic * @param array> $rows * @return array */ - private static function buildSummary(array $rows, float $forcedAccountCost = 0.0): array + private static function buildSummary(array $rows): array { $summary = [ 'add_fans_count' => 0, @@ -883,8 +969,6 @@ class ConversionLogic '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']; @@ -896,11 +980,9 @@ class ConversionLogic $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'] = round($summary['account_cost'] + (float)($row['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']); @@ -914,6 +996,54 @@ class ConversionLogic return $summary; } + /** + * @param array> $entities + */ + private static function extractGlobalAccountCost(array $entities): float + { + $globalAccountCost = 0.0; + foreach ($entities as $entity) { + $globalAccountCost = max($globalAccountCost, round((float)($entity['_global_account_cost'] ?? 0), 2)); + } + + return $globalAccountCost; + } + + /** + * @param array> $entities + */ + private static function sumEntityAddFans(array $entities): int + { + $total = 0; + foreach ($entities as $entity) { + $total += (int)($entity['add_fans_count'] ?? 0); + } + + return $total; + } + + /** + * @param array> $nodes + */ + private static function sumNodeAddFans(array $nodes): int + { + $total = 0; + foreach ($nodes as $node) { + $total += (int)($node['add_fans_count'] ?? 0); + } + + return $total; + } + + private static function allocateAccountCost(float $globalAccountCost, int $globalAddFansCount, int $rowAddFansCount): float + { + if ($globalAccountCost <= 0 || $globalAddFansCount <= 0 || $rowAddFansCount <= 0) { + return 0.0; + } + + return round(($globalAccountCost / $globalAddFansCount) * $rowAddFansCount, 2); + } + /** * @param array $row */ diff --git a/server/app/common/service/qywx/MediaChannelService.php b/server/app/common/service/qywx/MediaChannelService.php index 47cbc657..f1da44e7 100644 --- a/server/app/common/service/qywx/MediaChannelService.php +++ b/server/app/common/service/qywx/MediaChannelService.php @@ -6,8 +6,8 @@ namespace app\common\service\qywx; use app\common\model\QywxExternalContact; use app\common\model\QywxMediaChannel; -use think\db\Query; use think\facade\Db; +use think\db\Query; class MediaChannelService { @@ -75,6 +75,35 @@ class MediaChannelService return (string) ($channel['channel_name'] ?? ''); } + /** + * 挂号老数据渠道:doctor_appointment.channels 对应 dict_data(type_value=channels) 的 value。 + * + * @param array|null $channel + * @return int[] + */ + public static function getLegacyAppointmentChannelValues(?array $channel): array + { + if ($channel === null) { + return []; + } + + $names = array_values(array_unique(array_filter([ + trim((string) ($channel['channel_name'] ?? '')), + trim((string) ($channel['source_tag_name'] ?? '')), + ]))); + + if ($names === []) { + return []; + } + + $rows = Db::name('dict_data') + ->where('type_value', 'channels') + ->whereIn('name', $names) + ->column('value'); + + return array_values(array_filter(array_map('intval', $rows), static fn (int $value): bool => $value > 0)); + } + /** * @param array|null $channel */