getFileName()); conversionFanDetailExpect(is_string($conversionSource), 'Unable to read ConversionLogic source'); $buildDetailRows = (new ReflectionClass(ConversionLogic::class))->getMethod('buildFanDetailRows'); $buildDetailRows->setAccessible(true); $detailRows = $buildDetailRows->invoke(null, [ ['add_event_id' => 10, 'user_id' => 'staff-a', 'external_userid' => 'shared', 'add_time' => 100, 'is_deleted' => false], ['add_event_id' => 11, 'user_id' => 'staff-a', 'external_userid' => 'shared', 'add_time' => 200, 'is_deleted' => false], ['add_event_id' => 12, 'user_id' => 'staff-b', 'external_userid' => 'shared', 'add_time' => 300, 'is_deleted' => true], ]); conversionFanDetailExpect( count($detailRows) === 2 && (int) ($detailRows[0]['add_time'] ?? 0) === 100 && empty($detailRows[0]['is_deleted']) && (string) ($detailRows[1]['user_id'] ?? '') === 'staff-b' && !empty($detailRows[1]['is_deleted']), 'Details must deduplicate the same employee/customer while retaining the same customer under another employee' ); conversionFanDetailExpect( str_contains($conversionSource, 'self::loadFanDetailRows($startTimestamp, $endTimestamp, $mediaChannel, $adminIds)') && str_contains($conversionSource, 'e.id AS add_event_id') && str_contains($conversionSource, "->where('e.event_time', 'between', [\$startTimestamp, \$endTimestamp])") && str_contains($conversionSource, 'EXISTS (SELECT 1 FROM `') && str_contains($conversionSource, 'MAX(event_time) AS delete_time'), 'Aggregate and detail results must share the same employee/customer loader and expose event times' ); conversionFanDetailExpect( str_contains($conversionSource, "'external_userid' => \$externalUserId") && str_contains($conversionSource, "'customer_name'") && str_contains($conversionSource, "'wecom_staff_name'") && str_contains($conversionSource, "'is_deleted'") && str_contains($conversionSource, "'delete_time'"), 'Fan detail response must expose customer, employee, add and deletion fields' ); $firstVisitSource = file_get_contents($firstVisitReflection->getFileName()); conversionFanDetailExpect( is_string($firstVisitSource) && !str_contains($firstVisitSource, '$dashboard = self::overview($params, $adminId, $adminInfo);') && str_contains($firstVisitSource, 'resolveFanDetailEntity(') && str_contains($firstVisitSource, 'resolveFanDetailMemberDeptId(') && str_contains($firstVisitSource, "['channel_dept_ids']") && str_contains($firstVisitSource, "preg_match('/^M([1-9]\\d*)_(-?\\d+)$/', \$entityId") && str_contains($firstVisitSource, "MediaChannelService::getCurrentTagChannelByCode"), 'Fan detail must authorize the exact structural entity without recomputing overview and retain current-tag channel scope' ); conversionFanDetailExpect( str_contains($conversionSource, '$targetWorkWechatUserIds') && str_contains($conversionSource, 'fanDetailChannelDeptIds($mediaChannel)') && str_contains($conversionSource, 'filterEntitiesByChannelDeptScope(') && str_contains($conversionSource, 'NOT EXISTS (SELECT 1 FROM `') && str_contains($conversionSource, "':wecom:'") && str_contains($conversionSource, '$matched = $pairs;'), 'Fan detail must narrow the event query to the clicked employees/WeCom user before sorting and pagination' ); $controllerSource = file_get_contents(dirname(__DIR__) . '/app/adminapi/controller/firstvisit/ConversionController.php'); conversionFanDetailExpect( is_string($controllerSource) && str_contains($controllerSource, 'public function fansDetail()') && str_contains($controllerSource, 'FirstVisitConversionLogic::fansDetail('), 'First-visit conversion controller must expose the fansDetail endpoint' ); echo "FirstVisitConversionFanDetailTest passed\n";