Files
zyt/server/app/adminapi/logic/firstvisit/FirstVisitConversionLogic.php
T
2026-09-28 11:32:28 +08:00

1181 lines
49 KiB
PHP
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<?php
declare(strict_types=1);
namespace app\adminapi\logic\firstvisit;
use app\adminapi\logic\auth\AuthLogic;
use app\adminapi\logic\dept\DeptLogic;
use app\adminapi\logic\stats\ConversionLogic;
use app\adminapi\logic\stats\YejiStatsLogic;
use app\common\model\auth\Admin;
use app\common\model\auth\AdminDept;
use app\common\service\DataScope\DataScopeService;
use app\common\service\qywx\MediaChannelService;
use think\facade\Db;
/**
* 一诊「综合数据转化」。
*
* 自动指标复用 ConversionLogic;开口数来自获客客户首次发起会话事件。所有筛选先与 DataScope
* 可见管理员集合取交集,HTTP 参数不能扩大当前账号的数据范围。
*/
class FirstVisitConversionLogic
{
private const ASSISTANT_ROLE_ID = 2;
private const FINANCE_PERMISSION = 'firstvisit.conversion/viewFinance';
private const FINANCE_ALWAYS_ROLE_NAMES = ['经理', '管理员', '系统管理员'];
private const FINANCE_FIELD_KEYS = ['account_cost', 'cash_cost', 'roi'];
/** @return array<string,mixed> */
public static function overview(array $params, int $adminId, array $adminInfo): array
{
$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
);
$conversionParams = [
'dimension' => 'dept',
'time_type' => 'custom',
'start_date' => $startDate,
'end_date' => $endDate,
// 一诊筛选项在本层按自身权限和“当前企微标签”口径生成,不再让通用
// Conversion 额外加载一套包含历史渠道的筛选器。
'include_filters' => 0,
'include_members' => 1,
'exclude_cancelled_appointments' => 1,
'order_metric_mode' => 'performance',
'page_no' => 1,
'page_size' => 100,
];
if ($selectedDeptId > 0 && $deptSelectionValid) {
$conversionParams['dept_id'] = $selectedDeptId;
}
if ($selectedMediaChannelCode !== '') {
$conversionParams['media_channel_code'] = $selectedMediaChannelCode;
}
$conversion = ConversionLogic::overview(
$conversionParams,
$adminId,
$adminInfo,
$effectiveAdminIds,
$costAllocationAdminIds,
$selectedMediaChannel
);
$rows = is_array($conversion['lists'] ?? null) ? $conversion['lists'] : [];
$rowAllowedDeptIds = self::visibleRowDeptIds($effectiveAdminIds);
if ($rowAllowedDeptIds !== null) {
$rows = self::filterDeptRows($rows, array_fill_keys($rowAllowedDeptIds, true));
}
$rowDeptIdSet = [];
self::collectRowDeptIds($rows, $rowDeptIdSet);
$openCounts = self::loadOpenCounts(
$startDate,
$endDate,
$effectiveAdminIds,
array_fill_keys(array_keys($rowDeptIdSet), true),
$selectedMediaChannel
);
$openDirect = $openCounts['dept'];
self::applyOpenCounts($rows, $openDirect, $openCounts['admin']);
$summary = is_array($conversion['summary'] ?? null) ? $conversion['summary'] : [];
$summary['total_open_count'] = array_sum($openDirect);
$summary['total_open_rate'] = self::percent(
(int) $summary['total_open_count'],
(int) ($summary['add_fans_count'] ?? 0)
);
$summary['open_appointment_rate'] = self::percent(
(int) ($summary['paid_appointment_count'] ?? 0),
(int) $summary['total_open_count']
);
$summary['open_receive_rate'] = self::percent(
(int) ($summary['completed_order_count'] ?? 0),
(int) $summary['total_open_count']
);
$rankingKind = self::rankingKind($scopeValue, $selectedAssistantId);
$rankingRows = self::rankingRows($rows, $rankingKind);
// 目前只维护了部门月度目标;本人范围或筛选单个员工时不能拿整个部门目标冒充个人目标。
$targetDeptIds = ($scopeValue === DataScopeService::SCOPE_SELF || $selectedAssistantId > 0)
? []
: self::resolveTargetDeptIds($allowedDeptSet, $selectedDeptIds, $selectedDeptId);
$target = self::buildTargetProgress((int) date('Y'), $effectiveAdminIds, $targetDeptIds);
$selectedDeptName = $selectedDeptId > 0 && $deptSelectionValid
? (string) (Db::name('dept')->where('id', $selectedDeptId)->whereNull('delete_time')->value('name') ?? '')
: '';
$selectedAssistantName = $selectedAssistantId > 0 && $selectedAssistantValid
? (string) (Admin::where('id', $selectedAssistantId)->whereNull('delete_time')->value('name') ?? '')
: '';
$selectedMediaChannelName = $selectedMediaChannelCode !== ''
? (string) ($selectedMediaChannel['channel_name'] ?? $selectedMediaChannelCode)
: '';
if ($selectedMediaChannelName !== '' && !empty($selectedMediaChannel['is_group'])) {
$selectedMediaChannelName .= '(全部)';
}
$canViewFinance = self::canViewFinance($adminId, $adminInfo);
if (!$canViewFinance) {
$summary = self::maskFinanceFields($summary);
foreach ($rows as &$row) {
if (is_array($row)) {
$row = self::maskFinanceFields($row);
}
}
unset($row);
}
return self::withDeletedFansVisibility([
'meta' => [
'time_type' => $timeType,
'time_label' => $timeLabel,
'start_date' => $startDate,
'end_date' => $endDate,
'generated_at' => date('Y-m-d H:i:s'),
'scope_value' => $scopeValue,
'scope_label' => DataScopeService::scopeLabel($scopeValue),
'ranking_kind' => $rankingKind,
'selected_dept_name' => $selectedDeptName,
'selected_assistant_name' => $selectedAssistantName,
'selected_media_channel_code' => $selectedMediaChannelCode,
'selected_media_channel_name' => $selectedMediaChannelName,
'open_count_source' => $selectedMediaChannelCode === ''
? '企微获客客户首次开口事件'
: '企微获客客户首次开口事件(按当前客户标签筛选渠道)',
'can_view_finance' => $canViewFinance,
'appointment_rule' => '按预约日期统计,归属优先挂号医助、再回退诊单医助;仅含已预约、已完成和已过号',
'registration_rule' => '按支付时间统计已支付且实收金额大于 0、低于 10 元的订单,每笔计 1 个挂号并按订单创建人归属',
'performance_rule' => '按业务订单创建时间和创建人统计,排除取消、拒收、退款及已发生退款的订单',
],
'filters' => [
'departments' => DeptLogic::getAllDataScoped($adminId, $adminInfo),
'assistants' => self::assistantOptions($baseVisibleAdminIds, $selectedDeptIds, $selectedDeptId),
'media_channels' => MediaChannelService::getCurrentTagOptions(),
],
'summary' => $summary,
'rankings' => [
'orders' => self::topRows($rankingRows, 'completed_order_count'),
'amounts' => self::topRows($rankingRows, 'completed_order_amount'),
],
'rows' => $rows,
'target' => $target,
], $adminInfo);
}
/** @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 = self::withDeletedFansVisibility([
'lists' => [],
'count' => 0,
'page_no' => $pageNo,
'page_size' => $pageSize,
'date_range' => [$context['start_date'], $context['end_date']],
'entity' => null,
], $adminInfo, true);
$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 self::withDeletedFansVisibility($result, $adminInfo, true);
}
/**
* 删除客户统计是账号专属能力,与root、角色、财务权限和DataScope无关。
* adminInfo由认证token缓存提供;缺失账号时拒绝,不能从HTTP参数补齐或标准化账号。
*/
private static function canViewDeletedFans(array $adminInfo): bool
{
return ($adminInfo['account'] ?? null) === 'admin';
}
/**
* 只裁剪本页响应,不改变通用统计口径、加粉客户集合、排序或分页。
* 对整个响应递归处理,避免嵌套成员、排名或未来新增位置泄露同一敏感指标。
*/
private static function withDeletedFansVisibility(array $result, array $adminInfo, bool $detail = false): array
{
$canView = self::canViewDeletedFans($adminInfo);
if (!$canView) {
$fields = $detail
? ['deleted_fans_count', 'deleted_count', 'is_deleted', 'delete_time']
: ['deleted_fans_count', 'deleted_count'];
$result = self::removeDeletedFansFields($result, $fields);
}
if ($detail) {
$result['can_view_deleted_fans'] = $canView;
} else {
$result['meta']['can_view_deleted_fans'] = $canView;
}
return $result;
}
/** @param string[] $fields */
private static function removeDeletedFansFields(array $value, array $fields): array
{
foreach ($fields as $field) {
unset($value[$field]);
}
foreach ($value as &$item) {
if (is_array($item)) {
$item = self::removeDeletedFansFields($item, $fields);
}
}
unset($item);
return $value;
}
/**
* 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
{
$today = date('Y-m-d');
$timeType = (string) ($params['time_type'] ?? 'today');
if (!in_array($timeType, ['today', 'yesterday', 'week', 'month', 'quarter', 'year', 'custom'], true)) {
$timeType = 'today';
}
if ($timeType === 'custom') {
$startDate = trim((string) ($params['start_date'] ?? ''));
$endDate = trim((string) ($params['end_date'] ?? ''));
if ($startDate === '' || $endDate === '' || strtotime($startDate) === false || strtotime($endDate) === false) {
$startDate = $today;
$endDate = $today;
}
if ($startDate > $endDate) {
[$startDate, $endDate] = [$endDate, $startDate];
}
return [$startDate, $endDate, 'custom', $startDate . ' 至 ' . $endDate];
}
if ($timeType === 'yesterday') {
$yesterday = date('Y-m-d', strtotime('-1 day'));
return [$yesterday, $yesterday, $timeType, '昨天'];
}
if ($timeType === 'week') {
return [date('Y-m-d', strtotime('monday this week')), $today, $timeType, '本周'];
}
if ($timeType === 'month') {
return [date('Y-m-01'), $today, $timeType, '本月'];
}
if ($timeType === 'quarter') {
$quarterMonth = ((int) floor(((int) date('n') - 1) / 3) * 3) + 1;
return [date('Y-' . str_pad((string) $quarterMonth, 2, '0', STR_PAD_LEFT) . '-01'), $today, $timeType, '本季度'];
}
if ($timeType === 'year') {
return [date('Y-01-01'), $today, $timeType, '本年'];
}
return [$today, $today, 'today', '今日'];
}
/** @param int[]|null $visibleIds @param int[] $candidateIds @return int[]|null */
private static function intersectVisibleIds(?array $visibleIds, array $candidateIds): ?array
{
if ($visibleIds === null) {
return $candidateIds;
}
return array_values(array_intersect($visibleIds, $candidateIds));
}
private static function isActiveAssistant(int $adminId): bool
{
if ($adminId <= 0) {
return false;
}
return Db::name('admin')
->alias('a')
->join('admin_role ar', 'ar.admin_id = a.id')
->where('a.id', $adminId)
->where('ar.role_id', self::ASSISTANT_ROLE_ID)
->where('a.disable', 0)
->whereNull('a.delete_time')
->count() > 0;
}
/** @param int[]|null $visibleAdminIds @return int[]|null */
private static function visibleRowDeptIds(?array $visibleAdminIds): ?array
{
if ($visibleAdminIds === null) {
return null;
}
if ($visibleAdminIds === []) {
return [];
}
return self::normalizeIds(AdminDept::whereIn('admin_id', $visibleAdminIds)->column('dept_id'));
}
/**
* 个人指标仍只查本人;成本按本人所在部门全员的加粉占比分摊。
*
* @param int[]|null $effectiveAdminIds
* @return int[]|null null 表示使用默认分摊范围
*/
private static function costAllocationAdminIds(?array $effectiveAdminIds, bool $personalScope): ?array
{
if (!$personalScope) {
return null;
}
if ($effectiveAdminIds === []) {
return [];
}
$deptIds = self::visibleRowDeptIds($effectiveAdminIds);
if ($deptIds === null || $deptIds === []) {
return $effectiveAdminIds ?? [];
}
$ids = self::normalizeIds(AdminDept::whereIn('dept_id', $deptIds)->column('admin_id'));
return $ids !== [] ? $ids : ($effectiveAdminIds ?? []);
}
/** @param array<int,array<string,mixed>> $rows @param array<int,true> $allowedSet @return array<int,array<string,mixed>> */
private static function filterDeptRows(array $rows, array $allowedSet): array
{
if ($allowedSet === []) {
return [];
}
$out = [];
foreach ($rows as $row) {
if (!is_array($row)) {
continue;
}
if (in_array((string) ($row['type'] ?? ''), ['member', 'unbound'], true)) {
$out[] = $row;
continue;
}
$children = self::filterDeptRows(is_array($row['children'] ?? null) ? $row['children'] : [], $allowedSet);
$id = (int) ($row['id'] ?? 0);
if (isset($allowedSet[$id])) {
$row['children'] = $children;
if ($children === []) {
unset($row['children']);
}
$out[] = $row;
continue;
}
foreach ($children as $child) {
$out[] = $child;
}
}
return $out;
}
/** @param array<int,array<string,mixed>> $rows @param array<int,true> $set */
private static function collectRowDeptIds(array $rows, array &$set): void
{
foreach ($rows as $row) {
$id = (int) ($row['id'] ?? 0);
if ($id !== 0) {
$set[$id] = true;
}
self::collectRowDeptIds(is_array($row['children'] ?? null) ? $row['children'] : [], $set);
}
}
/**
* @param int[]|null $effectiveAdminIds
* @param array<int,true> $rowDeptSet
* @param array<string,mixed>|null $mediaChannel null=全部渠道
* @return array{dept:array<int,int>,admin:array<int,int>}
*/
private static function loadOpenCounts(
string $startDate,
string $endDate,
?array $effectiveAdminIds,
array $rowDeptSet,
?array $mediaChannel = null
): array
{
if ($effectiveAdminIds === [] || $rowDeptSet === []) {
return ['dept' => [], 'admin' => []];
}
$startTimestamp = strtotime($startDate . ' 00:00:00');
$endTimestamp = strtotime($endDate . ' 23:59:59');
$query = Db::name('qywx_customer_acquisition_event')
->alias('open_event')
->join(
'qywx_customer_acquisition_customer acquisition_customer',
'acquisition_customer.link_id = open_event.link_id'
. ' AND acquisition_customer.external_userid = open_event.external_userid'
. ' AND acquisition_customer.userid = open_event.userid'
)
->where('open_event.change_type', 'customer_start_chat')
->where('open_event.status', 1)
->whereBetween('open_event.event_time', [$startTimestamp, $endTimestamp])
->where('acquisition_customer.chat_status', 1)
->where('acquisition_customer.owner_admin_id', '>', 0);
if ($effectiveAdminIds !== null) {
$query->whereIn('acquisition_customer.owner_admin_id', $effectiveAdminIds);
}
if ($mediaChannel !== null) {
MediaChannelService::applyExternalUserChannelFilter(
$query,
'acquisition_customer.external_userid',
$mediaChannel,
'acquisition_customer.userid'
);
}
$rows = $query
// 客户可从多个获客链接进入;按员工+客户去重,与加粉组合口径一致。
->fieldRaw('acquisition_customer.owner_admin_id AS creator_id, COUNT(DISTINCT acquisition_customer.external_userid) AS open_count')
->group('acquisition_customer.owner_admin_id')
->select()
->toArray();
if ($rows === []) {
return ['dept' => [], 'admin' => []];
}
$creatorIds = self::normalizeIds(array_column($rows, 'creator_id'));
$deptRows = $creatorIds === [] ? [] : AdminDept::whereIn('admin_id', $creatorIds)
->field('admin_id, dept_id')
->order('admin_id', 'asc')
->order('dept_id', 'asc')
->select()
->toArray();
$adminDeptMap = [];
foreach ($deptRows as $deptRow) {
$adminDeptMap[(int) $deptRow['admin_id']][] = (int) $deptRow['dept_id'];
}
$deptMetaRows = Db::name('dept')
->whereNull('delete_time')
->field('id, pid, sort')
->select()
->toArray();
$deptMeta = [];
foreach ($deptMetaRows as $deptMetaRow) {
$deptId = (int) ($deptMetaRow['id'] ?? 0);
if ($deptId > 0) {
$deptMeta[$deptId] = [
'pid' => (int) ($deptMetaRow['pid'] ?? 0),
'sort' => (int) ($deptMetaRow['sort'] ?? 0),
];
}
}
$depthCache = [];
$depthOf = static function (int $deptId) use (&$depthOf, &$depthCache, $deptMeta): int {
if ($deptId <= 0 || !isset($deptMeta[$deptId])) {
return 0;
}
if (isset($depthCache[$deptId])) {
return $depthCache[$deptId];
}
$parentId = (int) ($deptMeta[$deptId]['pid'] ?? 0);
if ($parentId <= 0 || $parentId === $deptId || !isset($deptMeta[$parentId])) {
return $depthCache[$deptId] = 0;
}
return $depthCache[$deptId] = $depthOf($parentId) + 1;
};
foreach ($adminDeptMap as &$deptIds) {
usort($deptIds, static function (int $left, int $right) use ($depthOf, $deptMeta): int {
$depthCompare = $depthOf($right) <=> $depthOf($left);
if ($depthCompare !== 0) {
return $depthCompare;
}
$sortCompare = (int) ($deptMeta[$right]['sort'] ?? 0) <=> (int) ($deptMeta[$left]['sort'] ?? 0);
if ($sortCompare !== 0) {
return $sortCompare;
}
return $left <=> $right;
});
}
unset($deptIds);
$direct = [];
$adminDirect = [];
foreach ($rows as $row) {
$adminId = (int) ($row['creator_id'] ?? 0);
$targetDeptId = 0;
foreach ($adminDeptMap[$adminId] ?? [] as $deptId) {
if (isset($rowDeptSet[$deptId])) {
$targetDeptId = $deptId;
break;
}
}
if ($targetDeptId === 0 && isset($rowDeptSet[-2])) {
$targetDeptId = -2;
}
if ($targetDeptId !== 0) {
$openCount = (int) ($row['open_count'] ?? 0);
$direct[$targetDeptId] = ($direct[$targetDeptId] ?? 0) + $openCount;
$adminDirect[$adminId] = ($adminDirect[$adminId] ?? 0) + $openCount;
}
}
return ['dept' => $direct, 'admin' => $adminDirect];
}
/**
* @param array<int,array<string,mixed>> $rows
* @param array<int,int> $deptDirect
* @param array<int,int> $adminDirect
*/
private static function applyOpenCounts(array &$rows, array $deptDirect, array $adminDirect): int
{
$sum = 0;
foreach ($rows as &$row) {
$rowType = (string) ($row['type'] ?? '');
if (in_array($rowType, ['member', 'unbound'], true)) {
$count = $rowType === 'member'
? (int) ($adminDirect[(int) ($row['admin_id'] ?? 0)] ?? 0)
: 0;
$row['total_open_count'] = $count;
$row['total_open_rate'] = self::percent($count, (int) ($row['add_fans_count'] ?? 0));
$row['open_appointment_rate'] = self::percent(
(int) ($row['paid_appointment_count'] ?? 0),
$count
);
$row['open_receive_rate'] = self::percent(
(int) ($row['completed_order_count'] ?? 0),
$count
);
continue;
}
$children = is_array($row['children'] ?? null) ? $row['children'] : [];
$childTotal = self::applyOpenCounts($children, $deptDirect, $adminDirect);
if ($children !== []) {
$row['children'] = $children;
}
$directCount = (int) ($deptDirect[(int) ($row['id'] ?? 0)] ?? 0);
$count = $directCount + $childTotal;
$row['total_open_count'] = $count;
$row['total_open_rate'] = self::percent($count, (int) ($row['add_fans_count'] ?? 0));
$row['open_appointment_rate'] = self::percent(
(int) ($row['paid_appointment_count'] ?? 0),
$count
);
$row['open_receive_rate'] = self::percent((int) ($row['completed_order_count'] ?? 0), $count);
$sum += $directCount + $childTotal;
}
unset($row);
return $sum;
}
private static function canViewFinance(int $adminId, array $adminInfo): bool
{
if ((int) ($adminInfo['root'] ?? 0) === 1) {
return true;
}
foreach (self::roleNamesFromAdminInfo($adminInfo) as $roleName) {
if (in_array($roleName, self::FINANCE_ALWAYS_ROLE_NAMES, true)) {
return true;
}
}
if ($adminId <= 0) {
return false;
}
return in_array(self::FINANCE_PERMISSION, AuthLogic::getAuthByAdminId($adminId), true);
}
/** @return string[] */
private static function roleNamesFromAdminInfo(array $adminInfo): array
{
$names = preg_split('/[\/,,、]/u', (string) ($adminInfo['role_name'] ?? '')) ?: [];
return array_values(array_filter(array_map('trim', $names), static fn (string $name): bool => $name !== ''));
}
/**
* @param array<string, mixed> $entity
* @return array<string, mixed>
*/
private static function maskFinanceFields(array $entity): array
{
foreach (self::FINANCE_FIELD_KEYS as $key) {
unset($entity[$key]);
}
if (isset($entity['children']) && is_array($entity['children'])) {
foreach ($entity['children'] as &$child) {
if (is_array($child)) {
$child = self::maskFinanceFields($child);
}
}
unset($child);
}
return $entity;
}
/** 根据生效数据范围返回排行榜展示维度,不能把 scope_value 当作角色枚举。 */
private static function rankingKind(int $scopeValue, int $selectedAssistantId = 0): string
{
if ($scopeValue === DataScopeService::SCOPE_SELF || $selectedAssistantId > 0) {
return 'hidden';
}
return $scopeValue === DataScopeService::SCOPE_DEPT ? 'member' : 'group';
}
/** @param array<int,array<string,mixed>> $rows @return array<int,array<string,mixed>> */
private static function rankingRows(array $rows, string $rankingKind): array
{
if ($rankingKind === 'hidden') {
return [];
}
// “仅本部门”范围使用可见成员维度;更大范围使用当前可见组织根节点的
// 直属下级,避免父子汇总同时参与占比。
if ($rankingKind === 'member') {
$members = [];
self::collectRankingMembers($rows, $members);
return array_values($members);
}
// lists 里可能同时存在“未绑定/未分配部门”等虚拟根节点。它们会让顶层节点数量
// 大于 1,导致原逻辑无法展开唯一的真实组织根节点,图表最终只显示医院汇总行。
$visibleRows = array_values(array_filter($rows, static function (array $row): bool {
return (int) ($row['id'] ?? 0) > 0 && !((bool) ($row['_virtual_bucket'] ?? false));
}));
// 每个可见顶层分支只展示同一层级:有权限看到下级时展示直属子部门;没有可见
// 下级时保留当前部门。这样既能按角色/DataScope 展示子部门,也不会把父子汇总
// 同时放进占比图造成重复计算。
$chartRows = [];
foreach ($visibleRows as $row) {
$children = array_values(array_filter(
is_array($row['children'] ?? null) ? $row['children'] : [],
static fn (array $child): bool => (int) ($child['id'] ?? 0) > 0
&& !((bool) ($child['_virtual_bucket'] ?? false))
));
if ($children !== []) {
foreach ($children as $child) {
$chartRows[] = $child;
}
continue;
}
$chartRows[] = $row;
}
return $chartRows;
}
/**
* @param array<int,array<string,mixed>> $rows
* @param array<int,array<string,mixed>> $members
*/
private static function collectRankingMembers(array $rows, array &$members): void
{
foreach ($rows as $row) {
if ((string) ($row['type'] ?? '') === 'member') {
$adminId = (int) ($row['admin_id'] ?? 0);
if ($adminId > 0) {
$members[$adminId] = $row;
}
continue;
}
self::collectRankingMembers(
is_array($row['children'] ?? null) ? $row['children'] : [],
$members
);
}
}
/** @param array<int,array<string,mixed>> $rows @return array<int,array<string,mixed>> */
private static function topRows(array $rows, string $metric): array
{
$rows = array_values(array_filter($rows, static function (array $row): bool {
if ((string) ($row['type'] ?? '') === 'member') {
return (int) ($row['admin_id'] ?? 0) > 0;
}
return (int) ($row['id'] ?? 0) > 0;
}));
usort($rows, static function (array $left, array $right) use ($metric): int {
$valueCompare = (float) ($right[$metric] ?? 0) <=> (float) ($left[$metric] ?? 0);
if ($valueCompare !== 0) {
return $valueCompare;
}
$nameCompare = strnatcasecmp((string) ($left['name'] ?? ''), (string) ($right['name'] ?? ''));
if ($nameCompare !== 0) {
return $nameCompare;
}
return strcmp((string) ($left['id'] ?? ''), (string) ($right['id'] ?? ''));
});
return array_map(static fn (array $row): array => [
'id' => $row['id'] ?? 0,
'name' => (string) ($row['name'] ?? ''),
'value' => round((float) ($row[$metric] ?? 0), 2),
], $rows);
}
/** @param int[]|null $baseVisibleAdminIds @param int[] $selectedDeptIds @return array<int,array{id:int,name:string}> */
private static function assistantOptions(?array $baseVisibleAdminIds, array $selectedDeptIds, int $selectedDeptId): array
{
$query = Db::name('admin')
->alias('a')
->join('admin_role ar', 'ar.admin_id = a.id')
->where('ar.role_id', self::ASSISTANT_ROLE_ID)
->where('a.disable', 0)
->whereNull('a.delete_time');
if ($baseVisibleAdminIds !== null) {
if ($baseVisibleAdminIds === []) {
return [];
}
$query->whereIn('a.id', $baseVisibleAdminIds);
}
if ($selectedDeptId > 0) {
if ($selectedDeptIds === []) {
return [];
}
$query->join('admin_dept ad', 'ad.admin_id = a.id')->whereIn('ad.dept_id', $selectedDeptIds);
}
return $query->field('a.id, a.name')->distinct(true)->order('a.name', 'asc')->select()->toArray();
}
/** @param array<int,true>|null $allowedDeptSet @param int[] $selectedDeptIds @return int[]|null */
private static function resolveTargetDeptIds(?array $allowedDeptSet, array $selectedDeptIds, int $selectedDeptId): ?array
{
if ($selectedDeptId > 0) {
return $selectedDeptIds;
}
if ($allowedDeptSet === null) {
return null;
}
return array_map('intval', array_keys($allowedDeptSet));
}
/** @param int[]|null $effectiveAdminIds @param int[]|null $targetDeptIds @return array<string,mixed> */
private static function buildTargetProgress(int $year, ?array $effectiveAdminIds, ?array $targetDeptIds): array
{
$targetQuery = Db::name('dept_performance_target')->whereLike('year_month', $year . '-%');
if ($targetDeptIds !== null) {
if ($targetDeptIds === []) {
$targetRows = [];
} else {
$targetRows = $targetQuery->whereIn('dept_id', $targetDeptIds)
->fieldRaw('`year_month`, SUM(`target_amount`) AS target_amount, COUNT(DISTINCT `dept_id`) AS dept_count')
->group('`year_month`')
->select()
->toArray();
}
} else {
$targetRows = $targetQuery
->fieldRaw('`year_month`, SUM(`target_amount`) AS target_amount, COUNT(DISTINCT `dept_id`) AS dept_count')
->group('`year_month`')
->select()
->toArray();
}
$actualRows = [];
if ($effectiveAdminIds !== []) {
$actualQuery = Db::name('tcm_prescription_order')
->alias('po')
->whereNull('po.delete_time')
->where('po.create_time', 'between', [
strtotime($year . '-01-01 00:00:00'),
strtotime($year . '-12-31 23:59:59'),
]);
YejiStatsLogic::applyPrescriptionOrderEffectiveAmountQuery($actualQuery, 'po');
if ($effectiveAdminIds !== null) {
$actualQuery->whereIn('po.creator_id', $effectiveAdminIds);
}
$actualRows = $actualQuery
->fieldRaw("DATE_FORMAT(FROM_UNIXTIME(po.create_time), '%m') AS month_no, SUM(po.amount) AS actual_amount")
->group('month_no')
->select()
->toArray();
}
$targets = array_fill(1, 12, 0.0);
$actuals = array_fill(1, 12, 0.0);
$deptCountSet = [];
foreach ($targetRows as $row) {
$month = (int) substr((string) ($row['year_month'] ?? ''), 5, 2);
if ($month >= 1 && $month <= 12) {
$targets[$month] = round((float) ($row['target_amount'] ?? 0), 2);
$deptCountSet[$month] = (int) ($row['dept_count'] ?? 0);
}
}
foreach ($actualRows as $row) {
$month = (int) ($row['month_no'] ?? 0);
if ($month >= 1 && $month <= 12) {
$actuals[$month] = round((float) ($row['actual_amount'] ?? 0), 2);
}
}
$targetCumulative = [];
$actualCumulative = [];
$targetRunning = 0.0;
$actualRunning = 0.0;
for ($month = 1; $month <= 12; $month++) {
$targetRunning = round($targetRunning + $targets[$month], 2);
$actualRunning = round($actualRunning + $actuals[$month], 2);
$targetCumulative[] = $targetRunning;
$actualCumulative[] = $actualRunning;
}
$currentMonth = (int) date('n');
return [
'year' => $year,
'target_amount' => $targetRunning,
'actual_amount' => $actualRunning,
'completion_rate' => $targetRunning > 0 ? round($actualRunning / $targetRunning * 100, 2) : null,
'current_month_target' => $targets[$currentMonth],
'current_month_actual' => $actuals[$currentMonth],
'current_month_rate' => $targets[$currentMonth] > 0
? round($actuals[$currentMonth] / $targets[$currentMonth] * 100, 2)
: null,
'department_count' => max($deptCountSet ?: [0]),
'months' => array_map(static fn (int $month): string => str_pad((string) $month, 2, '0', STR_PAD_LEFT) . '月', range(1, 12)),
'target_cumulative' => $targetCumulative,
'actual_cumulative' => $actualCumulative,
];
}
/** @param array<int|string,mixed> $ids @return int[] */
private static function normalizeIds(array $ids): array
{
return array_values(array_unique(array_filter(array_map('intval', $ids), static fn (int $id): bool => $id > 0)));
}
private static function percent(int $numerator, int $denominator): float
{
return $denominator > 0 ? round($numerator / $denominator * 100, 2) : 0.0;
}
}