更新
This commit is contained in:
@@ -27,6 +27,21 @@ class ConversionController extends BaseAdminController
|
||||
));
|
||||
}
|
||||
|
||||
public function fansDetail()
|
||||
{
|
||||
if (!$this->hasPagePermission()) {
|
||||
return $this->fail('权限不足,无法查看加粉明细');
|
||||
}
|
||||
|
||||
@set_time_limit(120);
|
||||
|
||||
return $this->data(FirstVisitConversionLogic::fansDetail(
|
||||
$this->request->get(),
|
||||
$this->adminId,
|
||||
$this->adminInfo
|
||||
));
|
||||
}
|
||||
|
||||
private function hasPagePermission(): bool
|
||||
{
|
||||
if ((int) ($this->adminInfo['root'] ?? 0) === 1) {
|
||||
|
||||
@@ -31,49 +31,22 @@ class FirstVisitConversionLogic
|
||||
/** @return array<string,mixed> */
|
||||
public static function overview(array $params, int $adminId, array $adminInfo): array
|
||||
{
|
||||
[$startDate, $endDate, $timeType, $timeLabel] = self::resolveTimeRange($params);
|
||||
$baseVisibleAdminIds = DataScopeService::getVisibleAdminIds($adminId, $adminInfo);
|
||||
$allowedDeptSet = DataScopeService::getAllowedDeptIdSet($adminId, $adminInfo);
|
||||
$scopeValue = DataScopeService::getEffectiveScope($adminInfo);
|
||||
$selectedDeptId = max(0, (int) ($params['dept_id'] ?? 0));
|
||||
$selectedAssistantId = max(0, (int) ($params['assistant_id'] ?? 0));
|
||||
$requestedMediaChannelCode = trim((string)($params['media_channel_code'] ?? ''));
|
||||
$selectedMediaChannel = $requestedMediaChannelCode !== ''
|
||||
? MediaChannelService::getCurrentTagChannelByCode($requestedMediaChannelCode)
|
||||
: null;
|
||||
$selectedMediaChannelCode = $selectedMediaChannel !== null ? $requestedMediaChannelCode : '';
|
||||
|
||||
$deptSelectionValid = $selectedDeptId <= 0
|
||||
|| $allowedDeptSet === null
|
||||
|| isset($allowedDeptSet[$selectedDeptId]);
|
||||
$selectedDeptIds = [];
|
||||
if ($selectedDeptId > 0 && $deptSelectionValid) {
|
||||
$selectedDeptIds = array_values(array_unique(array_filter(array_map(
|
||||
'intval',
|
||||
DeptLogic::getSelfAndDescendantIds($selectedDeptId)
|
||||
), static fn (int $id): bool => $id > 0)));
|
||||
if ($allowedDeptSet !== null) {
|
||||
$selectedDeptIds = array_values(array_filter(
|
||||
$selectedDeptIds,
|
||||
static fn (int $id): bool => isset($allowedDeptSet[$id])
|
||||
));
|
||||
}
|
||||
}
|
||||
|
||||
$effectiveAdminIds = $deptSelectionValid ? $baseVisibleAdminIds : [];
|
||||
if ($selectedDeptId > 0 && $deptSelectionValid) {
|
||||
$deptAdminIds = $selectedDeptIds === []
|
||||
? []
|
||||
: self::normalizeIds(AdminDept::whereIn('dept_id', $selectedDeptIds)->column('admin_id'));
|
||||
$effectiveAdminIds = self::intersectVisibleIds($effectiveAdminIds, $deptAdminIds);
|
||||
}
|
||||
|
||||
$selectedAssistantValid = $selectedAssistantId <= 0;
|
||||
if ($selectedAssistantId > 0) {
|
||||
$selectedAssistantValid = self::isActiveAssistant($selectedAssistantId)
|
||||
&& ($effectiveAdminIds === null || in_array($selectedAssistantId, $effectiveAdminIds, true));
|
||||
$effectiveAdminIds = $selectedAssistantValid ? [$selectedAssistantId] : [];
|
||||
}
|
||||
$context = self::resolveScopeContext($params, $adminId, $adminInfo);
|
||||
$startDate = $context['start_date'];
|
||||
$endDate = $context['end_date'];
|
||||
$timeType = $context['time_type'];
|
||||
$timeLabel = $context['time_label'];
|
||||
$allowedDeptSet = $context['allowed_dept_set'];
|
||||
$baseVisibleAdminIds = $context['base_visible_admin_ids'];
|
||||
$scopeValue = $context['scope_value'];
|
||||
$selectedDeptId = $context['selected_dept_id'];
|
||||
$selectedAssistantId = $context['selected_assistant_id'];
|
||||
$selectedMediaChannel = $context['selected_media_channel'];
|
||||
$selectedMediaChannelCode = $context['selected_media_channel_code'];
|
||||
$deptSelectionValid = $context['dept_selection_valid'];
|
||||
$selectedDeptIds = $context['selected_dept_ids'];
|
||||
$effectiveAdminIds = $context['effective_admin_ids'];
|
||||
$selectedAssistantValid = $context['selected_assistant_valid'];
|
||||
$costAllocationAdminIds = self::costAllocationAdminIds(
|
||||
$effectiveAdminIds,
|
||||
$scopeValue === DataScopeService::SCOPE_SELF || $selectedAssistantId > 0
|
||||
@@ -208,6 +181,337 @@ class FirstVisitConversionLogic
|
||||
];
|
||||
}
|
||||
|
||||
/** @return array<string,mixed> */
|
||||
public static function fansDetail(array $params, int $adminId, array $adminInfo): array
|
||||
{
|
||||
$context = self::resolveScopeContext($params, $adminId, $adminInfo);
|
||||
$context['channel_dept_ids'] = ConversionLogic::fanDetailChannelDeptIds(
|
||||
$context['selected_media_channel']
|
||||
);
|
||||
$pageNo = max(1, (int) ($params['page_no'] ?? 1));
|
||||
$pageSize = max(1, min(100, (int) ($params['page_size'] ?? 20)));
|
||||
$empty = [
|
||||
'lists' => [],
|
||||
'count' => 0,
|
||||
'page_no' => $pageNo,
|
||||
'page_size' => $pageSize,
|
||||
'date_range' => [$context['start_date'], $context['end_date']],
|
||||
'entity' => null,
|
||||
];
|
||||
|
||||
$entityType = strtolower(trim((string) ($params['entity_type'] ?? '')));
|
||||
if (!in_array($entityType, ['dept', 'member'], true)) {
|
||||
return $empty;
|
||||
}
|
||||
|
||||
$entityId = trim((string) ($params['entity_id'] ?? ''));
|
||||
$requestedAdminId = max(0, (int) ($params['admin_id'] ?? 0));
|
||||
$resolved = self::resolveFanDetailEntity(
|
||||
$entityType,
|
||||
$entityId,
|
||||
$requestedAdminId,
|
||||
$context
|
||||
);
|
||||
if ($resolved === null) {
|
||||
return $empty;
|
||||
}
|
||||
$target = $resolved['target'];
|
||||
|
||||
$conversionParams = [
|
||||
'dimension' => 'dept',
|
||||
'time_type' => 'custom',
|
||||
'start_date' => $context['start_date'],
|
||||
'end_date' => $context['end_date'],
|
||||
'page_no' => $pageNo,
|
||||
'page_size' => $pageSize,
|
||||
];
|
||||
if ((int) $context['selected_dept_id'] > 0 && !empty($context['dept_selection_valid'])) {
|
||||
$conversionParams['dept_id'] = (int) $context['selected_dept_id'];
|
||||
}
|
||||
if ((string) $context['selected_media_channel_code'] !== '') {
|
||||
$conversionParams['media_channel_code'] = (string) $context['selected_media_channel_code'];
|
||||
}
|
||||
|
||||
$result = ConversionLogic::fanDetails(
|
||||
$conversionParams,
|
||||
$target,
|
||||
$adminId,
|
||||
$adminInfo,
|
||||
$context['effective_admin_ids'],
|
||||
$context['selected_media_channel']
|
||||
);
|
||||
if ((int) ($result['count'] ?? 0) <= 0) {
|
||||
return $empty;
|
||||
}
|
||||
$result['entity'] = [
|
||||
'type' => $entityType,
|
||||
'id' => $resolved['id'],
|
||||
'admin_id' => (int) $resolved['admin_id'],
|
||||
'name' => (string) $resolved['name'],
|
||||
'add_fans_count' => (int) $result['count'],
|
||||
'deleted_fans_count' => (int) ($result['deleted_count'] ?? 0),
|
||||
];
|
||||
unset($result['deleted_count']);
|
||||
|
||||
return $result;
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve only the clicked entity and its authorized target range. This is
|
||||
* deliberately structural: it avoids recomputing all overview metrics,
|
||||
* while still rejecting ids that do not exist in the current DataScope.
|
||||
*
|
||||
* @param array<string,mixed> $context
|
||||
* @return array{id:string|int,admin_id:int,name:string,target:array<string,mixed>}|null
|
||||
*/
|
||||
private static function resolveFanDetailEntity(
|
||||
string $entityType,
|
||||
string $entityId,
|
||||
int $requestedAdminId,
|
||||
array $context
|
||||
): ?array {
|
||||
$visibleAdminIds = $context['effective_admin_ids'];
|
||||
$selectedDeptId = (int) $context['selected_dept_id'];
|
||||
$channelDeptIds = $context['channel_dept_ids'] ?? null;
|
||||
|
||||
if ($entityType === 'member' && str_starts_with($entityId, 'U_')) {
|
||||
$wecomUserId = trim(substr($entityId, 2));
|
||||
if ($wecomUserId === '' || $requestedAdminId > 0 || $visibleAdminIds !== null || $selectedDeptId > 0) {
|
||||
return null;
|
||||
}
|
||||
$isBound = Db::name('admin')
|
||||
->whereNull('delete_time')
|
||||
->where('work_wechat_userid', $wecomUserId)
|
||||
->count() > 0;
|
||||
if ($isBound) {
|
||||
return null;
|
||||
}
|
||||
$historicalName = trim((string) (Db::name('admin')
|
||||
->where('work_wechat_userid', $wecomUserId)
|
||||
->order('id', 'desc')
|
||||
->value('name') ?? ''));
|
||||
|
||||
return [
|
||||
'id' => $entityId,
|
||||
'admin_id' => 0,
|
||||
'name' => $historicalName !== ''
|
||||
? $historicalName . '(' . $wecomUserId . ')'
|
||||
: $wecomUserId,
|
||||
'target' => ['type' => 'wecom_user', 'wecom_userid' => $wecomUserId],
|
||||
];
|
||||
}
|
||||
|
||||
if ($entityType === 'member') {
|
||||
if (!preg_match('/^M([1-9]\d*)_(-?\d+)$/', $entityId, $matches)) {
|
||||
return null;
|
||||
}
|
||||
$targetAdminId = (int) $matches[1];
|
||||
$rowDeptId = (int) $matches[2];
|
||||
if (($requestedAdminId > 0 && $requestedAdminId !== $targetAdminId)
|
||||
|| ($visibleAdminIds !== null && !in_array($targetAdminId, $visibleAdminIds, true))
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
$admin = Db::name('admin')
|
||||
->where('id', $targetAdminId)
|
||||
->whereNull('delete_time')
|
||||
->where('work_wechat_userid', '<>', '')
|
||||
->field('id,name')
|
||||
->find();
|
||||
$resolvedDeptId = self::resolveFanDetailMemberDeptId($targetAdminId, $context);
|
||||
if (!$admin
|
||||
|| $resolvedDeptId !== $rowDeptId
|
||||
|| ($resolvedDeptId === -2 && $selectedDeptId > 0)
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return [
|
||||
'id' => $entityId,
|
||||
'admin_id' => $targetAdminId,
|
||||
'name' => trim((string) ($admin['name'] ?? '')),
|
||||
'target' => ['type' => 'member', 'admin_id' => $targetAdminId],
|
||||
];
|
||||
}
|
||||
|
||||
if ($entityType !== 'dept' || !preg_match('/^-?\d+$/', $entityId)) {
|
||||
return null;
|
||||
}
|
||||
$deptId = (int) $entityId;
|
||||
if (in_array($deptId, [-1, -2], true)) {
|
||||
if ($selectedDeptId > 0 || $visibleAdminIds !== null) {
|
||||
return null;
|
||||
}
|
||||
$name = $deptId === -1 ? '未绑定后台账号' : '未分配部门';
|
||||
|
||||
return [
|
||||
'id' => $deptId,
|
||||
'admin_id' => 0,
|
||||
'name' => $name,
|
||||
'target' => ['type' => 'dept', 'dept_ids' => [$deptId]],
|
||||
];
|
||||
}
|
||||
if ($deptId <= 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$dept = Db::name('dept')->where('id', $deptId)->whereNull('delete_time')->field('id,name')->find();
|
||||
if (!$dept) {
|
||||
return null;
|
||||
}
|
||||
$universeDeptIds = $selectedDeptId > 0
|
||||
? self::normalizeIds(DeptLogic::getSelfAndDescendantIds($selectedDeptId))
|
||||
: self::normalizeIds(Db::name('dept')->whereNull('delete_time')->column('id'));
|
||||
if ($channelDeptIds !== null) {
|
||||
$universeDeptIds = array_values(array_intersect($universeDeptIds, $channelDeptIds));
|
||||
}
|
||||
$rowDeptIds = self::visibleRowDeptIds($visibleAdminIds);
|
||||
if (!in_array($deptId, $universeDeptIds, true)
|
||||
|| ($rowDeptIds !== null && !in_array($deptId, $rowDeptIds, true))
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
$targetDeptIds = array_values(array_intersect(
|
||||
self::normalizeIds(DeptLogic::getSelfAndDescendantIds($deptId)),
|
||||
$universeDeptIds,
|
||||
$rowDeptIds ?? $universeDeptIds
|
||||
));
|
||||
if ($targetDeptIds === []) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return [
|
||||
'id' => $deptId,
|
||||
'admin_id' => 0,
|
||||
'name' => trim((string) ($dept['name'] ?? '')),
|
||||
'target' => ['type' => 'dept', 'dept_ids' => $targetDeptIds],
|
||||
];
|
||||
}
|
||||
|
||||
/** @param array<string,mixed> $context */
|
||||
private static function resolveFanDetailMemberDeptId(int $adminId, array $context): int
|
||||
{
|
||||
// Other roles are rendered by ConversionLogic in the unassigned bucket.
|
||||
$hasMedicalRole = Db::name('admin_role')
|
||||
->where('admin_id', $adminId)
|
||||
->whereIn('role_id', [1, self::ASSISTANT_ROLE_ID])
|
||||
->count() > 0;
|
||||
if (!$hasMedicalRole) {
|
||||
return -2;
|
||||
}
|
||||
|
||||
$selectedDeptId = (int) $context['selected_dept_id'];
|
||||
$validDeptIds = $selectedDeptId > 0
|
||||
? self::normalizeIds(DeptLogic::getSelfAndDescendantIds($selectedDeptId))
|
||||
: self::normalizeIds(Db::name('dept')->whereNull('delete_time')->column('id'));
|
||||
if (($context['channel_dept_ids'] ?? null) !== null) {
|
||||
$validDeptIds = array_values(array_intersect($validDeptIds, $context['channel_dept_ids']));
|
||||
}
|
||||
$validDeptSet = array_fill_keys($validDeptIds, true);
|
||||
$deptRows = Db::name('dept')->whereNull('delete_time')->field('id,pid,sort')->select()->toArray();
|
||||
$deptMeta = [];
|
||||
foreach ($deptRows as $deptRow) {
|
||||
$id = (int) ($deptRow['id'] ?? 0);
|
||||
if ($id > 0) {
|
||||
$deptMeta[$id] = ['pid' => (int) ($deptRow['pid'] ?? 0), 'sort' => (int) ($deptRow['sort'] ?? 0)];
|
||||
}
|
||||
}
|
||||
$depthCache = [];
|
||||
$depthOf = static function (int $id) use (&$depthOf, &$depthCache, $deptMeta): int {
|
||||
if ($id <= 0 || !isset($deptMeta[$id])) {
|
||||
return 0;
|
||||
}
|
||||
if (isset($depthCache[$id])) {
|
||||
return $depthCache[$id];
|
||||
}
|
||||
$pid = (int) ($deptMeta[$id]['pid'] ?? 0);
|
||||
if ($pid <= 0 || $pid === $id || !isset($deptMeta[$pid])) {
|
||||
return $depthCache[$id] = 0;
|
||||
}
|
||||
|
||||
return $depthCache[$id] = $depthOf($pid) + 1;
|
||||
};
|
||||
$adminDeptIds = self::normalizeIds(AdminDept::where('admin_id', $adminId)->column('dept_id'));
|
||||
usort($adminDeptIds, static function (int $left, int $right) use ($depthOf, $deptMeta): int {
|
||||
return ($depthOf($right) <=> $depthOf($left))
|
||||
?: ((int) ($deptMeta[$right]['sort'] ?? 0) <=> (int) ($deptMeta[$left]['sort'] ?? 0))
|
||||
?: ($left <=> $right);
|
||||
});
|
||||
foreach ($adminDeptIds as $adminDeptId) {
|
||||
if (isset($validDeptSet[$adminDeptId])) {
|
||||
return $adminDeptId;
|
||||
}
|
||||
}
|
||||
|
||||
return -2;
|
||||
}
|
||||
|
||||
/** @return array<string,mixed> */
|
||||
private static function resolveScopeContext(array $params, int $adminId, array $adminInfo): array
|
||||
{
|
||||
[$startDate, $endDate, $timeType, $timeLabel] = self::resolveTimeRange($params);
|
||||
$baseVisibleAdminIds = DataScopeService::getVisibleAdminIds($adminId, $adminInfo);
|
||||
$allowedDeptSet = DataScopeService::getAllowedDeptIdSet($adminId, $adminInfo);
|
||||
$scopeValue = DataScopeService::getEffectiveScope($adminInfo);
|
||||
$selectedDeptId = max(0, (int) ($params['dept_id'] ?? 0));
|
||||
$selectedAssistantId = max(0, (int) ($params['assistant_id'] ?? 0));
|
||||
$requestedMediaChannelCode = trim((string) ($params['media_channel_code'] ?? ''));
|
||||
$selectedMediaChannel = $requestedMediaChannelCode !== ''
|
||||
? MediaChannelService::getCurrentTagChannelByCode($requestedMediaChannelCode)
|
||||
: null;
|
||||
$selectedMediaChannelCode = $selectedMediaChannel !== null ? $requestedMediaChannelCode : '';
|
||||
|
||||
$deptSelectionValid = $selectedDeptId <= 0
|
||||
|| $allowedDeptSet === null
|
||||
|| isset($allowedDeptSet[$selectedDeptId]);
|
||||
$selectedDeptIds = [];
|
||||
if ($selectedDeptId > 0 && $deptSelectionValid) {
|
||||
$selectedDeptIds = array_values(array_unique(array_filter(array_map(
|
||||
'intval',
|
||||
DeptLogic::getSelfAndDescendantIds($selectedDeptId)
|
||||
), static fn (int $id): bool => $id > 0)));
|
||||
if ($allowedDeptSet !== null) {
|
||||
$selectedDeptIds = array_values(array_filter(
|
||||
$selectedDeptIds,
|
||||
static fn (int $id): bool => isset($allowedDeptSet[$id])
|
||||
));
|
||||
}
|
||||
}
|
||||
|
||||
$effectiveAdminIds = $deptSelectionValid ? $baseVisibleAdminIds : [];
|
||||
if ($selectedDeptId > 0 && $deptSelectionValid) {
|
||||
$deptAdminIds = $selectedDeptIds === []
|
||||
? []
|
||||
: self::normalizeIds(AdminDept::whereIn('dept_id', $selectedDeptIds)->column('admin_id'));
|
||||
$effectiveAdminIds = self::intersectVisibleIds($effectiveAdminIds, $deptAdminIds);
|
||||
}
|
||||
|
||||
$selectedAssistantValid = $selectedAssistantId <= 0;
|
||||
if ($selectedAssistantId > 0) {
|
||||
$selectedAssistantValid = self::isActiveAssistant($selectedAssistantId)
|
||||
&& ($effectiveAdminIds === null || in_array($selectedAssistantId, $effectiveAdminIds, true));
|
||||
$effectiveAdminIds = $selectedAssistantValid ? [$selectedAssistantId] : [];
|
||||
}
|
||||
|
||||
return [
|
||||
'start_date' => $startDate,
|
||||
'end_date' => $endDate,
|
||||
'time_type' => $timeType,
|
||||
'time_label' => $timeLabel,
|
||||
'base_visible_admin_ids' => $baseVisibleAdminIds,
|
||||
'allowed_dept_set' => $allowedDeptSet,
|
||||
'scope_value' => $scopeValue,
|
||||
'selected_dept_id' => $selectedDeptId,
|
||||
'selected_assistant_id' => $selectedAssistantId,
|
||||
'selected_media_channel' => $selectedMediaChannel,
|
||||
'selected_media_channel_code' => $selectedMediaChannelCode,
|
||||
'dept_selection_valid' => $deptSelectionValid,
|
||||
'selected_dept_ids' => $selectedDeptIds,
|
||||
'effective_admin_ids' => $effectiveAdminIds,
|
||||
'selected_assistant_valid' => $selectedAssistantValid,
|
||||
];
|
||||
}
|
||||
|
||||
/** @return array{0:string,1:string,2:string,3:string} */
|
||||
private static function resolveTimeRange(array $params): array
|
||||
{
|
||||
|
||||
@@ -340,6 +340,304 @@ class ConversionLogic
|
||||
return $result;
|
||||
}
|
||||
|
||||
/**
|
||||
* Return the distinct external contacts behind an add_fans_count row.
|
||||
*
|
||||
* The caller must pass the already-authorized admin range and the exact
|
||||
* department ids represented by the clicked tree node. This keeps the
|
||||
* detail endpoint on the same data-scope and department-primary-mapping
|
||||
* rules as overview(), including descendant departments and virtual
|
||||
* buckets.
|
||||
*
|
||||
* @param array<string,mixed> $params
|
||||
* @param array<string,mixed> $target
|
||||
* @param int[]|null $trustedVisibleAdminIdsOverride
|
||||
* @param array<string,mixed>|null $trustedMediaChannelOverride
|
||||
* @return array<string,mixed>
|
||||
*/
|
||||
public static function fanDetails(
|
||||
array $params,
|
||||
array $target,
|
||||
int $adminId = 0,
|
||||
?array $adminInfo = null,
|
||||
?array $trustedVisibleAdminIdsOverride = null,
|
||||
?array $trustedMediaChannelOverride = null
|
||||
): array {
|
||||
self::$requestRowsCache = [];
|
||||
|
||||
[$startTimestamp, $endTimestamp, $startDate, $endDate] = self::resolveTimeRange($params);
|
||||
$pageNo = max(1, (int) ($params['page_no'] ?? 1));
|
||||
$pageSize = max(1, min(100, (int) ($params['page_size'] ?? 20)));
|
||||
$visibleAdminIds = $trustedVisibleAdminIdsOverride;
|
||||
if ($trustedVisibleAdminIdsOverride === null && $adminInfo !== null && $adminId > 0) {
|
||||
$visibleAdminIds = DataScopeService::getVisibleAdminIds($adminId, $adminInfo);
|
||||
}
|
||||
if ($visibleAdminIds !== null) {
|
||||
$visibleAdminIds = array_values(array_unique(array_filter(
|
||||
array_map('intval', $visibleAdminIds),
|
||||
static fn (int $id): bool => $id > 0
|
||||
)));
|
||||
}
|
||||
|
||||
$empty = [
|
||||
'lists' => [],
|
||||
'count' => 0,
|
||||
'page_no' => $pageNo,
|
||||
'page_size' => $pageSize,
|
||||
'date_range' => [$startDate, $endDate],
|
||||
];
|
||||
if ($visibleAdminIds === []) {
|
||||
return $empty;
|
||||
}
|
||||
|
||||
$mediaChannel = $trustedMediaChannelOverride;
|
||||
$requestedMediaChannelCode = trim((string) ($params['media_channel_code'] ?? ''));
|
||||
if ($mediaChannel === null && $requestedMediaChannelCode !== '') {
|
||||
$mediaChannel = MediaChannelService::getChannelByCode($requestedMediaChannelCode);
|
||||
}
|
||||
|
||||
$targetType = (string) ($target['type'] ?? '');
|
||||
$targetAdminId = max(0, (int) ($target['admin_id'] ?? 0));
|
||||
$targetWecomUserId = trim((string) ($target['wecom_userid'] ?? ''));
|
||||
$targetDeptIds = array_values(array_unique(array_map('intval', (array) ($target['dept_ids'] ?? []))));
|
||||
$targetDeptSet = array_fill_keys($targetDeptIds, true);
|
||||
if (($targetType === 'member' && $targetAdminId <= 0)
|
||||
|| ($targetType === 'wecom_user' && $targetWecomUserId === '')
|
||||
|| ($targetType === 'dept' && $targetDeptSet === [])
|
||||
) {
|
||||
return $empty;
|
||||
}
|
||||
|
||||
$allEntities = [];
|
||||
$entityIds = [];
|
||||
$adminToDeptIds = [];
|
||||
$targetWorkWechatUserIds = [];
|
||||
$unboundOnly = false;
|
||||
if ($targetType === 'dept') {
|
||||
$allEntities = self::loadEntities('dept', $params, $visibleAdminIds);
|
||||
$adminToDeptIds = self::loadAdminDeptMap();
|
||||
$channelDeptIds = self::fanDetailChannelDeptIds($mediaChannel);
|
||||
if ($channelDeptIds !== null) {
|
||||
$allEntities = self::filterEntitiesByChannelDeptScope(
|
||||
$allEntities,
|
||||
'dept',
|
||||
$channelDeptIds,
|
||||
$adminToDeptIds
|
||||
);
|
||||
}
|
||||
$entityIds = array_keys($allEntities);
|
||||
foreach ($targetDeptIds as $targetDeptId) {
|
||||
if (!isset($allEntities[$targetDeptId])) {
|
||||
return $empty;
|
||||
}
|
||||
}
|
||||
|
||||
if (isset($targetDeptSet[self::VIRTUAL_DEPT_UNBOUND_ADMIN_ID])) {
|
||||
// The unbound bucket exists only under SCOPE_ALL. Keep the
|
||||
// exclusion in SQL so this bucket does not first load every
|
||||
// fan pair in the installation.
|
||||
if ($visibleAdminIds !== null || count($targetDeptSet) !== 1) {
|
||||
return $empty;
|
||||
}
|
||||
$unboundOnly = true;
|
||||
} else {
|
||||
$candidateAdminQuery = Db::name('admin')
|
||||
->whereNull('delete_time')
|
||||
->where('work_wechat_userid', '<>', '')
|
||||
->field('id,work_wechat_userid');
|
||||
if ($visibleAdminIds !== null) {
|
||||
$candidateAdminQuery->whereIn('id', $visibleAdminIds);
|
||||
}
|
||||
foreach ($candidateAdminQuery->select()->toArray() as $candidateAdmin) {
|
||||
$candidateAdminId = (int) ($candidateAdmin['id'] ?? 0);
|
||||
$mappedIds = self::mapEntityIds(
|
||||
'dept',
|
||||
$candidateAdminId,
|
||||
$entityIds,
|
||||
$adminToDeptIds,
|
||||
$visibleAdminIds
|
||||
);
|
||||
$mappedDeptId = $mappedIds !== []
|
||||
? (int) $mappedIds[0]
|
||||
: self::VIRTUAL_DEPT_UNASSIGNED_ID;
|
||||
if (isset($targetDeptSet[$mappedDeptId])) {
|
||||
$workWechatUserId = trim((string) ($candidateAdmin['work_wechat_userid'] ?? ''));
|
||||
if ($workWechatUserId !== '') {
|
||||
$targetWorkWechatUserIds[$workWechatUserId] = $workWechatUserId;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
} elseif ($targetType === 'member') {
|
||||
if ($visibleAdminIds !== null && !in_array($targetAdminId, $visibleAdminIds, true)) {
|
||||
return $empty;
|
||||
}
|
||||
$targetAdmin = Db::name('admin')
|
||||
->where('id', $targetAdminId)
|
||||
->whereNull('delete_time')
|
||||
->where('work_wechat_userid', '<>', '')
|
||||
->field('id,work_wechat_userid')
|
||||
->find();
|
||||
if (!$targetAdmin) {
|
||||
return $empty;
|
||||
}
|
||||
$targetWorkWechatUserIds[] = trim((string) $targetAdmin['work_wechat_userid']);
|
||||
} else {
|
||||
// Unbound WeCom rows are never visible in a restricted scope.
|
||||
if ($visibleAdminIds !== null) {
|
||||
return $empty;
|
||||
}
|
||||
$boundAdminExists = Db::name('admin')
|
||||
->whereNull('delete_time')
|
||||
->where('work_wechat_userid', $targetWecomUserId)
|
||||
->count() > 0;
|
||||
if ($boundAdminExists) {
|
||||
return $empty;
|
||||
}
|
||||
$targetWorkWechatUserIds[] = $targetWecomUserId;
|
||||
}
|
||||
|
||||
$targetWorkWechatUserIds = array_values(array_unique(array_filter(
|
||||
array_map('strval', $targetWorkWechatUserIds),
|
||||
static fn (string $userId): bool => $userId !== ''
|
||||
)));
|
||||
if (!$unboundOnly && $targetWorkWechatUserIds === []) {
|
||||
return $empty;
|
||||
}
|
||||
|
||||
$pairs = self::loadFanDetailRows(
|
||||
$startTimestamp,
|
||||
$endTimestamp,
|
||||
$mediaChannel,
|
||||
null,
|
||||
$unboundOnly ? null : $targetWorkWechatUserIds,
|
||||
$unboundOnly
|
||||
);
|
||||
// Target membership has already been resolved before the event query,
|
||||
// so sorting and pagination operate on the clicked row's small set.
|
||||
$matched = $pairs;
|
||||
|
||||
usort($matched, static function (array $left, array $right): int {
|
||||
$timeCompare = ((int) ($right['add_time'] ?? 0)) <=> ((int) ($left['add_time'] ?? 0));
|
||||
if ($timeCompare !== 0) {
|
||||
return $timeCompare;
|
||||
}
|
||||
|
||||
return strcmp(
|
||||
(string) ($left['external_userid'] ?? ''),
|
||||
(string) ($right['external_userid'] ?? '')
|
||||
) ?: strcmp(
|
||||
(string) ($left['user_id'] ?? ''),
|
||||
(string) ($right['user_id'] ?? '')
|
||||
);
|
||||
});
|
||||
|
||||
$count = count($matched);
|
||||
$deletedCount = count(array_filter(
|
||||
$matched,
|
||||
static fn (array $row): bool => !empty($row['is_deleted'])
|
||||
));
|
||||
$offset = ($pageNo - 1) * $pageSize;
|
||||
$pageRows = array_slice($matched, $offset, $pageSize);
|
||||
$externalUserIds = array_values(array_unique(array_filter(array_map(
|
||||
static fn (array $row): string => trim((string) ($row['external_userid'] ?? '')),
|
||||
$pageRows
|
||||
))));
|
||||
$customerNames = $externalUserIds === []
|
||||
? []
|
||||
: Db::name('qywx_external_contact')
|
||||
->whereIn('external_userid', $externalUserIds)
|
||||
->column('name', 'external_userid');
|
||||
$deletedPageRows = array_values(array_filter(
|
||||
$pageRows,
|
||||
static fn (array $row): bool => !empty($row['is_deleted'])
|
||||
));
|
||||
$deleteTimesByPair = [];
|
||||
if ($deletedPageRows !== []) {
|
||||
$deletedUserIds = array_values(array_unique(array_filter(array_map(
|
||||
static fn (array $row): string => trim((string) ($row['user_id'] ?? '')),
|
||||
$deletedPageRows
|
||||
))));
|
||||
$deletedExternalUserIds = array_values(array_unique(array_filter(array_map(
|
||||
static fn (array $row): string => trim((string) ($row['external_userid'] ?? '')),
|
||||
$deletedPageRows
|
||||
))));
|
||||
$deletedEvents = Db::name('qywx_external_contact_event')
|
||||
->where('change_type', 'del_external_contact')
|
||||
->where('event_time', '<=', $endTimestamp)
|
||||
->whereIn('user_id', $deletedUserIds)
|
||||
->whereIn('external_userid', $deletedExternalUserIds)
|
||||
->field('user_id,external_userid,MAX(event_time) AS delete_time')
|
||||
->group('user_id,external_userid')
|
||||
->select()
|
||||
->toArray();
|
||||
foreach ($deletedEvents as $deletedEvent) {
|
||||
$key = trim((string) ($deletedEvent['user_id'] ?? ''))
|
||||
. "\0"
|
||||
. trim((string) ($deletedEvent['external_userid'] ?? ''));
|
||||
$deleteTimesByPair[$key] = max(0, (int) ($deletedEvent['delete_time'] ?? 0));
|
||||
}
|
||||
}
|
||||
$staffNames = self::resolveQywxUserNames(array_values(array_unique(array_filter(array_map(
|
||||
static fn (array $row): string => trim((string) ($row['user_id'] ?? '')),
|
||||
$pageRows
|
||||
)))));
|
||||
|
||||
$lists = array_map(static function (array $row) use ($customerNames, $staffNames, $deleteTimesByPair): array {
|
||||
$externalUserId = trim((string) ($row['external_userid'] ?? ''));
|
||||
$wecomUserId = trim((string) ($row['user_id'] ?? ''));
|
||||
$addTime = max(0, (int) ($row['add_time'] ?? 0));
|
||||
$deleted = !empty($row['is_deleted']);
|
||||
$deleteTime = $deleted
|
||||
? max(0, (int) ($deleteTimesByPair[$wecomUserId . "\0" . $externalUserId] ?? 0))
|
||||
: 0;
|
||||
if ($deleteTime < $addTime) {
|
||||
$deleteTime = 0;
|
||||
}
|
||||
|
||||
return [
|
||||
'external_userid' => $externalUserId,
|
||||
'customer_name' => trim((string) ($customerNames[$externalUserId] ?? '')),
|
||||
'wecom_userid' => $wecomUserId,
|
||||
'wecom_staff_name' => trim((string) ($staffNames[$wecomUserId] ?? '')),
|
||||
'add_time' => $addTime > 0 ? date('Y-m-d H:i:s', $addTime) : null,
|
||||
'is_deleted' => $deleted,
|
||||
'delete_time' => $deleted && $deleteTime > 0 ? date('Y-m-d H:i:s', $deleteTime) : null,
|
||||
];
|
||||
}, $pageRows);
|
||||
|
||||
return [
|
||||
'lists' => $lists,
|
||||
'count' => $count,
|
||||
'deleted_count' => $deletedCount,
|
||||
'page_no' => $pageNo,
|
||||
'page_size' => $pageSize,
|
||||
'date_range' => [$startDate, $endDate],
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Department scope imposed by an actively maintained media-channel cost
|
||||
* binding. null means the channel has no binding and therefore does not
|
||||
* restrict business statistics.
|
||||
*
|
||||
* @param array<string,mixed>|null $mediaChannel
|
||||
* @return int[]|null
|
||||
*/
|
||||
public static function fanDetailChannelDeptIds(?array $mediaChannel): ?array
|
||||
{
|
||||
if ($mediaChannel === null) {
|
||||
return null;
|
||||
}
|
||||
$mediaChannelCodes = MediaChannelService::getChannelCodesForStats($mediaChannel);
|
||||
if ($mediaChannelCodes === []) {
|
||||
return null;
|
||||
}
|
||||
$boundDeptIds = self::loadChannelBoundDeptIds($mediaChannelCodes);
|
||||
|
||||
return $boundDeptIds === [] ? null : self::expandDeptIdsWithDescendants($boundDeptIds);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param int[]|null $visibleAdminIds 可见 admin 集合(null = SCOPE_ALL)
|
||||
* @param int[] $eligibleDeptIds 当媒体渠道筛选生效时收窄过的部门作用域
|
||||
@@ -987,6 +1285,33 @@ class ConversionLogic
|
||||
int $endTimestamp,
|
||||
?array $mediaChannel,
|
||||
?array $adminIds = null
|
||||
): array {
|
||||
$detailRows = self::loadFanDetailRows($startTimestamp, $endTimestamp, $mediaChannel, $adminIds);
|
||||
$effectivePairs = array_values(array_filter(
|
||||
$detailRows,
|
||||
static fn (array $row): bool => empty($row['is_deleted'])
|
||||
));
|
||||
|
||||
return self::buildFanCountRows($detailRows, $effectivePairs);
|
||||
}
|
||||
|
||||
/**
|
||||
* Load the exact distinct (user_id, external_userid) pairs represented by
|
||||
* add_fans_count. Both aggregate statistics and the detail endpoint consume
|
||||
* this method, so channel, add-way, prior-add and deletion rules cannot
|
||||
* drift apart.
|
||||
*
|
||||
* @param array<string, mixed>|null $mediaChannel
|
||||
* @param int[]|null $adminIds
|
||||
* @return array<int, array{user_id:string,external_userid:string,add_time:int,is_deleted:bool,delete_time:int}>
|
||||
*/
|
||||
private static function loadFanDetailRows(
|
||||
int $startTimestamp,
|
||||
int $endTimestamp,
|
||||
?array $mediaChannel,
|
||||
?array $adminIds = null,
|
||||
?array $exactWorkWechatUserIds = null,
|
||||
bool $unboundOnly = false
|
||||
): array {
|
||||
if ($adminIds !== null) {
|
||||
$adminIds = array_values(array_unique(array_filter(
|
||||
@@ -999,21 +1324,38 @@ class ConversionLogic
|
||||
}
|
||||
}
|
||||
|
||||
$baseKey = self::requestRowsCacheKey('fans-effective-v9', [
|
||||
if ($exactWorkWechatUserIds !== null) {
|
||||
$exactWorkWechatUserIds = array_values(array_unique(array_filter(
|
||||
array_map('strval', $exactWorkWechatUserIds),
|
||||
static fn (string $userId): bool => $userId !== ''
|
||||
)));
|
||||
sort($exactWorkWechatUserIds);
|
||||
if ($exactWorkWechatUserIds === [] && !$unboundOnly) {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
$baseKey = self::requestRowsCacheKey('fans-detail-v1', [
|
||||
$startTimestamp,
|
||||
$endTimestamp,
|
||||
self::mediaChannelCacheKey($mediaChannel),
|
||||
]);
|
||||
$allKey = $baseKey . ':all';
|
||||
$cacheKey = $adminIds === null
|
||||
? $allKey
|
||||
: $baseKey . ':admins:' . implode(',', $adminIds);
|
||||
if ($unboundOnly) {
|
||||
$cacheKey = $baseKey . ':unbound';
|
||||
} elseif ($exactWorkWechatUserIds !== null) {
|
||||
$cacheKey = $baseKey . ':wecom:' . implode(',', $exactWorkWechatUserIds);
|
||||
} else {
|
||||
$cacheKey = $adminIds === null
|
||||
? $allKey
|
||||
: $baseKey . ':admins:' . implode(',', $adminIds);
|
||||
}
|
||||
if (isset(self::$requestRowsCache[$cacheKey])) {
|
||||
return self::$requestRowsCache[$cacheKey];
|
||||
}
|
||||
|
||||
$workWechatUserIds = null;
|
||||
if ($adminIds !== null) {
|
||||
$workWechatUserIds = $exactWorkWechatUserIds;
|
||||
if ($workWechatUserIds === null && $adminIds !== null) {
|
||||
$workWechatUserIds = Db::name('admin')
|
||||
->whereIn('id', $adminIds)
|
||||
->whereNull('delete_time')
|
||||
@@ -1052,10 +1394,17 @@ class ConversionLogic
|
||||
. ' AND prev_e.event_time < ?)',
|
||||
['add_external_contact', $startTimestamp]
|
||||
)
|
||||
->field(['e.user_id', 'e.external_userid'])
|
||||
->field('e.user_id,e.external_userid,MIN(e.event_time) AS add_time')
|
||||
->group('e.user_id, e.external_userid');
|
||||
if ($workWechatUserIds !== null) {
|
||||
$query->whereIn('e.user_id', $workWechatUserIds);
|
||||
} elseif ($unboundOnly) {
|
||||
$adminTable = config('database.connections.mysql.prefix') . 'admin';
|
||||
$query->whereRaw(
|
||||
'NOT EXISTS (SELECT 1 FROM `' . $adminTable . '` bound_a'
|
||||
. ' WHERE bound_a.work_wechat_userid = e.user_id'
|
||||
. ' AND bound_a.delete_time IS NULL)'
|
||||
);
|
||||
}
|
||||
$effectiveQuery = clone $query;
|
||||
if ($mediaChannel !== null) {
|
||||
@@ -1064,13 +1413,19 @@ class ConversionLogic
|
||||
}
|
||||
$effectivePairs = $effectiveQuery
|
||||
->whereRaw(
|
||||
'NOT EXISTS (SELECT 1 FROM `' . $eventTable . '` del_e'
|
||||
. ' WHERE del_e.user_id = e.user_id'
|
||||
. ' AND del_e.external_userid = e.external_userid'
|
||||
. ' AND del_e.change_type = ?'
|
||||
. ' AND del_e.event_time >= e.event_time'
|
||||
. ' AND del_e.event_time <= ?)',
|
||||
['del_external_contact', $endTimestamp]
|
||||
'EXISTS (SELECT 1 FROM `' . $eventTable . '` surviving_e'
|
||||
. ' WHERE surviving_e.user_id = e.user_id'
|
||||
. ' AND surviving_e.external_userid = e.external_userid'
|
||||
. ' AND surviving_e.change_type = ?'
|
||||
. ' AND surviving_e.event_time >= ?'
|
||||
. ' AND surviving_e.event_time <= ?'
|
||||
. ' AND NOT EXISTS (SELECT 1 FROM `' . $eventTable . '` surviving_del'
|
||||
. ' WHERE surviving_del.user_id = surviving_e.user_id'
|
||||
. ' AND surviving_del.external_userid = surviving_e.external_userid'
|
||||
. ' AND surviving_del.change_type = ?'
|
||||
. ' AND surviving_del.event_time >= surviving_e.event_time'
|
||||
. ' AND surviving_del.event_time <= ?))',
|
||||
['add_external_contact', $startTimestamp, $endTimestamp, 'del_external_contact', $endTimestamp]
|
||||
)
|
||||
->select()
|
||||
->toArray();
|
||||
@@ -1102,13 +1457,59 @@ class ConversionLogic
|
||||
// add_way 筛选对有效与已删粉丝使用同一口径。
|
||||
$candidatePairs = array_merge($effectivePairs, $deletedPairs);
|
||||
$candidatePairs = self::excludeUncountedFanPairs($candidatePairs);
|
||||
$rows = self::buildFanCountRows($candidatePairs, $effectivePairs);
|
||||
$rows = self::buildFanDetailRows($candidatePairs, $effectivePairs);
|
||||
|
||||
self::$requestRowsCache[$cacheKey] = $rows;
|
||||
|
||||
return self::$requestRowsCache[$cacheKey];
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<int,array<string,mixed>> $candidatePairs
|
||||
* @param array<int,array<string,mixed>> $effectivePairs
|
||||
* @return array<int, array{user_id:string,external_userid:string,add_time:int,is_deleted:bool,delete_time:int}>
|
||||
*/
|
||||
private static function buildFanDetailRows(
|
||||
array $candidatePairs,
|
||||
array $effectivePairs
|
||||
): array {
|
||||
$effectiveKeys = [];
|
||||
foreach ($effectivePairs as $pair) {
|
||||
$userId = trim((string) ($pair['user_id'] ?? ''));
|
||||
$externalUserId = trim((string) ($pair['external_userid'] ?? ''));
|
||||
if ($userId !== '' && $externalUserId !== '') {
|
||||
$effectiveKeys[$userId . "\0" . $externalUserId] = true;
|
||||
}
|
||||
}
|
||||
|
||||
$detailsByKey = [];
|
||||
foreach ($candidatePairs as $pair) {
|
||||
$userId = trim((string) ($pair['user_id'] ?? ''));
|
||||
$externalUserId = trim((string) ($pair['external_userid'] ?? ''));
|
||||
if ($userId === '' || $externalUserId === '') {
|
||||
continue;
|
||||
}
|
||||
$key = $userId . "\0" . $externalUserId;
|
||||
$addTime = max(0, (int) ($pair['add_time'] ?? 0));
|
||||
if (isset($detailsByKey[$key])) {
|
||||
$existingTime = (int) ($detailsByKey[$key]['add_time'] ?? 0);
|
||||
if ($addTime > 0 && ($existingTime <= 0 || $addTime < $existingTime)) {
|
||||
$detailsByKey[$key]['add_time'] = $addTime;
|
||||
}
|
||||
continue;
|
||||
}
|
||||
$detailsByKey[$key] = [
|
||||
'user_id' => $userId,
|
||||
'external_userid' => $externalUserId,
|
||||
'add_time' => $addTime,
|
||||
'is_deleted' => !isset($effectiveKeys[$key]),
|
||||
'delete_time' => 0,
|
||||
];
|
||||
}
|
||||
|
||||
return array_values($detailsByKey);
|
||||
}
|
||||
|
||||
/**
|
||||
* 按员工聚合全部候选新增,并标记其中期末已删除的子集。
|
||||
*
|
||||
|
||||
Reference in New Issue
Block a user