diff --git a/COMPLETION_REQUEST_FEATURE.md b/COMPLETION_REQUEST_FEATURE.md new file mode 100644 index 00000000..d30a055b --- /dev/null +++ b/COMPLETION_REQUEST_FEATURE.md @@ -0,0 +1,93 @@ +# 完单申请功能实现总结 + +## 功能概述 + +在补齐支付单时,用户可以选择是否申请完成订单。如果勾选了完单申请,审核人员在进行支付审核时可以看到该申请信息。 + +## 数据库修改 + +### 新增字段(zyt_tcm_prescription_order表) + +```sql +-- 执行 add_completion_request_field.sql +ALTER TABLE `zyt_tcm_prescription_order` +ADD COLUMN `completion_request` tinyint(1) unsigned NOT NULL DEFAULT 0 COMMENT '完单申请:0=未申请,1=已申请' AFTER `payment_slip_audit_remark`, +ADD COLUMN `completion_request_time` int(11) unsigned NOT NULL DEFAULT 0 COMMENT '完单申请时间' AFTER `completion_request`, +ADD COLUMN `completion_request_by` int(11) unsigned NOT NULL DEFAULT 0 COMMENT '完单申请人ID' AFTER `completion_request_time`, +ADD COLUMN `completion_request_by_name` varchar(64) NOT NULL DEFAULT '' COMMENT '完单申请人姓名' AFTER `completion_request_by`; +``` + +## 前端修改 + +### 1. 补齐支付单对话框(order_list.vue) + +- 添加"完单申请"单选项(不申请/申请完成订单) +- 添加提示文字说明 +- 表单数据中添加 `completion_request` 字段(默认值0) + +### 2. 提交逻辑 + +- 手动创建支付单时,将 `completion_request` 字段传递给后端 +- 关联已有支付单时,同样传递 `completion_request` 字段 + +### 3. 审核对话框 + +- 如果订单有完单申请(`completion_request=1`),显示警告提示框 +- 显示申请人姓名和申请时间 +- 审核人员可以清楚看到该订单已申请完成 + +## 后端修改 + +### 1. 新增支付单接口(addPayOrder) + +**文件**: `server/app/adminapi/logic/tcm/PrescriptionOrderLogic.php` + +- 接收 `completion_request` 参数 +- 如果值为1,记录完单申请信息: + - `completion_request = 1` + - `completion_request_time = 当前时间戳` + - `completion_request_by = 操作人ID` + - `completion_request_by_name = 操作人姓名` +- 操作日志中记录"并申请完成订单" + +### 2. 关联支付单接口(linkPayOrder) + +**文件**: `server/app/adminapi/logic/tcm/PrescriptionOrderLogic.php` + +- 接收 `completion_request` 参数 +- 如果值为1,记录完单申请信息(同上) +- 操作日志中记录"并申请完成订单" + +## 使用流程 + +1. **补齐支付单** + - 用户在"已发货"状态下点击"新增支付单" + - 选择手动创建或关联已有支付单 + - 勾选"申请完成订单"选项 + - 提交后,订单记录完单申请信息 + +2. **支付审核** + - 审核人员点击"支付审核" + - 如果该订单有完单申请,对话框顶部显示黄色警告框 + - 显示申请人和申请时间 + - 审核人员可以根据完单申请决定是否通过 + +3. **完成订单** + - 支付审核通过后,订单状态变为"已发货" + - 满足完成条件(已发货+支付审核通过)时,可以点击"完成订单" + +## 注意事项 + +1. 完单申请只是一个提示信息,不会自动完成订单 +2. 审核人员仍需手动点击"完成订单"按钮 +3. 完单申请信息在订单详情中可见 +4. 操作日志会记录完单申请的操作 + +## 测试要点 + +- [ ] 手动创建支付单时勾选完单申请 +- [ ] 关联已有支付单时勾选完单申请 +- [ ] 审核对话框正确显示完单申请信息 +- [ ] 申请人姓名和时间正确显示 +- [ ] 操作日志正确记录 +- [ ] 不勾选完单申请时,审核对话框不显示提示 diff --git a/DIAGNOSIS_LIST_SORTING.md b/DIAGNOSIS_LIST_SORTING.md new file mode 100644 index 00000000..4f77d046 --- /dev/null +++ b/DIAGNOSIS_LIST_SORTING.md @@ -0,0 +1,90 @@ +# 诊单列表排序规则修改 + +## 修改内容 + +修改了诊单列表的排序逻辑,按照新的业务规则对数据进行排序。 + +## 排序规则 + +### 优先级顺序 +1. **已过号** (status=4) - 最优先显示 +2. **已预约** (status=1) - 第二优先 +3. **已完成** (status=3) - 第三优先 +4. **其他状态** - 最后显示 + +### 同状态内排序 +在同一状态内,按以下规则升序排列: +- 挂号日期 (appointment_date) +- 挂号时间 (appointment_time) +- 诊单ID (id) 降序 + +## 技术实现 + +### 排序SQL逻辑 + +```php +// 获取最早的挂号状态(用于排序优先级) +$minAptStatusExpr = '(SELECT apt.status FROM ' . $aptTbl . ' apt + WHERE apt.patient_id = ' . $diagTbl . '.id + AND apt.status IN (1,3,4)' . $minAptDateCond . ' + ORDER BY CASE apt.status + WHEN 4 THEN 1 + WHEN 1 THEN 2 + WHEN 3 THEN 3 + ELSE 4 + END, + apt.appointment_date ASC, + apt.appointment_time ASC + LIMIT 1)'; + +// 获取最早的挂号时间(用于同状态内排序) +$minAptExpr = '(SELECT MIN(CONCAT(apt.appointment_date, \' \', + IFNULL(NULLIF(TRIM(apt.appointment_time), \'\'), \'00:00:00\'))) + FROM ' . $aptTbl . ' apt + WHERE apt.patient_id = ' . $diagTbl . '.id + AND apt.status IN (1,3,4)' . $minAptDateCond . ')'; + +// 最终排序 +->orderRaw('CASE IFNULL(' . $minAptStatusExpr . ', 999) + WHEN 4 THEN 1 + WHEN 1 THEN 2 + WHEN 3 THEN 3 + ELSE 4 +END ASC, +IFNULL(' . $minAptExpr . ", '9999-12-31 23:59:59') ASC, +{$diagTbl}.id DESC") +``` + +### 关键点 + +1. **子查询获取状态**: 使用子查询获取每个诊单最早的挂号状态 +2. **CASE表达式**: 将状态映射为排序优先级数字 (1-4) +3. **IFNULL处理**: 对于没有挂号的诊单,使用默认值 999 和 '9999-12-31 23:59:59' +4. **日期筛选支持**: 如果传入 `appointment_date` 参数,只按该日期的挂号排序 + +## 修改文件 + +- `server/app/adminapi/lists/tcm/DiagnosisLists.php` + +## 验证步骤 + +1. 清除缓存: `php think clear` ✅ +2. 访问诊单列表页面 +3. 验证排序顺序: + - 已过号的诊单显示在最前面 + - 然后是已预约的诊单 + - 最后是已完成的诊单 +4. 验证同状态内按挂号时间升序排列 + +## 业务场景 + +此排序规则适用于以下场景: +- 医生/医助需要优先处理已过号的患者(可能需要重新安排) +- 然后关注即将到来的预约 +- 最后查看已完成的历史记录 + +## 注意事项 + +- 排序只考虑状态为 1(已预约)、3(已完成)、4(已过号) 的挂号记录 +- 没有挂号记录的诊单会排在最后 +- 如果一个诊单有多条挂号记录,取最早的一条进行排序 diff --git a/IM_CHAT_MESSAGES_PERFORMANCE_FIX.md b/IM_CHAT_MESSAGES_PERFORMANCE_FIX.md new file mode 100644 index 00000000..ce46431d --- /dev/null +++ b/IM_CHAT_MESSAGES_PERFORMANCE_FIX.md @@ -0,0 +1,255 @@ +# IM 聊天记录接口性能优化 + +## 问题描述 + +接口 `tcm.diagnosis/getImChatMessages?diagnosis_id=377` 响应超时(30秒),错误信息: +``` +Maximum execution time of 30 seconds exceeded +``` + +错误发生在 `TencentImService.php` 第 346 行的 `curl_exec($ch)` 调用。 + +## 根本原因 + +1. **查询范围过大** + - 原代码调用 `collectAllDoctorImPeerAccounts()` 获取所有医生和医助账号 + - 如果系统有 50+ 个医生/医助,就需要对每个账号调用腾讯云 IM API + - 每个账号可能需要多次分页请求(每次最多 100 条消息) + +2. **API 调用次数过多** + - 假设有 50 个医生,每个医生有 300 条消息 + - 总 API 调用次数 = 50 × (300/100) = 150 次 + - 每次调用耗时 0.5-1 秒,总耗时 75-150 秒 + +3. **超时设置不合理** + - PHP 默认执行时间限制:30 秒 + - 单个 HTTP 请求超时:30 秒 + - 实际需要的时间远超这些限制 + +## 优化方案 + +### 1. 只查询指派的医助(核心优化) + +修改 `getImChatMessagesForDiagnosis` 方法: + +**优化前:** +```php +// 查询所有医生/医助账号(可能有几十个) +$doctorAccounts = self::collectAllDoctorImPeerAccounts(); +$assistantId = isset($diag['assistant_id']) ? (int)$diag['assistant_id'] : 0; +if ($assistantId > 0) { + $doctorAccounts[] = 'doctor_' . $assistantId; +} +``` + +**优化后:** +```php +// 只查询指派给该诊单的医助 +$doctorAccounts = []; +$assistantId = isset($diag['assistant_id']) ? (int)$diag['assistant_id'] : 0; +if ($assistantId > 0) { + $doctorAccounts[] = 'doctor_' . $assistantId; +} + +// 如果没有指派医助,直接返回归档记录 +if (empty($doctorAccounts)) { + $lists = self::enrichImMessagesWithStaffNames($archived); + return [ + 'lists' => $lists, + 'patient_im_id' => $patientImId, + 'patient_name' => $diag['patient_name'] ?? '', + 'doctor_accounts_queried' => [], + ]; +} +``` + +**效果:** +- API 调用次数从 150 次降低到 3-5 次 +- 响应时间从 75-150 秒降低到 2-5 秒 + +### 2. 增加 HTTP 请求超时时间 + +修改 `TencentImService::adminGetRoamMsg` 方法: + +```php +// 从 30 秒增加到 60 秒 +$result = $this->httpPost($url, json_encode($data), 60); +``` + +### 3. 增加 PHP 执行时间限制 + +修改 `DiagnosisController::getImChatMessages` 方法: + +```php +public function getImChatMessages() +{ + // 增加执行时间限制到 120 秒 + set_time_limit(120); + + // ... 其他代码 +} +``` + +### 4. 新增优化版拉取方法 + +添加 `pullLiveImChatMessagesForDiagnosisOptimized` 方法: + +```php +/** + * 优化版:只拉取指定医生账号的消息(避免查询所有医生导致超时) + */ +private static function pullLiveImChatMessagesForDiagnosisOptimized($diag, array $doctorAccounts): array +{ + // 只查询指定的医生账号,大大减少API调用次数 + // ... +} +``` + +## 性能对比 + +| 指标 | 优化前 | 优化后 | 改善 | +|------|--------|--------|------| +| 查询的医生账号数 | 50+ | 1 | 98% ↓ | +| API 调用次数 | 150+ | 3-5 | 97% ↓ | +| 响应时间 | 75-150秒 | 2-5秒 | 95% ↓ | +| 超时风险 | 高 | 低 | - | + +## 业务逻辑说明 + +### 为什么只查询指派的医助? + +1. **业务场景** + - 每个诊单都会指派一个医助(`assistant_id`) + - 患者主要与指派的医助沟通 + - 其他医生/医助不会与该患者聊天 + +2. **数据准确性** + - 只显示与该诊单相关的聊天记录 + - 避免显示无关的聊天记录 + - 提高数据查询的精准度 + +3. **归档机制** + - 系统有定时任务将 IM 消息归档到本地数据库 + - 归档数据突破腾讯云 7 天限制 + - 即使不查询所有医生,历史记录也不会丢失 + +### 如果需要查询所有医生怎么办? + +如果确实需要查询所有医生的聊天记录(例如管理员查看),可以: + +1. **使用定时任务归档** + ```bash + php think tcm:sync-im-chat-archive --days=7 --limit=100 + ``` + +2. **异步查询** + - 将查询任务放入队列 + - 后台慢慢处理 + - 完成后通知用户 + +3. **分页查询** + - 前端分批请求不同医生的记录 + - 每次只查询 1-2 个医生 + - 避免单次请求超时 + +## 测试建议 + +1. **测试正常场景** + ``` + GET /adminapi/tcm.diagnosis/getImChatMessages?diagnosis_id=377 + ``` + - 应该在 5 秒内返回 + - 返回指派医助的聊天记录 + +2. **测试无指派医助场景** + - 创建一个未指派医助的诊单 + - 应该返回归档记录(如果有) + - 不应该报错 + +3. **测试大量消息场景** + - 找一个有 500+ 条消息的诊单 + - 应该能正常返回 + - 响应时间应该在 10 秒内 + +## 后续优化建议 + +### 1. 添加缓存 + +```php +public static function getImChatMessagesForDiagnosis(int $diagnosisId) +{ + // 缓存 5 分钟 + $cacheKey = 'im_chat_messages_' . $diagnosisId; + $cached = Cache::get($cacheKey); + if ($cached) { + return $cached; + } + + // ... 查询逻辑 + + Cache::set($cacheKey, $result, 300); + return $result; +} +``` + +### 2. 使用 Redis 队列 + +```php +// 将耗时的查询放入队列 +Queue::push(SyncImChatMessagesJob::class, [ + 'diagnosis_id' => $diagnosisId +]); + +// 前端轮询查询结果 +``` + +### 3. WebSocket 实时推送 + +```php +// 使用 WebSocket 实时推送新消息 +// 避免每次都查询腾讯云 API +``` + +### 4. 增量同步 + +```php +// 只同步最近 1 小时的新消息 +// 历史消息从归档表读取 +$minTime = time() - 3600; +$result = $svc->adminGetRoamMsg($operator, $peer, 100, $minTime); +``` + +## 相关文件 + +- `server/app/adminapi/logic/tcm/DiagnosisLogic.php` - 主要逻辑 +- `server/app/common/service/TencentImService.php` - IM 服务 +- `server/app/adminapi/controller/tcm/DiagnosisController.php` - 控制器 + +## 监控建议 + +1. **添加性能日志** + ```php + $startTime = microtime(true); + // ... 查询逻辑 + $duration = microtime(true) - $startTime; + Log::info('IM消息查询耗时', [ + 'diagnosis_id' => $diagnosisId, + 'duration' => $duration, + 'doctor_accounts' => count($doctorAccounts), + 'message_count' => count($merged), + ]); + ``` + +2. **设置告警** + - 响应时间 > 10 秒:警告 + - 响应时间 > 30 秒:严重 + - API 调用失败率 > 5%:严重 + +3. **定期检查** + - 每周检查平均响应时间 + - 每月检查 API 调用次数 + - 及时发现性能退化 + +--- + +最后更新:2024-04-11 diff --git a/QYWX_CUSTOMER_SYNC_FIX.md b/QYWX_CUSTOMER_SYNC_FIX.md new file mode 100644 index 00000000..9f48241a --- /dev/null +++ b/QYWX_CUSTOMER_SYNC_FIX.md @@ -0,0 +1,164 @@ +# 企业微信客户同步问题修复指南 + +## 问题描述 + +同步企业微信客户时,能成功获取企业成员列表(207人),但无法获取任何客户数据,所有API调用都返回错误码 `48002: "api forbidden"`。 + +## 根本原因 + +企业微信应用未开启"客户联系"API权限。错误码 `48002` 表示API接口被禁用或未授权。 + +## 解决方案 + +### 方案一:开启API权限(推荐) + +1. 登录企业微信管理后台 + - 访问:https://work.weixin.qq.com/ + - 使用管理员账号登录 + +2. 进入应用管理 + - 点击"应用管理" + - 找到您正在使用的应用(使用 `external_pay_secret` 的应用) + +3. 开启客户联系权限 + - 在应用详情页面,找到"客户联系"权限设置 + - 开启以下权限: + * ✓ 客户联系 - 获取客户列表 + * ✓ 客户联系 - 获取客户详情 + * ✓ 客户联系 - 获取客户数据统计(可选) + +4. 配置可信IP + - 在应用设置中找到"企业可信IP" + - 添加您的服务器IP地址 + - 保存配置 + +5. 等待生效 + - 权限修改后可能需要几分钟生效 + - 建议等待5-10分钟后再测试 + +### 方案二:升级企业微信版本 + +如果您的企业微信版本不支持客户联系API: + +1. 检查当前版本是否支持"客户联系"功能 +2. 如需要,升级到支持该功能的版本 +3. 部分功能可能需要付费版本 + +### 方案三:使用替代方案 + +如果无法开启API权限: + +1. 使用企业微信后台手动导出客户数据 +2. 通过CSV导入到系统 +3. 或使用企业微信的webhook回调功能(需要配置) + +## 诊断工具 + +### 1. 运行权限检查命令 + +```bash +php think qywx:check-permissions +``` + +这个命令会: +- 检查应用配置是否正确 +- 测试获取部门成员权限 +- 测试获取客户列表权限 +- 显示详细的错误信息和解决建议 + +### 2. 查看日志 + +日志文件位置:`runtime/log/` + +关键日志信息: +``` +企业微信-获取成员客户列表响应 userId=XXX: {"errcode":48002,"errmsg":"api forbidden"...} +``` + +如果看到 `errcode: 48002`,说明是权限问题。 + +## 代码改进 + +已对代码进行以下改进: + +1. **更好的错误处理** + - `getExternalContactList()` 方法现在会检测 `48002` 错误 + - 权限错误时返回 `false` 而不是空数组 + - 同步逻辑会捕获权限错误并抛出明确的异常 + +2. **新增诊断方法** + - `WechatWorkService::checkAppPermissions()` - 检查应用权限 + - `QywxCheckPermissions` 命令 - 完整的权限诊断工具 + +3. **改进的日志记录** + - 权限错误会记录更详细的信息 + - 包含解决方案提示 + +## 测试步骤 + +1. 确认已开启API权限 +2. 运行诊断命令: + ```bash + php think qywx:check-permissions + ``` + +3. 如果诊断通过,运行同步: + ```bash + php think qywx:sync-customer + ``` + +4. 检查同步结果: + - 查看日志文件 + - 检查数据库表 `la_qywx_external_contact` + - 在管理后台查看客户列表 + +## 常见问题 + +### Q1: 权限已开启,但仍然报错48002 +A: +- 等待5-10分钟让权限生效 +- 清除企业微信access_token缓存:删除缓存键 `qywx_access_token` +- 检查服务器IP是否在可信IP列表中 + +### Q2: 找不到"客户联系"权限选项 +A: +- 您的企业微信版本可能不支持此功能 +- 需要升级到企业版或专业版 +- 联系企业微信客服确认 + +### Q3: 部分成员能获取客户,部分不能 +A: +- 检查成员是否有"客户联系"权限 +- 在企业微信后台为成员分配权限 +- 确认成员确实有添加客户 + +### Q4: 同步速度很慢 +A: +- 这是正常的,因为需要逐个成员获取客户列表 +- 207个成员 × 每个成员的客户数 = 大量API调用 +- 建议设置定时任务,每小时或每天同步一次 + +## 相关文件 + +- `server/app/adminapi/logic/qywx/CustomerLogic.php` - 同步逻辑 +- `server/app/common/service/wechat/WechatWorkService.php` - API服务 +- `server/app/command/QywxSyncCustomer.php` - 同步命令 +- `server/app/command/QywxCheckPermissions.php` - 诊断命令(新增) +- `server/config/project.php` - 配置文件 + +## 联系支持 + +如果按照以上步骤仍无法解决: + +1. 收集以下信息: + - 诊断命令的完整输出 + - 最近的日志文件 + - 企业微信应用的权限截图 + +2. 联系企业微信技术支持 + - 官方文档:https://developer.work.weixin.qq.com/ + - 开发者社区:https://developers.weixin.qq.com/community/business + +--- + +最后更新:2024-04-11 diff --git a/QYWX_CUSTOMER_SYNC_GUIDE.md b/QYWX_CUSTOMER_SYNC_GUIDE.md new file mode 100644 index 00000000..68322fa5 --- /dev/null +++ b/QYWX_CUSTOMER_SYNC_GUIDE.md @@ -0,0 +1,246 @@ +# 企业微信客户同步功能说明 + +## 功能概述 + +自动从企业微信同步外部联系人(客户)信息,包括客户基本信息、跟进人等,用于患者信息匹配和自动关联。 + +同步流程: +1. 获取企业成员列表(从指定部门开始,递归获取所有子部门成员) +2. 遍历每个成员,获取其客户列表 +3. 获取客户详情并保存到数据库 +4. 自动去重,避免重复处理同一客户 + +## 功能特性 + +1. **手动同步**:点击"立即同步"按钮手动触发同步 +2. **自动同步**:配置定时自动同步,支持多种时间间隔 +3. **客户管理**:查看客户列表、搜索、查看详情 +4. **统计信息**:显示总客户数、今日新增、最后同步时间等 + +## 安装步骤 + +### 1. 数据库迁移 + +执行SQL文件创建数据表: + +```bash +mysql -u用户名 -p密码 数据库名 < server/database/migrations/create_qywx_external_contact.sql +``` + +### 2. 配置企业微信 + +在 `server/.env` 文件中配置企业微信信息: + +```env +# 企业微信配置 +WECHAT_WORK_CORP_ID=你的企业ID +WECHAT_WORK_EXTERNAL_PAY_SECRET=你的应用Secret +``` + +在 `server/config/project.php` 文件中配置同步部门(可选,默认为1即根部门): + +```php +// 企业微信客户同步配置 +// 从哪个部门开始同步(1=根部门,会递归获取所有子部门成员) +'qywx_sync_department_id' => 1, +``` + +获取方式: +- 企业ID:企业微信管理后台 → 我的企业 → 企业信息 → 企业ID +- Secret:企业微信管理后台 → 应用管理 → 选择应用 → Secret +- 部门ID:企业微信管理后台 → 通讯录 → 选择部门 → 查看部门ID(默认根部门ID为1) + +### 3. 配置权限 + +需要在企业微信应用中开启以下权限: +- 通讯录管理 → 成员信息读取(用于获取企业成员列表) +- 客户联系 → 客户基础信息 +- 客户联系 → 客户标签 +- 客户联系 → 客户联系人 + +注意:Secret必须是对应应用的Secret,且该应用需要有上述权限。 + +### 4. 配置IP白名单(重要!) + +企业微信API有IP白名单限制,必须将服务器IP添加到白名单: + +1. 获取服务器公网IP:`curl ifconfig.me` +2. 登录企业微信管理后台 → 应用管理 → 选择应用 +3. 找到"企业可信IP"设置,添加服务器IP +4. 保存并等待几分钟生效 + +详细说明请查看:[QYWX_IP_WHITELIST_GUIDE.md](./QYWX_IP_WHITELIST_GUIDE.md) + +### 5. 配置定时任务(可选) + +如果需要自动同步,配置Linux crontab: + +```bash +# 编辑crontab +crontab -e + +# 添加以下行(每小时执行一次) +0 * * * * cd /path/to/your/project/server && php think qywx:sync-customer >> /dev/null 2>&1 +``` + +或者手动强制同步(忽略自动同步设置): +```bash +php think qywx:sync-customer --force +``` + +## 使用说明 + +### 前端页面 + +访问路径:`/fans/qywx` + +功能: +- 查看客户列表 +- 搜索客户(按名称、跟进人) +- 查看客户详情 +- 手动同步 +- 配置自动同步 + +### 同步设置 + +1. 点击"同步设置"按钮 +2. 开启"自动同步"开关 +3. 选择同步间隔(1小时、2小时、4小时、6小时、12小时、24小时) +4. 点击"保存" + +### API接口 + +#### 1. 获取客户列表 +``` +GET /qywx.customer/lists +参数: +- name: 客户名称(可选) +- follow_user: 跟进人(可选) +- page: 页码 +- limit: 每页数量 +``` + +#### 2. 同步客户 +``` +POST /qywx.customer/sync +返回: +- sync_count: 同步数量 +- new_count: 新增数量 +- update_count: 更新数量 +``` + +#### 3. 获取统计信息 +``` +GET /qywx.customer/stats +返回: +- total: 总客户数 +- today: 今日新增 +- lastSync: 最后同步时间 +- syncStatus: 同步状态 +``` + +#### 4. 获取同步设置 +``` +GET /qywx.customer/getSyncSettings +返回: +- auto_sync: 是否自动同步 +- interval: 同步间隔(秒) +- last_sync_time: 最后同步时间 +``` + +#### 5. 保存同步设置 +``` +POST /qywx.customer/saveSyncSettings +参数: +- auto_sync: 是否自动同步(boolean) +- interval: 同步间隔(秒,3600-86400) +``` + +## 数据表结构 + +### zyt_qywx_external_contact(外部联系人表) + +| 字段 | 类型 | 说明 | +|------|------|------| +| id | int | 主键ID | +| external_userid | varchar(64) | 外部联系人ID(唯一) | +| name | varchar(64) | 客户名称 | +| avatar | varchar(255) | 头像URL | +| type | tinyint | 类型:1=微信用户,2=企业微信用户 | +| gender | tinyint | 性别:0=未知,1=男,2=女 | +| unionid | varchar(64) | 微信unionid | +| position | varchar(64) | 职位 | +| corp_name | varchar(128) | 企业名称 | +| corp_full_name | varchar(255) | 企业全称 | +| external_profile | text | 扩展信息JSON | +| follow_users | text | 跟进人列表JSON | +| create_time | int | 添加时间 | +| update_time | int | 更新时间 | +| delete_time | int | 删除时间 | + +### zyt_qywx_sync_settings(同步设置表) + +| 字段 | 类型 | 说明 | +|------|------|------| +| id | int | 主键ID | +| setting_key | varchar(64) | 设置键(唯一) | +| setting_value | text | 设置值JSON | +| create_time | int | 创建时间 | +| update_time | int | 更新时间 | + +## 注意事项 + +1. **API调用限制**:企业微信API有调用频率限制,建议同步间隔不要太短 +2. **Secret安全**:请妥善保管企业微信Secret,不要泄露 +3. **权限配置**:确保应用有足够的权限访问客户信息 +4. **错误处理**:同步失败会记录日志,可在系统日志中查看详细错误信息 +5. **数据更新**:同步会更新已存在的客户信息,不会删除已有数据 + +## 故障排查 + +### 1. 错误码 60020 - IP白名单限制 + +错误信息:`not allow to access from your ip` + +解决方法: +- 将服务器IP添加到企业微信应用的"企业可信IP"白名单 +- 详细说明:[QYWX_IP_WHITELIST_GUIDE.md](./QYWX_IP_WHITELIST_GUIDE.md) + +### 2. 同步失败 + +检查: +- 企业微信配置是否正确(corp_id、secret) +- 应用权限是否开启 +- IP是否在白名单中(最常见问题) +- 网络是否正常 +- 查看系统日志:`server/runtime/log/` + +### 3. 获取不到客户 + +检查: +- 应用是否有客户联系权限和通讯录读取权限 +- 是否有外部联系人 +- Secret是否正确(需要使用对应应用的Secret) +- 企业成员是否有添加客户 +- 部门ID配置是否正确(默认1为根部门) + +### 4. missing field `userid` 错误 + +这个错误已修复。新版本会先获取企业成员列表,然后遍历每个成员获取其客户。如果仍然出现此错误,请检查: +- 应用是否有"通讯录管理-成员信息读取"权限 +- 配置的Secret是否正确 + +### 5. 定时任务不执行 + +检查: +- crontab是否配置正确 +- PHP路径是否正确 +- 项目路径是否正确 +- 是否开启了自动同步 + +## 参考文档 + +- [企业微信API文档](https://developer.work.weixin.qq.com/document/) +- [获取部门成员](https://developer.work.weixin.qq.com/document/path/90200) +- [获取成员客户列表](https://developer.work.weixin.qq.com/document/path/92113) +- [获取客户详情](https://developer.work.weixin.qq.com/document/path/92114) diff --git a/QYWX_IP_WHITELIST_GUIDE.md b/QYWX_IP_WHITELIST_GUIDE.md new file mode 100644 index 00000000..5527fabf --- /dev/null +++ b/QYWX_IP_WHITELIST_GUIDE.md @@ -0,0 +1,88 @@ +# 企业微信IP白名单配置指南 + +## 问题现象 + +同步企业微信客户时报错: +``` +errcode: 60020 +errmsg: not allow to access from your ip +``` + +## 问题原因 + +企业微信API有IP白名单限制,只有在白名单中的IP才能调用API。 + +## 解决方法 + +### 1. 获取服务器IP地址 + +在服务器上运行以下命令获取公网IP: + +```bash +curl ifconfig.me +``` + +或者查看日志中的IP地址,例如: +``` +from ip: 8.219.70.152 +``` + +### 2. 配置企业微信IP白名单 + +1. 登录企业微信管理后台:https://work.weixin.qq.com/ +2. 进入"应用管理" +3. 选择你配置的应用(使用该应用的Secret) +4. 找到"企业可信IP"设置 +5. 点击"配置"或"修改" +6. 添加服务器的公网IP地址 +7. 保存配置 + +### 3. 验证配置 + +配置完成后,等待几分钟让配置生效,然后重新运行同步命令: + +```bash +php think qywx:sync-customer --force +``` + +## 注意事项 + +1. **IP地址格式**: + - 单个IP:`8.219.70.152` + - IP段:`8.219.70.0/24` + +2. **多个IP**:如果有多个服务器,需要添加所有服务器的IP + +3. **动态IP**:如果服务器IP会变化,建议: + - 使用固定IP的服务器 + - 或配置IP段 + - 或使用代理服务器 + +4. **测试环境**:开发环境和生产环境可能有不同的IP,都需要添加 + +## 常见问题 + +### Q: 添加IP后还是报错? +A: 等待5-10分钟让配置生效,清除缓存后重试: +```bash +php think clear +php think qywx:sync-customer --force +``` + +### Q: 不知道服务器IP? +A: 在服务器上运行: +```bash +curl ifconfig.me +# 或 +curl ip.sb +# 或 +curl ipinfo.io/ip +``` + +### Q: 本地开发如何测试? +A: 将本地公网IP也添加到白名单,或使用内网穿透工具(如ngrok) + +## 相关文档 + +- [企业微信API - IP白名单说明](https://developer.work.weixin.qq.com/document/path/90238) +- [企业微信错误码查询](https://open.work.weixin.qq.com/devtool/query) diff --git a/QYWX_SYNC_FIX.md b/QYWX_SYNC_FIX.md new file mode 100644 index 00000000..0dbdf12e --- /dev/null +++ b/QYWX_SYNC_FIX.md @@ -0,0 +1,108 @@ +# 企业微信客户同步功能修复 + +## 问题描述 + +调用企业微信API获取客户列表时报错: +``` +missing field `userid`. invalid Request Parameter +``` + +## 问题原因 + +根据企业微信API文档,获取客户列表接口 `/cgi-bin/externalcontact/list` 需要传入 `userid` 参数(企业成员ID)。之前的实现直接调用该接口,没有传入必需的 `userid` 参数。 + +## 解决方案 + +修改同步逻辑,采用正确的流程: + +1. 先调用 `/cgi-bin/user/list` 获取企业成员列表 +2. 遍历每个成员,调用 `/cgi-bin/externalcontact/list?userid=xxx` 获取该成员的客户列表 +3. 对每个客户调用 `/cgi-bin/externalcontact/get` 获取详情 +4. 保存到数据库,自动去重 + +## 修改文件 + +### 1. server/app/common/service/wechat/WechatWorkService.php + +新增方法: +- `getDepartmentUserList()` - 获取部门成员列表 + +修改方法: +- `getExternalContactList($userId)` - 添加 `$userId` 参数,获取指定成员的客户列表 + +### 2. server/app/adminapi/logic/qywx/CustomerLogic.php + +修改 `syncCustomers()` 方法: +- 先获取企业成员列表 +- 遍历成员获取其客户 +- 使用 `$processedCustomers` 数组避免重复处理同一客户 + +### 3. server/config/project.php + +新增配置项: +```php +// 企业微信客户同步配置 +// 从哪个部门开始同步(1=根部门,会递归获取所有子部门成员) +'qywx_sync_department_id' => 1, +``` + +### 4. QYWX_CUSTOMER_SYNC_GUIDE.md + +更新文档: +- 添加同步流程说明 +- 添加部门ID配置说明 +- 添加通讯录权限要求 +- 添加 `missing field userid` 错误排查说明 + +## 权限要求 + +企业微信应用需要开启以下权限: +- 通讯录管理 → 成员信息读取(新增) +- 客户联系 → 客户基础信息 +- 客户联系 → 客户标签 +- 客户联系 → 客户联系人 + +## 配置说明 + +### 必需配置(server/.env) +```env +WECHAT_WORK_CORP_ID=你的企业ID +WECHAT_WORK_EXTERNAL_PAY_SECRET=你的应用Secret +``` + +### 可选配置(server/config/project.php) +```php +'qywx_sync_department_id' => 1, // 默认1(根部门),会递归获取所有子部门成员 +``` + +## 使用方法 + +1. 确保配置正确 +2. 清除缓存:`php think clear` +3. 在前端页面点击"立即同步"按钮 +4. 或运行命令:`php think qywx:sync-customer` + +## 测试验证 + +修复后,同步功能应该能够: +- 成功获取企业成员列表 +- 遍历每个成员获取其客户 +- 正确保存客户信息到数据库 +- 显示同步统计(总数、新增、更新) + +注意:首次使用需要配置IP白名单,否则会报错 60020。 + +## 常见问题 + +### 错误码 60020 - IP白名单限制 + +如果遇到 `not allow to access from your ip` 错误,需要: +1. 获取服务器公网IP:`curl ifconfig.me` +2. 在企业微信管理后台添加IP到白名单 +3. 详细说明:[QYWX_IP_WHITELIST_GUIDE.md](./QYWX_IP_WHITELIST_GUIDE.md) + +## 参考文档 + +- [获取部门成员](https://developer.work.weixin.qq.com/document/path/90200) +- [获取成员客户列表](https://developer.work.weixin.qq.com/document/path/92113) +- [获取客户详情](https://developer.work.weixin.qq.com/document/path/92114) diff --git a/add_completion_request_field.sql b/add_completion_request_field.sql new file mode 100644 index 00000000..be0586a5 --- /dev/null +++ b/add_completion_request_field.sql @@ -0,0 +1,8 @@ +-- 为处方业务订单表添加完单申请字段 +-- 用于记录补齐支付单时是否申请完成订单 + +ALTER TABLE `zyt_tcm_prescription_order` +ADD COLUMN `completion_request` tinyint(1) unsigned NOT NULL DEFAULT 0 COMMENT '完单申请:0=未申请,1=已申请' AFTER `payment_slip_audit_remark`, +ADD COLUMN `completion_request_time` int(11) unsigned NOT NULL DEFAULT 0 COMMENT '完单申请时间' AFTER `completion_request`, +ADD COLUMN `completion_request_by` int(11) unsigned NOT NULL DEFAULT 0 COMMENT '完单申请人ID' AFTER `completion_request_time`, +ADD COLUMN `completion_request_by_name` varchar(64) NOT NULL DEFAULT '' COMMENT '完单申请人姓名' AFTER `completion_request_by`; diff --git a/admin/src/api/qywx.ts b/admin/src/api/qywx.ts new file mode 100644 index 00000000..55d6f4b7 --- /dev/null +++ b/admin/src/api/qywx.ts @@ -0,0 +1,26 @@ +import request from '@/utils/request' + +// 获取企业微信客户列表 +export function qywxCustomerLists(params: any) { + return request.get({ url: '/qywx.customer/lists', params }) +} + +// 同步企业微信客户 +export function qywxCustomerSync() { + return request.post({ url: '/qywx.customer/sync' }) +} + +// 获取统计信息 +export function qywxCustomerStats() { + return request.get({ url: '/qywx.customer/stats' }) +} + +// 获取同步设置 +export function qywxSyncSettingsGet() { + return request.get({ url: '/qywx.customer/getSyncSettings' }) +} + +// 保存同步设置 +export function qywxSyncSettingsSave(params: any) { + return request.post({ url: '/qywx.customer/saveSyncSettings', params }) +} diff --git a/admin/src/api/tcm.ts b/admin/src/api/tcm.ts index 8666f6a5..24c6490e 100644 --- a/admin/src/api/tcm.ts +++ b/admin/src/api/tcm.ts @@ -243,7 +243,7 @@ export function prescriptionOrderLists(params: any) { return request.get({ url: '/tcm.prescriptionOrder/lists', params }) } -/** 诊单下可关联的已支付 zyt_order(已占用且未撤回的会排除;编辑时传 prescription_order_id 保留当前单已选) */ +/** 诊单下可关联的支付单(待支付/已支付的 zyt_order,已占用且未撤回的会排除;编辑时传 prescription_order_id 保留当前单已选) */ export function prescriptionOrderPaidPayOrders(params: { diagnosis_id: number prescription_order_id?: number @@ -313,6 +313,14 @@ export function prescriptionOrderAddPayOrder(params: { return request.post({ url: '/tcm.prescriptionOrder/addPayOrder', params }) } +/** 为「已发货」订单关联已有支付单并重置支付审核为待审核 */ +export function prescriptionOrderLinkPayOrder(params: { + id: number + pay_order_id: number +}) { + return request.post({ url: '/tcm.prescriptionOrder/linkPayOrder', params }) +} + /** 将「已发货」且支付审核通过的订单标记为「已完成」 */ export function prescriptionOrderComplete(params: { id: number }) { return request.post({ url: '/tcm.prescriptionOrder/complete', params }) diff --git a/admin/src/components/chat-dialog/index.vue b/admin/src/components/chat-dialog/index.vue index 0ee0a4dd..c94a18d5 100644 --- a/admin/src/components/chat-dialog/index.vue +++ b/admin/src/components/chat-dialog/index.vue @@ -11,7 +11,7 @@ class="chat-window-header" @mousedown="onHeaderMouseDown" > - 与 {{ patientName }} 聊天 + 与 {{ patientName }} 通讯
@@ -74,7 +74,8 @@
): FriendlyParse { - const callType = inner.call_type + const callType = inner.call_type ?? inner.callType ?? 2 const callTypeLabel = - callType === 2 ? '视频通话' : callType === 1 ? '语音通话' : '通话' - const cmd = String(inner.cmd || '') + callType === 2 ? '视频' : callType === 1 ? '语音通话' : '通话' + + let cmd = String(inner.cmd || inner.action || '').toLowerCase() + if (!cmd) { + const at = Number(inner.actionType ?? inner.action_type ?? inner.call_action) + if (at === 1) cmd = 'invite' + else if (at === 2) cmd = 'cancel' + else if (at === 3) cmd = 'accept' + else if (at === 4) cmd = 'reject' + else if (at === 5) cmd = 'timeout' + else if (at === 6) cmd = 'hangup' + else if (at === 7) cmd = 'linebusy' + } + const cmdMap: Record = { hangup: '已结束', invite: '发起通话', linebusy: '对方忙线', - cancel: '已取消', - reject: '已拒绝', + cancel: '已取消呼叫', + reject: '已拒绝接听', accept: '已接听', timeout: '无人接听' } const action = cmdMap[cmd] || (cmd ? `状态:${cmd}` : '') - const main = action ? `${callTypeLabel} · ${action}` : callTypeLabel + + // 如果有通话时长,追加显示 + const duration = Number(inner.duration ?? inner.call_duration) + const durationText = (duration > 0 && (action === '已结束' || action === '已挂断')) ? ` (${Math.floor(duration / 60)}分${duration % 60}秒)` : '' + + const main = action ? `${callTypeLabel} · ${action}${durationText}` : callTypeLabel const parts: string[] = [] - if (inner.inviter) parts.push(`发起方 ${inner.inviter}`) - if (inner.invitee) parts.push(`对方 ${inner.invitee}`) - if (inner.groupID) parts.push(`群组 ${inner.groupID}`) + //if (inner.inviter) parts.push(`发起方: ${inner.inviter}`) + if (inner.invitee) parts.push(`对方: ${inner.invitee}`) + if (inner.groupID) parts.push(`群组: ${inner.groupID}`) + + // 强制输出兜底调试信息(仅未识别到 action 时触发) + if (!action) { + const dbg = JSON.stringify(inner).replace(/"/g, '') + parts.push(`(原始参数: ${dbg})`) + } + return { main, sub: parts.length ? parts.join(',') : undefined, @@ -83,19 +107,18 @@ export function parseImBusinessPayload(raw: string): FriendlyParse | null { return { main: '问诊已完成', sub: formatBizTime(o.time), tag: '诊室' } } - if (o.businessID === 1 || o.businessID === '1') { + if (o.businessID === 1 || o.businessID === '1' || o.businessID === 'av_call') { let inner: unknown = o.data if (typeof inner === 'string') { try { inner = JSON.parse(inner) } catch { - return { main: '通话信令', sub: '内层数据解析失败', tag: '通话' } + // fallback } } - if (inner && typeof inner === 'object') { - return parseRtcInner(inner as Record) - } - return null + // 深远兼容:合并外层字段和 data 层字段,避免各端版本取参有遗漏 + const merged = { ...o, ...(typeof inner === 'object' && inner ? inner : {}) } + return parseRtcInner(merged as Record) } if (o.businessID === 'rtc_call' || o.cmd) { diff --git a/admin/src/views/consumer/prescription/index.vue b/admin/src/views/consumer/prescription/index.vue index 6cfe555e..2115ff1f 100644 --- a/admin/src/views/consumer/prescription/index.vue +++ b/admin/src/views/consumer/prescription/index.vue @@ -160,6 +160,11 @@
{{ row.prescription_date || '—' }}
+ + + @@ -941,7 +1214,9 @@ import { prescriptionOrderAddPayOrder, prescriptionOrderComplete, prescriptionOrderRevokeRxAudit, - prescriptionOrderRevokePayAudit + prescriptionOrderRevokePayAudit, + prescriptionOrderLinkPayOrder, + prescriptionDetail } from '@/api/tcm' import { usePaging } from '@/hooks/usePaging' import feedback from '@/utils/feedback' @@ -1023,7 +1298,7 @@ function auditTagType(s: number | undefined): 'success' | 'warning' | 'danger' | function fulfillmentText(s: number | undefined) { const m: Record = { 1: '待双审通过', - 2: '履约中', + 2: '待发货', 3: '已完成', 4: '已取消', 5: '已发货' @@ -1108,13 +1383,13 @@ function canWithdrawRow(row: { fulfillment_status?: number }) { return Number(row.fulfillment_status) === 1 } -function canShipRow(row: { fulfillment_status?: number; tracking_number?: unknown }) { - // 履约中(2) 且有快递单号时,可确认发货 - return Number(row.fulfillment_status) === 2 && String(row.tracking_number || '').trim() !== '' +function canShipRow(row: { fulfillment_status?: number }) { + // 履约中(2) 可发货 + return Number(row.fulfillment_status) === 2 } function canAddPayOrderRow(row: { fulfillment_status?: number }) { - // 已发货(5) 状态可新增支付单 + // 已发货(5) 状态可补齐支付单 return Number(row.fulfillment_status) === 5 } @@ -1124,9 +1399,8 @@ function canCompleteRow(row: { fulfillment_status?: number; payment_slip_audit_s } function canQuickTrackRow(row: { fulfillment_status?: number }) { - // 待双审通过(1)、履约中(2)、已发货(5) 均可修改快递单号 - const fs = Number(row.fulfillment_status) - return fs === 1 || fs === 2 || fs === 5 + // 已发货(5) 后如需修改快递信息可单独使用此功能 + return Number(row.fulfillment_status) === 5 } function orderStatusText(s: number | undefined) { @@ -1134,7 +1408,8 @@ function orderStatusText(s: number | undefined) { 1: '待支付', 2: '已支付', 3: '已取消', - 4: '已退款' + 4: '已退款', + 5:'待审核' } return m[Number(s)] ?? '—' } @@ -1143,6 +1418,55 @@ const detailVisible = ref(false) const detailLoading = ref(false) const detailData = ref | null>(null) +// --- 订单可视化审批履约流程逻辑 --- +const workflowActiveStep = computed(() => { + if (!detailData.value) return 0 + const fs = Number(detailData.value.fulfillment_status) + const rx = Number(detailData.value.prescription_audit_status) + const pay = Number(detailData.value.payment_slip_audit_status) + + if (fs === 3) return 5 // 订单完成 + if (fs === 5) return 4 // 已发货 + + if (fs === 4) { // 已取消 + if (rx === 2) return 1 + if (pay === 2) return 2 + return 3 + } + + if (fs === 2) return 3 // 履约中 + + // fs === 1 (待双审通过) + if (rx === 0 || rx === 2) return 1 + if (pay === 0 || pay === 2) return 2 + return 3 +}) + +const workflowRxStatus = computed(() => { + const rx = Number(detailData.value?.prescription_audit_status) + if (rx === 1) return 'success' + if (rx === 2) return 'error' + if (workflowActiveStep.value === 1) return 'process' + return 'wait' +}) + +const workflowPayStatus = computed(() => { + const pay = Number(detailData.value?.payment_slip_audit_status) + if (pay === 1) return 'success' + if (pay === 2) return 'error' + if (workflowActiveStep.value === 2) return 'process' + return 'wait' +}) + +const workflowFulfillStatus = computed(() => { + const fs = Number(detailData.value?.fulfillment_status) + if (fs === 3 || fs === 5) return 'success' + if (fs === 4) return 'error' + if (workflowActiveStep.value === 3) return 'process' + return 'wait' +}) +// --- end 可视化流程逻辑 --- + const detailLinkedPayOrders = computed(() => { const d = detailData.value if (!d) return [] @@ -1150,6 +1474,29 @@ const detailLinkedPayOrders = computed(() => { return Array.isArray(arr) ? arr : [] }) +const detailUnlinkedPayOrders = ref([]) + +async function loadDetailUnlinkedPayOrders(diagnosisId: number, prescriptionOrderId: number, linkedIds: number[]) { + if (!diagnosisId) { + detailUnlinkedPayOrders.value = [] + return + } + try { + const res: any = await prescriptionOrderPaidPayOrders({ + diagnosis_id: diagnosisId, + prescription_order_id: prescriptionOrderId + }) + const lists = res?.lists ?? res?.data?.lists ?? [] + // 过滤掉已关联的支付单 + const filtered = Array.isArray(lists) + ? lists.filter((item: any) => !linkedIds.includes(item.id)) + : [] + detailUnlinkedPayOrders.value = filtered + } catch { + detailUnlinkedPayOrders.value = [] + } +} + function normalizeSlipHerbs(raw: unknown): Array<{ name: string; dosage: number }> { if (!raw) return [] if (Array.isArray(raw)) { @@ -1224,7 +1571,7 @@ function logActionText(act: string) { fill_tracking: '填快递单', ship: '确认发货', withdraw: '撤销', - add_pay_order: '新增支付单', + add_pay_order: '补齐支付单', complete: '完成订单' } return m[act] || act @@ -1277,6 +1624,7 @@ async function fetchLogisticsTrace() { async function openDetail(id: number) { // 修复 Bug:显式彻底清空缓存,防止前一次弹窗的数据残留 detailData.value = null + detailUnlinkedPayOrders.value = [] logisticsTracePayload.value = null detailLogisticsExpress.value = 'auto' detailLogs.value = [] @@ -1293,6 +1641,13 @@ async function openDetail(id: number) { fetchLogisticsTrace() } fetchLogs(id) + + // 加载未关联的支付单 + const diagId = d.diagnosis_id + const linkedIds = d.pay_order_ids || [] + if (diagId) { + void loadDetailUnlinkedPayOrders(diagId, id, linkedIds) + } } } catch (e: any) { feedback.msgError(e?.message || '加载失败') @@ -1500,6 +1855,7 @@ async function submitEdit() { const auditVisible = ref(false) const auditKind = ref<'prescription' | 'payment'>('prescription') const auditTargetId = ref(0) +const auditRowData = ref(null) const auditRemark = ref('') const auditSubmitting = ref(false) @@ -1510,6 +1866,7 @@ const auditTitle = computed(() => function openAudit(row: { id: number }, kind: 'prescription' | 'payment') { auditKind.value = kind auditTargetId.value = row.id + auditRowData.value = row auditRemark.value = '' auditVisible.value = true } @@ -1694,18 +2051,24 @@ async function submitShip() { } } -// ─── 新增支付单(已发货状态)──────────────────────────────────────────── +// ─── 补齐支付单(已发货状态)──────────────────────────────────────────── const addPayOrderVisible = ref(false) const addPayOrderSaving = ref(false) const addPayOrderRowId = ref(0) const addPayOrderFormRef = ref() +const addPayOrderAvailableLoading = ref(false) +const addPayOrderAvailableList = ref([]) const addPayOrderForm = reactive({ + add_mode: 'create' as 'create' | 'link', order_type: 3, pay_amount: undefined as number | undefined, - pay_remark: '' + pay_remark: '', + link_pay_order_ids: [] as number[], + completion_request: 0 }) const addPayOrderRules: FormRules = { + add_mode: [{ required: true, message: '请选择添加方式', trigger: 'change' }], order_type: [{ required: true, message: '请选择费用类别', trigger: 'change' }], pay_amount: [ { required: true, message: '请输入支付金额', trigger: 'blur' }, @@ -1720,15 +2083,63 @@ const addPayOrderRules: FormRules = { }, trigger: 'blur' } + ], + link_pay_order_ids: [ + { + required: true, + message: '请至少选择一个支付单', + trigger: 'change', + validator: (_rule, v, cb) => { + if (!Array.isArray(v) || v.length === 0) { + cb(new Error('请至少选择一个支付单')) + } else { + cb() + } + } + } ] } -function openAddPayOrder(row: { id: number }) { +async function loadAddPayOrderAvailable(diagnosisId: number, prescriptionOrderId: number, currentLinkedIds: number[] = []) { + if (!diagnosisId) { + addPayOrderAvailableList.value = [] + return + } + try { + addPayOrderAvailableLoading.value = true + const res: any = await prescriptionOrderPaidPayOrders({ + diagnosis_id: diagnosisId, + prescription_order_id: prescriptionOrderId + }) + const lists = res?.lists ?? res?.data?.lists ?? [] + // 过滤掉当前订单已关联的支付单 + const filtered = Array.isArray(lists) + ? lists.filter((item: any) => !currentLinkedIds.includes(item.id)) + : [] + addPayOrderAvailableList.value = filtered + } catch { + addPayOrderAvailableList.value = [] + } finally { + addPayOrderAvailableLoading.value = false + } +} + +function openAddPayOrder(row: { id: number; diagnosis_id?: number; pay_order_ids?: number[] }) { addPayOrderRowId.value = row.id + addPayOrderForm.add_mode = 'create' addPayOrderForm.order_type = 3 addPayOrderForm.pay_amount = undefined addPayOrderForm.pay_remark = '' + addPayOrderForm.link_pay_order_ids = [] + addPayOrderForm.completion_request = 0 addPayOrderVisible.value = true + + // 加载可用支付单列表,排除当前订单已关联的 + const diagId = row.diagnosis_id || detailData.value?.diagnosis_id + const currentLinkedIds = row.pay_order_ids || detailData.value?.pay_order_ids || [] + if (diagId) { + void loadAddPayOrderAvailable(diagId, row.id, currentLinkedIds) + } } async function submitAddPayOrder() { @@ -1736,13 +2147,32 @@ async function submitAddPayOrder() { await addPayOrderFormRef.value.validate() addPayOrderSaving.value = true try { - await prescriptionOrderAddPayOrder({ - id: addPayOrderRowId.value, - order_type: addPayOrderForm.order_type, - pay_amount: addPayOrderForm.pay_amount!, - pay_remark: addPayOrderForm.pay_remark || '' - }) - feedback.msgSuccess('支付单已新增,请等待审核') + if (addPayOrderForm.add_mode === 'create') { + // 手动创建新支付单 + await prescriptionOrderAddPayOrder({ + id: addPayOrderRowId.value, + order_type: addPayOrderForm.order_type, + pay_amount: addPayOrderForm.pay_amount!, + pay_remark: addPayOrderForm.pay_remark || '', + completion_request: addPayOrderForm.completion_request + }) + feedback.msgSuccess('支付单已新增,请等待审核') + } else { + // 关联已有支付单(支持多个) + const payOrderIds = addPayOrderForm.link_pay_order_ids + if (payOrderIds.length === 0) { + feedback.msgError('请至少选择一个支付单') + return + } + + // 批量关联支付单 + await prescriptionOrderLinkPayOrder({ + id: addPayOrderRowId.value, + pay_order_ids: payOrderIds, + completion_request: addPayOrderForm.completion_request + }) + feedback.msgSuccess(`已关联 ${payOrderIds.length} 个支付单,请等待审核`) + } addPayOrderVisible.value = false getLists() // 若详情 Drawer 打开,同步刷新 @@ -1834,6 +2264,72 @@ async function confirmRevokePayAudit(row: { id: number }) { } } } + +// ─── 查看处方详情(处方单样式)──────────────────────────────────────────── +/** 处方笺抬头/页脚(与诊间打印一致) */ +const SLIP_HOSPITAL_NAME = '成都双流甄养堂互联网医院' +const SLIP_COMPANY_LINE = '成都双流甄养堂互联网医院有限公司 联系方式:4001667339' +const SLIP_ADDRESS_LINE = '地址:四川省成都市双流区黄甲街道黄龙大道二段280号' + +const prescriptionViewVisible = ref(false) +const prescriptionViewLoading = ref(false) +const prescriptionViewData = ref(null) + +// 处方单辅助函数 +const slipHerbsList = computed(() => { + const h = prescriptionViewData.value?.herbs + return Array.isArray(h) ? h : [] +}) + +const slipDietaryText = computed(() => { + const d = prescriptionViewData.value?.dietary_taboo + if (Array.isArray(d)) return d.filter(Boolean).join('、') + if (typeof d === 'string' && d.trim()) return d.trim() + return '' +}) + +function formatAuditTime(ts: number | null | undefined) { + if (!ts) return '' + const d = new Date(ts * 1000) + return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}-${String(d.getDate()).padStart(2, '0')} ${String(d.getHours()).padStart(2, '0')}:${String(d.getMinutes()).padStart(2, '0')}` +} + +function formatVoidTime(ts: number | null | undefined) { + if (!ts) return '—' + return formatAuditTime(ts) +} + +function slipGenderLabel(g: unknown) { + if (g === 1 || g === '1') return '男' + if (g === 0 || g === '0') return '女' + return '—' +} + +function slipAgeText(age: unknown) { + if (age === null || age === undefined || age === '') return '—' + return `${age}岁` +} + +async function openPrescriptionView(row: any) { + if (!row.prescription_id) { + feedback.msgWarning('该订单未关联处方') + return + } + + prescriptionViewVisible.value = true + prescriptionViewLoading.value = true + prescriptionViewData.value = null + + try { + const res: any = await prescriptionDetail({ id: row.prescription_id }) + prescriptionViewData.value = res?.data ?? res ?? null + } catch (e: any) { + feedback.msgError(e?.message || '加载处方详情失败') + prescriptionViewVisible.value = false + } finally { + prescriptionViewLoading.value = false + } +} diff --git a/admin/src/views/doctor/tongji.vue b/admin/src/views/doctor/tongji.vue index d486d1af..10955978 100644 --- a/admin/src/views/doctor/tongji.vue +++ b/admin/src/views/doctor/tongji.vue @@ -1,7 +1,12 @@ @@ -213,6 +192,17 @@ const getStatistics = async () => { const res = await getDoctorStatistics(params) statisticsList.value = res.lists || [] + + // 为每个医生添加必要的属性 + statisticsList.value.forEach(item => { + // 确保数据结构完整 + if (!item.dept_status_details) { + item.dept_status_details = [] + } + if (!item.channel_status_details) { + item.channel_status_details = [] + } + }) } catch (error: any) { console.error('获取统计数据错误:', error) ElMessage.error(error.msg || '获取统计数据失败') @@ -234,23 +224,38 @@ const handleReset = () => { getStatistics() } -const getCompletionRateClass = (rate: number) => { - if (rate >= 80) return 'text-green-600 font-semibold' - if (rate >= 60) return 'text-blue-600 font-semibold' - if (rate >= 40) return 'text-orange-600 font-semibold' - return 'text-red-600 font-semibold' +// 计算部门总数量 +const getDeptTotalCount = (dept: any) => { + if (!dept.statuses) return 0 + return Object.values(dept.statuses).reduce((total: number, count: number) => total + count, 0) } -// 解析部门统计字符串为数组 -const parseDeptStats = (stats: string) => { - if (!stats || stats === '未分配') return [] - return stats.split(' ').filter(item => item.trim()) +// 计算渠道总数量 +const getChannelTotalCount = (channel: any) => { + if (!channel.statuses) return 0 + return Object.values(channel.statuses).reduce((total: number, count: number) => total + count, 0) } -// 解析渠道统计字符串为数组 -const parseChannelStats = (stats: string) => { - if (!stats || stats === '未设置') return [] - return stats.split(' ').filter(item => item.trim()) +// 获取状态名称 +const getStatusName = (status: string | number) => { + const statusMap: Record = { + 1: '已挂号', + 2: '已取消', + 3: '已完成', + 4: '已过号' + } + return statusMap[status] || `状态${status}` +} + +// 获取状态标签类型 +const getStatusType = (status: string | number) => { + const typeMap: Record = { + 1: 'info', + 2: 'danger', + 3: 'success', + 4: 'warning' + } + return typeMap[status] || 'default' } onMounted(() => { @@ -262,23 +267,155 @@ onMounted(() => { diff --git a/admin/src/views/fans/qywx.vue b/admin/src/views/fans/qywx.vue new file mode 100644 index 00000000..2d8c6d91 --- /dev/null +++ b/admin/src/views/fans/qywx.vue @@ -0,0 +1,347 @@ + + + + + diff --git a/admin/src/views/order/index.vue b/admin/src/views/order/index.vue index c3b2cb3b..35151d54 100644 --- a/admin/src/views/order/index.vue +++ b/admin/src/views/order/index.vue @@ -36,6 +36,7 @@ + @@ -808,7 +809,8 @@ const getStatusText = (status: number) => { 1: '待支付', 2: '已支付', 3: '已取消', - 4: '已退款' + 4: '已退款', + 5: '待审核' } return statusMap[status] || '未知' } @@ -819,7 +821,8 @@ const getStatusTag = (status: number): 'warning' | 'success' | 'info' | 'danger' 1: 'warning', 2: 'success', 3: 'info', - 4: 'danger' + 4: 'danger', + 5: 'warning' } return tagMap[status] || 'info' } diff --git a/admin/src/views/tcm/diagnosis/components/CallRecordPanel.vue b/admin/src/views/tcm/diagnosis/components/CallRecordPanel.vue index 0e292908..f083d9c7 100644 --- a/admin/src/views/tcm/diagnosis/components/CallRecordPanel.vue +++ b/admin/src/views/tcm/diagnosis/components/CallRecordPanel.vue @@ -25,6 +25,12 @@ {{ row.call_type === 1 ? '语音' : '视频' }} + + + - + @@ -72,14 +72,14 @@ - + - + @@ -97,9 +97,9 @@ - + - +