更新
This commit is contained in:
@@ -0,0 +1,166 @@
|
||||
<?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";
|
||||
@@ -34,7 +34,7 @@ conversionFanRuleExpect(
|
||||
);
|
||||
conversionFanRuleExpect(
|
||||
str_contains($methodSource, "['del_external_contact', \$endTimestamp]"),
|
||||
'加粉统计必须继续排除区间内已删除客户'
|
||||
'加粉统计必须继续识别区间内已删除客户'
|
||||
);
|
||||
conversionFanRuleExpect(
|
||||
str_contains($methodSource, "['add_external_contact', \$startTimestamp]"),
|
||||
@@ -56,12 +56,26 @@ conversionFanRuleExpect(
|
||||
'取消会话存档条件后仍须排除扫一扫、搜手机号、名片分享及继承/分配客户'
|
||||
);
|
||||
|
||||
$buildCountMethod = new ReflectionMethod(ConversionLogic::class, 'buildFanCountRows');
|
||||
$buildCountSource = implode('', array_slice(
|
||||
$sourceLines,
|
||||
$buildCountMethod->getStartLine() - 1,
|
||||
$buildCountMethod->getEndLine() - $buildCountMethod->getStartLine() + 1
|
||||
));
|
||||
conversionFanRuleExpect(
|
||||
str_contains($buildCountSource, "++\$countsByUser[\$userId]['add_fans_count']")
|
||||
&& str_contains($buildCountSource, "++\$countsByUser[\$userId]['deleted_fans_count']"),
|
||||
'区间内已删除客户必须计入加粉总数,并同时计入已删除提示子集'
|
||||
);
|
||||
|
||||
$pageSource = file_get_contents(
|
||||
dirname(__DIR__, 2) . '/admin/src/views/first_visit/conversion/index.vue'
|
||||
);
|
||||
conversionFanRuleExpect(
|
||||
is_string($pageSource) && !str_contains($pageSource, '须会话同意'),
|
||||
'页面口径说明不能继续宣称加粉依赖会话存档同意'
|
||||
is_string($pageSource)
|
||||
&& !str_contains($pageSource, '须会话同意')
|
||||
&& str_contains($pageSource, '区间新增加粉(含已删除)'),
|
||||
'页面口径须说明加粉包含已删除客户,且不能继续宣称依赖会话存档同意'
|
||||
);
|
||||
|
||||
echo "Conversion fan event rule: OK\n";
|
||||
|
||||
@@ -78,4 +78,24 @@ if (str_contains($groupSql, 'follow_users') || str_contains($groupSql, 'LIKE'))
|
||||
throw new RuntimeException('分组渠道仍在扫描 follow_users JSON');
|
||||
}
|
||||
|
||||
$historicalQuery = Db::name('qywx_external_contact_event')->alias('e');
|
||||
MediaChannelService::applyHistoricalExternalUserChannelFilter(
|
||||
$historicalQuery,
|
||||
'e.external_userid',
|
||||
[
|
||||
'source_tag_id' => 'deleted-fan-channel-id',
|
||||
'source_tag_name' => '已删粉丝渠道',
|
||||
]
|
||||
);
|
||||
$historicalSql = (string)$historicalQuery->fetchSql()->select();
|
||||
if (!str_contains($historicalSql, 'historical_channel_contact.external_userid = e.external_userid')) {
|
||||
throw new RuntimeException('已删粉丝渠道未按 external_userid 关联历史客户快照');
|
||||
}
|
||||
if (!str_contains($historicalSql, 'historical_channel_contact.follow_users LIKE')) {
|
||||
throw new RuntimeException('已删粉丝渠道未使用保留的 follow_users 快照');
|
||||
}
|
||||
if (str_contains($historicalSql, 'historical_channel_contact.delete_time IS NULL')) {
|
||||
throw new RuntimeException('已删粉丝渠道错误排除了软删客户');
|
||||
}
|
||||
|
||||
echo "MEDIA_CHANNEL_EXTERNAL_USER_FILTER_OK\n";
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
declare(strict_types=1);
|
||||
|
||||
use app\adminapi\logic\firstvisit\WecomAcquisitionCustomerLogic;
|
||||
use app\common\service\DataScope\DataScopeService;
|
||||
use app\common\service\qywx\QywxCustomerAcquisitionApiService;
|
||||
use app\common\service\qywx\QywxCustomerAcquisitionCustomerService;
|
||||
use think\facade\Db;
|
||||
@@ -34,6 +35,15 @@ $linkId = '__smoke_link_' . $suffix;
|
||||
$externalUserId = '__smoke_external_' . $suffix;
|
||||
$userId = '__smoke_user_' . $suffix;
|
||||
$eventTime = time();
|
||||
$scopedAdmin = null;
|
||||
foreach (Db::name('admin')->where('root', 0)->whereNull('delete_time')->select()->toArray() as $candidate) {
|
||||
$candidateVisibleIds = DataScopeService::getVisibleAdminIds((int) $candidate['id'], $candidate);
|
||||
if (is_array($candidateVisibleIds) && $candidateVisibleIds !== []) {
|
||||
$scopedAdmin = $candidate;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
$api = new class($linkId, $externalUserId, $userId) extends QywxCustomerAcquisitionApiService {
|
||||
public function __construct(
|
||||
private string $testLinkId,
|
||||
@@ -49,7 +59,7 @@ $api = new class($linkId, $externalUserId, $userId) extends QywxCustomerAcquisit
|
||||
'external_userid' => $this->testExternalUserId,
|
||||
'userid' => $this->testUserId,
|
||||
'chat_status' => 2,
|
||||
'state' => 'smoke-sync',
|
||||
'state' => '',
|
||||
]],
|
||||
'next_cursor' => '',
|
||||
];
|
||||
@@ -72,6 +82,21 @@ $service = new QywxCustomerAcquisitionCustomerService($api);
|
||||
|
||||
Db::startTrans();
|
||||
try {
|
||||
$linkOwnerId = (int) ($scopedAdmin['id'] ?? $admin['id']);
|
||||
$localLinkId = (int) Db::name('qywx_promotion_link')->insertGetId([
|
||||
'pool_id' => 0,
|
||||
'account_id' => 0,
|
||||
'name' => '空来源统计冒烟链接',
|
||||
'group_name' => '测试',
|
||||
'wecom_url' => 'https://work.weixin.qq.com/ca/smoke',
|
||||
'remote_link_id' => $linkId,
|
||||
'remote_status' => 1,
|
||||
'owner_admin_id' => $linkOwnerId,
|
||||
'dept_id' => 0,
|
||||
'create_time' => $eventTime,
|
||||
'update_time' => $eventTime,
|
||||
]);
|
||||
|
||||
$startMessage = [
|
||||
'MsgId' => 'smoke-start-' . $suffix,
|
||||
'ChangeType' => 'customer_start_chat',
|
||||
@@ -104,6 +129,9 @@ try {
|
||||
$assert((int) ($customer['chat_status'] ?? -1) === 1, '列表同步错误回退了已确认的聊天状态');
|
||||
$assert((int) ($customer['recv_msg_cnt'] ?? -1) === 5, '累计接收消息数不正确或被重复累加');
|
||||
$assert((int) ($customer['message_count_known'] ?? 0) === 1, '精确消息次数标识未保存');
|
||||
$assert((string) ($customer['state'] ?? 'missing') === '', '企业微信返回空来源时客户未按原样保存');
|
||||
$assert((int) ($customer['promotion_link_id'] ?? 0) === $localLinkId, '客户未关联本地获客链接');
|
||||
$assert((int) ($customer['owner_admin_id'] ?? -1) === 0, '冒烟客户应保持为未匹配成员');
|
||||
|
||||
$stats = WecomAcquisitionCustomerLogic::statistics(
|
||||
['keyword' => $externalUserId, 'page_size' => 20],
|
||||
@@ -119,6 +147,19 @@ try {
|
||||
$assert(!array_key_exists('external_userid', $row), '接口不应返回原始客户 ExternalUserID');
|
||||
$assert(str_contains((string) ($row['external_userid_masked'] ?? ''), '*'), '客户标识没有脱敏');
|
||||
|
||||
if ($scopedAdmin !== null) {
|
||||
$scopedStats = WecomAcquisitionCustomerLogic::statistics(
|
||||
['keyword' => $externalUserId, 'page_size' => 20],
|
||||
(int) $scopedAdmin['id'],
|
||||
$scopedAdmin
|
||||
);
|
||||
$assert(
|
||||
(int) ($scopedStats['summary']['customer_count'] ?? 0) === 1,
|
||||
'空来源且成员未匹配时,链接归属范围内仍应显示企微返回的客户'
|
||||
);
|
||||
$assert(count($scopedStats['lists'] ?? []) === 1, '空来源客户未进入有限权限账号的统计列表');
|
||||
}
|
||||
|
||||
$eventKeys = [
|
||||
QywxCustomerAcquisitionCustomerService::eventKey($startMessage, 'customer_start_chat', '', $eventTime),
|
||||
QywxCustomerAcquisitionCustomerService::eventKey(
|
||||
|
||||
Reference in New Issue
Block a user