Files
zyt/server/app/adminapi/logic/stats/PerformanceDashboardLogic.php
T
2026-08-17 09:06:49 +08:00

1505 lines
56 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\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 NULLMySQL 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,
];
}
}