1505 lines
56 KiB
PHP
1505 lines
56 KiB
PHP
<?php
|
||
|
||
declare(strict_types=1);
|
||
|
||
namespace app\adminapi\logic\stats;
|
||
|
||
use app\adminapi\logic\dept\DeptLogic;
|
||
use app\common\model\auth\AdminDept;
|
||
use app\common\model\auth\AdminRole;
|
||
use app\common\model\auth\SystemRole;
|
||
use app\common\model\dept\Dept;
|
||
use app\common\service\DataScope\DataScopeService;
|
||
use think\facade\Db;
|
||
|
||
/**
|
||
* 数据驾驶舱聚合逻辑。
|
||
*
|
||
* 数据口径:
|
||
* - 所有“业绩/接诊诊单”与业绩统计、业务订单列表保持一致:按业务订单创建时间,
|
||
* 排除履约已取消/拒收/退款(4/9/10),金额取业务订单 amount,归属人取订单 creator_id。
|
||
* - 今日预约、面诊沿用 ConversionLogic;挂号按已支付且实收低于 10 元的订单统计。
|
||
* - 趋势使用同一业绩条件的轻量按日 SQL,固定补齐最近 7 个自然日。
|
||
* - 首页 KPI(业绩/加粉/预约/挂号/接诊/面诊)按角色收窄:医助=本人,组长=本小组,经理=本部门及下级,管理员=全部。
|
||
* - 本人本月业绩始终只统计当前登录账号。
|
||
* - 所有查询都使用当前视角解析出的可见管理员集合收窄;排行榜另按一中心/二中心规则。
|
||
*/
|
||
class PerformanceDashboardLogic
|
||
{
|
||
private const TREND_DAYS = 7;
|
||
|
||
/**
|
||
* @return array<string, mixed>
|
||
*/
|
||
public static function overview(int $adminId, array $adminInfo, array $params = []): array
|
||
{
|
||
$today = date('Y-m-d');
|
||
$yesterday = date('Y-m-d', strtotime('-1 day'));
|
||
$dayBeforeYesterday = date('Y-m-d', strtotime('-2 days'));
|
||
$monthStart = date('Y-m-01');
|
||
$previousMonthStart = date('Y-m-01', strtotime('first day of previous month'));
|
||
$previousMonthLastDay = (int) date('t', strtotime($previousMonthStart));
|
||
$comparisonDay = min((int) date('j'), $previousMonthLastDay);
|
||
$previousMonthComparableEnd = date(
|
||
'Y-m-d',
|
||
strtotime($previousMonthStart . ' +' . max(0, $comparisonDay - 1) . ' days')
|
||
);
|
||
$trendStart = date('Y-m-d', strtotime('-' . (self::TREND_DAYS - 1) . ' days'));
|
||
|
||
$scope = self::buildScopeContext($adminId, $adminInfo);
|
||
$roleScope = PerformanceDashboardScope::resolve($adminId, $adminInfo);
|
||
$scope['kind'] = $roleScope['kind'];
|
||
$scope['label'] = $roleScope['label'];
|
||
$scope['viewer_id'] = $adminId;
|
||
/** @var array<int>|null $rankingVisibleAdminIds */
|
||
$rankingVisibleAdminIds = $scope['_visible_admin_ids'];
|
||
unset($scope['_visible_admin_ids']);
|
||
/** @var array<int>|null $metricAdminIds */
|
||
$metricAdminIds = $roleScope['metric_admin_ids'];
|
||
$isGroupLeader = ($roleScope['kind'] ?? '') === PerformanceDashboardScope::KIND_GROUP_LEADER;
|
||
$scope['group_admin_ids'] = $isGroupLeader ? $metricAdminIds : null;
|
||
$scope['is_limited'] = $metricAdminIds !== null;
|
||
if ($metricAdminIds === null) {
|
||
$scope['visible_member_count'] = (int) Db::name('admin')->whereNull('delete_time')->count();
|
||
} else {
|
||
$scope['visible_member_count'] = count($metricAdminIds);
|
||
}
|
||
$centerRanking = self::resolveViewerCenterRankingScope($adminId);
|
||
$centerDeptIds = $centerRanking['dept_ids'];
|
||
$centerLocked = $centerDeptIds !== [];
|
||
$isErCenter = (bool) ($centerRanking['is_er'] ?? false);
|
||
$requestedRankingDeptId = max(0, (int) ($params['ranking_dept_id'] ?? 0));
|
||
$hasRankingDeptParam = array_key_exists('ranking_dept_id', $params)
|
||
&& $params['ranking_dept_id'] !== ''
|
||
&& $params['ranking_dept_id'] !== null;
|
||
if ($centerLocked && $isErCenter && !$hasRankingDeptParam && !$isGroupLeader) {
|
||
$requestedRankingDeptId = (int) ($centerRanking['own_dept_id'] ?? 0);
|
||
}
|
||
$rankingDeptId = self::resolveRankingDeptId(
|
||
$requestedRankingDeptId,
|
||
$adminId,
|
||
$adminInfo,
|
||
$centerDeptIds
|
||
);
|
||
|
||
$orderDaily = self::loadPerformanceOrderDaily($previousMonthStart, $today, $metricAdminIds);
|
||
$personalOrderDaily = self::loadPerformanceOrderDaily($monthStart, $today, [$adminId]);
|
||
$registrationDaily = self::loadRegistrationDaily($trendStart, $today, $metricAdminIds);
|
||
|
||
$monthAmount = self::sumDailyMetric($orderDaily, $monthStart, $today, 'amount');
|
||
$previousMonthAmount = self::sumDailyMetric(
|
||
$orderDaily,
|
||
$previousMonthStart,
|
||
$previousMonthComparableEnd,
|
||
'amount'
|
||
);
|
||
$yesterdayAmount = self::dailyMetric($orderDaily, $yesterday, 'amount');
|
||
$dayBeforeAmount = self::dailyMetric($orderDaily, $dayBeforeYesterday, 'amount');
|
||
$personalMonthAmount = self::sumDailyMetric($personalOrderDaily, $monthStart, $today, 'amount');
|
||
|
||
$todayOverview = ConversionLogic::overview([
|
||
'dimension' => 'dept',
|
||
'time_type' => 'today',
|
||
'include_members' => 0,
|
||
'include_filters' => 0,
|
||
'exclude_cancelled_appointments' => 1,
|
||
'page_no' => 1,
|
||
'page_size' => 100,
|
||
], $adminId, $adminInfo, $metricAdminIds);
|
||
$todaySummary = is_array($todayOverview['summary'] ?? null) ? $todayOverview['summary'] : [];
|
||
$yesterdayOverview = ConversionLogic::overview([
|
||
'dimension' => 'dept',
|
||
'time_type' => 'yesterday',
|
||
'include_members' => 0,
|
||
'include_filters' => 0,
|
||
'exclude_cancelled_appointments' => 1,
|
||
'page_no' => 1,
|
||
'page_size' => 100,
|
||
], $adminId, $adminInfo, $metricAdminIds);
|
||
$yesterdaySummary = is_array($yesterdayOverview['summary'] ?? null)
|
||
? $yesterdayOverview['summary']
|
||
: [];
|
||
|
||
// 业绩指标必须直接复用业绩页的权威聚合,不能使用 ConversionLogic 的“双审完成单”。
|
||
$todayPerformanceOverview = YejiStatsLogic::overview([
|
||
'start_date' => $today,
|
||
'end_date' => $today,
|
||
], $adminId, $adminInfo);
|
||
$appointmentRanking = self::buildRegistrationRanking(
|
||
$adminId,
|
||
$scope,
|
||
$rankingVisibleAdminIds,
|
||
$rankingDeptId,
|
||
$centerRanking
|
||
);
|
||
$centerPeopleIds = $centerLocked ? self::adminsInDeptIds($centerDeptIds) : null;
|
||
$rankingPeopleIds = $centerPeopleIds;
|
||
$rankingPeopleLabel = (string) ($centerRanking['label'] ?? $scope['label'] ?? '');
|
||
if (!$isGroupLeader && $rankingDeptId > 0) {
|
||
$deptAdminIds = self::departmentAdminIds($rankingDeptId);
|
||
$rankingPeopleIds = $rankingPeopleIds === null
|
||
? $deptAdminIds
|
||
: array_values(array_intersect($rankingPeopleIds, $deptAdminIds));
|
||
$rankingPeopleLabel = (string) (Dept::where('id', $rankingDeptId)->value('name') ?? $rankingPeopleLabel);
|
||
}
|
||
if ($isGroupLeader) {
|
||
$performanceRanking = self::loadPersonalPerformanceRanking(
|
||
$metricAdminIds,
|
||
$adminId,
|
||
(string) ($scope['viewer_name'] ?? ''),
|
||
$today,
|
||
true
|
||
);
|
||
$performanceTitle = '今日组员业绩排行';
|
||
$performanceKind = 'person';
|
||
$performanceScopeLabel = (string) ($scope['label'] ?? '本小组');
|
||
} elseif ($centerLocked) {
|
||
$performanceRanking = self::loadPersonalPerformanceRanking(
|
||
$rankingPeopleIds ?? [],
|
||
$adminId,
|
||
(string) ($scope['viewer_name'] ?? ''),
|
||
$today
|
||
);
|
||
$performanceTitle = $rankingPeopleLabel !== ''
|
||
? ('今日' . $rankingPeopleLabel . '业绩排行')
|
||
: '今日部门业绩排行';
|
||
$performanceKind = 'person';
|
||
$performanceScopeLabel = $rankingPeopleLabel;
|
||
} else {
|
||
$performanceRanking = self::buildPerformanceRanking(
|
||
is_array($todayPerformanceOverview['rows'] ?? null) ? $todayPerformanceOverview['rows'] : []
|
||
);
|
||
$performanceTitle = '今日部门业绩排行';
|
||
$performanceKind = 'dept';
|
||
$performanceScopeLabel = (string) ($scope['label'] ?? '');
|
||
}
|
||
$trendContext = YejiStatsLogic::resolveSharedYejiFilterContext([
|
||
'start_date' => $trendStart,
|
||
'end_date' => $today,
|
||
], $adminId, $adminInfo);
|
||
$trendContext = self::narrowTrendContext($trendContext, $metricAdminIds);
|
||
$trend = self::buildTrend(
|
||
$trendStart,
|
||
$today,
|
||
$metricAdminIds,
|
||
$orderDaily,
|
||
$registrationDaily,
|
||
$trendContext
|
||
);
|
||
$todayTrendIndex = max(0, count($trend['dates'] ?? []) - 1);
|
||
$yesterdayTrendIndex = max(0, $todayTrendIndex - 1);
|
||
|
||
$todayAddFansCount = (int) ($trend['leads'][$todayTrendIndex] ?? 0);
|
||
$yesterdayAddFansCount = (int) ($trend['leads'][$yesterdayTrendIndex] ?? 0);
|
||
$todayAppointmentCount = (int) ($trend['appointments'][$todayTrendIndex] ?? 0);
|
||
$yesterdayAppointmentCount = (int) ($trend['appointments'][$yesterdayTrendIndex] ?? 0);
|
||
$todayInterviewCount = (int) ($todaySummary['interview_count'] ?? 0);
|
||
$yesterdayInterviewCount = (int) ($yesterdaySummary['interview_count'] ?? 0);
|
||
$todayOrderCount = (int) self::dailyMetric($orderDaily, $today, 'count');
|
||
$yesterdayOrderCount = (int) self::dailyMetric($orderDaily, $yesterday, 'count');
|
||
$todayOrderAmount = self::dailyMetric($orderDaily, $today, 'amount');
|
||
$yesterdayOrderAmount = self::dailyMetric($orderDaily, $yesterday, 'amount');
|
||
$todayLowAmountPaymentCount = (int) self::dailyMetric($registrationDaily, $today, 'count');
|
||
$yesterdayLowAmountPaymentCount = (int) self::dailyMetric($registrationDaily, $yesterday, 'count');
|
||
$todayPaidAppointmentCount = $todayLowAmountPaymentCount;
|
||
$yesterdayPaidAppointmentCount = $yesterdayLowAmountPaymentCount;
|
||
$todayPaidAppointmentRate = self::percent($todayPaidAppointmentCount, $todayAddFansCount);
|
||
$yesterdayPaidAppointmentRate = self::percent($yesterdayPaidAppointmentCount, $yesterdayAddFansCount);
|
||
// 接诊卡片使用的是有效业务订单,接诊率必须使用同一订单口径,不能继续读取旧的双审完成单。
|
||
$todayInterviewReceiveRate = self::percent($todayOrderCount, $todayInterviewCount);
|
||
$yesterdayInterviewReceiveRate = self::percent($yesterdayOrderCount, $yesterdayInterviewCount);
|
||
|
||
$target = self::buildTargetProgress(
|
||
$adminId,
|
||
(string) ($roleScope['kind'] ?? PerformanceDashboardScope::KIND_ASSISTANT),
|
||
date('Y-m'),
|
||
$monthAmount,
|
||
$personalMonthAmount
|
||
);
|
||
|
||
return [
|
||
'scope' => $scope,
|
||
'performance' => [
|
||
'month_amount' => round($monthAmount, 2),
|
||
'month_compare_rate' => self::relativeChange($monthAmount, $previousMonthAmount),
|
||
'month_compare_label' => '较上月同期',
|
||
'today_amount' => round($todayOrderAmount, 2),
|
||
'today_compare_rate' => self::relativeChange($todayOrderAmount, $yesterdayOrderAmount),
|
||
'today_compare_label' => '较昨日',
|
||
'yesterday_amount' => round($yesterdayAmount, 2),
|
||
'yesterday_compare_rate' => self::relativeChange($yesterdayAmount, $dayBeforeAmount),
|
||
'yesterday_compare_label' => '较前一日',
|
||
'personal_month_amount' => round($personalMonthAmount, 2),
|
||
],
|
||
'today' => [
|
||
'add_fans_count' => $todayAddFansCount,
|
||
'appointment_total_count' => $todayAppointmentCount,
|
||
'low_amount_payment_count' => $todayLowAmountPaymentCount,
|
||
'interview_count' => $todayInterviewCount,
|
||
// 保留原响应字段名以兼容已发布前端,数值含义已统一为“计入业绩的业务订单”。
|
||
'completed_order_count' => $todayOrderCount,
|
||
'completed_order_amount' => $todayOrderAmount,
|
||
'paid_appointment_count' => $todayPaidAppointmentCount,
|
||
'paid_appointment_rate' => $todayPaidAppointmentRate,
|
||
'interview_receive_rate' => $todayInterviewReceiveRate,
|
||
'comparisons' => [
|
||
'add_fans_count' => self::buildComparison($todayAddFansCount, $yesterdayAddFansCount),
|
||
'appointment_total_count' => self::buildComparison(
|
||
$todayAppointmentCount,
|
||
$yesterdayAppointmentCount
|
||
),
|
||
'low_amount_payment_count' => self::buildComparison(
|
||
$todayLowAmountPaymentCount,
|
||
$yesterdayLowAmountPaymentCount
|
||
),
|
||
'interview_count' => self::buildComparison($todayInterviewCount, $yesterdayInterviewCount),
|
||
'completed_order_count' => self::buildComparison($todayOrderCount, $yesterdayOrderCount),
|
||
'completed_order_amount' => self::buildComparison($todayOrderAmount, $yesterdayOrderAmount),
|
||
'paid_appointment_rate' => self::buildComparison(
|
||
$todayPaidAppointmentRate,
|
||
$yesterdayPaidAppointmentRate
|
||
),
|
||
'interview_receive_rate' => self::buildComparison(
|
||
$todayInterviewReceiveRate,
|
||
$yesterdayInterviewReceiveRate
|
||
),
|
||
],
|
||
],
|
||
'rankings' => [
|
||
'appointments' => $appointmentRanking,
|
||
'performance' => [
|
||
'title' => $performanceTitle,
|
||
'kind' => $performanceKind,
|
||
'scope_label' => $performanceScopeLabel,
|
||
'items' => $performanceRanking,
|
||
],
|
||
],
|
||
'filters' => [
|
||
'ranking_departments' => $isGroupLeader
|
||
? []
|
||
: self::rankingDepartmentOptions($adminId, $adminInfo, $centerDeptIds),
|
||
'ranking_dept_id' => $isGroupLeader ? 0 : $rankingDeptId,
|
||
'ranking_selectable' => !$isGroupLeader,
|
||
],
|
||
'trend' => $trend,
|
||
'target' => $target,
|
||
'meta' => [
|
||
'generated_at' => date('Y-m-d H:i:s'),
|
||
'timezone' => date_default_timezone_get(),
|
||
'commission_note' => '本人业绩按当前账号创建的业务订单统计,排除已取消、拒收和退款订单。',
|
||
'rate_note' => '挂号及挂号率:按支付时间统计已支付且 0<实收金额<10 元的订单,每笔订单计 1 个挂号,并按订单创建人归属;预约按预约日期统计有效预约记录。接诊率:有效业务诊单数 / 已完成面诊数。',
|
||
],
|
||
];
|
||
}
|
||
|
||
/**
|
||
* @return array<string, mixed>
|
||
*/
|
||
private static function buildScopeContext(int $adminId, array $adminInfo): array
|
||
{
|
||
$scopeValue = DataScopeService::getEffectiveScope($adminInfo);
|
||
$visibleAdminIds = DataScopeService::getVisibleAdminIds($adminId, $adminInfo);
|
||
$roleIds = AdminRole::where('admin_id', $adminId)->column('role_id');
|
||
$roleIds = array_values(array_unique(array_filter(array_map('intval', $roleIds), static fn (int $id): bool => $id > 0)));
|
||
|
||
$roleNames = [];
|
||
if ($roleIds !== []) {
|
||
$roleNames = SystemRole::whereIn('id', $roleIds)
|
||
->whereNull('delete_time')
|
||
->order('sort', 'desc')
|
||
->column('name');
|
||
$roleNames = array_values(array_filter(array_map('strval', $roleNames)));
|
||
}
|
||
|
||
$deptIds = AdminDept::where('admin_id', $adminId)->column('dept_id');
|
||
$deptIds = array_values(array_unique(array_filter(array_map('intval', $deptIds), static fn (int $id): bool => $id > 0)));
|
||
$deptNames = [];
|
||
if ($deptIds !== []) {
|
||
$deptNames = Dept::whereIn('id', $deptIds)
|
||
->whereNull('delete_time')
|
||
->order('id', 'asc')
|
||
->column('name');
|
||
$deptNames = array_values(array_filter(array_map('strval', $deptNames)));
|
||
}
|
||
|
||
$isRoot = (int) ($adminInfo['root'] ?? 0) === 1;
|
||
$scopeKey = $isRoot ? 'root' : [
|
||
DataScopeService::SCOPE_ALL => 'all',
|
||
DataScopeService::SCOPE_DEPT_AND_CHILD => 'dept_children',
|
||
DataScopeService::SCOPE_DEPT => 'dept',
|
||
DataScopeService::SCOPE_SELF => 'self',
|
||
][$scopeValue] ?? 'self';
|
||
$scopeLabel = $isRoot ? '全部数据' : DataScopeService::scopeLabel($scopeValue);
|
||
|
||
if ($visibleAdminIds === null) {
|
||
$visibleMemberCount = (int) Db::name('admin')->whereNull('delete_time')->count();
|
||
} else {
|
||
$visibleMemberCount = count($visibleAdminIds);
|
||
}
|
||
|
||
return [
|
||
'key' => $scopeKey,
|
||
'scope_value' => $scopeValue,
|
||
'label' => $scopeLabel,
|
||
'is_limited' => $visibleAdminIds !== null,
|
||
'viewer_name' => (string) ($adminInfo['name'] ?? $adminInfo['account'] ?? ''),
|
||
'role_ids' => $roleIds,
|
||
'role_names' => $roleNames,
|
||
'department_names' => $deptNames,
|
||
'visible_member_count' => $visibleMemberCount,
|
||
'_visible_admin_ids' => $visibleAdminIds,
|
||
];
|
||
}
|
||
|
||
private static function resolveRankingDeptId(
|
||
int $requestedDeptId,
|
||
int $adminId,
|
||
array $adminInfo,
|
||
array $centerDeptIds = []
|
||
): int {
|
||
if ($requestedDeptId <= 0) {
|
||
return 0;
|
||
}
|
||
$exists = Dept::where('id', $requestedDeptId)->whereNull('delete_time')->count() > 0;
|
||
if (!$exists) {
|
||
return 0;
|
||
}
|
||
if ($centerDeptIds !== []) {
|
||
$allowedInCenter = array_fill_keys($centerDeptIds, true);
|
||
|
||
return isset($allowedInCenter[$requestedDeptId]) ? $requestedDeptId : 0;
|
||
}
|
||
|
||
$allowedDeptSet = DataScopeService::getAllowedDeptIdSet($adminId, $adminInfo);
|
||
if ($allowedDeptSet !== null && !isset($allowedDeptSet[$requestedDeptId])) {
|
||
return 0;
|
||
}
|
||
|
||
return $requestedDeptId;
|
||
}
|
||
|
||
/**
|
||
* @param int[] $centerDeptIds
|
||
* @return array<int, array<string, mixed>>
|
||
*/
|
||
private static function rankingDepartmentOptions(int $adminId, array $adminInfo, array $centerDeptIds = []): array
|
||
{
|
||
if ($centerDeptIds !== []) {
|
||
$tree = DeptLogic::getAllData();
|
||
if (!is_array($tree) || $tree === []) {
|
||
return [];
|
||
}
|
||
|
||
return self::filterDeptTreeByAllowedIds($tree, array_fill_keys($centerDeptIds, true));
|
||
}
|
||
|
||
return DeptLogic::getAllDataScoped($adminId, $adminInfo);
|
||
}
|
||
|
||
/**
|
||
* 只保留允许的部门节点;不允许的祖先只用来托举子树,不会出现在可选项里。
|
||
*
|
||
* @param array<int, array<string, mixed>> $nodes
|
||
* @param array<int, true> $allowedIdMap
|
||
* @return array<int, array<string, mixed>>
|
||
*/
|
||
private static function filterDeptTreeByAllowedIds(array $nodes, array $allowedIdMap): array
|
||
{
|
||
$out = [];
|
||
foreach ($nodes as $node) {
|
||
$id = (int) ($node['id'] ?? 0);
|
||
$rawChildren = $node['children'] ?? [];
|
||
$children = is_array($rawChildren) && $rawChildren !== []
|
||
? self::filterDeptTreeByAllowedIds($rawChildren, $allowedIdMap)
|
||
: [];
|
||
if (isset($allowedIdMap[$id])) {
|
||
$row = $node;
|
||
$row['children'] = $children;
|
||
$out[] = $row;
|
||
} else {
|
||
foreach ($children as $child) {
|
||
$out[] = $child;
|
||
}
|
||
}
|
||
}
|
||
|
||
return $out;
|
||
}
|
||
|
||
/**
|
||
* @param array<int>|null $visibleAdminIds
|
||
* @return array<string, array{amount: float, count: int}>
|
||
*/
|
||
private static function loadPerformanceOrderDaily(string $startDate, string $endDate, ?array $visibleAdminIds): array
|
||
{
|
||
if ($visibleAdminIds === []) {
|
||
return [];
|
||
}
|
||
|
||
$startTs = (int) strtotime($startDate . ' 00:00:00');
|
||
$endTs = (int) strtotime($endDate . ' 23:59:59');
|
||
$query = Db::name('tcm_prescription_order')
|
||
->alias('po')
|
||
->whereNull('po.delete_time')
|
||
->where('po.create_time', 'between', [$startTs, $endTs]);
|
||
|
||
YejiStatsLogic::applyPrescriptionOrderNotCancelledForPerformanceQuery($query, 'po');
|
||
|
||
if ($visibleAdminIds !== null) {
|
||
$query->whereIn('po.creator_id', $visibleAdminIds);
|
||
}
|
||
|
||
$rows = $query
|
||
->fieldRaw("FROM_UNIXTIME(po.create_time, '%Y-%m-%d') AS date_label, SUM(po.amount) AS amount_sum, COUNT(*) AS order_count")
|
||
->group('date_label')
|
||
->order('date_label', 'asc')
|
||
->select()
|
||
->toArray();
|
||
|
||
$out = [];
|
||
foreach ($rows as $row) {
|
||
$date = (string) ($row['date_label'] ?? '');
|
||
if ($date === '') {
|
||
continue;
|
||
}
|
||
$out[$date] = [
|
||
'amount' => round((float) ($row['amount_sum'] ?? 0), 2),
|
||
'count' => (int) ($row['order_count'] ?? 0),
|
||
];
|
||
}
|
||
|
||
return $out;
|
||
}
|
||
|
||
/**
|
||
* 0 < 实收金额 < 10 元的已支付订单笔数;退款订单状态为 4,不会进入统计。
|
||
*
|
||
* @param array<int>|null $visibleAdminIds
|
||
* @return array<string, array{count:int}>
|
||
*/
|
||
private static function loadRegistrationDaily(
|
||
string $startDate,
|
||
string $endDate,
|
||
?array $visibleAdminIds
|
||
): array {
|
||
if ($visibleAdminIds === []) {
|
||
return [];
|
||
}
|
||
|
||
$query = Db::name('order')
|
||
->whereNull('delete_time')
|
||
->where('status', 2)
|
||
->where('amount', '>', 0)
|
||
->where('amount', '<', 10)
|
||
// payment_time 是 DATETIME NULL;MySQL 8 严格模式下不能与空字符串比较。
|
||
->whereNotNull('payment_time')
|
||
->whereBetweenTime(
|
||
'payment_time',
|
||
$startDate . ' 00:00:00',
|
||
$endDate . ' 23:59:59'
|
||
);
|
||
if ($visibleAdminIds !== null) {
|
||
$query->whereIn('creator_id', $visibleAdminIds);
|
||
}
|
||
|
||
$rows = $query
|
||
->fieldRaw("DATE(payment_time) AS date_label, COUNT(*) AS item_count")
|
||
->group('date_label')
|
||
->select()
|
||
->toArray();
|
||
$out = [];
|
||
foreach ($rows as $row) {
|
||
$date = (string) ($row['date_label'] ?? '');
|
||
if ($date !== '') {
|
||
$out[$date] = ['count' => (int) ($row['item_count'] ?? 0)];
|
||
}
|
||
}
|
||
|
||
return $out;
|
||
}
|
||
|
||
/**
|
||
* @param array<string, array{amount: float, count: int}> $daily
|
||
*/
|
||
private static function sumDailyMetric(array $daily, string $startDate, string $endDate, string $metric): float
|
||
{
|
||
$sum = 0.0;
|
||
foreach ($daily as $date => $values) {
|
||
if ($date < $startDate || $date > $endDate) {
|
||
continue;
|
||
}
|
||
$sum += (float) ($values[$metric] ?? 0);
|
||
}
|
||
|
||
return round($sum, 2);
|
||
}
|
||
|
||
/**
|
||
* @param array<string, array{amount: float, count: int}> $daily
|
||
*/
|
||
private static function dailyMetric(array $daily, string $date, string $metric): float
|
||
{
|
||
return round((float) ($daily[$date][$metric] ?? 0), 2);
|
||
}
|
||
|
||
private static function percent(float $numerator, int $denominator): float
|
||
{
|
||
if ($denominator <= 0) {
|
||
return 0.0;
|
||
}
|
||
|
||
return round(($numerator / $denominator) * 100, 2);
|
||
}
|
||
|
||
private static function relativeChange(float $current, float $previous): ?float
|
||
{
|
||
if (abs($previous) < 0.00001) {
|
||
return null;
|
||
}
|
||
|
||
return round((($current - $previous) / $previous) * 100, 2);
|
||
}
|
||
|
||
/**
|
||
* @return array{direction: string, rate: float|null, previous: float}
|
||
*/
|
||
private static function buildComparison(float $current, float $previous): array
|
||
{
|
||
$difference = $current - $previous;
|
||
$direction = abs($difference) < 0.00001
|
||
? 'flat'
|
||
: ($difference > 0 ? 'up' : 'down');
|
||
|
||
return [
|
||
'direction' => $direction,
|
||
'rate' => self::relativeChange($current, $previous),
|
||
'previous' => round($previous, 2),
|
||
];
|
||
}
|
||
|
||
/**
|
||
* 登录人若挂在一中心 / 二中心子树内,实时排行默认看该中心成员。
|
||
* 例如挂在洛阳二中心一组,默认看该组,筛选可切换到二中心其它部门,不会混入一中心。
|
||
*
|
||
* @return array{dept_ids: int[], label: string, is_er: bool, own_dept_id: int}
|
||
*/
|
||
private static function resolveViewerCenterRankingScope(int $adminId): array
|
||
{
|
||
$empty = [
|
||
'dept_ids' => [],
|
||
'label' => '',
|
||
'is_er' => false,
|
||
'own_dept_id' => 0,
|
||
];
|
||
if ($adminId <= 0) {
|
||
return $empty;
|
||
}
|
||
|
||
$ownDeptIds = array_values(array_unique(array_filter(
|
||
array_map('intval', AdminDept::where('admin_id', $adminId)->column('dept_id')),
|
||
static fn (int $id): bool => $id > 0
|
||
)));
|
||
if ($ownDeptIds === []) {
|
||
return $empty;
|
||
}
|
||
|
||
$deptById = [];
|
||
$rows = Dept::whereNull('delete_time')->field(['id', 'pid', 'name'])->select()->toArray();
|
||
foreach ($rows as $row) {
|
||
$id = (int) ($row['id'] ?? 0);
|
||
if ($id <= 0) {
|
||
continue;
|
||
}
|
||
$deptById[$id] = [
|
||
'pid' => (int) ($row['pid'] ?? 0),
|
||
'name' => (string) ($row['name'] ?? ''),
|
||
];
|
||
}
|
||
|
||
$centerRoots = [];
|
||
foreach ($ownDeptIds as $deptId) {
|
||
$rootId = self::highestCenterAncestorId($deptId, $deptById);
|
||
if ($rootId > 0 && isset($deptById[$rootId])) {
|
||
$centerRoots[$rootId] = $deptById[$rootId]['name'];
|
||
}
|
||
}
|
||
if ($centerRoots === []) {
|
||
return $empty;
|
||
}
|
||
|
||
$deptIds = [];
|
||
$labels = [];
|
||
$isEr = false;
|
||
foreach ($centerRoots as $rootId => $name) {
|
||
foreach (DeptLogic::getSelfAndDescendantIds((int) $rootId) as $id) {
|
||
$id = (int) $id;
|
||
if ($id > 0) {
|
||
$deptIds[] = $id;
|
||
}
|
||
}
|
||
$label = self::centerRankingLabel($name);
|
||
if ($label !== '' && !in_array($label, $labels, true)) {
|
||
$labels[] = $label;
|
||
}
|
||
if (mb_strpos($name, '二中心') !== false) {
|
||
$isEr = true;
|
||
}
|
||
}
|
||
$deptIds = array_values(array_unique($deptIds));
|
||
|
||
return [
|
||
'dept_ids' => $deptIds,
|
||
'label' => implode('、', $labels),
|
||
'is_er' => $isEr,
|
||
'own_dept_id' => self::pickOwnDeptInCenter($ownDeptIds, $deptIds, $deptById),
|
||
];
|
||
}
|
||
|
||
/**
|
||
* @param int[] $ownDeptIds
|
||
* @param int[] $centerDeptIds
|
||
* @param array<int, array{pid: int, name: string}> $deptById
|
||
*/
|
||
private static function pickOwnDeptInCenter(array $ownDeptIds, array $centerDeptIds, array $deptById): int
|
||
{
|
||
$centerFlip = array_fill_keys($centerDeptIds, true);
|
||
$candidates = [];
|
||
foreach ($ownDeptIds as $id) {
|
||
if (isset($centerFlip[$id])) {
|
||
$candidates[] = $id;
|
||
}
|
||
}
|
||
if ($candidates === []) {
|
||
return 0;
|
||
}
|
||
|
||
$best = $candidates[0];
|
||
$bestDepth = -1;
|
||
foreach ($candidates as $id) {
|
||
$depth = 0;
|
||
$current = $id;
|
||
$seen = [];
|
||
while ($current > 0 && isset($deptById[$current]) && !isset($seen[$current])) {
|
||
$seen[$current] = true;
|
||
$depth++;
|
||
$current = $deptById[$current]['pid'];
|
||
}
|
||
if ($depth > $bestDepth || ($depth === $bestDepth && $id < $best)) {
|
||
$bestDepth = $depth;
|
||
$best = $id;
|
||
}
|
||
}
|
||
|
||
return $best;
|
||
}
|
||
|
||
/**
|
||
* @param array<int, array{pid: int, name: string}> $deptById
|
||
*/
|
||
private static function highestCenterAncestorId(int $deptId, array $deptById): int
|
||
{
|
||
$current = $deptId;
|
||
$seen = [];
|
||
$highest = 0;
|
||
while ($current > 0 && isset($deptById[$current]) && !isset($seen[$current])) {
|
||
$seen[$current] = true;
|
||
if (self::isCenterDeptName($deptById[$current]['name'])) {
|
||
$highest = $current;
|
||
}
|
||
$current = $deptById[$current]['pid'];
|
||
}
|
||
|
||
return $highest;
|
||
}
|
||
|
||
private static function isCenterDeptName(string $name): bool
|
||
{
|
||
return $name !== ''
|
||
&& (mb_strpos($name, '一中心') !== false || mb_strpos($name, '二中心') !== false);
|
||
}
|
||
|
||
private static function deptBelongsToErCenter(int $deptId): bool
|
||
{
|
||
if ($deptId <= 0) {
|
||
return false;
|
||
}
|
||
$erSet = DeptLogic::getErCenterSubtreeDeptIdSet();
|
||
|
||
return isset($erSet[$deptId]);
|
||
}
|
||
|
||
private static function centerRankingLabel(string $name): string
|
||
{
|
||
if (mb_strpos($name, '二中心') !== false) {
|
||
return '二中心';
|
||
}
|
||
if (mb_strpos($name, '一中心') !== false) {
|
||
return '一中心';
|
||
}
|
||
|
||
return $name;
|
||
}
|
||
|
||
/**
|
||
* @param array<string, mixed> $scope
|
||
* @param array<int>|null $baseVisibleAdminIds
|
||
* @param array{dept_ids: int[], label: string, is_er?: bool, own_dept_id?: int} $centerRanking
|
||
* @return array<string, mixed>
|
||
*/
|
||
private static function buildRegistrationRanking(
|
||
int $adminId,
|
||
array $scope,
|
||
?array $baseVisibleAdminIds,
|
||
int $rankingDeptId,
|
||
array $centerRanking
|
||
): array
|
||
{
|
||
$roleIds = array_map('intval', $scope['role_ids'] ?? []);
|
||
$isGroupLeader = ($scope['kind'] ?? '') === PerformanceDashboardScope::KIND_GROUP_LEADER;
|
||
$centerDeptIds = $centerRanking['dept_ids'] ?? [];
|
||
$centerLocked = $centerDeptIds !== [];
|
||
$isDoctorSelf = !$centerLocked
|
||
&& !$isGroupLeader
|
||
&& ($scope['key'] ?? '') === 'self'
|
||
&& in_array(1, $roleIds, true)
|
||
&& !in_array(2, $roleIds, true);
|
||
|
||
$rankingVisibleAdminIds = $baseVisibleAdminIds;
|
||
$rankingScopeLabel = (string) ($scope['label'] ?? '');
|
||
if ($isGroupLeader) {
|
||
$groupIds = $scope['group_admin_ids'] ?? null;
|
||
$rankingVisibleAdminIds = is_array($groupIds) ? array_values(array_filter(
|
||
array_map('intval', $groupIds),
|
||
static fn (int $id): bool => $id > 0
|
||
)) : [];
|
||
$rankingScopeLabel = '本小组';
|
||
} elseif ($centerLocked) {
|
||
$rankingVisibleAdminIds = self::adminsInDeptIds($centerDeptIds);
|
||
$rankingScopeLabel = (string) ($centerRanking['label'] ?? '本中心');
|
||
}
|
||
if (!$isGroupLeader && $rankingDeptId > 0) {
|
||
$deptAdminIds = self::departmentAdminIds($rankingDeptId);
|
||
$rankingVisibleAdminIds = $rankingVisibleAdminIds === null
|
||
? $deptAdminIds
|
||
: array_values(array_intersect($rankingVisibleAdminIds, $deptAdminIds));
|
||
$rankingScopeLabel = (string) (Dept::where('id', $rankingDeptId)->value('name') ?? $rankingScopeLabel);
|
||
}
|
||
|
||
$isErRanking = $rankingDeptId > 0
|
||
? self::deptBelongsToErCenter($rankingDeptId)
|
||
: (bool) ($centerRanking['is_er'] ?? false);
|
||
|
||
$items = $isErRanking
|
||
? self::loadAppointmentRankingItems($rankingVisibleAdminIds)
|
||
: self::loadRegistrationRankingItems($rankingVisibleAdminIds);
|
||
$items = self::finalizePersonRanking(
|
||
$items,
|
||
$rankingVisibleAdminIds,
|
||
$adminId,
|
||
(string) ($scope['viewer_name'] ?? ''),
|
||
$isGroupLeader
|
||
);
|
||
|
||
return [
|
||
'title' => $isErRanking ? '实时预约排行' : '实时挂号排行',
|
||
'kind' => $isDoctorSelf ? 'doctor' : 'member',
|
||
'metric' => $isErRanking ? 'appointment' : 'registration',
|
||
'scope_label' => $rankingScopeLabel,
|
||
'items' => $items,
|
||
];
|
||
}
|
||
|
||
/**
|
||
* @param array<int>|null $rankingVisibleAdminIds
|
||
* @return array<int, array<string, mixed>>
|
||
*/
|
||
private static function loadRegistrationRankingItems(?array $rankingVisibleAdminIds): array
|
||
{
|
||
$items = [];
|
||
if ($rankingVisibleAdminIds === []) {
|
||
return $items;
|
||
}
|
||
|
||
$query = Db::name('order')
|
||
->alias('o')
|
||
->join('admin a', 'a.id = o.creator_id AND a.delete_time IS NULL', 'INNER')
|
||
->whereNull('o.delete_time')
|
||
->where('o.status', 2)
|
||
->where('o.amount', '>', 0)
|
||
->where('o.amount', '<', 10)
|
||
->whereNotNull('o.payment_time')
|
||
->whereBetweenTime('o.payment_time', date('Y-m-d 00:00:00'), date('Y-m-d 23:59:59'));
|
||
if ($rankingVisibleAdminIds !== null) {
|
||
$query->whereIn('o.creator_id', $rankingVisibleAdminIds);
|
||
}
|
||
|
||
$rows = $query
|
||
->fieldRaw('o.creator_id AS id, a.name, COUNT(*) AS item_count, SUM(o.amount) AS amount_sum')
|
||
->group(['o.creator_id', 'a.name'])
|
||
->orderRaw('item_count DESC, amount_sum DESC, o.creator_id ASC')
|
||
->select()
|
||
->toArray();
|
||
foreach ($rows as $row) {
|
||
$items[] = [
|
||
'id' => (int) ($row['id'] ?? 0),
|
||
'name' => (string) ($row['name'] ?? ''),
|
||
'count' => (int) ($row['item_count'] ?? 0),
|
||
'amount' => round((float) ($row['amount_sum'] ?? 0), 2),
|
||
];
|
||
}
|
||
|
||
return $items;
|
||
}
|
||
|
||
/**
|
||
* 二中心实时预约排行:与驾驶舱「今日预约」同口径(预约日、状态已预约/已完成/改期)。
|
||
* 归属优先挂号医助、再诊单医助,缺失时回退医生。
|
||
*
|
||
* @param array<int>|null $rankingVisibleAdminIds
|
||
* @return array<int, array<string, mixed>>
|
||
*/
|
||
private static function loadAppointmentRankingItems(?array $rankingVisibleAdminIds): array
|
||
{
|
||
if ($rankingVisibleAdminIds === []) {
|
||
return [];
|
||
}
|
||
|
||
$today = date('Y-m-d');
|
||
$effectiveAssistantSql = 'COALESCE(NULLIF(da.assistant_id, 0), NULLIF(dg.assistant_id, 0))';
|
||
$rows = Db::name('doctor_appointment')
|
||
->alias('da')
|
||
->leftJoin('tcm_diagnosis dg', 'da.patient_id = dg.id')
|
||
->where('da.appointment_date', $today)
|
||
->whereIn('da.status', [1, 3, 4])
|
||
->whereRaw('(dg.id IS NULL OR dg.delete_time IS NULL)')
|
||
->field([
|
||
Db::raw("({$effectiveAssistantSql}) AS effective_assistant_id"),
|
||
'da.doctor_id',
|
||
Db::raw('COUNT(*) AS item_count'),
|
||
])
|
||
->group([$effectiveAssistantSql, 'da.doctor_id'])
|
||
->select()
|
||
->toArray();
|
||
|
||
$visibleFlip = $rankingVisibleAdminIds !== null ? array_flip($rankingVisibleAdminIds) : null;
|
||
$counts = [];
|
||
foreach ($rows as $row) {
|
||
$assistantId = (int) ($row['effective_assistant_id'] ?? 0);
|
||
$doctorId = (int) ($row['doctor_id'] ?? 0);
|
||
$ownerId = $assistantId > 0 ? $assistantId : $doctorId;
|
||
if ($ownerId <= 0) {
|
||
continue;
|
||
}
|
||
if ($visibleFlip !== null && !isset($visibleFlip[$ownerId])) {
|
||
continue;
|
||
}
|
||
$counts[$ownerId] = ($counts[$ownerId] ?? 0) + (int) ($row['item_count'] ?? 0);
|
||
}
|
||
if ($counts === []) {
|
||
return [];
|
||
}
|
||
|
||
arsort($counts, SORT_NUMERIC);
|
||
$ownerIds = array_keys($counts);
|
||
$nameMap = [];
|
||
if ($ownerIds !== []) {
|
||
$nameRows = Db::name('admin')
|
||
->whereIn('id', $ownerIds)
|
||
->whereNull('delete_time')
|
||
->field(['id', 'name'])
|
||
->select()
|
||
->toArray();
|
||
foreach ($nameRows as $nameRow) {
|
||
$nameMap[(int) ($nameRow['id'] ?? 0)] = (string) ($nameRow['name'] ?? '');
|
||
}
|
||
}
|
||
|
||
$items = [];
|
||
foreach ($ownerIds as $ownerId) {
|
||
$items[] = [
|
||
'id' => (int) $ownerId,
|
||
'name' => $nameMap[(int) $ownerId] ?? '',
|
||
'count' => (int) ($counts[$ownerId] ?? 0),
|
||
'amount' => 0.0,
|
||
];
|
||
}
|
||
|
||
return $items;
|
||
}
|
||
|
||
/**
|
||
* 把范围内所有成员补进排行(没单的记 0)。组长看全组;其他人仍只留前 5 + 本人。
|
||
*
|
||
* @param array<int, array<string, mixed>> $items
|
||
* @param array<int>|null $memberIds
|
||
* @return array<int, array<string, mixed>>
|
||
*/
|
||
private static function finalizePersonRanking(
|
||
array $items,
|
||
?array $memberIds,
|
||
int $viewerId,
|
||
string $viewerName,
|
||
bool $showAllMembers = false
|
||
): array {
|
||
if ($memberIds !== null) {
|
||
$memberIds = array_values(array_unique(array_filter(
|
||
array_map('intval', $memberIds),
|
||
static fn (int $id): bool => $id > 0
|
||
)));
|
||
$byId = [];
|
||
foreach ($items as $item) {
|
||
$id = (int) ($item['id'] ?? 0);
|
||
if ($id > 0) {
|
||
$byId[$id] = $item;
|
||
}
|
||
}
|
||
$missing = [];
|
||
foreach ($memberIds as $id) {
|
||
if (!isset($byId[$id])) {
|
||
$missing[] = $id;
|
||
}
|
||
}
|
||
$nameMap = [];
|
||
$lookupIds = $missing;
|
||
if ($viewerId > 0 && ($viewerName === '' || isset($byId[$viewerId]) === false)) {
|
||
$lookupIds[] = $viewerId;
|
||
}
|
||
$lookupIds = array_values(array_unique($lookupIds));
|
||
if ($lookupIds !== []) {
|
||
$nameMap = Db::name('admin')
|
||
->whereIn('id', $lookupIds)
|
||
->whereNull('delete_time')
|
||
->column('name', 'id');
|
||
}
|
||
$activeFlip = [];
|
||
if ($memberIds !== []) {
|
||
$activeIds = Db::name('admin')
|
||
->whereIn('id', $memberIds)
|
||
->whereNull('delete_time')
|
||
->column('id');
|
||
$activeFlip = array_fill_keys(array_map('intval', $activeIds), true);
|
||
}
|
||
foreach ($memberIds as $id) {
|
||
if (!isset($activeFlip[$id]) && $id !== $viewerId) {
|
||
continue;
|
||
}
|
||
if (!isset($byId[$id])) {
|
||
$byId[$id] = [
|
||
'id' => $id,
|
||
'name' => (string) ($nameMap[$id] ?? ''),
|
||
'count' => 0,
|
||
'amount' => 0.0,
|
||
];
|
||
}
|
||
}
|
||
$items = array_values($byId);
|
||
usort($items, static function (array $a, array $b): int {
|
||
$byAmount = (float) ($b['amount'] ?? 0) <=> (float) ($a['amount'] ?? 0);
|
||
if ($byAmount !== 0) {
|
||
return $byAmount;
|
||
}
|
||
$byCount = (int) ($b['count'] ?? 0) <=> (int) ($a['count'] ?? 0);
|
||
if ($byCount !== 0) {
|
||
return $byCount;
|
||
}
|
||
|
||
return (int) ($a['id'] ?? 0) <=> (int) ($b['id'] ?? 0);
|
||
});
|
||
}
|
||
|
||
if ($showAllMembers) {
|
||
foreach ($items as &$item) {
|
||
$item['is_self'] = (int) ($item['id'] ?? 0) === $viewerId;
|
||
if ($item['is_self'] && $viewerName !== '' && (string) ($item['name'] ?? '') === '') {
|
||
$item['name'] = $viewerName;
|
||
}
|
||
}
|
||
unset($item);
|
||
if ($viewerId > 0) {
|
||
$hasSelf = false;
|
||
foreach ($items as $item) {
|
||
if (!empty($item['is_self'])) {
|
||
$hasSelf = true;
|
||
break;
|
||
}
|
||
}
|
||
if (!$hasSelf) {
|
||
$items[] = [
|
||
'id' => $viewerId,
|
||
'name' => $viewerName,
|
||
'count' => 0,
|
||
'amount' => 0.0,
|
||
'is_self' => true,
|
||
];
|
||
}
|
||
}
|
||
|
||
return $items;
|
||
}
|
||
|
||
return self::ensureViewerInRanking($items, $viewerId, $viewerName);
|
||
}
|
||
|
||
/**
|
||
* 排行保留前 5 名,若当前登录人不在榜内则追加到底部,并用 is_self 标识。
|
||
*
|
||
* @param array<int, array<string, mixed>> $items
|
||
* @return array<int, array<string, mixed>>
|
||
*/
|
||
private static function ensureViewerInRanking(array $items, int $viewerId, string $viewerName, int $limit = 5): array
|
||
{
|
||
if ($viewerId <= 0) {
|
||
return array_slice($items, 0, $limit);
|
||
}
|
||
|
||
$selfItem = null;
|
||
foreach ($items as &$item) {
|
||
$isSelf = (int) ($item['id'] ?? 0) === $viewerId;
|
||
$item['is_self'] = $isSelf;
|
||
if ($isSelf) {
|
||
$selfItem = $item;
|
||
}
|
||
}
|
||
unset($item);
|
||
|
||
$top = array_slice($items, 0, $limit);
|
||
foreach ($top as $item) {
|
||
if (!empty($item['is_self'])) {
|
||
return $top;
|
||
}
|
||
}
|
||
|
||
$top[] = $selfItem ?? [
|
||
'id' => $viewerId,
|
||
'name' => $viewerName,
|
||
'count' => 0,
|
||
'amount' => 0.0,
|
||
'is_self' => true,
|
||
];
|
||
|
||
return $top;
|
||
}
|
||
|
||
/**
|
||
* 按个人今日业绩排行。组长会补齐组内全部成员(没单也列出)。
|
||
*
|
||
* @param array<int>|null $adminIds
|
||
* @return array<int, array<string, mixed>>
|
||
*/
|
||
private static function loadPersonalPerformanceRanking(
|
||
?array $adminIds,
|
||
int $viewerId,
|
||
string $viewerName,
|
||
string $today,
|
||
bool $showAllMembers = false
|
||
): array {
|
||
if ($adminIds === []) {
|
||
return self::finalizePersonRanking([], $adminIds, $viewerId, $viewerName, $showAllMembers);
|
||
}
|
||
|
||
$startTs = (int) strtotime($today . ' 00:00:00');
|
||
$endTs = (int) strtotime($today . ' 23:59:59');
|
||
$query = Db::name('tcm_prescription_order')
|
||
->alias('po')
|
||
->join('admin a', 'a.id = po.creator_id AND a.delete_time IS NULL', 'INNER')
|
||
->whereNull('po.delete_time')
|
||
->where('po.create_time', 'between', [$startTs, $endTs]);
|
||
YejiStatsLogic::applyPrescriptionOrderNotCancelledForPerformanceQuery($query, 'po');
|
||
if ($adminIds !== null) {
|
||
$query->whereIn('po.creator_id', $adminIds);
|
||
}
|
||
|
||
$rows = $query
|
||
->fieldRaw('po.creator_id AS id, a.name, COUNT(*) AS order_count, SUM(po.amount) AS amount_sum')
|
||
->group(['po.creator_id', 'a.name'])
|
||
->orderRaw('amount_sum DESC, order_count DESC, po.creator_id ASC')
|
||
->select()
|
||
->toArray();
|
||
|
||
$items = [];
|
||
foreach ($rows as $row) {
|
||
$items[] = [
|
||
'id' => (int) ($row['id'] ?? 0),
|
||
'name' => (string) ($row['name'] ?? ''),
|
||
'count' => (int) ($row['order_count'] ?? 0),
|
||
'amount' => round((float) ($row['amount_sum'] ?? 0), 2),
|
||
];
|
||
}
|
||
|
||
return self::finalizePersonRanking($items, $adminIds, $viewerId, $viewerName, $showAllMembers);
|
||
}
|
||
|
||
/** @return int[] */
|
||
private static function departmentAdminIds(int $deptId): array
|
||
{
|
||
return self::adminsInDeptIds(DeptLogic::getSelfAndDescendantIds($deptId));
|
||
}
|
||
|
||
/**
|
||
* @param int[] $deptIds
|
||
* @return int[]
|
||
*/
|
||
private static function adminsInDeptIds(array $deptIds): array
|
||
{
|
||
$deptIds = array_values(array_unique(array_filter(
|
||
array_map('intval', $deptIds),
|
||
static fn (int $id): bool => $id > 0
|
||
)));
|
||
if ($deptIds === []) {
|
||
return [];
|
||
}
|
||
|
||
return array_values(array_unique(array_filter(
|
||
array_map('intval', AdminDept::whereIn('dept_id', $deptIds)->column('admin_id')),
|
||
static fn (int $id): bool => $id > 0
|
||
)));
|
||
}
|
||
|
||
/**
|
||
* @param array<int, array<string, mixed>> $rows
|
||
* @return array<int, array<string, mixed>>
|
||
*/
|
||
private static function buildPerformanceRanking(array $rows): array
|
||
{
|
||
usort($rows, static function (array $a, array $b): int {
|
||
$byAmount = (float) ($b['performance_amount'] ?? 0) <=> (float) ($a['performance_amount'] ?? 0);
|
||
if ($byAmount !== 0) {
|
||
return $byAmount;
|
||
}
|
||
|
||
$byCount = (int) ($b['deal_order_count'] ?? 0) <=> (int) ($a['deal_order_count'] ?? 0);
|
||
if ($byCount !== 0) {
|
||
return $byCount;
|
||
}
|
||
|
||
return (int) ($a['dept_id'] ?? 0) <=> (int) ($b['dept_id'] ?? 0);
|
||
});
|
||
|
||
$items = [];
|
||
foreach (array_slice($rows, 0, 5) as $row) {
|
||
$items[] = [
|
||
'id' => (int) ($row['dept_id'] ?? 0),
|
||
'name' => (string) ($row['dept_name'] ?? '未归属中心'),
|
||
'amount' => round((float) ($row['performance_amount'] ?? 0), 2),
|
||
'count' => (int) ($row['deal_order_count'] ?? 0),
|
||
];
|
||
}
|
||
|
||
return $items;
|
||
}
|
||
|
||
/**
|
||
* @param array<string, mixed> $trendContext
|
||
* @param array<int>|null $visibleAdminIds
|
||
* @return array<string, mixed>
|
||
*/
|
||
private static function narrowTrendContext(array $trendContext, ?array $visibleAdminIds): array
|
||
{
|
||
if ($visibleAdminIds === null) {
|
||
return $trendContext;
|
||
}
|
||
$flip = array_flip($visibleAdminIds);
|
||
$adminToPrimary = is_array($trendContext['adminToPrimary'] ?? null)
|
||
? $trendContext['adminToPrimary']
|
||
: [];
|
||
$filtered = [];
|
||
$extraDeptIds = [];
|
||
foreach ($adminToPrimary as $adminId => $deptId) {
|
||
$adminId = (int) $adminId;
|
||
$deptId = (int) $deptId;
|
||
if (!isset($flip[$adminId])) {
|
||
continue;
|
||
}
|
||
$filtered[$adminId] = $deptId;
|
||
if ($deptId > 0) {
|
||
$extraDeptIds[$deptId] = true;
|
||
}
|
||
}
|
||
$trendContext['adminToPrimary'] = $filtered;
|
||
$tableRowDeptIds = is_array($trendContext['tableRowDeptIds'] ?? null)
|
||
? array_map('intval', $trendContext['tableRowDeptIds'])
|
||
: [];
|
||
foreach (array_keys($extraDeptIds) as $deptId) {
|
||
$tableRowDeptIds[] = (int) $deptId;
|
||
}
|
||
$trendContext['tableRowDeptIds'] = array_values(array_unique(array_filter(
|
||
$tableRowDeptIds,
|
||
static fn (int $id): bool => $id > 0
|
||
)));
|
||
|
||
return $trendContext;
|
||
}
|
||
|
||
/**
|
||
* @param array<int>|null $visibleAdminIds
|
||
* @param array<string, array{amount: float, count: int}> $orderDaily
|
||
* @param array<string, array{count: int}> $registrationDaily
|
||
* @param array<string, mixed> $trendContext
|
||
* @return array<string, mixed>
|
||
*/
|
||
private static function buildTrend(
|
||
string $startDate,
|
||
string $endDate,
|
||
?array $visibleAdminIds,
|
||
array $orderDaily,
|
||
array $registrationDaily,
|
||
array $trendContext
|
||
): array {
|
||
$adminToPrimary = is_array($trendContext['adminToPrimary'] ?? null)
|
||
? $trendContext['adminToPrimary']
|
||
: [];
|
||
$tableRowDeptIds = is_array($trendContext['tableRowDeptIds'] ?? null)
|
||
? array_values(array_map('intval', $trendContext['tableRowDeptIds']))
|
||
: [];
|
||
$leadDaily = self::loadLeadDaily($startDate, $endDate, $adminToPrimary, $tableRowDeptIds);
|
||
$appointmentDaily = self::loadAppointmentDaily(
|
||
$startDate,
|
||
$endDate,
|
||
$visibleAdminIds,
|
||
$adminToPrimary,
|
||
$tableRowDeptIds
|
||
);
|
||
$dates = [];
|
||
$registrations = [];
|
||
$appointments = [];
|
||
$leads = [];
|
||
$orders = [];
|
||
|
||
$cursor = strtotime($startDate);
|
||
$end = strtotime($endDate);
|
||
while ($cursor <= $end) {
|
||
$date = date('Y-m-d', $cursor);
|
||
$dates[] = date('m-d', $cursor);
|
||
$registrations[] = (int) ($registrationDaily[$date]['count'] ?? 0);
|
||
$appointments[] = (int) ($appointmentDaily[$date] ?? 0);
|
||
$leads[] = (int) ($leadDaily[$date] ?? 0);
|
||
$orders[] = (int) ($orderDaily[$date]['count'] ?? 0);
|
||
$cursor = strtotime('+1 day', $cursor);
|
||
}
|
||
|
||
return [
|
||
'date_range' => [$startDate, $endDate],
|
||
'dates' => $dates,
|
||
'registrations' => $registrations,
|
||
'appointments' => $appointments,
|
||
'leads' => $leads,
|
||
'orders' => $orders,
|
||
];
|
||
}
|
||
|
||
/**
|
||
* 与 YejiStatsLogic 的“进线”一致:只有能映射到当前业绩中心展示行的管理员事件才计入。
|
||
*
|
||
* @param array<int, int> $adminToPrimary
|
||
* @param int[] $tableRowDeptIds
|
||
* @return array<string, int>
|
||
*/
|
||
private static function loadLeadDaily(
|
||
string $startDate,
|
||
string $endDate,
|
||
array $adminToPrimary,
|
||
array $tableRowDeptIds
|
||
): array
|
||
{
|
||
$rowFlip = array_flip($tableRowDeptIds);
|
||
$mappedAdminIds = [];
|
||
foreach ($adminToPrimary as $adminId => $deptId) {
|
||
$adminId = (int) $adminId;
|
||
$deptId = (int) $deptId;
|
||
if ($adminId > 0 && isset($rowFlip[$deptId])) {
|
||
$mappedAdminIds[] = $adminId;
|
||
}
|
||
}
|
||
$mappedAdminIds = array_values(array_unique($mappedAdminIds));
|
||
if ($mappedAdminIds === []) {
|
||
return [];
|
||
}
|
||
|
||
$query = Db::name('qywx_external_contact_event')
|
||
->alias('e')
|
||
->join('admin a', 'a.work_wechat_userid = e.user_id AND a.delete_time IS NULL', 'INNER')
|
||
->where('e.change_type', 'add_external_contact')
|
||
->whereIn('a.id', $mappedAdminIds)
|
||
->where('e.event_time', 'between', [
|
||
strtotime($startDate . ' 00:00:00'),
|
||
strtotime($endDate . ' 23:59:59'),
|
||
]);
|
||
|
||
$rows = $query
|
||
->fieldRaw("FROM_UNIXTIME(e.event_time, '%Y-%m-%d') AS date_label, COUNT(*) AS item_count")
|
||
->group('date_label')
|
||
->select()
|
||
->toArray();
|
||
|
||
$out = [];
|
||
foreach ($rows as $row) {
|
||
$date = (string) ($row['date_label'] ?? '');
|
||
if ($date !== '') {
|
||
$out[$date] = (int) ($row['item_count'] ?? 0);
|
||
}
|
||
}
|
||
|
||
return $out;
|
||
}
|
||
|
||
/**
|
||
* @param array<int>|null $visibleAdminIds
|
||
* 与 YejiStatsLogic 的“预约诊单”一致:appointment_date,状态 1/3/4,
|
||
* 归属优先挂号医助、再诊单医助,缺失时回退医生;受限账号只保留当前业绩中心展示行。
|
||
*
|
||
* @param array<int>|null $visibleAdminIds
|
||
* @param array<int, int> $adminToPrimary
|
||
* @param int[] $tableRowDeptIds
|
||
* @return array<string, int>
|
||
*/
|
||
private static function loadAppointmentDaily(
|
||
string $startDate,
|
||
string $endDate,
|
||
?array $visibleAdminIds,
|
||
array $adminToPrimary,
|
||
array $tableRowDeptIds
|
||
): array {
|
||
if ($visibleAdminIds === []) {
|
||
return [];
|
||
}
|
||
|
||
$effectiveAssistantSql = 'COALESCE(NULLIF(a.assistant_id, 0), NULLIF(u.assistant_id, 0))';
|
||
$query = Db::name('doctor_appointment')
|
||
->alias('a')
|
||
->leftJoin('tcm_diagnosis u', 'a.patient_id = u.id')
|
||
->where('a.appointment_date', 'between', [$startDate, $endDate])
|
||
->whereIn('a.status', [1, 3, 4])
|
||
->whereRaw('(u.id IS NULL OR u.delete_time IS NULL)');
|
||
|
||
$rows = $query
|
||
->field([
|
||
'a.appointment_date AS date_label',
|
||
Db::raw("({$effectiveAssistantSql}) AS effective_assistant_id"),
|
||
'a.doctor_id',
|
||
Db::raw('COUNT(*) AS appointment_count'),
|
||
])
|
||
->group(['a.appointment_date', $effectiveAssistantSql, 'a.doctor_id'])
|
||
->select()
|
||
->toArray();
|
||
|
||
$visibleFlip = $visibleAdminIds !== null ? array_flip($visibleAdminIds) : null;
|
||
$rowFlip = array_flip($tableRowDeptIds);
|
||
$out = [];
|
||
foreach ($rows as $row) {
|
||
$date = (string) ($row['date_label'] ?? '');
|
||
if ($date === '') {
|
||
continue;
|
||
}
|
||
$effectiveAssistantId = (int) ($row['effective_assistant_id'] ?? 0);
|
||
$doctorId = (int) ($row['doctor_id'] ?? 0);
|
||
if ($visibleFlip !== null) {
|
||
if ($effectiveAssistantId > 0) {
|
||
if (!isset($visibleFlip[$effectiveAssistantId])) {
|
||
continue;
|
||
}
|
||
} elseif ($doctorId <= 0 || !isset($visibleFlip[$doctorId])) {
|
||
continue;
|
||
}
|
||
}
|
||
|
||
$deptId = $effectiveAssistantId > 0
|
||
? (int) ($adminToPrimary[$effectiveAssistantId] ?? 0)
|
||
: 0;
|
||
if ($deptId <= 0 && $doctorId > 0) {
|
||
$deptId = (int) ($adminToPrimary[$doctorId] ?? 0);
|
||
}
|
||
if ($visibleFlip !== null && !isset($rowFlip[$deptId])) {
|
||
continue;
|
||
}
|
||
|
||
$out[$date] = ($out[$date] ?? 0) + (int) ($row['appointment_count'] ?? 0);
|
||
}
|
||
|
||
return $out;
|
||
}
|
||
|
||
/**
|
||
* @return array<string, mixed>
|
||
*/
|
||
private static function buildTargetProgress(
|
||
int $adminId,
|
||
string $kind,
|
||
string $yearMonth,
|
||
float $completedAmount,
|
||
float $personalAmount
|
||
): array {
|
||
$deptIds = self::targetDeptIds($adminId, $kind);
|
||
$query = Db::name('dept_performance_target')->where('year_month', $yearMonth);
|
||
if ($deptIds !== null) {
|
||
if ($deptIds === []) {
|
||
return self::emptyTarget($yearMonth, $completedAmount, $personalAmount);
|
||
}
|
||
$query->whereIn('dept_id', $deptIds);
|
||
}
|
||
|
||
$rows = $query->field('dept_id, dept_name, target_amount')->select()->toArray();
|
||
$targetAmount = 0.0;
|
||
foreach ($rows as $row) {
|
||
$targetAmount += (float) ($row['target_amount'] ?? 0);
|
||
}
|
||
$targetAmount = round($targetAmount, 2);
|
||
|
||
return [
|
||
'year_month' => $yearMonth,
|
||
'target_amount' => $targetAmount,
|
||
'completed_amount' => round($completedAmount, 2),
|
||
'completion_rate' => $targetAmount > 0 ? round($completedAmount / $targetAmount * 100, 2) : null,
|
||
'personal_amount' => round($personalAmount, 2),
|
||
'personal_contribution_rate' => $completedAmount > 0 ? round($personalAmount / $completedAmount * 100, 2) : null,
|
||
'department_count' => count($rows),
|
||
];
|
||
}
|
||
|
||
/**
|
||
* @return array<int>|null null 表示全部部门。
|
||
*/
|
||
private static function targetDeptIds(int $adminId, string $kind): ?array
|
||
{
|
||
if ($kind === PerformanceDashboardScope::KIND_ADMIN) {
|
||
return null;
|
||
}
|
||
|
||
$ownDeptIds = AdminDept::where('admin_id', $adminId)->column('dept_id');
|
||
$ownDeptIds = array_values(array_unique(array_filter(
|
||
array_map('intval', $ownDeptIds),
|
||
static fn (int $id): bool => $id > 0
|
||
)));
|
||
if ($ownDeptIds === [] || $kind === PerformanceDashboardScope::KIND_ASSISTANT) {
|
||
return $ownDeptIds;
|
||
}
|
||
|
||
$out = [];
|
||
foreach ($ownDeptIds as $deptId) {
|
||
foreach (DeptLogic::getSelfAndDescendantIds($deptId) as $id) {
|
||
$id = (int) $id;
|
||
if ($id > 0) {
|
||
$out[$id] = true;
|
||
}
|
||
}
|
||
}
|
||
|
||
return array_keys($out);
|
||
}
|
||
|
||
/**
|
||
* @return array<string, mixed>
|
||
*/
|
||
private static function emptyTarget(string $yearMonth, float $completedAmount, float $personalAmount): array
|
||
{
|
||
return [
|
||
'year_month' => $yearMonth,
|
||
'target_amount' => 0.0,
|
||
'completed_amount' => round($completedAmount, 2),
|
||
'completion_rate' => null,
|
||
'personal_amount' => round($personalAmount, 2),
|
||
'personal_contribution_rate' => $completedAmount > 0 ? round($personalAmount / $completedAmount * 100, 2) : null,
|
||
'department_count' => 0,
|
||
];
|
||
}
|
||
}
|