543 lines
23 KiB
PHP
543 lines
23 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($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 NULL;MySQL 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)));
|
||
}
|
||
}
|