Files
zyt/server/app/adminapi/logic/firstvisit/FirstVisitDoctorDashboardLogic.php
T
2026-08-05 11:08:02 +08:00

469 lines
19 KiB
PHP

<?php
declare(strict_types=1);
namespace app\adminapi\logic\firstvisit;
use app\adminapi\logic\dept\DeptLogic;
use app\adminapi\logic\stats\DoctorDailyStatsLogic;
use app\adminapi\logic\stats\YejiStatsLogic;
use app\common\model\auth\AdminDept;
use app\common\service\DataScope\DataScopeService;
use think\facade\Db;
/**
* 一诊「医生看板」。
*
* 医生是最终展示维度;部门权限通过实际经手医助下推到挂号、诊单与业绩:
* - 医生 SELF:只看本人医生数据,不限制经手医助;
* - 医助 SELF:只看本人经手患者关联的医生数据;
* - 组长/经理:只看数据范围内医助经手患者关联的医生数据;
* - 管理员/ALL:全部医生,可再选择部门收窄。
*/
class FirstVisitDoctorDashboardLogic
{
private const DOCTOR_ROLE_ID = 1;
private const ASSISTANT_ROLE_ID = 2;
private const TREND_DAYS = 30;
/** @return array<string,mixed> */
public static function overview(array $params, int $adminId, array $adminInfo): array
{
$range = self::resolveRange((string) ($params['time_type'] ?? 'month'));
$scopeValue = DataScopeService::getEffectiveScope($adminInfo);
$roleIds = self::normalizeIds(Db::name('admin_role')->where('admin_id', $adminId)->column('role_id'));
$isRoot = (int) ($adminInfo['root'] ?? 0) === 1;
$doctorSelf = !$isRoot
&& $scopeValue === DataScopeService::SCOPE_SELF
&& in_array(self::DOCTOR_ROLE_ID, $roleIds, true);
$activeOnly = (int) ($params['active_only'] ?? 1) !== 0;
$selectedDeptId = $doctorSelf ? 0 : max(0, (int) ($params['dept_id'] ?? 0));
$selectedDoctorId = max(0, (int) ($params['doctor_id'] ?? 0));
$threshold = min(100.0, max(1.0, (float) ($params['alert_threshold'] ?? 15)));
$allDoctorOptions = self::doctorOptions($activeOnly, $doctorSelf ? $adminId : 0);
$doctorIds = self::normalizeIds(array_column($allDoctorOptions, 'id'));
if ($selectedDoctorId > 0) {
$doctorIds = in_array($selectedDoctorId, $doctorIds, true) ? [$selectedDoctorId] : [];
}
$allowedDeptSet = DataScopeService::getAllowedDeptIdSet($adminId, $adminInfo);
[$selectedDeptIds, $deptSelectionValid] = self::resolveSelectedDeptIds(
$selectedDeptId,
$allowedDeptSet
);
$assistantIds = self::resolveAssistantScope(
$adminId,
$adminInfo,
$doctorSelf,
$selectedDeptId,
$selectedDeptIds,
$deptSelectionValid
);
$stats = DoctorDailyStatsLogic::overview(
[
'start_date' => $range['start'],
'end_date' => $range['end'],
],
$adminId,
$adminInfo,
$doctorIds,
$assistantIds
);
$doctorDeptNames = self::doctorDepartmentNames($doctorIds);
$doctorStatus = self::doctorStatusMap($doctorIds);
$rows = self::enrichRows(
is_array($stats['rows'] ?? null) ? $stats['rows'] : [],
$doctorDeptNames,
$doctorStatus
);
$summary = self::buildSummary($rows);
$trend = self::buildAmountTrend($doctorIds, $assistantIds);
$selectedDeptName = $selectedDeptId > 0
? (string) (Db::name('dept')->where('id', $selectedDeptId)->whereNull('delete_time')->value('name') ?? '')
: '';
$selectedDoctorName = '';
if ($selectedDoctorId > 0) {
foreach ($allDoctorOptions as $doctor) {
if ((int) ($doctor['id'] ?? 0) === $selectedDoctorId) {
$selectedDoctorName = (string) ($doctor['name'] ?? '');
break;
}
}
}
return [
'meta' => [
'time_type' => $range['type'],
'time_label' => $range['label'],
'start_date' => $range['start'],
'end_date' => $range['end'],
'generated_at' => date('Y-m-d H:i:s'),
'scope_value' => $scopeValue,
'scope_label' => $doctorSelf ? '医生本人' : DataScopeService::scopeLabel($scopeValue),
'scope_kind' => $doctorSelf ? 'doctor_self' : ($assistantIds === null ? 'all' : 'assistant_scope'),
'selected_dept_name' => $selectedDeptName,
'selected_doctor_name' => $selectedDoctorName,
'doctor_count' => count($rows),
'appointment_rule' => '总挂号包含已预约、已取消、已完成和已过号;面诊取状态为已完成的挂号',
'performance_rule' => '诊单按订单创建时间统计,排除履约已取消、拒收和退款,金额归属处方开方医生',
],
'filters' => [
'departments' => $doctorSelf ? [] : DeptLogic::getAllDataScoped($adminId, $adminInfo),
'doctors' => $allDoctorOptions,
'can_filter_department' => !$doctorSelf,
],
'summary' => $summary,
'rankings' => [
'amounts' => self::ranking($rows, 'deal_amount', 8),
'conversion' => self::ranking($rows, 'receive_conversion_rate', 8),
],
'funnel' => [
['key' => 'appointment', 'label' => '挂号', 'value' => (int) $summary['appointment_total']],
['key' => 'interview', 'label' => '面诊', 'value' => (int) $summary['interview_count']],
['key' => 'receive', 'label' => '接诊', 'value' => (int) $summary['order_count']],
['key' => 'deal', 'label' => '成交', 'value' => (int) $summary['order_count']],
],
'trend' => $trend,
'alerts' => self::alertRows($rows, $threshold),
'alert_threshold' => $threshold,
'rows' => $rows,
];
}
/** @return array<string,string> */
private static function resolveRange(string $type): array
{
$today = date('Y-m-d');
if ($type === 'today') {
return ['type' => 'today', 'label' => '今日', 'start' => $today, 'end' => $today];
}
if ($type === 'week') {
return [
'type' => 'week', 'label' => '本周',
'start' => date('Y-m-d', strtotime('monday this week')), 'end' => $today,
];
}
return ['type' => 'month', 'label' => '本月', 'start' => date('Y-m-01'), 'end' => $today];
}
/** @return array<int,array{id:int,name:string,disable:int}> */
private static function doctorOptions(bool $activeOnly, int $selfDoctorId = 0): array
{
$query = Db::name('admin')->alias('a')
->join('admin_role ar', 'ar.admin_id = a.id')
->where('ar.role_id', self::DOCTOR_ROLE_ID)
->whereNull('a.delete_time');
if ($activeOnly) {
$query->where('a.disable', 0);
}
if ($selfDoctorId > 0) {
$query->where('a.id', $selfDoctorId);
}
return $query->field('a.id, a.name, a.disable')
->distinct(true)
->order('a.disable', 'asc')
->order('a.name', 'asc')
->select()
->toArray();
}
/** @param array<int,true>|null $allowedSet @return array{0:array<int>,1:bool} */
private static function resolveSelectedDeptIds(int $selectedDeptId, ?array $allowedSet): array
{
if ($selectedDeptId <= 0) {
return [[], true];
}
$ids = self::normalizeIds(DeptLogic::getSelfAndDescendantIds($selectedDeptId));
if ($allowedSet !== null) {
$ids = array_values(array_filter($ids, static fn (int $id): bool => isset($allowedSet[$id])));
}
return [$ids, $ids !== []];
}
/**
* null 表示医生本人或 ALL,不附加医助过滤;数组表示必须按这些医助经手的数据收窄。
*
* @param int[] $selectedDeptIds
* @return int[]|null
*/
private static function resolveAssistantScope(
int $adminId,
array $adminInfo,
bool $doctorSelf,
int $selectedDeptId,
array $selectedDeptIds,
bool $deptSelectionValid
): ?array {
if ($doctorSelf) {
return null;
}
if (!$deptSelectionValid) {
return [];
}
$visibleIds = DataScopeService::getVisibleAdminIds($adminId, $adminInfo);
$assistantIds = self::activeAssistantIds($visibleIds);
if ($selectedDeptId <= 0) {
return $visibleIds === null ? null : $assistantIds;
}
$deptAssistantIds = self::activeAssistantIdsByDepartment($selectedDeptIds);
if ($visibleIds === null) {
return $deptAssistantIds;
}
return array_values(array_intersect($assistantIds, $deptAssistantIds));
}
/** @param int[]|null $visibleIds @return int[] */
private static function activeAssistantIds(?array $visibleIds): array
{
if ($visibleIds === []) {
return [];
}
$query = Db::name('admin')->alias('a')
->join('admin_role ar', 'ar.admin_id = a.id')
->where('ar.role_id', self::ASSISTANT_ROLE_ID)
->where('a.disable', 0)
->whereNull('a.delete_time');
if ($visibleIds !== null) {
$query->whereIn('a.id', $visibleIds);
}
return self::normalizeIds($query->column('a.id'));
}
/** @param int[] $deptIds @return int[] */
private static function activeAssistantIdsByDepartment(array $deptIds): array
{
if ($deptIds === []) {
return [];
}
return self::normalizeIds(Db::name('admin')->alias('a')
->join('admin_role ar', 'ar.admin_id = a.id')
->join('admin_dept ad', 'ad.admin_id = a.id')
->where('ar.role_id', self::ASSISTANT_ROLE_ID)
->whereIn('ad.dept_id', $deptIds)
->where('a.disable', 0)
->whereNull('a.delete_time')
->distinct(true)
->column('a.id'));
}
/** @param int[] $doctorIds @return array<int,string> */
private static function doctorDepartmentNames(array $doctorIds): array
{
if ($doctorIds === []) {
return [];
}
$rows = AdminDept::alias('ad')
->join('dept d', 'd.id = ad.dept_id AND d.delete_time IS NULL')
->whereIn('ad.admin_id', $doctorIds)
->field('ad.admin_id, d.name')
->order('d.sort', 'desc')
->select()
->toArray();
$out = [];
foreach ($rows as $row) {
$id = (int) ($row['admin_id'] ?? 0);
$name = trim((string) ($row['name'] ?? ''));
if ($id > 0 && $name !== '' && !isset($out[$id])) {
$out[$id] = $name;
}
}
return $out;
}
/** @param int[] $doctorIds @return array<int,int> */
private static function doctorStatusMap(array $doctorIds): array
{
if ($doctorIds === []) {
return [];
}
$rows = Db::name('admin')->whereIn('id', $doctorIds)->field('id, disable')->select()->toArray();
$out = [];
foreach ($rows as $row) {
$out[(int) $row['id']] = (int) ($row['disable'] ?? 0);
}
return $out;
}
/** @param array<int,array<string,mixed>> $rows @return array<int,array<string,mixed>> */
private static function enrichRows(array $rows, array $deptNames, array $statusMap): array
{
$out = [];
foreach ($rows as $row) {
$id = (int) ($row['admin_id'] ?? 0);
$appointmentTotal = (int) ($row['appointment_total'] ?? 0);
$interviewCount = (int) ($row['appointment_completed'] ?? 0);
$orderCount = (int) ($row['deal_order_count'] ?? 0);
$out[] = array_merge($row, [
'doctor_id' => $id,
'department_name' => (string) ($deptNames[$id] ?? '未分配部门'),
'interview_count' => $interviewCount,
'order_count' => $orderCount,
'appointment_completion_rate' => $appointmentTotal > 0
? round($interviewCount / $appointmentTotal * 100, 2)
: null,
'receive_conversion_rate' => $interviewCount > 0
? round($orderCount / $interviewCount * 100, 2)
: null,
'status' => (int) ($statusMap[$id] ?? 0) === 0 ? 'active' : 'disabled',
]);
}
usort($out, static fn (array $a, array $b): int => (($b['deal_amount'] ?? 0) <=> ($a['deal_amount'] ?? 0)) ?: strcmp((string) $a['doctor_name'], (string) $b['doctor_name']));
return $out;
}
/** @param array<int,array<string,mixed>> $rows @return array<string,mixed> */
private static function buildSummary(array $rows): array
{
$appointmentTotal = 0;
$interviewCount = 0;
$orderCount = 0;
$dealAmount = 0.0;
$missed = 0;
$cancelled = 0;
foreach ($rows as $row) {
$appointmentTotal += (int) ($row['appointment_total'] ?? 0);
$interviewCount += (int) ($row['interview_count'] ?? 0);
$orderCount += (int) ($row['order_count'] ?? 0);
$dealAmount += (float) ($row['deal_amount'] ?? 0);
$missed += (int) ($row['appointment_missed'] ?? 0);
$cancelled += (int) ($row['appointment_cancelled'] ?? 0);
}
return [
'appointment_total' => $appointmentTotal,
'interview_count' => $interviewCount,
'order_count' => $orderCount,
'deal_amount' => round($dealAmount, 2),
'avg_order_amount' => $orderCount > 0 ? round($dealAmount / $orderCount, 2) : null,
'appointment_completion_rate' => $appointmentTotal > 0
? round($interviewCount / $appointmentTotal * 100, 2)
: null,
'receive_conversion_rate' => $interviewCount > 0
? round($orderCount / $interviewCount * 100, 2)
: null,
'missed_count' => $missed,
'cancelled_count' => $cancelled,
];
}
/** @param array<int,array<string,mixed>> $rows @return array<int,array<string,mixed>> */
private static function ranking(array $rows, string $field, int $limit): array
{
$ranked = $rows;
usort($ranked, static fn (array $a, array $b): int => (($b[$field] ?? 0) <=> ($a[$field] ?? 0)) ?: strcmp((string) $a['doctor_name'], (string) $b['doctor_name']));
$out = [];
foreach (array_slice($ranked, 0, $limit) as $row) {
$out[] = [
'doctor_id' => (int) ($row['doctor_id'] ?? 0),
'name' => (string) ($row['doctor_name'] ?? ''),
'value' => round((float) ($row[$field] ?? 0), 2),
'interview_count' => (int) ($row['interview_count'] ?? 0),
'order_count' => (int) ($row['order_count'] ?? 0),
];
}
return $out;
}
/** @param int[] $doctorIds @param int[]|null $assistantIds @return array<string,mixed> */
private static function buildAmountTrend(array $doctorIds, ?array $assistantIds): array
{
$endDate = date('Y-m-d');
$startDate = date('Y-m-d', strtotime('-' . (self::TREND_DAYS - 1) . ' days'));
$amountByDate = [];
if ($doctorIds !== [] && $assistantIds !== []) {
$query = Db::name('tcm_prescription_order')->alias('o')
->join('tcm_prescription rx', 'rx.id = o.prescription_id AND rx.delete_time IS NULL', 'INNER')
->whereNull('o.delete_time')
->whereIn('rx.creator_id', $doctorIds)
->where('o.diagnosis_id', '>', 0)
->where('o.create_time', 'between', [
strtotime($startDate . ' 00:00:00'),
strtotime($endDate . ' 23:59:59'),
]);
YejiStatsLogic::applyPrescriptionOrderNotCancelledForPerformanceQuery($query, 'o');
if ($assistantIds !== null) {
$query->whereIn('o.creator_id', $assistantIds);
}
$rows = $query
->fieldRaw("FROM_UNIXTIME(o.create_time, '%Y-%m-%d') AS date_label, SUM(o.amount) AS amount_sum")
->group('date_label')
->order('date_label', 'asc')
->select()
->toArray();
foreach ($rows as $row) {
$date = (string) ($row['date_label'] ?? '');
if ($date !== '') {
$amountByDate[$date] = round((float) ($row['amount_sum'] ?? 0), 2);
}
}
}
$dates = [];
$labels = [];
$amounts = [];
for ($offset = 0; $offset < self::TREND_DAYS; $offset++) {
$date = date('Y-m-d', strtotime($startDate . ' +' . $offset . ' days'));
$dates[] = $date;
$labels[] = date('m-d', strtotime($date));
$amounts[] = (float) ($amountByDate[$date] ?? 0);
}
return [
'start_date' => $startDate,
'end_date' => $endDate,
'dates' => $dates,
'labels' => $labels,
'amounts' => $amounts,
];
}
/** @param array<int,array<string,mixed>> $rows @return array<int,array<string,mixed>> */
private static function alertRows(array $rows, float $threshold): array
{
$alerts = array_values(array_filter($rows, static function (array $row) use ($threshold): bool {
$interviews = (int) ($row['interview_count'] ?? 0);
$rate = $row['receive_conversion_rate'] ?? null;
return $interviews > 0 && ($rate === null || (float) $rate < $threshold);
}));
usort($alerts, static fn (array $a, array $b): int => (($a['receive_conversion_rate'] ?? -1) <=> ($b['receive_conversion_rate'] ?? -1)) ?: (($b['interview_count'] ?? 0) <=> ($a['interview_count'] ?? 0)));
return array_map(static function (array $row) use ($threshold): array {
$rate = (float) ($row['receive_conversion_rate'] ?? 0);
return [
'doctor_id' => (int) ($row['doctor_id'] ?? 0),
'doctor_name' => (string) ($row['doctor_name'] ?? ''),
'department_name' => (string) ($row['department_name'] ?? ''),
'interview_count' => (int) ($row['interview_count'] ?? 0),
'order_count' => (int) ($row['order_count'] ?? 0),
'rate' => round($rate, 2),
'severity' => $rate < $threshold / 2 ? 'high' : 'medium',
'suggestion' => (int) ($row['order_count'] ?? 0) === 0
? '当前有面诊但无接诊诊单,建议核对诊单及跟进记录'
: '接诊转化低于预警线,建议复盘患者需求与沟通记录',
];
}, $alerts);
}
/** @param array<int|string,mixed> $ids @return int[] */
private static function normalizeIds(array $ids): array
{
return array_values(array_unique(array_filter(array_map('intval', $ids), static fn (int $id): bool => $id > 0)));
}
}