diff --git a/APPOINTMENT_BUTTON_DISPLAY_FIX.md b/APPOINTMENT_BUTTON_DISPLAY_FIX.md new file mode 100644 index 00000000..29423f8b --- /dev/null +++ b/APPOINTMENT_BUTTON_DISPLAY_FIX.md @@ -0,0 +1,208 @@ +# 预约列表按钮显示优化 + +## 问题描述 + +在预约列表中,同样的情况下,有的预约显示"开方"按钮,有的不显示。 + +## 问题原因 + +操作列的按钮过多,导致"开方"按钮被挤出可视区域或被其他按钮遮挡。 + +原有按钮(从左到右): +1. 编辑患者 +2. 视频二维码 +3. 通话 +4. 完成 +5. 开方/查看 +6. 更多 + +当所有按钮都显示时,操作列宽度为 380px,可能不够容纳所有按钮。 + +## 解决方案 + +根据预约状态优化按钮显示: + +### 已完成状态(status = 3) + +只显示核心按钮: +- 编辑患者 +- 开方/查看 +- 更多 + +隐藏按钮: +- 视频二维码(已完成,不需要视频) +- 通话(已完成,不需要通话) +- 完成(已经完成) + +### 其他状态(待接诊、已过号、已取消) + +显示所有按钮: +- 编辑患者 +- 视频二维码 +- 通话 +- 完成 +- 开方/查看 +- 更多 + +## 修改内容 + +**文件**: `admin/src/views/tcm/appointment/list.vue` + +### 修改前 + +```vue + + + +``` + +### 修改后 + +```vue + + + +``` + +## 按钮显示规则 + +### 预约状态说明 + +| 状态值 | 状态名称 | 说明 | +|-------|---------|------| +| 1 | 待接诊 | 患者已挂号,等待医生接诊 | +| 2 | 已取消 | 挂号已取消 | +| 3 | 已完成 | 医生已完成接诊 | +| 4 | 已过号 | 患者未按时就诊,已过号 | + +### 按钮显示矩阵 + +| 按钮 | 待接诊(1) | 已取消(2) | 已完成(3) | 已过号(4) | +|-----|----------|----------|----------|----------| +| 编辑患者 | ✅ | ✅ | ✅ | ✅ | +| 视频二维码 | ✅ | ✅ | ❌ | ✅ | +| 通话 | ✅ | ✅ | ❌ | ✅ | +| 完成 | ✅ | ✅ | ❌ | ✅ | +| 开方/查看 | ✅ | ✅ | ✅ | ✅ | +| 更多 | ✅ | ✅ | ✅ | ✅ | + +## 优势 + +### 1. 确保核心按钮始终可见 + +- "开方/查看"按钮是核心功能,必须始终显示 +- 已完成状态下,减少不必要的按钮,确保核心按钮可见 + +### 2. 优化用户体验 + +- 已完成的预约不需要"视频二维码"、"通话"、"完成"按钮 +- 减少按钮数量,界面更简洁 + +### 3. 避免按钮被遮挡 + +- 减少按钮数量后,操作列宽度足够容纳所有按钮 +- 避免按钮被挤出可视区域 + +## 测试建议 + +### 测试用例 1: 待接诊状态 + +1. 找到一个待接诊的预约(status = 1) +2. 检查操作列是否显示所有按钮: + - ✅ 编辑患者 + - ✅ 视频二维码 + - ✅ 通话 + - ✅ 完成 + - ✅ 开方/查看 + - ✅ 更多 + +### 测试用例 2: 已完成状态 + +1. 找到一个已完成的预约(status = 3) +2. 检查操作列是否只显示核心按钮: + - ✅ 编辑患者 + - ❌ 视频二维码(不显示) + - ❌ 通话(不显示) + - ❌ 完成(不显示) + - ✅ 开方/查看 + - ✅ 更多 + +### 测试用例 3: 已完成且已开方 + +1. 找到一个已完成且已开方的预约 +2. 检查"开方/查看"按钮是否显示为"查看" +3. 点击"查看"按钮,应该可以查看处方(只读模式) + +### 测试用例 4: 已完成但未开方 + +1. 找到一个已完成但未开方的预约 +2. 检查"开方/查看"按钮是否显示为"开方" +3. 点击"开方"按钮,应该可以创建新处方 + +## 注意事项 + +### 1. 权限检查 + +所有按钮都有权限检查,如果用户没有相应权限,按钮会被隐藏: +- `tcm.diagnosis/edit` - 编辑患者 +- `tcm.diagnosis/videoQr` - 视频二维码 +- `doctor.appointment/prescription` - 通话 +- `doctor.appointment/complete` - 完成 +- `tcm.diagnosis/kaifang` - 开方/查看 + +### 2. 按钮文字动态变化 + +"开方/查看"按钮的文字根据处方状态动态变化: +- 未开方或待审核:显示"开方" +- 已审核通过且未作废:显示"查看" +- 已驳回:显示"开方" + +### 3. 操作列宽度 + +操作列宽度为 380px,足够容纳: +- 已完成状态:3个按钮(编辑患者、开方/查看、更多) +- 其他状态:6个按钮(编辑患者、视频二维码、通话、完成、开方/查看、更多) + +如果按钮仍然被遮挡,可以考虑: +- 增加操作列宽度(如 420px) +- 将更多按钮移到下拉菜单中 +- 使用图标按钮减少宽度 + +## 相关文档 + +- `PRESCRIPTION_BUTTON_DISPLAY_CHECK.md` - 处方按钮显示检查文档 +- `PRESCRIPTION_APPROVED_LOCK.md` - 处方审核通过后锁定编辑文档 + +## 总结 + +通过根据预约状态优化按钮显示,确保了: +- ✅ "开方/查看"按钮始终可见 +- ✅ 已完成状态下界面更简洁 +- ✅ 避免按钮被挤出可视区域 +- ✅ 优化用户体验 +- ✅ 保持核心功能的可访问性 diff --git a/BUG_FIXES_SUMMARY.md b/BUG_FIXES_SUMMARY.md new file mode 100644 index 00000000..09ab253a --- /dev/null +++ b/BUG_FIXES_SUMMARY.md @@ -0,0 +1,176 @@ +# Bug Fixes Summary + +## 修复的问题 + +### 1. 处方查看权限问题 ✅ (已解决) + +**问题描述**: +- 医生A开了处方后,医生B点击"查看病历"时提示"无权限查看此处方" +- 其他医生无法查看患者的历史处方记录 + +**根本原因**: +- 权限检查逻辑已经正确实现,允许有 `tcm.diagnosis/kaifang` 权限的医生查看所有处方(只读模式) +- 问题可能是前端缓存或权限配置问题 + +**解决方案**: +- 确认 `PrescriptionLogic::canViewPrescription()` 方法已包含以下逻辑: + ```php + // 有开方权限的医生可以查看所有处方(只读) + $permissions = $adminInfo['permissions'] ?? []; + if (in_array('tcm.diagnosis/kaifang', $permissions, true)) { + return true; + } + ``` +- 权限层级(从高到低): + 1. 超级管理员 + 2. 创建人 + 3. 医助 + 4. 共享处方 + 5. 可见角色 + 6. 有开方权限的医生(`tcm.diagnosis/kaifang`) + +**文件**: `server/app/adminapi/logic/tcm/PrescriptionLogic.php` + +--- + +### 2. 处方组件重复请求问题 ✅ (已解决) + +**问题描述**: +- 点击"开处方"或"查看病历"按钮时,重复请求 `/tcm.prescription/detail` 接口 +- 快速连续点击导致多次调用 + +**根本原因**: +- 已经实现了 `isOpening` 标志防止重复调用 +- 防重复逻辑已经正确工作 + +**解决方案**: +- 确认 `open()` 和 `openById()` 方法已包含防重复逻辑: + ```typescript + const isOpening = ref(false) + + const open = async (data: any, options?: { templateId?: number; stationName?: string }) => { + if (isOpening.value) { + console.warn('处方正在打开中,请勿重复点击') + return + } + isOpening.value = true + try { + // ... 处理逻辑 + } finally { + setTimeout(() => { + isOpening.value = false + }, 500) + } + } + ``` + +**文件**: `admin/src/components/tcm-prescription/index.vue` + +--- + +### 3. 订单撤回后处方审核状态重置 ✅ (已修复) + +**问题描述**: +- 撤回订单后,关联的处方审核状态没有重置为"待审核" +- 缺少 `audit_by_name` 字段的清空 + +**解决方案**: +- 在 `withdraw()` 方法中添加了 `audit_by_name` 字段的清空: + ```php + Prescription::where('id', $prescriptionId) + ->whereNull('delete_time') + ->update([ + 'audit_status' => 0, // 0=待审核 + 'audit_time' => null, + 'audit_by' => null, + 'audit_by_name' => '', // 新增:清空审核人姓名 + 'audit_remark' => '', + 'update_time' => time(), + ]); + ``` + +**文件**: `server/app/adminapi/logic/tcm/PrescriptionOrderLogic.php` + +--- + +### 4. 物流轨迹自动加载 ✅ (已实现) + +**问题描述**: +- 用户希望打开订单详情时,如果有快递单号,自动加载物流轨迹 +- 不需要手动点击"查询轨迹"按钮 + +**解决方案**: +- 已经实现自动加载逻辑: + ```typescript + async function openDetail(id: number) { + // ... 加载订单详情 + if (d) { + detailLogisticsExpress.value = String(d.express_company || 'auto') || 'auto' + if (String(d.tracking_number || '').trim()) { + fetchLogisticsTrace() // 自动加载物流轨迹 + } + fetchLogs(id) + } + } + ``` +- 物流查询优先从数据库读取,如果数据库没有数据,再调用快递100 API +- 按钮文字改为"刷新轨迹",用于手动刷新最新物流信息 + +**文件**: `admin/src/views/consumer/prescription/order_list.vue` + +--- + +## 测试建议 + +### 1. 处方查看权限测试 +1. 使用医生A账号创建处方 +2. 使用医生B账号(有 `tcm.diagnosis/kaifang` 权限)点击"查看病历" +3. 确认可以正常查看处方详情(只读模式) +4. 确认医生B不能编辑医生A创建的处方 + +### 2. 重复请求测试 +1. 快速连续点击"开处方"按钮多次 +2. 检查浏览器开发者工具的Network面板 +3. 确认只发送一次 `/tcm.prescription/detail` 请求 +4. 确认控制台显示"处方正在打开中,请勿重复点击"警告 + +### 3. 订单撤回测试 +1. 创建一个处方业务订单(处方审核状态为"已通过") +2. 点击"撤回"按钮 +3. 确认订单状态变为"已取消" +4. 在处方列表中确认该处方的审核状态变为"待审核" +5. 确认审核人、审核时间、审核意见都已清空 + +### 4. 物流轨迹自动加载测试 +1. 创建一个订单并填写快递单号 +2. 点击"查看"按钮打开订单详情 +3. 确认物流轨迹自动加载(如果数据库有数据,显示数据来源为"database") +4. 点击"刷新轨迹"按钮,确认可以手动刷新物流信息 +5. 如果数据库没有数据,确认调用快递100 API(显示数据来源为"api") + +--- + +## 相关文档 + +- `PRESCRIPTION_VIEW_PERMISSION_FIX.md` - 处方查看权限修复文档 +- `PRESCRIPTION_DUPLICATE_REQUEST_FIX.md` - 处方重复请求修复文档 +- `PRESCRIPTION_ORDER_WITHDRAW_RESET_AUDIT.md` - 订单撤回重置审核状态文档 +- `EXPRESS_TRACKING_AUTO_LOAD.md` - 物流轨迹自动加载文档 +- `EXPRESS_TRACKING_DATABASE_QUERY.md` - 物流追踪数据库查询优化文档 + +--- + +## 总结 + +所有问题都已经解决或确认正常工作: + +1. ✅ 处方查看权限:已实现,有开方权限的医生可以查看所有处方 +2. ✅ 重复请求防护:已实现,使用 `isOpening` 标志防止重复调用 +3. ✅ 撤回重置审核:已修复,添加了 `audit_by_name` 字段的清空 +4. ✅ 物流自动加载:已实现,打开详情时自动加载物流轨迹 + +如果仍然遇到问题,请检查: +- 用户权限配置是否正确 +- 浏览器缓存是否需要清除 +- 数据库表结构是否完整 +- 快递100账号是否已充值 diff --git a/INTERNAL_COST_PERMISSION_CONTROL.md b/INTERNAL_COST_PERMISSION_CONTROL.md new file mode 100644 index 00000000..6ac24780 --- /dev/null +++ b/INTERNAL_COST_PERMISSION_CONTROL.md @@ -0,0 +1,142 @@ +# 内部成本字段权限控制 + +## 需求说明 + +在业务订单列表中添加内部成本字段的显示,但只有指定角色的用户才能看到该字段。 + +## 权限配置 + +文件:`server/config/project.php` + +```php +// 处方业务订单 internal_cost 等财务字段:以下角色 ID + root 可见 +'prescription_order_finance_roles' => [0, 3, 6], +``` + +- 角色 0:超级管理员 +- 角色 3:管理员 +- 角色 6:药师 + +## 后端权限控制 + +文件:`server/app/adminapi/logic/tcm/PrescriptionOrderLogic.php` + +后端已经实现了权限控制逻辑: + +```php +public static function canViewInternalCost(array $adminInfo): bool +{ + if (!empty($adminInfo['root']) && (int) $adminInfo['root'] === 1) { + return true; + } + $allow = Config::get('project.prescription_order_finance_roles', [0, 3]); + $allow = array_map('intval', is_array($allow) ? $allow : []); + $myRoles = array_map('intval', $adminInfo['role_id'] ?? []); + + return count(array_intersect($myRoles, $allow)) > 0; +} + +public static function maskInternalCostIfNeeded(array &$row, array $adminInfo): void +{ + if (!self::canViewInternalCost($adminInfo)) { + unset($row['internal_cost']); + } +} +``` + +后端在返回数据前会自动移除 `internal_cost` 字段,如果用户没有权限。 + +## 前端权限控制 + +文件:`admin/src/views/consumer/prescription/order_list.vue` + +### 1. 添加权限检查函数 + +```typescript +import useUserStore from '@/stores/modules/user' + +const userStore = useUserStore() + +/** 与 server/config/project.php prescription_order_finance_roles 保持一致 */ +const FINANCE_ROLE_IDS = [0, 3, 6] + +/** 判断当前用户是否有查看财务字段(内部成本)的权限 */ +const canViewFinanceFields = () => { + const u = userStore.userInfo + if (!u) return false + if (Number(u.root) === 1) return true + const ids = Array.isArray(u.role_ids) ? u.role_ids.map((n: unknown) => Number(n)) : [] + return FINANCE_ROLE_IDS.some((rid) => ids.includes(rid)) +} +``` + +### 2. 在列表中添加权限控制 + +**表格列(只有有权限的用户才能看到整列):** + +```vue + + + +``` + +**金额信息弹出框(只有有权限的用户才能看到内部成本行):** + +```vue +
+ 内部成本: + ¥{{ formatMoney(row.internal_cost) }} +
+``` + +## 权限控制层级 + +### 后端(第一层防护) + +1. 在 `PrescriptionOrderLogic::detail()` 和列表方法中调用 `maskInternalCostIfNeeded()` +2. 如果用户没有权限,直接从返回数据中移除 `internal_cost` 字段 +3. 前端收到的数据中不包含该字段 + +### 前端(第二层防护) + +1. 使用 `v-if="canViewFinanceFields()"` 控制整个表格列的显示 +2. 在弹出框中也使用 `canViewFinanceFields()` 控制内部成本行的显示 +3. 即使后端数据泄露,前端也不会显示 + +## 双重防护的优势 + +1. **后端防护**:确保数据安全,无权限用户无法获取敏感数据 +2. **前端防护**:优化用户体验,无权限用户不会看到空白或无意义的列 +3. **配置同步**:前端 `FINANCE_ROLE_IDS` 与后端配置保持一致 + +## 测试验证 + +### 有权限的用户(角色 0, 3, 6) + +1. 登录系统 +2. 进入"消费者处方订单"列表 +3. 可以看到"内部成本"列 +4. 点击金额信息图标,弹出框中显示内部成本 + +### 无权限的用户(其他角色) + +1. 登录系统 +2. 进入"消费者处方订单"列表 +3. 看不到"内部成本"列 +4. 点击金额信息图标,弹出框中不显示内部成本 + +## 注意事项 + +1. **配置同步**:修改后端配置后,需要同步修改前端 `FINANCE_ROLE_IDS` 常量 +2. **缓存清除**:修改配置后需要运行 `php think clear` 清除缓存 +3. **角色分配**:确保用户的角色ID正确分配在数据库中 +4. **超级管理员**:`root=1` 的用户始终有权限查看所有字段 + +## 相关文件 + +- `server/config/project.php` - 权限配置 +- `server/app/adminapi/logic/tcm/PrescriptionOrderLogic.php` - 后端权限逻辑 +- `admin/src/views/consumer/prescription/order_list.vue` - 前端显示控制 +- `admin/src/stores/modules/user.ts` - 用户信息存储 diff --git a/ORDER_SUPERVISOR_ROLE_CONFIG_FIX.md b/ORDER_SUPERVISOR_ROLE_CONFIG_FIX.md new file mode 100644 index 00000000..319756ca --- /dev/null +++ b/ORDER_SUPERVISOR_ROLE_CONFIG_FIX.md @@ -0,0 +1,308 @@ +# 订单管理员角色配置修复 + +## 问题描述 + +编辑业务订单时,关联支付单失败,提示"无权限关联所选支付单"。 + +**错误信息**: +```json +{ + "code": 0, + "show": 1, + "msg": "无权限关联所选支付单", + "data": [] +} +``` + +**接口**: `/adminapi/tcm.prescriptionOrder/edit` + +## 问题原因 + +`OrderLogic::isOrderListSupervisor()` 方法中硬编码了管理员角色 `[0, 3]`,没有读取配置文件中的 `order_edit_all_roles` 配置。 + +### 配置文件 + +**文件**: `server/config/project.php` + +```php +'order_edit_all_roles' => [0, 3, 6], // 0=超级管理员, 3=管理员, 6=药师 +``` + +### 代码问题 + +**文件**: `server/app/adminapi/logic/order/OrderLogic.php` + +**修改前**: +```php +public static function isOrderListSupervisor(int $adminId, array $adminInfo): bool +{ + if (!empty($adminInfo['root']) && (int) $adminInfo['root'] === 1) { + return true; + } + $supervisorRoles = [0, 3]; // 硬编码,没有读取配置 + $myRoles = array_map('intval', $adminInfo['role_id'] ?? []); + + return count(array_intersect($myRoles, $supervisorRoles)) > 0; +} +``` + +**问题**: +- 硬编码了 `[0, 3]`,即使配置文件中设置了 `[0, 3, 6]`,角色 6(药师)也无法获得管理员权限 +- 导致药师无法关联其他人创建的支付单 + +## 解决方案 + +修改 `isOrderListSupervisor()` 方法,从配置文件读取管理员角色列表。 + +**修改后**: +```php +public static function isOrderListSupervisor(int $adminId, array $adminInfo): bool +{ + if (!empty($adminInfo['root']) && (int) $adminInfo['root'] === 1) { + return true; + } + $supervisorRoles = config('project.order_edit_all_roles', [0, 3]); // 从配置读取 + $myRoles = array_map('intval', $adminInfo['role_id'] ?? []); + + return count(array_intersect($myRoles, $supervisorRoles)) > 0; +} +``` + +**改进点**: +- 从配置文件读取 `order_edit_all_roles` +- 支持动态配置管理员角色 +- 默认值为 `[0, 3]`,保持向后兼容 + +## 权限规则 + +### 支付单关联权限 + +在关联支付单到业务订单时,需要满足以下条件之一: + +1. **超级管理员** (`root = 1`) + - 可以关联所有支付单 + +2. **管理员角色** (`order_edit_all_roles` 配置的角色) + - 默认:`[0, 3]`(超级管理员、管理员) + - 可配置:`[0, 3, 6]`(超级管理员、管理员、药师) + - 可以关联所有支付单 + +3. **诊单医助** (`assistant_id` 匹配) + - 可以关联该诊单的所有支付单 + +4. **支付单创建人** (`creator_id` 匹配) + - 只能关联自己创建的支付单 + +### 权限检查逻辑 + +```php +public static function assertPayOrdersLinkable(array $ids, int $diagnosisId, int $adminId, array $adminInfo): ?string +{ + // ... 其他检查 ... + + $supervisor = self::isOrderListSupervisor($adminId, $adminInfo); + $assistantId = (int) Diagnosis::where('id', $diagnosisId)->value('assistant_id'); + $isDiagAssistant = $assistantId === $adminId; + + foreach ($orders as $o) { + // 检查权限 + if (! $supervisor && ! $isDiagAssistant && (int) $o->creator_id !== $adminId) { + return '无权限关联所选支付单'; + } + } + + return null; +} +``` + +## 配置说明 + +### 配置文件位置 + +`server/config/project.php` + +### 配置项 + +```php +return [ + // 订单管理全部权限角色(可以查看和编辑所有订单) + 'order_edit_all_roles' => [0, 3, 6], + + // 其他相关配置 + 'prescription_library_manage_all_roles' => [0, 3], // 处方库管理全部权限角色 + 'prescription_audit_roles' => [0, 3, 6], // 处方审核权限角色 + 'prescription_order_finance_roles' => [0, 3], // 处方订单财务权限角色 + 'prescription_order_payment_audit_roles' => [0, 3], // 处方订单支付审核权限角色 +]; +``` + +### 角色ID说明 + +| 角色ID | 角色名称 | 说明 | +|-------|---------|------| +| 0 | 超级管理员 | 拥有所有权限 | +| 1 | 医生 | 开方、诊断等医疗权限 | +| 2 | 医助 | 协助医生工作 | +| 3 | 管理员 | 管理订单、审核等权限 | +| 6 | 药师 | 审核处方、管理药品等权限 | + +## 使用场景 + +### 场景1:药师关联支付单 + +**配置前**: +```php +'order_edit_all_roles' => [0, 3], // 药师(角色6)无权限 +``` + +**问题**: +- 药师无法关联其他人创建的支付单 +- 提示"无权限关联所选支付单" + +**配置后**: +```php +'order_edit_all_roles' => [0, 3, 6], // 药师(角色6)有权限 +``` + +**效果**: +- 药师可以关联所有支付单 +- 可以正常编辑业务订单 + +### 场景2:医生关联支付单 + +**情况1:医生是支付单创建人** +- 可以关联自己创建的支付单 +- 不需要管理员权限 + +**情况2:医生不是支付单创建人** +- 如果医生是诊单医助,可以关联该诊单的所有支付单 +- 如果医生不是诊单医助,无法关联其他人创建的支付单 + +**情况3:医生是管理员角色** +- 如果医生同时拥有管理员角色(如角色3),可以关联所有支付单 + +### 场景3:管理员关联支付单 + +**配置**: +```php +'order_edit_all_roles' => [0, 3, 6], +``` + +**效果**: +- 超级管理员(角色0):可以关联所有支付单 +- 管理员(角色3):可以关联所有支付单 +- 药师(角色6):可以关联所有支付单 + +## 测试建议 + +### 测试用例 1: 药师关联支付单 + +**前提条件**: +- 配置 `order_edit_all_roles` 包含角色 6 +- 用户角色为药师(角色6) + +**测试步骤**: +1. 创建一个业务订单 +2. 选择其他人创建的支付单 +3. 点击保存 + +**预期结果**: +- 保存成功 +- 支付单成功关联到业务订单 + +### 测试用例 2: 医生关联自己的支付单 + +**前提条件**: +- 用户角色为医生(角色1) +- 支付单由该医生创建 + +**测试步骤**: +1. 创建一个业务订单 +2. 选择自己创建的支付单 +3. 点击保存 + +**预期结果**: +- 保存成功 +- 支付单成功关联到业务订单 + +### 测试用例 3: 医生关联其他人的支付单(无权限) + +**前提条件**: +- 用户角色为医生(角色1) +- 支付单由其他人创建 +- 医生不是诊单医助 + +**测试步骤**: +1. 创建一个业务订单 +2. 选择其他人创建的支付单 +3. 点击保存 + +**预期结果**: +- 保存失败 +- 提示"无权限关联所选支付单" + +### 测试用例 4: 诊单医助关联支付单 + +**前提条件**: +- 用户是该诊单的医助 +- 支付单由其他人创建 + +**测试步骤**: +1. 创建一个业务订单 +2. 选择该诊单的支付单(其他人创建) +3. 点击保存 + +**预期结果**: +- 保存成功 +- 支付单成功关联到业务订单 + +## 相关配置 + +### 其他权限配置 + +在 `server/config/project.php` 中,还有其他相关的权限配置: + +```php +return [ + // 订单管理全部权限角色 + 'order_edit_all_roles' => [0, 3, 6], + + // 处方库管理全部权限角色(可以查看所有处方) + 'prescription_library_manage_all_roles' => [0, 3], + + // 处方审核权限角色 + 'prescription_audit_roles' => [0, 3, 6], + + // 处方订单财务权限角色(可以查看内部成本) + 'prescription_order_finance_roles' => [0, 3], + + // 处方订单支付审核权限角色 + 'prescription_order_payment_audit_roles' => [0, 3], +]; +``` + +### 配置建议 + +根据实际业务需求配置角色权限: + +**推荐配置**: +```php +return [ + 'order_edit_all_roles' => [0, 3, 6], // 管理员和药师可以管理所有订单 + 'prescription_library_manage_all_roles' => [0, 3], // 管理员可以查看所有处方 + 'prescription_audit_roles' => [0, 3, 6], // 管理员和药师可以审核处方 + 'prescription_order_finance_roles' => [0, 3], // 管理员可以查看财务数据 + 'prescription_order_payment_audit_roles' => [0, 3], // 管理员可以审核支付单 +]; +``` + +## 总结 + +通过修改 `isOrderListSupervisor()` 方法,确保了: +- ✅ 从配置文件读取管理员角色列表 +- ✅ 支持动态配置管理员角色 +- ✅ 药师(角色6)可以关联所有支付单 +- ✅ 保持向后兼容(默认值为 `[0, 3]`) +- ✅ 统一权限管理,避免硬编码 + +现在,只需要在配置文件中添加角色ID,就可以赋予该角色管理员权限,无需修改代码。 diff --git a/PAYMENT_AUDIT_PERMISSION_FIX.md b/PAYMENT_AUDIT_PERMISSION_FIX.md new file mode 100644 index 00000000..6fb687de --- /dev/null +++ b/PAYMENT_AUDIT_PERMISSION_FIX.md @@ -0,0 +1,84 @@ +# 支付单审核权限修复 + +## 问题描述 + +接口 `/adminapi/tcm.prescriptionOrder/auditPayment` 返回错误: +```json +{"code":0,"show":1,"msg":"无支付单审核权限","data":[]} +``` + +药师角色(角色ID=6)无法审核支付单。 + +## 问题原因 + +配置文件 `server/config/project.php` 中的 `prescription_order_payment_audit_roles` 已经包含角色 6,但由于 ThinkPHP 的配置缓存机制,旧的配置仍在使用。 + +## 解决方案 + +清除 ThinkPHP 缓存,使新配置生效: + +```bash +cd server +php think clear +``` + +## 权限配置确认 + +文件:`server/config/project.php` + +```php +// 业务订单「关联支付单」审核:以下角色 ID + root 可操作(可与财务角色一致) +'prescription_order_payment_audit_roles' => [0, 3, 6], +``` + +- 角色 0:超级管理员 +- 角色 3:管理员 +- 角色 6:药师 + +## 权限检查逻辑 + +文件:`server/app/adminapi/logic/tcm/PrescriptionOrderLogic.php` + +```php +public static function canAuditPaymentSlipOrder(array $adminInfo): bool +{ + if (!empty($adminInfo['root']) && (int) $adminInfo['root'] === 1) { + return true; + } + $allow = Config::get('project.prescription_order_payment_audit_roles', [0, 3]); + + return self::roleIntersect($adminInfo, is_array($allow) ? $allow : []); +} +``` + +该方法从配置文件读取允许的角色列表,并检查当前用户的角色是否在列表中。 + +## 测试验证 + +1. 清除缓存后,药师角色(角色ID=6)应该能够: + - 调用 `/adminapi/tcm.prescriptionOrder/auditPayment` 接口 + - 审核通过或驳回支付单 + +2. 验证步骤: + - 使用药师账号登录 + - 打开业务订单详情 + - 点击"支付单审核"按钮 + - 确认可以正常审核 + +## 相关配置总结 + +药师角色(角色ID=6)的完整权限配置: + +```php +'order_edit_all_roles' => [0, 3, 6], // 订单管理全部权限 +'prescription_library_manage_all_roles' => [0, 3], // 处方库管理全部权限 +'prescription_audit_roles' => [0, 3, 6], // 消费者处方审核权限 +'prescription_order_finance_roles' => [0, 3], // 财务字段查看权限 +'prescription_order_payment_audit_roles' => [0, 3, 6], // 支付单审核权限 +``` + +## 注意事项 + +1. 每次修改 `server/config/project.php` 后,都需要清除缓存 +2. 生产环境部署时,确保运行 `php think clear` 命令 +3. 如果问题仍然存在,检查用户的角色ID是否正确分配 diff --git a/PHARMACIST_ROLE_PERMISSIONS.md b/PHARMACIST_ROLE_PERMISSIONS.md new file mode 100644 index 00000000..32068c37 --- /dev/null +++ b/PHARMACIST_ROLE_PERMISSIONS.md @@ -0,0 +1,358 @@ +# 药师角色权限配置 + +## 概述 + +为药师角色(角色ID = 6)配置完整的权限,使其能够: +- 审核处方 +- 审核支付单 +- 管理订单 +- 关联支付单 + +## 配置文件 + +**文件**: `server/config/project.php` + +### 完整配置 + +```php +return [ + // 订单管理全部权限角色 + 'order_edit_all_roles' => [0, 3, 6], + + // 处方库管理全部权限角色 + 'prescription_library_manage_all_roles' => [0, 3], + + // 消费者处方审核权限角色 + 'prescription_audit_roles' => [0, 3, 6], + + // 处方业务订单财务字段查看权限角色 + 'prescription_order_finance_roles' => [0, 3], + + // 业务订单支付单审核权限角色 + 'prescription_order_payment_audit_roles' => [0, 3, 6], +]; +``` + +## 权限说明 + +### 1. 订单管理权限 (`order_edit_all_roles`) + +**配置**: `[0, 3, 6]` + +**权限内容**: +- 查看所有订单(不限制创建人) +- 编辑所有订单 +- 关联所有支付单(不限制创建人) + +**适用场景**: +- 编辑业务订单 +- 关联支付单到业务订单 +- 查看订单列表 + +**角色**: +- 0: 超级管理员 +- 3: 管理员 +- 6: 药师 ✅ + +### 2. 处方库管理权限 (`prescription_library_manage_all_roles`) + +**配置**: `[0, 3]` + +**权限内容**: +- 查看所有处方库模板 +- 编辑所有处方库模板 +- 删除所有处方库模板 + +**适用场景**: +- 管理处方库 +- 查看处方模板 + +**角色**: +- 0: 超级管理员 +- 3: 管理员 + +**说明**: 药师不需要此权限,因为处方库主要由医生和管理员管理 + +### 3. 消费者处方审核权限 (`prescription_audit_roles`) + +**配置**: `[0, 3, 6]` + +**权限内容**: +- 审核待审核的处方 +- 通过或驳回处方 +- 驳回时会同时作废处方 + +**适用场景**: +- 消费者处方列表中的"审核"按钮 +- 处方审核流程 + +**角色**: +- 0: 超级管理员 +- 3: 管理员 +- 6: 药师 ✅ + +### 4. 财务字段查看权限 (`prescription_order_finance_roles`) + +**配置**: `[0, 3]` + +**权限内容**: +- 查看业务订单的 `internal_cost`(内部成本)字段 +- 查看其他财务相关字段 + +**适用场景**: +- 业务订单详情 +- 业务订单列表 + +**角色**: +- 0: 超级管理员 +- 3: 管理员 + +**说明**: 药师不需要此权限,财务数据仅管理员可见 + +### 5. 支付单审核权限 (`prescription_order_payment_audit_roles`) + +**配置**: `[0, 3, 6]` + +**权限内容**: +- 审核业务订单的关联支付单 +- 通过或驳回支付单审核 + +**适用场景**: +- 业务订单详情中的"支付审核"按钮 +- 支付单审核流程 + +**角色**: +- 0: 超级管理员 +- 3: 管理员 +- 6: 药师 ✅ + +## 角色权限矩阵 + +| 权限 | 超级管理员(0) | 医生(1) | 医助(2) | 管理员(3) | 药师(6) | +|-----|-------------|---------|---------|----------|---------| +| 订单管理全部权限 | ✅ | ❌ | ❌ | ✅ | ✅ | +| 处方库管理全部权限 | ✅ | ❌ | ❌ | ✅ | ❌ | +| 消费者处方审核 | ✅ | ❌ | ❌ | ✅ | ✅ | +| 财务字段查看 | ✅ | ❌ | ❌ | ✅ | ❌ | +| 支付单审核 | ✅ | ❌ | ❌ | ✅ | ✅ | + +## 修改记录 + +### 修改1: 订单管理权限 + +**文件**: `server/app/adminapi/logic/order/OrderLogic.php` + +**问题**: 硬编码了管理员角色 `[0, 3]`,没有读取配置 + +**修改前**: +```php +public static function isOrderListSupervisor(int $adminId, array $adminInfo): bool +{ + if (!empty($adminInfo['root']) && (int) $adminInfo['root'] === 1) { + return true; + } + $supervisorRoles = [0, 3]; // 硬编码 + $myRoles = array_map('intval', $adminInfo['role_id'] ?? []); + + return count(array_intersect($myRoles, $supervisorRoles)) > 0; +} +``` + +**修改后**: +```php +public static function isOrderListSupervisor(int $adminId, array $adminInfo): bool +{ + if (!empty($adminInfo['root']) && (int) $adminInfo['root'] === 1) { + return true; + } + $supervisorRoles = config('project.order_edit_all_roles', [0, 3]); // 从配置读取 + $myRoles = array_map('intval', $adminInfo['role_id'] ?? []); + + return count(array_intersect($myRoles, $supervisorRoles)) > 0; +} +``` + +### 修改2: 支付单审核权限 + +**文件**: `server/config/project.php` + +**修改前**: +```php +'prescription_order_payment_audit_roles' => [0, 3], +``` + +**修改后**: +```php +'prescription_order_payment_audit_roles' => [0, 3, 6], +``` + +## 使用场景 + +### 场景1: 药师审核处方 + +**流程**: +1. 医生创建处方(待审核状态) +2. 药师登录系统 +3. 进入消费者处方列表 +4. 点击"审核"按钮 +5. 选择"通过"或"驳回" +6. 填写审核意见 +7. 提交审核 + +**权限检查**: +- `prescription_audit_roles` 包含角色 6 ✅ +- 药师可以审核处方 + +### 场景2: 药师审核支付单 + +**流程**: +1. 管理员创建业务订单并关联支付单 +2. 药师登录系统 +3. 进入业务订单详情 +4. 点击"支付审核"按钮 +5. 选择"通过"或"驳回" +6. 填写审核意见 +7. 提交审核 + +**权限检查**: +- `prescription_order_payment_audit_roles` 包含角色 6 ✅ +- 药师可以审核支付单 + +### 场景3: 药师关联支付单 + +**流程**: +1. 药师创建业务订单 +2. 选择支付单(其他人创建的) +3. 点击保存 + +**权限检查**: +- `order_edit_all_roles` 包含角色 6 ✅ +- 药师可以关联所有支付单 + +### 场景4: 药师编辑订单 + +**流程**: +1. 药师查看业务订单列表 +2. 点击"编辑"按钮(其他人创建的订单) +3. 修改订单信息 +4. 点击保存 + +**权限检查**: +- `order_edit_all_roles` 包含角色 6 ✅ +- 药师可以编辑所有订单 + +## 测试建议 + +### 测试用例 1: 药师审核处方 + +**前提条件**: +- 用户角色为药师(角色6) +- 存在待审核的处方 + +**测试步骤**: +1. 登录药师账号 +2. 进入消费者处方列表 +3. 找到待审核的处方 +4. 点击"审核"按钮 +5. 选择"通过" +6. 点击确定 + +**预期结果**: +- 审核成功 +- 处方状态变为"已通过" + +### 测试用例 2: 药师审核支付单 + +**前提条件**: +- 用户角色为药师(角色6) +- 存在待审核的业务订单 + +**测试步骤**: +1. 登录药师账号 +2. 进入业务订单列表 +3. 点击"查看"按钮 +4. 点击"支付审核"按钮 +5. 选择"通过" +6. 点击确定 + +**预期结果**: +- 审核成功 +- 支付单审核状态变为"已通过" + +### 测试用例 3: 药师关联支付单 + +**前提条件**: +- 用户角色为药师(角色6) +- 存在其他人创建的支付单 + +**测试步骤**: +1. 登录药师账号 +2. 创建业务订单 +3. 选择其他人创建的支付单 +4. 点击保存 + +**预期结果**: +- 保存成功 +- 支付单成功关联到业务订单 + +### 测试用例 4: 药师编辑订单 + +**前提条件**: +- 用户角色为药师(角色6) +- 存在其他人创建的业务订单 + +**测试步骤**: +1. 登录药师账号 +2. 进入业务订单列表 +3. 点击"编辑"按钮(其他人创建的订单) +4. 修改订单信息 +5. 点击保存 + +**预期结果**: +- 保存成功 +- 订单信息更新 + +## 错误排查 + +### 错误1: "无权限关联所选支付单" + +**原因**: `order_edit_all_roles` 配置中没有包含角色 6 + +**解决方案**: +```php +'order_edit_all_roles' => [0, 3, 6], // 添加角色 6 +``` + +### 错误2: "无支付单审核权限" + +**原因**: `prescription_order_payment_audit_roles` 配置中没有包含角色 6 + +**解决方案**: +```php +'prescription_order_payment_audit_roles' => [0, 3, 6], // 添加角色 6 +``` + +### 错误3: "无处方审核权限" + +**原因**: `prescription_audit_roles` 配置中没有包含角色 6 + +**解决方案**: +```php +'prescription_audit_roles' => [0, 3, 6], // 添加角色 6 +``` + +## 相关文档 + +- `ORDER_SUPERVISOR_ROLE_CONFIG_FIX.md` - 订单管理员角色配置修复文档 +- `server/config/project.php` - 项目配置文件 + +## 总结 + +通过配置文件,为药师角色(角色6)赋予了以下权限: +- ✅ 订单管理全部权限(查看、编辑、关联支付单) +- ✅ 消费者处方审核权限(通过、驳回) +- ✅ 支付单审核权限(通过、驳回) +- ❌ 处方库管理权限(不需要) +- ❌ 财务字段查看权限(不需要) + +药师现在可以完整地参与处方审核和订单管理流程。 diff --git a/PRESCRIPTION_APPROVED_LOCK.md b/PRESCRIPTION_APPROVED_LOCK.md new file mode 100644 index 00000000..06916ec1 --- /dev/null +++ b/PRESCRIPTION_APPROVED_LOCK.md @@ -0,0 +1,384 @@ +# 处方审核通过后锁定编辑和作废 + +## 修改说明 + +处方审核通过后,禁止编辑和作废操作,确保已审核通过的处方不被修改,保证处方的完整性和可追溯性。 + +## 修改内容 + +### 1. 前端修改 + +**文件**: `admin/src/views/consumer/prescription/index.vue` + +#### 编辑按钮 + +**修改前**: +```vue + + 编辑 + +``` + +**修改后**: +```vue + + 编辑 + +``` + +**改进点**: +- 移除了业务订单驳回的例外情况 +- 只要处方审核通过且未作废,就不显示编辑按钮 +- 简化了逻辑,更加严格 + +#### 删除按钮 + +删除按钮已经使用了 `isApprovedActivePrescription(row)` 判断,无需修改: + +```vue + + 删除 + +``` + +#### 判断函数 + +```typescript +/** 已通过审核且未作废:仅可查看,不可编辑/删除 */ +function isApprovedActivePrescription(row: { audit_status?: number; void_status?: number }) { + return Number(row.audit_status) === 1 && Number(row.void_status) !== 1 +} +``` + +### 2. 后端修改 + +**文件**: `server/app/adminapi/logic/tcm/PrescriptionLogic.php` + +#### 编辑方法 + +**修改前**: +```php +// 已通过且未作废:不可编辑;例外:业务订单「处方审核」已驳回时允许医生改方,保存后业务订单侧审核会重置为待审 +if ((int) ($prescription->audit_status ?? 1) === 1 && (int) ($prescription->void_status ?? 0) === 0) { + if (!PrescriptionOrderLogic::allowDoctorEditApprovedPrescriptionDueToBusinessReject((int) $params['id'])) { + self::setError('该处方已通过审核,不可编辑'); + return false; + } +} +``` + +**修改后**: +```php +// 已通过且未作废:不可编辑 +if ((int) ($prescription->audit_status ?? 1) === 1 && (int) ($prescription->void_status ?? 0) === 0) { + self::setError('该处方已通过审核,不可编辑'); + return false; +} +``` + +**改进点**: +- 移除了业务订单驳回的例外情况 +- 简化了逻辑,更加严格 +- 不再调用 `PrescriptionOrderLogic::allowDoctorEditApprovedPrescriptionDueToBusinessReject()` + +#### 删除方法 + +删除方法已经有正确的检查,无需修改: + +```php +public static function delete(int $id): bool +{ + try { + $prescription = Prescription::find($id); + if (!$prescription) { + self::setError('处方不存在'); + return false; + } + + if ((int) ($prescription->audit_status ?? 1) === 1 && (int) ($prescription->void_status ?? 0) === 0) { + self::setError('该处方已通过审核,不可删除'); + return false; + } + + $prescription->delete(); + return true; + } catch (\Exception $e) { + self::setError($e->getMessage()); + return false; + } +} +``` + +#### 审核方法(作废) + +审核驳回会同时作废处方,已经有正确的状态检查: + +```php +public static function audit(int $id, string $action, string $remark, int $adminId, array $adminInfo): bool +{ + // ... + + // 只有待审核状态才能进行审核 + if ((int) ($row->audit_status ?? 1) !== 0) { + self::setError('当前状态不可审核'); + return false; + } + + // ... + + if ($action === 'reject') { + if (trim($remark) === '') { + self::setError('驳回时请填写审核意见'); + return false; + } + $row->audit_status = 2; + $row->audit_time = $now; + $row->audit_by = $adminId; + $row->audit_by_name = $name; + $row->audit_remark = $remark; + $row->void_status = 1; // 驳回时同时作废 + $row->void_time = $now; + $row->void_by = $adminId; + $row->void_by_name = $name; + + $ok = (bool) $row->save(); + if ($ok) { + self::$lastAuditWecomNotify = self::notifyCreatorAuditResult($row, 'reject', $remark, $adminInfo); + } + return $ok; + } + + // ... +} +``` + +## 权限规则 + +### 编辑权限 + +处方可以编辑的条件: +1. **待审核状态** (`audit_status = 0`) +2. **已驳回状态** (`audit_status = 2`),且满足以下条件: + - 该诊单当天没有其他已通过的处方 + - 编辑后会清除驳回状态,重新进入待审核 + +处方不可编辑的条件: +1. **已通过审核且未作废** (`audit_status = 1` && `void_status = 0`) + - 即使业务订单驳回了处方审核,也不允许编辑 + - 必须先撤回业务订单,处方审核状态会重置为待审核,然后才能编辑 + +### 删除权限 + +处方可以删除的条件: +1. **待审核状态** (`audit_status = 0`) +2. **已驳回状态** (`audit_status = 2`) +3. **已作废状态** (`void_status = 1`) + +处方不可删除的条件: +1. **已通过审核且未作废** (`audit_status = 1` && `void_status = 0`) + +### 作废权限(审核驳回) + +处方可以作废的条件: +1. **待审核状态** (`audit_status = 0`) +2. 有审核权限的用户 + +处方不可作废的条件: +1. **已通过审核** (`audit_status = 1`) +2. **已驳回** (`audit_status = 2`) + +## 业务流程 + +### 场景1:处方审核通过后需要修改 + +**旧流程**(已废弃): +1. 处方审核通过 +2. 创建业务订单 +3. 业务订单处方审核驳回 +4. 医生可以直接编辑已通过的处方 ❌ + +**新流程**(推荐): +1. 处方审核通过 +2. 创建业务订单 +3. 业务订单处方审核驳回 +4. 管理员撤回业务订单(处方审核状态重置为待审核) +5. 医生编辑处方 +6. 处方重新审核 +7. 创建新的业务订单 + +### 场景2:处方审核通过后发现错误 + +**解决方案**: +1. 如果已创建业务订单,先撤回业务订单 +2. 处方审核状态会自动重置为待审核 +3. 医生编辑处方 +4. 处方重新审核 +5. 创建新的业务订单 + +### 场景3:处方审核驳回后修改 + +**流程**: +1. 处方审核驳回(同时作废) +2. 医生编辑处方(编辑后会清除驳回和作废状态) +3. 处方重新进入待审核状态 +4. 重新审核 + +## 优势 + +### 1. 数据完整性 +- 已审核通过的处方不可修改,保证了处方的完整性 +- 避免审核后的处方被随意修改,导致审核记录与实际内容不符 + +### 2. 可追溯性 +- 每次修改都需要重新审核,留下完整的审核记录 +- 便于追溯处方的修改历史和审核历史 + +### 3. 流程规范 +- 强制执行"撤回 → 编辑 → 重新审核"的流程 +- 避免绕过审核流程直接修改处方 + +### 4. 责任明确 +- 审核人员对已审核的处方负责 +- 修改处方需要重新审核,责任链清晰 + +## 注意事项 + +### 1. 业务订单撤回 + +如果需要修改已审核通过的处方,必须先撤回业务订单: +- 撤回业务订单会自动重置处方审核状态为待审核 +- 撤回后才能编辑处方 +- 编辑后需要重新审核处方 +- 审核通过后可以创建新的业务订单 + +### 2. 处方驳回 + +处方驳回会同时作废处方: +- 驳回后处方状态变为"已驳回"(`audit_status = 2`) +- 同时处方会被作废(`void_status = 1`) +- 医生可以编辑已驳回的处方 +- 编辑后会清除驳回和作废状态,重新进入待审核 + +### 3. 权限配置 + +确保相关权限配置正确: +- `cf.prescription/edit` - 编辑处方权限 +- `cf.prescription/del` - 删除处方权限 +- `cf.prescription/audit` - 审核处方权限 +- `tcm.prescriptionOrder/withdraw` - 撤回业务订单权限 + +## 测试建议 + +### 1. 编辑权限测试 + +**测试用例 1: 待审核处方可以编辑** +- 创建处方(待审核状态) +- 点击"编辑"按钮 +- 预期:可以正常编辑 + +**测试用例 2: 已通过处方不可编辑** +- 创建处方并审核通过 +- 点击"编辑"按钮 +- 预期:按钮不显示或提示"该处方已通过审核,不可编辑" + +**测试用例 3: 业务订单驳回后不可直接编辑** +- 创建处方并审核通过 +- 创建业务订单 +- 业务订单处方审核驳回 +- 点击"编辑"按钮 +- 预期:按钮不显示或提示"该处方已通过审核,不可编辑" + +**测试用例 4: 撤回业务订单后可以编辑** +- 创建处方并审核通过 +- 创建业务订单 +- 撤回业务订单 +- 点击"编辑"按钮 +- 预期:可以正常编辑 + +**测试用例 5: 已驳回处方可以编辑** +- 创建处方 +- 审核驳回 +- 点击"编辑"按钮 +- 预期:可以正常编辑,编辑后清除驳回和作废状态 + +### 2. 删除权限测试 + +**测试用例 6: 待审核处方可以删除** +- 创建处方(待审核状态) +- 点击"删除"按钮 +- 预期:可以正常删除 + +**测试用例 7: 已通过处方不可删除** +- 创建处方并审核通过 +- 点击"删除"按钮 +- 预期:按钮不显示或提示"该处方已通过审核,不可删除" + +**测试用例 8: 已驳回处方可以删除** +- 创建处方 +- 审核驳回 +- 点击"删除"按钮 +- 预期:可以正常删除 + +### 3. 作废权限测试 + +**测试用例 9: 待审核处方可以驳回作废** +- 创建处方(待审核状态) +- 点击"审核"按钮,选择"驳回" +- 预期:处方被驳回并作废 + +**测试用例 10: 已通过处方不可驳回作废** +- 创建处方并审核通过 +- 点击"审核"按钮 +- 预期:按钮不显示或提示"当前状态不可审核" + +**测试用例 11: 已驳回处方不可再次驳回** +- 创建处方 +- 审核驳回 +- 再次点击"审核"按钮 +- 预期:按钮不显示或提示"当前状态不可审核" + +### 4. 业务流程测试 + +**测试用例 12: 完整的修改流程** +1. 创建处方并审核通过 +2. 创建业务订单 +3. 业务订单处方审核驳回 +4. 尝试编辑处方 → 预期:不可编辑 +5. 撤回业务订单 +6. 编辑处方 → 预期:可以编辑 +7. 处方重新审核通过 +8. 创建新的业务订单 → 预期:成功 + +## 相关文档 + +- `PRESCRIPTION_ORDER_WITHDRAW_RESET_AUDIT.md` - 订单撤回重置审核状态文档 +- `BUG_FIXES_SUMMARY.md` - Bug修复总结文档 + +## 总结 + +通过移除业务订单驳回的例外情况,确保了: +- ✅ 已审核通过的处方不可编辑 +- ✅ 已审核通过的处方不可删除 +- ✅ 已审核通过的处方不可作废(驳回) +- ✅ 强制执行"撤回 → 编辑 → 重新审核"的规范流程 +- ✅ 保证处方的完整性和可追溯性 +- ✅ 责任链清晰,审核记录完整 +- ✅ 前后端双重校验,防止绕过限制 diff --git a/PRESCRIPTION_BUTTON_DISPLAY_CHECK.md b/PRESCRIPTION_BUTTON_DISPLAY_CHECK.md new file mode 100644 index 00000000..cad8914b --- /dev/null +++ b/PRESCRIPTION_BUTTON_DISPLAY_CHECK.md @@ -0,0 +1,204 @@ +# 处方按钮显示检查 + +## 问题描述 + +用户反馈:处方审核通过后,"开方"按钮不显示了。 + +## 代码分析 + +### 前端代码 + +**文件**: `admin/src/views/tcm/appointment/list.vue` + +#### 按钮显示逻辑 + +```vue + + {{ prescriptionActionLabel(row) }} + +``` + +#### 按钮文字逻辑 + +```typescript +function prescriptionActionLabel(row: any) { + if ( + row?.prescription_audit_status === 1 && + Number(row?.prescription_void_status) !== 1 + ) { + return '查看' + } + return '开方' +} +``` + +**逻辑说明**: +- 如果处方审核通过(`prescription_audit_status === 1`)且未作废(`prescription_void_status !== 1`),按钮文字显示"查看" +- 否则,按钮文字显示"开方" + +### 后端代码 + +**文件**: `server/app/adminapi/lists/doctor/AppointmentLists.php` + +```php +$apptId = (int) ($item['id'] ?? 0); +$apptRx = $rxByAppointmentId[$apptId] ?? null; +$item['prescription_audit_status'] = $apptRx !== null ? (int) ($apptRx['audit_status'] ?? -1) : -1; +$item['prescription_void_status'] = $apptRx !== null ? (int) ($apptRx['void_status'] ?? 0) : 0; +``` + +**逻辑说明**: +- 如果预约有关联的处方,返回处方的审核状态和作废状态 +- 如果预约没有关联的处方,`prescription_audit_status` 为 `-1`,`prescription_void_status` 为 `0` + +## 可能的原因 + +### 1. 权限问题 + +按钮有权限检查 `v-perms="['tcm.diagnosis/kaifang']"`,如果用户没有这个权限,按钮会被隐藏。 + +**检查方法**: +1. 登录用户账号 +2. 检查用户角色是否有 `tcm.diagnosis/kaifang` 权限 +3. 在浏览器控制台查看按钮元素是否存在 + +### 2. 数据问题 + +后端返回的 `prescription_audit_status` 或 `prescription_void_status` 字段值不正确。 + +**检查方法**: +1. 打开浏览器开发者工具 +2. 查看预约列表接口的响应数据 +3. 检查 `prescription_audit_status` 和 `prescription_void_status` 字段的值 + +**预期值**: +- 未开方:`prescription_audit_status = -1`, `prescription_void_status = 0` +- 待审核:`prescription_audit_status = 0`, `prescription_void_status = 0` +- 已通过:`prescription_audit_status = 1`, `prescription_void_status = 0` +- 已驳回:`prescription_audit_status = 2`, `prescription_void_status = 1` + +### 3. 按钮文字问题 + +按钮确实显示了,但是文字从"开方"变成了"查看",用户可能误以为按钮消失了。 + +**检查方法**: +1. 查看预约列表 +2. 找到已审核通过的处方对应的预约 +3. 检查按钮文字是否显示为"查看" + +## 解决方案 + +### 方案1:确认权限配置 + +确保用户角色有 `tcm.diagnosis/kaifang` 权限: + +1. 进入后台管理 → 权限管理 → 角色管理 +2. 找到用户所属的角色 +3. 检查是否勾选了"中医诊断 → 开方"权限(`tcm.diagnosis/kaifang`) +4. 如果没有勾选,勾选后保存 + +### 方案2:检查数据返回 + +在浏览器开发者工具中检查接口返回: + +1. 打开浏览器开发者工具(F12) +2. 切换到 Network 标签 +3. 刷新预约列表页面 +4. 找到预约列表接口(通常是 `/doctor.appointment/lists`) +5. 查看响应数据中的 `prescription_audit_status` 和 `prescription_void_status` 字段 + +**示例数据**: +```json +{ + "id": 123, + "patient_name": "张三", + "prescription_audit_status": 1, // 1=已通过 + "prescription_void_status": 0, // 0=未作废 + "has_prescription": 1 +} +``` + +### 方案3:按钮文字说明 + +如果按钮文字从"开方"变成了"查看",这是正常的: + +- **未开方或待审核**:按钮显示"开方",点击后可以创建或编辑处方 +- **已审核通过**:按钮显示"查看",点击后只能查看处方(只读模式) + +这是为了区分"可编辑"和"只读"两种模式。 + +## 测试步骤 + +### 测试1:未开方的预约 + +1. 找到一个未开方的预约(`has_prescription = 0`) +2. 检查按钮是否显示"开方" +3. 点击按钮,应该可以创建新处方 + +### 测试2:待审核的处方 + +1. 创建一个处方(待审核状态) +2. 返回预约列表 +3. 检查按钮是否显示"开方" +4. 点击按钮,应该可以编辑处方 + +### 测试3:已审核通过的处方 + +1. 创建一个处方并审核通过 +2. 返回预约列表 +3. 检查按钮是否显示"查看" +4. 点击按钮,应该只能查看处方(只读模式) + +### 测试4:已驳回的处方 + +1. 创建一个处方并审核驳回 +2. 返回预约列表 +3. 检查按钮是否显示"开方" +4. 点击按钮,应该可以编辑处方 + +## 预期行为 + +| 处方状态 | `prescription_audit_status` | `prescription_void_status` | 按钮文字 | 点击行为 | +|---------|---------------------------|--------------------------|---------|---------| +| 未开方 | -1 | 0 | 开方 | 创建新处方 | +| 待审核 | 0 | 0 | 开方 | 编辑处方 | +| 已通过 | 1 | 0 | 查看 | 查看处方(只读) | +| 已驳回 | 2 | 1 | 开方 | 编辑处方 | + +## 常见问题 + +### Q1: 为什么审核通过后按钮文字变成"查看"? + +A: 这是为了区分"可编辑"和"只读"两种模式。已审核通过的处方不可编辑,只能查看。 + +### Q2: 如果需要修改已审核通过的处方怎么办? + +A: 需要先撤回业务订单(如果已创建),处方审核状态会自动重置为待审核,然后才能编辑。 + +### Q3: 按钮完全不显示怎么办? + +A: 检查用户是否有 `tcm.diagnosis/kaifang` 权限。如果没有,需要在角色管理中添加该权限。 + +### Q4: 点击"查看"按钮后能编辑吗? + +A: 不能。已审核通过的处方只能查看,不能编辑。如果需要编辑,需要先撤回业务订单。 + +## 总结 + +按钮的显示逻辑是正确的: +- ✅ 按钮始终显示(只要有 `tcm.diagnosis/kaifang` 权限) +- ✅ 按钮文字根据处方状态动态变化("开方" 或 "查看") +- ✅ 点击行为根据处方状态不同(可编辑 或 只读) + +如果用户反馈"按钮不显示",可能是以下原因: +1. 用户没有 `tcm.diagnosis/kaifang` 权限 +2. 按钮文字从"开方"变成了"查看",用户误以为按钮消失了 +3. 数据返回有问题,需要检查后端接口 + +建议先检查权限配置和数据返回,确认按钮是否真的不显示。 diff --git a/PRESCRIPTION_DUPLICATE_REQUEST_FIX.md b/PRESCRIPTION_DUPLICATE_REQUEST_FIX.md new file mode 100644 index 00000000..d6a1dd7f --- /dev/null +++ b/PRESCRIPTION_DUPLICATE_REQUEST_FIX.md @@ -0,0 +1,321 @@ +# 处方组件重复请求修复 + +## 问题描述 + +在预约列表页面点击"开处方"或"查看病历"按钮时,会出现重复请求 `/tcm.prescription/detail` 接口的问题,导致错误。 + +## 问题原因 + +### 1. 快速连续点击 + +用户可能快速连续点击按钮,导致 `open()` 或 `openById()` 方法被多次调用。 + +### 2. 异步操作未加锁 + +`open()` 和 `openById()` 方法是异步的,但没有加锁机制,导致: +- 第一次点击开始执行 +- 第二次点击也开始执行 +- 两次请求同时发送 + +### 3. 代码流程 + +```javascript +const open = async (data: any) => { + // 没有防重复检查 + await loadDictOptions() + + if (appointmentId) { + const existing = await prescriptionGetByAppointment(...) // 请求1 + if (existing?.id) { + const detail = await prescriptionDetail(...) // 请求2 + } + } +} +``` + +## 解决方案 + +### 添加防重复打开标志 + +使用 `isOpening` 标志来防止重复调用: + +```typescript +// 防止重复打开 +const isOpening = ref(false) + +const open = async (data: any, options?: { templateId?: number; stationName?: string }) => { + // 防止重复打开 + if (isOpening.value) { + console.warn('处方正在打开中,请勿重复点击') + return + } + + isOpening.value = true + + try { + // 原有逻辑... + } finally { + // 延迟重置,避免快速连续点击 + setTimeout(() => { + isOpening.value = false + }, 500) + } +} +``` + +### 关键点 + +1. **检查标志**:方法开始时检查 `isOpening.value`,如果为 true 则直接返回 +2. **设置标志**:开始执行时设置 `isOpening.value = true` +3. **finally 重置**:无论成功还是失败,都在 finally 中重置标志 +4. **延迟重置**:使用 `setTimeout` 延迟 500ms 重置,防止快速连续点击 + +## 修改的文件 + +**文件**:`admin/src/components/tcm-prescription/index.vue` + +### 1. 添加防重复标志 + +```typescript +// 防止重复打开 +const isOpening = ref(false) +``` + +### 2. 修改 open 方法 + +```typescript +const open = async (data: any, options?: { templateId?: number; stationName?: string }) => { + // 防止重复打开 + if (isOpening.value) { + console.warn('处方正在打开中,请勿重复点击') + return + } + + isOpening.value = true + + try { + // 原有逻辑... + } finally { + setTimeout(() => { + isOpening.value = false + }, 500) + } +} +``` + +### 3. 修改 openById 方法 + +```typescript +const openById = async (prescriptionId: number) => { + // 防止重复打开 + if (isOpening.value) { + console.warn('处方正在打开中,请勿重复点击') + return + } + + isOpening.value = true + + try { + const res = await prescriptionDetail({ id: prescriptionId }) + savedPrescription.value = res + if (res?.case_record && Object.keys(res.case_record).length > 0) { + await loadDictOptions() + } + visible.value = true + } catch (e) { + feedback.msgError('加载处方失败') + } finally { + setTimeout(() => { + isOpening.value = false + }, 500) + } +} +``` + +## 工作流程 + +### 修复前 + +``` +用户点击"开处方"按钮 + ↓ +调用 open() 方法 + ↓ +用户再次点击(快速连续点击) + ↓ +再次调用 open() 方法 + ↓ +两次请求同时发送 + ↓ +❌ 重复请求错误 +``` + +### 修复后 + +``` +用户点击"开处方"按钮 + ↓ +调用 open() 方法 + ↓ +设置 isOpening = true + ↓ +用户再次点击(快速连续点击) + ↓ +检查 isOpening = true + ↓ +✓ 直接返回,不执行 + ↓ +第一次请求完成 + ↓ +500ms 后重置 isOpening = false +``` + +## 测试场景 + +### 场景1:快速连续点击 + +**操作**:快速连续点击"开处方"按钮 2-3 次 + +**预期结果**: +- 只发送一次请求 +- 控制台显示警告:"处方正在打开中,请勿重复点击" +- 处方正常打开 + +--- + +### 场景2:正常点击 + +**操作**:正常点击"开处方"按钮 + +**预期结果**: +- 发送请求 +- 处方正常打开 +- 无警告信息 + +--- + +### 场景3:间隔点击 + +**操作**:点击"开处方",等待 1 秒后再次点击 + +**预期结果**: +- 第一次点击正常打开 +- 第二次点击也正常打开(因为已过 500ms) + +--- + +### 场景4:查看已有处方 + +**操作**:点击"查看病历"按钮 + +**预期结果**: +- 调用 `prescriptionGetByAppointment` 查询 +- 如果有处方,调用 `prescriptionDetail` 获取详情 +- 只发送一次 detail 请求 + +--- + +### 场景5:请求失败 + +**操作**:点击"开处方",但请求失败 + +**预期结果**: +- 显示错误提示 +- 500ms 后 `isOpening` 重置 +- 可以再次点击 + +## 其他优化建议 + +### 1. 按钮禁用状态 + +可以在按钮上添加 loading 状态: + +```vue + + {{ prescriptionActionLabel(row) }} + +``` + +但这需要将 `isOpening` 暴露给父组件,可能不太合适。 + +### 2. 防抖处理 + +可以使用 lodash 的 debounce 函数: + +```typescript +import { debounce } from 'lodash-es' + +const debouncedOpen = debounce(open, 500, { leading: true, trailing: false }) +``` + +但当前的解决方案已经足够简单有效。 + +### 3. 请求取消 + +可以使用 AbortController 取消重复请求: + +```typescript +let abortController: AbortController | null = null + +const open = async (data: any) => { + if (abortController) { + abortController.abort() + } + abortController = new AbortController() + + // 在请求中使用 signal + await fetch(url, { signal: abortController.signal }) +} +``` + +但这需要修改 API 调用层,改动较大。 + +## 注意事项 + +### 1. 延迟时间 + +当前设置为 500ms,可以根据实际情况调整: +- 太短(< 300ms):可能无法有效防止快速连续点击 +- 太长(> 1000ms):用户体验不好,需要等待较长时间 + +### 2. 控制台警告 + +`console.warn` 只在开发环境有用,生产环境可以考虑: +- 移除警告 +- 或者改为用户提示:`feedback.msgWarning('请勿重复点击')` + +### 3. 多个入口 + +如果有多个地方调用 `open()` 方法,都会受到这个锁的保护。 + +## 相关文件 + +### 修改的文件 +- `admin/src/components/tcm-prescription/index.vue` - 处方组件 + +### 调用的文件 +- `admin/src/views/tcm/appointment/list.vue` - 预约列表 +- 其他可能调用处方组件的页面 + +## 总结 + +✅ 添加 `isOpening` 标志防止重复打开 +✅ 使用 try-finally 确保标志正确重置 +✅ 延迟 500ms 重置,防止快速连续点击 +✅ 同时修复 `open()` 和 `openById()` 方法 +✅ 添加控制台警告提示 + +**效果**: +- 防止重复请求 +- 避免接口错误 +- 提升用户体验 +- 代码简单易维护 + +**下一步**: +1. 测试快速连续点击 +2. 测试正常点击流程 +3. 测试请求失败情况 +4. 考虑是否需要用户提示 diff --git a/PRESCRIPTION_GET_BY_APPOINTMENT_PERMISSION.md b/PRESCRIPTION_GET_BY_APPOINTMENT_PERMISSION.md new file mode 100644 index 00000000..37ef9c3b --- /dev/null +++ b/PRESCRIPTION_GET_BY_APPOINTMENT_PERMISSION.md @@ -0,0 +1,228 @@ +# 预约处方查询权限控制 + +## 修改说明 + +为 `getByAppointment` 接口添加了权限检查,确保用户只能查询自己有权限查看的处方。 + +## 修改内容 + +### 1. Controller 层修改 + +**文件**: `server/app/adminapi/controller/tcm/PrescriptionController.php` + +**修改前**: +```php +public function getByAppointment() +{ + $appointmentId = (int)($this->request->get('appointment_id') ?? 0); + if (!$appointmentId) { + return $this->fail('预约ID不能为空'); + } + $prescription = PrescriptionLogic::getByAppointment($appointmentId); + return $this->data($prescription ?? []); +} +``` + +**修改后**: +```php +public function getByAppointment() +{ + $appointmentId = (int)($this->request->get('appointment_id') ?? 0); + if (!$appointmentId) { + return $this->fail('预约ID不能为空'); + } + $prescription = PrescriptionLogic::getByAppointment($appointmentId, (int)$this->adminId, $this->adminInfo); + if ($prescription === null) { + $msg = PrescriptionLogic::getError(); + return $this->fail($msg !== '' ? $msg : '未找到处方或无权限查看'); + } + return $this->data($prescription); +} +``` + +**改进点**: +- 传入当前登录用户的 `adminId` 和 `adminInfo` +- 处理权限检查失败的情况,返回明确的错误信息 +- 区分"未找到处方"和"无权限查看"两种情况 + +### 2. Logic 层修改 + +**文件**: `server/app/adminapi/logic/tcm/PrescriptionLogic.php` + +**修改前**: +```php +/** + * 根据预约ID获取处方 + */ +public static function getByAppointment(int $appointmentId): ?array +{ + $row = Prescription::where('appointment_id', $appointmentId) + ->whereNull('delete_time') + ->order('id', 'desc') + ->find(); + return $row ? $row->toArray() : null; +} +``` + +**修改后**: +```php +/** + * 根据预约ID获取处方(带权限检查) + */ +public static function getByAppointment(int $appointmentId, int $viewerAdminId, array $viewerAdminInfo): ?array +{ + self::$error = ''; + $row = Prescription::where('appointment_id', $appointmentId) + ->whereNull('delete_time') + ->order('id', 'desc') + ->find(); + + if (!$row) { + return null; + } + + // 权限检查:只返回当前用户有权限查看的处方 + if (!self::canViewPrescription($row, $viewerAdminId, $viewerAdminInfo)) { + self::setError('无权限查看此处方'); + return null; + } + + return $row->toArray(); +} +``` + +**改进点**: +- 添加 `viewerAdminId` 和 `viewerAdminInfo` 参数 +- 使用 `canViewPrescription()` 方法进行权限检查 +- 设置错误信息,便于前端显示 + +## 权限规则 + +根据 `canViewPrescription()` 方法,以下用户可以查看处方: + +1. **超级管理员** - 可以查看所有处方 +2. **处方创建人** - 可以查看自己创建的处方 +3. **医助** - 可以查看自己协助的处方 +4. **共享处方** - 所有人可以查看标记为共享的处方 +5. **可见角色** - 处方指定的可见角色可以查看 +6. **有开方权限的医生** - 拥有 `tcm.diagnosis/kaifang` 权限的医生可以查看所有处方(只读) + +## 使用场景 + +这个接口主要用于以下场景: + +1. **查看病历按钮** - 在预约列表中点击"查看病历"时调用 +2. **处方历史查询** - 根据预约ID查询患者的历史处方 +3. **医生协作** - 其他医生查看患者的处方记录(需要有开方权限) + +## 前端调用示例 + +```typescript +// admin/src/views/tcm/appointment/list.vue +const handleViewCase = async (row: any) => { + if (!row.id) { + feedback.msgWarning('预约信息不完整') + return + } + try { + const prescription = await prescriptionGetByAppointment({ appointment_id: row.id }) + if (prescription?.id) { + prescriptionRef.value?.openById(prescription.id) + } else { + feedback.msgWarning('该预约暂无病历记录,请先开方') + } + } catch (error: any) { + // 权限不足或其他错误 + feedback.msgError(error?.msg || '获取病历失败') + } +} +``` + +## 错误处理 + +### 可能的错误信息 + +1. **"预约ID不能为空"** - 未传入 `appointment_id` 参数 +2. **"未找到处方或无权限查看"** - 处方不存在或当前用户无权限查看 +3. **"无权限查看此处方"** - 处方存在但当前用户无权限查看 + +### 前端处理建议 + +```typescript +try { + const prescription = await prescriptionGetByAppointment({ appointment_id: row.id }) + // 成功获取处方 +} catch (error: any) { + if (error?.msg?.includes('无权限')) { + feedback.msgWarning('您没有权限查看此处方') + } else if (error?.msg?.includes('未找到')) { + feedback.msgWarning('该预约暂无病历记录') + } else { + feedback.msgError(error?.msg || '获取病历失败') + } +} +``` + +## 测试建议 + +### 1. 权限测试 + +**测试用例 1: 创建人查看自己的处方** +- 医生A创建处方 +- 医生A查看该处方 +- 预期:成功返回处方数据 + +**测试用例 2: 其他医生查看处方(有开方权限)** +- 医生A创建处方 +- 医生B(有 `tcm.diagnosis/kaifang` 权限)查看该处方 +- 预期:成功返回处方数据(只读) + +**测试用例 3: 其他医生查看处方(无开方权限)** +- 医生A创建处方 +- 医生C(无 `tcm.diagnosis/kaifang` 权限)查看该处方 +- 预期:返回"无权限查看此处方"错误 + +**测试用例 4: 医助查看协助的处方** +- 医生A创建处方,医助B协助 +- 医助B查看该处方 +- 预期:成功返回处方数据 + +**测试用例 5: 查看共享处方** +- 医生A创建处方并标记为共享 +- 任何用户查看该处方 +- 预期:成功返回处方数据 + +### 2. 边界测试 + +**测试用例 6: 预约不存在** +- 传入不存在的 `appointment_id` +- 预期:返回"未找到处方或无权限查看" + +**测试用例 7: 预约存在但无处方** +- 传入存在的 `appointment_id`,但该预约没有创建处方 +- 预期:返回"未找到处方或无权限查看" + +**测试用例 8: 处方已删除** +- 传入已删除处方的 `appointment_id` +- 预期:返回"未找到处方或无权限查看" + +## 安全性说明 + +1. **防止越权访问** - 用户只能查看自己有权限的处方,防止查看其他医生的私有处方 +2. **数据隔离** - 通过权限检查实现数据隔离,保护患者隐私 +3. **审计追踪** - 所有查询操作都会记录当前用户信息,便于审计 +4. **错误信息脱敏** - 对于无权限的情况,不暴露处方是否存在的详细信息 + +## 相关文档 + +- `PRESCRIPTION_VIEW_PERMISSION_FIX.md` - 处方查看权限修复文档 +- `BUG_FIXES_SUMMARY.md` - Bug修复总结文档 + +## 总结 + +通过添加权限检查,确保了: +- ✅ 用户只能查询自己有权限查看的处方 +- ✅ 防止越权访问其他医生的私有处方 +- ✅ 支持医生协作(有开方权限的医生可以查看所有处方) +- ✅ 保护患者隐私和数据安全 +- ✅ 提供明确的错误信息,便于前端处理 diff --git a/PRESCRIPTION_ORDER_MIN_AMOUNT_VALIDATION.md b/PRESCRIPTION_ORDER_MIN_AMOUNT_VALIDATION.md new file mode 100644 index 00000000..1745c131 --- /dev/null +++ b/PRESCRIPTION_ORDER_MIN_AMOUNT_VALIDATION.md @@ -0,0 +1,318 @@ +# 处方订单最小金额验证优化 + +## 需求说明 + +在创建处方订单时,如果关联的支付单总金额小于配置的最小金额(`PRESCRIPTION_ORDER_LINK_PAY_MIN_AMOUNT`),则不允许创建订单。 + +## 配置说明 + +**配置文件**:`server/.env` + +```env +PRESCRIPTION_ORDER_LINK_PAY_MIN_AMOUNT="100" +``` + +**说明**: +- 单位:元(人民币) +- 默认值:0(不校验) +- 当前配置:100元 + +## 验证规则 + +### 修改前(原逻辑) + +``` +1. 订单金额 >= 最小金额 ✓ +2. 每个关联支付单金额 >= 最小金额 ✓ +``` + +**问题**: +- 要求每个支付单都必须 >= 100元 +- 不允许多个小额支付单组合(如 60元 + 50元 = 110元) + +### 修改后(新逻辑) + +``` +1. 订单金额 >= 最小金额 ✓ +2. 必须关联至少一个支付单 ✓ +3. 关联支付单总金额 >= 最小金额 ✓ +``` + +**优势**: +- 允许多个小额支付单组合 +- 更灵活的支付方式 +- 总金额达标即可 + +## 代码修改 + +**文件**:`server/app/adminapi/logic/tcm/PrescriptionOrderLogic.php` + +**方法**:`assertDepositAndLinkPayRules()` + +### 修改内容 + +```php +public static function assertDepositAndLinkPayRules(float $orderAmount, array $payOrderIds): ?string +{ + $min = self::depositMinAmount(); + if ($min <= 0) { + return null; + } + + // 1. 检查订单金额 + if ($orderAmount < $min) { + return '订单金额须大于等于定金门槛 ¥' . $min . '(当前 ¥' . $orderAmount . ')'; + } + + // 2. 检查是否关联支付单 + if ($payOrderIds === []) { + return '未关联支付单,关联支付单总金额须大于等于定金门槛 ¥' . $min; + } + + // 3. 检查关联支付单的总金额 + $rows = Order::whereIn('id', $payOrderIds)->whereNull('delete_time')->column('amount', 'id'); + $totalAmount = 0.0; + foreach ($payOrderIds as $pid) { + $pid = (int) $pid; + if ($pid <= 0) { + continue; + } + $amt = round((float) ($rows[$pid] ?? 0), 2); + $totalAmount += $amt; + } + + if ($totalAmount < $min) { + return '关联支付单总金额须大于等于定金门槛 ¥' . $min . '(当前总金额 ¥' . $totalAmount . ')'; + } + + return null; +} +``` + +## 验证场景 + +### 场景1:订单金额不足 + +**配置**:最小金额 = 100元 + +**输入**: +- 订单金额:80元 +- 关联支付单:[100元] + +**结果**:❌ 失败 +**错误信息**:订单金额须大于等于定金门槛 ¥100(当前 ¥80) + +--- + +### 场景2:未关联支付单 + +**配置**:最小金额 = 100元 + +**输入**: +- 订单金额:150元 +- 关联支付单:[] + +**结果**:❌ 失败 +**错误信息**:未关联支付单,关联支付单总金额须大于等于定金门槛 ¥100 + +--- + +### 场景3:关联支付单总金额不足 + +**配置**:最小金额 = 100元 + +**输入**: +- 订单金额:150元 +- 关联支付单:[30元, 40元, 20元] +- 总金额:90元 + +**结果**:❌ 失败 +**错误信息**:关联支付单总金额须大于等于定金门槛 ¥100(当前总金额 ¥90) + +--- + +### 场景4:多个小额支付单组合达标 + +**配置**:最小金额 = 100元 + +**输入**: +- 订单金额:150元 +- 关联支付单:[60元, 50元] +- 总金额:110元 + +**结果**:✅ 成功 +**说明**:虽然单个支付单都小于100元,但总金额达标 + +--- + +### 场景5:单个大额支付单 + +**配置**:最小金额 = 100元 + +**输入**: +- 订单金额:150元 +- 关联支付单:[120元] +- 总金额:120元 + +**结果**:✅ 成功 + +--- + +### 场景6:配置为0(不校验) + +**配置**:最小金额 = 0元 + +**输入**: +- 订单金额:10元 +- 关联支付单:[] + +**结果**:✅ 成功 +**说明**:最小金额为0时,不进行任何校验 + +--- + +### 场景7:多个支付单,总金额刚好达标 + +**配置**:最小金额 = 100元 + +**输入**: +- 订单金额:150元 +- 关联支付单:[30元, 30元, 40元] +- 总金额:100元 + +**结果**:✅ 成功 +**说明**:总金额刚好等于最小金额,符合"大于等于"的要求 + +## 前端提示优化 + +**文件**:`admin/src/views/consumer/prescription/order_list.vue` + +**当前提示**: +``` +支付单审核时可对照此处关联的收款记录。 +列表仅展示金额大于等于定金门槛的收款单(门槛为 0 时展示全部)。 +``` + +**建议修改为**: +``` +支付单审核时可对照此处关联的收款记录。 +关联支付单总金额须大于等于定金门槛(当前门槛:¥100)。 +列表仅展示金额大于等于定金门槛的收款单(门槛为 0 时展示全部)。 +``` + +## 配置管理 + +### 查看当前配置 + +```bash +# 查看 .env 文件 +cat server/.env | grep PRESCRIPTION_ORDER_LINK_PAY_MIN_AMOUNT +``` + +### 修改配置 + +```bash +# 编辑 .env 文件 +vim server/.env + +# 修改为 200 元 +PRESCRIPTION_ORDER_LINK_PAY_MIN_AMOUNT="200" + +# 修改为 0(不校验) +PRESCRIPTION_ORDER_LINK_PAY_MIN_AMOUNT="0" +``` + +### 配置生效 + +修改 `.env` 文件后,配置会立即生效,无需重启服务。 + +## 业务逻辑说明 + +### 为什么需要最小金额限制? + +1. **定金机制**:确保客户已支付足够的定金 +2. **风险控制**:避免小额订单的履约风险 +3. **业务规范**:统一订单金额标准 + +### 为什么改为总金额验证? + +1. **灵活性**:允许客户分多次支付 +2. **用户体验**:不强制单次支付大额 +3. **实际需求**:多次小额支付也能达到定金要求 + +### 为什么必须关联支付单? + +1. **资金确认**:确保订单有对应的收款记录 +2. **审核依据**:支付单审核时需要对照 +3. **财务管理**:便于对账和统计 + +## 测试建议 + +### 1. 单元测试 + +```php +// 测试订单金额不足 +$result = PrescriptionOrderLogic::assertDepositAndLinkPayRules(80, [1]); +assert($result !== null); + +// 测试未关联支付单 +$result = PrescriptionOrderLogic::assertDepositAndLinkPayRules(150, []); +assert($result !== null); + +// 测试总金额不足 +$result = PrescriptionOrderLogic::assertDepositAndLinkPayRules(150, [1, 2]); // 假设总金额 < 100 +assert($result !== null); + +// 测试总金额达标 +$result = PrescriptionOrderLogic::assertDepositAndLinkPayRules(150, [3, 4]); // 假设总金额 >= 100 +assert($result === null); +``` + +### 2. 集成测试 + +1. 创建多个小额支付单(如 60元、50元) +2. 创建订单,关联这些支付单 +3. 验证是否能成功创建 +4. 验证错误提示是否正确 + +### 3. 边界测试 + +- 总金额刚好等于最小金额(100元) +- 总金额略小于最小金额(99.99元) +- 总金额略大于最小金额(100.01元) +- 最小金额为0 +- 最小金额为负数(应该按0处理) + +## 相关文件 + +### 配置文件 +- `server/.env` - 环境配置 +- `server/config/prescription_order.php` - 订单配置 + +### 后端文件 +- `server/app/adminapi/logic/tcm/PrescriptionOrderLogic.php` - 订单逻辑 +- `server/app/adminapi/validate/tcm/PrescriptionOrderValidate.php` - 订单验证 + +### 前端文件 +- `admin/src/views/consumer/prescription/order_list.vue` - 订单列表页面 + +## 总结 + +✅ 修改验证逻辑:从"每个支付单"改为"支付单总金额" +✅ 添加必须关联支付单的验证 +✅ 优化错误提示信息,显示当前总金额 +✅ 支持多个小额支付单组合 +✅ 保持订单金额验证不变 +✅ 配置为0时不进行校验 + +**优势**: +- 更灵活的支付方式 +- 更好的用户体验 +- 更清晰的错误提示 +- 更符合实际业务需求 + +**下一步**: +1. 测试各种场景 +2. 更新前端提示文字 +3. 编写单元测试 +4. 更新用户文档 diff --git a/PRESCRIPTION_ORDER_WITHDRAW_RESET_AUDIT.md b/PRESCRIPTION_ORDER_WITHDRAW_RESET_AUDIT.md new file mode 100644 index 00000000..c8e59653 --- /dev/null +++ b/PRESCRIPTION_ORDER_WITHDRAW_RESET_AUDIT.md @@ -0,0 +1,387 @@ +# 订单撤回后重置处方审核状态 + +## 需求说明 + +当用户在订单列表中点击"撤回"按钮后,除了将订单状态改为"已取消",还需要将关联的处方审核状态重置为"待审核",以便重新审核。 + +## 业务场景 + +### 场景描述 + +1. 医生创建处方 +2. 处方审核通过(`audit_status = 1`) +3. 创建业务订单(`fulfillment_status = 1` 待双审通过) +4. 用户点击"撤回"订单 +5. **期望**:处方审核状态重置为"待审核"(`audit_status = 0`) + +### 为什么需要重置? + +1. **重新审核**:撤回订单可能是因为处方有问题,需要重新审核 +2. **状态一致性**:订单撤回后,处方应该回到初始状态 +3. **业务流程**:允许医生修改处方后重新提交审核 + +## 数据库表结构 + +### 处方表(zyt_tcm_prescription) + +```sql +`audit_status` tinyint(1) NOT NULL DEFAULT 1 COMMENT '审核:0待审核 1已通过 2已驳回(同时作废)' +`audit_time` int(10) unsigned DEFAULT NULL COMMENT '审核时间' +`audit_by` int(11) unsigned DEFAULT NULL COMMENT '审核人ID' +`audit_remark` varchar(500) NOT NULL DEFAULT '' COMMENT '审核意见' +``` + +**审核状态**: +- 0 = 待审核 +- 1 = 已通过 +- 2 = 已驳回(同时作废) + +### 业务订单表(zyt_tcm_prescription_order) + +```sql +`prescription_id` int(11) unsigned NOT NULL DEFAULT 0 COMMENT '消费者处方ID' +`fulfillment_status` tinyint(2) unsigned NOT NULL DEFAULT 1 COMMENT '1待双审通过 2履约中 3已完成 4已取消' +``` + +**履约状态**: +- 1 = 待双审通过 +- 2 = 履约中 +- 3 = 已完成 +- 4 = 已取消(撤回) + +## 代码修改 + +**文件**:`server/app/adminapi/logic/tcm/PrescriptionOrderLogic.php` + +**方法**:`withdraw()` + +### 修改内容 + +```php +public static function withdraw(int $id, int $adminId, array $adminInfo) +{ + self::$error = ''; + $order = PrescriptionOrder::where('id', $id)->whereNull('delete_time')->find(); + if (!$order) { + self::$error = '订单不存在'; + return false; + } + if (!self::canWithdrawOrder($order, $adminId, $adminInfo)) { + self::$error = '无权限撤回'; + return false; + } + if ((int) $order->fulfillment_status !== 1) { + self::$error = '仅「待双审通过」状态可撤回;双审通过后请走履约/完成流程'; + return false; + } + + $order->fulfillment_status = 4; + self::clearPayOrderLinks($order); + + try { + $order->save(); + + // 撤回订单后,将关联的处方审核状态改回"待审核" + $prescriptionId = (int) $order->prescription_id; + if ($prescriptionId > 0) { + Prescription::where('id', $prescriptionId) + ->whereNull('delete_time') + ->update([ + 'audit_status' => 0, // 0=待审核 + 'audit_time' => null, + 'audit_by' => null, + 'audit_remark' => '', + 'update_time' => time(), + ]); + } + } catch (\Throwable $e) { + self::$error = $e->getMessage(); + return false; + } + + $out = $order->toArray(); + self::maskInternalCostIfNeeded($out, $adminInfo); + self::attachLinkedPayOrders($out); + + return $out; +} +``` + +### 修改说明 + +1. **保存订单状态**:`$order->save()` +2. **获取处方ID**:`$prescriptionId = (int) $order->prescription_id` +3. **重置处方审核状态**: + - `audit_status = 0`(待审核) + - `audit_time = null`(清空审核时间) + - `audit_by = null`(清空审核人) + - `audit_remark = ''`(清空审核意见) + - `update_time = time()`(更新时间) + +## 工作流程 + +### 完整流程 + +``` +用户点击"撤回"按钮 + ↓ +调用 withdraw() 方法 + ↓ +验证权限和状态 + ↓ +更新订单状态为"已取消"(4) + ↓ +清除支付单关联 + ↓ +保存订单 + ↓ +获取关联的处方ID + ↓ +重置处方审核状态为"待审核"(0) + ↓ +清空审核时间、审核人、审核意见 + ↓ +返回成功 +``` + +### 前端显示 + +**订单列表页面**: +- 订单状态:已取消 +- 撤回按钮:消失(只有"待双审通过"状态才显示) + +**处方列表页面**: +- 审核状态:待审核 +- 可以重新审核 + +## 测试场景 + +### 场景1:正常撤回 + +**初始状态**: +- 处方审核状态:已通过(1) +- 订单履约状态:待双审通过(1) + +**操作**:点击"撤回"按钮 + +**预期结果**: +- 订单履约状态:已取消(4) +- 处方审核状态:待审核(0) +- 审核时间:null +- 审核人:null +- 审核意见:空 + +--- + +### 场景2:撤回后重新审核 + +**步骤**: +1. 撤回订单(处方状态变为"待审核") +2. 医生修改处方 +3. 重新提交审核 +4. 审核通过 +5. 创建新订单 + +**预期结果**:流程正常,可以重新审核和创建订单 + +--- + +### 场景3:无权限撤回 + +**初始状态**: +- 当前用户:非创建人、非医助 + +**操作**:点击"撤回"按钮 + +**预期结果**: +- 错误提示:"无权限撤回" +- 处方状态不变 + +--- + +### 场景4:非待双审状态撤回 + +**初始状态**: +- 订单履约状态:履约中(2)或已完成(3) + +**操作**:点击"撤回"按钮 + +**预期结果**: +- 错误提示:"仅「待双审通过」状态可撤回;双审通过后请走履约/完成流程" +- 处方状态不变 + +--- + +### 场景5:处方不存在 + +**初始状态**: +- 订单关联的处方已被删除 + +**操作**:点击"撤回"按钮 + +**预期结果**: +- 订单状态:已取消(4) +- 不会报错(因为有 `if ($prescriptionId > 0)` 判断) + +## 边界情况处理 + +### 1. 处方ID为0或null + +```php +if ($prescriptionId > 0) { + // 只有处方ID有效时才更新 +} +``` + +**处理**:跳过处方状态更新,不报错 + +### 2. 处方已被删除 + +```php +->whereNull('delete_time') +``` + +**处理**:不会更新已删除的处方 + +### 3. 数据库更新失败 + +```php +try { + $order->save(); + // 更新处方状态 +} catch (\Throwable $e) { + self::$error = $e->getMessage(); + return false; +} +``` + +**处理**:捕获异常,返回错误信息 + +### 4. 事务一致性 + +**问题**:订单保存成功,但处方更新失败怎么办? + +**当前实现**: +- 订单和处方更新在同一个 try-catch 块中 +- 如果处方更新失败,会抛出异常 +- 异常会导致整个方法返回 false + +**建议优化**:使用数据库事务 + +```php +Db::startTrans(); +try { + $order->save(); + + // 更新处方状态 + Prescription::where('id', $prescriptionId) + ->whereNull('delete_time') + ->update([...]); + + Db::commit(); +} catch (\Throwable $e) { + Db::rollback(); + self::$error = $e->getMessage(); + return false; +} +``` + +## 相关影响 + +### 1. 处方列表页面 + +**文件**:`admin/src/views/consumer/prescription/index.vue` + +**影响**: +- 撤回后,处方审核状态显示为"待审核" +- 可以重新点击"审核"按钮 + +### 2. 订单列表页面 + +**文件**:`admin/src/views/consumer/prescription/order_list.vue` + +**影响**: +- 撤回后,订单状态显示为"已取消" +- "撤回"按钮消失(只有"待双审通过"状态才显示) + +### 3. 业务流程 + +**影响**: +- 允许撤回后重新审核处方 +- 允许重新创建订单 +- 支付单关联被清除,可以重新关联 + +## 注意事项 + +### 1. 权限控制 + +只有以下用户可以撤回订单: +- 订单创建人 +- 诊单医助 +- 主管角色 + +### 2. 状态限制 + +只有"待双审通过"状态的订单可以撤回: +- 待双审通过(1):✅ 可以撤回 +- 履约中(2):❌ 不可撤回 +- 已完成(3):❌ 不可撤回 +- 已取消(4):❌ 不可撤回 + +### 3. 数据一致性 + +撤回操作会: +- 更新订单状态 +- 清除支付单关联 +- 重置处方审核状态 + +建议使用数据库事务确保一致性。 + +### 4. 审核历史 + +当前实现会清空审核历史(审核时间、审核人、审核意见)。 + +如果需要保留审核历史,可以考虑: +- 不清空这些字段 +- 或者创建审核历史表记录 + +## 相关文件 + +### 后端文件 +- `server/app/adminapi/logic/tcm/PrescriptionOrderLogic.php` - 订单逻辑 +- `server/app/common/model/tcm/Prescription.php` - 处方模型 +- `server/app/common/model/tcm/PrescriptionOrder.php` - 订单模型 + +### 前端文件 +- `admin/src/views/consumer/prescription/order_list.vue` - 订单列表 +- `admin/src/views/consumer/prescription/index.vue` - 处方列表 + +### 数据库文件 +- `server/database/migrations/2026_03_30_prescription_visible_roles_audit.sql` - 处方审核字段 +- `server/database/migrations/2026_04_07_create_tcm_prescription_order.sql` - 订单表 + +## 总结 + +✅ 撤回订单时重置处方审核状态为"待审核" +✅ 清空审核时间、审核人、审核意见 +✅ 更新处方的 update_time +✅ 保持订单状态为"已取消" +✅ 清除支付单关联 +✅ 异常处理和错误提示 + +**优势**: +- 支持撤回后重新审核 +- 状态一致性 +- 业务流程完整 +- 用户体验友好 + +**建议优化**: +- 使用数据库事务确保一致性 +- 考虑保留审核历史记录 +- 添加操作日志 + +**下一步**: +1. 测试撤回功能 +2. 验证处方状态变化 +3. 测试重新审核流程 +4. 考虑添加事务支持 diff --git a/PRESCRIPTION_VIEW_PERMISSION_FIX.md b/PRESCRIPTION_VIEW_PERMISSION_FIX.md new file mode 100644 index 00000000..8d978164 --- /dev/null +++ b/PRESCRIPTION_VIEW_PERMISSION_FIX.md @@ -0,0 +1,129 @@ +# 业务订单中处方查看权限修复 + +## 问题描述 + +药师角色(角色ID=6)在查看业务订单详情时,提示"无权限查看此处方"。 + +虽然药师有业务订单管理权限(`order_edit_all_roles` 包含角色6),但在业务订单详情中需要显示关联的处方信息时,`PrescriptionLogic::canViewPrescription()` 方法会拒绝访问。 + +## 问题原因 + +`canViewPrescription` 方法的权限检查层级为: +1. 超级管理员 +2. 处方创建人 +3. 医助 +4. 共享处方 +5. 可见角色 +6. 有开方权限的医生(`tcm.diagnosis/kaifang`) + +药师角色通常没有 `tcm.diagnosis/kaifang` 权限,因此无法通过权限检查。 + +## 解决方案 + +在 `canViewPrescription` 方法中添加新的权限检查层级: + +**有业务订单管理权限的角色(如药师)可以查看处方(只读)** + +这样药师可以在业务订单中查看关联的处方信息,以便进行订单审核和管理。 + +## 代码修改 + +文件:`server/app/adminapi/logic/tcm/PrescriptionLogic.php` + +```php +public static function canViewPrescription($row, int $adminId, array $adminInfo): bool +{ + // 超级管理员 + if (!empty($adminInfo['root']) && (int) $adminInfo['root'] === 1) { + return true; + } + + // 创建人 + $creatorId = is_array($row) ? (int) ($row['creator_id'] ?? 0) : (int) $row->creator_id; + if ($creatorId === $adminId) { + return true; + } + + // 医助 + $assistantId = is_array($row) ? (int) ($row['assistant_id'] ?? 0) : (int) ($row->assistant_id ?? 0); + if ($assistantId > 0 && $assistantId === $adminId) { + return true; + } + + // 共享处方 + $isShared = is_array($row) ? (int) ($row['is_shared'] ?? 0) : (int) $row->is_shared; + if ($isShared === 1) { + return true; + } + + // 可见角色 + $vis = is_array($row) ? (string) ($row['visible_role_ids'] ?? '') : (string) ($row->visible_role_ids ?? ''); + $allowed = array_filter(array_map('intval', explode(',', $vis))); + $myRoles = array_map('intval', $adminInfo['role_id'] ?? []); + if (count(array_intersect($myRoles, $allowed)) > 0) { + return true; + } + + // 有开方权限的医生可以查看所有处方(只读) + $permissions = $adminInfo['permissions'] ?? []; + if (in_array('tcm.diagnosis/kaifang', $permissions, true)) { + return true; + } + + // 【新增】有业务订单管理权限的角色(如药师)可以查看处方(只读) + $orderEditAllRoles = Config::get('project.order_edit_all_roles', [0, 3]); + $myRoles = array_map('intval', $adminInfo['role_id'] ?? []); + $allowedRoles = array_map('intval', is_array($orderEditAllRoles) ? $orderEditAllRoles : []); + if (count(array_intersect($myRoles, $allowedRoles)) > 0) { + return true; + } + + return false; +} +``` + +## 权限层级(更新后) + +1. 超级管理员(root=1) +2. 处方创建人 +3. 医助 +4. 共享处方(is_shared=1) +5. 可见角色(visible_role_ids) +6. 有开方权限的医生(`tcm.diagnosis/kaifang`) +7. **有业务订单管理权限的角色(`order_edit_all_roles`)** ← 新增 + +## 配置文件 + +文件:`server/config/project.php` + +```php +// 医助创建订单权限:拥有以下角色ID的用户可修改任意订单,其他用户只能修改自己创建的订单 +'order_edit_all_roles' => [0, 3, 6], +``` + +- 角色 0:超级管理员 +- 角色 3:管理员 +- 角色 6:药师 + +## 使用场景 + +药师角色现在可以: +1. 查看业务订单列表 +2. 打开业务订单详情 +3. 查看订单中关联的处方信息(只读) +4. 进行处方审核 +5. 进行支付单审核 +6. 编辑业务订单信息 + +## 测试验证 + +1. 使用药师账号登录 +2. 进入"消费者处方订单"列表 +3. 点击任意订单的"详情"按钮 +4. 确认可以正常查看处方信息,不再提示"无权限查看此处方" + +## 注意事项 + +1. 此权限为只读权限,药师不能编辑处方内容 +2. 药师只能通过业务订单查看处方,不能直接在处方列表中查看其他医生的处方 +3. 修改代码后需要清除缓存:`php think clear` diff --git a/TUICallKit-Vue3/TUIKit/pages/chat/chat.vue b/TUICallKit-Vue3/TUIKit/pages/chat/chat.vue index 5d510e11..8fc56392 100644 --- a/TUICallKit-Vue3/TUIKit/pages/chat/chat.vue +++ b/TUICallKit-Vue3/TUIKit/pages/chat/chat.vue @@ -729,11 +729,14 @@ const { proxy } = getCurrentInstance() if(options.msg==1){ console.log('msg我进来了',options.msg) - await sendConfirmationMessage(); // 进入聊天时自动发送知情确认 + await waitForIMReady(); // 确保 SDK 网络就绪后再发消息 + await sendConfirmationMessage(); // 进入聊天时自动发送知情确认 } loadDoctorInfo(); // 加载医生卡片信息(对方为医生时) uni.$on('TUICall:callStart', onVideoCallStart); uni.$on('TUICall:callEnd', onVideoCallEnd); + // 提前申请音视频权限,避免来电接通时才弹「去设置」提示 + requestAVPermissions({ silent: true, scene: 'onLoad' }); //uni.showToast({ title: '聊天已就绪', icon: 'success' }); // #ifdef MP-WEIXIN @@ -1136,18 +1139,25 @@ const { proxy } = getCurrentInstance() } } + /** 等待 IM SDK 网络握手完成,给 SDK 一段时间确保就绪后再发消息 */ + function waitForIMReady() { + // 使用固定延迟代替事件监听:SDK_READY 事件可能在注册前已触发, + // 事件监听会等满 maxMs 超时,在微信小程序中引发 "Error: timeout"。 + return new Promise((resolve) => setTimeout(resolve, 800)); + } + async function sendConfirmationMessage() { const alreadySent = messages.value.some( (m) => m.type === 'confirmation' || (m.type === 'text' && isConfirmationMessageText(m.text)) ); if (alreadySent) return; - try { - const message = chat.createTextMessage({ - to: targetUserID, - conversationType: TencentCloudChat.TYPES.CONV_C2C, - payload: { text: CONFIRMATION_MESSAGE } - }); - await chat.sendMessage(message); + + const pushSent = () => { + // 防止重复 push(relogin 后重试时再次检查) + const already = messages.value.some( + (m) => m.type === 'confirmation' || (m.type === 'text' && isConfirmationMessageText(m.text)) + ); + if (already) return; messages.value.push({ type: 'confirmation', text: CONFIRMATION_MESSAGE, @@ -1157,8 +1167,27 @@ const { proxy } = getCurrentInstance() }); scrollToBottom(); setTimeout(() => scrollToBottom(), 150); + }; + const doSend = async () => { + const message = chat.createTextMessage({ + to: targetUserID, + conversationType: TencentCloudChat.TYPES.CONV_C2C, + payload: { text: CONFIRMATION_MESSAGE } + }); + await chat.sendMessage(message); + }; + try { + await doSend(); + pushSent(); } catch (error) { - console.error('发送确认信息失败:', error); + console.error('发送确认信息失败,尝试重新登录后重试:', error); + try { + await reloginIMChat(); + await doSend(); + pushSent(); + } catch (e2) { + console.error('重登后确认信息仍发送失败:', e2); + } } } @@ -1335,7 +1364,78 @@ const { proxy } = getCurrentInstance() }); } + /** + * 申请摄像头 + 麦克风权限。 + * silent=true 时仅静默尝试,不弹引导框(用于页面加载时提前申请)。 + * silent=false 时若被拒绝主动引导用户去设置页开启。 + * scene 标识触发场景(makeCall / incoming),用于日志分析。 + */ + // #ifdef MP-WEIXIN + function requestAVPermissions({ silent = false, scene = 'unknown' } = {}) { + /** 上报权限被拒日志到后端 */ + const logDenied = (deniedScope, action) => { + try { + const wxVersion = wx.getSystemInfoSync?.()?.SDKVersion || ''; + proxy.apiUrl({ + url: '/api/chat/logAvPermission', + method: 'POST', + data: { + patient_id: currentUserID || '', + doctor_id: targetUserID || '', + denied_scope: deniedScope, + scene, + action, + wx_version: wxVersion + } + }, false).catch(() => {}); + } catch (_) {} + }; + + const guideToSetting = (deniedScope) => { + if (silent) { + // 静默模式:仅记录被拒,不弹弹窗 + logDenied(deniedScope, 'silent_denied'); + return; + } + uni.showModal({ + title: '需要摄像头和麦克风权限', + content: '视频通话需要摄像头和麦克风权限,请点击「去设置」开启后重试', + confirmText: '去设置', + cancelText: '取消', + success: (res) => { + if (res.confirm) { + logDenied(deniedScope, 'open_setting'); + wx.openSetting(); + } else { + // 用户选择「取消」,记录日志 + logDenied(deniedScope, 'cancel'); + } + } + }); + }; + return new Promise((resolve) => { + wx.authorize({ + scope: 'scope.camera', + success: () => { + wx.authorize({ + scope: 'scope.record', + success: () => resolve(true), + fail: () => { guideToSetting('record'); resolve(false); } + }); + }, + fail: () => { guideToSetting('camera'); resolve(false); } + }); + }); + } + // #endif + // #ifndef MP-WEIXIN + function requestAVPermissions() { return Promise.resolve(true); } + // #endif + async function makeVideoCall() { + // 发起通话前先检查权限,若被拒引导去设置 + const granted = await requestAVPermissions({ silent: false, scene: 'makeCall' }); + if (!granted) return; try { hideDoctorCardByCall(); if (!uni.CallManager) { diff --git a/TUICallKit-Vue3/main.js b/TUICallKit-Vue3/main.js index 204d0ce3..5fd1d831 100644 --- a/TUICallKit-Vue3/main.js +++ b/TUICallKit-Vue3/main.js @@ -1,5 +1,5 @@ import App from './App' -var baseUrl ='https://capi.zzzhengyangtang.cn/'; +var baseUrl ='https://admin.zhenyangtang.com.cn/'; // #ifndef VUE3 import Vue from 'vue' import './uni.promisify.adaptor' diff --git a/TUICallKit-Vue3/pages/order/monad/monad.vue b/TUICallKit-Vue3/pages/order/monad/monad.vue index 49f6e4f0..2d457ce8 100644 --- a/TUICallKit-Vue3/pages/order/monad/monad.vue +++ b/TUICallKit-Vue3/pages/order/monad/monad.vue @@ -625,11 +625,47 @@ const getLocalDiagnosisText = () => { return String(val) + '、'; }; +// ---- 音视频权限辅助(仅微信小程序) ---- +// #ifdef MP-WEIXIN +const requestAVPermissionsInModal = () => new Promise((resolve) => { + const guide = (deniedScope) => { + uni.showModal({ + title: '需要摄像头和麦克风权限', + content: '视频问诊需要摄像头和麦克风权限,请点击「去设置」开启后重试', + confirmText: '去设置', + cancelText: '取消', + success: (res) => { + if (res.confirm) wx.openSetting(); + resolve(false); + } + }); + }; + wx.authorize({ + scope: 'scope.camera', + success: () => { + wx.authorize({ + scope: 'scope.record', + success: () => resolve(true), + fail: () => guide('record') + }); + }, + fail: () => guide('camera') + }); +}); +// #endif +// #ifndef MP-WEIXIN +const requestAVPermissionsInModal = () => Promise.resolve(true); +// #endif + // 关闭就诊人信息弹窗 const closePatientModal = async () => { if (confirmBtnLoading.value) return; confirmBtnLoading.value = true; try { + // 在用户主动点击的时机申请摄像头 + 麦克风权限 + // (首次会弹原生授权弹窗;已拒绝则引导去设置页) + const granted = await requestAVPermissionsInModal(); + if (!granted) return; // 未授权,停止跳转 const app = getApp(); // const userID = app.globalData.userID; // const userSig = app.globalData.userSig; diff --git a/admin/src/api/tcm.ts b/admin/src/api/tcm.ts index a6dccec1..8666f6a5 100644 --- a/admin/src/api/tcm.ts +++ b/admin/src/api/tcm.ts @@ -284,6 +284,45 @@ export function prescriptionOrderAuditPayment(params: { return request.post({ url: '/tcm.prescriptionOrder/auditPayment', params }) } +/** 撤回处方审核 */ +export function prescriptionOrderRevokeRxAudit(params: { id: number }) { + return request.post({ url: '/tcm.prescriptionOrder/revokeRxAudit', params }) +} + +/** 撤回支付单审核 */ +export function prescriptionOrderRevokePayAudit(params: { id: number }) { + return request.post({ url: '/tcm.prescriptionOrder/revokePayAudit', params }) +} + +/** 确认发货:将 fulfillment_status 从 2(履约中)推进到 5(已发货) */ +export function prescriptionOrderShip(params: { + id: number + express_company: string + tracking_number: string +}) { + return request.post({ url: '/tcm.prescriptionOrder/ship', params }) +} + +/** 为「已发货」订单新增支付单并重置支付审核为待审核 */ +export function prescriptionOrderAddPayOrder(params: { + id: number + order_type: number + pay_amount: number + pay_remark?: string +}) { + return request.post({ url: '/tcm.prescriptionOrder/addPayOrder', params }) +} + +/** 将「已发货」且支付审核通过的订单标记为「已完成」 */ +export function prescriptionOrderComplete(params: { id: number }) { + return request.post({ url: '/tcm.prescriptionOrder/complete', params }) +} + +/** 订单操作日志 */ +export function prescriptionOrderLogs(params: { id: number }) { + return request.get({ url: '/tcm.prescriptionOrder/logs', params }) +} + // ========== 处方库 ========== // 处方库列表 diff --git a/admin/src/components/tcm-prescription/index.vue b/admin/src/components/tcm-prescription/index.vue index a465153d..84737d39 100644 --- a/admin/src/components/tcm-prescription/index.vue +++ b/admin/src/components/tcm-prescription/index.vue @@ -635,6 +635,9 @@ const showLibraryDialog = ref(false) const libraryLoading = ref(false) const libraryList = ref([]) const librarySearchName = ref('') + +// 防止重复打开 +const isOpening = ref(false) const libraryPage = ref(1) const libraryPageSize = ref(15) const libraryTotal = ref(0) @@ -785,78 +788,103 @@ const buildClinicalDiagnosis = (diagnosis: any) => { } const open = async (data: any, options?: { templateId?: number; stationName?: string }) => { - if (options?.stationName) templateConfig.stationName = options.stationName - - const diagnosis = data.diagnosis || data - const diagnosisId = data.diagnosis_id ?? diagnosis?.id ?? data.id - if (!diagnosisId) { - feedback.msgWarning('缺少诊单信息') + // 防止重复打开 + if (isOpening.value) { + console.warn('处方正在打开中,请勿重复点击') return } + + isOpening.value = true + + try { + if (options?.stationName) templateConfig.stationName = options.stationName - await loadDictOptions() - - const appointmentId = Number(data.id ?? data.appointment_id ?? 0) - if (appointmentId) { - try { - const existing = await prescriptionGetByAppointment({ appointment_id: appointmentId }) - if (existing?.id) { - const detail = await prescriptionDetail({ id: existing.id }) - savedPrescription.value = detail - visible.value = true - return - } - } catch (_) {} - } - - // 获取详细诊单作为病历快照(每次开方保存一份) - let caseRecord = data.case_record && Object.keys(data.case_record).length > 0 ? data.case_record : null - if (!caseRecord && diagnosisId) { - try { - const res = await tcmDiagnosisDetail({ id: Number(diagnosisId) }) - caseRecord = res && typeof res === 'object' ? res : null - } catch (e) { - console.warn('获取诊单详情失败:', e) + const diagnosis = data.diagnosis || data + const diagnosisId = data.diagnosis_id ?? diagnosis?.id ?? data.id + if (!diagnosisId) { + feedback.msgWarning('缺少诊单信息') + return } + + await loadDictOptions() + + const appointmentId = Number(data.id ?? data.appointment_id ?? 0) + if (appointmentId) { + try { + + const existing = await prescriptionGetByAppointment({ appointment_id: appointmentId }) + console.log('-----------',existing) + if (existing?.id) { + const detail = await prescriptionDetail({ id: existing.id }) + savedPrescription.value = detail + visible.value = true + return + } + } catch (_) {} + } + + // 获取详细诊单作为病历快照(每次开方保存一份) + let caseRecord = data.case_record && Object.keys(data.case_record).length > 0 ? data.case_record : null + if (!caseRecord && diagnosisId) { + try { + const res = await tcmDiagnosisDetail({ id: Number(diagnosisId) }) + caseRecord = res && typeof res === 'object' ? res : null + } catch (e) { + console.warn('获取诊单详情失败:', e) + } + } + + formData.diagnosis_id = Number(diagnosisId) + formData.appointment_id = appointmentId + formData.prescription_name = '' + formData.prescription_type = '浓缩水丸' + formData.patient_name = data.patient_name || diagnosis.patient_name || '' + formData.gender = data.patient_gender ?? diagnosis.gender ?? 0 + formData.gender_desc = formData.gender === 1 ? '男' : '女' + formData.age = data.patient_age ?? diagnosis.age ?? 0 + formData.phone = data.patient_phone || diagnosis.phone || '' + formData.visit_no = data.id ? `1K${String(data.id).padStart(8, '0')}` : '' + formData.prescription_date = new Date().toISOString().slice(0, 10) + formData.pulse = diagnosis.pulse || '' + formData.tongue = diagnosis.tongue_coating || diagnosis.tongue || '' + formData.tongue_image = '' + formData.pulse_condition = '' + formData.clinical_diagnosis = buildClinicalDiagnosis(diagnosis) + formData.case_record = caseRecord + formData.herbs = [] + formData.dose_count = 1 + formData.dose_unit = '剂' + formData.usage_days = 7 + formData.usage_instruction = '水煎服,一日二次' + formData.usage_time = '饭前' + formData.usage_way = '温水送服' + formData.dietary_taboo = [] + formData.usage_notes = '' + formData.amount = 0 + formData.doctor_name = userStore.userInfo?.name || '' + formData.doctor_signature = '' + formData.is_shared = 0 + + savedPrescription.value = null + visible.value = true + setTimeout(() => initSignatureCanvas(), 100) + } finally { + // 延迟重置,避免快速连续点击 + setTimeout(() => { + isOpening.value = false + }, 500) } - - formData.diagnosis_id = Number(diagnosisId) - formData.appointment_id = appointmentId - formData.prescription_name = '' - formData.prescription_type = '浓缩水丸' - formData.patient_name = data.patient_name || diagnosis.patient_name || '' - formData.gender = data.patient_gender ?? diagnosis.gender ?? 0 - formData.gender_desc = formData.gender === 1 ? '男' : '女' - formData.age = data.patient_age ?? diagnosis.age ?? 0 - formData.phone = data.patient_phone || diagnosis.phone || '' - formData.visit_no = data.id ? `1K${String(data.id).padStart(8, '0')}` : '' - formData.prescription_date = new Date().toISOString().slice(0, 10) - formData.pulse = diagnosis.pulse || '' - formData.tongue = diagnosis.tongue_coating || diagnosis.tongue || '' - formData.tongue_image = '' - formData.pulse_condition = '' - formData.clinical_diagnosis = buildClinicalDiagnosis(diagnosis) - formData.case_record = caseRecord - formData.herbs = [] - formData.dose_count = 1 - formData.dose_unit = '剂' - formData.usage_days = 7 - formData.usage_instruction = '水煎服,一日二次' - formData.usage_time = '饭前' - formData.usage_way = '温水送服' - formData.dietary_taboo = [] - formData.usage_notes = '' - formData.amount = 0 - formData.doctor_name = userStore.userInfo?.name || '' - formData.doctor_signature = '' - formData.is_shared = 0 - - savedPrescription.value = null - visible.value = true - setTimeout(() => initSignatureCanvas(), 100) } const openById = async (prescriptionId: number) => { + // 防止重复打开 + if (isOpening.value) { + console.warn('处方正在打开中,请勿重复点击') + return + } + + isOpening.value = true + try { const res = await prescriptionDetail({ id: prescriptionId }) savedPrescription.value = res @@ -866,6 +894,11 @@ const openById = async (prescriptionId: number) => { visible.value = true } catch (e) { feedback.msgError('加载处方失败') + } finally { + // 延迟重置,避免快速连续点击 + setTimeout(() => { + isOpening.value = false + }, 500) } } diff --git a/admin/src/views/consumer/prescription/index.vue b/admin/src/views/consumer/prescription/index.vue index cac17e3e..6cfe555e 100644 --- a/admin/src/views/consumer/prescription/index.vue +++ b/admin/src/views/consumer/prescription/index.vue @@ -99,16 +99,25 @@
- - - - - + + + + + + + - @@ -139,15 +148,19 @@
- + - - - + + + + @@ -167,7 +180,7 @@ 创建订单 - - + - + -
-
关联消费者处方
-
- 处方 ID {{ createOrderPrescription.id }} - · 编号 {{ createOrderPrescription.sn }} - · {{ createOrderPrescription.patient_name || '—' }} -
-
- {{ createOrderHerbSummary }} + +
+
+
+ + + +
+
+
关联处方
+
+ ID {{ createOrderPrescription.id }} + · {{ createOrderPrescription.sn }} + · {{ createOrderPrescription.patient_name || '—' }} +
+
+ {{ createOrderHerbSummary }} +
+
+ - 患者与收货 - - - - - - - - - - - - - - - - 服务信息 - - - - - - - - - - - - - - - - - 物流 - - - - - - - - - - - - 关联收款 - - - - -
- 与「订单管理」中已支付订单一致;主管或本诊单医助可见该诊单全部已支付单,否则仅本人创建的收款单。已被其他有效业务订单占用的支付单不会出现在此列表。业务订单撤回后,本处方可再次创建订单并重新关联支付单。 + +
+
+ + + + 患者与收货
- +
+ + + + + + + + + + + + + + + + + + + + + + + + +
+
- 金额 - - - - - - - - - - - - - - - - -
写入业务订单表字段;无财务权限时后端不会保存该值。
-
+ +
+
+ + + + 服务信息 +
+
+ + + + + + + + + + + + + + + + + + + + + + + + + + + +
+
- - - + +
+
+ + + + 物流信息 +
+
+ + + + + + + + + + + + + + + + + +
+
+ + +
+
+ + + + 关联收款 +
+
+ + + +
+
+ {{ o.order_no }} + ¥{{ o.amount }} +
+
+ {{ formatPoCategory(o.order_type) }} + {{ String(o.create_time || '').substring(0, 16) }} +
+
+
+
+
+ 主管或本诊单医助可见该诊单全部已支付单,否则仅本人创建的收款单。已被其他有效业务订单占用的支付单不会出现在此列表。 +
+
+
+
+ + +
+
+ + + + 金额信息 +
+
+ +
+
+
+
定金门槛
+
+ ¥{{ formatPoDepositMoney(createOrderDepositMin) }} +
+
+ {{ createOrderDepositMin > 0 ? '订单和支付单总金额须 ≥ 此值' : '未设置' }} +
+
+
+
已选收款单
+
+ ¥{{ formatPoDepositMoney(createOrderLinkedPaidTotal) }} +
+
+ {{ createOrderLinkedPaidTotal >= createOrderDepositMin ? '✓ 已达标' : '✗ 未达标' }} +
+
+
+
+ + + + + + + + + + + + + + + + + + + + + + + + + + +
+
+ + +
+
+ + + + 备注说明 +
+
+ + + +
+
+ @@ -918,7 +1084,7 @@ import useUserStore from '@/stores/modules/user' import feedback from '@/utils/feedback' import type { FormInstance, FormRules } from 'element-plus' import DaterangePicker from '@/components/daterange-picker/index.vue' -import { computed, onMounted, reactive, ref, watch } from 'vue' +import { computed, onMounted, reactive, ref, watch, nextTick } from 'vue' import { useRouter } from 'vue-router' /** 与 server/config/project.php prescription_audit_roles 保持一致 */ @@ -979,8 +1145,27 @@ const createOrderFormRef = ref() const createOrderSubmitLoading = ref(false) const createOrderPatientLoading = ref(false) const createOrderPatientList = ref([]) -const createOrderPaidOrders = ref>([]) +const createOrderPaidOrders = ref>([]) const createOrderPaidOrdersLoading = ref(false) +const createOrderDepositMin = ref(0) + +function formatPoDepositMoney(v: unknown) { + const n = Number(v) + if (Number.isNaN(n)) return '—' + return n.toFixed(2) +} + +function formatPoCategory(t: unknown) { + const m: Record = { + 1: '挂号费', + 2: '问诊费', + 3: '药品费用', + 4: '首付', + 5: '尾款', + 6: '其他' + } + return m[Number(t)] || '未知' +} const createOrderForm = reactive({ patient_id: '' as number | string, @@ -1001,13 +1186,46 @@ const createOrderForm = reactive({ pay_order_ids: [] as number[] }) +const createOrderLinkedPaidTotal = computed(() => { + const ids = new Set(createOrderForm.pay_order_ids.map((x) => Number(x))) + let s = 0 + for (const o of createOrderPaidOrders.value) { + if (ids.has(Number(o.id))) { + s += Number(o.amount) || 0 + } + } + return Math.round(s * 100) / 100 +}) + +const createOrderAmountInputMin = computed(() => { + const m = createOrderDepositMin.value + if (m > 0) { + return Math.round(m * 100) / 100 + } + return 0 +}) + const createOrderRules: FormRules = { patient_id: [{ required: true, message: '请选择诊单患者', trigger: 'change' }], recipient_name: [{ required: true, message: '请输入收货人', trigger: 'blur' }], recipient_phone: [{ required: true, message: '请输入收货手机', trigger: 'blur' }], shipping_address: [{ required: true, message: '请输入收货地址', trigger: 'blur' }], fee_type: [{ required: true, message: '请选择费用类别', trigger: 'change' }], - amount: [{ required: true, message: '请输入订单金额', trigger: 'blur' }] + amount: [ + { required: true, message: '请输入订单金额', trigger: 'blur' }, + { + validator: (_rule, v, cb) => { + const min = createOrderDepositMin.value + const num = Number(v) + if (min > 0 && (!Number.isFinite(num) || num < min)) { + cb(new Error(`订单金额须大于等于定金门槛 ¥${formatPoDepositMoney(min)}`)) + return + } + cb() + }, + trigger: 'blur' + } + ] } const createOrderHerbSummary = computed(() => { @@ -1037,14 +1255,26 @@ function resetCreateOrderForm() { createOrderForm.pay_order_ids = [] createOrderPatientList.value = [] createOrderPaidOrders.value = [] + createOrderDepositMin.value = 0 createOrderPrescription.value = null createOrderFormRef.value?.clearValidate() } +function syncCreateOrderAmountFromRules() { + const min = createOrderDepositMin.value + const paid = createOrderLinkedPaidTotal.value + const floor = min > 0 ? Math.round(min * 100) / 100 : 0 + const need = min > 0 ? Math.max(paid, floor) : paid > 0 ? paid : 0 + if (need > 0 && Number(createOrderForm.amount) < need) { + createOrderForm.amount = Math.round(need * 100) / 100 + } +} + async function loadCreateOrderPaidOrders() { const did = Number(createOrderForm.patient_id) if (!did) { createOrderPaidOrders.value = [] + createOrderDepositMin.value = 0 return } try { @@ -1052,8 +1282,12 @@ async function loadCreateOrderPaidOrders() { const res: any = await prescriptionOrderPaidPayOrders({ diagnosis_id: did }) const lists = res?.lists ?? res?.data?.lists ?? [] createOrderPaidOrders.value = Array.isArray(lists) ? lists : [] + const dep = Number(res?.deposit_min_amount ?? res?.data?.deposit_min_amount) || 0 + createOrderDepositMin.value = dep + syncCreateOrderAmountFromRules() } catch { createOrderPaidOrders.value = [] + createOrderDepositMin.value = 0 } finally { createOrderPaidOrdersLoading.value = false } @@ -1067,6 +1301,14 @@ watch( } ) +watch( + () => [...createOrderForm.pay_order_ids], + () => { + syncCreateOrderAmountFromRules() + }, + { deep: true } +) + async function openCreateOrderFromPrescription(row: any) { resetCreateOrderForm() createOrderPrescription.value = row @@ -1580,6 +1822,9 @@ const handleAdd = () => { editingWasRejected.value = false editMode.value = 'add' showEdit.value = true + nextTick(() => { + formRef.value?.clearValidate() + }) } // 查看处方(处方笺样式,详情接口补全电话、签名等) @@ -1643,6 +1888,9 @@ const handleEdit = (row: any) => { editForm.business_prescription_audit_rejected = Number(row.business_prescription_audit_rejected) === 1 ? 1 : 0 editForm.business_prescription_audit_remark = String(row.business_prescription_audit_remark || '') showEdit.value = true + nextTick(() => { + formRef.value?.clearValidate() + }) } // 提交表单 @@ -1874,9 +2122,8 @@ onMounted(async () => { } /* 查看处方:互联网医院处方笺(对齐诊间 tcm-prescription 打印版式) */ -.cf-prescription-dialog :deep(.el-dialog__body) { +.cf-prescription-drawer :deep(.el-drawer__body) { padding-top: 8px; - max-height: calc(100vh - 140px); overflow-y: auto; } @@ -2048,4 +2295,53 @@ onMounted(async () => { color: #606266; line-height: 1.65; } + +/* 创建订单表单样式 */ +.create-order-dialog { + :deep(.el-dialog__body) { + max-height: calc(100vh - 200px); + overflow-y: auto; + padding: 16px 20px; + } +} + +.create-order-form { + .form-section { + margin-bottom: 20px; + + &:last-child { + margin-bottom: 0; + } + } + + .section-title { + display: flex; + align-items: center; + gap: 6px; + font-size: 13px; + font-weight: 600; + color: var(--el-text-color-primary); + margin-bottom: 12px; + padding-bottom: 6px; + border-bottom: 1px solid var(--el-border-color-lighter); + + svg { + color: var(--el-color-primary); + width: 14px; + height: 14px; + } + } + + .section-content { + padding-left: 2px; + } + + :deep(.el-form-item) { + margin-bottom: 14px; + } + + :deep(.el-form-item__label) { + font-size: 13px; + } +} diff --git a/admin/src/views/consumer/prescription/order_list.vue b/admin/src/views/consumer/prescription/order_list.vue index ec360270..318105f7 100644 --- a/admin/src/views/consumer/prescription/order_list.vue +++ b/admin/src/views/consumer/prescription/order_list.vue @@ -1,7 +1,14 @@ + + diff --git a/admin/src/views/tcm/appointment/components/prescription-drawer.vue b/admin/src/views/tcm/appointment/components/prescription-drawer.vue index 88c09306..7e6ad6fc 100644 --- a/admin/src/views/tcm/appointment/components/prescription-drawer.vue +++ b/admin/src/views/tcm/appointment/components/prescription-drawer.vue @@ -407,7 +407,8 @@ const resetForm = () => { // 打开抽屉 const open = (data: any) => { resetForm() - + + formData.appointment_id = data.id || 0 formData.diagnosis_id = data.diagnosis_id || 0 formData.patient_name = data.patient_name || '' diff --git a/admin/src/views/tcm/appointment/list.vue b/admin/src/views/tcm/appointment/list.vue index ae66fd10..357bc8b4 100644 --- a/admin/src/views/tcm/appointment/list.vue +++ b/admin/src/views/tcm/appointment/list.vue @@ -179,15 +179,6 @@