Files
zyt/server/app/adminapi/logic/doctor/AppointmentLogic.php
T

1142 lines
42 KiB
PHP
Executable File
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
namespace app\adminapi\logic\doctor;
use app\common\logic\BaseLogic;
use app\common\model\doctor\Appointment;
use app\common\model\doctor\Roster;
use app\common\service\doctor\RosterSegmentService;
use app\common\model\tcm\Diagnosis;
use app\common\model\tcm\DiagnosisGuahaoLog;
use app\common\model\auth\Admin;
use app\common\model\auth\AdminRole;
use app\common\service\DataScope\DataScopeService;
use app\api\logic\ChatNotifyLogic;
use app\adminapi\logic\tcm\PrescriptionLogic;
use app\adminapi\logic\tcm\DiagnosisLogic;
use app\adminapi\logic\tcm\BloodRecordLogic;
use app\adminapi\logic\tcm\TrackingNoteLogic;
use app\adminapi\logic\doctor\DoctorNoteLogic;
use app\common\model\tcm\Prescription;
use app\common\service\wechat\WechatWorkAppMessageService;
use app\common\model\dict\DictData;
use think\facade\Db;
use think\facade\Log;
/**
* 医生预约逻辑
* Class AppointmentLogic
* @package app\adminapi\logic\doctor
*/
class AppointmentLogic extends BaseLogic
{
/** 仅这些字典名称需填写 channel_source_detail(与 admin 预约弹窗白名单一致,按 name 全等) */
private const CHANNEL_NAMES_REQUIRING_SOURCE_DETAIL = [
'自媒体4H',
'自媒体3Q',
'自媒体3H',
'自媒体2H',
'自媒体2Q',
];
private static function channelDictNameRequiresSourceDetail(string $name): bool
{
return in_array($name, self::CHANNEL_NAMES_REQUIRING_SOURCE_DETAIL, true);
}
/**
* @param array<int|string, mixed> $cols Db::name('doctor_appointment')->getTableFields()
*/
private static function assertAppointmentChannelWritable(array $cols, string $chSrc): ?string
{
$hasChannelSource = in_array('channel_source', $cols, true);
$hasChannels = in_array('channels', $cols, true);
if (!$hasChannelSource && !$hasChannels) {
return '挂号表缺少渠道字段(channel_source 或 channels),无法保存渠道来源';
}
if (!$hasChannelSource && $hasChannels && $chSrc !== '' && !is_numeric($chSrc)) {
return '当前挂号表仅有数值型渠道字段 channels,无法写入该渠道;请在数据库增加 channel_source 列';
}
return null;
}
/**
* @param array<string, mixed> $row
* @param array<int|string, mixed> $cols
*/
private static function mergeAppointmentChannelIntoRow(array &$row, array $cols, string $chSrc, string $chDetail): void
{
$hasChannelSource = in_array('channel_source', $cols, true);
$hasChannelDetail = in_array('channel_source_detail', $cols, true);
$hasChannels = in_array('channels', $cols, true);
if ($hasChannelSource) {
$row['channel_source'] = $chSrc;
}
if ($hasChannelDetail) {
$row['channel_source_detail'] = $chDetail;
}
if ($hasChannels && is_numeric($chSrc)) {
$row['channels'] = (int) $chSrc;
}
}
/**
* @param array<string, mixed> $row
* @param array<int|string, mixed> $cols
*
* @return array<string, mixed>
*/
private static function filterAppointmentRowByExistingColumns(array $row, array $cols): array
{
$out = [];
foreach ($row as $k => $v) {
if (in_array((string) $k, $cols, true)) {
$out[$k] = $v;
}
}
return $out;
}
/**
* @notes 获取可用时间段
* @param array $params
* @return array
*/
public static function getAvailableSlots(array $params)
{
try {
$doctorId = $params['doctor_id'];
$date = $params['appointment_date'];
$period = $params['period'] ?? 'all';
// 记录请求参数
\think\facade\Log::info('获取可用时段 - 请求参数', [
'doctor_id' => $doctorId,
'date' => $date,
'period' => $period
]);
// 1. 检查医生在该日期是否有出诊排班(只查询status=1的记录)
$rosters = Roster::where([
'doctor_id' => $doctorId,
'date' => $date,
'status' => 1 // 只查询出诊状态
])->select()->toArray();
// 记录查询到的排班
\think\facade\Log::info('查询到的排班数据', [
'doctor_id' => $doctorId,
'date' => $date,
'count' => count($rosters),
'rosters' => $rosters
]);
// 额外记录:查询该日期所有排班(用于调试)
$allRosters = Roster::where([
'doctor_id' => $doctorId,
'date' => $date
])->select()->toArray();
\think\facade\Log::info('该日期所有排班数据(包括非出诊)', [
'count' => count($allRosters),
'all_rosters' => $allRosters
]);
// 如果没有出诊排班,返回空数组
if (empty($rosters)) {
\think\facade\Log::warning('没有找到出诊排班', [
'doctor_id' => $doctorId,
'date' => $date
]);
return ['slots' => []];
}
// 2. 按每段排班的起止时间与号源间隔生成时间段
$slots = [];
foreach ($rosters as $roster) {
if ((int) $roster['status'] !== 1) {
\think\facade\Log::warning('跳过非出诊排班', [
'roster_id' => $roster['id'],
'status' => $roster['status'],
]);
continue;
}
$window = RosterSegmentService::resolveWindow($roster);
if ($window === null) {
\think\facade\Log::warning('无法解析排班起止时间,跳过', [
'roster_id' => $roster['id'],
'period' => $roster['period'] ?? null,
]);
continue;
}
[$startHm, $endHm] = $window;
$slotMinutes = RosterSegmentService::normalizeSlotMinutes($roster['slot_minutes'] ?? 15);
$times = RosterSegmentService::generateSlotTimes($startHm, $endHm, $slotMinutes);
$times = RosterSegmentService::applyQuotaCap($times, (int) ($roster['quota'] ?? 0));
\think\facade\Log::info('处理排班时段', [
'roster_id' => $roster['id'],
'window' => $window,
'slot_minutes' => $slotMinutes,
'slot_count' => count($times),
]);
foreach ($times as $time) {
$slots[] = [
'time' => $time,
'available' => true,
'quota' => 1,
'period' => $roster['period'] ?? 'segment',
];
}
}
\think\facade\Log::info('生成的时间段数量(去重前)', [
'count' => count($slots),
'sample_slots' => array_slice($slots, 0, 3)
]);
// 如果没有生成任何时间段,返回空数组
if (empty($slots)) {
\think\facade\Log::warning('没有生成任何时间段');
return ['slots' => []];
}
// 3. 去重:按时间去重(因为可能有重复的排班记录)
$uniqueSlots = [];
$timeMap = [];
foreach ($slots as $slot) {
$time = $slot['time'];
if (!isset($timeMap[$time])) {
$timeMap[$time] = true;
$uniqueSlots[] = $slot;
}
}
$slots = $uniqueSlots;
\think\facade\Log::info('生成的时间段数量(去重后)', [
'count' => count($slots),
'first_3_slots' => array_slice($slots, 0, 3),
'last_3_slots' => array_slice($slots, -3),
]);
// 4. 查询已预约的时间段
$appointmentTimes = Appointment::where([
'doctor_id' => $doctorId,
'appointment_date' => $date,
'status' => 1 // 只查询有效预约
])->column('appointment_time');
// 将时间格式统一为 HH:MM(去掉秒)
$appointments = array_map(function($time) {
// 如果是 HH:MM:SS 格式,截取前5位
return substr($time, 0, 5);
}, $appointmentTimes);
// 4. 标记已占用的时间段
foreach ($slots as &$slot) {
if (in_array($slot['time'], $appointments)) {
$slot['available'] = false;
$slot['quota'] = 0;
}
}
// 5. 按时间排序
usort($slots, function($a, $b) {
return strcmp($a['time'], $b['time']);
});
return ['slots' => $slots];
} catch (\Exception $e) {
self::setError($e->getMessage());
return ['slots' => []];
}
}
/**
* @notes 创建预约
* @param array $params
* @return array|bool
*/
public static function create(array $params, int $operatorAdminId = 0, array $operatorAdminInfo = [])
{
try {
Db::startTrans();
// 同一诊单患者在「所选预约日」仅允许一条「已预约」或「已过号」记录(与 appointment_date 一致,不能误用服务器当天拦其它日期)
$apptDate = trim((string) ($params['appointment_date'] ?? ''));
if ($apptDate === '') {
self::setError('预约日期不能为空');
Db::rollback();
return false;
}
$patientSameDay = Appointment::where('patient_id', (int) $params['patient_id'])
->where('appointment_date', $apptDate)
->whereIn('status', [1, 4])
->find();
if ($patientSameDay) {
self::setError('该患者在所选日期已有挂号(已预约或已过号),无法重复预约');
Db::rollback();
return false;
}
// 1. 检查时间段是否已被预约(每个时间段只能预约1次)
// 统一时间格式为 HH:MM:SS
$appointmentTime = strlen($params['appointment_time']) == 5
? $params['appointment_time'] . ':00'
: $params['appointment_time'];
$exists = Appointment::where([
'doctor_id' => $params['doctor_id'],
'appointment_date' => $params['appointment_date'],
'status' => 1
])->where('appointment_time', $appointmentTime)
->find();
if ($exists) {
self::setError('该时间段已被预约');
Db::rollback();
return false;
}
$chSrc = trim((string) ($params['channel_source'] ?? ''));
$chDetail = trim((string) ($params['channel_source_detail'] ?? ''));
$channelDictName = trim((string) (DictData::where('type_value', 'channels')
->where('value', $chSrc)
->value('name') ?? ''));
if ($channelDictName !== '' && self::channelDictNameRequiresSourceDetail($channelDictName)) {
if ($chDetail === '') {
self::setError('请填写自媒体渠道补充信息');
Db::rollback();
return false;
}
}
$cols = Db::name('doctor_appointment')->getTableFields();
$cols = is_array($cols) ? $cols : [];
$channelErr = self::assertAppointmentChannelWritable($cols, $chSrc);
if ($channelErr !== null) {
self::setError($channelErr);
Db::rollback();
return false;
}
$insert = [
'patient_id' => (int) $params['patient_id'],
'doctor_id' => (int) $params['doctor_id'],
'appointment_date' => $params['appointment_date'],
'appointment_time' => $appointmentTime,
'appointment_type' => $params['appointment_type'] ?? 'video',
'remark' => $params['remark'] ?? '',
'status' => 1,
'create_time' => time(),
'update_time' => time(),
];
if (in_array('roster_id', $cols, true)) {
$insert['roster_id'] = 0;
}
if (in_array('assistant_id', $cols, true)) {
$insert['assistant_id'] = (int) ($params['assistant_id'] ?? 0);
}
$periodVal = $params['period'] ?? 'all';
if (in_array('period', $cols, true)) {
$insert['period'] = $periodVal;
} elseif (in_array('type', $cols, true)) {
$insert['type'] = $periodVal;
}
self::mergeAppointmentChannelIntoRow($insert, $cols, $chSrc, $chDetail);
$insert = self::filterAppointmentRowByExistingColumns($insert, $cols);
$newId = (int) Db::name('doctor_appointment')->insertGetId($insert);
if ($newId <= 0) {
self::setError('预约创建失败');
Db::rollback();
return false;
}
Db::commit();
$doctorName = (string) (Admin::where('id', (int) $params['doctor_id'])->value('name') ?? '');
$hm = substr((string) $appointmentTime, 0, 5);
$summary = sprintf(
'挂号 #%d:医生「%s」,%s %s',
$newId,
$doctorName !== '' ? $doctorName : ('ID:' . (int) $params['doctor_id']),
(string) $params['appointment_date'],
$hm
);
self::appendDiagnosisGuahaoLog(
(int) $params['patient_id'],
$newId,
$operatorAdminId,
$operatorAdminInfo,
'create',
$summary
);
return ['id' => $newId];
} catch (\Exception $e) {
Db::rollback();
self::setError($e->getMessage());
return false;
}
}
/**
* @notes 取消预约
* @param array $params
* @return bool
*/
public static function cancel(array $params, int $operatorAdminId = 0, array $operatorAdminInfo = [])
{
try {
$appointment = Appointment::findOrEmpty($params['id']);
if ($appointment->isEmpty()) {
self::setError('预约记录不存在');
return false;
}
$st = (int) $appointment->status;
if ($st === 2) {
return true;
}
if ($st === 3) {
self::setError('预约已完成,无法取消');
return false;
}
if ($st !== 1 && $st !== 4) {
self::setError('当前状态不可取消');
return false;
}
$diagId = (int) $appointment->patient_id;
$aptId = (int) $appointment->id;
$doctorId = (int) $appointment->doctor_id;
$aptDate = (string) ($appointment->appointment_date ?? '');
$aptTimeRaw = (string) ($appointment->appointment_time ?? '');
$hm = strlen($aptTimeRaw) >= 5 ? substr($aptTimeRaw, 0, 5) : $aptTimeRaw;
$appointment->status = 2; // 已取消
$appointment->update_time = time();
$appointment->save();
$doctorName = (string) (Admin::where('id', $doctorId)->value('name') ?? '');
$summary = sprintf(
'取消挂号 #%d:医生「%s」,%s %s',
$aptId,
$doctorName !== '' ? $doctorName : ('ID:' . $doctorId),
$aptDate,
$hm
);
self::appendDiagnosisGuahaoLog(
$diagId,
$aptId,
$operatorAdminId,
$operatorAdminInfo,
'cancel',
$summary
);
return true;
} catch (\Exception $e) {
self::setError($e->getMessage());
return false;
}
}
/**
* @notes 预约详情
* @param array $params
* @return array
*/
public static function detail(array $params)
{
$appointment = Appointment::alias('a')
->leftJoin('zyt_tcm_diagnosis u', 'a.patient_id = u.id')
->leftJoin('zyt_admin ad', 'a.doctor_id = ad.id')
->field('a.*, u.patient_name as patient_name, u.phone as patient_phone, ad.name as doctor_name')
->where('a.id', $params['id'])
->findOrEmpty()
->toArray();
if (empty($appointment)) {
return [];
}
// 添加状态和类型描述
$statusMap = [
1 => '已预约',
2 => '已取消',
3 => '已完成',
];
$appointment['status_desc'] = $statusMap[$appointment['status']] ?? '未知';
$typeMap = [
'video' => '视频问诊',
'text' => '图文问诊',
'phone' => '电话问诊',
];
$appointment['appointment_type_desc'] = $typeMap[$appointment['appointment_type']] ?? '未知';
// 格式化时间戳为日期时间
if (isset($appointment['create_time']) && is_numeric($appointment['create_time'])) {
$appointment['create_time'] = date('Y-m-d H:i:s', $appointment['create_time']);
}
if (isset($appointment['update_time']) && is_numeric($appointment['update_time'])) {
$appointment['update_time'] = date('Y-m-d H:i:s', $appointment['update_time']);
}
return $appointment;
}
/**
* @notes 获取医生某天的可用号源总数
* @param int $doctorId
* @param string $date
* @return int
*/
public static function getDoctorAvailability($doctorId, $date)
{
try {
$rosters = Roster::where([
'doctor_id' => $doctorId,
'date' => $date,
'status' => 1,
])->select()->toArray();
if (empty($rosters)) {
return 0;
}
$bookedHm = Appointment::where([
'doctor_id' => $doctorId,
'appointment_date' => $date,
'status' => 1,
])->column('appointment_time');
$bookedHm = array_map(static function ($t) {
return substr((string) $t, 0, 5);
}, $bookedHm);
$bookedSet = array_fill_keys($bookedHm, true);
$totalAvailable = 0;
foreach ($rosters as $roster) {
$window = RosterSegmentService::resolveWindow($roster);
if ($window === null) {
continue;
}
[$startHm, $endHm] = $window;
$slotMinutes = RosterSegmentService::normalizeSlotMinutes($roster['slot_minutes'] ?? 15);
$times = RosterSegmentService::generateSlotTimes($startHm, $endHm, $slotMinutes);
$times = RosterSegmentService::applyQuotaCap($times, (int) ($roster['quota'] ?? 0));
foreach ($times as $t) {
if (empty($bookedSet[$t])) {
++$totalAvailable;
}
}
}
return $totalAvailable;
} catch (\Exception $e) {
return 0;
}
}
/**
* @notes 完成预约
* @param array $params
* @return bool
*/
public static function complete(array $params)
{
try {
$appointment = Appointment::findOrEmpty($params['id']);
if ($appointment->isEmpty()) {
self::setError('预约记录不存在');
return false;
}
if ($appointment->status == 3) {
self::setError('该预约已处理,无法完成');
return false;
}
$appointment->status = 3; // 已完成
$appointment->update_time = time();
$appointment->save();
// 面诊结束:通知对应医助(从诊单 assistant_id 获取)
try {
$diagnosis = Diagnosis::where('id', $appointment->patient_id)->find();
if ($diagnosis && !empty($diagnosis->assistant_id)) {
$assistantId = (int) $diagnosis->assistant_id;
$patientName = $diagnosis->patient_name ?? '患者';
$doctor = Admin::where('id', $appointment->doctor_id)->find();
$doctorName = $doctor ? $doctor->name : '医生';
ChatNotifyLogic::addConsultationCompleteNotify(
$assistantId,
$patientName,
$doctorName,
(int) $appointment->patient_id
);
}
} catch (\Throwable $e) {
\think\facade\Log::warning('面诊结束通知医助失败: ' . $e->getMessage());
}
// 完成就诊后系统代开处方(无药材、标记 is_system_auto;失败不影响「完成」成功)
try {
PrescriptionLogic::createSystemAutoFromCompletedAppointment((int) $appointment->id);
} catch (\Throwable $e) {
Log::warning('完成挂号后系统代开处方失败: ' . $e->getMessage());
}
return true;
} catch (\Exception $e) {
self::setError($e->getMessage());
return false;
}
}
/**
* @notes 接诊台聚合:单次返回挂号信息 + 诊单病例 + 血糖血压记录 + 助理姓名
* 数据复用:detail() / DiagnosisLogic::detail() / BloodRecordLogic::getRecordsByPatient()
* 备注:appointment.patient_id 实际对应 tcm_diagnosis.id
* @param array $params
* @return array
*/
public static function reception(array $params): array
{
// 1) 挂号详情(已包含 patient_name / patient_phone / doctor_name / status_desc 等)
$appointment = self::detail($params);
if (empty($appointment)) {
return [];
}
// 2) 诊单(病例)详情:appointment.patient_id == tcm_diagnosis.id
$diagnosisId = (int) ($appointment['patient_id'] ?? 0);
$diagnosis = $diagnosisId > 0
? DiagnosisLogic::detail(['id' => $diagnosisId])
: [];
// 3) 跟踪信息(血糖血压 / 饮食 / 运动)改为前端按窗口 lazy load,
// 详见 DiagnosisController::trackingWindow。此处不再一次性返回。
// 4) 补全医助姓名(detail() 未关联 admin 助理表)
$assistantName = '';
if (!empty($diagnosis['assistant_id'])) {
$assistant = Admin::where('id', (int) $diagnosis['assistant_id'])->find();
if ($assistant) {
$assistantName = $assistant->name ?? '';
}
}
$appointment['assistant_name'] = $assistantName;
$appointment['assistant_id'] = $diagnosis['assistant_id'] ?? 0;
// 5) 是否已开方(与列表口径一致:未删除 & 未作废)
$hasPrescription = 0;
if ($diagnosisId > 0) {
$rxExists = Prescription::where('diagnosis_id', $diagnosisId)
->whereNull('delete_time')
->where('void_status', 0)
->find();
$hasPrescription = $rxExists ? 1 : 0;
}
$appointment['has_prescription'] = $hasPrescription;
// 6) 对诊单字段进行字典/枚举翻译(产出 *_text 附加字段,不覆盖原 value)
$diagnosis = self::enrichDiagnosisLabels($diagnosis);
// 7) 医生备注
$doctorNotes = $diagnosisId > 0
? DoctorNoteLogic::getByDiagnosis($diagnosisId)
: [];
// 7.1) 跟踪备注
$trackingNotes = $diagnosisId > 0
? TrackingNoteLogic::getByDiagnosis($diagnosisId)
: [];
return [
'appointment' => $appointment,
'diagnosis' => $diagnosis,
'doctor_notes' => $doctorNotes,
'tracking_notes' => $trackingNotes,
];
}
/**
* 字典翻译:把诊单中以 code 存储的字段(字典/枚举)补一份中文 label 字段,
* 便于前端(接诊台 / 只读病例页等只读场景)直接渲染,无需加载字典。
* 原始 code 字段保留不动,edit 场景仍可使用。
*
* @param array $diagnosis
* @return array
*/
public static function enrichDiagnosisLabels(array $diagnosis): array
{
if (empty($diagnosis)) {
return $diagnosis;
}
// 单值字段:value 为字典 value,翻译成字典 name
$singleDictFields = [
'diagnosis_type' => 'diagnosis_type',
'syndrome_type' => 'syndrome_type',
'diabetes_type' => 'diabetes_type',
'water_intake' => 'water_intake',
'weight_change' => 'weight_change',
'fatty_liver_degree' => 'fatty_liver_degree',
];
// 多值字段:value 为数组或逗号分隔字符串
$multiDictFields = [
'past_history' => 'past_history',
'appetite' => 'appetite',
'diet_condition' => 'diet_condition',
'body_feeling' => 'body_feeling',
'sleep_condition' => 'sleep_condition',
'eye_condition' => 'eye_condition',
'head_feeling' => 'head_feeling',
'sweat_condition' => 'sweat_condition',
'skin_condition' => 'skin_condition',
'urine_condition' => 'urine_condition',
'stool_condition' => 'stool_condition',
'kidney_condition' => 'kidney_condition',
];
// 一次性查出用到的所有字典
$allTypes = array_values(array_unique(array_merge(array_values($singleDictFields), array_values($multiDictFields))));
$dictMap = self::buildDictMap($allTypes);
foreach ($singleDictFields as $field => $type) {
if (!array_key_exists($field, $diagnosis)) {
continue;
}
$diagnosis[$field . '_text'] = self::dictLabel($dictMap, $type, $diagnosis[$field]);
}
foreach ($multiDictFields as $field => $type) {
if (!array_key_exists($field, $diagnosis)) {
continue;
}
$v = $diagnosis[$field];
$arr = is_array($v)
? $v
: (is_string($v) && $v !== '' ? preg_split('/[,,、]/', $v) : []);
$labels = [];
foreach ($arr as $item) {
$item = is_string($item) ? trim($item) : $item;
if ($item === '' || $item === null) {
continue;
}
$labels[] = self::dictLabel($dictMap, $type, $item);
}
$diagnosis[$field . '_text'] = implode('、', array_filter($labels));
}
// 枚举字段
$diagnosis['gender_text'] = isset($diagnosis['gender']) ? self::genderText((int) $diagnosis['gender']) : '';
$diagnosis['marital_status_text'] = array_key_exists('marital_status', $diagnosis) ? self::maritalText($diagnosis['marital_status']) : '';
foreach (['trauma_history', 'surgery_history', 'allergy_history', 'family_history', 'pregnancy_history'] as $f) {
if (array_key_exists($f, $diagnosis)) {
$diagnosis[$f . '_text'] = self::yesNoText($diagnosis[$f]);
}
}
return $diagnosis;
}
/**
* 构建 type_value => [value => name] 的两级查询映射
*
* @param array $types
* @return array
*/
private static function buildDictMap(array $types): array
{
if (empty($types)) {
return [];
}
$rows = DictData::whereIn('type_value', $types)
->field(['type_value', 'value', 'name'])
->select()
->toArray();
$map = [];
foreach ($rows as $r) {
$tv = (string) ($r['type_value'] ?? '');
$v = (string) ($r['value'] ?? '');
$n = (string) ($r['name'] ?? '');
if ($tv === '' || $v === '') {
continue;
}
$map[$tv][$v] = $n;
}
return $map;
}
/**
* 找不到字典项时回显原值(避免空白)
*/
private static function dictLabel(array $dictMap, string $type, $value): string
{
if ($value === null || $value === '') {
return '';
}
$key = (string) $value;
return $dictMap[$type][$key] ?? $key;
}
private static function genderText(int $gender): string
{
if ($gender === 1) return '男';
if ($gender === 0) return '女';
return '';
}
private static function maritalText($v): string
{
if ($v === null || $v === '') return '';
$n = (int) $v;
if ($n === 0) return '未婚';
if ($n === 1) return '已婚';
if ($n === 2) return '离异';
return '';
}
private static function yesNoText($v): string
{
if ($v === null || $v === '') return '';
return ((int) $v) === 1 ? '有' : '无';
}
/**
* @notes 通知接诊医助(按诊单 assistant_id 查企微 userid 后发应用消息)
* 文案固定:马上面诊到你对应的患者了,请联系患者进入诊室等待。
* @param int $appointmentId
* @return array{ok: bool, message?: string}
*/
public static function notifyAssistant(int $appointmentId): array
{
try {
$appointment = Appointment::findOrEmpty($appointmentId);
if ($appointment->isEmpty()) {
return ['ok' => false, 'message' => '挂号记录不存在'];
}
$diagnosis = Diagnosis::where('id', (int) $appointment->patient_id)->find();
if (!$diagnosis) {
return ['ok' => false, 'message' => '未找到对应的诊单'];
}
$assistantId = (int) ($diagnosis->assistant_id ?? 0);
if ($assistantId <= 0) {
return ['ok' => false, 'message' => '该患者尚未指派医助'];
}
$assistant = Admin::whereNull('delete_time')->find($assistantId);
if (!$assistant) {
return ['ok' => false, 'message' => '医助账号不存在'];
}
$wxId = trim((string) ($assistant->work_wechat_userid ?? ''));
if ($wxId === '') {
Log::warning("接诊台通知医助跳过:医助 admin_id={$assistantId} 未绑定 work_wechat_userid");
return [
'ok' => false,
'message' => '该医助未绑定企业微信账号,无法推送消息(请其在后台「扫码绑定企业微信」后再试)',
];
}
$patientName = (string) ($diagnosis->patient_name ?? '患者');
$text = "【接诊通知】马上面诊到你对应的患者了,请联系患者进入诊室等待。\n患者:{$patientName}\n时间:" . (string) $appointment->appointment_date . ' ' . (string) $appointment->appointment_time;
$result = WechatWorkAppMessageService::sendTextToUser($wxId, $text);
if (empty($result['ok'])) {
Log::warning('接诊台通知医助失败:' . ($result['message'] ?? ''));
}
return $result;
} catch (\Throwable $e) {
Log::error('接诊台通知医助异常:' . $e->getMessage());
return ['ok' => false, 'message' => '企业微信通知异常:' . $e->getMessage()];
}
}
/**
* 与 AppointmentLists 一致的可见性(不含 progress_board / diag_scope_relax
*/
private static function appointmentRowManageableByAdmin(
Appointment $appointment,
?Diagnosis $diag,
int $adminId,
array $adminInfo
): bool {
$roleIds = array_map('intval', AdminRole::where('admin_id', $adminId)->column('role_id'));
if (in_array(1, $roleIds, true) && (int) $appointment->doctor_id !== $adminId) {
return false;
}
if (in_array(2, $roleIds, true)) {
$asst = $diag ? (int) $diag->assistant_id : 0;
if ($asst !== $adminId) {
return false;
}
}
if (!DataScopeService::isEnabled()) {
return true;
}
$ids = DataScopeService::getVisibleAdminIds($adminId, $adminInfo);
if ($ids === []) {
return false;
}
if ($ids === null) {
return true;
}
$docId = (int) $appointment->doctor_id;
$asstId = $diag ? (int) $diag->assistant_id : 0;
return in_array($docId, $ids, true)
|| ($asstId > 0 && in_array($asstId, $ids, true));
}
/**
* 后台编辑挂号(预约日期/时段/类型/状态/备注/医助)
*
* @param array<string, mixed> $params
*/
public static function adminEdit(array $params, int $adminId, array $adminInfo): bool
{
try {
$id = (int) ($params['id'] ?? 0);
if ($id <= 0) {
self::setError('参数错误');
return false;
}
$appointment = Appointment::findOrEmpty($id);
if ($appointment->isEmpty()) {
self::setError('预约记录不存在');
return false;
}
$diag = Diagnosis::where('id', (int) $appointment->patient_id)->whereNull('delete_time')->find();
if (!self::appointmentRowManageableByAdmin($appointment, $diag ?: null, $adminId, $adminInfo)) {
self::setError('无权限编辑');
return false;
}
$period = trim((string) ($params['period'] ?? ''));
if (!in_array($period, ['morning', 'afternoon', 'all'], true)) {
self::setError('时段无效');
return false;
}
$appointmentDate = trim((string) ($params['appointment_date'] ?? ''));
if ($appointmentDate === '') {
self::setError('预约日期不能为空');
return false;
}
$rawTime = trim((string) ($params['appointment_time'] ?? ''));
if ($rawTime === '') {
self::setError('预约时间不能为空');
return false;
}
$appointmentTime = strlen($rawTime) === 5 ? $rawTime . ':00' : $rawTime;
$status = (int) ($params['status'] ?? 0);
if ($status < 1 || $status > 4) {
self::setError('状态无效');
return false;
}
$appointmentType = trim((string) ($params['appointment_type'] ?? ''));
if (!in_array($appointmentType, ['video', 'text', 'phone'], true)) {
self::setError('预约类型无效');
return false;
}
$remark = isset($params['remark']) ? trim((string) $params['remark']) : '';
if (mb_strlen($remark) > 500) {
self::setError('备注过长');
return false;
}
$chSrc = trim((string) ($params['channel_source'] ?? ''));
if ($chSrc === '') {
self::setError('请选择渠道来源');
return false;
}
$chDetail = trim((string) ($params['channel_source_detail'] ?? ''));
$channelDictName = trim((string) (DictData::where('type_value', 'channels')
->where('value', $chSrc)
->value('name') ?? ''));
if ($channelDictName !== '' && self::channelDictNameRequiresSourceDetail($channelDictName)) {
if ($chDetail === '') {
self::setError('请填写自媒体渠道补充信息');
return false;
}
}
if (mb_strlen($chDetail) > 128) {
self::setError('渠道补充说明过长');
return false;
}
$postAssistant = array_key_exists('assistant_id', $params);
$assistantId = null;
if ($postAssistant) {
$assistantRaw = $params['assistant_id'];
if ($assistantRaw !== null && $assistantRaw !== '') {
$aid = (int) $assistantRaw;
$assistantId = $aid > 0 ? $aid : null;
}
}
$patientId = (int) $appointment->patient_id;
$doctorId = (int) $appointment->doctor_id;
if (in_array($status, [1, 4], true)) {
$patientDup = Appointment::where('patient_id', $patientId)
->where('appointment_date', $appointmentDate)
->whereIn('status', [1, 4])
->where('id', '<>', $id)
->find();
if ($patientDup) {
self::setError('该诊单在所选日期已有其他有效挂号(已预约/已过号)');
return false;
}
}
if ($status === 1) {
$slotDup = Appointment::where('doctor_id', $doctorId)
->where('appointment_date', $appointmentDate)
->where('appointment_time', $appointmentTime)
->where('status', 1)
->where('id', '<>', $id)
->find();
if ($slotDup) {
self::setError('该医生在所选时段已被占用');
return false;
}
}
$beforeDate = (string) ($appointment->appointment_date ?? '');
$beforeTimeRaw = (string) ($appointment->appointment_time ?? '');
$beforeHm = strlen($beforeTimeRaw) >= 5 ? substr($beforeTimeRaw, 0, 5) : $beforeTimeRaw;
$snap = $appointment->toArray();
$beforePeriod = (string) ($snap['period'] ?? $snap['type'] ?? '');
$beforeStatus = (int) $appointment->status;
$tblFields = Db::name('doctor_appointment')->getTableFields();
$cols = is_array($tblFields) ? $tblFields : [];
$channelErr = self::assertAppointmentChannelWritable($cols, $chSrc);
if ($channelErr !== null) {
self::setError($channelErr);
return false;
}
Db::startTrans();
$saveData = [
'appointment_date' => $appointmentDate,
'appointment_time' => $appointmentTime,
'appointment_type' => $appointmentType,
'status' => $status,
'remark' => $remark,
'update_time' => time(),
];
if (in_array('assistant_id', $cols, true) && $postAssistant) {
$saveData['assistant_id'] = $assistantId;
}
if (in_array('period', $cols, true)) {
$saveData['period'] = $period;
} elseif (in_array('type', $cols, true)) {
$saveData['type'] = $period;
}
self::mergeAppointmentChannelIntoRow($saveData, $cols, $chSrc, $chDetail);
Db::name('doctor_appointment')->where('id', $id)->update($saveData);
Db::commit();
$hm = substr($appointmentTime, 0, 5);
$summary = sprintf(
'后台修改挂号 #%d:日期 %s %s→%s %s,时段 %s→%s,状态 %d→%d',
$id,
$beforeDate,
$beforeHm,
$appointmentDate,
$hm,
$beforePeriod !== '' ? $beforePeriod : '—',
$period,
$beforeStatus,
$status
);
self::appendDiagnosisGuahaoLog($patientId, $id, $adminId, $adminInfo, 'admin_edit', $summary);
return true;
} catch (\Throwable $e) {
Db::rollback();
self::setError($e->getMessage());
return false;
}
}
/**
* 诊单列表维度:记录谁在后台发起挂号 / 取消挂号(写入失败不影响主流程)
*/
private static function appendDiagnosisGuahaoLog(
int $diagnosisId,
int $appointmentId,
int $adminId,
array $adminInfo,
string $action,
string $summary
): void {
if ($diagnosisId <= 0) {
return;
}
$adminName = (string) ($adminInfo['name'] ?? '');
if ($adminName === '' && $adminId > 0) {
$adminName = (string) (Admin::where('id', $adminId)->value('name') ?? '');
}
try {
$log = new DiagnosisGuahaoLog();
$log->diagnosis_id = $diagnosisId;
$log->appointment_id = $appointmentId;
$log->admin_id = $adminId;
$log->admin_name = mb_substr($adminName, 0, 64);
$log->action = mb_substr($action, 0, 32);
$log->summary = mb_substr($summary, 0, 500);
$log->create_time = time();
$log->save();
} catch (\Throwable $e) {
Log::warning('诊单挂号日志写入失败: ' . $e->getMessage());
}
}
}