This commit is contained in:
Your Name
2026-04-10 14:43:58 +08:00
parent fdf714f833
commit 4909ec6daa
38 changed files with 6549 additions and 735 deletions
+208
View File
@@ -0,0 +1,208 @@
# 预约列表按钮显示优化
## 问题描述
在预约列表中,同样的情况下,有的预约显示"开方"按钮,有的不显示。
## 问题原因
操作列的按钮过多,导致"开方"按钮被挤出可视区域或被其他按钮遮挡。
原有按钮(从左到右):
1. 编辑患者
2. 视频二维码
3. 通话
4. 完成
5. 开方/查看
6. 更多
当所有按钮都显示时,操作列宽度为 380px,可能不够容纳所有按钮。
## 解决方案
根据预约状态优化按钮显示:
### 已完成状态(status = 3
只显示核心按钮:
- 编辑患者
- 开方/查看
- 更多
隐藏按钮:
- 视频二维码(已完成,不需要视频)
- 通话(已完成,不需要通话)
- 完成(已经完成)
### 其他状态(待接诊、已过号、已取消)
显示所有按钮:
- 编辑患者
- 视频二维码
- 通话
- 完成
- 开方/查看
- 更多
## 修改内容
**文件**: `admin/src/views/tcm/appointment/list.vue`
### 修改前
```vue
<el-table-column label="操作" width="380" fixed="right" align="left">
<template #default="{ row }">
<div class="action-btns">
<el-button>编辑患者</el-button>
<el-button>视频二维码</el-button>
<el-button>通话</el-button>
<el-button>完成</el-button>
<el-button>{{ prescriptionActionLabel(row) }}</el-button>
<el-dropdown>更多</el-dropdown>
</div>
</template>
</el-table-column>
```
### 修改后
```vue
<el-table-column label="操作" width="380" fixed="right" align="left">
<template #default="{ row }">
<div class="action-btns">
<el-button>编辑患者</el-button>
<!-- 已完成状态不显示这些按钮 -->
<template v-if="row.status !== 3">
<el-button>视频二维码</el-button>
<el-button>通话</el-button>
<el-button>完成</el-button>
</template>
<!-- 开方/查看按钮始终显示 -->
<el-button>{{ prescriptionActionLabel(row) }}</el-button>
<el-dropdown>更多</el-dropdown>
</div>
</template>
</el-table-column>
```
## 按钮显示规则
### 预约状态说明
| 状态值 | 状态名称 | 说明 |
|-------|---------|------|
| 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` - 处方审核通过后锁定编辑文档
## 总结
通过根据预约状态优化按钮显示,确保了:
- ✅ "开方/查看"按钮始终可见
- ✅ 已完成状态下界面更简洁
- ✅ 避免按钮被挤出可视区域
- ✅ 优化用户体验
- ✅ 保持核心功能的可访问性
+176
View File
@@ -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账号是否已充值
+142
View File
@@ -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
<el-table-column v-if="canViewFinanceFields()" label="内部成本" width="92">
<template #default="{ row }">
{{ row.internal_cost != null && row.internal_cost !== '' ? `¥${row.internal_cost}` : '—' }}
</template>
</el-table-column>
```
**金额信息弹出框(只有有权限的用户才能看到内部成本行):**
```vue
<div v-if="canViewFinanceFields() && row.internal_cost != null && row.internal_cost !== ''" class="flex justify-between">
<span class="text-gray-500">内部成本</span>
<span class="font-medium text-orange-600">¥{{ formatMoney(row.internal_cost) }}</span>
</div>
```
## 权限控制层级
### 后端(第一层防护)
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` - 用户信息存储
+308
View File
@@ -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,就可以赋予该角色管理员权限,无需修改代码。
+84
View File
@@ -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是否正确分配
+358
View File
@@ -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)赋予了以下权限:
- ✅ 订单管理全部权限(查看、编辑、关联支付单)
- ✅ 消费者处方审核权限(通过、驳回)
- ✅ 支付单审核权限(通过、驳回)
- ❌ 处方库管理权限(不需要)
- ❌ 财务字段查看权限(不需要)
药师现在可以完整地参与处方审核和订单管理流程。
+384
View File
@@ -0,0 +1,384 @@
# 处方审核通过后锁定编辑和作废
## 修改说明
处方审核通过后,禁止编辑和作废操作,确保已审核通过的处方不被修改,保证处方的完整性和可追溯性。
## 修改内容
### 1. 前端修改
**文件**: `admin/src/views/consumer/prescription/index.vue`
#### 编辑按钮
**修改前**:
```vue
<el-button
v-if="!isApprovedActivePrescription(row) || Number(row.business_prescription_audit_rejected) === 1"
type="primary"
link
@click="handleEdit(row)"
v-perms="['cf.prescription/edit']"
>
编辑
</el-button>
```
**修改后**:
```vue
<el-button
v-if="!isApprovedActivePrescription(row)"
type="primary"
link
@click="handleEdit(row)"
v-perms="['cf.prescription/edit']"
>
编辑
</el-button>
```
**改进点**:
- 移除了业务订单驳回的例外情况
- 只要处方审核通过且未作废,就不显示编辑按钮
- 简化了逻辑,更加严格
#### 删除按钮
删除按钮已经使用了 `isApprovedActivePrescription(row)` 判断,无需修改:
```vue
<el-button
v-if="!isApprovedActivePrescription(row)"
type="danger"
link
@click="handleDelete(row.id)"
v-perms="['cf.prescription/del']"
>
删除
</el-button>
```
#### 判断函数
```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修复总结文档
## 总结
通过移除业务订单驳回的例外情况,确保了:
- ✅ 已审核通过的处方不可编辑
- ✅ 已审核通过的处方不可删除
- ✅ 已审核通过的处方不可作废(驳回)
- ✅ 强制执行"撤回 → 编辑 → 重新审核"的规范流程
- ✅ 保证处方的完整性和可追溯性
- ✅ 责任链清晰,审核记录完整
- ✅ 前后端双重校验,防止绕过限制
+204
View File
@@ -0,0 +1,204 @@
# 处方按钮显示检查
## 问题描述
用户反馈:处方审核通过后,"开方"按钮不显示了。
## 代码分析
### 前端代码
**文件**: `admin/src/views/tcm/appointment/list.vue`
#### 按钮显示逻辑
```vue
<el-button
v-perms="['tcm.diagnosis/kaifang']"
type="primary"
link
size="small"
@click="handlePrescription(row)"
>
{{ prescriptionActionLabel(row) }}
</el-button>
```
#### 按钮文字逻辑
```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. 数据返回有问题,需要检查后端接口
建议先检查权限配置和数据返回,确认按钮是否真的不显示。
+321
View File
@@ -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
<el-button
:loading="isOpening"
@click="handlePrescription(row)"
>
{{ prescriptionActionLabel(row) }}
</el-button>
```
但这需要将 `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. 考虑是否需要用户提示
@@ -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修复总结文档
## 总结
通过添加权限检查,确保了:
- ✅ 用户只能查询自己有权限查看的处方
- ✅ 防止越权访问其他医生的私有处方
- ✅ 支持医生协作(有开方权限的医生可以查看所有处方)
- ✅ 保护患者隐私和数据安全
- ✅ 提供明确的错误信息,便于前端处理
+318
View File
@@ -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. 更新用户文档
+387
View File
@@ -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. 考虑添加事务支持
+129
View File
@@ -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`
+109 -9
View File
@@ -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) {
+1 -1
View File
@@ -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'
@@ -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;
+39
View File
@@ -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 })
}
// ========== 处方库 ==========
// 处方库列表
+97 -64
View File
@@ -635,6 +635,9 @@ const showLibraryDialog = ref(false)
const libraryLoading = ref(false)
const libraryList = ref<any[]>([])
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)
}
}
+475 -179
View File
@@ -99,16 +99,25 @@
<div class="mt-3">
<el-table :data="pager.lists" size="default" stripe>
<el-table-column label="ID" prop="id" min-width="60" />
<el-table-column label="处方编号" prop="sn" min-width="140" />
<el-table-column label="类型" prop="prescription_type" min-width="100" />
<el-table-column label="患者姓名" prop="patient_name" min-width="100" />
<el-table-column label="性别" min-width="60">
<el-table-column label="处方编号" min-width="160">
<template #default="{ row }">
{{ row.gender === 1 ? '男' : '女' }}
<span class="font-medium">{{ row.sn || '—' }}</span>
<div class="text-xs text-gray-400 mt-0.5">ID: {{ row.id }}</div>
</template>
</el-table-column>
<el-table-column label="处方类型" min-width="100">
<template #default="{ row }">
<el-tag type="info" size="small">{{ row.prescription_type || '—' }}</el-tag>
</template>
</el-table-column>
<el-table-column label="患者信息" min-width="110">
<template #default="{ row }">
<div class="font-medium">{{ row.patient_name || '—' }}</div>
<div class="text-xs text-gray-500 mt-0.5">
{{ row.gender === 1 ? '男' : (row.gender === 0 ? '女' : '未知') }} · {{ row.age ? row.age + '岁' : '—' }}
</div>
</template>
</el-table-column>
<el-table-column label="年龄" prop="age" min-width="60" />
@@ -139,15 +148,19 @@
</div>
</template>
</el-table-column>
<el-table-column label="作废" min-width="80">
<el-table-column label="作废" min-width="80" align="center">
<template #default="{ row }">
<el-tag v-if="row.void_status === 1" type="danger" size="small">作废</el-tag>
<el-tag v-if="row.void_status === 1" type="danger" size="small">作废</el-tag>
<span v-else class="text-gray-400"></span>
</template>
</el-table-column>
<el-table-column label="医师" prop="doctor_name" min-width="100" />
<el-table-column label="处方日期" prop="prescription_date" min-width="120" />
<el-table-column label="创建时间" min-width="170">
<el-table-column label="医师信息" min-width="120">
<template #default="{ row }">
<div class="font-medium">{{ row.doctor_name || '—' }}</div>
<div class="text-xs text-gray-500 mt-0.5">{{ row.prescription_date || '—' }}</div>
</template>
</el-table-column>
<el-table-column label="创建时间" min-width="160">
<template #default="{ row }">
{{ formatListCreateTime(row.create_time) }}
</template>
@@ -167,7 +180,7 @@
创建订单
</el-button>
<el-button
v-if="!isApprovedActivePrescription(row) || Number(row.business_prescription_audit_rejected) === 1"
v-if="!isApprovedActivePrescription(row)"
type="primary"
link
@click="handleEdit(row)"
@@ -203,12 +216,12 @@
</div>
</el-card>
<!-- 新增/编辑弹窗查看 = 处方笺样式 -->
<el-dialog
<!-- 新增/编辑/查看抽屉 -->
<el-drawer
v-model="showEdit"
:title="editMode === 'add' ? '新增处方' : editMode === 'edit' ? '编辑处方' : '查看处方'"
:width="editMode === 'view' ? '920px' : '900px'"
class="cf-prescription-dialog"
:size="editMode === 'view' ? '920px' : '860px'"
class="cf-prescription-drawer"
:close-on-click-modal="false"
destroy-on-close
@closed="onEditDialogClosed"
@@ -695,182 +708,335 @@
</el-button>
</template>
</template>
</el-dialog>
</el-drawer>
<!-- 从消费者处方创建业务订单(zyt_tcm_prescription_order,与支付单 zyt_order 分离) -->
<el-dialog
v-model="createOrderVisible"
title="创建业务订单"
width="640px"
width="850px"
:close-on-click-modal="false"
@close="resetCreateOrderForm"
@closed="resetCreateOrderForm"
class="create-order-dialog"
>
<div v-if="createOrderPrescription" class="mb-4 rounded bg-gray-50 px-3 py-2 text-sm text-gray-700">
<div class="font-medium text-gray-900">关联消费者处方</div>
<div>
处方 ID {{ createOrderPrescription.id }}
<span v-if="createOrderPrescription.sn"> · 编号 {{ createOrderPrescription.sn }}</span>
· {{ createOrderPrescription.patient_name || '—' }}
</div>
<div class="text-gray-500 mt-1 line-clamp-2">
{{ createOrderHerbSummary }}
<!-- 关联处方信息卡片 -->
<div v-if="createOrderPrescription" class="mb-4 rounded-lg border border-blue-100 bg-blue-50 p-3">
<div class="flex items-start gap-2">
<div class="flex-shrink-0 mt-0.5">
<svg class="w-4 h-4 text-blue-600" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 12h6m-6 4h6m2 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z" />
</svg>
</div>
<div class="flex-1 min-w-0">
<div class="text-xs font-medium text-blue-900 mb-0.5">关联处方</div>
<div class="text-xs text-blue-700">
ID {{ createOrderPrescription.id }}
<span v-if="createOrderPrescription.sn"> · {{ createOrderPrescription.sn }}</span>
· {{ createOrderPrescription.patient_name || '—' }}
</div>
<div class="text-xs text-blue-600 mt-1 line-clamp-1">
{{ createOrderHerbSummary }}
</div>
</div>
</div>
</div>
<el-form
ref="createOrderFormRef"
:model="createOrderForm"
:rules="createOrderRules"
label-width="120px"
label-width="90px"
class="create-order-form"
>
<el-divider content-position="left">患者与收货</el-divider>
<el-form-item label="诊单患者" prop="patient_id">
<el-select
v-model="createOrderForm.patient_id"
class="w-full"
placeholder="搜索姓名/手机/身份证"
filterable
remote
:remote-method="searchPatientsForOrder"
:loading="createOrderPatientLoading"
@change="onCreateOrderPatientChange"
>
<el-option
v-for="item in createOrderPatientList"
:key="item.id"
:label="`${item.patient_name} (${item.phone || '无手机'})`"
:value="item.id"
/>
</el-select>
</el-form-item>
<el-form-item label="收货人" prop="recipient_name">
<el-input v-model="createOrderForm.recipient_name" placeholder="可与诊单姓名一致" maxlength="50" />
</el-form-item>
<el-form-item label="收货手机" prop="recipient_phone">
<el-input v-model="createOrderForm.recipient_phone" placeholder="用于物流联系与单号匹配" maxlength="20" />
</el-form-item>
<el-form-item label="收货地址" prop="shipping_address">
<el-input
v-model="createOrderForm.shipping_address"
type="textarea"
:rows="2"
placeholder="完整收货地址"
maxlength="200"
show-word-limit
/>
</el-form-item>
<el-divider content-position="left">服务信息</el-divider>
<el-form-item label="是否复诊">
<el-switch v-model="createOrderForm.is_follow_up" :active-value="1" :inactive-value="0" />
</el-form-item>
<el-form-item label="用药疗程(天)">
<el-input-number
v-model="createOrderForm.medication_days"
:min="1"
:max="999"
:step="1"
placeholder="天数"
class="w-full"
/>
</el-form-item>
<el-form-item label="上次医生/医助">
<el-input v-model="createOrderForm.prev_staff" placeholder="可选" maxlength="100" />
</el-form-item>
<el-form-item label="服务渠道">
<el-input v-model="createOrderForm.service_channel" placeholder="可选" maxlength="100" />
</el-form-item>
<el-form-item label="服务套餐">
<el-input v-model="createOrderForm.service_package" placeholder="可选" maxlength="100" />
</el-form-item>
<el-divider content-position="left">物流</el-divider>
<el-form-item label="快递公司">
<el-select v-model="createOrderForm.express_company" placeholder="查物流用" class="w-full">
<el-option label="自动识别" value="auto" />
<el-option label="顺丰速运" value="sf" />
<el-option label="京东快递" value="jd" />
</el-select>
</el-form-item>
<el-form-item label="快递单号">
<el-input v-model="createOrderForm.tracking_number" placeholder="可创建时填写或发货前补录" maxlength="80" />
</el-form-item>
<el-divider content-position="left">关联收款</el-divider>
<el-form-item label="已支付单">
<el-select
v-model="createOrderForm.pay_order_ids"
class="w-full"
multiple
collapse-tags
collapse-tags-tooltip
placeholder="多选本诊单下已支付收款单"
:loading="createOrderPaidOrdersLoading"
>
<el-option
v-for="o in createOrderPaidOrders"
:key="o.id"
:label="`${o.order_no} · ¥${o.amount}`"
:value="o.id"
/>
</el-select>
<div class="text-xs text-gray-400 mt-1">
与「订单管理」中已支付订单一致;主管或本诊单医助可见该诊单全部已支付单,否则仅本人创建的收款单。已被其他有效业务订单占用的支付单不会出现在此列表。业务订单撤回后,本处方可再次创建订单并重新关联支付单。
<!-- 患者与收货信息 -->
<div class="form-section">
<div class="section-title">
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M16 7a4 4 0 11-8 0 4 4 0 018 0zM12 14a7 7 0 00-7 7h14a7 7 0 00-7-7z" />
</svg>
<span>患者与收货</span>
</div>
</el-form-item>
<div class="section-content">
<el-row :gutter="16">
<el-col :span="24">
<el-form-item label="诊单患者" prop="patient_id">
<el-select
v-model="createOrderForm.patient_id"
class="w-full"
placeholder="搜索姓名/手机/身份证"
filterable
remote
:remote-method="searchPatientsForOrder"
:loading="createOrderPatientLoading"
@change="onCreateOrderPatientChange"
>
<el-option
v-for="item in createOrderPatientList"
:key="item.id"
:label="`${item.patient_name} (${item.phone || '无手机'})`"
:value="item.id"
/>
</el-select>
</el-form-item>
</el-col>
<el-col :span="12">
<el-form-item label="收货人" prop="recipient_name">
<el-input v-model="createOrderForm.recipient_name" placeholder="收货人姓名" maxlength="50" />
</el-form-item>
</el-col>
<el-col :span="12">
<el-form-item label="收货手机" prop="recipient_phone">
<el-input v-model="createOrderForm.recipient_phone" placeholder="用于物流联系" maxlength="20" />
</el-form-item>
</el-col>
<el-col :span="24">
<el-form-item label="收货地址" prop="shipping_address">
<el-input
v-model="createOrderForm.shipping_address"
type="textarea"
:rows="2"
placeholder="完整收货地址"
maxlength="200"
show-word-limit
/>
</el-form-item>
</el-col>
</el-row>
</div>
</div>
<el-divider content-position="left">金额</el-divider>
<el-form-item label="费用类别" prop="fee_type">
<el-select v-model="createOrderForm.fee_type" placeholder="请选择" class="w-full">
<el-option label="挂号费" :value="1" />
<el-option label="问诊费" :value="2" />
<el-option label="药品费用" :value="3" />
<el-option label="首付费用" :value="4" />
<el-option label="尾款费用" :value="5" />
<el-option label="其他费用" :value="6" />
<el-option label="全部费用" :value="7" />
</el-select>
</el-form-item>
<el-form-item label="订单金额" prop="amount">
<el-input-number
v-model="createOrderForm.amount"
:min="0"
:step="0.01"
:precision="2"
class="w-full"
placeholder="业务订单金额(元)"
/>
</el-form-item>
<el-form-item
v-perms="['finance.account_log/lists']"
label="内部成本"
>
<el-input-number
v-model="createOrderForm.internal_cost"
:min="0"
:step="0.01"
:precision="2"
class="w-full"
placeholder="仅财务角色写入库表"
/>
<div class="text-xs text-gray-400 mt-1">写入业务订单表字段;无财务权限时后端不会保存该值。</div>
</el-form-item>
<!-- 服务信息 -->
<div class="form-section">
<div class="section-title">
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 5H7a2 2 0 00-2 2v12a2 2 0 002 2h10a2 2 0 002-2V7a2 2 0 00-2-2h-2M9 5a2 2 0 002 2h2a2 2 0 002-2M9 5a2 2 0 012-2h2a2 2 0 012 2" />
</svg>
<span>服务信息</span>
</div>
<div class="section-content">
<el-row :gutter="16">
<el-col :span="8">
<el-form-item label="是否复诊">
<el-switch v-model="createOrderForm.is_follow_up" :active-value="1" :inactive-value="0" />
</el-form-item>
</el-col>
<el-col :span="8">
<el-form-item label="用药疗程">
<el-input-number
v-model="createOrderForm.medication_days"
:min="1"
:max="999"
:step="1"
placeholder="天数"
class="w-full"
/>
</el-form-item>
</el-col>
<el-col :span="8">
<el-form-item label="上次医生">
<el-input v-model="createOrderForm.prev_staff" placeholder="可选" maxlength="100" />
</el-form-item>
</el-col>
<el-col :span="12">
<el-form-item label="服务渠道">
<el-input v-model="createOrderForm.service_channel" placeholder="可选" maxlength="100" />
</el-form-item>
</el-col>
<el-col :span="12">
<el-form-item label="服务套餐">
<el-input v-model="createOrderForm.service_package" placeholder="可选" maxlength="100" />
</el-form-item>
</el-col>
</el-row>
</div>
</div>
<el-form-item label="备注补充">
<el-input
v-model="createOrderForm.remark_extra"
type="textarea"
:rows="2"
placeholder="其他说明(可选)"
maxlength="200"
show-word-limit
/>
</el-form-item>
<!-- 物流信息 -->
<div class="form-section">
<div class="section-title">
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M20 7l-8-4-8 4m16 0l-8 4m8-4v10l-8 4m0-10L4 7m8 4v10M4 7v10l8 4" />
</svg>
<span>物流信息</span>
</div>
<div class="section-content">
<el-row :gutter="16">
<el-col :span="12">
<el-form-item label="快递公司">
<el-select v-model="createOrderForm.express_company" placeholder="查物流用" class="w-full">
<el-option label="自动识别" value="auto" />
<el-option label="顺丰速运" value="sf" />
<el-option label="京东快递" value="jd" />
<el-option label="极兔速递" value="jt" />
</el-select>
</el-form-item>
</el-col>
<el-col :span="12">
<el-form-item label="快递单号">
<el-input v-model="createOrderForm.tracking_number" placeholder="可创建后补录" maxlength="80" />
</el-form-item>
</el-col>
</el-row>
</div>
</div>
<!-- 关联收款 -->
<div class="form-section">
<div class="section-title">
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M17 9V7a2 2 0 00-2-2H5a2 2 0 00-2 2v6a2 2 0 002 2h2m2 4h10a2 2 0 002-2v-6a2 2 0 00-2-2H9a2 2 0 00-2 2v6a2 2 0 002 2zm7-5a2 2 0 11-4 0 2 2 0 014 0z" />
</svg>
<span>关联收款</span>
</div>
<div class="section-content">
<el-form-item label="已支付单">
<el-select
v-model="createOrderForm.pay_order_ids"
class="w-full"
multiple
collapse-tags
collapse-tags-tooltip
placeholder="多选本诊单下已支付收款单"
:loading="createOrderPaidOrdersLoading"
>
<el-option
v-for="o in createOrderPaidOrders"
:key="o.id"
:label="`${o.order_no} · ¥${o.amount} · ${formatPoCategory(o.order_type)}`"
:value="o.id"
class="!h-auto py-1.5"
>
<div class="flex flex-col gap-0.5" style="line-height: normal;">
<div class="flex items-center justify-between">
<span class="font-medium text-[13px] text-gray-800">{{ o.order_no }}</span>
<span class="text-orange-500 font-medium ml-4">¥{{ o.amount }}</span>
</div>
<div class="flex items-center justify-between text-xs text-gray-400 mt-1">
<span class="truncate pr-2">{{ formatPoCategory(o.order_type) }}<template v-if="o.remark"> · {{ o.remark }}</template></span>
<span class="shrink-0">{{ String(o.create_time || '').substring(0, 16) }}</span>
</div>
</div>
</el-option>
</el-select>
<div class="text-xs text-gray-500 mt-1.5 leading-relaxed">
主管或本诊单医助可见该诊单全部已支付单,否则仅本人创建的收款单。已被其他有效业务订单占用的支付单不会出现在此列表。
</div>
</el-form-item>
</div>
</div>
<!-- 金额信息 -->
<div class="form-section">
<div class="section-title">
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 8c-1.657 0-3 .895-3 2s1.343 2 3 2 3 .895 3 2-1.343 2-3 2m0-8c1.11 0 2.08.402 2.599 1M12 8V7m0 1v8m0 0v1m0-1c-1.11 0-2.08-.402-2.599-1M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />
</svg>
<span>金额信息</span>
</div>
<div class="section-content">
<!-- 金额概览卡片 -->
<div class="mb-3 rounded-lg border border-gray-200 bg-gray-50 p-3">
<div class="grid grid-cols-2 gap-3">
<div>
<div class="text-xs text-gray-500 mb-0.5">定金门槛</div>
<div class="text-base font-semibold text-gray-900">
¥{{ formatPoDepositMoney(createOrderDepositMin) }}
</div>
<div class="text-xs text-gray-400 mt-0.5">
{{ createOrderDepositMin > 0 ? '订单和支付单总金额须 ≥ 此值' : '未设置' }}
</div>
</div>
<div>
<div class="text-xs text-gray-500 mb-0.5">已选收款单</div>
<div class="text-base font-semibold" :class="createOrderLinkedPaidTotal >= createOrderDepositMin ? 'text-green-600' : 'text-red-600'">
¥{{ formatPoDepositMoney(createOrderLinkedPaidTotal) }}
</div>
<div class="text-xs mt-0.5" :class="createOrderLinkedPaidTotal >= createOrderDepositMin ? 'text-green-600' : 'text-red-600'">
{{ createOrderLinkedPaidTotal >= createOrderDepositMin ? '✓ 已达标' : '✗ 未达标' }}
</div>
</div>
</div>
</div>
<el-row :gutter="16">
<el-col :span="12">
<el-form-item label="费用类别" prop="fee_type">
<el-select v-model="createOrderForm.fee_type" placeholder="请选择" class="w-full">
<el-option label="挂号费" :value="1" />
<el-option label="问诊费" :value="2" />
<el-option label="药品费用" :value="3" />
<el-option label="首付费用" :value="4" />
<el-option label="尾款费用" :value="5" />
<el-option label="其他费用" :value="6" />
<el-option label="全部费用" :value="7" />
</el-select>
</el-form-item>
</el-col>
<el-col :span="12">
<el-form-item label="订单金额" prop="amount">
<el-input-number
v-model="createOrderForm.amount"
:min="createOrderAmountInputMin"
:step="0.01"
:precision="2"
class="w-full"
placeholder="业务订单金额(元)"
/>
</el-form-item>
</el-col>
<el-col v-perms="['finance.account_log/lists']" :span="12">
<el-form-item label="内部成本">
<el-input-number
v-model="createOrderForm.internal_cost"
:min="0"
:step="0.01"
:precision="2"
class="w-full"
placeholder="仅财务角色可见"
/>
</el-form-item>
</el-col>
</el-row>
</div>
</div>
<!-- 备注 -->
<div class="form-section">
<div class="section-title">
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M7 8h10M7 12h4m1 8l-4-4H5a2 2 0 01-2-2V6a2 2 0 012-2h14a2 2 0 012 2v8a2 2 0 01-2 2h-3l-4 4z" />
</svg>
<span>备注说明</span>
</div>
<div class="section-content">
<el-form-item label="备注补充">
<el-input
v-model="createOrderForm.remark_extra"
type="textarea"
:rows="2"
placeholder="其他说明(可选)"
maxlength="200"
show-word-limit
/>
</el-form-item>
</div>
</div>
</el-form>
<template #footer>
<el-button @click="createOrderVisible = false">取消</el-button>
<el-button type="primary" :loading="createOrderSubmitLoading" @click="submitCreateOrderFromPrescription">
创建业务订单
</el-button>
<div class="flex items-center justify-between">
<div class="text-xs text-gray-500">
<span v-if="createOrderDepositMin > 0" class="text-amber-600">
提示:订单金额和关联支付单总金额须 ≥ ¥{{ formatPoDepositMoney(createOrderDepositMin) }}
</span>
</div>
<div class="flex gap-2">
<el-button @click="createOrderVisible = false">取消</el-button>
<el-button type="primary" :loading="createOrderSubmitLoading" @click="submitCreateOrderFromPrescription">
创建业务订单
</el-button>
</div>
</div>
</template>
</el-dialog>
@@ -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<FormInstance>()
const createOrderSubmitLoading = ref(false)
const createOrderPatientLoading = ref(false)
const createOrderPatientList = ref<any[]>([])
const createOrderPaidOrders = ref<Array<{ id: number; order_no?: string; amount?: number | string }>>([])
const createOrderPaidOrders = ref<Array<{ id: number; order_no?: string; amount?: number | string; order_type?: number | string; remark?: string; create_time?: string }>>([])
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<number, string> = {
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;
}
}
</style>
File diff suppressed because it is too large Load Diff
@@ -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 || ''
+13 -18
View File
@@ -179,15 +179,6 @@
<el-table-column label="操作" width="380" fixed="right" align="left">
<template #default="{ row }">
<div class="action-btns">
<!-- <el-button
v-perms="['doctor.appointment/detail']"
type="primary"
link
size="small"
@click="handleDetail(row)"
>
详情
</el-button> -->
<el-button
v-perms="['tcm.diagnosis/edit']"
type="primary"
@@ -198,6 +189,7 @@
编辑患者
</el-button>
<template v-if="row.status !== 3">
<el-button
v-perms="['tcm.diagnosis/videoQr']"
type="warning"
@@ -227,15 +219,17 @@
>
完成
</el-button>
<el-button
v-perms="['tcm.diagnosis/kaifang']"
type="primary"
link
size="small"
@click="handlePrescription(row)"
>
{{ prescriptionActionLabel(row) }}
</el-button>
</template>
<el-button
v-perms="['tcm.diagnosis/kaifang']"
type="primary"
link
size="small"
@click="handlePrescription(row)"
>
{{ prescriptionActionLabel(row) }}
</el-button>
<el-dropdown trigger="click" @command="(cmd) => handleAction(cmd, row)" placement="bottom-end">
<el-button type="info" link size="small">
@@ -959,6 +953,7 @@ function prescriptionActionLabel(row: any) {
// 开处方 / 查看已通过处方(只读)
const handlePrescription = (row: any) => {
//console.log(row)
if (!row.diagnosis_id && !row.patient_id) {
feedback.msgWarning('该预约缺少诊单信息,请先编辑患者')
return
+3
View File
@@ -41,6 +41,9 @@ UNIQUE_IDENTIFICATION = likeadmin
# 演示环境
DEMO_ENV = false
; 处方业务订单:关联收款每笔金额须大于等于该值(元),业务订单金额也须大于等于该值;0=不校验
PRESCRIPTION_ORDER_LINK_PAY_MIN_AMOUNT = 0
# 物流轨迹(快递100):https://api.kuaidi100.com
; ThinkPHP .env 为 INI:下列建议写在分区声明之前;若误写在 trtc 分区内,config 已兼容
# 同时填 CUSTOMER、KEY 即启用;临时关闭设 LOGISTICS_KUAIDI100_DISABLE = true
@@ -68,6 +68,7 @@ class PrescriptionController extends BaseAdminController
{
$params = (new PrescriptionValidate())->get()->goCheck('detail');
$detail = PrescriptionLogic::detail((int) $params['id'], (int) $this->adminId, $this->adminInfo);
if (!$detail) {
$msg = PrescriptionLogic::getError();
@@ -129,8 +130,12 @@ class PrescriptionController extends BaseAdminController
if (!$appointmentId) {
return $this->fail('预约ID不能为空');
}
$prescription = PrescriptionLogic::getByAppointment($appointmentId);
return $this->data($prescription ?? []);
$prescription = PrescriptionLogic::getByAppointment($appointmentId, (int)$this->adminId, $this->adminInfo);
if ($prescription === null) {
//$msg = PrescriptionLogic::getError();
return '';
}
return $this->data($prescription);
}
/**
@@ -34,7 +34,19 @@ class PrescriptionOrderController extends BaseAdminController
$exceptPo > 0 ? $exceptPo : null
);
return $this->success('', ['lists' => $lists]);
$min = PrescriptionOrderLogic::depositMinAmount();
// if ($min > 0) {
// $lists = array_values(array_filter(
// $lists,
// static fn(array $r): bool => round((float) ($r['amount'] ?? 0), 2) >= $min
// ));
// }
return $this->success('', [
'lists' => $lists,
'deposit_min_amount' => $min,
]);
}
public function create()
@@ -107,6 +119,24 @@ class PrescriptionOrderController extends BaseAdminController
return $this->success('操作成功', $result);
}
/**
* 撤回处方审核
*/
public function revokeRxAudit()
{
$params = (new PrescriptionOrderValidate())->post()->goCheck('detail');
$result = PrescriptionOrderLogic::revokeRxAudit(
(int) $params['id'],
$this->adminId,
$this->adminInfo
);
if ($result === false) {
return $this->fail(PrescriptionOrderLogic::getError());
}
return $this->success('处方审核已撤回', $result);
}
public function auditPayment()
{
$params = (new PrescriptionOrderValidate())->post()->goCheck('auditPayment');
@@ -124,6 +154,44 @@ class PrescriptionOrderController extends BaseAdminController
return $this->success('操作成功', $result);
}
/**
* 撤回支付单审核
*/
public function revokePayAudit()
{
$params = (new PrescriptionOrderValidate())->post()->goCheck('detail');
$result = PrescriptionOrderLogic::revokePayAudit(
(int) $params['id'],
$this->adminId,
$this->adminInfo
);
if ($result === false) {
return $this->fail(PrescriptionOrderLogic::getError());
}
return $this->success('支付单审核已撤回', $result);
}
/**
* 确认发货:将履约状态推进到 5(已发货),同时更新快递信息
*/
public function ship()
{
$params = (new PrescriptionOrderValidate())->post()->goCheck('ship');
$result = PrescriptionOrderLogic::ship(
(int) $params['id'],
(string) ($params['express_company'] ?? 'auto'),
(string) ($params['tracking_number'] ?? ''),
$this->adminId,
$this->adminInfo
);
if ($result === false) {
return $this->fail(PrescriptionOrderLogic::getError());
}
return $this->success('发货成功', $result);
}
/**
* 医助/创建人撤回:仅「待双审通过」可撤(未通过或双审均驳回等仍为该状态)
*/
@@ -137,4 +205,48 @@ class PrescriptionOrderController extends BaseAdminController
return $this->success('已撤回', $result);
}
/**
* 获取业务订单操作日志
*/
public function logs()
{
$params = (new PrescriptionOrderValidate())->get()->goCheck('logs');
$result = PrescriptionOrderLogic::getLogs((int) $params['id'], $this->adminId, $this->adminInfo);
// 遇到没有权限或者订单不存在,逻辑里设置了 self::$error
if ($result === [] && PrescriptionOrderLogic::getError() !== '') {
return $this->fail(PrescriptionOrderLogic::getError());
}
return $this->success('', $result);
}
/**
* 为「已发货」订单新增一条关联支付单,并重置支付单审核状态为待审核
*/
public function addPayOrder()
{
$params = (new PrescriptionOrderValidate())->post()->goCheck('addPayOrder');
$result = PrescriptionOrderLogic::addPayOrder($params, $this->adminId, $this->adminInfo);
if ($result === false) {
return $this->fail(PrescriptionOrderLogic::getError());
}
return $this->success('新增支付单成功', $result);
}
/**
* 将「已发货」且支付审核已通过的订单标记为「已完成」
*/
public function complete()
{
$params = (new PrescriptionOrderValidate())->post()->goCheck('complete');
$result = PrescriptionOrderLogic::complete((int) $params['id'], $this->adminId, $this->adminInfo);
if ($result === false) {
return $this->fail(PrescriptionOrderLogic::getError());
}
return $this->success('订单已完成', $result);
}
}
@@ -82,7 +82,7 @@ class OrderLists extends BaseAdminDataLists implements ListsSearchInterface
$where[] = ['patient_id', 'in', function ($query) {
$query->table('zyt_tcm_diagnosis')
->whereNull('delete_time')
->where('patient_name|patient_phone', 'like', '%' . $this->params['patient_keyword'] . '%')
->where('patient_name|phone', 'like', '%' . $this->params['patient_keyword'] . '%')
->field('id');
}];
}
@@ -128,7 +128,7 @@ class OrderLists extends BaseAdminDataLists implements ListsSearchInterface
$where[] = ['patient_id', 'in', function ($query) {
$query->table('zyt_tcm_diagnosis')
->whereNull('delete_time')
->where('patient_name|patient_phone', 'like', '%' . $this->params['patient_keyword'] . '%')
->where('patient_name|phone', 'like', '%' . $this->params['patient_keyword'] . '%')
->field('id');
}];
}
@@ -6,12 +6,14 @@ namespace app\adminapi\lists\tcm;
use app\adminapi\lists\BaseAdminDataLists;
use app\adminapi\logic\tcm\PrescriptionOrderLogic;
use app\common\lists\ListsExtendInterface;
use app\common\lists\ListsSearchInterface;
use app\common\model\Order;
use app\common\model\tcm\Diagnosis;
use app\common\model\tcm\PrescriptionOrder;
use app\common\model\tcm\PrescriptionOrderPayOrder;
class PrescriptionOrderLists extends BaseAdminDataLists implements ListsSearchInterface
class PrescriptionOrderLists extends BaseAdminDataLists implements ListsSearchInterface, ListsExtendInterface
{
public function setSearch(): array
{
@@ -38,18 +40,115 @@ class PrescriptionOrderLists extends BaseAdminDataLists implements ListsSearchIn
->limit($this->limitOffset, $this->limitLength)
->select()
->toArray();
$poIds = array_values(array_filter(array_map('intval', array_column($lists, 'id'))));
$paidSumByPo = [];
if ($poIds !== []) {
$links = PrescriptionOrderPayOrder::whereIn('prescription_order_id', $poIds)
->field(['prescription_order_id', 'pay_order_id'])
->select()
->toArray();
$byPo = [];
foreach ($links as $l) {
$poid = (int) ($l['prescription_order_id'] ?? 0);
$oid = (int) ($l['pay_order_id'] ?? 0);
if ($poid > 0 && $oid > 0) {
$byPo[$poid][] = $oid;
}
}
$allOids = [];
foreach ($byPo as $oids) {
$allOids = array_merge($allOids, $oids);
}
$allOids = array_values(array_unique($allOids));
$amountByOid = [];
if ($allOids !== []) {
$amountByOid = Order::whereIn('id', $allOids)->whereNull('delete_time')->column('amount', 'id');
}
foreach ($poIds as $pid) {
$s = 0.0;
foreach ($byPo[$pid] ?? [] as $oid) {
$s += (float) ($amountByOid[$oid] ?? 0);
}
$paidSumByPo[$pid] = round($s, 2);
}
}
// 获取创建人姓名
$creatorIds = array_values(array_unique(array_filter(array_map('intval', array_column($lists, 'creator_id')))));
$creatorNames = [];
if ($creatorIds !== []) {
$creatorNames = \app\common\model\auth\Admin::whereIn('id', $creatorIds)->column('name', 'id');
}
// 获取处方ID对应的开方人姓名
$prescriptionIds = array_values(array_unique(array_filter(array_map('intval', array_column($lists, 'prescription_id')))));
$prescriptionCreators = [];
if ($prescriptionIds !== []) {
$prescriptions = \app\common\model\tcm\Prescription::whereIn('id', $prescriptionIds)
->whereNull('delete_time')
->field(['id', 'creator_id', 'doctor_name'])
->select()
->toArray();
$doctorIds = [];
foreach ($prescriptions as $rx) {
$rxId = (int) ($rx['id'] ?? 0);
$doctorId = (int) ($rx['creator_id'] ?? 0);
$doctorName = (string) ($rx['doctor_name'] ?? '');
if ($rxId > 0) {
$prescriptionCreators[$rxId] = [
'doctor_id' => $doctorId,
'doctor_name' => $doctorName
];
if ($doctorId > 0) {
$doctorIds[] = $doctorId;
}
}
}
// 如果 doctor_name 为空,从 Admin 表获取
$doctorIds = array_values(array_unique($doctorIds));
if ($doctorIds !== []) {
$doctorNamesFromAdmin = \app\common\model\auth\Admin::whereIn('id', $doctorIds)->column('name', 'id');
foreach ($prescriptionCreators as &$pc) {
if (empty($pc['doctor_name']) && $pc['doctor_id'] > 0) {
$pc['doctor_name'] = $doctorNamesFromAdmin[$pc['doctor_id']] ?? '';
}
}
unset($pc);
}
}
$depMin = PrescriptionOrderLogic::depositMinAmount();
foreach ($lists as &$item) {
PrescriptionOrderLogic::maskInternalCostIfNeeded($item, $this->adminInfo);
$pid = (int) ($item['id'] ?? 0);
$item['linked_pay_order_count'] = (int) PrescriptionOrderPayOrder::where(
'prescription_order_id',
(int) ($item['id'] ?? 0)
$pid
)->count();
$item['linked_pay_paid_total'] = $paidSumByPo[$pid] ?? 0.0;
$item['deposit_min_amount'] = $depMin;
// 添加创建人姓名
$creatorId = (int) ($item['creator_id'] ?? 0);
$item['creator_name'] = $creatorId > 0 ? ($creatorNames[$creatorId] ?? '') : '';
// 添加开方人姓名
$rxId = (int) ($item['prescription_id'] ?? 0);
$item['doctor_name'] = $rxId > 0 ? ($prescriptionCreators[$rxId]['doctor_name'] ?? '') : '';
}
unset($item);
return $lists;
}
public function extend(): array
{
return [
'deposit_min_amount' => PrescriptionOrderLogic::depositMinAmount(),
];
}
public function count(): int
{
$query = PrescriptionOrder::where($this->searchWhere)->whereNull('delete_time');
+169 -168
View File
@@ -47,7 +47,7 @@ class OrderLogic
$order->status = 1; // 待支付
$order->remark = $params['remark'] ?? '';
$order->save();
return $order;
} catch (\Exception $e) {
self::setError($e->getMessage());
@@ -102,146 +102,146 @@ class OrderLogic
$cursor = '';
do {
$resp = $service->getBillList($beginTime, $endTime, $payeeUserid, $cursor, 100);
if (($resp['errcode'] ?? -1) !== 0) {
$result['errors'][] = '获取账单失败: ' . ($resp['errmsg'] ?? '未知错误');
break;
}
$billList = $resp['bill_list'] ?? [];
$cursor = $resp['next_cursor'] ?? '';
foreach ($billList as $bill) {
$tradeState = (int)($bill['trade_state'] ?? 0);
// 1:已完成(已支付) 3:已完成有退款 — 见企业微信 get_bill_list 文档
if (!in_array($tradeState, [1, 3], true)) {
$result['skipped']++;
continue;
if (($resp['errcode'] ?? -1) !== 0) {
$result['errors'][] = '获取账单失败: ' . ($resp['errmsg'] ?? '未知错误');
break;
}
$targetStatus = $tradeState === 3 ? 4 : 2; // 4=已退款 2=已支付
$billList = $resp['bill_list'] ?? [];
$cursor = $resp['next_cursor'] ?? '';
$orderNo = (string)($bill['out_trade_no'] ?? $bill['trade_no'] ?? '');
$totalFee = (int)($bill['total_fee'] ?? $bill['total_amount'] ?? 0);
$tradeNo = (string)($bill['transaction_id'] ?? $bill['trade_no'] ?? '');
$payTime = $bill['pay_time'] ?? $bill['time_end'] ?? '';
$billPayeeUserid = (string)($bill['payee_userid'] ?? '');
if (empty($orderNo) || $totalFee <= 0) {
$result['skipped']++;
continue;
}
$amount = round($totalFee / 100, 2);
$orderType = ($amount == 5.00) ? 1 : $defaultOrderType;
$creatorId = 0;
if ($billPayeeUserid) {
$admin = Admin::where('work_wechat_userid', $billPayeeUserid)->whereNull('delete_time')->find();
if ($admin) {
$creatorId = (int)$admin->id;
}
}
$payerExternalUserid = (string)($bill['external_userid'] ?? '');
$paymentTimeStr = $payTime
? (is_numeric($payTime) ? date('Y-m-d H:i:s', (int)$payTime) : (string)$payTime)
: date('Y-m-d H:i:s');
$order = Order::where('order_no', $orderNo)->find();
if ($order) {
$needSave = false;
$currentStatus = (int)$order->status;
if ($currentStatus !== $targetStatus) {
// 待支付:随账单变为已支付或已退款(含未同步到「已支付」即发生退款)
if ($currentStatus === 1) {
$order->status = $targetStatus;
$needSave = true;
} elseif ($currentStatus === 2 && $targetStatus === 4) {
$order->status = 4;
$needSave = true;
} elseif ($currentStatus === 4 && $targetStatus === 2) {
// 账单从「有退款」变回「已完成」等异常纠偏
$order->status = 2;
$needSave = true;
}
// 已取消(3) 不自动改支付状态,避免覆盖后台取消
}
if ($currentStatus === 1 || $targetStatus === 2) {
if ($order->payment_method !== 'wechat') {
$order->payment_method = 'wechat';
$needSave = true;
}
if ($paymentTimeStr !== '' && (string)$order->payment_time !== $paymentTimeStr) {
$order->payment_time = $paymentTimeStr;
$needSave = true;
}
}
if ($tradeNo !== '' && (string)$order->trade_no !== $tradeNo) {
$order->trade_no = $tradeNo;
$needSave = true;
}
if (abs((float)$order->amount - $amount) > 0.000001) {
$order->amount = $amount;
$needSave = true;
}
$defaultCreatorIdInt = (int)$defaultCreatorId;
if ($billPayeeUserid && $creatorId !== $defaultCreatorIdInt && (int)$order->creator_id === $defaultCreatorIdInt) {
$order->payee_userid = $billPayeeUserid;
$order->payer_external_userid = $payerExternalUserid;
$order->creator_id = $creatorId;
$needSave = true;
} elseif ($billPayeeUserid !== '' && (string)$order->payee_userid !== $billPayeeUserid) {
$order->payee_userid = $billPayeeUserid;
$needSave = true;
}
if ($payerExternalUserid !== '' && (string)$order->payer_external_userid !== $payerExternalUserid) {
$order->payer_external_userid = $payerExternalUserid;
$needSave = true;
}
if ($amount == 5.00 && (int)$order->order_type !== 1) {
$order->order_type = 1;
$needSave = true;
}
if ($needSave) {
$order->save();
$result['updated']++;
} else {
foreach ($billList as $bill) {
$tradeState = (int)($bill['trade_state'] ?? 0);
// 1:已完成(已支付) 3:已完成有退款 — 见企业微信 get_bill_list 文档
if (!in_array($tradeState, [1, 3], true)) {
$result['skipped']++;
continue;
}
} else {
try {
$order = new Order();
$order->order_no = $orderNo;
$order->patient_id = null;
$order->creator_id = $creatorId;
$order->order_type = $orderType;
$order->amount = $amount;
$order->status = $targetStatus;
$order->payment_method = 'wechat';
$order->payment_time = $paymentTimeStr;
$order->trade_no = $tradeNo;
$remark = '企业微信直接收款同步,待关联患者';
if ($tradeState === 3) {
$refundCent = (int)($bill['total_refund_fee'] ?? 0);
if ($refundCent > 0) {
$remark .= '(账单含退款' . round($refundCent / 100, 2) . '元)';
$targetStatus = $tradeState === 3 ? 4 : 2; // 4=已退款 2=已支付
$orderNo = (string)($bill['out_trade_no'] ?? $bill['trade_no'] ?? '');
$totalFee = (int)($bill['total_fee'] ?? $bill['total_amount'] ?? 0);
$tradeNo = (string)($bill['transaction_id'] ?? $bill['trade_no'] ?? '');
$payTime = $bill['pay_time'] ?? $bill['time_end'] ?? '';
$billPayeeUserid = (string)($bill['payee_userid'] ?? '');
if (empty($orderNo) || $totalFee <= 0) {
$result['skipped']++;
continue;
}
$amount = round($totalFee / 100, 2);
$orderType = ($amount == 5.00) ? 1 : $defaultOrderType;
$creatorId = 0;
if ($billPayeeUserid) {
$admin = Admin::where('work_wechat_userid', $billPayeeUserid)->whereNull('delete_time')->find();
if ($admin) {
$creatorId = (int)$admin->id;
}
}
$payerExternalUserid = (string)($bill['external_userid'] ?? '');
$paymentTimeStr = $payTime
? (is_numeric($payTime) ? date('Y-m-d H:i:s', (int)$payTime) : (string)$payTime)
: date('Y-m-d H:i:s');
$order = Order::where('order_no', $orderNo)->find();
if ($order) {
$needSave = false;
$currentStatus = (int)$order->status;
if ($currentStatus !== $targetStatus) {
// 待支付:随账单变为已支付或已退款(含未同步到「已支付」即发生退款)
if ($currentStatus === 1) {
$order->status = $targetStatus;
$needSave = true;
} elseif ($currentStatus === 2 && $targetStatus === 4) {
$order->status = 4;
$needSave = true;
} elseif ($currentStatus === 4 && $targetStatus === 2) {
// 账单从「有退款」变回「已完成」等异常纠偏
$order->status = 2;
$needSave = true;
}
// 已取消(3) 不自动改支付状态,避免覆盖后台取消
}
if ($currentStatus === 1 || $targetStatus === 2) {
if ($order->payment_method !== 'wechat') {
$order->payment_method = 'wechat';
$needSave = true;
}
if ($paymentTimeStr !== '' && (string)$order->payment_time !== $paymentTimeStr) {
$order->payment_time = $paymentTimeStr;
$needSave = true;
}
}
$order->remark = $remark;
$order->payee_userid = $billPayeeUserid;
$order->payer_external_userid = $payerExternalUserid;
$order->save();
$result['created']++;
} catch (\Exception $e) {
$result['errors'][] = "订单 {$orderNo}: " . $e->getMessage();
Log::error("企业微信账单同步创建订单失败: {$orderNo}, " . $e->getMessage());
if ($tradeNo !== '' && (string)$order->trade_no !== $tradeNo) {
$order->trade_no = $tradeNo;
$needSave = true;
}
if (abs((float)$order->amount - $amount) > 0.000001) {
$order->amount = $amount;
$needSave = true;
}
$defaultCreatorIdInt = (int)$defaultCreatorId;
if ($billPayeeUserid && $creatorId !== $defaultCreatorIdInt && (int)$order->creator_id === $defaultCreatorIdInt) {
$order->payee_userid = $billPayeeUserid;
$order->payer_external_userid = $payerExternalUserid;
$order->creator_id = $creatorId;
$needSave = true;
} elseif ($billPayeeUserid !== '' && (string)$order->payee_userid !== $billPayeeUserid) {
$order->payee_userid = $billPayeeUserid;
$needSave = true;
}
if ($payerExternalUserid !== '' && (string)$order->payer_external_userid !== $payerExternalUserid) {
$order->payer_external_userid = $payerExternalUserid;
$needSave = true;
}
if ($amount == 5.00 && (int)$order->order_type !== 1) {
$order->order_type = 1;
$needSave = true;
}
if ($needSave) {
$order->save();
$result['updated']++;
} else {
$result['skipped']++;
}
} else {
try {
$order = new Order();
$order->order_no = $orderNo;
$order->patient_id = null;
$order->creator_id = $creatorId;
$order->order_type = $orderType;
$order->amount = $amount;
$order->status = $targetStatus;
$order->payment_method = 'wechat';
$order->payment_time = $paymentTimeStr;
$order->trade_no = $tradeNo;
$remark = '企业微信直接收款同步,待关联患者';
if ($tradeState === 3) {
$refundCent = (int)($bill['total_refund_fee'] ?? 0);
if ($refundCent > 0) {
$remark .= '(账单含退款' . round($refundCent / 100, 2) . '元)';
}
}
$order->remark = $remark;
$order->payee_userid = $billPayeeUserid;
$order->payer_external_userid = $payerExternalUserid;
$order->save();
$result['created']++;
} catch (\Exception $e) {
$result['errors'][] = "订单 {$orderNo}: " . $e->getMessage();
Log::error("企业微信账单同步创建订单失败: {$orderNo}, " . $e->getMessage());
}
}
}
}
} while ($cursor);
}
@@ -295,7 +295,7 @@ class OrderLogic
self::setError('订单不存在');
return false;
}
if (isset($params['remark'])) {
$order->remark = $params['remark'];
}
@@ -307,7 +307,7 @@ class OrderLogic
if (isset($params['order_type'])) {
$order->order_type = (int)$params['order_type'];
}
$order->save();
return true;
} catch (\Exception $e) {
@@ -330,17 +330,17 @@ class OrderLogic
self::setError('订单不存在');
return false;
}
if ($order->status != 1) {
self::setError('订单状态不允许支付');
return false;
}
$order->status = 2; // 已支付
$order->payment_method = $paymentMethod;
$order->payment_time = date('Y-m-d H:i:s');
$order->save();
return true;
} catch (\Exception $e) {
self::setError($e->getMessage());
@@ -361,15 +361,15 @@ class OrderLogic
self::setError('订单不存在');
return false;
}
if ($order->status != 1) {
self::setError('只有待支付的订单才能取消');
return false;
}
$order->status = 3; // 已取消
$order->save();
return true;
} catch (\Exception $e) {
self::setError($e->getMessage());
@@ -390,15 +390,15 @@ class OrderLogic
self::setError('订单不存在');
return false;
}
if ($order->status != 2) {
self::setError('只有已支付的订单才能退款');
return false;
}
$order->status = 4; // 已退款
$order->save();
return true;
} catch (\Exception $e) {
self::setError($e->getMessage());
@@ -419,10 +419,10 @@ class OrderLogic
self::setError('订单不存在');
return false;
}
// 删除订单
$order->delete();
return true;
} catch (\Exception $e) {
self::setError($e->getMessage());
@@ -466,11 +466,11 @@ class OrderLogic
{
$order = Order::with(['patient', 'creator'])
->find($id);
if (!$order) {
return null;
}
return $order->toArray();
}
@@ -496,10 +496,10 @@ class OrderLogic
{
// 移除sign字段
unset($params['sign']);
// 按键排序
ksort($params);
// 构建签名字符串
$signStr = '';
foreach ($params as $key => $value) {
@@ -508,12 +508,12 @@ class OrderLogic
}
}
$signStr = rtrim($signStr, '&');
// 使用私钥签名
$privateKeyResource = openssl_pkey_get_private($privateKey);
openssl_sign($signStr, $sign, $privateKeyResource, OPENSSL_ALGO_SHA256);
openssl_free_key($privateKeyResource);
return base64_encode($sign);
}
@@ -527,10 +527,10 @@ class OrderLogic
{
// 移除sign字段
unset($params['sign']);
// 按键排序
ksort($params);
// 构建签名字符串
$signStr = '';
foreach ($params as $key => $value) {
@@ -539,7 +539,7 @@ class OrderLogic
}
}
$signStr .= 'key=' . $apiKey;
// MD5签名
return strtoupper(md5($signStr));
}
@@ -606,14 +606,14 @@ class OrderLogic
// 获取订单号
$orderNo = is_array($order) ? $order['order_no'] : $order->order_no;
$amount = is_array($order) ? ($order['amount'] ?? 0) : $order->amount;
// 获取支付宝配置
$alipayConfig = config('pay.alipay');
if (empty($alipayConfig['app_id']) || empty($alipayConfig['merchant_private_key'])) {
throw new \Exception('支付宝配置不完整');
}
// 使用支付宝SDK生成支付URL
$params = [
'app_id' => $alipayConfig['app_id'],
@@ -632,14 +632,14 @@ class OrderLogic
'body' => '订单号:' . $orderNo,
], JSON_UNESCAPED_UNICODE),
];
// 生成签名
$sign = self::generateAlipaySign($params, $alipayConfig['merchant_private_key']);
$params['sign'] = $sign;
// 生成支付URL
$payUrl = $alipayConfig['gateway_url'] . '?' . http_build_query($params);
return $payUrl;
} catch (\Exception $e) {
self::setError($e->getMessage());
@@ -658,14 +658,14 @@ class OrderLogic
// 获取订单号
$orderNo = is_array($order) ? $order['order_no'] : $order->order_no;
$amount = is_array($order) ? ($order['amount'] ?? 0) : $order->amount;
// 获取微信配置
$wechatConfig = config('pay.wechat');
if (empty($wechatConfig['app_id']) || empty($wechatConfig['mch_id']) || empty($wechatConfig['api_key'])) {
throw new \Exception('微信支付配置不完整');
}
// 生成微信支付数据
$payData = [
'appid' => $wechatConfig['app_id'],
@@ -678,11 +678,11 @@ class OrderLogic
'notify_url' => $wechatConfig['notify_url'] ?: config('app.url') . '/api/order/wechat-notify',
'trade_type' => 'NATIVE', // 二维码支付
];
// 生成签名
$sign = self::generateWechatSign($payData, $wechatConfig['api_key']);
$payData['sign'] = $sign;
// 返回支付数据
return [
'pay_url' => $wechatConfig['gateway_url'] . '/pay/unifiedorder',
@@ -918,7 +918,7 @@ class OrderLogic
if (!empty($adminInfo['root']) && (int) $adminInfo['root'] === 1) {
return true;
}
$supervisorRoles = [0, 3];
$supervisorRoles = config('project.order_edit_all_roles', [0, 3]);
$myRoles = array_map('intval', $adminInfo['role_id'] ?? []);
return count(array_intersect($myRoles, $supervisorRoles)) > 0;
@@ -985,6 +985,7 @@ class OrderLogic
if ($busy && ! $allowed) {
continue;
}
$out[] = $row;
}
@@ -998,7 +999,7 @@ class OrderLogic
*/
public static function assertPayOrdersLinkable(array $ids, int $diagnosisId, int $adminId, array $adminInfo): ?string
{
$ids = array_values(array_unique(array_filter(array_map('intval', $ids), static fn (int $v): bool => $v > 0)));
$ids = array_values(array_unique(array_filter(array_map('intval', $ids), static fn(int $v): bool => $v > 0)));
if ($diagnosisId <= 0) {
return '诊单无效';
}
@@ -74,30 +74,54 @@ class PrescriptionLogic
*/
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;
}
return count(array_intersect($myRoles, $allowed)) > 0;
// 有开方权限的医生可以查看所有处方(只读)
// 这样其他医生可以查看患者的历史处方记录
$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;
}
private static function generateSn(): string
@@ -261,13 +285,10 @@ class PrescriptionLogic
return false;
}
// 已通过且未作废:不可编辑;例外:业务订单「处方审核」已驳回时允许医生改方,保存后业务订单侧审核会重置为待审
// 已通过且未作废:不可编辑
if ((int) ($prescription->audit_status ?? 1) === 1 && (int) ($prescription->void_status ?? 0) === 0) {
if (!PrescriptionOrderLogic::allowDoctorEditApprovedPrescriptionDueToBusinessReject((int) $params['id'])) {
self::setError('该处方已通过审核,不可编辑');
return false;
}
self::setError('该处方已通过审核,不可编辑');
return false;
}
// 已驳回:仅当该诊单+当天+同一创建人下没有另一条「已通过且未作废」的处方时允许重新编辑(改后待审核、作废一并清除)
@@ -435,6 +456,51 @@ class PrescriptionLogic
return $arr;
}
/**
* 业务订单「处方审核」通过时:消费者处方仍为待审核则一并记为已通过,
* 避免消费者处方列表筛选「未通过」仍包含该条、与业务侧已通过不一致。
*/
public static function syncApproveConsumerPrescriptionIfPending(
int $prescriptionId,
int $adminId,
array $adminInfo,
string $remark
): void {
if ($prescriptionId <= 0) {
return;
}
$row = Prescription::find($prescriptionId);
if (!$row) {
return;
}
if ((int) ($row->void_status ?? 0) === 1) {
return;
}
if ((int) ($row->audit_status ?? 1) !== 0) {
return;
}
self::$lastAuditWecomNotify = null;
$name = (string) ($adminInfo['name'] ?? '');
$now = time();
$finalRemark = trim($remark) !== '' ? trim($remark) : '业务订单处方审核通过';
$finalRemark = mb_substr($finalRemark, 0, 500);
$row->audit_status = 1;
$row->audit_time = $now;
$row->audit_by = $adminId;
$row->audit_by_name = $name;
$row->audit_remark = $finalRemark;
try {
if ($row->save()) {
self::$lastAuditWecomNotify = self::notifyCreatorAuditResult($row, 'approve', $finalRemark, $adminInfo);
}
} catch (\Throwable $e) {
Log::error('syncApproveConsumerPrescriptionIfPending: ' . $e->getMessage());
}
}
/**
* @return int[]
*/
@@ -601,15 +667,28 @@ class PrescriptionLogic
}
/**
* 根据预约ID获取处方
* 根据预约ID获取处方(带权限检查)
*/
public static function getByAppointment(int $appointmentId): ?array
public static function getByAppointment(int $appointmentId, int $viewerAdminId, array $viewerAdminInfo): ?array
{
self::$error = '';
$row = Prescription::where('appointment_id', $appointmentId)
->where('creator_id',$viewerAdminId)
->whereNull('delete_time')
->order('id', 'desc')
->find();
return $row ? $row->toArray() : null;
if (!$row) {
return null;
}
// 权限检查:只返回当前用户有权限查看的处方
if (!self::canViewPrescription($row, $viewerAdminId, $viewerAdminInfo)) {
self::setError('无权限查看此处方');
return null;
}
return $row->toArray();
}
/**
@@ -4,11 +4,15 @@ declare(strict_types=1);
namespace app\adminapi\logic\tcm;
use app\adminapi\logic\auth\AuthLogic;
use app\adminapi\logic\order\OrderLogic;
use app\common\model\Order;
use app\common\model\tcm\Diagnosis;
use app\common\model\tcm\Prescription;
use app\common\model\tcm\PrescriptionOrder;
use app\common\model\tcm\PrescriptionOrderLog;
use app\common\model\tcm\PrescriptionOrderPayOrder;
use app\common\model\auth\Admin;
use app\common\service\ExpressTrackService;
use think\facade\Config;
@@ -84,6 +88,20 @@ class PrescriptionOrderLogic
return count(array_intersect($myRoles, $allow)) > 0;
}
/**
* 业务订单详情内是否返回/展示处方「药材明细」(菜单权限 tcm.prescriptionOrder/viewRxHerbs
*/
private static function canViewPrescriptionHerbsInOrderDetail(array $adminInfo): bool
{
if (!empty($adminInfo['root']) && (int) $adminInfo['root'] === 1) {
return true;
}
$perms = AuthLogic::getAuthByAdminId((int) ($adminInfo['admin_id'] ?? 0));
return in_array('tcm.prescriptionOrder/viewRxHerbs', $perms, true);
}
public static function canAuditPrescriptionOrder(array $adminInfo): bool
{
if (!empty($adminInfo['root']) && (int) $adminInfo['root'] === 1) {
@@ -114,6 +132,47 @@ class PrescriptionOrderLogic
}
}
/** 定金门槛(元),0 表示不校验 */
public static function depositMinAmount(): float
{
return round((float) Config::get('prescription_order.link_pay_min_amount', 0), 2);
}
/**
* @param int[] $payOrderIds
*/
public static function assertDepositAndLinkPayRules(float $orderAmount, array $payOrderIds): ?string
{
$min = self::depositMinAmount();
if ($min <= 0) {
return null;
}
if ($orderAmount < $min) {
return '订单金额须大于等于定金门槛 ¥' . $min . '(当前 ¥' . $orderAmount . '';
}
if ($payOrderIds === []) {
return '未关联支付单,关联支付单总金额须大于等于定金门槛 ¥' . $min;
}
// 检查关联支付单的总金额
$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;
}
public static function generateOrderNo(): string
{
return 'PO' . date('YmdHis') . mt_rand(100000, 999999);
@@ -279,6 +338,36 @@ class PrescriptionOrderLogic
->select()
->toArray();
$creatorIds = array_values(array_unique(array_filter(array_map('intval', array_column($rows, 'creator_id')))));
$creatorNames = [];
if ($creatorIds !== []) {
$creatorNames = \app\common\model\auth\Admin::whereIn('id', $creatorIds)->column('name', 'id');
}
$typeMap = [
1 => '挂号费',
2 => '问诊费',
3 => '药品费用',
4 => '首付费用',
5 => '尾款费用',
6 => '其他费用',
7 => '全部费用',
];
$statusMap = [
1 => '待支付',
2 => '已支付',
3 => '已取消',
4 => '已退款',
];
foreach ($rows as &$r) {
$ot = (int) ($r['order_type'] ?? 0);
$st = (int) ($r['status'] ?? 0);
$r['order_type_desc'] = $typeMap[$ot] ?? '—';
$r['status_desc'] = $statusMap[$st] ?? '—';
$cid = (int) ($r['creator_id'] ?? 0);
$r['creator_name'] = $cid > 0 ? (string) ($creatorNames[$cid] ?? '') : '';
}
unset($r);
return $rows;
}
@@ -291,12 +380,20 @@ class PrescriptionOrderLogic
if ($id <= 0) {
$arr['pay_order_ids'] = [];
$arr['linked_pay_orders'] = [];
$arr['linked_pay_paid_total'] = 0.0;
$arr['deposit_min_amount'] = self::depositMinAmount();
return;
}
$ids = self::linkedPayOrderIdList($id);
$arr['pay_order_ids'] = $ids;
$arr['linked_pay_orders'] = self::linkedPayOrdersPayload($ids);
$sum = 0.0;
foreach ($arr['linked_pay_orders'] as $o) {
$sum += (float) ($o['amount'] ?? 0);
}
$arr['linked_pay_paid_total'] = round($sum, 2);
$arr['deposit_min_amount'] = self::depositMinAmount();
}
/**
@@ -352,6 +449,14 @@ class PrescriptionOrderLogic
}
}
$orderAmtPreview = round((float) ($params['amount'] ?? 0), 2);
$depErrCreate = self::assertDepositAndLinkPayRules($orderAmtPreview, $payOrderIds);
if ($depErrCreate !== null) {
self::$error = $depErrCreate;
return false;
}
$internalCost = null;
if (isset($params['internal_cost']) && $params['internal_cost'] !== '' && $params['internal_cost'] !== null) {
if (self::canViewInternalCost($adminInfo)) {
@@ -396,6 +501,8 @@ class PrescriptionOrderLogic
return false;
}
self::writeLog((int) $order->id, $adminId, $adminInfo, 'create', '创建业务订单');
if ($payOrderIds !== []) {
self::replacePayOrderLinks((int) $order->id, $payOrderIds);
}
@@ -425,6 +532,44 @@ class PrescriptionOrderLogic
self::maskInternalCostIfNeeded($arr, $adminInfo);
self::attachLinkedPayOrders($arr);
// 添加创建人姓名
$creatorId = (int) ($arr['creator_id'] ?? 0);
if ($creatorId > 0) {
$creatorName = Admin::where('id', $creatorId)->value('name');
$arr['creator_name'] = $creatorName ? (string) $creatorName : '';
} else {
$arr['creator_name'] = '';
}
$rxId = (int) ($arr['prescription_id'] ?? 0);
$arr['prescription'] = null;
$arr['prescription_detail_error'] = '';
$arr['doctor_name'] = '';
if ($rxId > 0) {
$rxDetail = PrescriptionLogic::detail($rxId, $adminId, $adminInfo);
if ($rxDetail !== null) {
$arr['prescription'] = $rxDetail;
// 添加开方人姓名(优先使用处方的 doctor_name,否则从 creator_id 获取)
$doctorName = (string) ($rxDetail['doctor_name'] ?? '');
if ($doctorName === '') {
$rxCreatorId = (int) ($rxDetail['creator_id'] ?? 0);
if ($rxCreatorId > 0) {
$doctorName = (string) (Admin::where('id', $rxCreatorId)->value('name') ?? '');
}
}
$arr['doctor_name'] = $doctorName;
} else {
$err = PrescriptionLogic::getError();
$arr['prescription_detail_error'] = $err !== '' ? $err : '处方不存在';
}
}
$canHerbs = self::canViewPrescriptionHerbsInOrderDetail($adminInfo);
$arr['prescription_detail_herbs_visible'] = $canHerbs;
if (! $canHerbs && isset($arr['prescription']) && is_array($arr['prescription'])) {
unset($arr['prescription']['herbs']);
}
return $arr;
}
@@ -581,9 +726,10 @@ class PrescriptionOrderLogic
$order->linked_pay_order_id = $single[0];
}
}
if (array_key_exists('internal_cost', $params) && self::canViewInternalCost($adminInfo)) {
$raw = $params['internal_cost'];
if ($raw === '' || $raw === null) {
$order->internal_cost = null;
} else {
@@ -591,6 +737,14 @@ class PrescriptionOrderLogic
}
}
$finalPayIds = self::linkedPayOrderIdList((int) $order->id);
$depErr = self::assertDepositAndLinkPayRules(round((float) $order->amount, 2), $finalPayIds);
if ($depErr !== null) {
self::$error = $depErr;
return false;
}
try {
$order->save();
} catch (\Throwable $e) {
@@ -599,6 +753,71 @@ class PrescriptionOrderLogic
return false;
}
self::writeLog((int) $order->id, $adminId, $adminInfo, 'edit', '编辑了业务订单及相关信息');
$out = $order->toArray();
self::maskInternalCostIfNeeded($out, $adminInfo);
self::attachLinkedPayOrders($out);
return $out;
}
/**
* 确认发货:更新快递信息并将 fulfillment_status 从 2(履约中)推进到 5(已发货)
*
* @return array<string,mixed>|false
*/
public static function ship(int $id, string $expressCompany, string $trackingNumber, int $adminId, array $adminInfo)
{
self::$error = '';
$trackingNumber = trim($trackingNumber);
if ($trackingNumber === '') {
self::$error = '快递单号不能为空';
return false;
}
$order = PrescriptionOrder::where('id', $id)->whereNull('delete_time')->find();
if (!$order) {
self::$error = '订单不存在';
return false;
}
if (!self::canAccessOrder($order, $adminId, $adminInfo)) {
self::$error = '无权限操作';
return false;
}
if ((int) $order->fulfillment_status !== 2 && (int) $order->fulfillment_status !== 5) {
self::$error = '仅「履约中」或「已发货」状态的订单可更新快递信息';
return false;
}
$order->express_company = self::normalizeExpressCompany($expressCompany);
$order->tracking_number = $trackingNumber;
// 仅履约中(2)时才推进到已发货(5),已发货(5)则只更新快递信息保持状态
$isFirstShip = false;
if ((int) $order->fulfillment_status === 2) {
$order->fulfillment_status = 5;
$isFirstShip = true;
}
try {
$order->save();
} catch (\Throwable $e) {
self::$error = $e->getMessage();
return false;
}
if ($isFirstShip) {
self::writeLog((int) $order->id, $adminId, $adminInfo, 'ship', '确认发货,运单:' . $trackingNumber . '' . $expressCompany . '');
} else {
self::writeLog((int) $order->id, $adminId, $adminInfo, 'fill_tracking', '更新发货信息,运单:' . $trackingNumber . '' . $expressCompany . '');
}
$out = $order->toArray();
self::maskInternalCostIfNeeded($out, $adminInfo);
self::attachLinkedPayOrders($out);
@@ -634,12 +853,29 @@ class PrescriptionOrderLogic
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_by_name' => '',
'audit_remark' => '',
'update_time' => time(),
]);
}
} catch (\Throwable $e) {
self::$error = $e->getMessage();
return false;
}
self::writeLog((int) $order->id, $adminId, $adminInfo, 'withdraw', '撤回订单,状态变更为「已取消」');
$out = $order->toArray();
self::maskInternalCostIfNeeded($out, $adminInfo);
self::attachLinkedPayOrders($out);
@@ -704,6 +940,20 @@ class PrescriptionOrderLogic
return false;
}
if ($action === 'approve') {
$syncRemark = trim($remark) !== '' ? trim($remark) : '业务订单处方审核通过';
PrescriptionLogic::syncApproveConsumerPrescriptionIfPending(
(int) $order->prescription_id,
$adminId,
$adminInfo,
$syncRemark
);
}
$actionName = $action === 'approve' ? 'audit_rx_approve' : 'audit_rx_reject';
$summary = $action === 'approve' ? '处方审核通过' . ($remark ? ',意见:' . $remark : '') : '处方审核驳回,意见:' . $remark;
self::writeLog((int) $order->id, $adminId, $adminInfo, $actionName, $summary);
$out = $order->toArray();
self::maskInternalCostIfNeeded($out, $adminInfo);
self::attachLinkedPayOrders($out);
@@ -744,6 +994,7 @@ class PrescriptionOrderLogic
return false;
}
// 已发货状态(5)允许重新发起支付单审核(新增支付单场景)
if ($action === 'reject' && trim($remark) === '') {
self::$error = '驳回时请填写审核意见';
@@ -771,10 +1022,306 @@ class PrescriptionOrderLogic
return false;
}
$actionName = $action === 'approve' ? 'audit_pay_approve' : 'audit_pay_reject';
$summary = $action === 'approve' ? '支付单审核通过' . ($remark ? ',意见:' . $remark : '') : '支付单审核驳回,意见:' . $remark;
self::writeLog((int) $order->id, $adminId, $adminInfo, $actionName, $summary);
$out = $order->toArray();
self::maskInternalCostIfNeeded($out, $adminInfo);
self::attachLinkedPayOrders($out);
return $out;
}
/**
* 撤回处方审核:将已通过/已驳回的处方审核重置为待审核
*
* @return array<string,mixed>|false
*/
public static function revokeRxAudit(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::canAuditPrescriptionOrder($adminInfo)) {
self::$error = '无处方审核权限';
return false;
}
$rxStatus = (int) $order->prescription_audit_status;
if ($rxStatus === 0) {
self::$error = '当前已是待审核状态,无需撤回';
return false;
}
$fs = (int) $order->fulfillment_status;
if ($fs === 3 || $fs === 4) {
self::$error = '订单已结束,不可撤回审核';
return false;
}
// 如果处方审核已通过,且支付审核也已通过或已驳回,不允许撤回处方审核
if ($rxStatus === 1 && (int) $order->payment_slip_audit_status !== 0) {
self::$error = '支付单审核已进行,请先撤回支付单审核';
return false;
}
$order->prescription_audit_status = 0;
$order->prescription_audit_remark = '';
// 如果支付审核不是待审核,也一并重置
if ((int) $order->payment_slip_audit_status !== 0) {
$order->payment_slip_audit_status = 0;
$order->payment_slip_audit_remark = '';
}
self::syncFulfillmentStatus($order);
try {
$order->save();
} catch (\Throwable $e) {
self::$error = $e->getMessage();
return false;
}
self::writeLog((int) $order->id, $adminId, $adminInfo, 'revoke_rx_audit', '撤回处方审核,状态重置为待审核');
$out = $order->toArray();
self::maskInternalCostIfNeeded($out, $adminInfo);
self::attachLinkedPayOrders($out);
return $out;
}
/**
* 撤回支付单审核:将已通过/已驳回的支付单审核重置为待审核
*
* @return array<string,mixed>|false
*/
public static function revokePayAudit(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::canAuditPaymentSlipOrder($adminInfo)) {
self::$error = '无支付单审核权限';
return false;
}
if ((int) $order->prescription_audit_status !== 1) {
self::$error = '处方审核未通过,无法撤回支付单审核';
return false;
}
$payStatus = (int) $order->payment_slip_audit_status;
if ($payStatus === 0) {
self::$error = '当前已是待审核状态,无需撤回';
return false;
}
$fs = (int) $order->fulfillment_status;
if ($fs === 3 || $fs === 4) {
self::$error = '订单已结束,不可撤回审核';
return false;
}
$order->payment_slip_audit_status = 0;
$order->payment_slip_audit_remark = '';
self::syncFulfillmentStatus($order);
try {
$order->save();
} catch (\Throwable $e) {
self::$error = $e->getMessage();
return false;
}
self::writeLog((int) $order->id, $adminId, $adminInfo, 'revoke_pay_audit', '撤回支付单审核,状态重置为待审核');
$out = $order->toArray();
self::maskInternalCostIfNeeded($out, $adminInfo);
self::attachLinkedPayOrders($out);
return $out;
}
/**
* @return array<int, array<string, mixed>>
*/
public static function getLogs(int $orderId, int $adminId, array $adminInfo): array
{
self::$error = '';
$order = PrescriptionOrder::where('id', $orderId)->whereNull('delete_time')->find();
if (!$order) {
self::$error = '订单不存在';
return [];
}
if (!self::canAccessOrder($order, $adminId, $adminInfo)) {
self::$error = '无权限查看';
return [];
}
return PrescriptionOrderLog::where('prescription_order_id', $orderId)
->order('id', 'desc')
->select()
->toArray();
}
/**
* 为「已发货」(fulfillment_status=5) 的业务订单新增一条关联支付单(zyt_order),
* 创建后将支付单链接到业务订单,并将处方/支付审核状态重置为待审核以启动再次审核流程。
*
* @param array<string,mixed> $params id, order_type, amount, remark
* @return array<string,mixed>|false
*/
public static function addPayOrder(array $params, int $adminId, array $adminInfo)
{
self::$error = '';
$id = (int) $params['id'];
$order = PrescriptionOrder::where('id', $id)->whereNull('delete_time')->find();
if (!$order) {
self::$error = '订单不存在';
return false;
}
if (!self::canAccessOrder($order, $adminId, $adminInfo)) {
self::$error = '无权限操作';
return false;
}
if ((int) $order->fulfillment_status !== 5) {
self::$error = '仅「已发货」状态的订单可新增支付单';
return false;
}
$orderType = (int) ($params['order_type'] ?? 3);
$amount = round((float) ($params['pay_amount'] ?? 0), 2);
$remark = mb_substr(trim((string) ($params['pay_remark'] ?? '')), 0, 200);
if ($amount <= 0) {
self::$error = '支付单金额须大于 0';
return false;
}
// 创建 zyt_order 并标记为已支付(后台直接确认)
$payOrder = new Order();
$payOrder->order_no = OrderLogic::generateOrderNo();
$payOrder->patient_id = (int) $order->diagnosis_id;
$payOrder->creator_id = $adminId;
$payOrder->order_type = $orderType;
$payOrder->amount = $amount;
$payOrder->status = 2; // 已支付
$payOrder->payment_method = 'manual';
$payOrder->payment_time = date('Y-m-d H:i:s');
$payOrder->remark = $remark;
try {
$payOrder->save();
} catch (\Throwable $e) {
self::$error = $e->getMessage();
return false;
}
// 将新支付单追加到业务订单关联列表
$existingIds = self::linkedPayOrderIdList($id);
$newIds = array_unique(array_merge($existingIds, [(int) $payOrder->id]));
self::replacePayOrderLinks($id, $newIds);
$order->linked_pay_order_id = $newIds[0];
// 重置支付单审核为待审核,以启动新一轮审核(处方审核保持通过状态不变)
$order->payment_slip_audit_status = 0;
$order->payment_slip_audit_remark = '';
try {
$order->save();
} catch (\Throwable $e) {
self::$error = $e->getMessage();
return false;
}
self::writeLog($id, $adminId, $adminInfo, 'add_pay_order',
'新增支付单 #' . $payOrder->id . '(¥' . $amount . '),类型:' . $orderType . ',等待支付审核');
$out = $order->toArray();
self::maskInternalCostIfNeeded($out, $adminInfo);
self::attachLinkedPayOrders($out);
return $out;
}
/**
* 将「已发货」(fulfillment_status=5) 且支付审核已通过的订单标记为「已完成」(3)。
*
* @return array<string,mixed>|false
*/
public static function complete(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::canAccessOrder($order, $adminId, $adminInfo)) {
self::$error = '无权限操作';
return false;
}
if ((int) $order->fulfillment_status !== 5) {
self::$error = '仅「已发货」状态可标记为已完成';
return false;
}
if ((int) $order->payment_slip_audit_status !== 1) {
self::$error = '支付单审核尚未通过,不可完成订单';
return false;
}
$order->fulfillment_status = 3;
try {
$order->save();
} catch (\Throwable $e) {
self::$error = $e->getMessage();
return false;
}
self::writeLog($id, $adminId, $adminInfo, 'complete', '订单完成,状态变更为「已完成」');
$out = $order->toArray();
self::maskInternalCostIfNeeded($out, $adminInfo);
self::attachLinkedPayOrders($out);
return $out;
}
private static function writeLog(int $orderId, int $adminId, array $adminInfo, string $action, string $summary): void
{
$adminName = $adminInfo['name'] ?? '';
if ($adminName === '' && $adminId > 0) {
$adminName = (string) \app\common\model\auth\Admin::where('id', $adminId)->value('name');
}
$log = new PrescriptionOrderLog();
$log->prescription_order_id = $orderId;
$log->admin_id = $adminId;
$log->admin_name = mb_substr($adminName, 0, 64);
$log->action = mb_substr($action, 0, 32);
$log->summary = mb_substr($summary, 0, 500);
$log->create_time = time();
try {
$log->save();
} catch (\Throwable $e) {
// 忽略日志写入错误
}
}
}
@@ -9,24 +9,27 @@ use app\common\validate\BaseValidate;
class PrescriptionOrderValidate extends BaseValidate
{
protected $rule = [
'id' => 'require|integer',
'diagnosis_id' => 'require|integer|gt:0',
'prescription_id' => 'require|integer',
'pay_order_ids' => 'array',
'recipient_name' => 'require|max:50',
'recipient_phone' => 'require|max:20',
'id' => 'require|integer',
'diagnosis_id' => 'require|integer|gt:0',
'prescription_id' => 'require|integer',
'pay_order_ids' => 'array',
'recipient_name' => 'require|max:50',
'recipient_phone' => 'require|max:20',
'shipping_address' => 'require|max:500',
'is_follow_up' => 'in:0,1',
'prev_staff' => 'max:100',
'service_channel' => 'max:100',
'service_package' => 'max:100',
'tracking_number' => 'max:80',
'express_company' => 'max:20',
'fee_type' => 'require|in:1,2,3,4,5,6,7',
'amount' => 'require|float',
'remark_extra' => 'max:500',
'action' => 'require|in:approve,reject',
'remark' => 'max:500',
'is_follow_up' => 'in:0,1',
'prev_staff' => 'max:100',
'service_channel' => 'max:100',
'service_package' => 'max:100',
'tracking_number' => 'max:80',
'express_company' => 'max:20',
'fee_type' => 'require|in:1,2,3,4,5,6,7',
'amount' => 'require|float',
'order_type' => 'require|in:1,2,3,4,5,6,7',
'pay_amount' => 'require|float|gt:0',
'pay_remark' => 'max:200',
'remark_extra' => 'max:500',
'action' => 'require|in:approve,reject',
'remark' => 'max:500',
];
protected $message = [
@@ -56,6 +59,10 @@ class PrescriptionOrderValidate extends BaseValidate
'auditPrescription' => ['id', 'action', 'remark'],
'auditPayment' => ['id', 'action', 'remark'],
'withdraw' => ['id'],
'ship' => ['id', 'tracking_number', 'express_company'],
'logs' => ['id'],
'paidPayOrders' => ['diagnosis_id'],
'addPayOrder' => ['id', 'order_type', 'pay_amount', 'pay_remark'],
'complete' => ['id'],
];
}
+28 -1
View File
@@ -3,13 +3,40 @@
namespace app\api\controller;
use app\api\logic\ChatNotifyLogic;
use think\facade\Db;
/**
* 聊天相关接口(C端-患者)
*/
class ChatController extends BaseApiController
{
public array $notNeedLogin = ['notifyOpen'];
public array $notNeedLogin = ['notifyOpen', 'logAvPermission'];
/**
* 上报音视频权限拒绝日志
* 患者小程序请求摄像头/麦克风权限被拒绝,且用户点击「取消」未前往设置页时调用
*/
public function logAvPermission()
{
$patientId = trim((string) ($this->request->post('patient_id', '')));
$doctorId = trim((string) ($this->request->post('doctor_id', '')));
$deniedScope = trim((string) ($this->request->post('denied_scope', '')));
$scene = trim((string) ($this->request->post('scene', '')));
$action = trim((string) ($this->request->post('action', 'cancel')));
$wxVersion = trim((string) ($this->request->post('wx_version', '')));
Db::name('av_permission_log')->insert([
'patient_id' => $patientId,
'doctor_id' => $doctorId,
'denied_scope' => $deniedScope ?: 'unknown',
'scene' => $scene ?: 'unknown',
'action' => in_array($action, ['cancel', 'open_setting']) ? $action : 'cancel',
'wx_version' => $wxVersion,
'create_time' => time(),
]);
return $this->success('已记录');
}
/**
* 患者打开会话时通知医生
@@ -0,0 +1,17 @@
<?php
declare(strict_types=1);
namespace app\common\model\tcm;
use app\common\model\BaseModel;
/**
* 处方业务订单操作日志
*/
class PrescriptionOrderLog extends BaseModel
{
protected $name = 'tcm_prescription_order_log';
protected $autoWriteTimestamp = false; // 时间戳由代码里显式写
}
+14
View File
@@ -0,0 +1,14 @@
<?php
declare(strict_types=1);
/**
* 处方业务订单(履约单)
*
* .env 示例:PRESCRIPTION_ORDER_LINK_PAY_MIN_AMOUNT = 100
* 为 0 或未配置时不校验。
*/
return [
// 定金门槛(元):关联的每笔已支付单金额须 **大于等于** 此值;业务订单金额须 **大于等于** 此值
'link_pay_min_amount' => (float) env('PRESCRIPTION_ORDER_LINK_PAY_MIN_AMOUNT', 0),
];
+5 -5
View File
@@ -102,20 +102,20 @@ return [
'tabbar_style' => ['default_color' => '#999999', 'selected_color' => '#c455ff'],
],
// 订单编辑权限:拥有以下角色ID的用户可修改任意订单,其他用户只能修改自己创建的订单
'order_edit_all_roles' => [0, 3],
// 医助创建订单权限:拥有以下角色ID的用户可修改任意订单,其他用户只能修改自己创建的订单
'order_edit_all_roles' => [0, 3, 6],
// 处方库:以下角色ID + 超级管理员(root) 可查看/编辑/删除全部;他人仅可管理自己创建的,公开处方对他人只读
'prescription_library_manage_all_roles' => [0, 3],
// 消费者处方单:以下角色 + root 可对「待审核」处方进行通过/驳回(驳回即作废处方)
'prescription_audit_roles' => [0, 3,6],
'prescription_audit_roles' => [0, 3, 6],
// 处方业务订单 internal_cost 等财务字段:以下角色 ID + root 可见
'prescription_order_finance_roles' => [0, 3],
'prescription_order_finance_roles' => [0, 3, 6],
// 业务订单「关联支付单」审核:以下角色 ID + root 可操作(可与财务角色一致)
'prescription_order_payment_audit_roles' => [0, 3],
'prescription_order_payment_audit_roles' => [0, 3, 6],
// 腾讯云实时音视频(TRTC)配置
'trtc' => [
+15
View File
@@ -0,0 +1,15 @@
-- 音视频权限拒绝日志表
CREATE TABLE IF NOT EXISTS `zyt_av_permission_log` (
`id` int(11) unsigned NOT NULL AUTO_INCREMENT COMMENT '主键ID',
`patient_id` varchar(64) NOT NULL DEFAULT '' COMMENT '患者IDpatient_xxx格式)',
`doctor_id` varchar(64) NOT NULL DEFAULT '' COMMENT '医生IDdoctor_xxx格式)',
`denied_scope` varchar(32) NOT NULL DEFAULT '' COMMENT '被拒绝的权限 scopecamera/record',
`scene` varchar(32) NOT NULL DEFAULT '' COMMENT '触发场景(makeCall / incoming',
`action` varchar(16) NOT NULL DEFAULT 'cancel' COMMENT '用户行为:cancel=取消 / open_setting=去设置',
`wx_version` varchar(32) NOT NULL DEFAULT '' COMMENT '微信基础库版本',
`create_time` int(11) unsigned NOT NULL DEFAULT '0' COMMENT '创建时间(Unix秒)',
PRIMARY KEY (`id`),
KEY `idx_patient` (`patient_id`),
KEY `idx_doctor` (`doctor_id`),
KEY `idx_create_time` (`create_time`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_general_ci COMMENT='音视频权限拒绝行为日志';
@@ -0,0 +1,13 @@
-- 处方业务订单详情:查看处方药材明细(无此权限时接口不返回 herbs)
SET @po_menu_id := (SELECT id FROM zyt_system_menu WHERE perms = 'tcm.prescriptionOrder/lists' LIMIT 1);
INSERT INTO zyt_system_menu (
pid, type, name, icon, sort, perms, paths, component,
selected, params, is_cache, is_show, is_disable, create_time, update_time
)
SELECT
@po_menu_id, 'A', '药材明细', '', 9, 'tcm.prescriptionOrder/viewRxHerbs', '', '',
'', '', 0, 1, 0, UNIX_TIMESTAMP(), UNIX_TIMESTAMP()
FROM DUAL
WHERE @po_menu_id IS NOT NULL
AND NOT EXISTS (SELECT 1 FROM zyt_system_menu WHERE perms = 'tcm.prescriptionOrder/viewRxHerbs');
BIN
View File
Binary file not shown.