|
|
<?php
|
|
|
|
|
|
namespace App\Services;
|
|
|
|
|
|
use Illuminate\Support\Facades\DB;
|
|
|
|
|
|
/**
|
|
|
* 报表服务类
|
|
|
* 处理各类统计报表
|
|
|
*/
|
|
|
class ReportService
|
|
|
{
|
|
|
/**
|
|
|
* 获取预约总体统计报表
|
|
|
*
|
|
|
* @param string $dateType 日期类型: daily-日报, weekly-周报, monthly-月报
|
|
|
* @param string $startDate 开始日期 YYYY-MM-DD
|
|
|
* @param string $endDate 结束日期 YYYY-MM-DD
|
|
|
* @param int|null $departmentId 科室ID
|
|
|
* @param int|null $channelId 渠道ID
|
|
|
* @param int|null $patientType 患者类型
|
|
|
* @return array
|
|
|
*/
|
|
|
public function getAppointmentOverallReport($dateType, $startDate, $endDate, $departmentId = null, $channelId = null, $patientType = null)
|
|
|
{
|
|
|
// 验证日期范围
|
|
|
if ($startDate > $endDate) {
|
|
|
return ['success' => false, 'message' => '开始日期不能大于结束日期'];
|
|
|
}
|
|
|
|
|
|
// 根据日期类型确定分组粒度
|
|
|
$dateFormat = $this->getDateFormatByType($dateType);
|
|
|
$groupByRaw = $this->getGroupByRawByType($dateType);
|
|
|
|
|
|
// 构建查询
|
|
|
$query = DB::table('exam_appointment as ea')
|
|
|
->leftJoin('exam_application as eapp', 'ea.exam_application_id', '=', 'eapp.id')
|
|
|
->leftJoin('department as d', 'eapp.examination_department_code', '=', 'd.code')
|
|
|
->leftJoin('channel as c', 'ea.channel_id', '=', 'c.id')
|
|
|
->leftJoin('department_resource as dr', 'ea.department_resource_id', '=', 'dr.id')
|
|
|
->whereDate('ea.appointment_time', '>=', $startDate)
|
|
|
->whereDate('ea.appointment_time', '<=', $endDate)
|
|
|
->select([
|
|
|
DB::raw("$dateFormat as date_label"),
|
|
|
DB::raw('COUNT(*) as total_appointments'),
|
|
|
DB::raw('SUM(CASE WHEN ea.status = 1 THEN 1 ELSE 0 END) as booked_count'),
|
|
|
DB::raw('SUM(CASE WHEN ea.status = 2 THEN 1 ELSE 0 END) as checked_in_count'),
|
|
|
DB::raw('SUM(CASE WHEN ea.status = 3 THEN 1 ELSE 0 END) as completed_count'),
|
|
|
DB::raw('SUM(CASE WHEN ea.status = 4 THEN 1 ELSE 0 END) as cancelled_count'),
|
|
|
DB::raw('SUM(CASE WHEN ea.status = 5 THEN 1 ELSE 0 END) as no_show_count'),
|
|
|
]);
|
|
|
|
|
|
// 科室筛选
|
|
|
if ($departmentId != null) {
|
|
|
$query->where('d.id', $departmentId);
|
|
|
}
|
|
|
|
|
|
// 渠道筛选
|
|
|
if ($channelId != null) {
|
|
|
$query->where('ea.channel_id', $channelId);
|
|
|
}
|
|
|
|
|
|
// 患者类型筛选
|
|
|
if ($patientType != null) {
|
|
|
$query->where('eapp.patient_type', $patientType);
|
|
|
}
|
|
|
|
|
|
// 分组查询
|
|
|
$data = $query->groupByRaw($groupByRaw)
|
|
|
->orderBy('date_label')
|
|
|
->get()
|
|
|
->map(function ($item) {
|
|
|
// 计算百分比
|
|
|
$total = $item->total_appointments ?: 1; // 避免除零
|
|
|
$bookedTotal = $item->booked_count ?: 1; // 避免除零
|
|
|
$item->success_rate = round(($item->booked_count / $total) * 100, 2);
|
|
|
$item->check_in_rate = round(($item->checked_in_count / $bookedTotal) * 100, 2);
|
|
|
$item->completion_rate = round(($item->completed_count / $bookedTotal) * 100, 2);
|
|
|
// 取消率和爽约率基于预约总数计算
|
|
|
$item->cancel_rate = round(($item->cancelled_count / $total) * 100, 2);
|
|
|
$item->no_show_rate = round(($item->no_show_count / $total) * 100, 2);
|
|
|
return $item;
|
|
|
});
|
|
|
|
|
|
// 计算汇总数据
|
|
|
$summary = $this->calculateSummary($data);
|
|
|
|
|
|
return [
|
|
|
'success' => true,
|
|
|
'data' => [
|
|
|
'list' => $data,
|
|
|
'summary' => $summary,
|
|
|
'report_type' => $this->getReportTypeName($dateType),
|
|
|
'date_range' => [
|
|
|
'start' => $startDate,
|
|
|
'end' => $endDate
|
|
|
]
|
|
|
]
|
|
|
];
|
|
|
}
|
|
|
|
|
|
/**
|
|
|
* 根据日期类型获取日期格式
|
|
|
*/
|
|
|
private function getDateFormatByType($dateType)
|
|
|
{
|
|
|
switch ($dateType) {
|
|
|
case 'weekly':
|
|
|
return "CONCAT(DATE_FORMAT(DATE_SUB(ea.appointment_time, INTERVAL WEEKDAY(ea.appointment_time) DAY), '%m/%d'), ' - ', DATE_FORMAT(DATE_ADD(DATE_SUB(ea.appointment_time, INTERVAL WEEKDAY(ea.appointment_time) DAY), INTERVAL 6 DAY), '%m/%d'))";
|
|
|
case 'monthly':
|
|
|
return "DATE_FORMAT(ea.appointment_time, '%Y-%m')";
|
|
|
case 'daily':
|
|
|
default:
|
|
|
return "DATE(ea.appointment_time)";
|
|
|
}
|
|
|
}
|
|
|
|
|
|
/**
|
|
|
* 根据日期类型获取分组字段
|
|
|
*/
|
|
|
private function getGroupByRawByType($dateType)
|
|
|
{
|
|
|
switch ($dateType) {
|
|
|
case 'weekly':
|
|
|
return "CONCAT(DATE_FORMAT(DATE_SUB(ea.appointment_time, INTERVAL WEEKDAY(ea.appointment_time) DAY), '%m/%d'), ' - ', DATE_FORMAT(DATE_ADD(DATE_SUB(ea.appointment_time, INTERVAL WEEKDAY(ea.appointment_time) DAY), INTERVAL 6 DAY), '%m/%d'))";
|
|
|
case 'monthly':
|
|
|
return "DATE_FORMAT(ea.appointment_time, '%Y-%m')";
|
|
|
case 'daily':
|
|
|
default:
|
|
|
return "DATE(ea.appointment_time)";
|
|
|
}
|
|
|
}
|
|
|
|
|
|
/**
|
|
|
* 获取报表类型名称
|
|
|
*/
|
|
|
private function getReportTypeName($dateType)
|
|
|
{
|
|
|
$names = [
|
|
|
'daily' => '日报',
|
|
|
'weekly' => '周报',
|
|
|
'monthly' => '月报'
|
|
|
];
|
|
|
return $names[$dateType] ?? '日报';
|
|
|
}
|
|
|
|
|
|
/**
|
|
|
* 计算汇总数据
|
|
|
*/
|
|
|
private function calculateSummary($data)
|
|
|
{
|
|
|
if ($data->isEmpty()) {
|
|
|
return [
|
|
|
'total_appointments' => 0,
|
|
|
'total_booked' => 0,
|
|
|
'total_checked_in' => 0,
|
|
|
'total_completed' => 0,
|
|
|
'total_cancelled' => 0,
|
|
|
'total_no_show' => 0,
|
|
|
'avg_check_in_rate' => 0,
|
|
|
'avg_completion_rate' => 0,
|
|
|
'avg_cancel_rate' => 0,
|
|
|
'avg_no_show_rate' => 0
|
|
|
];
|
|
|
}
|
|
|
|
|
|
$summary = [
|
|
|
'total_appointments' => 0,
|
|
|
'total_booked' => 0,
|
|
|
'total_checked_in' => 0,
|
|
|
'total_completed' => 0,
|
|
|
'total_cancelled' => 0,
|
|
|
'total_no_show' => 0
|
|
|
];
|
|
|
|
|
|
foreach ($data as $item) {
|
|
|
$summary['total_appointments'] += $item->total_appointments;
|
|
|
$summary['total_booked'] += $item->booked_count;
|
|
|
$summary['total_checked_in'] += $item->checked_in_count;
|
|
|
$summary['total_completed'] += $item->completed_count;
|
|
|
$summary['total_cancelled'] += $item->cancelled_count;
|
|
|
$summary['total_no_show'] += $item->no_show_count;
|
|
|
}
|
|
|
|
|
|
// 计算平均比率
|
|
|
// 取消率和爽约率基于预约总数计算(因为预约可能直接被取消)
|
|
|
// 到检率和完成率基于预约成功数计算
|
|
|
$totalBooked = $summary['total_booked'] ?: 1;
|
|
|
$totalAppointments = $summary['total_appointments'] ?: 1;
|
|
|
|
|
|
$summary['avg_check_in_rate'] = round(($summary['total_checked_in'] / $totalBooked) * 100, 2);
|
|
|
$summary['avg_completion_rate'] = round(($summary['total_completed'] / $totalBooked) * 100, 2);
|
|
|
$summary['avg_cancel_rate'] = round(($summary['total_cancelled'] / $totalAppointments) * 100, 2);
|
|
|
$summary['avg_no_show_rate'] = round(($summary['total_no_show'] / $totalAppointments) * 100, 2);
|
|
|
|
|
|
return $summary;
|
|
|
}
|
|
|
|
|
|
/**
|
|
|
* 获取时间段分布报表(检查室 × 时间段矩阵)
|
|
|
*
|
|
|
* @param string $startDate 开始日期 YYYY-MM-DD
|
|
|
* @param string $endDate 结束日期 YYYY-MM-DD
|
|
|
* @param int|null $departmentId 科室 ID
|
|
|
* @param int|null $resourceId 资源 ID(多资源逗号分隔)
|
|
|
* @param int|null $channelId 渠道 ID
|
|
|
* @param int|null $patientType 患者类型
|
|
|
* @return array
|
|
|
*/
|
|
|
public function getTimeDistributionReport($startDate, $endDate, $departmentId = null, $resourceId = null, $channelId = null, $patientType = null)
|
|
|
{
|
|
|
if ($startDate > $endDate) {
|
|
|
return ['success' => false, 'message' => '开始日期不能大于结束日期'];
|
|
|
}
|
|
|
|
|
|
$timeSlots = [
|
|
|
'08:00-09:00', '09:00-10:00', '10:00-11:00', '11:00-12:00',
|
|
|
'12:00-13:00', '13:00-14:00', '14:00-15:00', '15:00-16:00',
|
|
|
'16:00-17:00', '17:00-18:00', '18:00-19:00', '19:00-20:00'
|
|
|
];
|
|
|
|
|
|
$resourcesQuery = DB::table('department_resource as dr')
|
|
|
->leftJoin('department as d', 'dr.department_id', '=', 'd.id')
|
|
|
->where('dr.deleted', 0)
|
|
|
->where('dr.status', 1)
|
|
|
->select('dr.id', 'dr.name as resource_name', 'dr.department_id', 'd.name as department_name');
|
|
|
|
|
|
if ($departmentId != null && $departmentId !== '') {
|
|
|
$resourcesQuery->where('dr.department_id', $departmentId);
|
|
|
}
|
|
|
|
|
|
if ($resourceId != null && $resourceId !== '') {
|
|
|
$resourceIds = is_array($resourceId) ? $resourceId : explode(',', $resourceId);
|
|
|
$resourcesQuery->whereIn('dr.id', $resourceIds);
|
|
|
}
|
|
|
|
|
|
$resources = $resourcesQuery->orderBy('dr.department_id', 'asc')
|
|
|
->orderBy('dr.id', 'asc')
|
|
|
->get()
|
|
|
->keyBy('id');
|
|
|
|
|
|
if ($resources->isEmpty()) {
|
|
|
return [
|
|
|
'success' => true,
|
|
|
'data' => [
|
|
|
'resources' => [],
|
|
|
'time_slots' => $timeSlots,
|
|
|
'matrix' => [],
|
|
|
'date_range' => ['start' => $startDate, 'end' => $endDate]
|
|
|
]
|
|
|
];
|
|
|
}
|
|
|
|
|
|
$appointmentsQuery = DB::table('exam_appointment as ea')
|
|
|
->leftJoin('plan as p', 'ea.time_slot_id', '=', 'p.id')
|
|
|
->leftJoin('plan_batch as pb', 'p.batch_id', '=', 'pb.id')
|
|
|
->leftJoin('exam_application as eapp', 'ea.exam_application_id', '=', 'eapp.id')
|
|
|
->leftJoin('channel as c', 'ea.channel_id', '=', 'c.id')
|
|
|
->whereDate('pb.plan_date', '>=', $startDate)
|
|
|
->whereDate('pb.plan_date', '<=', $endDate)
|
|
|
->where('pb.deleted', 0)
|
|
|
->where('p.deleted', 0);
|
|
|
|
|
|
if ($departmentId != null && $departmentId !== '') {
|
|
|
$appointmentsQuery->where('pb.department_id', $departmentId);
|
|
|
}
|
|
|
|
|
|
if ($resourceId != null && $resourceId !== '') {
|
|
|
$resourceIds = is_array($resourceId) ? $resourceId : explode(',', $resourceId);
|
|
|
$appointmentsQuery->whereIn('ea.department_resource_id', $resourceIds);
|
|
|
}
|
|
|
|
|
|
if ($channelId != null && $channelId !== '') {
|
|
|
$appointmentsQuery->where('ea.channel_id', $channelId);
|
|
|
}
|
|
|
|
|
|
if ($patientType != null && $patientType !== '') {
|
|
|
$appointmentsQuery->where('eapp.patient_type', $patientType);
|
|
|
}
|
|
|
|
|
|
$appointmentStats = $appointmentsQuery
|
|
|
->select([
|
|
|
'ea.department_resource_id',
|
|
|
DB::raw("TIME_FORMAT(p.start_time, '%H:00') as hour_time"),
|
|
|
DB::raw('COUNT(*) as appointment_count'),
|
|
|
DB::raw('SUM(CASE WHEN ea.status = 2 THEN 1 ELSE 0 END) as checked_in_count'),
|
|
|
DB::raw('SUM(CASE WHEN ea.status = 3 THEN 1 ELSE 0 END) as completed_count'),
|
|
|
DB::raw('SUM(CASE WHEN ea.status = 4 THEN 1 ELSE 0 END) as cancelled_count'),
|
|
|
DB::raw('SUM(CASE WHEN ea.status = 5 THEN 1 ELSE 0 END) as no_show_count'),
|
|
|
])
|
|
|
->groupBy('ea.department_resource_id', DB::raw("TIME_FORMAT(p.start_time, '%H:00')"))
|
|
|
->get()
|
|
|
->groupBy('department_resource_id');
|
|
|
|
|
|
$planQuery = DB::table('plan as p')
|
|
|
->leftJoin('plan_batch as pb', 'p.batch_id', '=', 'pb.id')
|
|
|
->whereDate('pb.plan_date', '>=', $startDate)
|
|
|
->whereDate('pb.plan_date', '<=', $endDate)
|
|
|
->where('pb.deleted', 0)
|
|
|
->where('p.deleted', 0);
|
|
|
|
|
|
if ($departmentId != null && $departmentId !== '') {
|
|
|
$planQuery->where('pb.department_id', $departmentId);
|
|
|
}
|
|
|
|
|
|
if ($resourceId != null && $resourceId !== '') {
|
|
|
$resourceIds = is_array($resourceId) ? $resourceId : explode(',', $resourceId);
|
|
|
$planQuery->whereIn('pb.resource_id', $resourceIds);
|
|
|
}
|
|
|
|
|
|
$planStats = $planQuery
|
|
|
->select([
|
|
|
'pb.resource_id',
|
|
|
DB::raw("TIME_FORMAT(p.start_time, '%H:00') as hour_time"),
|
|
|
DB::raw('SUM(p.total_quota) as planned_quota'),
|
|
|
])
|
|
|
->groupBy('pb.resource_id', DB::raw("TIME_FORMAT(p.start_time, '%H:00')"))
|
|
|
->get()
|
|
|
->groupBy('resource_id');
|
|
|
|
|
|
$matrix = [];
|
|
|
foreach ($resources as $resourceIdKey => $resource) {
|
|
|
$resourceAppointments = $appointmentStats->get($resourceIdKey, collect());
|
|
|
$resourcePlans = $planStats->get($resourceIdKey, collect());
|
|
|
|
|
|
$row = [
|
|
|
'resource_id' => $resourceIdKey,
|
|
|
'resource_name' => $resource->resource_name,
|
|
|
'department_id' => $resource->department_id,
|
|
|
'department_name' => $resource->department_name ?? '',
|
|
|
'slots' => []
|
|
|
];
|
|
|
|
|
|
foreach ($timeSlots as $slot) {
|
|
|
$hourKey = substr($slot, 0, 5);
|
|
|
|
|
|
$stat = null;
|
|
|
foreach ($resourceAppointments as $item) {
|
|
|
if ($item->hour_time === $hourKey) {
|
|
|
$stat = $item;
|
|
|
break;
|
|
|
}
|
|
|
}
|
|
|
|
|
|
$planned = null;
|
|
|
foreach ($resourcePlans as $item) {
|
|
|
if ($item->hour_time === $hourKey) {
|
|
|
$planned = $item;
|
|
|
break;
|
|
|
}
|
|
|
}
|
|
|
|
|
|
$appointmentCount = $stat ? (int)$stat->appointment_count : 0;
|
|
|
$checkedInCount = $stat ? (int)$stat->checked_in_count : 0;
|
|
|
$completedCount = $stat ? (int)$stat->completed_count : 0;
|
|
|
$cancelledCount = $stat ? (int)$stat->cancelled_count : 0;
|
|
|
$noShowCount = $stat ? (int)$stat->no_show_count : 0;
|
|
|
$plannedQuota = $planned ? (int)$planned->planned_quota : 0;
|
|
|
|
|
|
$checkInRate = $appointmentCount > 0 ? round(($checkedInCount / $appointmentCount) * 100, 2) : 0;
|
|
|
$completionRate = $appointmentCount > 0 ? round(($completedCount / $appointmentCount) * 100, 2) : 0;
|
|
|
$utilizationRate = $plannedQuota > 0 ? round(($appointmentCount / $plannedQuota) * 100, 2) : 0;
|
|
|
|
|
|
$row['slots'][] = [
|
|
|
'time_period' => $slot,
|
|
|
'appointment_count' => $appointmentCount,
|
|
|
'checked_in_count' => $checkedInCount,
|
|
|
'completed_count' => $completedCount,
|
|
|
'cancelled_count' => $cancelledCount,
|
|
|
'no_show_count' => $noShowCount,
|
|
|
'planned_quota' => $plannedQuota,
|
|
|
'check_in_rate' => $checkInRate,
|
|
|
'completion_rate' => $completionRate,
|
|
|
'utilization_rate' => $utilizationRate,
|
|
|
];
|
|
|
}
|
|
|
|
|
|
$matrix[] = $row;
|
|
|
}
|
|
|
|
|
|
return [
|
|
|
'success' => true,
|
|
|
'data' => [
|
|
|
'resources' => array_values($resources->map(fn($r) => [
|
|
|
'id' => $r->id,
|
|
|
'name' => $r->resource_name,
|
|
|
'department_id' => $r->department_id,
|
|
|
'department_name' => $r->department_name ?? ''
|
|
|
])->values()->all()),
|
|
|
'time_slots' => $timeSlots,
|
|
|
'matrix' => $matrix,
|
|
|
'date_range' => ['start' => $startDate, 'end' => $endDate]
|
|
|
]
|
|
|
];
|
|
|
}
|
|
|
|
|
|
/**
|
|
|
* 计算汇总数据
|
|
|
*/
|
|
|
private function calculateSummaryOld($data)
|
|
|
{
|
|
|
if ($data->isEmpty()) {
|
|
|
return [
|
|
|
'total_appointments' => 0,
|
|
|
'total_booked' => 0,
|
|
|
'total_checked_in' => 0,
|
|
|
'total_completed' => 0,
|
|
|
'total_cancelled' => 0,
|
|
|
'total_no_show' => 0
|
|
|
];
|
|
|
}
|
|
|
|
|
|
$summary = [
|
|
|
'total_appointments' => 0,
|
|
|
'total_booked' => 0,
|
|
|
'total_checked_in' => 0,
|
|
|
'total_completed' => 0,
|
|
|
'total_cancelled' => 0,
|
|
|
'total_no_show' => 0
|
|
|
];
|
|
|
|
|
|
foreach ($data as $item) {
|
|
|
$summary['total_appointments'] += $item->total_appointments;
|
|
|
$summary['total_booked'] += $item->booked_count;
|
|
|
$summary['total_checked_in'] += $item->checked_in_count;
|
|
|
$summary['total_completed'] += $item->completed_count;
|
|
|
$summary['total_cancelled'] += $item->cancelled_count;
|
|
|
$summary['total_no_show'] += $item->no_show_count;
|
|
|
}
|
|
|
|
|
|
$totalBooked = $summary['total_booked'] ?: 1;
|
|
|
$totalAppointments = $summary['total_appointments'] ?: 1;
|
|
|
|
|
|
$summary['avg_check_in_rate'] = round(($summary['total_checked_in'] / $totalBooked) * 100, 2);
|
|
|
$summary['avg_completion_rate'] = round(($summary['total_completed'] / $totalBooked) * 100, 2);
|
|
|
$summary['avg_cancel_rate'] = round(($summary['total_cancelled'] / $totalAppointments) * 100, 2);
|
|
|
$summary['avg_no_show_rate'] = round(($summary['total_no_show'] / $totalAppointments) * 100, 2);
|
|
|
|
|
|
return $summary;
|
|
|
}
|
|
|
}
|