Merge branch 'master' into cs

This commit is contained in:
Your Name
2026-05-05 18:26:03 +08:00
35 changed files with 3612 additions and 348 deletions
@@ -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 诊单挂号 / 取消挂号 操作日志(谁在何时操作)
*/
@@ -0,0 +1,81 @@
<?php
// +----------------------------------------------------------------------
// | likeadmin快速开发前后端分离管理后台(PHP版)
// +----------------------------------------------------------------------
// | 欢迎阅读学习系统程序代码,建议反馈是我们前进的动力
// | 开源版本可自由商用,可去除界面版权logo
// | gitee下载:https://gitee.com/likeshop_gitee/likeadmin
// | github下载:https://github.com/likeshop-github/likeadmin
// | 访问官网:https://www.likeadmin.cn
// | likeadmin团队 版权所有 拥有最终解释权
// +----------------------------------------------------------------------
// | author: likeadminTeam
// +----------------------------------------------------------------------
declare(strict_types=1);
namespace app\adminapi\controller\tcm;
use app\adminapi\controller\BaseAdminController;
use app\adminapi\lists\tcm\DiagnosisTodoLists;
use app\adminapi\logic\tcm\DiagnosisTodoLogic;
use app\adminapi\validate\tcm\DiagnosisTodoValidate;
/**
* 诊单待办事项控制器
*
* 职责:仅 lists / add / cancel / detail;编辑能力故意不开放(要改 = 取消重建)
*
* @package app\adminapi\controller\tcm
*/
class DiagnosisTodoController extends BaseAdminController
{
/**
* @notes 待办列表(按 diagnosis_id
*/
public function lists()
{
(new DiagnosisTodoValidate())->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);
}
}
@@ -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;
}
@@ -0,0 +1,73 @@
<?php
// +----------------------------------------------------------------------
// | likeadmin快速开发前后端分离管理后台(PHP版)
// +----------------------------------------------------------------------
// | 欢迎阅读学习系统程序代码,建议反馈是我们前进的动力
// | 开源版本可自由商用,可去除界面版权logo
// | gitee下载:https://gitee.com/likeshop_gitee/likeadmin
// | github下载:https://github.com/likeshop-github/likeadmin
// | 访问官网:https://www.likeadmin.cn
// | likeadmin团队 版权所有 拥有最终解释权
// +----------------------------------------------------------------------
// | author: likeadminTeam
// +----------------------------------------------------------------------
declare(strict_types=1);
namespace app\adminapi\lists\tcm;
use app\adminapi\lists\BaseAdminDataLists;
use app\adminapi\logic\tcm\DiagnosisTodoLogic;
use app\common\lists\ListsSearchInterface;
use app\common\model\tcm\DiagnosisTodo;
/**
* 诊单待办事项列表
*
* 默认按 remind_time desc 排序;按 diagnosis_id 过滤。
*
* @package app\adminapi\lists\tcm
*/
class DiagnosisTodoLists extends BaseAdminDataLists implements ListsSearchInterface
{
public function setSearch(): array
{
return [
'=' => ['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();
}
}
@@ -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;
@@ -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<string,mixed> $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<int,array<string,mixed>>,
* diet_records: array<int,array<string,mixed>>,
* exercise_records: array<int,array<string,mixed>>,
* 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<string,mixed> $diagnosis 已字典翻译过的诊单数据
* @return array<string,mixed>
*/
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<int,array<string,mixed>>
*/
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<int,array<string,mixed>>
*/
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<int,array<string,mixed>>
*/
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;
}
}
@@ -0,0 +1,187 @@
<?php
// +----------------------------------------------------------------------
// | likeadmin快速开发前后端分离管理后台(PHP版)
// +----------------------------------------------------------------------
// | 欢迎阅读学习系统程序代码,建议反馈是我们前进的动力
// | 开源版本可自由商用,可去除界面版权logo
// | gitee下载:https://gitee.com/likeshop_gitee/likeadmin
// | github下载:https://github.com/likeshop-github/likeadmin
// | 访问官网:https://www.likeadmin.cn
// | likeadmin团队 版权所有 拥有最终解释权
// +----------------------------------------------------------------------
// | author: likeadminTeam
// +----------------------------------------------------------------------
declare(strict_types=1);
namespace app\adminapi\logic\tcm;
use app\common\logic\BaseLogic;
use app\common\model\auth\Admin;
use app\common\model\auth\AdminRole;
use app\common\model\tcm\Diagnosis;
use app\common\model\tcm\DiagnosisTodo;
/**
* 诊单待办事项逻辑
*
* 状态机:
* add() → status=0 (待执行)
* cron → status=1 (已发送) / status=3 (失败)
* cancel() → status=2 (已取消,仅 status=0 可取消)
*
* 鉴权:
* add:诊单需存在;数据权限交由路由层「tcm.diagnosisTodo/add」节点 + 前端 tab disabled 控制。
* cancel:仅创建人本人或超级管理员 (role_id=1) 可取消,且仅 status=0 时。
*
* @package app\adminapi\logic\tcm
*/
class DiagnosisTodoLogic extends BaseLogic
{
/**
* @notes 新增待办
*
* @param array<string,mixed> $params 已通过 sceneAdd 验证
* @param int $adminId 当前 admin id
* @param array<string,mixed> $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<string,mixed>
*/
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<string,mixed> $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);
}
}
@@ -0,0 +1,95 @@
<?php
namespace app\adminapi\logic\tcm;
use app\common\logic\BaseLogic;
use app\common\model\tcm\TrackingNote;
/**
* 诊单跟踪备注 Logic
*
* - 按 diagnosis_id + 当天 find-or-create
* - 多次追加 content(换行分隔,带 [HH:MM] 前缀)
* - 不涉及附件(与医生备注 DoctorNoteLogic 不同)
*/
class TrackingNoteLogic extends BaseLogic
{
/**
* 追加跟踪备注(按天合并)
*
* @param array $params diagnosis_id (int)、admin_id (int)、content (string)
* @return bool
*/
public static function addOrAppend(array $params): bool
{
try {
$diagnosisId = (int) ($params['diagnosis_id'] ?? 0);
$adminId = (int) ($params['admin_id'] ?? 0);
$newContent = trim((string) ($params['content'] ?? ''));
if ($diagnosisId <= 0) {
self::setError('诊单ID缺失');
return false;
}
if ($newContent === '') {
self::setError('备注内容不能为空');
return false;
}
$today = date('Y-m-d');
$time = date('H:i');
$line = "[{$time}] {$newContent}";
$existing = TrackingNote::where('diagnosis_id', $diagnosisId)
->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 [];
}
}
}
@@ -0,0 +1,93 @@
<?php
// +----------------------------------------------------------------------
// | likeadmin快速开发前后端分离管理后台(PHP版)
// +----------------------------------------------------------------------
// | 欢迎阅读学习系统程序代码,建议反馈是我们前进的动力
// | 开源版本可自由商用,可去除界面版权logo
// | gitee下载:https://gitee.com/likeshop_gitee/likeadmin
// | github下载:https://github.com/likeshop-github/likeadmin
// | 访问官网:https://www.likeadmin.cn
// | likeadmin团队 版权所有 拥有最终解释权
// +----------------------------------------------------------------------
// | author: likeadminTeam
// +----------------------------------------------------------------------
declare(strict_types=1);
namespace app\adminapi\validate\tcm;
use app\common\model\tcm\Diagnosis;
use app\common\model\tcm\DiagnosisTodo;
use app\common\validate\BaseValidate;
/**
* 诊单待办事项验证
* Class DiagnosisTodoValidate
* @package app\adminapi\validate\tcm
*/
class DiagnosisTodoValidate extends BaseValidate
{
protected $rule = [
'id' => '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;
}
}
@@ -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 = [])
{
+207
View File
@@ -0,0 +1,207 @@
<?php
declare(strict_types=1);
namespace app\command;
use app\common\model\auth\Admin;
use app\common\model\tcm\Diagnosis;
use app\common\model\tcm\DiagnosisTodo;
use app\common\service\wechat\WechatWorkAppMessageService;
use think\console\Command;
use think\console\Input;
use think\console\Output;
use think\facade\Db;
use think\facade\Log;
/**
* 诊单待办事项 - 企业微信定时推送
*
* 使用方法:
* php think tcm:diagnosis-todo-notify
*
* 推荐 cron(每分钟执行):
* * * * * * cd /path/to/server && php think tcm:diagnosis-todo-notify >> /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<string,mixed> $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() 返回的是带前缀的全名。
* 这里去掉首段 `<prefix>_`。注意:项目实际前缀是 `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;
}
}
@@ -0,0 +1,99 @@
<?php
// +----------------------------------------------------------------------
// | likeadmin快速开发前后端分离管理后台(PHP版)
// +----------------------------------------------------------------------
// | 欢迎阅读学习系统程序代码,建议反馈是我们前进的动力
// | 开源版本可自由商用,可去除界面版权logo
// | gitee下载:https://gitee.com/likeshop_gitee/likeadmin
// | github下载:https://github.com/likeshop-github/likeadmin
// | 访问官网:https://www.likeadmin.cn
// | likeadmin团队 版权所有 拥有最终解释权
// +----------------------------------------------------------------------
// | author: likeadminTeam
// +----------------------------------------------------------------------
namespace app\common\model\tcm;
use app\common\model\BaseModel;
/**
* 诊单待办事项模型
*
* 状态机:0=待执行 → 1=已发送 / 2=已取消 / 3=发送失败(终态,不重试)
*
* @package app\common\model\tcm
*/
class DiagnosisTodo extends BaseModel
{
protected $name = 'tcm_diagnosis_todo';
// 自动时间戳类型设置为整型
protected $autoWriteTimestamp = true;
protected $createTime = 'create_time';
protected $updateTime = 'update_time';
protected $deleteTime = 'delete_time';
// 取出字段保持整型,前端按需要再格式化
protected $dateFormat = false;
/** 状态:待执行 */
public const STATUS_PENDING = 0;
/** 状态:已发送(成功) */
public const STATUS_SENT = 1;
/** 状态:已取消(人工) */
public const STATUS_CANCELLED = 2;
/** 状态:发送失败(终态,不重试) */
public const STATUS_FAILED = 3;
/**
* 状态文本映射
*
* @var array<int,string>
*/
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) : '';
}
}
@@ -0,0 +1,21 @@
<?php
namespace app\common\model\tcm;
use app\common\model\BaseModel;
/**
* 诊单跟踪备注模型
* 表 zyt_tracking_note:按天一条,多次追加更新;只存文字。
*/
class TrackingNote extends BaseModel
{
protected $name = 'tracking_note';
protected $autoWriteTimestamp = true;
protected $createTime = 'create_time';
protected $updateTime = 'update_time';
protected $deleteTime = 'delete_time';
protected $dateFormat = false;
}
+2
View File
@@ -34,5 +34,7 @@ return [
'gancao:sync-logistics' => 'app\\command\\GancaoSyncLogisticsRoute',
// 迁移诊单图片到医生备注表
'migrate:images-to-doctor-note' => 'app\\command\\MigrateImagesToDoctorNote',
// 诊单待办事项:扫描到点的待执行项并向创建人发送企业微信消息
'tcm:diagnosis-todo-notify' => 'app\\command\\DiagnosisTodoNotify',
],
];
@@ -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';
@@ -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');
@@ -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');
@@ -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 '所属诊单 idzyt_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 = '诊单待办事项(企微定时推送给创建人)';
@@ -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');
@@ -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='诊单跟踪备注';