diff --git a/.claude/settings.local.json b/.claude/settings.local.json index e2325159..a9695518 100644 --- a/.claude/settings.local.json +++ b/.claude/settings.local.json @@ -11,7 +11,11 @@ "Bash(php think *)", "Bash(python3 -c ' *)", "Bash(python3 *)", - "Bash(awk '/ + + diff --git a/server/app/adminapi/controller/tcm/DiagnosisController.php b/server/app/adminapi/controller/tcm/DiagnosisController.php index 5780d06c..ecac300b 100644 --- a/server/app/adminapi/controller/tcm/DiagnosisController.php +++ b/server/app/adminapi/controller/tcm/DiagnosisController.php @@ -18,6 +18,7 @@ use app\adminapi\controller\BaseAdminController; use app\adminapi\lists\tcm\DiagnosisLists; use app\adminapi\logic\order\OrderActionLogLogic; use app\adminapi\logic\tcm\DiagnosisLogic; +use app\adminapi\logic\tcm\TrackingNoteLogic; use app\adminapi\validate\tcm\DiagnosisValidate; use app\common\model\Order; use app\common\model\WechatChatRecord; @@ -113,12 +114,86 @@ class DiagnosisController extends BaseAdminController */ public function detail() { - + $params = (new DiagnosisValidate())->goCheck('id'); $result = DiagnosisLogic::detail($params); return $this->data($result); } + /** + * @notes 二诊只读病例详情(患者信息 + 病例 + 跟踪记录) + * + * 路由:GET /tcm.diagnosis/readonlyDetail?id=:diagnosisId + * 权限:tcm.diagnosis/readonlyDetail + * + * @return \think\response\Json + */ + public function readonlyDetail() + { + $params = (new DiagnosisValidate())->goCheck('readonlyDetail'); + $result = DiagnosisLogic::readonlyDetail($params, $this->adminId, $this->adminInfo); + if (empty($result)) { + return $this->fail(DiagnosisLogic::getError() ?: '诊单不存在或无权访问'); + } + + return $this->data($result); + } + + /** + * @notes 跟踪信息(血糖血压 / 饮食 / 运动)—— 按日期区间 lazy load + * + * 路由:GET /tcm.diagnosis/trackingWindow?id=:diagnosisId&start_date=&end_date= + * 权限:tcm.diagnosis/readonlyDetail(与只读病例页同档;接诊台亦复用) + * + * @return \think\response\Json + */ + public function trackingWindow() + { + $params = (new DiagnosisValidate())->goCheck('trackingWindow'); + $result = DiagnosisLogic::fetchTrackingWindow( + (int) $params['id'], + (string) ($params['start_date'] ?? ''), + (string) ($params['end_date'] ?? '') + ); + return $this->data($result); + } + + /** + * @notes 新增跟踪备注(按天合并追加,仅文字) + * + * 路由:POST /tcm.diagnosis/addTrackingNote + * 权限:tcm.diagnosis/addTrackingNote + * + * @return \think\response\Json + */ + public function addTrackingNote() + { + $params = (new DiagnosisValidate())->post()->goCheck('addTrackingNote'); + $ok = TrackingNoteLogic::addOrAppend([ + 'diagnosis_id' => (int) $params['diagnosis_id'], + 'admin_id' => (int) $this->adminId, + 'content' => (string) $params['tracking_content'], + ]); + if ($ok === false) { + return $this->fail(TrackingNoteLogic::getError() ?: '保存失败'); + } + return $this->success('保存成功'); + } + + /** + * @notes 拉取跟踪备注列表(note_date DESC) + * + * 路由:GET /tcm.diagnosis/trackingNotes?diagnosis_id=:diagnosisId + * 权限:tcm.diagnosis/trackingNotes + * + * @return \think\response\Json + */ + public function trackingNotes() + { + $params = (new DiagnosisValidate())->goCheck('trackingNotes'); + return $this->data(TrackingNoteLogic::getByDiagnosis((int) $params['diagnosis_id'])); + } + /** * @notes 诊单挂号 / 取消挂号 操作日志(谁在何时操作) */ diff --git a/server/app/adminapi/controller/tcm/DiagnosisTodoController.php b/server/app/adminapi/controller/tcm/DiagnosisTodoController.php new file mode 100644 index 00000000..89d6b8c3 --- /dev/null +++ b/server/app/adminapi/controller/tcm/DiagnosisTodoController.php @@ -0,0 +1,81 @@ +goCheck('list'); + + return $this->dataLists(new DiagnosisTodoLists()); + } + + /** + * @notes 新增待办 + */ + public function add() + { + $params = (new DiagnosisTodoValidate())->post()->goCheck('add'); + $result = DiagnosisTodoLogic::add($params, $this->adminId, $this->adminInfo); + if (!$result) { + return $this->fail(DiagnosisTodoLogic::getError() ?: '创建失败'); + } + + return $this->success('创建成功', [], 1, 1); + } + + /** + * @notes 取消待办(仅 status=0 可取消,仅创建人/超管) + */ + public function cancel() + { + $params = (new DiagnosisTodoValidate())->post()->goCheck('cancel'); + $result = DiagnosisTodoLogic::cancel((int) $params['id'], $this->adminId); + if (!$result) { + return $this->fail(DiagnosisTodoLogic::getError() ?: '取消失败'); + } + + return $this->success('已取消', [], 1, 1); + } + + /** + * @notes 待办详情 + */ + public function detail() + { + $params = (new DiagnosisTodoValidate())->goCheck('detail'); + $result = DiagnosisTodoLogic::detail((int) $params['id'], $this->adminId); + + return $this->data($result); + } +} diff --git a/server/app/adminapi/lists/tcm/DiagnosisLists.php b/server/app/adminapi/lists/tcm/DiagnosisLists.php index 559c86a6..846218b3 100644 --- a/server/app/adminapi/lists/tcm/DiagnosisLists.php +++ b/server/app/adminapi/lists/tcm/DiagnosisLists.php @@ -17,6 +17,7 @@ namespace app\adminapi\lists\tcm; use app\adminapi\lists\BaseAdminDataLists; use app\adminapi\logic\dept\DeptLogic; use app\common\model\tcm\Diagnosis; +use app\common\model\tcm\BloodRecord; use app\common\model\DiagnosisViewRecord; use app\common\model\doctor\Appointment; use app\common\model\tcm\Prescription; @@ -381,6 +382,25 @@ class DiagnosisLists extends BaseAdminDataLists implements ListsSearchInterface foreach ($lists as &$item) { $item['video_call_hint'] = $this->buildVideoCallHint($latestCallByDiag[$item['id']] ?? null); } + unset($item); + + // 未服务天数:以血糖记录最近一条 record_date 为锚点(口径见 prd T3) + // 单次 GROUP BY 聚合,不会成 N+1;走 idx_diagnosis_id 索引。 + $diagIdsForUnserved = array_values(array_filter(array_unique(array_column($lists, 'id')))); + $maxBloodByDiag = []; + if (!empty($diagIdsForUnserved)) { + $maxBloodByDiag = BloodRecord::whereIn('diagnosis_id', $diagIdsForUnserved) + ->whereNull('delete_time') + ->group('diagnosis_id') + ->column('MAX(record_date)', 'diagnosis_id'); + } + $nowTs = time(); + foreach ($lists as &$item) { + $last = (int) ($maxBloodByDiag[(int) $item['id']] ?? 0); + $item['last_blood_record_at'] = $last > 0 ? date('Y-m-d', $last) : null; + $item['unserved_days'] = $last > 0 ? max(0, (int) floor(($nowTs - $last) / 86400)) : null; + } + unset($item); return $lists; } diff --git a/server/app/adminapi/lists/tcm/DiagnosisTodoLists.php b/server/app/adminapi/lists/tcm/DiagnosisTodoLists.php new file mode 100644 index 00000000..020c3ceb --- /dev/null +++ b/server/app/adminapi/lists/tcm/DiagnosisTodoLists.php @@ -0,0 +1,73 @@ + ['diagnosis_id', 'status', 'creator_id'], + ]; + } + + public function lists(): array + { + $lists = DiagnosisTodo::where($this->searchWhere) + ->field([ + 'id', 'diagnosis_id', 'patient_id', 'content', 'remind_time', + 'status', 'creator_id', 'creator_name', 'notified_at', 'error', + 'cancelled_at', 'cancelled_by', 'create_time', 'update_time', + ]) + ->append([ + 'status_text', + 'remind_time_text', + 'notified_at_text', + 'cancelled_at_text', + ]) + ->order('remind_time', 'desc') + ->order('id', 'desc') + ->limit($this->limitOffset, $this->limitLength) + ->select() + ->toArray(); + + // 业务态:是否能被「我」取消(前端按钮显隐用) + $adminId = (int) $this->adminId; + foreach ($lists as &$item) { + $item['can_cancel'] = DiagnosisTodoLogic::canCancel($item, $adminId); + } + + return $lists; + } + + public function count(): int + { + return DiagnosisTodo::where($this->searchWhere)->count(); + } +} diff --git a/server/app/adminapi/logic/doctor/AppointmentLogic.php b/server/app/adminapi/logic/doctor/AppointmentLogic.php index 4d375416..869198ee 100644 --- a/server/app/adminapi/logic/doctor/AppointmentLogic.php +++ b/server/app/adminapi/logic/doctor/AppointmentLogic.php @@ -13,6 +13,7 @@ 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; @@ -516,10 +517,8 @@ class AppointmentLogic extends BaseLogic ? DiagnosisLogic::detail(['id' => $diagnosisId]) : []; - // 3) 血糖血压记录 - $bloodRecords = $diagnosisId > 0 - ? BloodRecordLogic::getRecordsByPatient(['diagnosis_id' => $diagnosisId]) - : []; + // 3) 跟踪信息(血糖血压 / 饮食 / 运动)改为前端按窗口 lazy load, + // 详见 DiagnosisController::trackingWindow。此处不再一次性返回。 // 4) 补全医助姓名(detail() 未关联 admin 助理表) $assistantName = ''; @@ -551,23 +550,28 @@ class AppointmentLogic extends BaseLogic ? DoctorNoteLogic::getByDiagnosis($diagnosisId) : []; + // 7.1) 跟踪备注 + $trackingNotes = $diagnosisId > 0 + ? TrackingNoteLogic::getByDiagnosis($diagnosisId) + : []; + return [ - 'appointment' => $appointment, - 'diagnosis' => $diagnosis, - 'blood_records' => $bloodRecords, - 'doctor_notes' => $doctorNotes, + 'appointment' => $appointment, + 'diagnosis' => $diagnosis, + 'doctor_notes' => $doctorNotes, + 'tracking_notes' => $trackingNotes, ]; } /** * 字典翻译:把诊单中以 code 存储的字段(字典/枚举)补一份中文 label 字段, - * 便于前端(接诊台等只读场景)直接渲染,无需加载字典。 + * 便于前端(接诊台 / 只读病例页等只读场景)直接渲染,无需加载字典。 * 原始 code 字段保留不动,edit 场景仍可使用。 * * @param array $diagnosis * @return array */ - private static function enrichDiagnosisLabels(array $diagnosis): array + public static function enrichDiagnosisLabels(array $diagnosis): array { if (empty($diagnosis)) { return $diagnosis; diff --git a/server/app/adminapi/logic/tcm/DiagnosisLogic.php b/server/app/adminapi/logic/tcm/DiagnosisLogic.php index 4e835894..5aa86648 100644 --- a/server/app/adminapi/logic/tcm/DiagnosisLogic.php +++ b/server/app/adminapi/logic/tcm/DiagnosisLogic.php @@ -19,9 +19,18 @@ use app\common\model\tcm\Diagnosis; use app\common\model\tcm\DiagnosisGuahaoLog; use app\common\model\tcm\DiagnosisAssignLog; use app\common\model\tcm\ImChatMessage; +use app\common\model\tcm\BloodRecord; +use app\common\model\tcm\DietRecord; +use app\common\model\tcm\ExerciseRecord; use app\common\model\DiagnosisViewRecord; +use app\common\model\doctor\Appointment; +use app\common\model\auth\Admin; +use app\common\model\auth\AdminRole; use app\adminapi\logic\doctor\DoctorNoteLogic; +use app\adminapi\logic\doctor\AppointmentLogic; +use app\adminapi\logic\tcm\TrackingNoteLogic; use app\common\service\FileService; +use app\common\service\DataScope\DataScopeService; use think\facade\Db; /** * 中医辨房病因诊单逻辑 @@ -3324,4 +3333,270 @@ class DiagnosisLogic extends BaseLogic return $logs; } + + /** + * @notes 二诊只读病例详情 + * + * 用途:医助从诊单列表「查看 / 双击」进入只读页(T1+T2),返回与接诊台同结构的 + * 三块内容(appointment + diagnosis + 跟踪记录)。 + * + * 数据权限(与列表 lists 接口一致): + * - 医助 (role_id=2):仅能访问 assistant_id == self 的诊单 + * - DataScope 启用:assistant_id 须落在 visibleAdminIds + * - 越权 → setError + 返回 [] + * + * @param array $params 至少含 id + * @param int $adminId + * @param array $adminInfo request->adminInfo + * @return array + */ + public static function readonlyDetail(array $params, int $adminId, array $adminInfo): array + { + $diagnosisId = (int) ($params['id'] ?? 0); + if ($diagnosisId <= 0) { + self::setError('诊单 id 不能为空'); + + return []; + } + + // 1) 数据权限闸 — 不通过则返回「不存在或无权访问」 + $accessQuery = Diagnosis::where('id', $diagnosisId)->whereNull('delete_time'); + + $roleIds = array_map('intval', AdminRole::where('admin_id', $adminId)->column('role_id')); + if (in_array(2, $roleIds, true)) { + // 医助仅看自己被指派的 + $accessQuery->where('assistant_id', $adminId); + } + + if (DataScopeService::isEnabled()) { + $visibleIds = DataScopeService::getVisibleAdminIds($adminId, $adminInfo); + if ($visibleIds === []) { + self::setError('诊单不存在或无权访问'); + + return []; + } + if (is_array($visibleIds)) { + $accessQuery->whereIn('assistant_id', $visibleIds); + } + } + + if (!$accessQuery->find()) { + self::setError('诊单不存在或无权访问'); + + return []; + } + + // 2) 诊单详情(含图片聚合等)+ 字典翻译 + $diagnosis = self::detail(['id' => $diagnosisId]); + if (empty($diagnosis)) { + self::setError('诊单数据为空'); + + return []; + } + $diagnosis = AppointmentLogic::enrichDiagnosisLabels($diagnosis); + + // 3) 最近一条挂号(PatientInfoCard 显示用,无挂号则给默认空对象) + $appointment = self::buildLatestAppointmentForReadonly((int) ($diagnosis['id'] ?? $diagnosisId), $diagnosis); + + // 4) 跟踪信息(血糖血压 / 饮食 / 运动)改为前端按窗口 lazy load, + // 详见 DiagnosisController::trackingWindow。此处不再一次性返回。 + + // 5) 医生备注 + $doctorNotes = DoctorNoteLogic::getByDiagnosis($diagnosisId); + + // 5.1) 跟踪备注(只读展示,按 note_date DESC) + $trackingNotes = TrackingNoteLogic::getByDiagnosis($diagnosisId); + + // 6) 未服务天数(与 T3 列同口径) + $maxBloodTs = BloodRecord::where('diagnosis_id', $diagnosisId) + ->max('record_date'); + $maxBloodTs = (int) $maxBloodTs; + $unservedDays = $maxBloodTs > 0 ? max(0, (int) floor((time() - $maxBloodTs) / 86400)) : null; + $lastBloodRecordAt = $maxBloodTs > 0 ? date('Y-m-d', $maxBloodTs) : null; + + return [ + 'appointment' => $appointment, + 'diagnosis' => $diagnosis, + 'doctor_notes' => $doctorNotes, + 'tracking_notes' => $trackingNotes, + 'unserved_days' => $unservedDays, + 'last_blood_record_at' => $lastBloodRecordAt, + ]; + } + + /** + * 取指定日期区间内的三类跟踪记录(血糖血压 / 饮食 / 运动),供 readonlyDetail 与 + * 医生接诊台 reception 通过独立接口 lazy load。 + * + * 区间语义:闭区间 [startDate, endDate](Y-m-d),均不传则不限。 + * + * @return array{ + * blood_records: array>, + * diet_records: array>, + * exercise_records: array>, + * start_date: string, + * end_date: string, + * } + */ + public static function fetchTrackingWindow(int $diagnosisId, string $startDate = '', string $endDate = ''): array + { + $sinceTs = $startDate !== '' ? (int) strtotime($startDate . ' 00:00:00') : 0; + $untilTs = $endDate !== '' ? (int) strtotime($endDate . ' 23:59:59') : 0; + $sinceTs = $sinceTs > 0 ? $sinceTs : 0; + $untilTs = $untilTs > 0 ? $untilTs : 0; + + return [ + 'blood_records' => self::fetchBloodRecordsForReadonly($diagnosisId, $sinceTs, $untilTs), + 'diet_records' => self::fetchDietRecordsForReadonly($diagnosisId, $sinceTs, $untilTs), + 'exercise_records' => self::fetchExerciseRecordsForReadonly($diagnosisId, $sinceTs, $untilTs), + 'start_date' => $startDate, + 'end_date' => $endDate, + ]; + } + + /** + * 拼一份「最近一条挂号」给只读页 PatientInfoCard 用;没有挂号则给默认空骨架。 + * + * @param int $diagnosisId + * @param array $diagnosis 已字典翻译过的诊单数据 + * @return array + */ + private static function buildLatestAppointmentForReadonly(int $diagnosisId, array $diagnosis): array + { + $apt = Appointment::where('patient_id', $diagnosisId) + ->order('id', 'desc') + ->find(); + + $patientName = (string) ($diagnosis['patient_name'] ?? ''); + $patientPhone = (string) ($diagnosis['phone'] ?? ''); + + if (!$apt) { + return [ + 'id' => 0, + 'patient_id' => $diagnosisId, + 'patient_name' => $patientName, + 'patient_phone' => $patientPhone, + 'doctor_id' => 0, + 'doctor_name' => '', + 'assistant_id' => (int) ($diagnosis['assistant_id'] ?? 0), + 'assistant_name' => self::resolveAssistantName((int) ($diagnosis['assistant_id'] ?? 0)), + 'appointment_date' => '', + 'appointment_time' => '', + 'period' => '', + 'status' => 0, + 'status_desc' => '无挂号', + 'has_prescription' => 0, + 'remark' => '', + ]; + } + + $aptArr = $apt->toArray(); + $aptArr['patient_name'] = $patientName; + $aptArr['patient_phone'] = $patientPhone; + // 医生姓名 + $aptArr['doctor_name'] = ''; + $docId = (int) ($aptArr['doctor_id'] ?? 0); + if ($docId > 0) { + $aptArr['doctor_name'] = (string) Admin::where('id', $docId)->value('name'); + } + $aptArr['assistant_id'] = (int) ($diagnosis['assistant_id'] ?? 0); + $aptArr['assistant_name'] = self::resolveAssistantName((int) ($diagnosis['assistant_id'] ?? 0)); + // status_desc 与现有列表口径保持简单映射 + $statusMap = [ + 0 => '未挂号', + 1 => '已预约', + 2 => '已取消', + 3 => '已完成', + 4 => '已过号', + ]; + $aptArr['status_desc'] = $statusMap[(int) ($aptArr['status'] ?? 0)] ?? ''; + $aptArr['has_prescription'] = (int) ($diagnosis['has_prescription'] ?? 0); + + return $aptArr; + } + + private static function resolveAssistantName(int $assistantId): string + { + if ($assistantId <= 0) { + return ''; + } + + return (string) Admin::where('id', $assistantId)->value('name'); + } + + /** + * @return array> + */ + private static function fetchBloodRecordsForReadonly(int $diagnosisId, int $sinceTs, int $untilTs = 0): array + { + $query = BloodRecord::where('diagnosis_id', $diagnosisId); + if ($sinceTs > 0) { + $query->where('record_date', '>=', $sinceTs); + } + if ($untilTs > 0) { + $query->where('record_date', '<=', $untilTs); + } + $rows = $query->order('record_date', 'desc') + ->order('record_time', 'desc') + ->select() + ->toArray(); + + // 给前端一个 Y-m-d 字符串方便按日期透视;保留原 record_date 整数戳作日期窗口比较 + foreach ($rows as &$row) { + $ts = (int) ($row['record_date'] ?? 0); + $row['record_date_ts'] = $ts; + $row['record_date'] = $ts > 0 ? date('Y-m-d', $ts) : ''; + } + + return $rows; + } + + /** + * @return array> + */ + private static function fetchDietRecordsForReadonly(int $diagnosisId, int $sinceTs, int $untilTs = 0): array + { + $query = DietRecord::where('diagnosis_id', $diagnosisId); + if ($sinceTs > 0) { + $query->where('record_date', '>=', $sinceTs); + } + if ($untilTs > 0) { + $query->where('record_date', '<=', $untilTs); + } + $rows = $query->order('record_date', 'desc')->select()->toArray(); + + foreach ($rows as &$row) { + $ts = (int) ($row['record_date'] ?? 0); + $row['record_date_ts'] = $ts; + $row['record_date'] = $ts > 0 ? date('Y-m-d', $ts) : ''; + } + + return $rows; + } + + /** + * @return array> + */ + private static function fetchExerciseRecordsForReadonly(int $diagnosisId, int $sinceTs, int $untilTs = 0): array + { + $query = ExerciseRecord::where('diagnosis_id', $diagnosisId); + if ($sinceTs > 0) { + $query->where('record_date', '>=', $sinceTs); + } + if ($untilTs > 0) { + $query->where('record_date', '<=', $untilTs); + } + $rows = $query->order('record_date', 'desc') + ->append(['intensity_text']) + ->select() + ->toArray(); + + foreach ($rows as &$row) { + $ts = (int) ($row['record_date'] ?? 0); + $row['record_date_ts'] = $ts; + $row['record_date'] = $ts > 0 ? date('Y-m-d', $ts) : ''; + } + + return $rows; + } } diff --git a/server/app/adminapi/logic/tcm/DiagnosisTodoLogic.php b/server/app/adminapi/logic/tcm/DiagnosisTodoLogic.php new file mode 100644 index 00000000..f6d5282e --- /dev/null +++ b/server/app/adminapi/logic/tcm/DiagnosisTodoLogic.php @@ -0,0 +1,187 @@ + $params 已通过 sceneAdd 验证 + * @param int $adminId 当前 admin id + * @param array $adminInfo request->adminInfo + */ + public static function add(array $params, int $adminId, array $adminInfo): bool + { + try { + $diagnosisId = (int) ($params['diagnosis_id'] ?? 0); + $diagnosis = Diagnosis::findOrEmpty($diagnosisId); + if ($diagnosis->isEmpty()) { + self::setError('诊单不存在'); + + return false; + } + + $remindTime = (int) ($params['remind_time'] ?? 0); + if ($remindTime <= time()) { + self::setError('提醒时间必须晚于当前时间'); + + return false; + } + + $creatorName = trim((string) ($adminInfo['name'] ?? '')); + if ($creatorName === '' && $adminId > 0) { + $creatorName = (string) Admin::where('id', $adminId)->value('name'); + } + + DiagnosisTodo::create([ + 'diagnosis_id' => $diagnosisId, + 'patient_id' => (int) ($diagnosis->getAttr('patient_id') ?? 0), + 'content' => trim((string) ($params['content'] ?? '')), + 'remind_time' => $remindTime, + 'status' => DiagnosisTodo::STATUS_PENDING, + 'creator_id' => $adminId, + 'creator_name' => $creatorName, + ]); + + return true; + } catch (\Throwable $e) { + self::setError($e->getMessage()); + + return false; + } + } + + /** + * @notes 取消待办(人工) + */ + public static function cancel(int $id, int $adminId): bool + { + try { + $todo = DiagnosisTodo::findOrEmpty($id); + if ($todo->isEmpty()) { + self::setError('待办不存在'); + + return false; + } + + $status = (int) $todo->getAttr('status'); + if ($status !== DiagnosisTodo::STATUS_PENDING) { + self::setError('该待办已不是「待执行」状态,无法取消'); + + return false; + } + + $creatorId = (int) $todo->getAttr('creator_id'); + $isSuper = self::isSuperAdmin($adminId); + if ($creatorId !== $adminId && !$isSuper) { + self::setError('仅创建人或超级管理员可取消'); + + return false; + } + + $todo->save([ + 'status' => DiagnosisTodo::STATUS_CANCELLED, + 'cancelled_at' => time(), + 'cancelled_by' => $adminId, + ]); + + return true; + } catch (\Throwable $e) { + self::setError($e->getMessage()); + + return false; + } + } + + /** + * @notes 详情 + * + * @return array + */ + public static function detail(int $id, int $adminId = 0): array + { + $todo = DiagnosisTodo::findOrEmpty($id); + if ($todo->isEmpty()) { + return []; + } + $arr = $todo->append([ + 'status_text', + 'remind_time_text', + 'notified_at_text', + 'cancelled_at_text', + ])->toArray(); + + // 业务态:是否能被「我」取消(前端按钮显隐用) + $arr['can_cancel'] = self::canCancel($todo->toArray(), $adminId); + + return $arr; + } + + /** + * 是否超管:role_id=1 + */ + public static function isSuperAdmin(int $adminId): bool + { + if ($adminId <= 0) { + return false; + } + $roleIds = AdminRole::where('admin_id', $adminId)->column('role_id'); + + return in_array(1, array_map('intval', $roleIds), true); + } + + /** + * 业务判定:当前 admin 能否取消该待办 + * + * @param array $todoRow + */ + public static function canCancel(array $todoRow, int $adminId): bool + { + if ((int) ($todoRow['status'] ?? -1) !== DiagnosisTodo::STATUS_PENDING) { + return false; + } + if ($adminId <= 0) { + return false; + } + if ((int) ($todoRow['creator_id'] ?? 0) === $adminId) { + return true; + } + + return self::isSuperAdmin($adminId); + } +} diff --git a/server/app/adminapi/logic/tcm/TrackingNoteLogic.php b/server/app/adminapi/logic/tcm/TrackingNoteLogic.php new file mode 100644 index 00000000..53318e72 --- /dev/null +++ b/server/app/adminapi/logic/tcm/TrackingNoteLogic.php @@ -0,0 +1,95 @@ +where('note_date', $today) + ->whereNull('delete_time') + ->find(); + + if ($existing) { + $prev = trim((string) ($existing->content ?? '')); + $existing->content = $prev !== '' ? ($prev . "\n" . $line) : $line; + $existing->save(); + } else { + TrackingNote::create([ + 'diagnosis_id' => $diagnosisId, + 'admin_id' => $adminId, + 'note_date' => $today, + 'content' => $line, + ]); + } + + return true; + } catch (\Exception $e) { + self::setError($e->getMessage()); + return false; + } + } + + /** + * 按 diagnosis_id 获取跟踪备注列表(note_date DESC) + * + * @param int $diagnosisId + * @param int $limit 最近 N 天 + * @return array + */ + public static function getByDiagnosis(int $diagnosisId, int $limit = 60): array + { + try { + if ($diagnosisId <= 0) { + return []; + } + + $records = TrackingNote::where('diagnosis_id', $diagnosisId) + ->whereNull('delete_time') + ->order('note_date', 'desc') + ->limit($limit) + ->select() + ->toArray(); + + return $records; + } catch (\Exception $e) { + self::setError($e->getMessage()); + return []; + } + } +} diff --git a/server/app/adminapi/validate/tcm/DiagnosisTodoValidate.php b/server/app/adminapi/validate/tcm/DiagnosisTodoValidate.php new file mode 100644 index 00000000..7007b867 --- /dev/null +++ b/server/app/adminapi/validate/tcm/DiagnosisTodoValidate.php @@ -0,0 +1,93 @@ + 'require|number', + 'diagnosis_id' => 'require|number|gt:0|checkDiagnosis', + 'content' => 'require|length:1,500', + 'remind_time' => 'require|number|checkRemindFuture', + ]; + + protected $message = [ + 'id.require' => '参数缺失', + 'diagnosis_id.require' => '诊单 id 不能为空', + 'diagnosis_id.gt' => '诊单 id 非法', + 'content.require' => '请输入提醒内容', + 'content.length' => '提醒内容长度需在 1-500 字', + 'remind_time.require' => '请选择提醒时间', + 'remind_time.number' => '提醒时间格式错误', + ]; + + public function sceneAdd() + { + return $this->only(['diagnosis_id', 'content', 'remind_time']); + } + + public function sceneCancel() + { + return $this->only(['id']); + } + + public function sceneDetail() + { + return $this->only(['id']); + } + + public function sceneList() + { + return $this->only(['diagnosis_id']) + ->append('diagnosis_id', 'require|number|gt:0'); + } + + /** + * 诊单存在校验 + */ + protected function checkDiagnosis($value): bool|string + { + $diagnosis = Diagnosis::findOrEmpty((int) $value); + if ($diagnosis->isEmpty()) { + return '诊单不存在'; + } + + return true; + } + + /** + * 提醒时间必须在当前时间之后(至少 30 秒),避免立刻就过期 + */ + protected function checkRemindFuture($value): bool|string + { + $ts = (int) $value; + if ($ts <= time() + 30) { + return '提醒时间必须晚于当前时间'; + } + + return true; + } +} diff --git a/server/app/adminapi/validate/tcm/DiagnosisValidate.php b/server/app/adminapi/validate/tcm/DiagnosisValidate.php index 2e48279d..91856536 100644 --- a/server/app/adminapi/validate/tcm/DiagnosisValidate.php +++ b/server/app/adminapi/validate/tcm/DiagnosisValidate.php @@ -37,6 +37,10 @@ class DiagnosisValidate extends BaseValidate 'tongue_images' => 'array', 'report_files' => 'array', 'diabetes_discovery_year' => 'max:50', + 'start_date' => 'date', + 'end_date' => 'date|checkDateRange', + 'diagnosis_id' => 'require|integer|checkDiagnosisId', + 'tracking_content' => 'require|length:1,1000', ]; protected $message = [ @@ -55,6 +59,11 @@ class DiagnosisValidate extends BaseValidate 'status.in' => '状态参数错误', 'current_medications.max' => '在用药物最多2000个字符', 'diabetes_discovery_year.max' => '发现糖尿病患病史最多50个字符', + 'start_date.date' => '开始日期格式不正确', + 'end_date.date' => '结束日期格式不正确', + 'diagnosis_id.require' => '诊单ID不能为空', + 'tracking_content.require' => '跟踪备注内容不能为空', + 'tracking_content.length' => '跟踪备注最多1000个字符', ]; public function sceneAdd() @@ -71,7 +80,29 @@ class DiagnosisValidate extends BaseValidate { return $this->only(['id']); } - + + public function sceneReadonlyDetail() + { + return $this->only(['id']); + } + + public function sceneTrackingWindow() + { + return $this->only(['id', 'start_date', 'end_date']); + } + + /** 新增跟踪备注:诊单ID + 备注内容 */ + public function sceneAddTrackingNote() + { + return $this->only(['diagnosis_id', 'tracking_content']); + } + + /** 拉取跟踪备注列表:诊单ID */ + public function sceneTrackingNotes() + { + return $this->only(['diagnosis_id']); + } + public function sceneGenerateQrcode() { return $this->only(['diagnosis_id', 'doctor_id', 'patient_id', 'share_user_id', 'mini_program_path']) @@ -107,6 +138,26 @@ class DiagnosisValidate extends BaseValidate return true; } + /** 校验 diagnosis_id 字段(addTrackingNote 等场景使用,避开 id 字段) */ + protected function checkDiagnosisId($value) + { + $diagnosis = Diagnosis::findOrEmpty($value); + if ($diagnosis->isEmpty()) { + return '诊单不存在'; + } + return true; + } + + /** end_date 不能早于 start_date */ + protected function checkDateRange($value, $rule, $data = []) + { + $start = $data['start_date'] ?? ''; + if ($start && $value && strtotime($value) < strtotime($start)) { + return '结束日期不能早于开始日期'; + } + return true; + } + /** 视频场景需 doctor_id,确认诊单需 diagnosis_id */ protected function checkQrcodeIds($value, $rule, $data = []) { diff --git a/server/app/command/DiagnosisTodoNotify.php b/server/app/command/DiagnosisTodoNotify.php new file mode 100644 index 00000000..7c0cdf23 --- /dev/null +++ b/server/app/command/DiagnosisTodoNotify.php @@ -0,0 +1,207 @@ +> /var/log/zyt-todo.log 2>&1 + * + * 行为: + * 1. 取出 status=0 且 remind_time<=now 的待办,按 remind_time asc,限 200 条 + * 2. 每条用乐观锁 (update set status=1 where id=? and status=0) 抢占,避免并发重复推送 + * 3. 找创建人 admin.work_wechat_userid,未绑定 → status=3 + error + * 4. 调 WechatWorkAppMessageService::sendTextToUser,失败 → status=3 + error(终态,不重试) + * 5. 整批不中断;输出 处理 N / 成功 X / 失败 Y / 跳过未绑定 Z + */ +class DiagnosisTodoNotify extends Command +{ + /** 单次扫描最大条数 */ + private const BATCH_LIMIT = 200; + + protected function configure() + { + $this->setName('tcm:diagnosis-todo-notify') + ->setDescription('诊单待办事项:扫描到点的待执行项并向创建人发送企业微信消息'); + } + + protected function execute(Input $input, Output $output): int + { + $startTs = microtime(true); + $now = time(); + + $output->writeln('[' . date('Y-m-d H:i:s', $now) . '] 开始扫描诊单待办事项...'); + + $totalProcessed = 0; + $totalSent = 0; + $totalFailed = 0; + $totalSkipped = 0; // 被并发抢占 + $totalUnbound = 0; // 创建人未绑定企微(计入 failed) + + try { + $todoTable = (new DiagnosisTodo())->getTable(); + + $rows = Db::name(self::stripTablePrefix($todoTable)) + ->where('status', DiagnosisTodo::STATUS_PENDING) + ->where('remind_time', '<=', $now) + ->whereNull('delete_time') + ->order('remind_time', 'asc') + ->limit(self::BATCH_LIMIT) + ->select() + ->toArray(); + + $output->writeln('待处理数量:' . count($rows)); + + foreach ($rows as $row) { + $totalProcessed++; + $todoId = (int) ($row['id'] ?? 0); + if ($todoId <= 0) { + continue; + } + + try { + // 乐观锁:抢占成功时影响 1 行;并发场景下另一个进程已抢走则影响 0 行 → 跳过 + $affected = Db::name(self::stripTablePrefix($todoTable)) + ->where('id', $todoId) + ->where('status', DiagnosisTodo::STATUS_PENDING) + ->update([ + 'status' => DiagnosisTodo::STATUS_SENT, + 'notified_at' => $now, + 'update_time' => $now, + ]); + + if ($affected <= 0) { + $totalSkipped++; + continue; + } + + // 取创建人 work_wechat_userid + $creatorId = (int) ($row['creator_id'] ?? 0); + $wxId = ''; + if ($creatorId > 0) { + $wxId = (string) Admin::where('id', $creatorId)->value('work_wechat_userid'); + } + + if ($wxId === '') { + $this->markFailed($todoTable, $todoId, '创建人未绑定企业微信 userid'); + $totalUnbound++; + $totalFailed++; + continue; + } + + // 拼推送文本 + $text = $this->buildText($row); + + $res = WechatWorkAppMessageService::sendTextToUser($wxId, $text); + if (!($res['ok'] ?? false)) { + $errMsg = (string) ($res['message'] ?? '企业微信推送失败'); + $this->markFailed($todoTable, $todoId, $errMsg); + $totalFailed++; + continue; + } + + $totalSent++; + } catch (\Throwable $e) { + Log::error('诊单待办推送异常 todo_id=' . $todoId . ' msg=' . $e->getMessage()); + try { + $this->markFailed($todoTable, $todoId, '系统异常: ' . $e->getMessage()); + } catch (\Throwable $ee) { + Log::error('回写失败状态出错 todo_id=' . $todoId . ' msg=' . $ee->getMessage()); + } + $totalFailed++; + } + } + } catch (\Throwable $e) { + Log::error('诊单待办批处理异常: ' . $e->getMessage()); + $output->error('批处理异常: ' . $e->getMessage()); + + return 1; + } + + $duration = round(microtime(true) - $startTs, 3); + $output->writeln(sprintf( + '处理完成。总数: %d, 成功: %d, 失败: %d (其中未绑定企微: %d), 并发跳过: %d, 耗时: %ss', + $totalProcessed, + $totalSent, + $totalFailed, + $totalUnbound, + $totalSkipped, + $duration + )); + + return 0; + } + + /** + * 拼接推送文本 + * + * @param array $row 待办记录原始行 + */ + private function buildText(array $row): string + { + $patientName = ''; + $diagnosisId = (int) ($row['diagnosis_id'] ?? 0); + if ($diagnosisId > 0) { + $patientName = (string) Diagnosis::where('id', $diagnosisId)->value('patient_name'); + } + + $remindAt = (int) ($row['remind_time'] ?? 0); + $content = trim((string) ($row['content'] ?? '')); + + $lines = [ + '【患者跟踪提醒】', + '患者:' . ($patientName !== '' ? $patientName : '-'), + '时间:' . ($remindAt > 0 ? date('Y-m-d H:i', $remindAt) : '-'), + '内容:' . ($content !== '' ? $content : '-'), + '— 二中心跟踪系统', + ]; + + return implode("\n", $lines); + } + + /** + * 标记一条待办为「发送失败」,写入 error 字段 + */ + private function markFailed(string $todoTable, int $todoId, string $errMsg): void + { + $errMsg = mb_substr($errMsg, 0, 500); + Db::name(self::stripTablePrefix($todoTable)) + ->where('id', $todoId) + ->update([ + 'status' => DiagnosisTodo::STATUS_FAILED, + 'error' => $errMsg, + 'update_time' => time(), + ]); + } + + /** + * Db::name() 接收的是不带前缀的表名,BloodRecord 等 Model::getTable() 返回的是带前缀的全名。 + * 这里去掉首段 `_`。注意:项目实际前缀是 `zyt_`,但 think-orm 的 Db::name() + * 内部会用 config('database.prefix') 自动拼回,所以这里只需剥离一次即可。 + */ + private static function stripTablePrefix(string $tableWithPrefix): string + { + $prefix = (string) config('database.connections.mysql.prefix', ''); + if ($prefix !== '' && str_starts_with($tableWithPrefix, $prefix)) { + return substr($tableWithPrefix, strlen($prefix)); + } + + return $tableWithPrefix; + } +} diff --git a/server/app/common/model/tcm/DiagnosisTodo.php b/server/app/common/model/tcm/DiagnosisTodo.php new file mode 100644 index 00000000..0adda3cf --- /dev/null +++ b/server/app/common/model/tcm/DiagnosisTodo.php @@ -0,0 +1,99 @@ + + */ + public const STATUS_TEXT_MAP = [ + self::STATUS_PENDING => '待执行', + self::STATUS_SENT => '已发送', + self::STATUS_CANCELLED => '已取消', + self::STATUS_FAILED => '发送失败', + ]; + + /** + * @notes 状态文本访问器 + */ + public function getStatusTextAttr($value, $data): string + { + $status = (int) ($data['status'] ?? 0); + + return self::STATUS_TEXT_MAP[$status] ?? '未知'; + } + + /** + * @notes 提醒时间格式化(Y-m-d H:i) + */ + public function getRemindTimeTextAttr($value, $data): string + { + $ts = (int) ($data['remind_time'] ?? 0); + + return $ts > 0 ? date('Y-m-d H:i', $ts) : ''; + } + + /** + * @notes 推送时间格式化 + */ + public function getNotifiedAtTextAttr($value, $data): string + { + $ts = (int) ($data['notified_at'] ?? 0); + + return $ts > 0 ? date('Y-m-d H:i', $ts) : ''; + } + + /** + * @notes 取消时间格式化 + */ + public function getCancelledAtTextAttr($value, $data): string + { + $ts = (int) ($data['cancelled_at'] ?? 0); + + return $ts > 0 ? date('Y-m-d H:i', $ts) : ''; + } +} diff --git a/server/app/common/model/tcm/TrackingNote.php b/server/app/common/model/tcm/TrackingNote.php new file mode 100644 index 00000000..306f2885 --- /dev/null +++ b/server/app/common/model/tcm/TrackingNote.php @@ -0,0 +1,21 @@ + 'app\\command\\GancaoSyncLogisticsRoute', // 迁移诊单图片到医生备注表 'migrate:images-to-doctor-note' => 'app\\command\\MigrateImagesToDoctorNote', + // 诊单待办事项:扫描到点的待执行项并向创建人发送企业微信消息 + 'tcm:diagnosis-todo-notify' => 'app\\command\\DiagnosisTodoNotify', ], ]; diff --git a/server/sql/1.9.20260420/cleanup_orphan_menus.sql b/server/sql/1.9.20260420/cleanup_orphan_menus.sql new file mode 100644 index 00000000..781a97fd --- /dev/null +++ b/server/sql/1.9.20260420/cleanup_orphan_menus.sql @@ -0,0 +1,35 @@ +-- 清理菜单表中的孤儿记录(可选 · 跟 T1+T2 无关) +-- +-- 背景:admin 启动时会按菜单表的 component 字段去 import.meta.glob 出来的 vue 文件里找匹配, +-- 找不到就 console.error("找不到组件 xxx")。前端 admin/src/views/stats/ 下没有 doctor-daily 目录, +-- 但菜单表里仍残留 component='stats/doctor-daily/index' 的记录 → 每次刷页面都会有这条警告。 +-- +-- 安全提示: +-- 1. **先跑下面的 SELECT**,确认要删的记录确实是孤儿(前端 views 下不存在)。 +-- 2. 如果想保留菜单数据但不再触发 console.error,可以改 component 为空字符串而不是删除。 +-- 3. 如果以后该功能要回归,可以从 git 历史里恢复或重新写 menu SQL。 +-- +-- 步骤 1:探测 +-- ============================================================= +-- SELECT id, pid, type, name, perms, paths, component, is_show, create_time +-- FROM zyt_system_menu +-- WHERE component LIKE '%doctor-daily%' +-- OR perms LIKE '%doctor.daily%' +-- OR paths LIKE '%doctor-daily%'; + +-- 步骤 2(可选 A):把这条菜单及其子按钮全部删掉 +-- ============================================================= +-- 先把它的子节点(按钮权限)一并清理 +-- DELETE FROM zyt_system_menu +-- WHERE pid IN ( +-- SELECT id FROM ( +-- SELECT id FROM zyt_system_menu WHERE component = 'stats/doctor-daily/index' +-- ) t +-- ); +-- DELETE FROM zyt_system_menu WHERE component = 'stats/doctor-daily/index'; + +-- 步骤 2(可选 B):仅置空 component,保留菜单与按钮权限不动(最稳妥) +-- ============================================================= +-- UPDATE zyt_system_menu +-- SET component = '' +-- WHERE component = 'stats/doctor-daily/index'; diff --git a/server/sql/1.9.20260505/add_diagnosis_readonly_menu.sql b/server/sql/1.9.20260505/add_diagnosis_readonly_menu.sql new file mode 100644 index 00000000..9f641f96 --- /dev/null +++ b/server/sql/1.9.20260505/add_diagnosis_readonly_menu.sql @@ -0,0 +1,17 @@ +# 二诊只读病例详情接口权限注册(T1+T2 - 2026-05-05) +# 挂在「中医诊单」(perms=tcm.diagnosis/lists) 菜单下 +# tcm.diagnosis/readonlyDetail 二诊只读病例详情接口 + +SET @diag_menu_id := (SELECT id FROM zyt_system_menu WHERE perms = 'tcm.diagnosis/lists' LIMIT 1); + +INSERT INTO zyt_system_menu ( + pid, type, name, icon, sort, perms, paths, component, + selected, params, is_cache, is_show, is_disable, create_time, update_time +) +SELECT + @diag_menu_id, 'A', '患者信息详情', '', 70, + 'tcm.diagnosis/readonlyDetail', '', '', + '', '', 0, 1, 0, UNIX_TIMESTAMP(), UNIX_TIMESTAMP() +FROM DUAL +WHERE @diag_menu_id IS NOT NULL + AND NOT EXISTS (SELECT 1 FROM zyt_system_menu WHERE perms = 'tcm.diagnosis/readonlyDetail'); diff --git a/server/sql/1.9.20260505/add_diagnosis_todo_menu.sql b/server/sql/1.9.20260505/add_diagnosis_todo_menu.sql new file mode 100644 index 00000000..598ba150 --- /dev/null +++ b/server/sql/1.9.20260505/add_diagnosis_todo_menu.sql @@ -0,0 +1,44 @@ +# 诊单待办事项:菜单按钮权限注册(T5 - 2026-05-05) +# 挂在「中医诊单」(perms=tcm.diagnosis/lists) 菜单下,按钮型权限节点: +# tcm.diagnosisTodo/lists 待办列表 +# tcm.diagnosisTodo/add 新增待办 +# tcm.diagnosisTodo/cancel 取消待办 +# detail 不单独注册(前端不主动调,由列表/详情接口已覆盖) + +SET @diag_menu_id := (SELECT id FROM zyt_system_menu WHERE perms = 'tcm.diagnosis/lists' LIMIT 1); + +INSERT INTO zyt_system_menu ( + pid, type, name, icon, sort, perms, paths, component, + selected, params, is_cache, is_show, is_disable, create_time, update_time +) +SELECT + @diag_menu_id, 'A', '待办列表', '', 60, + 'tcm.diagnosisTodo/lists', '', '', + '', '', 0, 1, 0, UNIX_TIMESTAMP(), UNIX_TIMESTAMP() +FROM DUAL +WHERE @diag_menu_id IS NOT NULL + AND NOT EXISTS (SELECT 1 FROM zyt_system_menu WHERE perms = 'tcm.diagnosisTodo/lists'); + +INSERT INTO zyt_system_menu ( + pid, type, name, icon, sort, perms, paths, component, + selected, params, is_cache, is_show, is_disable, create_time, update_time +) +SELECT + @diag_menu_id, 'A', '新增待办', '', 61, + 'tcm.diagnosisTodo/add', '', '', + '', '', 0, 1, 0, UNIX_TIMESTAMP(), UNIX_TIMESTAMP() +FROM DUAL +WHERE @diag_menu_id IS NOT NULL + AND NOT EXISTS (SELECT 1 FROM zyt_system_menu WHERE perms = 'tcm.diagnosisTodo/add'); + +INSERT INTO zyt_system_menu ( + pid, type, name, icon, sort, perms, paths, component, + selected, params, is_cache, is_show, is_disable, create_time, update_time +) +SELECT + @diag_menu_id, 'A', '取消待办', '', 62, + 'tcm.diagnosisTodo/cancel', '', '', + '', '', 0, 1, 0, UNIX_TIMESTAMP(), UNIX_TIMESTAMP() +FROM DUAL +WHERE @diag_menu_id IS NOT NULL + AND NOT EXISTS (SELECT 1 FROM zyt_system_menu WHERE perms = 'tcm.diagnosisTodo/cancel'); diff --git a/server/sql/1.9.20260505/add_tcm_diagnosis_todo.sql b/server/sql/1.9.20260505/add_tcm_diagnosis_todo.sql new file mode 100644 index 00000000..db4291c4 --- /dev/null +++ b/server/sql/1.9.20260505/add_tcm_diagnosis_todo.sql @@ -0,0 +1,29 @@ +-- 诊单待办事项(T5 - 2026-05-05) +-- 用途:医助在诊单上挂「将来要提醒自己的事」,到点由企微推送给创建人本人。 +-- 设计:状态机 0=待执行 → 1=已发送(成功)/ 2=已取消(人工) / 3=发送失败(终态,不重试) +-- 索引说明: +-- idx_diagnosis_id —— 编辑页/列表按诊单查询 +-- idx_creator_id —— 反查某 admin 创建过的待办(取消权限校验等) +-- idx_status_remind_time —— 复合索引,cron 扫表「status=0 AND remind_time<=now」核心索引 + +CREATE TABLE IF NOT EXISTS `zyt_tcm_diagnosis_todo` ( + `id` int(11) unsigned NOT NULL AUTO_INCREMENT COMMENT '主键', + `diagnosis_id` int(11) unsigned NOT NULL DEFAULT 0 COMMENT '所属诊单 id(zyt_tcm_diagnosis.id)', + `patient_id` int(11) unsigned NOT NULL DEFAULT 0 COMMENT '冗余患者 id,便于反查', + `content` varchar(500) NOT NULL DEFAULT '' COMMENT '提醒内容(企微推送正文)', + `remind_time` int(11) unsigned NOT NULL DEFAULT 0 COMMENT '提醒时间戳(秒);cron 扫描 remind_time<=now', + `status` tinyint(1) unsigned NOT NULL DEFAULT 0 COMMENT '状态:0=待执行 1=已发送 2=已取消 3=发送失败', + `creator_id` int(11) unsigned NOT NULL DEFAULT 0 COMMENT '创建人 admin.id(推送目标)', + `creator_name` varchar(64) NOT NULL DEFAULT '' COMMENT '创建人姓名快照', + `notified_at` int(11) unsigned NOT NULL DEFAULT 0 COMMENT '实际推送时间(成功时写入)', + `error` varchar(500) NOT NULL DEFAULT '' COMMENT '失败原因(status=3 时写入)', + `cancelled_at` int(11) unsigned NOT NULL DEFAULT 0 COMMENT '取消时间', + `cancelled_by` int(11) unsigned NOT NULL DEFAULT 0 COMMENT '取消人 admin.id', + `create_time` int(11) unsigned NOT NULL DEFAULT 0 COMMENT '创建时间', + `update_time` int(11) unsigned NOT NULL DEFAULT 0 COMMENT '更新时间', + `delete_time` int(11) unsigned NULL DEFAULT NULL COMMENT '软删除时间', + PRIMARY KEY (`id`), + KEY `idx_diagnosis_id` (`diagnosis_id`), + KEY `idx_creator_id` (`creator_id`), + KEY `idx_status_remind_time` (`status`, `remind_time`) +) ENGINE = InnoDB DEFAULT CHARSET = utf8mb4 COLLATE = utf8mb4_unicode_ci COMMENT = '诊单待办事项(企微定时推送给创建人)'; diff --git a/server/sql/1.9.20260505/add_tracking_note_menu.sql b/server/sql/1.9.20260505/add_tracking_note_menu.sql new file mode 100644 index 00000000..ee7a046c --- /dev/null +++ b/server/sql/1.9.20260505/add_tracking_note_menu.sql @@ -0,0 +1,30 @@ +# 诊单跟踪备注接口权限注册(2026-05-05) +# 挂在「中医诊单」(perms=tcm.diagnosis/lists) 菜单下 +# tcm.diagnosis/addTrackingNote 新增跟踪备注 +# tcm.diagnosis/trackingNotes 拉取跟踪备注列表 + +SET @diag_menu_id := (SELECT id FROM zyt_system_menu WHERE perms = 'tcm.diagnosis/lists' LIMIT 1); + +INSERT INTO zyt_system_menu ( + pid, type, name, icon, sort, perms, paths, component, + selected, params, is_cache, is_show, is_disable, create_time, update_time +) +SELECT + @diag_menu_id, 'A', '新增跟踪备注', '', 80, + 'tcm.diagnosis/addTrackingNote', '', '', + '', '', 0, 1, 0, UNIX_TIMESTAMP(), UNIX_TIMESTAMP() +FROM DUAL +WHERE @diag_menu_id IS NOT NULL + AND NOT EXISTS (SELECT 1 FROM zyt_system_menu WHERE perms = 'tcm.diagnosis/addTrackingNote'); + +INSERT INTO zyt_system_menu ( + pid, type, name, icon, sort, perms, paths, component, + selected, params, is_cache, is_show, is_disable, create_time, update_time +) +SELECT + @diag_menu_id, 'A', '跟踪备注列表', '', 81, + 'tcm.diagnosis/trackingNotes', '', '', + '', '', 0, 1, 0, UNIX_TIMESTAMP(), UNIX_TIMESTAMP() +FROM DUAL +WHERE @diag_menu_id IS NOT NULL + AND NOT EXISTS (SELECT 1 FROM zyt_system_menu WHERE perms = 'tcm.diagnosis/trackingNotes'); diff --git a/server/sql/1.9.20260505/add_tracking_note_table.sql b/server/sql/1.9.20260505/add_tracking_note_table.sql new file mode 100644 index 00000000..20847352 --- /dev/null +++ b/server/sql/1.9.20260505/add_tracking_note_table.sql @@ -0,0 +1,16 @@ +-- 诊单跟踪备注表(按天一条,多次追加更新;只存文字,不含附件) + +CREATE TABLE IF NOT EXISTS `zyt_tracking_note` ( + `id` int(11) unsigned NOT NULL AUTO_INCREMENT, + `diagnosis_id` int(11) unsigned NOT NULL COMMENT '诊单ID (tcm_diagnosis.id)', + `admin_id` int(11) unsigned NOT NULL DEFAULT 0 COMMENT '记录人ID (system_admin.id)', + `note_date` date NOT NULL COMMENT '记录日期', + `content` text DEFAULT NULL COMMENT '跟踪备注文字(多次追加以换行分隔,带 [HH:MM] 前缀)', + `create_time` int(10) unsigned NOT NULL DEFAULT 0, + `update_time` int(10) unsigned NOT NULL DEFAULT 0, + `delete_time` int(10) unsigned DEFAULT NULL, + PRIMARY KEY (`id`), + UNIQUE KEY `uk_diagnosis_date` (`diagnosis_id`, `note_date`), + KEY `idx_admin` (`admin_id`), + KEY `idx_diagnosis_date` (`diagnosis_id`, `note_date`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='诊单跟踪备注';