Files
zyt/server/app/adminapi/logic/firstvisit/FirstVisitConversionLogic.php
T
2026-08-31 15:17:34 +08:00

1200 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\model\stats\PersonalYeji;
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),
self::personalYejiMediaSources($selectedMediaChannelCode, $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 string[]|null $mediaSources null=全部渠道;空数组=所选渠道没有可匹配的手工来源
* @return array{dept:array<int,int>,admin:array<int,int>}
*/
private static function loadOpenCounts(
string $startDate,
string $endDate,
?array $effectiveAdminIds,
array $rowDeptSet,
?array $mediaSources = null
): array
{
if ($effectiveAdminIds === [] || $rowDeptSet === [] || $mediaSources === []) {
return ['dept' => [], 'admin' => []];
}
$query = PersonalYeji::whereBetween('yeji_date', [$startDate, $endDate]);
if ($effectiveAdminIds !== null) {
$query->whereIn('creator_id', $effectiveAdminIds);
}
if ($mediaSources !== null) {
$query->whereIn('media_source', $mediaSources);
}
$rows = $query
->fieldRaw('creator_id, SUM(total_open_count) AS open_count')
->group('creator_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;
}
/**
* 手工开口按 personal_yeji.media_source 保存;渠道筛选时仅匹配该渠道自身的稳定标识和名称。
* 不使用 source_group_name,避免同组多个渠道的开口数被重复计入每个渠道。
*
* @param array<string,mixed>|null $channel
* @return string[]|null
*/
private static function personalYejiMediaSources(string $channelCode, ?array $channel): ?array
{
if ($channelCode === '') {
return null;
}
if ($channel === null) {
return [];
}
$values = [
$channelCode,
$channel['channel_name'] ?? '',
$channel['source_tag_name'] ?? '',
$channel['legacy_channel_name'] ?? '',
$channel['legacy_source_tag_name'] ?? '',
];
foreach (['channel_codes', 'channel_names', 'source_tag_names'] as $listKey) {
if (!isset($channel[$listKey]) || !is_array($channel[$listKey])) {
continue;
}
foreach ($channel[$listKey] as $item) {
$values[] = $item;
}
}
return array_values(array_unique(array_filter(array_map(
static fn ($value): string => trim((string) $value),
$values
), static fn (string $value): bool => $value !== '' && !str_starts_with($value, MediaChannelService::GROUP_CODE_PREFIX))));
}
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;
}
}