This commit is contained in:
Your Name
2026-03-27 18:06:12 +08:00
parent 9160c36735
commit 099bc1dd22
645 changed files with 276473 additions and 957 deletions
@@ -24,7 +24,7 @@ use app\adminapi\validate\LoginValidate;
*/
class LoginController extends BaseAdminController
{
public array $notNeedLogin = ['account', 'workWechatConfig', 'workWechatLogin', 'checkDbColumn'];
public array $notNeedLogin = ['account', 'workWechatConfig', 'workWechatLogin', 'checkDbColumn', 'changeFirstPassword'];
/**
* @notes 账号登录
@@ -101,6 +101,41 @@ class LoginController extends BaseAdminController
]);
}
/**
* @notes 首次登录修改密码
* @return \think\response\Json
*/
public function changeFirstPassword()
{
$password = $this->request->post('password', '');
$passwordConfirm = $this->request->post('password_confirm', '');
if (empty($password)) {
return $this->fail('请输入新密码');
}
if (strlen($password) < 6) {
return $this->fail('密码长度不能少于6位');
}
if ($password !== $passwordConfirm) {
return $this->fail('两次输入的密码不一致');
}
$token = $this->request->header('token');
if (empty($token)) {
return $this->fail('请先登录');
}
$result = (new LoginLogic())->changeFirstPassword($token, $password);
if ($result === false) {
return $this->fail(LoginLogic::getError());
}
return $this->success('密码修改成功');
}
/**
* @notes 退出登录
* @return \think\response\Json
@@ -32,6 +32,7 @@ class AppointmentController extends BaseAdminController
public function create()
{
$params = (new AppointmentValidate())->post()->goCheck('create');
$params['assistant_id'] = $this->adminId;
$result = AppointmentLogic::create($params);
if ($result === false) {
return $this->fail(AppointmentLogic::getError());
@@ -0,0 +1,68 @@
<?php
namespace app\adminapi\controller\doctor;
use app\adminapi\controller\BaseAdminController;
use app\adminapi\lists\doctor\MedicineLists;
use app\adminapi\logic\doctor\MedicineLogic;
use app\adminapi\validate\doctor\MedicineValidate;
/**
* 药品库控制器
*/
class MedicineController extends BaseAdminController
{
/**
* 药品列表
*/
public function lists()
{
return $this->dataLists(new MedicineLists());
}
/**
* 添加药品
*/
public function add()
{
$params = (new MedicineValidate())->post()->goCheck('add');
$result = MedicineLogic::add($params);
if ($result) {
return $this->success('添加成功', [], 1, 1);
}
return $this->fail(MedicineLogic::getError());
}
/**
* 编辑药品
*/
public function edit()
{
$params = (new MedicineValidate())->post()->goCheck('edit');
$result = MedicineLogic::edit($params);
if ($result) {
return $this->success('编辑成功', [], 1, 1);
}
return $this->fail(MedicineLogic::getError());
}
/**
* 删除药品
*/
public function delete()
{
$params = (new MedicineValidate())->post()->goCheck('delete');
MedicineLogic::delete($params);
return $this->success('删除成功', [], 1, 1);
}
/**
* 药品详情
*/
public function detail()
{
$params = (new MedicineValidate())->goCheck('detail');
$result = MedicineLogic::detail($params);
return $this->data($result);
}
}
@@ -29,8 +29,17 @@ class RosterController extends BaseAdminController
*/
public function save()
{
$params = (new RosterValidate())->post()->goCheck('save');
$post = request()->post();
$defaults = [];
if (empty($post['period'])) {
$defaults['period'] = 'segment';
}
$params = (new RosterValidate())->post()->goCheck('save', $defaults);
$result = RosterLogic::save($params);
if ($result === false) {
return $this->fail(RosterLogic::getError());
}
return $this->success('保存成功', $result);
}
@@ -0,0 +1,23 @@
<?php
namespace app\adminapi\controller\doctor;
use app\adminapi\controller\BaseAdminController;
use app\adminapi\lists\doctor\StatisticsLists;
/**
* 医生统计控制器
* Class StatisticsController
* @package app\adminapi\controller\doctor
*/
class StatisticsController extends BaseAdminController
{
/**
* @notes 医生诊单统计列表
* @return \think\response\Json
*/
public function lists()
{
return $this->dataLists(new StatisticsLists());
}
}
@@ -263,7 +263,7 @@ class DiagnosisController extends BaseAdminController
$result = DiagnosisLogic::endCall($params);
if ($result) {
return $this->success('结束通话成功', [], 1, 1);
return $this->success('', [], 1, 1);
}
return $this->fail(DiagnosisLogic::getError());
}
@@ -317,7 +317,7 @@ class DiagnosisController extends BaseAdminController
$params['admin_id'] = (int)$this->adminId;
$result = DiagnosisLogic::bindCallRoom($params);
if ($result) {
return $this->success('绑定成功', [], 1, 1);
return $this->success('', [], 1, 1);
}
return $this->fail(DiagnosisLogic::getError());
}
@@ -42,7 +42,8 @@ class PrescriptionLibraryController extends BaseAdminController
public function edit()
{
$params = (new PrescriptionLibraryValidate())->post()->goCheck('edit');
$result = PrescriptionLibraryLogic::edit($params, $this->adminId);
$canAll = PrescriptionLibraryLogic::canManageAllPrescriptions($this->adminId, $this->adminInfo);
$result = PrescriptionLibraryLogic::edit($params, $this->adminId, $canAll);
if (!$result) {
return $this->fail(PrescriptionLibraryLogic::getError());
}
@@ -55,7 +56,8 @@ class PrescriptionLibraryController extends BaseAdminController
public function delete()
{
$params = (new PrescriptionLibraryValidate())->post()->goCheck('delete');
$result = PrescriptionLibraryLogic::delete($params['id'], $this->adminId);
$canAll = PrescriptionLibraryLogic::canManageAllPrescriptions($this->adminId, $this->adminInfo);
$result = PrescriptionLibraryLogic::delete((int) $params['id'], $this->adminId, $canAll);
if (!$result) {
return $this->fail(PrescriptionLibraryLogic::getError());
}
@@ -68,7 +70,8 @@ class PrescriptionLibraryController extends BaseAdminController
public function detail()
{
$params = (new PrescriptionLibraryValidate())->get()->goCheck('detail');
$detail = PrescriptionLibraryLogic::detail((int)$params['id'], $this->adminId);
$canAll = PrescriptionLibraryLogic::canManageAllPrescriptions($this->adminId, $this->adminInfo);
$detail = PrescriptionLibraryLogic::detail((int) $params['id'], $this->adminId, $canAll);
if (!$detail) {
return $this->fail('处方不存在或无权限查看');
}
@@ -7,6 +7,7 @@ use app\common\model\DiagnosisViewRecord;
use app\common\model\doctor\Appointment;
use app\common\model\tcm\Prescription;
use app\common\model\auth\AdminRole;
use app\common\lists\ListsExtendInterface;
use app\common\lists\ListsSearchInterface;
/**
@@ -14,7 +15,7 @@ use app\common\lists\ListsSearchInterface;
* Class AppointmentLists
* @package app\adminapi\lists\doctor
*/
class AppointmentLists extends BaseAdminDataLists implements ListsSearchInterface
class AppointmentLists extends BaseAdminDataLists implements ListsSearchInterface, ListsExtendInterface
{
/**
* @notes 设置搜索条件
@@ -147,45 +148,31 @@ class AppointmentLists extends BaseAdminDataLists implements ListsSearchInterfac
}
/**
* @notes 获取数量
* @return int
* 列表 count / Tab 角标统计共用的过滤条件(不依赖 lists() 对 searchWhere 的副作用)
* @param mixed $query
* @param bool $applyStatusFilter 为 false 时按状态分组统计各 Tab 数量
*/
public function count(): int
private function applyAppointmentCountFilters($query, bool $applyStatusFilter): void
{
// 获取当前管理员的角色ID
$roleIds = AdminRole::where('admin_id', $this->adminId)->column('role_id');
// 处理患者姓名搜索
if (!empty($this->params['patient_name'])) {
$this->searchWhere[] = ['u.patient_name', 'like', '%' . $this->params['patient_name'] . '%'];
$query->where('u.patient_name', 'like', '%' . $this->params['patient_name'] . '%');
}
// 处理医生姓名搜索
if (!empty($this->params['doctor_name'])) {
$this->searchWhere[] = ['ad.name', 'like', '%' . $this->params['doctor_name'] . '%'];
$query->where('ad.name', 'like', '%' . $this->params['doctor_name'] . '%');
}
// 处理状态搜索
if (isset($this->params['status']) && $this->params['status'] !== '') {
$this->searchWhere[] = ['a.status', '=', $this->params['status']];
if ($applyStatusFilter && isset($this->params['status']) && $this->params['status'] !== '') {
$query->where('a.status', '=', $this->params['status']);
}
// 处理日期范围搜索
if (!empty($this->params['start_date']) && !empty($this->params['end_date'])) {
$this->searchWhere[] = ['a.appointment_date', 'between', [$this->params['start_date'], $this->params['end_date']]];
$query->whereBetween('a.appointment_date', [$this->params['start_date'], $this->params['end_date']]);
} elseif (!empty($this->params['start_date'])) {
$this->searchWhere[] = ['a.appointment_date', '>=', $this->params['start_date']];
$query->where('a.appointment_date', '>=', $this->params['start_date']);
} elseif (!empty($this->params['end_date'])) {
$this->searchWhere[] = ['a.appointment_date', '<=', $this->params['end_date']];
$query->where('a.appointment_date', '<=', $this->params['end_date']);
}
// 构建查询
$query = Appointment::alias('a')
->leftJoin('tcm_diagnosis u', 'a.patient_id = u.id')
->leftJoin('admin ad', 'a.doctor_id = ad.id')
->where($this->searchWhere);
// 是否确认诊单
if (isset($this->params['diagnosis_confirmed']) && $this->params['diagnosis_confirmed'] !== '') {
$confirmed = (int)$this->params['diagnosis_confirmed'];
$tbl = (new DiagnosisViewRecord())->getTable();
@@ -196,17 +183,57 @@ class AppointmentLists extends BaseAdminDataLists implements ListsSearchInterfac
$query->whereNotExists($subSql);
}
}
// 如果是医生角色(role_id=1),只显示挂自己号的预约
if (in_array(1, $roleIds)) {
$query->where('a.doctor_id', $this->adminId);
}
// 如果是医助角色(role_id=2),只显示自己添加的患者的预约
if (in_array(2, $roleIds)) {
$query->where('u.assistant_id', $this->adminId);
}
}
return $query->count();
/**
* @notes 获取数量
* @return int
*/
public function count(): int
{
$query = Appointment::alias('a')
->leftJoin('tcm_diagnosis u', 'a.patient_id = u.id')
->leftJoin('admin ad', 'a.doctor_id = ad.id');
$this->applyAppointmentCountFilters($query, true);
return (int)$query->count();
}
/**
* @notes 扩展字段:按需返回各状态数量(一次 GROUP BY,替代前端 4 次列表请求)
* @return array
*/
public function extend(): array
{
if (empty($this->params['include_status_counts'])) {
return [];
}
$query = Appointment::alias('a')
->leftJoin('tcm_diagnosis u', 'a.patient_id = u.id')
->leftJoin('admin ad', 'a.doctor_id = ad.id');
$this->applyAppointmentCountFilters($query, false);
$rows = $query->field('a.status, COUNT(*) AS cnt')
->group('a.status')
->select()
->toArray();
$out = [1 => 0, 2 => 0, 3 => 0, 4 => 0];
foreach ($rows as $r) {
$s = (int)($r['status'] ?? 0);
if (array_key_exists($s, $out)) {
$out[$s] = (int)($r['cnt'] ?? 0);
}
}
return ['status_count' => $out];
}
}
@@ -0,0 +1,76 @@
<?php
namespace app\adminapi\lists\doctor;
use app\adminapi\lists\BaseAdminDataLists;
use app\common\model\doctor\Medicine;
use app\common\lists\ListsSearchInterface;
/**
* 药品库列表
*/
class MedicineLists extends BaseAdminDataLists implements ListsSearchInterface
{
/**
* 设置搜索条件
*/
public function setSearch(): array
{
return [
'%like%' => ['supplier'],
'=' => ['status'],
];
}
/**
* name 参数:中文等按名称模糊;纯英文字母按拼音首字母字段 + 名称模糊(OR)
*/
private function appendMedicineNameSearch($query): void
{
$keyword = trim((string) ($this->params['name'] ?? ''));
if ($keyword === '') {
return;
}
if (preg_match('/^[a-zA-Z]+$/', $keyword)) {
$kw = strtolower($keyword);
$query->where(function ($q) use ($kw, $keyword) {
$q->where('name_pinyin_abbr', 'like', '%' . $kw . '%')
->whereOr('name', 'like', '%' . $keyword . '%');
});
} else {
$query->where('name', 'like', '%' . $keyword . '%');
}
}
/**
* 列表字段
*/
public function lists(): array
{
$query = Medicine::where($this->searchWhere);
$this->appendMedicineNameSearch($query);
return $query
->field([
'id', 'name', 'name_pinyin_abbr', 'supplier', 'unit',
'settlement_price', 'retail_price', 'stock',
'image', 'status', 'remark',
'create_time', 'update_time',
])
->limit($this->limitOffset, $this->limitLength)
->order(['id' => 'desc'])
->select()
->toArray();
}
/**
* 列表数量
*/
public function count(): int
{
$query = Medicine::where($this->searchWhere);
$this->appendMedicineNameSearch($query);
return $query->count();
}
}
@@ -41,9 +41,11 @@ class RosterLists extends BaseAdminDataLists implements ListsSearchInterface
}
$lists = Roster::where($where)
->field(['id', 'doctor_id', 'date', 'period', 'status', 'quota', 'max_patients', 'booked_count', 'remark', 'create_time', 'update_time'])
->order(['date' => 'asc', 'period' => 'asc'])
->limit($this->limitOffset, $this->limitLength)
->field([
'id', 'doctor_id', 'date', 'period', 'start_time', 'end_time', 'shift_type', 'slot_minutes',
'status', 'quota', 'max_patients', 'booked_count', 'remark', 'create_time', 'update_time',
])
->order(['date' => 'asc', 'start_time' => 'asc', 'id' => 'asc'])
->select()
->toArray();
@@ -0,0 +1,227 @@
<?php
namespace app\adminapi\lists\doctor;
use app\adminapi\lists\BaseAdminDataLists;
use app\common\model\doctor\Appointment;
use app\common\model\auth\Admin;
/**
* 医生统计列表
* Class StatisticsLists
* @package app\adminapi\lists\doctor
*/
class StatisticsLists extends BaseAdminDataLists
{
/**
* @notes 搜索条件
* @return array
*/
public function setSearch(): array
{
return [];
}
/**
* @notes 获取列表
* @return array
*/
public function lists(): array
{
$doctorId = $this->params['doctor_id'] ?? '';
$timeType = $this->params['time_type'] ?? 'today';
$startDate = $this->params['start_date'] ?? '';
$endDate = $this->params['end_date'] ?? '';
// 计算时间范围
list($startDate, $endDate, $timeRangeText) = $this->getTimeRange($timeType, $startDate, $endDate);
// 获取医生列表(role_id=1 表示医生角色)
$doctorAdminIds = \app\common\model\auth\AdminRole::where('role_id', 1)->column('admin_id');
if (empty($doctorAdminIds)) {
return [];
}
// 查询1:获取预约统计数据(快速查询,只查appointment表)
$statisticsData = \think\facade\Db::name('doctor_appointment')
->field([
'doctor_id',
'COUNT(*) as total_count',
'SUM(CASE WHEN status = 1 THEN 1 ELSE 0 END) as registered_count',
'SUM(CASE WHEN status = 3 THEN 1 ELSE 0 END) as completed_count',
'SUM(CASE WHEN status = 4 THEN 1 ELSE 0 END) as missed_count',
'SUM(CASE WHEN status = 2 THEN 1 ELSE 0 END) as cancelled_count'
])
->whereIn('doctor_id', $doctorAdminIds)
->where('appointment_date', '>=', $startDate)
->where('appointment_date', '<=', $endDate);
if ($doctorId) {
$statisticsData->where('doctor_id', $doctorId);
}
$statisticsData = $statisticsData->group('doctor_id')->select()->toArray();
// 将统计数据按 doctor_id 索引
$statsMap = [];
foreach ($statisticsData as $stat) {
$statsMap[$stat['doctor_id']] = $stat;
}
// 获取医生信息
$doctorQuery = Admin::field('id,name')->whereIn('id', $doctorAdminIds);
if ($doctorId) {
$doctorQuery->where('id', $doctorId);
}
$doctors = $doctorQuery->select()->toArray();
// 查询2:获取部门统计信息(通过assistant_id直接关联,按部门分组统计数量)
$doctorIdsWithAppointments = array_keys($statsMap);
$deptStatsMap = [];
$channelStatsMap = [];
if (!empty($doctorIdsWithAppointments)) {
// 通过 assistant_id → admin_dept → dept 获取部门统计
$deptStats = \think\facade\Db::name('doctor_appointment')
->alias('apt')
->leftJoin('admin_dept ad', 'apt.assistant_id = ad.admin_id')
->leftJoin('dept dept', 'ad.dept_id = dept.id')
->field('apt.doctor_id, dept.name as dept_name, COUNT(*) as dept_count')
->whereIn('apt.doctor_id', $doctorIdsWithAppointments)
->where('apt.appointment_date', '>=', $startDate)
->where('apt.appointment_date', '<=', $endDate)
->whereNotNull('dept.id')
->group('apt.doctor_id, dept.id')
->select()
->toArray();
// 组装部门统计数据:医生ID => "部门1(数量1) 部门2(数量2)"
foreach ($deptStats as $stat) {
if (!isset($deptStatsMap[$stat['doctor_id']])) {
$deptStatsMap[$stat['doctor_id']] = [];
}
$deptStatsMap[$stat['doctor_id']][] = $stat['dept_name'] . '(' . $stat['dept_count'] . ')';
}
// 获取渠道字典数据
$channelDict = \think\facade\Db::name('dict_data')
->where('type_value', 'channels')
->column('name', 'value');
// 获取渠道统计信息(channels字段,按渠道分组统计数量)
$channelStats = \think\facade\Db::name('doctor_appointment')
->field('doctor_id, channels, COUNT(*) as channel_count')
->whereIn('doctor_id', $doctorIdsWithAppointments)
->where('appointment_date', '>=', $startDate)
->where('appointment_date', '<=', $endDate)
->whereNotNull('channels')
->where('channels', '<>', '')
->group('doctor_id, channels')
->select()
->toArray();
// 组装渠道统计数据:医生ID => "渠道名称1(数量1) 渠道名称2(数量2)"
foreach ($channelStats as $stat) {
if (!isset($channelStatsMap[$stat['doctor_id']])) {
$channelStatsMap[$stat['doctor_id']] = [];
}
$channelName = $channelDict[$stat['channels']] ?? $stat['channels'];
$channelStatsMap[$stat['doctor_id']][] = $channelName . '(' . $stat['channel_count'] . ')';
}
}
// 组装结果
$result = [];
foreach ($doctors as $doctor) {
$stat = $statsMap[$doctor['id']] ?? [
'total_count' => 0,
'registered_count' => 0,
'completed_count' => 0,
'missed_count' => 0,
'cancelled_count' => 0
];
// 计算完成率
$completionRate = $stat['total_count'] > 0
? round(($stat['completed_count'] / $stat['total_count']) * 100, 2)
: 0;
$result[] = [
'doctor_id' => $doctor['id'],
'doctor_name' => $doctor['name'],
'total_count' => (int)$stat['total_count'],
'registered_count' => (int)$stat['registered_count'],
'completed_count' => (int)$stat['completed_count'],
'missed_count' => (int)$stat['missed_count'],
'cancelled_count' => (int)$stat['cancelled_count'],
'completion_rate' => $completionRate,
'dept_stats' => isset($deptStatsMap[$doctor['id']]) ? implode(' ', $deptStatsMap[$doctor['id']]) : '未分配',
'channel_stats' => isset($channelStatsMap[$doctor['id']]) ? implode(' ', $channelStatsMap[$doctor['id']]) : '未设置',
'time_range' => $timeRangeText
];
}
return $result;
}
/**
* @notes 获取时间范围
* @param string $timeType
* @param string $customStartDate
* @param string $customEndDate
* @return array
*/
private function getTimeRange($timeType, $customStartDate, $customEndDate)
{
$today = date('Y-m-d');
switch ($timeType) {
case 'today':
$startDate = $today;
$endDate = $today;
$timeRangeText = '今天 (' . $today . ')';
break;
case 'week':
$startDate = date('Y-m-d', strtotime('-6 days'));
$endDate = $today;
$timeRangeText = '最近7天 (' . $startDate . ' 至 ' . $endDate . ')';
break;
case 'month':
$startDate = date('Y-m-d', strtotime('-29 days'));
$endDate = $today;
$timeRangeText = '最近30天 (' . $startDate . ' 至 ' . $endDate . ')';
break;
case 'custom':
if ($customStartDate && $customEndDate) {
$startDate = $customStartDate;
$endDate = $customEndDate;
$timeRangeText = '自定义 (' . $startDate . ' 至 ' . $endDate . ')';
} else {
$startDate = $today;
$endDate = $today;
$timeRangeText = '今天 (' . $today . ')';
}
break;
default:
$startDate = $today;
$endDate = $today;
$timeRangeText = '今天 (' . $today . ')';
}
return [$startDate, $endDate, $timeRangeText];
}
/**
* @notes 获取数量
* @return int
*/
public function count(): int
{
return count($this->lists());
}
}
@@ -5,6 +5,7 @@ declare(strict_types=1);
namespace app\adminapi\lists\tcm;
use app\adminapi\lists\BaseAdminDataLists;
use app\adminapi\logic\tcm\PrescriptionLibraryLogic;
use app\common\model\tcm\PrescriptionLibrary;
use app\common\lists\ListsSearchInterface;
@@ -24,6 +25,21 @@ class PrescriptionLibraryLists extends BaseAdminDataLists implements ListsSearch
];
}
/**
* @notes 列表数据范围:超管/管理员角色看全部;普通账号仅看自己创建 + 公开处方
*/
private function applyDataScope($query)
{
if (PrescriptionLibraryLogic::canManageAllPrescriptions($this->adminId, $this->adminInfo)) {
return $query;
}
return $query->where(function ($q) {
$q->where('creator_id', $this->adminId)
->whereOr('is_public', 1);
});
}
/**
* @notes 获取列表
*/
@@ -34,12 +50,10 @@ class PrescriptionLibraryLists extends BaseAdminDataLists implements ListsSearch
'creator_id', 'creator_name', 'create_time', 'update_time'
];
$lists = PrescriptionLibrary::where($this->searchWhere)
->where(function ($query) {
// 只显示自己创建的或公开的处方
$query->where('creator_id', $this->adminId)
->whereOr('is_public', 1);
})
$query = PrescriptionLibrary::where($this->searchWhere);
$this->applyDataScope($query);
$lists = $query
->field($field)
->limit($this->limitOffset, $this->limitLength)
->order('id', 'desc')
@@ -63,12 +77,9 @@ class PrescriptionLibraryLists extends BaseAdminDataLists implements ListsSearch
*/
public function count(): int
{
return PrescriptionLibrary::where($this->searchWhere)
->where(function ($query) {
// 只统计自己创建的或公开的处方
$query->where('creator_id', $this->adminId)
->whereOr('is_public', 1);
})
->count();
$query = PrescriptionLibrary::where($this->searchWhere);
$this->applyDataScope($query);
return $query->count();
}
}
+46
View File
@@ -60,6 +60,7 @@ class LoginLogic extends BaseLogic
'avatar' => $avatar,
'role_name' => $adminInfo['role_name'],
'token' => $adminInfo['token'],
'is_paw' => $admin->is_paw ?? 1, // 0=需要修改密码,1=正常
];
}
@@ -133,6 +134,7 @@ class LoginLogic extends BaseLogic
'avatar' => $avatar,
'role_name' => $adminInfo['role_name'],
'token' => $adminInfo['token'],
'is_paw' => $admin->is_paw ?? 1, // 0=需要修改密码,1=正常
];
}
@@ -211,4 +213,48 @@ class LoginLogic extends BaseLogic
//设置token过期
return AdminTokenService::expireToken($adminInfo['token']);
}
/**
* @notes 首次登录修改密码
* @param string $token
* @param string $password
* @return bool
*/
public function changeFirstPassword($token, $password)
{
try {
// 通过 token 获取管理员信息
$adminSession = \app\common\model\auth\AdminSession::where('token', '=', $token)
->where('expire_time', '>', time())
->find();
if (!$adminSession) {
self::setError('登录已过期,请重新登录');
return false;
}
$admin = Admin::find($adminSession->admin_id);
if (!$admin) {
self::setError('管理员不存在');
return false;
}
// 使用系统的密码加密方式
$passwordSalt = Config::get('project.unique_identification');
$encryptedPassword = create_password($password, $passwordSalt);
// 更新密码和 is_paw 标记
$admin->password = $encryptedPassword;
$admin->is_paw = 1;
$admin->save();
// 使当前 token 过期,要求重新登录
AdminTokenService::expireToken($token);
return true;
} catch (\Exception $e) {
self::setError($e->getMessage());
return false;
}
}
}
@@ -281,7 +281,7 @@ class AdminLogic extends BaseLogic
{
$admin = Admin::field([
'id', 'account', 'name', 'disable', 'root',
'multipoint_login', 'avatar',
'multipoint_login', 'avatar', 'is_paw',
'gender', 'age', 'phone', 'title', 'department',
'specialty', 'education', 'experience', 'honors',
'license_no', 'qualification_images', 'enable_image_consult', 'enable_video_consult', 'enable_charge',
@@ -311,6 +311,9 @@ class AdminLogic extends BaseLogic
return $admin;
}
$authRoleIds = AdminRole::where('admin_id', $params['id'])->column('role_id');
$admin['role_ids'] = array_values(array_map('intval', $authRoleIds));
$result['user'] = $admin;
// 当前管理员角色拥有的菜单
$result['menu'] = MenuLogic::getMenuByAdminId($params['id']);
@@ -5,6 +5,7 @@ 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\auth\Admin;
use app\api\logic\ChatNotifyLogic;
@@ -70,72 +71,46 @@ class AppointmentLogic extends BaseLogic
return ['slots' => []];
}
// 2. 根据排班时段生成时间段
// 2. 按每段排班的起止时间与号源间隔生成时间段
$slots = [];
foreach ($rosters as $roster) {
// 再次确认状态为出诊(双重检查)
if ($roster['status'] != 1) {
if ((int) $roster['status'] !== 1) {
\think\facade\Log::warning('跳过非出诊排班', [
'roster_id' => $roster['id'],
'status' => $roster['status']
]);
continue;
}
$periodValue = $roster['period'];
\think\facade\Log::info('处理排班时段', [
'roster_id' => $roster['id'],
'period' => $periodValue,
'status' => $roster['status'],
'period_type' => gettype($periodValue)
]);
// 根据时段生成时间段(支持数字和字符串格式)
$startHour = null;
$endHour = null;
if ($periodValue == 1 || $periodValue === 'morning' || $periodValue === '上午') {
// 上午:9:00-12:00
$startHour = 9;
$endHour = 12;
\think\facade\Log::info('识别为上午时段', ['period' => $periodValue]);
} elseif ($periodValue == 2 || $periodValue === 'afternoon' || $periodValue === '下午') {
// 下午:14:00-18:00
$startHour = 14;
$endHour = 18;
\think\facade\Log::info('识别为下午时段', ['period' => $periodValue]);
} else {
\think\facade\Log::warning('未知的时段值,跳过此记录', [
'roster_id' => $roster['id'],
'period' => $periodValue,
'period_type' => gettype($periodValue)
]);
continue;
}
// 确保startHour和endHour已设置
if ($startHour === null || $endHour === null) {
\think\facade\Log::error('时段参数未正确设置', [
'roster_id' => $roster['id'],
'period' => $periodValue
'status' => $roster['status'],
]);
continue;
}
// 生成15分钟间隔的时间段
for ($hour = $startHour; $hour < $endHour; $hour++) {
for ($minute = 0; $minute < 60; $minute += 15) {
$time = sprintf('%02d:%02d', $hour, $minute);
$slots[] = [
'time' => $time,
'available' => true,
'quota' => 1,
'period' => $periodValue
];
}
$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',
];
}
}
@@ -164,14 +139,8 @@ class AppointmentLogic extends BaseLogic
\think\facade\Log::info('生成的时间段数量(去重后)', [
'count' => count($slots),
'morning_slots' => count(array_filter($slots, function($s) {
return $s['time'] < '12:00';
})),
'afternoon_slots' => count(array_filter($slots, function($s) {
return $s['time'] >= '14:00';
})),
'first_3_slots' => array_slice($slots, 0, 3),
'last_3_slots' => array_slice($slots, -3)
'last_3_slots' => array_slice($slots, -3),
]);
// 4. 查询已预约的时间段
@@ -237,6 +206,7 @@ class AppointmentLogic extends BaseLogic
}
$data = [
'patient_id' => $params['patient_id'],
'assistant_id'=>$params['assistant_id'],
'doctor_id' => $params['doctor_id'],
'roster_id' => 0, // 不再关联排班表
'appointment_date' => $params['appointment_date'],
@@ -244,6 +214,7 @@ class AppointmentLogic extends BaseLogic
'appointment_time' => $appointmentTime,
'appointment_type' => $params['appointment_type'] ?? 'video',
'remark' => $params['remark'] ?? '',
'channel_source' => $params['channel_source'] ?? '',
'status' => 1,
'create_time' => time(),
'update_time' => time(),
@@ -339,28 +310,41 @@ class AppointmentLogic extends BaseLogic
public static function getDoctorAvailability($doctorId, $date)
{
try {
$totalAvailable = 0;
// 查询上午和下午的排班
$rosters = Roster::where([
'doctor_id' => $doctorId,
'date' => $date,
'status' => 1 // 出诊
])->select();
'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) {
// 查询该时段已预约数量
$appointmentCount = Appointment::where([
'doctor_id' => $doctorId,
'appointment_date' => $date,
'period' => $roster->period,
'status' => 1
])->count();
// 计算剩余号源
$remaining = $roster->quota - $appointmentCount;
if ($remaining > 0) {
$totalAvailable += $remaining;
$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;
}
}
}
@@ -0,0 +1,81 @@
<?php
namespace app\adminapi\logic\doctor;
use app\common\logic\BaseLogic;
use app\common\model\doctor\Medicine;
use app\common\service\doctor\MedicineNameAbbrService;
/**
* 药品库逻辑层
*/
class MedicineLogic extends BaseLogic
{
/**
* 添加药品
*/
public static function add(array $params): bool
{
try {
Medicine::create([
'name' => $params['name'],
'name_pinyin_abbr' => MedicineNameAbbrService::build($params['name']),
'supplier' => $params['supplier'],
'unit' => $params['unit'],
'settlement_price' => $params['settlement_price'],
'retail_price' => $params['retail_price'],
'stock' => $params['stock'] ?? 0,
'image' => $params['image'] ?? '',
'status' => $params['status'] ?? 1,
'remark' => $params['remark'] ?? '',
]);
return true;
} catch (\Exception $e) {
self::setError($e->getMessage());
return false;
}
}
/**
* 编辑药品
*/
public static function edit(array $params): bool
{
try {
Medicine::update([
'id' => $params['id'],
'name' => $params['name'],
'name_pinyin_abbr' => MedicineNameAbbrService::build($params['name']),
'supplier' => $params['supplier'],
'unit' => $params['unit'],
'settlement_price' => $params['settlement_price'],
'retail_price' => $params['retail_price'],
'stock' => $params['stock'] ?? 0,
'image' => $params['image'] ?? '',
'status' => $params['status'] ?? 1,
'remark' => $params['remark'] ?? '',
]);
return true;
} catch (\Exception $e) {
self::setError($e->getMessage());
return false;
}
}
/**
* 删除药品
*/
public static function delete(array $params): bool
{
Medicine::destroy($params['id']);
return true;
}
/**
* 药品详情
*/
public static function detail(array $params): array
{
return Medicine::findOrEmpty($params['id'])->toArray();
}
}
+133 -49
View File
@@ -4,6 +4,7 @@ namespace app\adminapi\logic\doctor;
use app\common\logic\BaseLogic;
use app\common\model\doctor\Roster;
use app\common\service\doctor\RosterSegmentService;
use think\facade\Db;
/**
@@ -13,6 +14,80 @@ use think\facade\Db;
*/
class RosterLogic extends BaseLogic
{
/**
* 组装单条排班数据(period 缺省为 segment
*/
protected static function buildRowData(array $params): array
{
$period = $params['period'] ?? '';
if (!in_array($period, ['morning', 'afternoon', 'night', 'segment'], true)) {
$period = 'segment';
}
$start = trim((string) ($params['start_time'] ?? ''));
$end = trim((string) ($params['end_time'] ?? ''));
$status = (int) $params['status'];
$slotMinutes = RosterSegmentService::normalizeSlotMinutes($params['slot_minutes'] ?? 15);
if ($status === 1) {
if ($start === '' || $end === '') {
throw new \InvalidArgumentException('出诊须填写接诊开始与结束时间');
}
if ($start >= $end) {
throw new \InvalidArgumentException('结束时间须晚于开始时间');
}
} else {
if ($start === '' || $end === '') {
throw new \InvalidArgumentException('请填写时段开始与结束时间');
}
if ($start >= $end) {
throw new \InvalidArgumentException('结束时间须晚于开始时间');
}
}
$shiftType = $params['shift_type'] ?? '';
if ($shiftType !== '' && !in_array($shiftType, ['day', 'night'], true)) {
$shiftType = '';
}
$quota = (int) ($params['quota'] ?? 0);
if ($status !== 1) {
$quota = 0;
}
return [
'doctor_id' => (int) $params['doctor_id'],
'date' => $params['date'],
'period' => $period,
'start_time' => $start,
'end_time' => $end,
'shift_type' => $shiftType !== '' ? $shiftType : null,
'slot_minutes' => $slotMinutes,
'status' => $status,
'quota' => $quota,
'max_patients' => $status === 1 ? (int) ($params['max_patients'] ?? 0) : 0,
'remark' => (string) ($params['remark'] ?? ''),
];
}
/**
* 是否存在相同医生、日期、起止时间的记录(排除指定 id)
*/
protected static function duplicateExists(int $doctorId, string $date, string $start, string $end, ?int $excludeId = null): bool
{
$q = Roster::where([
['doctor_id', '=', $doctorId],
['date', '=', $date],
['start_time', '=', $start],
['end_time', '=', $end],
]);
if ($excludeId) {
$q->where('id', '<>', $excludeId);
}
return (bool) $q->find();
}
/**
* @notes 保存排班
* @param array $params
@@ -21,30 +96,33 @@ class RosterLogic extends BaseLogic
public static function save(array $params)
{
try {
$data = [
'doctor_id' => $params['doctor_id'],
'date' => $params['date'],
'period' => $params['period'],
'status' => $params['status'],
'quota' => $params['quota'] ?? 0,
'max_patients' => $params['max_patients'] ?? 0,
'remark' => $params['remark'] ?? '',
];
$data = self::buildRowData($params);
if (isset($params['id']) && $params['id']) {
// 更新
$data['update_time'] = time();
Roster::where('id', $params['id'])->update($data);
return ['id' => $params['id']];
} else {
// 新增
$data['create_time'] = time();
$data['update_time'] = time();
$roster = Roster::create($data);
return ['id' => $roster->id];
if (self::duplicateExists($data['doctor_id'], $data['date'], $data['start_time'], $data['end_time'], !empty($params['id']) ? (int) $params['id'] : null)) {
self::setError('该医生在同一天已存在相同的接诊时段');
return false;
}
if (!empty($params['id'])) {
$data['update_time'] = time();
Roster::where('id', (int) $params['id'])->update($data);
return ['id' => (int) $params['id']];
}
$data['create_time'] = time();
$data['update_time'] = time();
$roster = Roster::create($data);
return ['id' => $roster->id];
} catch (\InvalidArgumentException $e) {
self::setError($e->getMessage());
return false;
} catch (\Exception $e) {
self::setError($e->getMessage());
return false;
}
}
@@ -58,9 +136,11 @@ class RosterLogic extends BaseLogic
{
try {
Roster::destroy($params['id']);
return true;
} catch (\Exception $e) {
self::setError($e->getMessage());
return false;
}
}
@@ -84,53 +164,50 @@ class RosterLogic extends BaseLogic
{
try {
Db::startTrans();
$successCount = 0;
$updateCount = 0;
$createCount = 0;
foreach ($params['rosters'] as $roster) {
$data = [
'doctor_id' => $roster['doctor_id'],
'date' => $roster['date'],
'period' => $roster['period'],
'status' => $roster['status'],
'quota' => $roster['quota'] ?? 0,
'max_patients' => $roster['max_patients'] ?? 0,
'remark' => $roster['remark'] ?? '',
];
// 检查是否已存在
foreach ($params['rosters'] as $roster) {
$data = self::buildRowData($roster);
$exists = Roster::where([
['doctor_id', '=', $data['doctor_id']],
['date', '=', $data['date']],
['period', '=', $data['period']],
['start_time', '=', $data['start_time']],
['end_time', '=', $data['end_time']],
])->find();
if ($exists) {
// 更新
$data['update_time'] = time();
$exists->save($data);
$updateCount++;
++$updateCount;
} else {
// 新增
$data['create_time'] = time();
$data['update_time'] = time();
Roster::create($data);
$createCount++;
++$createCount;
}
$successCount++;
++$successCount;
}
Db::commit();
return [
'success_count' => $successCount,
'create_count' => $createCount,
'update_count' => $updateCount,
];
} catch (\InvalidArgumentException $e) {
Db::rollback();
self::setError($e->getMessage());
return false;
} catch (\Exception $e) {
Db::rollback();
self::setError($e->getMessage());
return false;
}
}
@@ -145,28 +222,29 @@ class RosterLogic extends BaseLogic
try {
Db::startTrans();
// 获取源排班数据
$where = [
['date', 'between', [$params['source_start_date'], $params['source_end_date']]]
['date', 'between', [$params['source_start_date'], $params['source_end_date']]],
];
if (isset($params['doctor_id']) && $params['doctor_id']) {
$where[] = ['doctor_id', '=', $params['doctor_id']];
}
$sourceRosters = Roster::where($where)->select();
// 计算日期差
$sourceDays = (strtotime($params['source_end_date']) - strtotime($params['source_start_date'])) / 86400;
$targetDays = (strtotime($params['target_start_date']) - strtotime($params['source_start_date'])) / 86400;
foreach ($sourceRosters as $roster) {
$newDate = date('Y-m-d', strtotime($roster->date) + ($targetDays * 86400));
$data = [
'doctor_id' => $roster->doctor_id,
'date' => $newDate,
'period' => $roster->period,
'start_time' => $roster->start_time,
'end_time' => $roster->end_time,
'shift_type' => $roster->shift_type,
'slot_minutes' => $roster->slot_minutes ?: 15,
'status' => $roster->status,
'quota' => $roster->quota,
'max_patients' => $roster->max_patients,
@@ -175,23 +253,29 @@ class RosterLogic extends BaseLogic
'update_time' => time(),
];
// 检查目标日期是否已存在排班
$exists = Roster::where([
if (empty($data['start_time']) || empty($data['end_time'])) {
continue;
}
$dup = Roster::where([
['doctor_id', '=', $data['doctor_id']],
['date', '=', $data['date']],
['period', '=', $data['period']],
['start_time', '=', $data['start_time']],
['end_time', '=', $data['end_time']],
])->find();
if (!$exists) {
if (!$dup) {
Roster::create($data);
}
}
Db::commit();
return true;
} catch (\Exception $e) {
Db::rollback();
self::setError($e->getMessage());
return false;
}
}
+268 -35
View File
@@ -16,7 +16,9 @@ namespace app\adminapi\logic\tcm;
use app\common\logic\BaseLogic;
use app\common\model\tcm\Diagnosis;
use app\common\model\tcm\ImChatMessage;
use app\common\model\DiagnosisViewRecord;
use think\facade\Db;
/**
* 中医辨房病因诊单逻辑
* Class DiagnosisLogic
@@ -499,7 +501,10 @@ class DiagnosisLogic extends BaseLogic
$doctorUserId = 'doctor_' . $adminId;
$query = Diagnosis::where('id', $patientId)
->where('delete_time', null)->find();
if(!$query){
self::setError('患者诊单已被删除');
return false;
}
// 患者userId(必须与小程序端一致)
$patientUserId = 'patient_' . $patientId;
@@ -524,7 +529,9 @@ class DiagnosisLogic extends BaseLogic
'userSig' => $userSig,
'assistant_id'=>$query->assistant_id?'doctor_'.$query->assistant_id:'',
'patientUserId' => $patientUserId, // 患者的userId(用于发起通话)
'expireTime' => 86400 // 24小时
'expireTime' => 86400, // 24小时
// 与 .env [trtc] ISLOCHOSTVOD 一致:true 允许浏览器本地录制并上传
'isLochostVod' => (bool)config('trtc.is_lochost_vod', false),
];
} catch (\Exception $e) {
self::setError($e->getMessage());
@@ -620,16 +627,11 @@ class DiagnosisLogic extends BaseLogic
}
/**
* @notes 拉取与本诊单相关的腾讯云 IM 单聊记录C2Cpatient_{患者ID} 与 全部医生/医助账号 doctor_* 分别会话后合并
* @notes 拉取与本诊单相关的 IM 单聊记录:本地归档 + 腾讯云漫游合并(归档突破云端约 7 天限制
*/
public static function getImChatMessagesForDiagnosis(int $diagnosisId)
{
try {
$config = self::getTrtcConfig();
if (!$config) {
self::setError('请先配置腾讯云 TRTC / IM 参数');
return false;
}
$diag = Diagnosis::where('id', $diagnosisId)->where('delete_time', null)->find();
if (!$diag) {
self::setError('诊单不存在');
@@ -641,6 +643,24 @@ class DiagnosisLogic extends BaseLogic
return false;
}
$patientImId = 'patient_' . $patientId;
$archived = self::loadArchivedImChatRows($diagnosisId);
$config = self::getTrtcConfig();
if (!$config) {
if (empty($archived)) {
self::setError('请先配置腾讯云 TRTC / IM 参数');
return false;
}
$lists = self::enrichImMessagesWithStaffNames($archived);
return [
'lists' => $lists,
'patient_im_id' => $patientImId,
'patient_name' => $diag['patient_name'] ?? '',
'doctor_accounts_queried' => [],
];
}
$doctorAccounts = self::collectAllDoctorImPeerAccounts();
$assistantId = isset($diag['assistant_id']) ? (int)$diag['assistant_id'] : 0;
if ($assistantId > 0) {
@@ -652,34 +672,12 @@ class DiagnosisLogic extends BaseLogic
return false;
}
$imService = new \app\common\service\TencentImService();
$merged = [];
foreach ($doctorAccounts as $docAccount) {
$batch = self::pullAllRoamMessages($imService, $docAccount, $patientImId);
foreach ($batch as $row) {
$merged[] = $row;
}
}
usort($merged, function ($a, $b) {
return ($a['time'] ?? 0) <=> ($b['time'] ?? 0);
});
$seen = [];
$unique = [];
foreach ($merged as $row) {
$k = $row['msg_id'] ?? '';
if ($k !== '' && isset($seen[$k])) {
continue;
}
if ($k !== '') {
$seen[$k] = true;
}
$unique[] = $row;
}
$unique = self::enrichImMessagesWithStaffNames($unique);
$live = self::pullLiveImChatMessagesForDiagnosis($diag);
$merged = self::mergeImMessagesByMsgId($archived, $live);
$merged = self::enrichImMessagesWithStaffNames($merged);
return [
'lists' => $unique,
'lists' => $merged,
'patient_im_id' => $patientImId,
'patient_name' => $diag['patient_name'] ?? '',
'doctor_accounts_queried' => $doctorAccounts,
@@ -690,6 +688,239 @@ class DiagnosisLogic extends BaseLogic
}
}
/**
* 定时任务:从腾讯云拉取漫游消息写入归档表
*
* @return array{inserted:int, skipped_live_empty:bool, error?:string}
*/
public static function syncImChatArchiveForDiagnosis(int $diagnosisId): array
{
$out = ['inserted' => 0, 'skipped_live_empty' => false];
try {
if (!self::getTrtcConfig()) {
$out['error'] = 'TRTC/IM 未配置';
return $out;
}
$diag = Diagnosis::where('id', $diagnosisId)->where('delete_time', null)->find();
if (!$diag) {
$out['error'] = '诊单不存在';
return $out;
}
$live = self::pullLiveImChatMessagesForDiagnosis($diag);
if (empty($live)) {
$out['skipped_live_empty'] = true;
return $out;
}
$live = self::enrichImMessagesWithStaffNames($live);
$patientId = (int)$diag['patient_id'];
$out['inserted'] = self::persistImChatArchiveRows($diagnosisId, $patientId, $live);
return $out;
} catch (\Exception $e) {
$out['error'] = $e->getMessage();
return $out;
}
}
/**
* @return array{diagnoses:int, inserted:int, errors:array<int, string>}
*/
public static function syncImChatArchiveBatch(int $sinceDays, int $limit, ?int $onlyDiagnosisId): array
{
$stats = ['diagnoses' => 0, 'inserted' => 0, 'errors' => []];
$limit = max(1, min(500, $limit));
$q = Diagnosis::where('delete_time', null);
if ($onlyDiagnosisId !== null && $onlyDiagnosisId > 0) {
$q->where('id', $onlyDiagnosisId);
} elseif ($sinceDays > 0) {
$q->where('update_time', '>=', time() - $sinceDays * 86400);
}
$ids = $q->order('id', 'desc')->limit($limit)->column('id');
foreach ($ids as $id) {
$stats['diagnoses']++;
$r = self::syncImChatArchiveForDiagnosis((int)$id);
if (!empty($r['error'])) {
$stats['errors'][] = 'diagnosis ' . $id . ': ' . $r['error'];
continue;
}
$stats['inserted'] += (int)($r['inserted'] ?? 0);
}
return $stats;
}
/**
* @return array<int, array<string, mixed>>
*/
private static function loadArchivedImChatRows(int $diagnosisId): array
{
if ($diagnosisId <= 0) {
return [];
}
$list = ImChatMessage::where('diagnosis_id', $diagnosisId)
->order('msg_time', 'asc')
->order('id', 'asc')
->select()
->toArray();
$out = [];
foreach ($list as $row) {
$out[] = [
'msg_id' => (string)($row['msg_id'] ?? ''),
'from_account' => (string)($row['from_account'] ?? ''),
'to_account' => (string)($row['to_account'] ?? ''),
'time' => (int)($row['msg_time'] ?? 0),
'is_from_doctor' => !empty($row['is_from_doctor']),
'msg_type' => (string)($row['msg_type'] ?? ''),
'text' => (string)($row['text'] ?? ''),
'image_url' => (string)($row['image_url'] ?? ''),
'file_url' => (string)($row['file_url'] ?? ''),
'file_name' => (string)($row['file_name'] ?? ''),
'raw_elem_type' => (string)($row['raw_elem_type'] ?? ''),
'from_staff_name' => (string)($row['from_staff_name'] ?? ''),
'doctor_peer_account' => (string)($row['doctor_peer_account'] ?? ''),
];
}
return $out;
}
/**
* @param array<int, array<string, mixed>> $archived
* @param array<int, array<string, mixed>> $live
* @return array<int, array<string, mixed>>
*/
private static function mergeImMessagesByMsgId(array $archived, array $live): array
{
$map = [];
$tail = [];
foreach ($archived as $r) {
$k = (string)($r['msg_id'] ?? '');
if ($k !== '') {
$map[$k] = $r;
} else {
$tail[] = $r;
}
}
foreach ($live as $r) {
$k = (string)($r['msg_id'] ?? '');
if ($k !== '') {
$map[$k] = $r;
} else {
$tail[] = $r;
}
}
$merged = array_values($map);
$merged = array_merge($merged, $tail);
usort($merged, function ($a, $b) {
return ($a['time'] ?? 0) <=> ($b['time'] ?? 0);
});
return $merged;
}
/**
* @param Diagnosis|array<string, mixed> $diag
* @return array<int, array<string, mixed>>
*/
private static function pullLiveImChatMessagesForDiagnosis($diag): array
{
$patientId = (int)$diag['patient_id'];
if ($patientId <= 0) {
return [];
}
$patientImId = 'patient_' . $patientId;
$doctorAccounts = self::collectAllDoctorImPeerAccounts();
$assistantId = isset($diag['assistant_id']) ? (int)$diag['assistant_id'] : 0;
if ($assistantId > 0) {
$doctorAccounts[] = 'doctor_' . $assistantId;
}
$doctorAccounts = array_values(array_unique($doctorAccounts));
if (empty($doctorAccounts)) {
return [];
}
$imService = new \app\common\service\TencentImService();
$merged = [];
foreach ($doctorAccounts as $docAccount) {
$batch = self::pullAllRoamMessages($imService, $docAccount, $patientImId);
foreach ($batch as $row) {
$merged[] = $row;
}
}
usort($merged, function ($a, $b) {
return ($a['time'] ?? 0) <=> ($b['time'] ?? 0);
});
$seen = [];
$unique = [];
foreach ($merged as $row) {
$k = $row['msg_id'] ?? '';
if ($k !== '' && isset($seen[$k])) {
continue;
}
if ($k !== '') {
$seen[$k] = true;
}
$unique[] = $row;
}
return $unique;
}
/**
* @param array<int, array<string, mixed>> $rows
*/
private static function persistImChatArchiveRows(int $diagnosisId, int $patientId, array $rows): int
{
$now = time();
$chunks = [];
foreach ($rows as $r) {
$msgId = (string)($r['msg_id'] ?? '');
if ($msgId === '') {
continue;
}
$chunks[] = [
'diagnosis_id' => $diagnosisId,
'patient_id' => $patientId,
'msg_id' => $msgId,
'from_account' => (string)($r['from_account'] ?? ''),
'to_account' => (string)($r['to_account'] ?? ''),
'msg_time' => (int)($r['time'] ?? 0),
'is_from_doctor' => !empty($r['is_from_doctor']) ? 1 : 0,
'msg_type' => (string)($r['msg_type'] ?? ''),
'text' => (string)($r['text'] ?? ''),
'image_url' => (string)($r['image_url'] ?? ''),
'file_url' => (string)($r['file_url'] ?? ''),
'file_name' => (string)($r['file_name'] ?? ''),
'raw_elem_type' => (string)($r['raw_elem_type'] ?? ''),
'from_staff_name' => (string)($r['from_staff_name'] ?? ''),
'doctor_peer_account' => (string)($r['doctor_peer_account'] ?? ''),
'create_time' => $now,
];
}
$inserted = 0;
foreach (array_chunk($chunks, 80) as $chunk) {
$inserted += self::insertIgnoreImChatBatch($chunk);
}
return $inserted;
}
/**
* @param array<int, array<string, mixed>> $chunk
*/
private static function insertIgnoreImChatBatch(array $chunk): int
{
if (empty($chunk)) {
return 0;
}
$table = (new ImChatMessage())->getTable();
$cols = array_keys($chunk[0]);
$colSql = '`' . implode('`,`', $cols) . '`';
$rowPh = '(' . implode(',', array_fill(0, count($cols), '?')) . ')';
$allPh = implode(',', array_fill(0, count($chunk), $rowPh));
$flat = [];
foreach ($chunk as $row) {
foreach ($cols as $c) {
$flat[] = $row[$c];
}
}
$sql = 'INSERT IGNORE INTO `' . $table . '` (' . $colSql . ') VALUES ' . $allPh;
return (int)Db::execute($sql, $flat);
}
/**
* 为 from_account = doctor_{id} 的消息补充后台姓名,便于前端区分不同医生
*
@@ -747,7 +978,9 @@ class DiagnosisLogic extends BaseLogic
if (!is_array($raw)) {
continue;
}
$out[] = self::normalizeTimMessage($raw);
$normalized = self::normalizeTimMessage($raw);
$normalized['doctor_peer_account'] = $operator;
$out[] = $normalized;
}
$complete = (int)$res['complete'];
if ($complete === 1) {
@@ -5,13 +5,34 @@ declare(strict_types=1);
namespace app\adminapi\logic\tcm;
use app\common\logic\BaseLogic;
use app\common\model\auth\AdminRole;
use app\common\model\tcm\PrescriptionLibrary;
use think\facade\Config;
/**
* 处方库逻辑层
*/
class PrescriptionLibraryLogic extends BaseLogic
{
/**
* @notes 是否可管理全部处方(超级管理员 或 配置中的管理员角色)
*/
public static function canManageAllPrescriptions(int $adminId, array $adminInfo): bool
{
if (!empty($adminInfo['root']) && (int) $adminInfo['root'] === 1) {
return true;
}
$allowRoles = Config::get('project.prescription_library_manage_all_roles', []);
if ($allowRoles === [] || $allowRoles === null) {
$allowRoles = Config::get('project.order_edit_all_roles', [0, 3]);
}
$myRoles = AdminRole::where('admin_id', $adminId)->column('role_id');
return count(array_intersect($myRoles, $allowRoles)) > 0;
}
/**
* @notes 添加处方库
*/
@@ -24,7 +45,7 @@ class PrescriptionLibraryLogic extends BaseLogic
}
$model = PrescriptionLibrary::create($params);
return $model->id;
return (int) $model->id;
} catch (\Exception $e) {
self::setError($e->getMessage());
return null;
@@ -34,7 +55,7 @@ class PrescriptionLibraryLogic extends BaseLogic
/**
* @notes 编辑处方库
*/
public static function edit(array $params, int $adminId): bool
public static function edit(array $params, int $adminId, bool $canManageAll = false): bool
{
try {
$model = PrescriptionLibrary::findOrEmpty($params['id']);
@@ -43,8 +64,7 @@ class PrescriptionLibraryLogic extends BaseLogic
return false;
}
// 权限检查:只有创建者或公开的处方才能编辑
if ($model->creator_id != $adminId && $model->is_public != 1) {
if (!$canManageAll && (int) $model->creator_id !== $adminId) {
self::setError('无权限编辑此处方');
return false;
}
@@ -65,7 +85,7 @@ class PrescriptionLibraryLogic extends BaseLogic
/**
* @notes 删除处方库
*/
public static function delete(int $id, int $adminId): bool
public static function delete(int $id, int $adminId, bool $canManageAll = false): bool
{
try {
$model = PrescriptionLibrary::findOrEmpty($id);
@@ -74,8 +94,7 @@ class PrescriptionLibraryLogic extends BaseLogic
return false;
}
// 权限检查:只有创建者才能删除
if ($model->creator_id != $adminId) {
if (!$canManageAll && (int) $model->creator_id !== $adminId) {
self::setError('无权限删除此处方');
return false;
}
@@ -91,7 +110,7 @@ class PrescriptionLibraryLogic extends BaseLogic
/**
* @notes 处方库详情
*/
public static function detail(int $id, int $adminId): ?array
public static function detail(int $id, int $adminId, bool $canManageAll = false): ?array
{
try {
$model = PrescriptionLibrary::findOrEmpty($id);
@@ -99,8 +118,7 @@ class PrescriptionLibraryLogic extends BaseLogic
return null;
}
// 权限检查:只有创建者或公开的处方才能查看
if ($model->creator_id != $adminId && $model->is_public != 1) {
if (!$canManageAll && (int) $model->creator_id !== $adminId && (int) $model->is_public !== 1) {
return null;
}
@@ -23,6 +23,7 @@ class AppointmentValidate extends BaseValidate
'period' => 'in:morning,afternoon,all',
'appointment_time' => 'require',
'appointment_type' => 'in:video,text,phone',
'channel_source' => 'require',
];
/**
@@ -37,6 +38,7 @@ class AppointmentValidate extends BaseValidate
'period' => '时段',
'appointment_time' => '预约时间',
'appointment_type' => '预约类型',
'channel_source' => '渠道来源',
];
/**
@@ -45,7 +47,7 @@ class AppointmentValidate extends BaseValidate
*/
public function sceneCreate()
{
return $this->only(['patient_id', 'doctor_id', 'appointment_date', 'period', 'appointment_time', 'appointment_type']);
return $this->only(['patient_id', 'doctor_id', 'appointment_date', 'period', 'appointment_time', 'appointment_type', 'channel_source']);
}
/**
@@ -0,0 +1,58 @@
<?php
namespace app\adminapi\validate\doctor;
use app\common\validate\BaseValidate;
/**
* 药品库验证器
*/
class MedicineValidate extends BaseValidate
{
protected $rule = [
'id' => 'require|integer',
'name' => 'require|max:100',
'supplier' => 'require|max:100',
'unit' => 'require|max:20',
'settlement_price' => 'require|float|>=:0',
'retail_price' => 'require|float|>=:0',
'stock' => 'integer|>=:0',
'image' => 'max:255',
'status' => 'in:0,1',
'remark' => 'max:500',
];
protected $message = [
'id.require' => '药品ID不能为空',
'name.require' => '药材名称不能为空',
'name.max' => '药材名称不能超过100个字符',
'supplier.require' => '供应商名称不能为空',
'supplier.max' => '供应商名称不能超过100个字符',
'unit.require' => '单位不能为空',
'settlement_price.require' => '结算价不能为空',
'settlement_price.float' => '结算价必须是数字',
'retail_price.require' => '零售价不能为空',
'retail_price.float' => '零售价必须是数字',
'stock.integer' => '库存必须是整数',
];
public function sceneAdd()
{
return $this->only(['name', 'supplier', 'unit', 'settlement_price', 'retail_price', 'stock', 'image', 'status', 'remark']);
}
public function sceneEdit()
{
return $this->only(['id', 'name', 'supplier', 'unit', 'settlement_price', 'retail_price', 'stock', 'image', 'status', 'remark']);
}
public function sceneDelete()
{
return $this->only(['id']);
}
public function sceneDetail()
{
return $this->only(['id']);
}
}
@@ -12,26 +12,38 @@ use app\common\validate\BaseValidate;
class RosterValidate extends BaseValidate
{
protected $rule = [
'id' => 'require',
'id' => 'number',
'doctor_id' => 'require',
'date' => 'require|date',
'period' => 'require|in:morning,afternoon',
'period' => 'in:morning,afternoon,night,segment',
// 须用数组写法:字符串规则里的 | 会与「多规则分隔符」冲突,导致 preg_match 报错
'start_time' => ['require', 'regex' => '/^([01]\d|2[0-3]):[0-5]\d$/'],
'end_time' => ['require', 'regex' => '/^([01]\d|2[0-3]):[0-5]\d$/'],
'slot_minutes' => 'number|between:5,120',
'status' => 'require|in:1,2,3,4',
'quota' => 'number',
'max_patients' => 'number',
'remark' => 'max:500',
];
protected $message = [
'id.require' => '请选择排班',
'id.number' => '排班ID格式错误',
'doctor_id.require' => '请选择医生',
'date.require' => '请选择日期',
'date.date' => '日期格式不正确',
'period.require' => '请选择时段',
'period.in' => '时段参数错误',
'period.in' => '时段类型参数错误',
'start_time.require' => '请填写接诊开始时间',
'start_time.regex' => '开始时间格式须为 HH:mm',
'end_time.require' => '请填写接诊结束时间',
'end_time.regex' => '结束时间格式须为 HH:mm',
'slot_minutes.number' => '号源间隔须为数字',
'slot_minutes.between' => '号源间隔须在 5120 分钟',
'status.require' => '请选择状态',
'status.in' => '状态参数错误',
'quota.number' => '号源数必须是数字',
'max_patients.number' => '最大接诊数必须是数字',
'remark.max' => '备注过长',
];
/**
@@ -39,7 +51,10 @@ class RosterValidate extends BaseValidate
*/
public function sceneSave()
{
return $this->only(['doctor_id', 'date', 'period', 'status', 'quota', 'max_patients', 'remark']);
return $this->only([
'id', 'doctor_id', 'date', 'period', 'start_time', 'end_time', 'shift_type', 'slot_minutes',
'status', 'quota', 'max_patients', 'remark',
]);
}
/**
@@ -47,7 +62,7 @@ class RosterValidate extends BaseValidate
*/
public function sceneDelete()
{
return $this->only(['id']);
return $this->only(['id'])->append('id', 'require');
}
/**
@@ -55,7 +70,7 @@ class RosterValidate extends BaseValidate
*/
public function sceneDetail()
{
return $this->only(['id']);
return $this->only(['id'])->append('id', 'require');
}
/**
@@ -0,0 +1,51 @@
<?php
declare(strict_types=1);
namespace app\common\command;
use app\adminapi\logic\tcm\DiagnosisLogic;
use think\console\Command;
use think\console\Input;
use think\console\input\Option;
use think\console\Output;
use think\facade\Log;
/**
* 从腾讯云 IM 拉取诊单单聊漫游消息并写入本地归档表(建议 cron 每几小时执行)
*/
class SyncImChatArchive extends Command
{
protected function configure()
{
$this->setName('sync_im_chat_archive')
->setDescription('同步诊单腾讯云 IM 聊天记录到数据库归档')
->addOption('since-days', null, Option::VALUE_OPTIONAL, '仅处理最近 N 天内更新过的诊单;0 表示不限制', '7')
->addOption('limit', 'l', Option::VALUE_OPTIONAL, '本轮最多处理的诊单数量(1-500)', '50')
->addOption('diagnosis-id', null, Option::VALUE_OPTIONAL, '只同步指定诊单 ID,设置后忽略 since-days', '0');
}
protected function execute(Input $input, Output $output)
{
$sinceDays = (int)$input->getOption('since-days');
$sinceDays = max(0, $sinceDays);
$limit = (int)$input->getOption('limit');
$onlyId = (int)$input->getOption('diagnosis-id');
$only = $onlyId > 0 ? $onlyId : null;
if ($only !== null) {
$output->writeln("同步诊单 ID={$only} ...");
} else {
$output->writeln('同步 IM 归档:since-days=' . ($sinceDays > 0 ? $sinceDays : '不限制') . ", limit={$limit}");
}
$stats = DiagnosisLogic::syncImChatArchiveBatch($sinceDays, $limit, $only);
$output->writeln("处理诊单数: {$stats['diagnoses']},新插入行数(INSERT IGNORE 成功数): {$stats['inserted']}");
if (!empty($stats['errors'])) {
foreach ($stats['errors'] as $e) {
$output->writeln("<error>{$e}</error>");
Log::error('sync_im_chat_archive: ' . $e);
}
}
}
}
@@ -0,0 +1,98 @@
<?php
declare(strict_types=1);
namespace app\common\command;
use app\common\model\doctor\Medicine;
use app\common\service\doctor\MedicineNameAbbrService;
use think\console\Command;
use think\console\Input;
use think\console\input\Option;
use think\console\Output;
/**
* 批量回填药材库 name_pinyin_abbr(拼音首字母,供检索)
*
* 用法:
* php think sync_medicine_pinyin_abbr # 仅更新 abbr 为空的记录
* php think sync_medicine_pinyin_abbr --all # 全部按当前名称重算
* php think sync_medicine_pinyin_abbr --dry # 只打印将更新多少条,不写库
*/
class SyncMedicinePinyinAbbr extends Command
{
protected function configure()
{
$this->setName('sync_medicine_pinyin_abbr')
->setDescription('回填/刷新药材表 name_pinyin_abbr(需已执行迁移并 composer 安装 overtrue/pinyin')
->addOption('all', null, Option::VALUE_NONE, '重算全部记录(默认只处理 abbr 为空的)')
->addOption('dry', null, Option::VALUE_NONE, '仅统计/预览,不写入数据库');
}
protected function execute(Input $input, Output $output)
{
if (!class_exists(\Overtrue\Pinyin\Pinyin::class)) {
$output->writeln('<error>未检测到 overtrue/pinyin,请在 server 目录执行: composer update</error>');
return 1;
}
$all = (bool) $input->getOption('all');
$dry = (bool) $input->getOption('dry');
$makeQuery = function () use ($all) {
$q = Medicine::order('id', 'asc');
if (!$all) {
$q->where('name_pinyin_abbr', '');
}
return $q;
};
$total = $makeQuery()->count();
if ($total === 0) {
$output->writeln('没有需要处理的记录。');
return 0;
}
$output->writeln(($all ? '模式: 全部重算' : '模式: 仅空 abbr') . ",待处理 {$total}" . ($dry ? 'dry-run' : ''));
$updated = 0;
$skipped = 0;
$makeQuery()->chunk(200, function ($rows) use (&$updated, &$skipped, $dry) {
foreach ($rows as $row) {
$data = $row instanceof \think\Model ? $row->toArray() : (array) $row;
$id = (int) ($data['id'] ?? 0);
$name = trim((string) ($data['name'] ?? ''));
if ($id <= 0 || $name === '') {
++$skipped;
continue;
}
$abbr = MedicineNameAbbrService::build($name);
if ($abbr === '') {
++$skipped;
continue;
}
if ($dry) {
++$updated;
continue;
}
Medicine::where('id', $id)->update([
'name_pinyin_abbr' => $abbr,
'update_time' => time(),
]);
++$updated;
}
});
if ($dry) {
$output->writeln("[预览] 将写入/覆盖拼音首字母: {$updated} 条,跳过(空名或无法生成): {$skipped}");
} else {
$output->writeln("完成: 已更新 {$updated} 条,跳过 {$skipped} 条。");
}
return 0;
}
}
@@ -0,0 +1,31 @@
<?php
namespace app\common\model\doctor;
use app\common\model\BaseModel;
/**
* 药品库模型
*/
class Medicine extends BaseModel
{
protected $name = 'doctor_medicine';
// 设置字段信息
protected $schema = [
'id' => 'int',
'name' => 'string',
'name_pinyin_abbr' => 'string',
'supplier' => 'string',
'unit' => 'string',
'settlement_price' => 'float',
'retail_price' => 'float',
'stock' => 'int',
'image' => 'string',
'status' => 'int',
'remark' => 'string',
'create_time' => 'int',
'update_time' => 'int',
'delete_time' => 'int',
];
}
+16 -1
View File
@@ -47,7 +47,22 @@ class Roster extends BaseModel
*/
public function getPeriodDescAttr($value, $data)
{
return $data['period'] == 'morning' ? '上午' : '下午';
$st = $data['start_time'] ?? '';
$et = $data['end_time'] ?? '';
if ($st !== '' && $et !== '') {
return $st . '-' . $et;
}
if (($data['period'] ?? '') === 'morning') {
return '上午';
}
if (($data['period'] ?? '') === 'afternoon') {
return '下午';
}
if (($data['period'] ?? '') === 'night') {
return '夜班';
}
return '时段';
}
/**
@@ -0,0 +1,17 @@
<?php
namespace app\common\model\tcm;
use app\common\model\BaseModel;
/**
* 诊单 IM 聊天记录归档
*/
class ImChatMessage extends BaseModel
{
protected $name = 'tcm_im_chat_message';
protected $autoWriteTimestamp = false;
protected $dateFormat = false;
}
@@ -0,0 +1,25 @@
<?php
namespace app\common\service\doctor;
use Overtrue\Pinyin\Pinyin;
/**
* 药材名称 → 拼音首字母连写(小写),供列表检索;未安装 overtrue/pinyin 时返回空字符串。
*/
class MedicineNameAbbrService
{
public static function build(string $name): string
{
$name = trim($name);
if ($name === '') {
return '';
}
if (!class_exists(Pinyin::class)) {
return '';
}
$abbr = (string) Pinyin::abbr($name)->join('');
return strtolower($abbr);
}
}
@@ -0,0 +1,87 @@
<?php
namespace app\common\service\doctor;
/**
* 排班时段:起止时间 + 号源间隔
*/
class RosterSegmentService
{
/**
* 从排班记录解析 [开始 HH:mm, 结束 HH:mm],无效返回 null
*/
public static function resolveWindow(array $roster): ?array
{
$start = isset($roster['start_time']) ? trim((string) $roster['start_time']) : '';
$end = isset($roster['end_time']) ? trim((string) $roster['end_time']) : '';
if ($start !== '' && $end !== ''
&& preg_match('/^\d{2}:\d{2}$/', $start)
&& preg_match('/^\d{2}:\d{2}$/', $end)
&& $start < $end) {
return [$start, $end];
}
$p = $roster['period'] ?? '';
if ($p == 1 || $p === 'morning' || $p === '上午') {
return ['09:00', '12:00'];
}
if ($p == 2 || $p === 'afternoon' || $p === '下午') {
return ['14:00', '18:00'];
}
if ($p === 'night' || $p === '夜班' || $p === 'evening') {
return ['18:00', '22:00'];
}
return null;
}
public static function normalizeSlotMinutes($raw): int
{
$n = (int) $raw;
if ($n < 5) {
return 15;
}
if ($n > 120) {
return 120;
}
return $n;
}
/**
* 左闭右开 [start, end) 按 slotMinutes 切分,得到每个号的开始时刻 HH:mm
*/
public static function generateSlotTimes(string $start, string $end, int $slotMinutes): array
{
$slotMinutes = self::normalizeSlotMinutes($slotMinutes);
$base = '1970-01-01 ';
$cur = strtotime($base . $start . ':00');
$endTs = strtotime($base . $end . ':00');
if ($cur === false || $endTs === false || $endTs <= $cur) {
return [];
}
$times = [];
$step = $slotMinutes * 60;
while ($cur < $endTs) {
$times[] = date('H:i', $cur);
$cur += $step;
}
return $times;
}
/**
* quota>0 时限制最大可约槽位数;quota=0 表示不限制(由时段长度决定)
*/
public static function applyQuotaCap(array $times, int $quota): array
{
if ($quota > 0 && count($times) > $quota) {
return array_slice($times, 0, $quota);
}
return $times;
}
}