setAccessible(true); return $method; } $buildFanCountRows = conversionPrivateMethod('buildFanCountRows'); $fanRows = $buildFanCountRows->invoke(null, [ ['user_id' => 'alice', 'external_userid' => 'live'], ['user_id' => 'alice', 'external_userid' => 'deleted'], ['user_id' => 'alice', 'external_userid' => 'deleted'], // distinct pair only ['user_id' => 'bob', 'external_userid' => 'deleted-only'], ['user_id' => 'carol', 'external_userid' => 'delete-readded'], ], [ ['user_id' => 'alice', 'external_userid' => 'live'], // A candidate pair that survived a delete/re-add cycle remains effective and is not deleted. ['user_id' => 'carol', 'external_userid' => 'delete-readded'], ['user_id' => 'nobody', 'external_userid' => 'not-a-candidate'], ]); $fanRowsByUser = []; foreach ($fanRows as $row) { $fanRowsByUser[(string) $row['user_id']] = $row; } deletedFansExpect( ($fanRowsByUser['alice']['add_fans_count'] ?? null) === 2 && ($fanRowsByUser['alice']['deleted_fans_count'] ?? null) === 1, 'All distinct candidate pairs must count as add fans, with deleted fans retained as a subset' ); deletedFansExpect( ($fanRowsByUser['bob']['add_fans_count'] ?? null) === 1 && ($fanRowsByUser['bob']['deleted_fans_count'] ?? null) === 1, 'A deleted-only candidate must count once in add fans and once in its deleted subset' ); deletedFansExpect( ($fanRowsByUser['carol']['add_fans_count'] ?? null) === 1 && ($fanRowsByUser['carol']['deleted_fans_count'] ?? null) === 0, 'A delete/re-add pair that remains effective at period end must not count as deleted' ); deletedFansExpect( !isset($fanRowsByUser['nobody']), 'Effective rows outside the source-filtered candidate set must not be counted' ); $newEntityRow = conversionPrivateMethod('newEntityRow'); $parent = $newEntityRow->invoke(null, 10, 'Parent'); $parent['pid'] = 0; $parent['sort'] = 0; $parent['add_fans_count'] = 1; $parent['deleted_fans_count'] = 1; $child = $newEntityRow->invoke(null, 11, 'Child'); $child['pid'] = 10; $child['sort'] = 0; $child['add_fans_count'] = 2; $child['deleted_fans_count'] = 2; $buildDeptTreeRows = conversionPrivateMethod('buildDeptTreeRows'); [$allRows] = $buildDeptTreeRows->invoke(null, [10 => $parent, 11 => $child], 0, 1, 15); deletedFansExpect( ($allRows[0]['add_fans_count'] ?? null) === 3 && ($allRows[0]['deleted_fans_count'] ?? null) === 3 && ($allRows[0]['children'][0]['deleted_fans_count'] ?? null) === 2, 'Department parents must aggregate add fans and their deleted subset without cross-adding them' ); $buildSummary = conversionPrivateMethod('buildSummary'); $summary = $buildSummary->invoke(null, $allRows, 'dept'); deletedFansExpect( ($summary['deleted_fans_count'] ?? null) === 3, 'Summary must expose the deleted_fans_count total' ); deletedFansExpect( ($buildSummary->invoke(null, [], 'dept')['deleted_fans_count'] ?? null) === 0, 'Empty summaries must expose deleted_fans_count as zero' ); $rateRow = $newEntityRow->invoke(null, 12, 'Rate sample'); $rateRow['add_fans_count'] = 25; $rateRow['deleted_fans_count'] = 2; $rateRow['paid_appointment_count'] = 5; $rateRow['account_cost'] = 100.0; $rateSummary = $buildSummary->invoke(null, [$rateRow], 'admin'); deletedFansExpect( ($rateSummary['add_fans_count'] ?? null) === 25 && ($rateSummary['deleted_fans_count'] ?? null) === 2 && ($rateSummary['paid_appointment_rate'] ?? null) === 20.0 && ($rateSummary['cash_cost'] ?? null) === 4.0, 'Rates and per-fan costs must use the inclusive add_fans_count without adding deleted fans twice' ); $buildEmptyMemberRow = conversionPrivateMethod('buildEmptyMemberRow'); $emptyMember = $buildEmptyMemberRow->invoke(null, 'U_test', 'Test', 'unbound', 'Unbound'); deletedFansExpect( array_key_exists('deleted_fans_count', $emptyMember) && $emptyMember['deleted_fans_count'] === 0, 'Virtual member rows must always expose deleted_fans_count' ); $memberEntity = $newEntityRow->invoke(null, 20, 'Member'); $memberEntity['deleted_fans_count'] = 4; $finalizeAdminMemberRow = conversionPrivateMethod('finalizeAdminMemberRow'); $member = $finalizeAdminMemberRow->invoke(null, $memberEntity, 0.0, 0, false, [], [], 'Assistant', false, 10); deletedFansExpect( ($member['deleted_fans_count'] ?? null) === 4, 'Real member rows must expose deleted_fans_count' ); $hasBusinessMetrics = conversionPrivateMethod('hasBusinessMetrics'); $deletedSubsetEntity = $newEntityRow->invoke(null, 30, 'Deleted subset'); $deletedSubsetEntity['add_fans_count'] = 1; $deletedSubsetEntity['deleted_fans_count'] = 1; deletedFansExpect( $hasBusinessMetrics->invoke(null, $deletedSubsetEntity) === true, 'An add-fan row with a deleted subset must not be pruned as empty' ); $finalizeRows = conversionPrivateMethod('finalizeRows'); $personalRows = $finalizeRows->invoke(null, [30 => $deletedSubsetEntity], true); deletedFansExpect( ($personalRows[0]['add_fans_count'] ?? null) === 1 && ($personalRows[0]['deleted_fans_count'] ?? null) === 1, 'Personal rows must retain deleted_fans_count as an add_fans_count subset' ); $logicSource = file_get_contents((new ReflectionClass(ConversionLogic::class))->getFileName()); deletedFansExpect(is_string($logicSource), 'Unable to read ConversionLogic source'); deletedFansExpect( str_contains($logicSource, "['add_fans_count', 'deleted_fans_count', 'total_open_count'") && str_contains($logicSource, "VIRTUAL_DEPT_UNBOUND_ADMIN_ID]['deleted_fans_count']") && str_contains($logicSource, "VIRTUAL_DEPT_UNASSIGNED_ID]['deleted_fans_count']"), 'Dual-role merging and virtual department buckets must propagate deleted_fans_count' ); deletedFansExpect( str_contains($logicSource, 'applyHistoricalExternalUserChannelFilter') && str_contains($logicSource, 'surviving_e.event_time >= ?') && str_contains($logicSource, 'surviving_del.event_time >= surviving_e.event_time'), 'Deleted pairs must use historical channel attribution and exclude pairs with a surviving re-add' ); $pageSource = file_get_contents( dirname(__DIR__, 2) . '/admin/src/views/first_visit/conversion/index.vue' ); deletedFansExpect( is_string($pageSource) && str_contains($pageSource, 'dashboard.summary.deleted_fans_count') && str_contains($pageSource, 'row.deleted_fans_count') && str_contains($pageSource, 'hasDeletedFans'), 'The first-visit conversion page must show deleted-fan markers for the summary and detail rows' ); echo "Conversion deleted fans count: OK\n";