Files
zyt/server/app/adminapi/logic/stats/PerformanceDashboardLogic.php
T
2026-08-07 09:16:15 +08:00

968 lines
38 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;它们不是业绩指标。
* - 趋势使用同一业绩条件的轻量按日 SQL,固定补齐最近 7 个自然日。
* - 所有查询都使用 DataScopeService 返回的可见管理员集合收窄。
*/
class PerformanceDashboardLogic
{
private const TREND_DAYS = 7;
private const PAID_APPOINTMENT_DEPT_NAME = '一中心';
/**
* @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);
/** @var array<int>|null $visibleAdminIds */
$visibleAdminIds = $scope['_visible_admin_ids'];
unset($scope['_visible_admin_ids']);
$rankingDeptId = self::resolveRankingDeptId(
max(0, (int) ($params['ranking_dept_id'] ?? 0)),
$adminId,
$adminInfo
);
$orderDaily = self::loadPerformanceOrderDaily($previousMonthStart, $today, $visibleAdminIds);
$personalOrderDaily = self::loadPerformanceOrderDaily($monthStart, $today, [$adminId]);
$lowAmountPaymentDaily = self::loadLowAmountPaymentDaily($yesterday, $today, $visibleAdminIds);
$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);
$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);
$yesterdaySummary = is_array($yesterdayOverview['summary'] ?? null)
? $yesterdayOverview['summary']
: [];
// 业绩指标必须直接复用业绩页的权威聚合,不能使用 ConversionLogic 的“双审完成单”。
$todayPerformanceOverview = YejiStatsLogic::overview([
'start_date' => $today,
'end_date' => $today,
], $adminId, $adminInfo);
$appointmentRanking = self::buildAppointmentRanking(
$adminId,
$adminInfo,
$scope,
$visibleAdminIds,
$rankingDeptId
);
$performanceRanking = self::buildPerformanceRanking(
is_array($todayPerformanceOverview['rows'] ?? null) ? $todayPerformanceOverview['rows'] : []
);
$trendContext = YejiStatsLogic::resolveSharedYejiFilterContext([
'start_date' => $trendStart,
'end_date' => $today,
], $adminId, $adminInfo);
$trend = self::buildTrend($trendStart, $today, $visibleAdminIds, $orderDaily, $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($lowAmountPaymentDaily, $today, 'count');
$yesterdayLowAmountPaymentCount = (int) self::dailyMetric($lowAmountPaymentDaily, $yesterday, 'count');
$todayPaidAppointmentCount = self::paidAppointmentCountWithCenterRule($todayOverview);
$yesterdayPaidAppointmentCount = self::paidAppointmentCountWithCenterRule($yesterdayOverview);
$todayPaidAppointmentRate = self::percent($todayPaidAppointmentCount, $todayAddFansCount);
$yesterdayPaidAppointmentRate = self::percent($yesterdayPaidAppointmentCount, $yesterdayAddFansCount);
// 接诊卡片使用的是有效业务订单,接诊率必须使用同一订单口径,不能继续读取旧的双审完成单。
$todayInterviewReceiveRate = self::percent($todayOrderCount, $todayInterviewCount);
$yesterdayInterviewReceiveRate = self::percent($yesterdayOrderCount, $yesterdayInterviewCount);
$target = self::buildTargetProgress(
$adminId,
(int) ($scope['scope_value'] ?? DataScopeService::SCOPE_SELF),
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' => '今日部门业绩排行',
'scope_label' => (string) ($scope['label'] ?? ''),
'items' => $performanceRanking,
],
],
'filters' => [
'ranking_departments' => self::rankingDepartmentOptions($adminId, $adminInfo),
'ranking_dept_id' => $rankingDeptId,
],
'trend' => $trend,
'target' => $target,
'meta' => [
'generated_at' => date('Y-m-d H:i:s'),
'timezone' => date_default_timezone_get(),
'commission_note' => '本人业绩按当前账号创建的业务订单统计,排除已取消、拒收和退款订单。',
'rate_note' => '付费挂号率:一中心员工的有效挂号全部按付费挂号,其他部门按 5 元实付挂号;接诊率:有效业务诊单数 / 已完成面诊数。近 7 天挂号排除已取消记录,诊单按订单创建时间统计并排除履约 4/9/10。',
],
];
}
/**
* 一中心员工的有效挂号全部视为付费;其它部门仍沿用 5 元实付挂号口径。
* 部门树的父节点已汇总子节点,因此命中“一中心”后不再向下递归,避免重复累计。
*
* @param array<string, mixed> $overview
*/
private static function paidAppointmentCountWithCenterRule(array $overview): int
{
$summary = is_array($overview['summary'] ?? null) ? $overview['summary'] : [];
$paidTotal = (int) ($summary['paid_appointment_count'] ?? 0);
$rows = is_array($overview['lists'] ?? null) ? $overview['lists'] : [];
[$centerAppointments, $centerPaidAppointments] = self::namedDepartmentAppointmentMetrics(
$rows,
self::PAID_APPOINTMENT_DEPT_NAME
);
return max(0, $paidTotal - $centerPaidAppointments + $centerAppointments);
}
/**
* @param array<int, array<string, mixed>> $rows
* @return array{0:int,1:int}
*/
private static function namedDepartmentAppointmentMetrics(array $rows, string $departmentName): array
{
$appointmentCount = 0;
$paidAppointmentCount = 0;
foreach ($rows as $row) {
if (trim((string) ($row['name'] ?? '')) === $departmentName) {
$appointmentCount += (int) ($row['appointment_total_count'] ?? 0);
$paidAppointmentCount += (int) ($row['paid_appointment_count'] ?? 0);
continue;
}
$children = is_array($row['children'] ?? null) ? $row['children'] : [];
if ($children === []) {
continue;
}
[$childAppointments, $childPaidAppointments] = self::namedDepartmentAppointmentMetrics(
$children,
$departmentName
);
$appointmentCount += $childAppointments;
$paidAppointmentCount += $childPaidAppointments;
}
return [$appointmentCount, $paidAppointmentCount];
}
/**
* @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): int
{
if ($requestedDeptId <= 0) {
return 0;
}
$exists = Dept::where('id', $requestedDeptId)->whereNull('delete_time')->count() > 0;
if (!$exists) {
return 0;
}
$allowedDeptSet = DataScopeService::getAllowedDeptIdSet($adminId, $adminInfo);
if ($allowedDeptSet !== null && !isset($allowedDeptSet[$requestedDeptId])) {
return 0;
}
return $requestedDeptId;
}
/** @return array<int, array<string, mixed>> */
private static function rankingDepartmentOptions(int $adminId, array $adminInfo): array
{
return DeptLogic::getAllDataScoped($adminId, $adminInfo);
}
/**
* @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 loadLowAmountPaymentDaily(
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)
->whereNotNull('payment_time')
->where('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),
];
}
/**
* @param array<string, mixed> $scope
* @return array<string, mixed>
*/
private static function buildAppointmentRanking(
int $adminId,
array $adminInfo,
array $scope,
?array $baseVisibleAdminIds,
int $rankingDeptId
): array
{
$roleIds = array_map('intval', $scope['role_ids'] ?? []);
$isDoctorSelf = ($scope['key'] ?? '') === 'self'
&& in_array(1, $roleIds, true)
&& !in_array(2, $roleIds, true);
if ($isDoctorSelf) {
$doctorStats = DoctorDailyStatsLogic::overview([
'start_date' => date('Y-m-d'),
'end_date' => date('Y-m-d'),
], $adminId, $adminInfo);
$items = [];
foreach (array_slice($doctorStats['rows'] ?? [], 0, 5) as $row) {
$items[] = [
'id' => (int) ($row['admin_id'] ?? 0),
'name' => (string) ($row['doctor_name'] ?? ''),
// DoctorDailyStats 的 total 含已取消;驾驶舱实时排行只统计有效挂号。
'count' => max(
0,
(int) ($row['appointment_total'] ?? 0) - (int) ($row['appointment_cancelled'] ?? 0)
),
'amount' => round((float) ($row['deal_amount'] ?? 0), 2),
];
}
return [
'title' => '实时挂号排行',
'kind' => 'doctor',
'scope_label' => (string) ($scope['label'] ?? ''),
'items' => $items,
];
}
$rankingVisibleAdminIds = $baseVisibleAdminIds;
$rankingScopeLabel = (string) ($scope['label'] ?? '');
if (
(int) ($scope['scope_value'] ?? DataScopeService::SCOPE_SELF) === DataScopeService::SCOPE_SELF
&& in_array(2, $roleIds, true)
) {
$departmentAssistantIds = self::directDepartmentAssistantIds($adminId);
if ($departmentAssistantIds !== []) {
$rankingVisibleAdminIds = $departmentAssistantIds;
$rankingScopeLabel = '本人所属部门';
}
}
if ($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);
}
$assistantStats = ConversionLogic::overview([
'dimension' => 'assistant',
'time_type' => 'today',
'include_filters' => 0,
'exclude_cancelled_appointments' => 1,
'page_no' => 1,
'page_size' => $rankingVisibleAdminIds !== null ? max(1, count($rankingVisibleAdminIds)) : 100,
], $adminId, $adminInfo, $rankingVisibleAdminIds);
$rows = is_array($assistantStats['lists'] ?? null) ? $assistantStats['lists'] : [];
usort($rows, static function (array $a, array $b): int {
$byAppointment = (int) ($b['appointment_total_count'] ?? 0) <=> (int) ($a['appointment_total_count'] ?? 0);
if ($byAppointment !== 0) {
return $byAppointment;
}
$byAmount = (float) ($b['completed_order_amount'] ?? 0) <=> (float) ($a['completed_order_amount'] ?? 0);
if ($byAmount !== 0) {
return $byAmount;
}
return (int) ($a['id'] ?? 0) <=> (int) ($b['id'] ?? 0);
});
$items = [];
foreach (array_slice($rows, 0, 5) as $row) {
$items[] = [
'id' => (int) ($row['id'] ?? 0),
'name' => (string) ($row['name'] ?? ''),
'count' => (int) ($row['appointment_total_count'] ?? 0),
'amount' => round((float) ($row['completed_order_amount'] ?? 0), 2),
];
}
return [
'title' => '实时挂号排行',
'kind' => 'assistant',
'scope_label' => $rankingScopeLabel,
'items' => $items,
];
}
/** @return int[] */
private static function departmentAdminIds(int $deptId): array
{
$deptIds = array_values(array_unique(array_filter(
array_map('intval', DeptLogic::getSelfAndDescendantIds($deptId)),
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
)));
}
/**
* SELF 医助排行的卡片级例外:只扩展到当前账号所有有效直接部门内的有效医助。
* 不展开子部门,也不改变驾驶舱其它指标的数据范围。
*
* @return int[]
*/
private static function directDepartmentAssistantIds(int $adminId): array
{
if ($adminId <= 0) {
return [];
}
$activeAdmin = Db::name('admin')
->where('id', $adminId)
->whereNull('delete_time')
->value('id');
if ((int) $activeAdmin <= 0) {
return [];
}
$deptIds = Db::name('admin_dept')
->alias('ad')
->join('dept d', 'd.id = ad.dept_id AND d.delete_time IS NULL', 'INNER')
->where('ad.admin_id', $adminId)
->column('ad.dept_id');
$deptIds = array_values(array_unique(array_filter(
array_map('intval', $deptIds),
static fn (int $id): bool => $id > 0
)));
if ($deptIds === []) {
return [];
}
$assistantIds = Db::name('admin_dept')
->alias('ad')
->join('dept d', 'd.id = ad.dept_id AND d.delete_time IS NULL', 'INNER')
->join('admin a', 'a.id = ad.admin_id AND a.delete_time IS NULL', 'INNER')
->join('admin_role ar', 'ar.admin_id = a.id AND ar.role_id = 2', 'INNER')
->join('system_role sr', 'sr.id = ar.role_id AND sr.delete_time IS NULL', 'INNER')
->whereIn('ad.dept_id', $deptIds)
->distinct(true)
->column('a.id');
return array_values(array_unique(array_filter(
array_map('intval', $assistantIds),
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<int>|null $visibleAdminIds
* @param array<string, array{amount: float, count: int}> $orderDaily
* @param array<string, mixed> $trendContext
* @return array<string, mixed>
*/
private static function buildTrend(
string $startDate,
string $endDate,
?array $visibleAdminIds,
array $orderDaily,
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 = [];
$appointments = [];
$leads = [];
$orders = [];
$cursor = strtotime($startDate);
$end = strtotime($endDate);
while ($cursor <= $end) {
$date = date('Y-m-d', $cursor);
$dates[] = date('m-d', $cursor);
$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,
'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,
int $scopeValue,
string $yearMonth,
float $completedAmount,
float $personalAmount
): array {
$deptIds = self::targetDeptIds($adminId, $scopeValue);
$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, int $scopeValue): ?array
{
if ($scopeValue === DataScopeService::SCOPE_ALL) {
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 === [] || $scopeValue !== DataScopeService::SCOPE_DEPT_AND_CHILD) {
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,
];
}
}