Files
zyt/server/app/adminapi/logic/firstvisit/FirstVisitDoctorDashboardLogic.php
T
2026-08-13 16:37:37 +08:00

543 lines
23 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\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($params);
$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
);
// 支付单没有医生字段,当前数据中的低额支付单也未关联患者;挂号只能按创建人及权限范围汇总,
// 不能为了医生排行而将医助创建的支付单虚构分摊给某位医生。
$registrationCreatorIds = $doctorSelf ? [$adminId] : $assistantIds;
$registrationTotal = self::loadRegistrationTotal(
$range['start'],
$range['end'],
$registrationCreatorIds
);
$summary = self::buildSummary($rows, $registrationTotal);
$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),
'registration_rule' => '总挂号按支付时间统计已支付且实收金额大于 0、低于 10 元的订单,每笔计 1 个,并按订单创建人及当前权限范围归属',
'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' => 'registration', 'label' => '挂号', 'value' => (int) $summary['registration_total']],
['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> */
/** @return array{type:string,label:string,start:string,end:string} */
private static function resolveRange(array $params): array
{
$today = date('Y-m-d');
$type = (string) ($params['time_type'] ?? 'month');
if (!in_array($type, ['today', 'yesterday', 'week', 'month', 'custom'], true)) {
$type = 'month';
}
if ($type === 'custom') {
$start = trim((string) ($params['start_date'] ?? ''));
$end = trim((string) ($params['end_date'] ?? ''));
if ($start === '' || $end === '' || strtotime($start) === false || strtotime($end) === false) {
$start = date('Y-m-01');
$end = $today;
}
if ($start > $end) {
[$start, $end] = [$end, $start];
}
return [
'type' => 'custom',
'label' => $start . ' 至 ' . $end,
'start' => $start,
'end' => $end,
];
}
if ($type === 'today') {
return ['type' => 'today', 'label' => '今日', 'start' => $today, 'end' => $today];
}
if ($type === 'yesterday') {
$yesterday = date('Y-m-d', strtotime('-1 day'));
return ['type' => 'yesterday', 'label' => '昨天', 'start' => $yesterday, 'end' => $yesterday];
}
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, int $registrationTotal): 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 [
'registration_total' => $registrationTotal,
'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,
];
}
/**
* 新挂号口径:支付时间位于筛选区间、状态为已支付、0 < 实收金额 < 10 元。
* null 表示全部创建人,空数组表示当前权限范围没有可统计创建人。
*
* @param int[]|null $creatorIds
*/
private static function loadRegistrationTotal(
string $startDate,
string $endDate,
?array $creatorIds
): int {
if ($creatorIds === []) {
return 0;
}
$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 ($creatorIds !== null) {
$query->whereIn('creator_id', $creatorIds);
}
return (int) $query->count();
}
/** @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::applyPrescriptionOrderEffectiveAmountQuery($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)));
}
}