469 lines
19 KiB
PHP
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)));
|
|
}
|
|
}
|