167 lines
6.8 KiB
PHP
167 lines
6.8 KiB
PHP
<?php
|
|
|
|
declare(strict_types=1);
|
|
|
|
use app\adminapi\logic\stats\ConversionLogic;
|
|
|
|
require dirname(__DIR__) . '/vendor/autoload.php';
|
|
|
|
function deletedFansExpect(bool $condition, string $message): void
|
|
{
|
|
if (!$condition) {
|
|
throw new RuntimeException($message);
|
|
}
|
|
}
|
|
|
|
function conversionPrivateMethod(string $name): ReflectionMethod
|
|
{
|
|
$method = new ReflectionMethod(ConversionLogic::class, $name);
|
|
$method->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";
|