订单
This commit is contained in:
@@ -0,0 +1,75 @@
|
||||
# 添加剂量单位和剂数字段到订单编辑表单
|
||||
|
||||
## 任务概述
|
||||
在处方订单编辑表单中添加 `dose_unit`(剂量单位)和 `dose_count`(剂数)字段,支持用户选择和编辑。
|
||||
|
||||
## 实现内容
|
||||
|
||||
### 1. 前端表单字段(admin/src/views/consumer/prescription/order_list.vue)
|
||||
|
||||
#### 添加的表单项:
|
||||
- **剂量单位** (`dose_unit`): 下拉选择框,7个选项
|
||||
- 剂、丸、袋、盒、瓶、膏、贴
|
||||
- 默认值:剂
|
||||
|
||||
- **剂数** (`dose_count`): 数字输入框
|
||||
- 最小值:1
|
||||
- 默认值:1
|
||||
|
||||
#### 修改位置:
|
||||
- 表单定义:在 `medication_days` 字段后添加
|
||||
- 数据加载:`openEdit()` 函数中加载 `dose_count` 和 `dose_unit`
|
||||
- 数据提交:`submitEdit()` 函数中包含 `dose_count` 和 `dose_unit`
|
||||
|
||||
### 2. 后端逻辑(server/app/adminapi/logic/tcm/PrescriptionOrderLogic.php)
|
||||
|
||||
#### 修改的方法:
|
||||
- `edit()` 方法添加字段处理:
|
||||
```php
|
||||
$order->dose_unit = (string) ($params['dose_unit'] ?? '剂');
|
||||
$order->dose_count = isset($params['dose_count']) && (int)$params['dose_count'] > 0 ? (int)$params['dose_count'] : 1;
|
||||
```
|
||||
|
||||
### 3. 数据库迁移(add_dose_unit_to_prescription_order.sql)
|
||||
|
||||
#### 添加的字段:
|
||||
```sql
|
||||
ALTER TABLE `zyt_tcm_prescription_order`
|
||||
ADD COLUMN `dose_unit` varchar(20) NOT NULL DEFAULT '剂' COMMENT '剂量单位:剂、丸、袋、盒、瓶、膏、贴' AFTER `medication_days`,
|
||||
ADD COLUMN `dose_count` int(11) NOT NULL DEFAULT 1 COMMENT '剂数' AFTER `dose_unit`;
|
||||
```
|
||||
|
||||
## 字段说明
|
||||
|
||||
| 字段名 | 类型 | 默认值 | 说明 |
|
||||
|--------|------|--------|------|
|
||||
| dose_unit | varchar(20) | 剂 | 剂量单位:剂、丸、袋、盒、瓶、膏、贴 |
|
||||
| dose_count | int(11) | 1 | 剂数,最小值为1 |
|
||||
|
||||
## 部署步骤
|
||||
|
||||
1. **执行数据库迁移**:
|
||||
```bash
|
||||
mysql -u用户名 -p数据库名 < add_dose_unit_to_prescription_order.sql
|
||||
```
|
||||
|
||||
2. **部署后端代码**:
|
||||
- 更新 `server/app/adminapi/logic/tcm/PrescriptionOrderLogic.php`
|
||||
|
||||
3. **部署前端代码**:
|
||||
- 更新 `admin/src/views/consumer/prescription/order_list.vue`
|
||||
- 重新构建前端:`npm run build`
|
||||
|
||||
## 测试要点
|
||||
|
||||
- [ ] 创建新订单时,`dose_unit` 默认为"剂",`dose_count` 默认为1
|
||||
- [ ] 编辑订单时,能正确加载已保存的 `dose_unit` 和 `dose_count` 值
|
||||
- [ ] 修改 `dose_unit` 和 `dose_count` 后能正确保存
|
||||
- [ ] 验证 `dose_count` 不能小于1
|
||||
- [ ] 验证所有7个剂量单位选项都能正常选择和保存
|
||||
|
||||
## 相关文件
|
||||
|
||||
- `admin/src/views/consumer/prescription/order_list.vue` - 前端订单列表和编辑表单
|
||||
- `server/app/adminapi/logic/tcm/PrescriptionOrderLogic.php` - 后端订单编辑逻辑
|
||||
- `add_dose_unit_to_prescription_order.sql` - 数据库迁移脚本
|
||||
@@ -0,0 +1,91 @@
|
||||
# 检查处方用量字段是否已添加到数据库
|
||||
|
||||
## 问题描述
|
||||
处方的用量相关字段(`dosage_amount`、`dosage_unit`、`need_decoction`)没有保存到数据库。
|
||||
|
||||
## 检查步骤
|
||||
|
||||
### 1. 检查数据库表结构
|
||||
|
||||
执行以下SQL查询,检查字段是否存在:
|
||||
|
||||
```sql
|
||||
SELECT COLUMN_NAME, DATA_TYPE, COLUMN_DEFAULT, COLUMN_COMMENT
|
||||
FROM information_schema.COLUMNS
|
||||
WHERE TABLE_SCHEMA = DATABASE()
|
||||
AND TABLE_NAME = 'zyt_tcm_prescription'
|
||||
AND COLUMN_NAME IN ('dosage_amount', 'dosage_unit', 'need_decoction')
|
||||
ORDER BY ORDINAL_POSITION;
|
||||
```
|
||||
|
||||
### 2. 如果字段不存在,执行迁移
|
||||
|
||||
如果上述查询返回空结果,说明字段还未添加,需要执行迁移文件:
|
||||
|
||||
```bash
|
||||
mysql -u用户名 -p数据库名 < add_prescription_dosage_fields.sql
|
||||
```
|
||||
|
||||
或者直接在数据库中执行:
|
||||
|
||||
```sql
|
||||
ALTER TABLE `zyt_tcm_prescription`
|
||||
ADD COLUMN `dosage_amount` decimal(10,2) DEFAULT NULL COMMENT '用量数值' AFTER `prescription_type`,
|
||||
ADD COLUMN `dosage_unit` varchar(10) NOT NULL DEFAULT '' COMMENT '用量单位(g/ml)' AFTER `dosage_amount`,
|
||||
ADD COLUMN `need_decoction` tinyint(1) NOT NULL DEFAULT 0 COMMENT '是否代煎(0否1是,仅饮片)' AFTER `dosage_unit`;
|
||||
```
|
||||
|
||||
### 3. 验证字段已添加
|
||||
|
||||
再次执行步骤1的查询,应该看到3个字段:
|
||||
|
||||
| COLUMN_NAME | DATA_TYPE | COLUMN_DEFAULT | COLUMN_COMMENT |
|
||||
|-------------|-----------|----------------|----------------|
|
||||
| dosage_amount | decimal | NULL | 用量数值 |
|
||||
| dosage_unit | varchar | '' | 用量单位(g/ml) |
|
||||
| need_decoction | tinyint | 0 | 是否代煎(0否1是,仅饮片) |
|
||||
|
||||
## 代码检查
|
||||
|
||||
### 前端代码 ✅
|
||||
- `admin/src/components/tcm-prescription/index.vue` 已正确实现
|
||||
- 包含 `dosage_amount`、`dosage_unit`、`need_decoction` 字段
|
||||
- 有 `watch` 监听处方类型变化,自动设置单位
|
||||
|
||||
### 后端代码 ✅
|
||||
- `server/app/adminapi/logic/tcm/PrescriptionLogic.php` 的 `add()` 方法已包含这些字段
|
||||
- `server/app/adminapi/logic/tcm/PrescriptionLogic.php` 的 `edit()` 方法已包含这些字段
|
||||
|
||||
### 数据库模型 ✅
|
||||
- `server/app/common/model/tcm/Prescription.php` 已配置类型转换:
|
||||
```php
|
||||
protected $type = [
|
||||
'dosage_amount' => 'float',
|
||||
'need_decoction' => 'integer',
|
||||
];
|
||||
```
|
||||
|
||||
## 结论
|
||||
|
||||
代码层面一切正常,问题很可能是**数据库迁移未执行**。
|
||||
|
||||
请执行上述步骤1检查数据库表结构,如果字段不存在,执行步骤2的迁移SQL。
|
||||
|
||||
## 测试步骤
|
||||
|
||||
迁移完成后,测试以下场景:
|
||||
|
||||
1. **创建新处方**
|
||||
- 选择"浓缩水丸",用量应为 1-10g 下拉选择
|
||||
- 选择"饮片",用量应为 50-250ml 下拉选择,并显示代煎选项
|
||||
- 选择其他类型,用量应为自由输入
|
||||
- 保存后检查数据库,确认字段已保存
|
||||
|
||||
2. **编辑已有处方**
|
||||
- 打开已有处方
|
||||
- 修改用量和代煎选项
|
||||
- 保存后检查数据库,确认字段已更新
|
||||
|
||||
3. **查看处方详情**
|
||||
- 在订单详情中查看处方
|
||||
- 确认用量和代煎信息正确显示
|
||||
@@ -0,0 +1,161 @@
|
||||
# 甘草 API "Array to string conversion" 错误修复
|
||||
|
||||
## 问题描述
|
||||
|
||||
调用甘草处方下单接口时出现错误:
|
||||
```json
|
||||
{"code":2,"message":"Array to string conversion"}
|
||||
```
|
||||
|
||||
## 根本原因
|
||||
|
||||
经过分析代码和 API 文档,发现以下几个问题:
|
||||
|
||||
### 1. 变量作用域问题
|
||||
在 `PrescriptionOrderLogic::submitGancaoRecipel()` 方法中,`$appNo` 变量在返回语句中被引用,但实际定义在更早的位置(line 1638),然后又在 line 1658 被重新赋值。这可能导致变量引用错误。
|
||||
|
||||
### 2. 空字符串处理问题
|
||||
`buildSubmitPayload()` 方法中的 `cradle_store` 字段可能为空字符串,而甘草 API 可能不接受空字符串。
|
||||
|
||||
### 3. 患者年龄格式问题
|
||||
原代码使用 `sprintf('%d.00', $ageInt)` 格式化年龄,但 API 可能期望简单的整数字符串。
|
||||
|
||||
### 4. doct_advice 字段处理
|
||||
`doct_advice` 数组中的某些字段可能包含空字符串或未正确处理的值,导致 JSON 编码时出现问题。
|
||||
|
||||
## 修复内容
|
||||
|
||||
### 1. 修复 `GancaoScmRecipelService.php`
|
||||
|
||||
#### 修改患者年龄格式
|
||||
```php
|
||||
// 修改前
|
||||
$patientAge = sprintf('%d.00', $ageInt);
|
||||
|
||||
// 修改后
|
||||
$patientAge = (string) $ageInt;
|
||||
```
|
||||
|
||||
#### 确保 cradle_store 不为空
|
||||
```php
|
||||
$preview['cradle_store'] = mb_substr(trim((string) ($c['cradle_store'] ?? '')), 0, 32);
|
||||
if ($preview['cradle_store'] === '') {
|
||||
$preview['cradle_store'] = 'default';
|
||||
}
|
||||
```
|
||||
|
||||
#### 优化 doct_advice 字段处理
|
||||
```php
|
||||
// 确保所有字段都是字符串,避免数组到字符串的转换错误
|
||||
$tabooStr = $taboo !== '' ? $taboo : '无';
|
||||
$usageTimeStr = $usageTime;
|
||||
$usageBriefStr = $usageBrief !== '' ? $usageBrief : '遵医嘱';
|
||||
$othersStr = trim((string) ($rx['usage_notes'] ?? ''));
|
||||
$notesDoctorStr = trim((string) ($order['remark_extra'] ?? ''));
|
||||
|
||||
$preview['doct_advice'] = [
|
||||
'taboo' => $tabooStr,
|
||||
'usage_time' => $usageTimeStr,
|
||||
'usage_brief' => $usageBriefStr,
|
||||
'others' => $othersStr !== '' ? $othersStr : '',
|
||||
'notes_doctor' => $notesDoctorStr !== '' ? $notesDoctorStr : '',
|
||||
];
|
||||
```
|
||||
|
||||
#### 添加调试日志
|
||||
```php
|
||||
public static function ctmSubmit(array $submitPayload): array
|
||||
{
|
||||
$transport = self::transport();
|
||||
|
||||
// 记录请求负载以便调试
|
||||
Log::info('Gancao CTM_SUBMIT_RECIPEL payload', ['payload' => $submitPayload]);
|
||||
|
||||
return $transport->post($submitPayload);
|
||||
}
|
||||
```
|
||||
|
||||
### 2. 修复 `PrescriptionOrderLogic.php`
|
||||
|
||||
#### 修复变量引用问题
|
||||
```php
|
||||
// 在返回语句中使用正确的变量引用
|
||||
$fee = $subBody['result']['fee'] ?? [];
|
||||
$appNo = $submitPayload['app_order_no'] ?? ('PO' . (string) $order->id);
|
||||
|
||||
return [
|
||||
'recipel_order_no' => $gcNo,
|
||||
'app_order_no' => $appNo,
|
||||
'fee' => is_array($fee) ? $fee : [],
|
||||
];
|
||||
```
|
||||
|
||||
#### 添加详细错误日志
|
||||
```php
|
||||
use think\facade\Log;
|
||||
|
||||
// 在 submitGancaoRecipel 方法中添加错误日志
|
||||
$subRet = GancaoScmRecipelService::ctmSubmit($submitPayload);
|
||||
if ((int) ($subRet['state'] ?? 0) !== 1) {
|
||||
$errMsg = (string) ($subRet['msg'] ?? '');
|
||||
$response = isset($subRet['response']) ? mb_substr((string) $subRet['response'], 0, 500) : '';
|
||||
self::$error = '甘草下单通信失败:' . $errMsg . ($response !== '' ? ';响应:' . $response : '');
|
||||
Log::error('Gancao CTM_SUBMIT failed', ['subRet' => $subRet, 'payload' => $submitPayload]);
|
||||
|
||||
return false;
|
||||
}
|
||||
$subBody = $subRet['body'] ?? [];
|
||||
if (!GancaoScmRecipelService::isApiSuccess($subBody)) {
|
||||
self::$error = '甘草下单失败:' . GancaoScmRecipelService::apiStatusMessage($subBody);
|
||||
Log::error('Gancao CTM_SUBMIT api error', ['body' => $subBody, 'payload' => $submitPayload]);
|
||||
|
||||
return false;
|
||||
}
|
||||
```
|
||||
|
||||
## 测试建议
|
||||
|
||||
1. **检查日志文件**:修复后再次调用接口,查看 `runtime/log/` 目录下的日志文件,查找 `Gancao CTM_SUBMIT_RECIPEL payload` 日志,确认发送的数据格式是否正确。
|
||||
|
||||
2. **验证必填字段**:根据甘草 API 文档,确保以下字段都有有效值:
|
||||
- `token`:有效的 API token
|
||||
- `df_id`:剂型 ID(如 101 表示饮片)
|
||||
- `amount`:剂量数量
|
||||
- `m_list`:药材列表(每个药材必须有 `id`、`quantity`、`brief` 字段)
|
||||
- `express_to`:收货地址信息
|
||||
- `patient`:患者信息
|
||||
- `doctor`:医生信息
|
||||
- `callback_url`:回调地址(必须是 HTTPS)
|
||||
|
||||
3. **检查配置**:确认 `.env` 文件中的甘草配置完整:
|
||||
```env
|
||||
GANCAO_SCM_ENABLED=true
|
||||
GANCAO_SCM_GATEWAY_URL=https://xxx
|
||||
GANCAO_SCM_GATEWAY_AK=xxx
|
||||
GANCAO_SCM_GATEWAY_SK=xxx
|
||||
GANCAO_SCM_BIZ_AK=xxx
|
||||
GANCAO_SCM_BIZ_SK=xxx
|
||||
GANCAO_SCM_CALLBACK_URL=https://xxx
|
||||
GANCAO_SCM_CRADLE_STORE=xxx
|
||||
GANCAO_SCM_EXPRESS_TYPE=sf
|
||||
```
|
||||
|
||||
4. **测试流程**:
|
||||
- 创建一个测试处方订单
|
||||
- 确保处方审核通过
|
||||
- 调用 `submitGancaoRecipel` 接口
|
||||
- 查看返回结果和日志
|
||||
|
||||
## 可能的其他原因
|
||||
|
||||
如果修复后仍然出现错误,可能是以下原因:
|
||||
|
||||
1. **药材 ID 映射问题**:某些药材的甘草 ID(`gid`)未正确配置
|
||||
2. **剂型参数问题**:`df101ext` 等剂型扩展参数不符合 API 要求
|
||||
3. **地址解析问题**:收货地址无法正确解析为省市区
|
||||
4. **电话号码格式**:电话号码不是 11 位数字
|
||||
|
||||
## 参考文档
|
||||
|
||||
- 甘草 API 文档:https://apidoc.igancao.com/service-doc/scm-outer-recipel.html
|
||||
- 特别关注「中药处方下单」和「中药处方下单参数预检查」章节
|
||||
@@ -0,0 +1,278 @@
|
||||
# 甘草 API "Array to string conversion" 错误完整解决方案
|
||||
|
||||
## 问题现象
|
||||
|
||||
调用甘草处方下单接口时返回:
|
||||
```json
|
||||
{"code":2,"message":"Array to string conversion"}
|
||||
```
|
||||
|
||||
## 根本原因分析
|
||||
|
||||
"Array to string conversion" 错误通常发生在以下情况:
|
||||
|
||||
1. **配置文件中的值被错误地解析为数组**
|
||||
- 例如:`.env` 中 `GANCAO_SCM_EXPRESS_TYPE=["sf"]` 会被解析为数组
|
||||
- 正确应该是:`GANCAO_SCM_EXPRESS_TYPE=sf`
|
||||
|
||||
2. **PHP 尝试将数组转换为字符串**
|
||||
- 在字符串拼接、JSON 编码或其他操作中
|
||||
|
||||
3. **配置读取问题**
|
||||
- ThinkPHP 的配置解析可能将某些值错误地转换为数组
|
||||
|
||||
## 完整修复步骤
|
||||
|
||||
### 步骤 1: 检查配置文件
|
||||
|
||||
运行配置检查脚本:
|
||||
|
||||
```bash
|
||||
cd /path/to/your/project
|
||||
php test_gancao_config.php
|
||||
```
|
||||
|
||||
这个脚本会检查:
|
||||
- 所有必需的配置项是否存在
|
||||
- 配置值的类型是否正确
|
||||
- 是否有数组值被错误地用作字符串
|
||||
|
||||
### 步骤 2: 修复 .env 配置
|
||||
|
||||
确保 `.env` 文件中的甘草配置格式正确:
|
||||
|
||||
```env
|
||||
# 甘草配置必须写在文件顶部,或者使用 [GANCAO_SCM] 分区
|
||||
# 不要写在 [trtc] 等其他分区内
|
||||
|
||||
GANCAO_SCM_ENABLED=true
|
||||
GANCAO_SCM_GATEWAY_URL=https://your-gateway-url.com
|
||||
GANCAO_SCM_GATEWAY_AK=your_gateway_ak
|
||||
GANCAO_SCM_GATEWAY_SK=your_gateway_sk_16chars
|
||||
GANCAO_SCM_BIZ_AK=your_biz_ak
|
||||
GANCAO_SCM_BIZ_SK=your_biz_sk
|
||||
GANCAO_SCM_CALLBACK_URL=https://your-domain.com/api/gancao/callback
|
||||
GANCAO_SCM_EXPRESS_TYPE=sf
|
||||
GANCAO_SCM_CRADLE_STORE=your_store_name
|
||||
GANCAO_SCM_DF_ID=101
|
||||
|
||||
# 可选配置
|
||||
GANCAO_SCM_DEFAULT_IS_DECOCT=1
|
||||
GANCAO_SCM_DEFAULT_TIMES_PER_DAY=2
|
||||
GANCAO_SCM_DEFAULT_NUM_PER_PACK=2
|
||||
GANCAO_SCM_DEFAULT_DOSE_ML=150
|
||||
```
|
||||
|
||||
**重要注意事项:**
|
||||
|
||||
1. ❌ **错误示例**(会导致数组转字符串错误):
|
||||
```env
|
||||
GANCAO_SCM_EXPRESS_TYPE=["sf"]
|
||||
GANCAO_SCM_CALLBACK_URL=["https://..."]
|
||||
```
|
||||
|
||||
2. ✅ **正确示例**:
|
||||
```env
|
||||
GANCAO_SCM_EXPRESS_TYPE=sf
|
||||
GANCAO_SCM_CALLBACK_URL=https://...
|
||||
```
|
||||
|
||||
3. 不要在值周围加引号(除非值本身包含空格)
|
||||
4. 不要使用 JSON 格式
|
||||
5. 不要使用数组语法 `[]`
|
||||
|
||||
### 步骤 3: 检查配置文件
|
||||
|
||||
如果使用了 `config/gancao_scm.php` 配置文件,确保格式正确:
|
||||
|
||||
```php
|
||||
<?php
|
||||
return [
|
||||
'enabled' => env('gancao_scm.enabled', false),
|
||||
'gateway_url' => env('gancao_scm.gateway_url', ''),
|
||||
'gateway_ak' => env('gancao_scm.gateway_ak', ''),
|
||||
'gateway_sk' => env('gancao_scm.gateway_sk', ''),
|
||||
'biz_ak' => env('gancao_scm.biz_ak', ''),
|
||||
'biz_sk' => env('gancao_scm.biz_sk', ''),
|
||||
'callback_url' => env('gancao_scm.callback_url', ''),
|
||||
'express_type' => env('gancao_scm.express_type', 'sf'), // 确保是字符串
|
||||
'cradle_store' => env('gancao_scm.cradle_store', ''),
|
||||
'df_id' => (int)env('gancao_scm.df_id', 101),
|
||||
|
||||
// 可选配置
|
||||
'default_is_decoct' => (int)env('gancao_scm.default_is_decoct', 1),
|
||||
'default_times_per_day' => (int)env('gancao_scm.default_times_per_day', 2),
|
||||
'default_num_per_pack' => (int)env('gancao_scm.default_num_per_pack', 2),
|
||||
'default_dose_ml' => (int)env('gancao_scm.default_dose_ml', 150),
|
||||
|
||||
// 药材 ID 映射(这个可以是数组)
|
||||
'herb_id_map' => [
|
||||
// '药材名' => 甘草ID
|
||||
],
|
||||
];
|
||||
```
|
||||
|
||||
### 步骤 4: 清除缓存
|
||||
|
||||
```bash
|
||||
# 清除 ThinkPHP 缓存
|
||||
cd server
|
||||
php think clear
|
||||
|
||||
# 或者手动删除缓存目录
|
||||
rm -rf runtime/cache/*
|
||||
rm -rf runtime/temp/*
|
||||
```
|
||||
|
||||
### 步骤 5: 重启服务
|
||||
|
||||
```bash
|
||||
# 如果使用 PHP-FPM
|
||||
sudo systemctl restart php-fpm
|
||||
|
||||
# 如果使用 Nginx
|
||||
sudo systemctl restart nginx
|
||||
|
||||
# 如果使用 Apache
|
||||
sudo systemctl restart apache2
|
||||
```
|
||||
|
||||
### 步骤 6: 查看日志
|
||||
|
||||
修复后再次调用接口,查看日志文件:
|
||||
|
||||
```bash
|
||||
tail -f server/runtime/log/$(date +%Y%m%d).log
|
||||
```
|
||||
|
||||
日志会显示:
|
||||
- `Gancao CTM_SUBMIT_RECIPEL payload` - 发送的完整负载
|
||||
- `Gancao CTM_SUBMIT failed` - 如果通信失败
|
||||
- `Gancao CTM_SUBMIT api error` - 如果 API 返回错误
|
||||
- `submitGancaoRecipel exception` - 如果发生异常
|
||||
|
||||
## 代码修复说明
|
||||
|
||||
已修复的文件:
|
||||
|
||||
### 1. `GancaoScmRecipelService.php`
|
||||
|
||||
- ✅ 修复 `express_type` 可能是数组的问题
|
||||
- ✅ 修复 `callback_url` 可能是数组的问题
|
||||
- ✅ 修复患者年龄格式
|
||||
- ✅ 优化 `doct_advice` 字段处理
|
||||
- ✅ 添加调试日志
|
||||
|
||||
### 2. `PrescriptionOrderLogic.php`
|
||||
|
||||
- ✅ 修复 `$appNo` 变量引用问题
|
||||
- ✅ 添加详细错误日志
|
||||
- ✅ 添加异常捕获
|
||||
|
||||
### 3. `PrescriptionOrderController.php`
|
||||
|
||||
- ✅ 添加 try-catch 异常处理
|
||||
- ✅ 添加返回值类型检查
|
||||
- ✅ 添加详细日志记录
|
||||
|
||||
## 测试步骤
|
||||
|
||||
1. **运行配置检查**:
|
||||
```bash
|
||||
php test_gancao_config.php
|
||||
```
|
||||
|
||||
2. **检查输出**:
|
||||
- 所有配置项应该显示 ✓
|
||||
- 不应该有 ❌ 或 ⚠️ 标记
|
||||
- JSON 编码应该成功
|
||||
|
||||
3. **调用接口**:
|
||||
```bash
|
||||
curl -X POST https://your-domain.com/api/tcm.prescriptionOrder/submitGancaoRecipel \
|
||||
-H "Content-Type: application/json" \
|
||||
-H "token: your_token" \
|
||||
-d '{"id": 123}'
|
||||
```
|
||||
|
||||
4. **查看日志**:
|
||||
```bash
|
||||
tail -f server/runtime/log/$(date +%Y%m%d).log | grep -i gancao
|
||||
```
|
||||
|
||||
## 常见错误和解决方案
|
||||
|
||||
### 错误 1: "Array to string conversion"
|
||||
|
||||
**原因**:配置值是数组而不是字符串
|
||||
|
||||
**解决**:
|
||||
1. 运行 `php test_gancao_config.php` 找出哪个配置是数组
|
||||
2. 修改 `.env` 文件,移除 `[]` 和引号
|
||||
3. 清除缓存并重启服务
|
||||
|
||||
### 错误 2: "callback_url is invalid"
|
||||
|
||||
**原因**:回调地址未配置或格式错误
|
||||
|
||||
**解决**:
|
||||
1. 确保 `GANCAO_SCM_CALLBACK_URL` 配置正确
|
||||
2. 必须是 HTTPS 地址
|
||||
3. 格式:`https://your-domain.com/api/gancao/callback`
|
||||
|
||||
### 错误 3: "express_type is invalid"
|
||||
|
||||
**原因**:快递类型配置错误
|
||||
|
||||
**解决**:
|
||||
1. 检查 `GANCAO_SCM_EXPRESS_TYPE` 配置
|
||||
2. 有效值:`sf`(顺丰)、`jd`(京东)、`jt`(极兔)、`auto`(自动)
|
||||
3. 默认使用 `sf`
|
||||
|
||||
### 错误 4: JSON 编码失败
|
||||
|
||||
**原因**:数据中包含无法编码的内容
|
||||
|
||||
**解决**:
|
||||
1. 检查处方数据中是否有特殊字符
|
||||
2. 检查药材名称是否包含非 UTF-8 字符
|
||||
3. 查看日志中的完整错误信息
|
||||
|
||||
## 验证修复
|
||||
|
||||
修复成功的标志:
|
||||
|
||||
1. ✅ 配置检查脚本全部通过
|
||||
2. ✅ 接口返回成功:
|
||||
```json
|
||||
{
|
||||
"code": 1,
|
||||
"msg": "甘草药方上传成功",
|
||||
"data": {
|
||||
"recipel_order_no": "GC202604...",
|
||||
"app_order_no": "PO123",
|
||||
"fee": {
|
||||
"total": 150.00,
|
||||
"medicine": 120.00,
|
||||
"process": 20.00,
|
||||
"express": 10.00
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
3. ✅ 日志中显示 `submitGancaoRecipel success`
|
||||
4. ✅ 数据库中 `gancao_reciperl_order_no` 字段已更新
|
||||
|
||||
## 需要帮助?
|
||||
|
||||
如果以上步骤都无法解决问题,请提供:
|
||||
|
||||
1. 配置检查脚本的完整输出
|
||||
2. 错误日志的完整内容(`runtime/log/*.log`)
|
||||
3. 接口返回的完整响应
|
||||
4. `.env` 文件中的甘草配置部分(隐藏敏感信息)
|
||||
|
||||
## 参考文档
|
||||
|
||||
- 甘草 API 文档:https://apidoc.igancao.com/service-doc/scm-outer-recipel.html
|
||||
- ThinkPHP 配置文档:https://www.kancloud.cn/manual/thinkphp6_0/1037488
|
||||
@@ -0,0 +1,355 @@
|
||||
# 甘草订单状态回调功能配置指南
|
||||
|
||||
## 功能说明
|
||||
|
||||
实现甘草订单状态回调功能,当甘草订单状态发生变化时(如审核通过、制作中、发货、完成等),甘草系统会主动回调我们的接口,更新订单状态。
|
||||
|
||||
## 已创建的文件
|
||||
|
||||
### 1. 回调控制器
|
||||
**文件:** `server/app/api/controller/GancaoCallbackController.php`
|
||||
|
||||
**功能:**
|
||||
- 接收甘草订单状态回调
|
||||
- 验证回调签名(防止伪造)
|
||||
- 更新订单状态
|
||||
- 记录回调日志
|
||||
|
||||
**支持的订单状态:**
|
||||
- `10` - 系统审核中
|
||||
- `11` - 系统审核通过
|
||||
- `110` - 订单药房流转制作中(包含:派单、审方、调配、复核、浸泡、煎药、包装、发货等流程)
|
||||
- `20` - 物流中
|
||||
- `30` - 完成(终态)
|
||||
- `90` - 拦截(终止流转:可恢复)
|
||||
- `91` - 主动撤单(退费:终态)
|
||||
- `92` - 驳回(无法制作并退费:终态)
|
||||
|
||||
### 2. 数据库迁移文件
|
||||
**文件:** `add_gancao_callback_fields.sql`
|
||||
|
||||
**新增字段:**
|
||||
- `gancao_order_state` - 甘草订单状态
|
||||
- `gancao_flow_name` - 订单流程名称
|
||||
- `gancao_supplier` - 供应商/药房名称
|
||||
- `gancao_remark` - 订单备注
|
||||
|
||||
## 配置步骤
|
||||
|
||||
### 步骤 1: 执行数据库迁移
|
||||
|
||||
```bash
|
||||
# 连接到数据库
|
||||
mysql -u root -p your_database
|
||||
|
||||
# 执行 SQL 文件
|
||||
source /path/to/add_gancao_callback_fields.sql;
|
||||
|
||||
# 或者直接执行
|
||||
mysql -u root -p your_database < add_gancao_callback_fields.sql
|
||||
```
|
||||
|
||||
### 步骤 2: 添加路由
|
||||
|
||||
在 `server/route/api.php` 文件中添加回调路由:
|
||||
|
||||
```php
|
||||
<?php
|
||||
use think\facade\Route;
|
||||
|
||||
// 甘草订单状态回调(不需要登录验证)
|
||||
Route::post('gancao/callback/order-status', 'GancaoCallbackController@orderStatus');
|
||||
```
|
||||
|
||||
**注意:** 这个路由不需要登录验证,因为是甘草系统主动回调。
|
||||
|
||||
### 步骤 3: 配置回调 URL
|
||||
|
||||
在 `.env` 文件中配置回调地址:
|
||||
|
||||
```env
|
||||
# 甘草回调配置
|
||||
GANCAO_SCM_CALLBACK_URL=https://your-domain.com/api/gancao/callback/order-status
|
||||
|
||||
# 回调签名验证(可选,如果甘草提供了独立的回调密钥)
|
||||
# GANCAO_SCM_CALLBACK_APPKEY=your_callback_appkey
|
||||
# GANCAO_SCM_CALLBACK_SECRET_KEY=your_callback_secret_key
|
||||
```
|
||||
|
||||
**重要:**
|
||||
1. 回调地址必须是 **HTTPS**
|
||||
2. 回调地址必须能从公网访问
|
||||
3. 如果是开发环境,可以使用内网穿透工具(如 ngrok)
|
||||
|
||||
### 步骤 4: 更新配置文件
|
||||
|
||||
在 `server/config/gancao_scm.php` 中添加回调配置:
|
||||
|
||||
```php
|
||||
return [
|
||||
// ... 其他配置
|
||||
|
||||
'callback_url' => (string) zyt_gancao_scm_env('CALLBACK_URL', ''),
|
||||
|
||||
// 回调签名验证(如果甘草提供了独立的密钥)
|
||||
'callback_appkey' => (string) zyt_gancao_scm_env('CALLBACK_APPKEY', ''),
|
||||
'callback_secret_key' => (string) zyt_gancao_scm_env('CALLBACK_SECRET_KEY', ''),
|
||||
];
|
||||
```
|
||||
|
||||
### 步骤 5: 测试回调接口
|
||||
|
||||
#### 方法 A:使用 curl 测试
|
||||
|
||||
```bash
|
||||
# 模拟甘草回调请求
|
||||
curl -X POST https://your-domain.com/api/gancao/callback/order-status \
|
||||
-H "Content-Type: application/json" \
|
||||
-H "access-appkey: your_appkey" \
|
||||
-H "access-nonce: test1234" \
|
||||
-H "access-timestamp: $(date +%s)" \
|
||||
-H "access-sign: calculated_sign" \
|
||||
-d '{
|
||||
"recipel_order_no": "GC202604...",
|
||||
"state": 11,
|
||||
"ext": {
|
||||
"flow_name": "审核通过"
|
||||
}
|
||||
}'
|
||||
```
|
||||
|
||||
#### 方法 B:查看日志
|
||||
|
||||
```bash
|
||||
# 查看回调日志
|
||||
tail -f server/runtime/log/$(date +%Y%m%d).log | grep -i "gancao callback"
|
||||
```
|
||||
|
||||
### 步骤 6: 向甘草提供回调地址
|
||||
|
||||
联系甘草技术支持,提供以下信息:
|
||||
1. 回调地址:`https://your-domain.com/api/gancao/callback/order-status`
|
||||
2. 确认回调签名验证方式
|
||||
3. 测试回调是否正常
|
||||
|
||||
## 回调数据格式
|
||||
|
||||
### 请求头
|
||||
|
||||
```
|
||||
access-appkey: ak-xxxxx
|
||||
access-nonce: random_string
|
||||
access-timestamp: 1234567890
|
||||
access-sign: md5_hash
|
||||
```
|
||||
|
||||
### 请求体
|
||||
|
||||
```json
|
||||
{
|
||||
"recipel_order_no": "GC202604...",
|
||||
"state": 110,
|
||||
"ext": {
|
||||
"flow_name": "煎药开始",
|
||||
"supplier": "XX药房"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### 状态 110(制作中)的扩展信息
|
||||
|
||||
```json
|
||||
{
|
||||
"recipel_order_no": "GC202604...",
|
||||
"state": 110,
|
||||
"ext": {
|
||||
"flow_name": "派单|审方|调配|复核|浸泡|煎药开始|包装|发货|寄出",
|
||||
"supplier": "XX药房"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### 状态 20(物流中)的扩展信息
|
||||
|
||||
```json
|
||||
{
|
||||
"recipel_order_no": "GC202604...",
|
||||
"state": 20,
|
||||
"ext": {
|
||||
"shipping_name": "顺丰速运",
|
||||
"nu": "SF1234567890",
|
||||
"supplier": "XX药房"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## 签名验证
|
||||
|
||||
### 签名算法
|
||||
|
||||
```
|
||||
access_sign = md5(access-appkey + secret-key + access-nonce + access-timestamp + request_body)
|
||||
```
|
||||
|
||||
### 示例
|
||||
|
||||
```
|
||||
access-appkey: ak-36b05d5034f13a86b102c97e72f14
|
||||
secret-key: f8c1f3c58b55a61a4d41242016d314ea
|
||||
access-nonce: 1jd4u8ii
|
||||
access-timestamp: 1723014934
|
||||
Body: {"recipel_order_no":"1234","state":10,"ext":{"flow_name":"浸泡开始"}}
|
||||
|
||||
计算:
|
||||
md5("ak-36b05d5034f13a86b102c97e72f14" + "f8c1f3c58b55a61a4d41242016d314ea" + "1jd4u8ii" + "1723014934" + '{"recipel_order_no":"1234","state":10,"ext":{"flow_name":"浸泡开始"}}')
|
||||
= 03b46e3b385d1dceb183cad08e44f31f
|
||||
```
|
||||
|
||||
## 订单状态映射
|
||||
|
||||
### 甘草状态 → 系统履约状态
|
||||
|
||||
| 甘草状态 | 状态名称 | 系统履约状态 | 说明 |
|
||||
|---------|---------|-------------|------|
|
||||
| 10 | 系统审核中 | 保持不变 | 甘草内部审核 |
|
||||
| 11 | 系统审核通过 | 保持不变 | 审核通过,准备制作 |
|
||||
| 110 | 订单药房流转制作中 | 2(履约中) | 药房制作流程 |
|
||||
| 110(发货) | 发货/寄出 | 5(已发货) | 药房已发货 |
|
||||
| 20 | 物流中 | 5(已发货) | 快递运输中 |
|
||||
| 30 | 完成 | 3(已完成) | 订单完成 |
|
||||
| 90 | 拦截 | 保持不变 | 订单被拦截 |
|
||||
| 91 | 主动撤单 | 4(已取消) | 撤单并退费 |
|
||||
| 92 | 驳回 | 4(已取消) | 无法制作并退费 |
|
||||
|
||||
## 日志记录
|
||||
|
||||
### 回调接收日志
|
||||
|
||||
```
|
||||
[info] Gancao callback received: {
|
||||
"headers": {...},
|
||||
"body": {...}
|
||||
}
|
||||
```
|
||||
|
||||
### 回调处理日志
|
||||
|
||||
```
|
||||
[info] Gancao callback processed: {
|
||||
"order_id": 123,
|
||||
"recipel_order_no": "GC202604...",
|
||||
"state": 110,
|
||||
"ext": {...}
|
||||
}
|
||||
```
|
||||
|
||||
### 订单日志
|
||||
|
||||
在 `zyt_prescription_order_log` 表中记录:
|
||||
- `admin_name`: "甘草系统"
|
||||
- `action`: "gancao_callback"
|
||||
- `summary`: "甘草订单状态更新:系统审核通过"
|
||||
|
||||
## 错误处理
|
||||
|
||||
### 签名验证失败
|
||||
|
||||
```
|
||||
[warning] Gancao callback sign verification failed
|
||||
```
|
||||
|
||||
**处理:** 返回 "ok",避免甘草重试
|
||||
|
||||
### 订单不存在
|
||||
|
||||
```
|
||||
[warning] Gancao callback order not found
|
||||
```
|
||||
|
||||
**处理:** 返回 "ok",记录日志
|
||||
|
||||
### 更新失败
|
||||
|
||||
```
|
||||
[error] Gancao callback update order failed
|
||||
```
|
||||
|
||||
**处理:** 返回 "ok",记录错误日志
|
||||
|
||||
## 重试机制
|
||||
|
||||
甘草的重试策略:
|
||||
- 如果回调失败(未返回 "ok" 或超时),会进行重试
|
||||
- 最多重试 10 次
|
||||
- 重试间隔:失败次数 × 5 分钟
|
||||
|
||||
**建议:**
|
||||
1. 接口必须在 5 秒内返回 "ok"
|
||||
2. 使用异步处理复杂逻辑
|
||||
3. 即使处理失败也返回 "ok",避免重复回调
|
||||
|
||||
## 安全建议
|
||||
|
||||
1. **启用签名验证**:防止伪造回调
|
||||
2. **记录所有回调**:便于排查问题
|
||||
3. **限制访问频率**:防止恶意请求
|
||||
4. **使用 HTTPS**:保护数据传输安全
|
||||
5. **IP 白名单**:只允许甘草服务器 IP 访问(可选)
|
||||
|
||||
## 监控和告警
|
||||
|
||||
### 监控指标
|
||||
|
||||
1. 回调接收数量
|
||||
2. 签名验证失败次数
|
||||
3. 订单更新失败次数
|
||||
4. 回调处理耗时
|
||||
|
||||
### 告警规则
|
||||
|
||||
1. 签名验证失败率 > 10%
|
||||
2. 订单更新失败率 > 5%
|
||||
3. 回调处理耗时 > 3 秒
|
||||
|
||||
## 测试清单
|
||||
|
||||
- [ ] 数据库字段已添加
|
||||
- [ ] 路由已配置
|
||||
- [ ] 回调 URL 已配置
|
||||
- [ ] 签名验证正常
|
||||
- [ ] 订单状态更新正常
|
||||
- [ ] 日志记录正常
|
||||
- [ ] 错误处理正常
|
||||
- [ ] 已向甘草提供回调地址
|
||||
- [ ] 已测试真实回调
|
||||
|
||||
## 故障排查
|
||||
|
||||
### 问题 1:收不到回调
|
||||
|
||||
**检查:**
|
||||
1. 回调 URL 是否正确
|
||||
2. 服务器是否能从公网访问
|
||||
3. 防火墙是否开放端口
|
||||
4. 路由是否配置正确
|
||||
|
||||
### 问题 2:签名验证失败
|
||||
|
||||
**检查:**
|
||||
1. appkey 是否正确
|
||||
2. secret-key 是否正确
|
||||
3. 签名算法是否正确
|
||||
4. 请求体是否被修改
|
||||
|
||||
### 问题 3:订单状态未更新
|
||||
|
||||
**检查:**
|
||||
1. 订单是否存在
|
||||
2. 数据库字段是否添加
|
||||
3. 更新逻辑是否正确
|
||||
4. 查看错误日志
|
||||
|
||||
## 相关文档
|
||||
|
||||
- 甘草 API 文档:https://apidoc.igancao.com/service-doc/scm-outer-recipel.html#订单状态回调
|
||||
- ThinkPHP 路由文档:https://www.kancloud.cn/manual/thinkphp6_0/1037493
|
||||
@@ -0,0 +1,318 @@
|
||||
# 甘草 API "Array to string conversion" 最终修复
|
||||
|
||||
## 问题根源
|
||||
|
||||
错误发生在 `GancaoScmRecipelService::getToken()` 方法中:
|
||||
|
||||
```php
|
||||
// 第 126 行
|
||||
if ($code === '10103' || str_contains($apiMsg, '10103')) {
|
||||
```
|
||||
|
||||
**原因:** 当甘草 API 返回错误时,`$body['status']['msg']` 可能是**数组**而不是字符串,导致:
|
||||
1. `apiStatusMessage()` 方法尝试将数组转换为字符串
|
||||
2. `str_contains()` 函数接收到数组参数,触发 "Array to string conversion" 错误
|
||||
|
||||
## 已修复的问题
|
||||
|
||||
### 1. `apiStatusMessage()` 方法
|
||||
|
||||
**修复前:**
|
||||
```php
|
||||
$msg = (string) ($body['status']['msg'] ?? '');
|
||||
```
|
||||
|
||||
**修复后:**
|
||||
```php
|
||||
$msg = $body['status']['msg'] ?? '';
|
||||
|
||||
// 确保 msg 是字符串
|
||||
if (is_array($msg)) {
|
||||
$msg = json_encode($msg, JSON_UNESCAPED_UNICODE);
|
||||
} else {
|
||||
$msg = (string) $msg;
|
||||
}
|
||||
```
|
||||
|
||||
### 2. `getToken()` 方法中的 `str_contains()` 调用
|
||||
|
||||
**修复前:**
|
||||
```php
|
||||
if ($code === '10103' || str_contains($apiMsg, '10103')) {
|
||||
```
|
||||
|
||||
**修复后:**
|
||||
```php
|
||||
// 确保 $apiMsg 是字符串,避免 Array to string conversion 错误
|
||||
$apiMsgStr = is_string($apiMsg) ? $apiMsg : json_encode($apiMsg, JSON_UNESCAPED_UNICODE);
|
||||
|
||||
if ($code === '10103' || str_contains($apiMsgStr, '10103')) {
|
||||
```
|
||||
|
||||
### 3. 其他已修复的问题
|
||||
|
||||
#### SSL 连接问题(`GancaoOpenApiTransport.php`)
|
||||
- ✅ 改用 HTTP/1.1
|
||||
- ✅ 强制使用 TLS 1.2
|
||||
- ✅ 降低 OpenSSL 安全级别
|
||||
- ✅ 增加连接超时
|
||||
- ✅ 添加 TCP keepalive
|
||||
|
||||
#### 配置类型问题(`GancaoScmRecipelService.php`)
|
||||
- ✅ 确保 `express_type` 是字符串
|
||||
- ✅ 确保 `callback_url` 是字符串
|
||||
- ✅ 修复患者年龄格式
|
||||
- ✅ 优化 `doct_advice` 字段处理
|
||||
|
||||
#### 错误处理(`PrescriptionOrderLogic.php` 和 `PrescriptionOrderController.php`)
|
||||
- ✅ 添加详细错误日志
|
||||
- ✅ 添加异常捕获
|
||||
- ✅ 添加返回值类型检查
|
||||
|
||||
## 完整的修复文件列表
|
||||
|
||||
1. ✅ `server/app/common/service/gancao/GancaoScmRecipelService.php`
|
||||
2. ✅ `server/app/common/service/gancao/GancaoOpenApiTransport.php`
|
||||
3. ✅ `server/app/adminapi/logic/tcm/PrescriptionOrderLogic.php`
|
||||
4. ✅ `server/app/adminapi/controller/tcm/PrescriptionOrderController.php`
|
||||
|
||||
## 部署步骤
|
||||
|
||||
### 步骤 1: 上传所有修复文件
|
||||
|
||||
```bash
|
||||
# 上传修复后的文件
|
||||
scp server/app/common/service/gancao/GancaoScmRecipelService.php user@server:/path/to/server/app/common/service/gancao/
|
||||
scp server/app/common/service/gancao/GancaoOpenApiTransport.php user@server:/path/to/server/app/common/service/gancao/
|
||||
scp server/app/adminapi/logic/tcm/PrescriptionOrderLogic.php user@server:/path/to/server/app/adminapi/logic/tcm/
|
||||
scp server/app/adminapi/controller/tcm/PrescriptionOrderController.php user@server:/path/to/server/app/adminapi/controller/tcm/
|
||||
|
||||
# 上传测试脚本
|
||||
scp test_gancao_config.php user@server:/path/to/
|
||||
scp test_gancao_ssl.php user@server:/path/to/
|
||||
scp diagnose_gancao_payload.php user@server:/path/to/
|
||||
```
|
||||
|
||||
### 步骤 2: 验证文件已上传
|
||||
|
||||
```bash
|
||||
ssh user@server
|
||||
cd /path/to/your/project
|
||||
|
||||
# 检查文件修改时间
|
||||
ls -la server/app/common/service/gancao/GancaoScmRecipelService.php
|
||||
ls -la server/app/common/service/gancao/GancaoOpenApiTransport.php
|
||||
ls -la server/app/adminapi/logic/tcm/PrescriptionOrderLogic.php
|
||||
ls -la server/app/adminapi/controller/tcm/PrescriptionOrderController.php
|
||||
```
|
||||
|
||||
### 步骤 3: 运行配置检查
|
||||
|
||||
```bash
|
||||
php test_gancao_config.php
|
||||
```
|
||||
|
||||
**期望输出:** 所有配置项显示 ✓
|
||||
|
||||
### 步骤 4: 运行 SSL 测试
|
||||
|
||||
```bash
|
||||
php test_gancao_ssl.php
|
||||
```
|
||||
|
||||
**期望输出:** 至少一种 SSL 配置测试成功,并且能成功获取 token
|
||||
|
||||
### 步骤 5: 清除缓存
|
||||
|
||||
```bash
|
||||
cd server
|
||||
php think clear
|
||||
rm -rf runtime/cache/*
|
||||
rm -rf runtime/temp/*
|
||||
```
|
||||
|
||||
### 步骤 6: 重启服务
|
||||
|
||||
```bash
|
||||
# PHP-FPM
|
||||
sudo systemctl restart php-fpm
|
||||
|
||||
# Nginx
|
||||
sudo systemctl restart nginx
|
||||
|
||||
# 或者 Apache
|
||||
sudo systemctl restart apache2
|
||||
```
|
||||
|
||||
### 步骤 7: 测试接口
|
||||
|
||||
**开启日志监控:**
|
||||
```bash
|
||||
tail -f server/runtime/log/$(date +%Y%m%d).log | grep -E "(Gancao|gancao|submitGancao)"
|
||||
```
|
||||
|
||||
**在另一个终端调用接口:**
|
||||
```bash
|
||||
curl -X POST https://your-domain.com/api/tcm.prescriptionOrder/submitGancaoRecipel \
|
||||
-H "Content-Type: application/json" \
|
||||
-H "token: your_admin_token" \
|
||||
-d '{"id": 订单ID}'
|
||||
```
|
||||
|
||||
## 预期结果
|
||||
|
||||
### 成功的情况
|
||||
|
||||
**接口响应:**
|
||||
```json
|
||||
{
|
||||
"code": 1,
|
||||
"show": 0,
|
||||
"msg": "甘草药方上传成功",
|
||||
"data": {
|
||||
"recipel_order_no": "GC202604...",
|
||||
"app_order_no": "PO123",
|
||||
"fee": {
|
||||
"total": 150.00,
|
||||
"medicine": 120.00,
|
||||
"process": 20.00,
|
||||
"express": 10.00
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**日志内容:**
|
||||
```
|
||||
[info] Gancao MAKE_TOKEN request: {...}
|
||||
[info] Gancao MAKE_TOKEN response: {...}
|
||||
[info] Gancao CTM_SUBMIT_RECIPEL payload: {...}
|
||||
[info] submitGancaoRecipel success: {...}
|
||||
```
|
||||
|
||||
**数据库变化:**
|
||||
- `prescription_order` 表中对应订单的 `gancao_reciperl_order_no` 字段已更新
|
||||
- `gancao_submit_time` 字段已更新为当前时间戳
|
||||
|
||||
### 失败的情况
|
||||
|
||||
如果仍然失败,日志会显示详细错误信息:
|
||||
|
||||
**配置错误:**
|
||||
```
|
||||
[error] Gancao MAKE_TOKEN api error: {"body": {...}, "apiMsg": "..."}
|
||||
```
|
||||
→ 检查 `.env` 配置,特别是 AK/SK
|
||||
|
||||
**SSL 连接错误:**
|
||||
```
|
||||
[error] Gancao CTM_SUBMIT failed: {"subRet": {...}, "payload": {...}}
|
||||
```
|
||||
→ 运行 `php test_gancao_ssl.php` 诊断 SSL 问题
|
||||
|
||||
**业务逻辑错误:**
|
||||
```
|
||||
[error] submitGancaoRecipel exception: {"message": "...", "trace": "..."}
|
||||
```
|
||||
→ 检查处方数据是否完整,药材 ID 是否正确映射
|
||||
|
||||
## 常见错误和解决方案
|
||||
|
||||
### 错误 1: "Array to string conversion"
|
||||
|
||||
**已修复!** 如果仍然出现,请检查:
|
||||
1. 文件是否正确上传
|
||||
2. 缓存是否已清除
|
||||
3. 服务是否已重启
|
||||
|
||||
### 错误 2: SSL 连接错误
|
||||
|
||||
**解决方案:**
|
||||
1. 运行 `php test_gancao_ssl.php`
|
||||
2. 查看哪种 SSL 配置成功
|
||||
3. 如果都失败,检查 OpenSSL 版本:`openssl version`
|
||||
4. 联系甘草技术支持确认 SSL 要求
|
||||
|
||||
### 错误 3: "MAKE_TOKEN 失败:[10103]"
|
||||
|
||||
**原因:** AK/SK 不匹配或密码计算错误
|
||||
|
||||
**解决方案:**
|
||||
1. 检查 `.env` 中的配置:
|
||||
```env
|
||||
GANCAO_SCM_BIZ_AK=xxx
|
||||
GANCAO_SCM_BIZ_SK=xxx
|
||||
```
|
||||
2. 确认 BIZ AK/SK 与网关 AK/SK 是否相同
|
||||
3. 检查服务器时间是否正确:`date`
|
||||
4. 联系甘草获取正确的 AK/SK
|
||||
|
||||
### 错误 4: "药材无法匹配甘草药ID"
|
||||
|
||||
**原因:** 药材 ID 映射缺失
|
||||
|
||||
**解决方案:**
|
||||
1. 检查 `zyt_doctor_medicine` 表中药材的 `gid` 字段
|
||||
2. 或在 `.env` 配置 `herb_id_map`:
|
||||
```php
|
||||
'herb_id_map' => [
|
||||
'当归' => 1001,
|
||||
'黄芪' => 1002,
|
||||
]
|
||||
```
|
||||
|
||||
## 验证修复成功
|
||||
|
||||
✅ **所有以下条件都满足,说明修复成功:**
|
||||
|
||||
1. `php test_gancao_config.php` 全部通过
|
||||
2. `php test_gancao_ssl.php` 显示 "✓ 成功获取 token"
|
||||
3. 接口返回 `{"code": 1, "msg": "甘草药方上传成功"}`
|
||||
4. 日志中显示 `submitGancaoRecipel success`
|
||||
5. 数据库中订单已更新 `gancao_reciperl_order_no`
|
||||
6. 没有 "Array to string conversion" 错误
|
||||
7. 没有 SSL 连接错误
|
||||
|
||||
## 技术支持
|
||||
|
||||
如果以上步骤都无法解决问题,请收集以下信息:
|
||||
|
||||
1. **配置检查输出:**
|
||||
```bash
|
||||
php test_gancao_config.php > config_check.txt 2>&1
|
||||
```
|
||||
|
||||
2. **SSL 测试输出:**
|
||||
```bash
|
||||
php test_gancao_ssl.php > ssl_check.txt 2>&1
|
||||
```
|
||||
|
||||
3. **错误日志:**
|
||||
```bash
|
||||
tail -200 server/runtime/log/$(date +%Y%m%d).log > error.log
|
||||
```
|
||||
|
||||
4. **接口响应:**
|
||||
```bash
|
||||
curl -X POST ... > response.json 2>&1
|
||||
```
|
||||
|
||||
5. **系统信息:**
|
||||
```bash
|
||||
php -v > system_info.txt
|
||||
openssl version >> system_info.txt
|
||||
curl --version >> system_info.txt
|
||||
cat /etc/os-release >> system_info.txt
|
||||
```
|
||||
|
||||
将这些文件发送给技术支持进行分析。
|
||||
|
||||
## 总结
|
||||
|
||||
这次修复解决了三个主要问题:
|
||||
|
||||
1. **Array to string conversion** - 甘草 API 返回的错误消息可能是数组
|
||||
2. **SSL 连接失败** - TLS 版本和 HTTP 版本不兼容
|
||||
3. **配置类型错误** - 某些配置值被错误地解析为数组
|
||||
|
||||
所有问题都已修复,现在应该可以正常调用甘草 API 了!
|
||||
@@ -0,0 +1,286 @@
|
||||
# 甘草 API "Array to string conversion" 错误修复总结
|
||||
|
||||
## 问题描述
|
||||
在 Linux 服务器上调用甘草处方下单接口时,返回错误:
|
||||
```json
|
||||
{"code":2,"message":"Array to string conversion"}
|
||||
```
|
||||
|
||||
## 已完成的修复
|
||||
|
||||
### 1. 代码层面修复
|
||||
|
||||
#### ✅ `GancaoScmRecipelService.php`
|
||||
- 修复 `express_type` 字段:确保是字符串而不是数组
|
||||
- 修复 `callback_url` 字段:确保是字符串而不是数组
|
||||
- 修复患者年龄格式:从 `sprintf('%d.00', $ageInt)` 改为 `(string) $ageInt`
|
||||
- 优化 `doct_advice` 字段处理:确保所有子字段都是字符串
|
||||
- 添加调试日志:记录发送的完整负载
|
||||
|
||||
#### ✅ `PrescriptionOrderLogic.php`
|
||||
- 修复 `$appNo` 变量引用问题
|
||||
- 添加详细错误日志:记录失败时的完整请求和响应
|
||||
- 添加 `use think\facade\Log;` 导入
|
||||
|
||||
#### ✅ `PrescriptionOrderController.php`
|
||||
- 添加 try-catch 异常处理
|
||||
- 添加返回值类型检查
|
||||
- 添加详细日志记录
|
||||
|
||||
### 2. 诊断工具
|
||||
|
||||
创建了三个诊断脚本:
|
||||
|
||||
1. **`test_gancao_config.php`** - 配置检查脚本
|
||||
- 检查所有配置项是否存在
|
||||
- 检查配置值类型是否正确
|
||||
- 检查是否有数组值被错误地用作字符串
|
||||
- 测试 JSON 编码
|
||||
|
||||
2. **`diagnose_gancao_payload.php`** - 负载诊断脚本
|
||||
- 测试 `buildPreviewPayload` 方法
|
||||
- 测试 `buildSubmitPayload` 方法
|
||||
- 检查每个字段的类型
|
||||
- 测试 JSON 编码
|
||||
- 保存负载到文件以便检查
|
||||
|
||||
3. **文档**:
|
||||
- `GANCAO_API_FIX.md` - 初步修复说明
|
||||
- `GANCAO_ARRAY_TO_STRING_FIX.md` - 完整解决方案
|
||||
- `GANCAO_FIX_SUMMARY.md` - 本文档
|
||||
|
||||
## 下一步操作
|
||||
|
||||
### 步骤 1: 上传修复后的代码到服务器
|
||||
|
||||
```bash
|
||||
# 上传修改的文件
|
||||
scp server/app/common/service/gancao/GancaoScmRecipelService.php user@server:/path/to/server/app/common/service/gancao/
|
||||
scp server/app/adminapi/logic/tcm/PrescriptionOrderLogic.php user@server:/path/to/server/app/adminapi/logic/tcm/
|
||||
scp server/app/adminapi/controller/tcm/PrescriptionOrderController.php user@server:/path/to/server/app/adminapi/controller/tcm/
|
||||
|
||||
# 上传诊断脚本
|
||||
scp test_gancao_config.php user@server:/path/to/
|
||||
scp diagnose_gancao_payload.php user@server:/path/to/
|
||||
```
|
||||
|
||||
### 步骤 2: 在服务器上运行配置检查
|
||||
|
||||
```bash
|
||||
ssh user@server
|
||||
cd /path/to/your/project
|
||||
|
||||
# 运行配置检查
|
||||
php test_gancao_config.php
|
||||
```
|
||||
|
||||
**期望输出**:
|
||||
```
|
||||
=== 甘草配置检查 ===
|
||||
|
||||
1. 检查配置是否存在:
|
||||
✓ 配置已加载
|
||||
|
||||
2. 检查各项配置:
|
||||
- enabled: ✓ boolean = true
|
||||
- gateway_url: ✓ string = https://...
|
||||
- gateway_ak: ✓ string = xxx
|
||||
- gateway_sk: ✓ string = xxx
|
||||
- biz_ak: ✓ string = xxx
|
||||
- biz_sk: ✓ string = xxx
|
||||
- callback_url: ✓ string = https://...
|
||||
- express_type: ✓ string = sf
|
||||
- cradle_store: ✓ string = xxx
|
||||
- df_id: ✓ integer = 101
|
||||
|
||||
...
|
||||
|
||||
=== 检查结果: ✓ 配置正常 ===
|
||||
```
|
||||
|
||||
**如果看到 ❌ 或 ⚠️**:
|
||||
- 记录哪个字段有问题
|
||||
- 检查 `.env` 文件中该字段的配置
|
||||
- 修复后重新运行检查
|
||||
|
||||
### 步骤 3: 运行负载诊断
|
||||
|
||||
```bash
|
||||
php diagnose_gancao_payload.php
|
||||
```
|
||||
|
||||
这会:
|
||||
- 测试负载构建过程
|
||||
- 检查每个字段的类型
|
||||
- 测试 JSON 编码
|
||||
- 生成一个 JSON 文件(`gancao_payload_YYYYMMDDHHMMSS.json`)
|
||||
|
||||
检查生成的 JSON 文件,确保:
|
||||
- 没有字段的值是 `"Array"`
|
||||
- 所有字符串字段都是字符串
|
||||
- 所有数组字段都是数组
|
||||
|
||||
### 步骤 4: 清除缓存
|
||||
|
||||
```bash
|
||||
cd server
|
||||
php think clear
|
||||
|
||||
# 或手动删除
|
||||
rm -rf runtime/cache/*
|
||||
rm -rf runtime/temp/*
|
||||
```
|
||||
|
||||
### 步骤 5: 重启服务
|
||||
|
||||
```bash
|
||||
# PHP-FPM
|
||||
sudo systemctl restart php-fpm
|
||||
|
||||
# Nginx
|
||||
sudo systemctl restart nginx
|
||||
```
|
||||
|
||||
### 步骤 6: 测试接口
|
||||
|
||||
```bash
|
||||
# 查看实时日志
|
||||
tail -f server/runtime/log/$(date +%Y%m%d).log | grep -i gancao
|
||||
```
|
||||
|
||||
在另一个终端调用接口:
|
||||
```bash
|
||||
curl -X POST https://your-domain.com/api/tcm.prescriptionOrder/submitGancaoRecipel \
|
||||
-H "Content-Type: application/json" \
|
||||
-H "token: your_token" \
|
||||
-d '{"id": 订单ID}'
|
||||
```
|
||||
|
||||
### 步骤 7: 检查日志
|
||||
|
||||
日志中应该看到:
|
||||
|
||||
**成功的情况**:
|
||||
```
|
||||
[info] Gancao CTM_SUBMIT_RECIPEL payload: {...}
|
||||
[info] submitGancaoRecipel success: {...}
|
||||
```
|
||||
|
||||
**失败的情况**:
|
||||
```
|
||||
[error] Gancao CTM_SUBMIT failed: {...}
|
||||
[error] submitGancaoRecipel exception: {...}
|
||||
```
|
||||
|
||||
## 常见问题排查
|
||||
|
||||
### 问题 1: 配置检查显示某个字段是数组
|
||||
|
||||
**解决方案**:
|
||||
1. 编辑 `.env` 文件
|
||||
2. 找到该字段的配置行
|
||||
3. 移除 `[]` 和多余的引号
|
||||
4. 示例:
|
||||
```env
|
||||
# 错误
|
||||
GANCAO_SCM_EXPRESS_TYPE=["sf"]
|
||||
|
||||
# 正确
|
||||
GANCAO_SCM_EXPRESS_TYPE=sf
|
||||
```
|
||||
5. 保存后清除缓存并重启服务
|
||||
|
||||
### 问题 2: JSON 中包含 "Array" 字符串
|
||||
|
||||
**解决方案**:
|
||||
1. 运行 `diagnose_gancao_payload.php` 找出是哪个字段
|
||||
2. 检查该字段在代码中的处理
|
||||
3. 确保使用 `(string)` 强制转换
|
||||
4. 检查配置文件中该字段的值
|
||||
|
||||
### 问题 3: 仍然报 "Array to string conversion"
|
||||
|
||||
**可能原因**:
|
||||
1. 缓存未清除
|
||||
2. 服务未重启
|
||||
3. 上传的文件未生效
|
||||
4. 配置文件有语法错误
|
||||
|
||||
**排查步骤**:
|
||||
1. 确认文件已上传:`ls -la server/app/common/service/gancao/GancaoScmRecipelService.php`
|
||||
2. 检查文件修改时间:`stat server/app/common/service/gancao/GancaoScmRecipelService.php`
|
||||
3. 清除所有缓存:`rm -rf server/runtime/*`
|
||||
4. 重启所有服务
|
||||
5. 检查 PHP 错误日志:`tail -f /var/log/php-fpm/error.log`
|
||||
|
||||
### 问题 4: 配置检查通过但接口仍然失败
|
||||
|
||||
**排查步骤**:
|
||||
1. 检查日志中的完整错误信息
|
||||
2. 查看 `Gancao CTM_SUBMIT_RECIPEL payload` 日志,确认发送的数据
|
||||
3. 检查甘草 API 返回的错误信息
|
||||
4. 确认药材 ID 映射是否正确
|
||||
5. 确认处方数据是否完整
|
||||
|
||||
## 验证修复成功
|
||||
|
||||
修复成功的标志:
|
||||
|
||||
1. ✅ `test_gancao_config.php` 全部通过
|
||||
2. ✅ `diagnose_gancao_payload.php` 全部通过
|
||||
3. ✅ 接口返回成功:
|
||||
```json
|
||||
{
|
||||
"code": 1,
|
||||
"msg": "甘草药方上传成功",
|
||||
"data": {
|
||||
"recipel_order_no": "GC...",
|
||||
"app_order_no": "PO...",
|
||||
"fee": {...}
|
||||
}
|
||||
}
|
||||
```
|
||||
4. ✅ 日志显示 `submitGancaoRecipel success`
|
||||
5. ✅ 数据库 `prescription_order` 表中 `gancao_reciperl_order_no` 字段已更新
|
||||
|
||||
## 需要进一步帮助
|
||||
|
||||
如果以上步骤都无法解决问题,请提供:
|
||||
|
||||
1. **配置检查输出**:
|
||||
```bash
|
||||
php test_gancao_config.php > config_check.txt 2>&1
|
||||
```
|
||||
|
||||
2. **负载诊断输出**:
|
||||
```bash
|
||||
php diagnose_gancao_payload.php > payload_check.txt 2>&1
|
||||
```
|
||||
|
||||
3. **错误日志**:
|
||||
```bash
|
||||
tail -100 server/runtime/log/$(date +%Y%m%d).log > error.log
|
||||
```
|
||||
|
||||
4. **接口响应**:
|
||||
```bash
|
||||
curl -X POST ... > response.json 2>&1
|
||||
```
|
||||
|
||||
5. **`.env` 配置**(隐藏敏感信息):
|
||||
```bash
|
||||
grep GANCAO_SCM .env > gancao_config.txt
|
||||
```
|
||||
|
||||
将这些文件发送给技术支持进行分析。
|
||||
|
||||
## 总结
|
||||
|
||||
这次修复主要解决了配置值类型错误的问题。关键点:
|
||||
|
||||
1. **配置必须是正确的类型**:字符串字段不能是数组
|
||||
2. **`.env` 格式很重要**:不要使用 JSON 语法或数组语法
|
||||
3. **缓存必须清除**:修改配置后必须清除缓存
|
||||
4. **日志很重要**:添加了详细日志帮助排查问题
|
||||
|
||||
希望这次修复能解决你的问题!
|
||||
@@ -0,0 +1,212 @@
|
||||
# 甘草预下单测试功能
|
||||
|
||||
## 功能概述
|
||||
在订单编辑表单中添加"甘草预下单测试"功能,允许用户在不真实提交订单的情况下,测试当前配置的甘草预下单价格。测试结果以抽屉形式展示,包含完整的价格、配送、规则检查和药材明细信息。
|
||||
|
||||
## 实现内容
|
||||
|
||||
### 1. 后端接口
|
||||
|
||||
#### 控制器方法 (`server/app/adminapi/controller/tcm/PrescriptionOrderController.php`)
|
||||
```php
|
||||
public function previewGancaoRecipel()
|
||||
```
|
||||
- 调用验证器场景:`previewGancaoRecipel`
|
||||
- 调用逻辑方法:`PrescriptionOrderLogic::previewGancaoRecipel()`
|
||||
- 返回预览结果(包含价格信息)
|
||||
|
||||
#### 逻辑方法 (`server/app/adminapi/logic/tcm/PrescriptionOrderLogic.php`)
|
||||
```php
|
||||
public static function previewGancaoRecipel(int $id, int $adminId, array $adminInfo)
|
||||
```
|
||||
- 验证订单和处方存在性
|
||||
- 检查药材是否能匹配甘草药ID
|
||||
- 获取甘草token
|
||||
- 调用 `CTM_PREVIEW` 接口
|
||||
- 检查规则拦截和药材可用性
|
||||
- 返回完整的预览数据(不提交订单)
|
||||
|
||||
#### 验证器 (`server/app/adminapi/validate/tcm/PrescriptionOrderValidate.php`)
|
||||
- 添加场景:`previewGancaoRecipel => ['id']`
|
||||
|
||||
### 2. 前端实现
|
||||
|
||||
#### API接口 (`admin/src/api/tcm.ts`)
|
||||
```typescript
|
||||
export function prescriptionOrderPreviewGancaoRecipel(params: { id: number })
|
||||
```
|
||||
|
||||
#### UI组件 (`admin/src/views/consumer/prescription/order_list.vue`)
|
||||
|
||||
**测试按钮位置**:在"剂数"和"剂量单位"字段后,"上次医生"字段前
|
||||
|
||||
**组件结构**:
|
||||
```vue
|
||||
<el-button @click="testGancaoPreview">测试价格</el-button>
|
||||
<el-drawer v-model="gancaoPreviewDrawerVisible">
|
||||
<!-- 价格信息 -->
|
||||
<!-- 配送信息 -->
|
||||
<!-- 规则检查 -->
|
||||
<!-- 药材明细 -->
|
||||
<!-- 订单信息 -->
|
||||
</el-drawer>
|
||||
```
|
||||
|
||||
### 3. 抽屉展示内容
|
||||
|
||||
#### 价格信息卡片
|
||||
- **药材成本** (`m_cost`): 橙色显示,单位:元
|
||||
- **加工费** (`proces_cost`): 蓝色显示,单位:分转元
|
||||
- **物流费** (`lis_cost`): 绿色显示,单位:分转元
|
||||
- **总计**: 红色加粗显示,自动计算总和
|
||||
|
||||
#### 配送信息卡片
|
||||
- 调配中心名称 (`ds_name`)
|
||||
- 调配中心ID (`ds_id`)
|
||||
- 服用天数范围 (`take_days.min` - `take_days.max`)
|
||||
- 默认服用天数 (`take_days.default`)
|
||||
|
||||
#### 规则检查卡片
|
||||
- 显示所有规则检查结果 (`rule_check`)
|
||||
- 根据 `type` 显示不同颜色:
|
||||
- `type >= 2`: 错误(红色)
|
||||
- `type === 1`: 警告(黄色)
|
||||
- `type === 0`: 信息(蓝色)
|
||||
- 显示规则代码 (`code`) 和消息 (`msg`)
|
||||
|
||||
#### 药材明细表格
|
||||
- 序号
|
||||
- 药材名称 (`title`)
|
||||
- 用量 (`quantity` + `unit`)
|
||||
- 单价 (`price`,元/克,保留4位小数)
|
||||
- 小计(单价 × 用量,橙色显示)
|
||||
- 库存状态 (`is_available`,1=有货/0=缺货)
|
||||
- 备注 (`brief`)
|
||||
|
||||
#### 订单信息卡片
|
||||
- 订单号 (`order_no`):跨2列显示
|
||||
- 剂数 (`dose_count`):显示格式"X剂"(如:5剂)
|
||||
- 单剂量:自动计算所有药材用量总和,显示格式"X克"(如:196克)
|
||||
|
||||
## 接口返回数据结构
|
||||
|
||||
```json
|
||||
{
|
||||
"code": 1,
|
||||
"msg": "预下单成功",
|
||||
"data": {
|
||||
"success": true,
|
||||
"fee": {
|
||||
"m_cost": "112.88",
|
||||
"proces_cost": 80,
|
||||
"lis_cost": 0
|
||||
},
|
||||
"result": {
|
||||
"aa_id": 73,
|
||||
"ds_id": 135,
|
||||
"ds_name": "郑州-甘草调配中心√",
|
||||
"take_days": {
|
||||
"min": 7,
|
||||
"max": 35,
|
||||
"default": 28
|
||||
},
|
||||
"rule_check": [
|
||||
{
|
||||
"code": "pill_yield_notice",
|
||||
"msg": "出丸量约98g±5%",
|
||||
"type": 0
|
||||
}
|
||||
],
|
||||
"fee": {
|
||||
"m_cost": "112.88",
|
||||
"proces_cost": 80,
|
||||
"lis_cost": 0
|
||||
},
|
||||
"m_list": [
|
||||
{
|
||||
"id": 420,
|
||||
"title": "龙胆",
|
||||
"price": 0.405028,
|
||||
"unit": "克",
|
||||
"quantity": "6",
|
||||
"is_available": 1,
|
||||
"brief": ""
|
||||
}
|
||||
]
|
||||
},
|
||||
"order_no": "PO20260414160525788214",
|
||||
"dose_count": 5
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## UI设计特点
|
||||
|
||||
### 布局
|
||||
- 使用 `el-drawer` 抽屉组件,宽度 800px
|
||||
- 内容分为5个卡片区域,使用 `el-card` 组件
|
||||
- 使用 `el-descriptions` 展示键值对信息
|
||||
- 使用 `el-table` 展示药材明细列表
|
||||
|
||||
### 颜色方案
|
||||
- 药材成本:橙色 (`text-orange-600`)
|
||||
- 加工费:蓝色 (`text-blue-600`)
|
||||
- 物流费:绿色 (`text-green-600`)
|
||||
- 总计:红色加粗 (`text-red-600 font-bold`)
|
||||
- 小计:橙色 (`text-orange-600`)
|
||||
|
||||
### 交互
|
||||
- 点击"测试价格"按钮触发预下单
|
||||
- 按钮显示加载状态(loading)
|
||||
- 成功后自动打开抽屉展示结果
|
||||
- 失败时显示错误提示消息
|
||||
|
||||
## 使用流程
|
||||
|
||||
1. 打开订单编辑对话框
|
||||
2. 配置剂数(`dose_count`)和剂量单位(`dose_unit`)
|
||||
3. 点击"测试价格"按钮
|
||||
4. 系统调用甘草 `CTM_PREVIEW` 接口
|
||||
5. 自动打开抽屉展示完整的预下单结果
|
||||
|
||||
## 注意事项
|
||||
|
||||
- 预下单测试**不会**真实提交订单到甘草
|
||||
- 预下单测试**不会**扣费
|
||||
- 仅用于验证配置和查看价格
|
||||
- 需要先保存订单才能测试(`editForm.id` 必须存在)
|
||||
- 测试结果会检查:
|
||||
- 药材是否匹配甘草药ID
|
||||
- 规则是否拦截
|
||||
- 药材是否缺货
|
||||
- 抽屉关闭时自动销毁内容(`destroy-on-close`)
|
||||
|
||||
## 相关文件
|
||||
|
||||
### 后端
|
||||
- `server/app/adminapi/controller/tcm/PrescriptionOrderController.php`
|
||||
- `server/app/adminapi/logic/tcm/PrescriptionOrderLogic.php`
|
||||
- `server/app/adminapi/validate/tcm/PrescriptionOrderValidate.php`
|
||||
- `server/app/common/service/gancao/GancaoScmRecipelService.php`
|
||||
|
||||
### 前端
|
||||
- `admin/src/api/tcm.ts`
|
||||
- `admin/src/views/consumer/prescription/order_list.vue`
|
||||
|
||||
## 测试要点
|
||||
|
||||
- [ ] 点击"测试价格"按钮能正常调用接口
|
||||
- [ ] 成功时自动打开抽屉展示结果
|
||||
- [ ] 价格信息显示正确(药材成本、加工费、物流费、总计)
|
||||
- [ ] 配送信息显示完整(调配中心、服用天数范围)
|
||||
- [ ] 规则检查按类型显示不同颜色
|
||||
- [ ] 药材明细表格显示完整(名称、用量、单价、小计、库存状态)
|
||||
- [ ] 订单信息显示正确(订单号、剂数)
|
||||
- [ ] 失败时显示清晰的错误信息
|
||||
- [ ] 按钮加载状态正常显示
|
||||
- [ ] 价格单位正确(分转元)
|
||||
- [ ] 仅在编辑已存在订单时显示测试按钮
|
||||
- [ ] 测试不会真实提交订单到甘草
|
||||
- [ ] 测试不会修改订单状态
|
||||
- [ ] 抽屉关闭后内容正确销毁
|
||||
|
||||
@@ -0,0 +1,284 @@
|
||||
# 甘草 SSL 连接错误修复指南
|
||||
|
||||
## 错误信息
|
||||
|
||||
```
|
||||
甘草网关通信失败:通信失败:HTTP 200 curl#56 OpenSSL SSL_read: error:0A000126:SSL routines::unexpected eof while reading, errno 0
|
||||
```
|
||||
|
||||
## 问题分析
|
||||
|
||||
这个错误表示:
|
||||
- ✅ TCP 连接成功(HTTP 200)
|
||||
- ✅ SSL 握手开始
|
||||
- ❌ SSL 读取数据时连接意外断开
|
||||
|
||||
**常见原因:**
|
||||
1. TLS 版本不兼容(服务器要求 TLS 1.2+,客户端使用旧版本)
|
||||
2. SSL 加密套件不匹配
|
||||
3. HTTP 版本问题(HTTP/1.0 vs HTTP/1.1)
|
||||
4. OpenSSL 安全级别过高
|
||||
5. 服务器端提前关闭连接
|
||||
|
||||
## 已实施的修复
|
||||
|
||||
### 修改 `GancaoOpenApiTransport.php`
|
||||
|
||||
```php
|
||||
// 1. 改用 HTTP/1.1(原来是 HTTP/1.0)
|
||||
curl_setopt($ch, CURLOPT_HTTP_VERSION, CURL_HTTP_VERSION_1_1);
|
||||
|
||||
// 2. 增加连接超时
|
||||
curl_setopt($ch, CURLOPT_CONNECTTIMEOUT, 10); // 从 5 秒增加到 10 秒
|
||||
|
||||
// 3. 完全禁用 SSL 验证
|
||||
curl_setopt($ch, CURLOPT_SSL_VERIFYHOST, 0); // 从 2 改为 0
|
||||
|
||||
// 4. 强制使用 TLS 1.2
|
||||
curl_setopt($ch, CURLOPT_SSLVERSION, CURL_SSLVERSION_TLSv1_2);
|
||||
|
||||
// 5. 降低 OpenSSL 安全级别
|
||||
curl_setopt($ch, CURLOPT_SSL_CIPHER_LIST, 'DEFAULT@SECLEVEL=1');
|
||||
|
||||
// 6. 添加 TCP keepalive
|
||||
curl_setopt($ch, CURLOPT_TCP_KEEPALIVE, 1);
|
||||
curl_setopt($ch, CURLOPT_TCP_KEEPIDLE, 120);
|
||||
curl_setopt($ch, CURLOPT_TCP_KEEPINTVL, 60);
|
||||
```
|
||||
|
||||
## 测试步骤
|
||||
|
||||
### 步骤 1: 上传修复后的文件
|
||||
|
||||
```bash
|
||||
scp server/app/common/service/gancao/GancaoOpenApiTransport.php user@server:/path/to/server/app/common/service/gancao/
|
||||
scp test_gancao_ssl.php user@server:/path/to/
|
||||
```
|
||||
|
||||
### 步骤 2: 运行 SSL 测试
|
||||
|
||||
```bash
|
||||
ssh user@server
|
||||
cd /path/to/your/project
|
||||
php test_gancao_ssl.php
|
||||
```
|
||||
|
||||
**期望输出:**
|
||||
```
|
||||
=== 甘草 SSL 连接测试 ===
|
||||
|
||||
网关地址: https://xxx.com
|
||||
|
||||
1. DNS 解析测试:
|
||||
✓ xxx.com -> 1.2.3.4
|
||||
|
||||
2. TCP 连接测试:
|
||||
✓ TCP 连接成功
|
||||
|
||||
3. SSL/TLS 支持检测:
|
||||
- SSLv2: ✗ 不支持
|
||||
- SSLv3: ✗ 不支持
|
||||
- TLS 1.0: ✓ 支持
|
||||
- TLS 1.1: ✓ 支持
|
||||
- TLS 1.2: ✓ 支持
|
||||
- TLS 1.3: ✓ 支持
|
||||
|
||||
4. cURL SSL 测试:
|
||||
测试 HTTP/1.0 + TLS 1.2:
|
||||
❌ 失败: [56] OpenSSL SSL_read...
|
||||
测试 HTTP/1.1 + TLS 1.2:
|
||||
✓ 成功 (HTTP 200)
|
||||
测试 HTTP/1.1 + TLS 1.2 + SECLEVEL=1:
|
||||
✓ 成功 (HTTP 200)
|
||||
|
||||
5. OpenSSL 版本信息:
|
||||
版本: OpenSSL 1.1.1...
|
||||
|
||||
6. cURL 版本信息:
|
||||
版本: 7.x.x
|
||||
SSL 版本: OpenSSL/1.1.1...
|
||||
|
||||
7. 测试实际 API 调用 (MAKE_TOKEN):
|
||||
✓ 成功获取 token (长度: 64)
|
||||
```
|
||||
|
||||
### 步骤 3: 清除缓存并重启
|
||||
|
||||
```bash
|
||||
cd server
|
||||
php think clear
|
||||
rm -rf runtime/cache/*
|
||||
|
||||
# 重启服务
|
||||
sudo systemctl restart php-fpm
|
||||
sudo systemctl restart nginx
|
||||
```
|
||||
|
||||
### 步骤 4: 测试接口
|
||||
|
||||
```bash
|
||||
# 查看日志
|
||||
tail -f server/runtime/log/$(date +%Y%m%d).log | grep -i gancao
|
||||
```
|
||||
|
||||
在另一个终端调用接口:
|
||||
```bash
|
||||
curl -X POST https://your-domain.com/api/tcm.prescriptionOrder/submitGancaoRecipel \
|
||||
-H "Content-Type: application/json" \
|
||||
-H "token: your_token" \
|
||||
-d '{"id": 订单ID}'
|
||||
```
|
||||
|
||||
## 如果仍然失败
|
||||
|
||||
### 方案 A: 检查 OpenSSL 版本
|
||||
|
||||
```bash
|
||||
openssl version
|
||||
```
|
||||
|
||||
**要求:** OpenSSL 1.0.2 或更高版本
|
||||
|
||||
**如果版本过低,升级 OpenSSL:**
|
||||
|
||||
```bash
|
||||
# CentOS/RHEL
|
||||
sudo yum update openssl
|
||||
|
||||
# Ubuntu/Debian
|
||||
sudo apt-get update
|
||||
sudo apt-get install --only-upgrade openssl
|
||||
|
||||
# 重新编译 PHP(如果需要)
|
||||
```
|
||||
|
||||
### 方案 B: 修改 OpenSSL 配置
|
||||
|
||||
编辑 `/etc/ssl/openssl.cnf`:
|
||||
|
||||
```ini
|
||||
# 在文件开头添加
|
||||
openssl_conf = openssl_init
|
||||
|
||||
[openssl_init]
|
||||
ssl_conf = ssl_sect
|
||||
|
||||
[ssl_sect]
|
||||
system_default = system_default_sect
|
||||
|
||||
[system_default_sect]
|
||||
MinProtocol = TLSv1.2
|
||||
CipherString = DEFAULT@SECLEVEL=1
|
||||
```
|
||||
|
||||
重启服务:
|
||||
```bash
|
||||
sudo systemctl restart php-fpm
|
||||
```
|
||||
|
||||
### 方案 C: 使用不同的 TLS 版本
|
||||
|
||||
如果 TLS 1.2 不工作,尝试其他版本。
|
||||
|
||||
修改 `GancaoOpenApiTransport.php`:
|
||||
|
||||
```php
|
||||
// 尝试 TLS 1.3
|
||||
curl_setopt($ch, CURLOPT_SSLVERSION, CURL_SSLVERSION_TLSv1_3);
|
||||
|
||||
// 或者让 cURL 自动选择
|
||||
curl_setopt($ch, CURLOPT_SSLVERSION, CURL_SSLVERSION_DEFAULT);
|
||||
```
|
||||
|
||||
### 方案 D: 禁用 HTTP/2
|
||||
|
||||
如果使用了 HTTP/2,尝试禁用:
|
||||
|
||||
```php
|
||||
curl_setopt($ch, CURLOPT_HTTP_VERSION, CURL_HTTP_VERSION_1_1);
|
||||
```
|
||||
|
||||
### 方案 E: 增加调试信息
|
||||
|
||||
临时添加详细的 cURL 调试:
|
||||
|
||||
```php
|
||||
// 在 GancaoOpenApiTransport.php 的 post 方法中添加
|
||||
curl_setopt($ch, CURLOPT_VERBOSE, true);
|
||||
$verbose = fopen('php://temp', 'w+');
|
||||
curl_setopt($ch, CURLOPT_STDERR, $verbose);
|
||||
|
||||
// 在 curl_exec 之后添加
|
||||
rewind($verbose);
|
||||
$verboseLog = stream_get_contents($verbose);
|
||||
\think\facade\Log::info('cURL verbose output', ['log' => $verboseLog]);
|
||||
```
|
||||
|
||||
查看详细日志:
|
||||
```bash
|
||||
tail -f server/runtime/log/$(date +%Y%m%d).log
|
||||
```
|
||||
|
||||
### 方案 F: 联系甘草技术支持
|
||||
|
||||
提供以下信息:
|
||||
1. `php test_gancao_ssl.php` 的完整输出
|
||||
2. `openssl version` 输出
|
||||
3. `php -v` 输出
|
||||
4. `curl --version` 输出
|
||||
5. 服务器操作系统版本
|
||||
|
||||
询问:
|
||||
- 甘草网关支持的 TLS 版本
|
||||
- 推荐的 SSL 加密套件
|
||||
- 是否有特殊的 HTTP 头要求
|
||||
- 是否有 IP 白名单限制
|
||||
|
||||
## 常见问题
|
||||
|
||||
### Q1: 为什么 HTTP/1.0 会导致问题?
|
||||
|
||||
A: 某些现代服务器不再支持 HTTP/1.0,或者在 HTTP/1.0 下有不同的 SSL 处理逻辑。HTTP/1.1 是更标准的选择。
|
||||
|
||||
### Q2: SECLEVEL=1 是什么?
|
||||
|
||||
A: OpenSSL 1.1.0+ 引入了安全级别概念:
|
||||
- SECLEVEL=2(默认):要求 112 位安全性
|
||||
- SECLEVEL=1:要求 80 位安全性(更宽松)
|
||||
- SECLEVEL=0:允许所有加密套件(不推荐)
|
||||
|
||||
降低安全级别可以兼容更多服务器,但会降低安全性。
|
||||
|
||||
### Q3: 为什么要禁用 SSL 验证?
|
||||
|
||||
A: 在开发/测试环境中,禁用 SSL 验证可以避免证书问题。**生产环境建议启用验证**。
|
||||
|
||||
### Q4: TCP keepalive 有什么用?
|
||||
|
||||
A: 保持 TCP 连接活跃,防止长时间传输时连接被中断。
|
||||
|
||||
## 验证修复成功
|
||||
|
||||
修复成功的标志:
|
||||
|
||||
1. ✅ `php test_gancao_ssl.php` 显示 "✓ 成功获取 token"
|
||||
2. ✅ 接口返回成功:
|
||||
```json
|
||||
{
|
||||
"code": 1,
|
||||
"msg": "甘草药方上传成功",
|
||||
"data": {...}
|
||||
}
|
||||
```
|
||||
3. ✅ 日志中没有 SSL 错误
|
||||
4. ✅ 数据库中订单已更新
|
||||
|
||||
## 总结
|
||||
|
||||
SSL 连接问题通常是由于:
|
||||
1. **TLS 版本不匹配** → 使用 TLS 1.2
|
||||
2. **HTTP 版本问题** → 使用 HTTP/1.1
|
||||
3. **OpenSSL 安全级别过高** → 降低到 SECLEVEL=1
|
||||
4. **连接超时** → 增加超时时间
|
||||
|
||||
修复后应该能正常连接甘草 API。如果仍有问题,运行 `test_gancao_ssl.php` 并根据输出进一步诊断。
|
||||
@@ -0,0 +1,169 @@
|
||||
# 甘草上传药方按钮显示条件
|
||||
|
||||
## 需求
|
||||
"上传药方"按钮应该只在特定条件下显示:
|
||||
1. 处方审核已通过
|
||||
2. 还没有上传过甘草订单(gancao_reciperl_order_no 为空)
|
||||
|
||||
## 实现
|
||||
|
||||
### 文件:`admin/src/views/consumer/prescription/order_list.vue`
|
||||
|
||||
#### 1. 添加条件判断函数
|
||||
|
||||
```typescript
|
||||
function canUploadGancaoRow(row: {
|
||||
prescription_audit_status?: number
|
||||
gancao_reciperl_order_no?: string
|
||||
}) {
|
||||
// 处方审核已通过(1) 且没有甘草订单号时才显示
|
||||
const rxStatus = Number(row.prescription_audit_status)
|
||||
const gancaoOrderNo = String(row.gancao_reciperl_order_no || '').trim()
|
||||
return rxStatus === 1 && gancaoOrderNo === ''
|
||||
}
|
||||
```
|
||||
|
||||
**逻辑说明**:
|
||||
- `prescription_audit_status === 1`:处方审核状态为"已通过"
|
||||
- `gancao_reciperl_order_no` 为空:还没有上传过甘草订单
|
||||
|
||||
#### 2. 更新按钮显示条件
|
||||
|
||||
```vue
|
||||
<el-button
|
||||
v-if="canUploadGancaoRow(row)"
|
||||
v-perms="['tcm.prescriptionOrder/submitGancaoRecipel']"
|
||||
type="warning"
|
||||
link
|
||||
:loading="gancaoSubmitId === row.id"
|
||||
@click="confirmSubmitGancaoRecipel(row)"
|
||||
>上传药方</el-button>
|
||||
```
|
||||
|
||||
## 显示逻辑
|
||||
|
||||
### 场景 1:处方未审核
|
||||
```
|
||||
prescription_audit_status: 0 (待审核)
|
||||
gancao_reciperl_order_no: ''
|
||||
→ 按钮隐藏 ❌
|
||||
```
|
||||
|
||||
### 场景 2:处方已驳回
|
||||
```
|
||||
prescription_audit_status: 2 (已驳回)
|
||||
gancao_reciperl_order_no: ''
|
||||
→ 按钮隐藏 ❌
|
||||
```
|
||||
|
||||
### 场景 3:处方已通过,未上传
|
||||
```
|
||||
prescription_audit_status: 1 (已通过)
|
||||
gancao_reciperl_order_no: ''
|
||||
→ 按钮显示 ✅
|
||||
```
|
||||
|
||||
### 场景 4:处方已通过,已上传
|
||||
```
|
||||
prescription_audit_status: 1 (已通过)
|
||||
gancao_reciperl_order_no: 'GC202604150001'
|
||||
→ 按钮隐藏 ❌
|
||||
```
|
||||
|
||||
## 审核状态说明
|
||||
|
||||
| 状态值 | 说明 | 是否显示按钮 |
|
||||
|--------|------|-------------|
|
||||
| 0 | 待审核 | ❌ |
|
||||
| 1 | 已通过 | ✅ (如果未上传) |
|
||||
| 2 | 已驳回 | ❌ |
|
||||
|
||||
## 业务流程
|
||||
|
||||
```
|
||||
创建订单
|
||||
↓
|
||||
处方待审核 (prescription_audit_status = 0)
|
||||
↓ 审核
|
||||
处方已通过 (prescription_audit_status = 1)
|
||||
↓ 显示"上传药方"按钮
|
||||
点击上传
|
||||
↓ 调用甘草API
|
||||
上传成功,保存订单号 (gancao_reciperl_order_no = 'GC...')
|
||||
↓ 按钮隐藏
|
||||
订单继续履约流程
|
||||
```
|
||||
|
||||
## 相关字段
|
||||
|
||||
### prescription_audit_status (处方审核状态)
|
||||
- **类型**: tinyint(2)
|
||||
- **值**:
|
||||
- 0: 待审核
|
||||
- 1: 已通过
|
||||
- 2: 已驳回
|
||||
|
||||
### gancao_reciperl_order_no (甘草处方订单号)
|
||||
- **类型**: varchar(32)
|
||||
- **说明**: 上传甘草成功后返回的订单号
|
||||
- **示例**: `GC202604150001`
|
||||
- **空值**: 表示还未上传
|
||||
|
||||
## 防止重复上传
|
||||
|
||||
通过检查 `gancao_reciperl_order_no` 字段,确保:
|
||||
1. 每个订单只能上传一次
|
||||
2. 上传成功后按钮自动隐藏
|
||||
3. 避免重复提交导致的数据混乱
|
||||
|
||||
## 用户体验
|
||||
|
||||
### 上传前
|
||||
```
|
||||
操作列:
|
||||
[详情/审核] [编辑] [上传药方] [确认发货] ...
|
||||
```
|
||||
|
||||
### 上传后
|
||||
```
|
||||
操作列:
|
||||
[详情/审核] [编辑] [确认发货] ...
|
||||
```
|
||||
"上传药方"按钮消失,界面更简洁。
|
||||
|
||||
## 错误处理
|
||||
|
||||
如果用户尝试重复上传(通过其他方式),后端也有验证:
|
||||
|
||||
```php
|
||||
// server/app/adminapi/logic/tcm/PrescriptionOrderLogic.php
|
||||
if (!self::canUploadGancaoRecipel($item, $adminId, $adminInfo, $assistantByDiag)) {
|
||||
self::$error = '须处方审核通过后方可上传甘草;已完成/已取消订单不可上传;同一订单仅可上传一次';
|
||||
return false;
|
||||
}
|
||||
```
|
||||
|
||||
## 测试清单
|
||||
|
||||
- [ ] 创建新订单,处方待审核,按钮不显示
|
||||
- [ ] 处方审核通过,按钮显示
|
||||
- [ ] 点击上传药方,上传成功
|
||||
- [ ] 刷新页面,按钮消失
|
||||
- [ ] 处方被驳回,按钮不显示
|
||||
- [ ] 已上传的订单,按钮不显示
|
||||
|
||||
## 相关文件
|
||||
|
||||
- `admin/src/views/consumer/prescription/order_list.vue` - 订单列表页面
|
||||
- `server/app/adminapi/logic/tcm/PrescriptionOrderLogic.php` - 后端业务逻辑
|
||||
- `GANCAO_CALLBACK_SETUP.md` - 甘草回调功能文档
|
||||
|
||||
## 总结
|
||||
|
||||
通过添加 `canUploadGancaoRow()` 函数,实现了智能的按钮显示控制:
|
||||
- ✅ 只在处方审核通过后显示
|
||||
- ✅ 上传成功后自动隐藏
|
||||
- ✅ 防止重复上传
|
||||
- ✅ 提升用户体验
|
||||
|
||||
这样可以避免用户误操作,确保业务流程的正确性。
|
||||
@@ -0,0 +1,275 @@
|
||||
# 药材名称拼音首字母功能说明
|
||||
|
||||
## 概述
|
||||
系统使用 `overtrue/pinyin` 包自动生成中文药材名称的拼音首字母缩写,存储在 `name_pinyin_abbr` 字段中,用于快速检索。
|
||||
|
||||
## 核心实现
|
||||
|
||||
### 1. 依赖包
|
||||
```json
|
||||
"overtrue/pinyin": "^6.0"
|
||||
```
|
||||
已在 `server/composer.json` 中配置。
|
||||
|
||||
### 2. 服务类:`MedicineNameAbbrService`
|
||||
**文件位置:** `server/app/common/service/doctor/MedicineNameAbbrService.php`
|
||||
|
||||
```php
|
||||
<?php
|
||||
|
||||
namespace app\common\service\doctor;
|
||||
|
||||
use Overtrue\Pinyin\Pinyin;
|
||||
|
||||
class MedicineNameAbbrService
|
||||
{
|
||||
public static function build(string $name): string
|
||||
{
|
||||
$name = trim($name);
|
||||
if ($name === '') {
|
||||
return '';
|
||||
}
|
||||
if (!class_exists(Pinyin::class)) {
|
||||
return '';
|
||||
}
|
||||
$abbr = (string) Pinyin::abbr($name)->join('');
|
||||
|
||||
return strtolower($abbr);
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**功能说明:**
|
||||
- 输入:中文药材名称(如 "当归")
|
||||
- 输出:拼音首字母小写连写(如 "dg")
|
||||
- 自动处理:去除空格、转小写
|
||||
- 容错:如果 Pinyin 类不存在,返回空字符串
|
||||
|
||||
### 3. 使用示例
|
||||
|
||||
#### 示例 1:当归
|
||||
```php
|
||||
MedicineNameAbbrService::build('当归');
|
||||
// 输出: "dg"
|
||||
```
|
||||
|
||||
#### 示例 2:黄芪
|
||||
```php
|
||||
MedicineNameAbbrService::build('黄芪');
|
||||
// 输出: "hq"
|
||||
```
|
||||
|
||||
#### 示例 3:党参
|
||||
```php
|
||||
MedicineNameAbbrService::build('党参');
|
||||
// 输出: "ds"
|
||||
```
|
||||
|
||||
#### 示例 4:白术
|
||||
```php
|
||||
MedicineNameAbbrService::build('白术');
|
||||
// 输出: "bs"
|
||||
```
|
||||
|
||||
## 自动生成时机
|
||||
|
||||
### 1. 创建药材时
|
||||
**文件:** `server/app/adminapi/logic/doctor/MedicineLogic.php`
|
||||
|
||||
```php
|
||||
Medicine::create([
|
||||
'name' => $params['name'],
|
||||
'name_pinyin_abbr' => MedicineNameAbbrService::build($params['name']),
|
||||
// ... 其他字段
|
||||
]);
|
||||
```
|
||||
|
||||
### 2. 编辑药材时
|
||||
```php
|
||||
Medicine::update([
|
||||
'id' => $params['id'],
|
||||
'name' => $params['name'],
|
||||
'name_pinyin_abbr' => MedicineNameAbbrService::build($params['name']),
|
||||
// ... 其他字段
|
||||
]);
|
||||
```
|
||||
|
||||
## 批量回填命令
|
||||
|
||||
### 命令:`sync_medicine_pinyin_abbr`
|
||||
**文件:** `server/app/common/command/SyncMedicinePinyinAbbr.php`
|
||||
|
||||
用于批量生成或更新已有药材的拼音首字母。
|
||||
|
||||
### 使用方法
|
||||
|
||||
#### 1. 仅更新空字段(默认)
|
||||
```bash
|
||||
cd server
|
||||
php think sync_medicine_pinyin_abbr
|
||||
```
|
||||
只处理 `name_pinyin_abbr` 为空的记录。
|
||||
|
||||
#### 2. 重算全部记录
|
||||
```bash
|
||||
php think sync_medicine_pinyin_abbr --all
|
||||
```
|
||||
重新计算所有药材的拼音首字母(覆盖现有值)。
|
||||
|
||||
#### 3. 预览模式(不写入数据库)
|
||||
```bash
|
||||
php think sync_medicine_pinyin_abbr --dry
|
||||
```
|
||||
只统计将更新多少条,不实际写入。
|
||||
|
||||
#### 4. 组合使用
|
||||
```bash
|
||||
php think sync_medicine_pinyin_abbr --all --dry
|
||||
```
|
||||
预览全部重算的结果。
|
||||
|
||||
### 命令输出示例
|
||||
```
|
||||
模式: 仅空 abbr,待处理 150 条
|
||||
完成: 已更新 145 条,跳过 5 条。
|
||||
```
|
||||
|
||||
## 检索功能
|
||||
|
||||
### 列表搜索
|
||||
**文件:** `server/app/adminapi/lists/doctor/MedicineLists.php`
|
||||
|
||||
```php
|
||||
$query->where(function ($q) use ($kw, $keyword) {
|
||||
$q->where('name_pinyin_abbr', 'like', '%' . $kw . '%')
|
||||
->whereOr('name', 'like', '%' . $keyword . '%');
|
||||
});
|
||||
```
|
||||
|
||||
**功能说明:**
|
||||
- 用户输入 "dg" 可以搜索到 "当归"
|
||||
- 用户输入 "当归" 也可以搜索到
|
||||
- 支持模糊匹配(LIKE 查询)
|
||||
|
||||
### 搜索示例
|
||||
|
||||
| 用户输入 | 匹配药材 |
|
||||
|---------|---------|
|
||||
| dg | 当归 |
|
||||
| hq | 黄芪 |
|
||||
| ds | 党参、丹参 |
|
||||
| bs | 白术、白芍 |
|
||||
| 当归 | 当归 |
|
||||
|
||||
## 数据库字段
|
||||
|
||||
### 表:`zyt_doctor_medicine`
|
||||
```sql
|
||||
`name_pinyin_abbr` varchar(64) DEFAULT NULL COMMENT '名称拼音首字母缩写(小写连写)'
|
||||
```
|
||||
|
||||
### 索引建议
|
||||
如果药材数量很大(>10000条),建议添加索引:
|
||||
```sql
|
||||
ALTER TABLE `zyt_doctor_medicine`
|
||||
ADD INDEX `idx_name_pinyin_abbr` (`name_pinyin_abbr`);
|
||||
```
|
||||
|
||||
## 常见问题
|
||||
|
||||
### Q1: 如何安装 overtrue/pinyin?
|
||||
```bash
|
||||
cd server
|
||||
composer require overtrue/pinyin
|
||||
```
|
||||
(已在项目中安装,无需手动执行)
|
||||
|
||||
### Q2: 多音字如何处理?
|
||||
`overtrue/pinyin` 会自动选择最常用的读音。例如:
|
||||
- "重" → "z" (重量) 或 "c" (重复)
|
||||
- 默认使用最常见的读音
|
||||
|
||||
### Q3: 如何处理英文或数字?
|
||||
- 英文字母:保留原样并转小写
|
||||
- 数字:保留原样
|
||||
- 示例:`"维生素B12"` → `"wssbB12"` → `"wssbb12"`
|
||||
|
||||
### Q4: 为什么有些药材搜索不到?
|
||||
可能原因:
|
||||
1. `name_pinyin_abbr` 字段为空 → 运行批量回填命令
|
||||
2. 药材名称包含特殊字符 → 检查数据质量
|
||||
3. 索引未生效 → 检查数据库索引
|
||||
|
||||
### Q5: 如何在其他模块使用?
|
||||
```php
|
||||
use app\common\service\doctor\MedicineNameAbbrService;
|
||||
|
||||
// 生成拼音首字母
|
||||
$abbr = MedicineNameAbbrService::build('当归');
|
||||
echo $abbr; // 输出: dg
|
||||
```
|
||||
|
||||
## 性能优化建议
|
||||
|
||||
### 1. 批量处理
|
||||
命令使用 `chunk(200)` 分批处理,避免内存溢出:
|
||||
```php
|
||||
$makeQuery()->chunk(200, function ($rows) {
|
||||
// 每次处理 200 条
|
||||
});
|
||||
```
|
||||
|
||||
### 2. 异步生成
|
||||
对于大量数据,建议:
|
||||
- 使用队列异步处理
|
||||
- 在低峰期运行批量命令
|
||||
- 使用 `--dry` 先预览再执行
|
||||
|
||||
### 3. 缓存策略
|
||||
如果药材数据不常变动,可以:
|
||||
- 缓存常用药材的拼音首字母
|
||||
- 使用 Redis 存储热门搜索词
|
||||
|
||||
## 相关文件
|
||||
|
||||
| 文件 | 说明 |
|
||||
|------|------|
|
||||
| `server/app/common/service/doctor/MedicineNameAbbrService.php` | 拼音首字母生成服务 |
|
||||
| `server/app/common/command/SyncMedicinePinyinAbbr.php` | 批量回填命令 |
|
||||
| `server/app/adminapi/logic/doctor/MedicineLogic.php` | 药材增删改逻辑 |
|
||||
| `server/app/adminapi/lists/doctor/MedicineLists.php` | 药材列表搜索 |
|
||||
| `server/app/common/model/doctor/Medicine.php` | 药材模型 |
|
||||
|
||||
## 扩展应用
|
||||
|
||||
此功能可以扩展到其他需要中文检索的模块:
|
||||
|
||||
### 1. 患者姓名检索
|
||||
```php
|
||||
use app\common\service\doctor\MedicineNameAbbrService;
|
||||
|
||||
$patientName = '张三';
|
||||
$abbr = MedicineNameAbbrService::build($patientName); // "zs"
|
||||
```
|
||||
|
||||
### 2. 医生姓名检索
|
||||
```php
|
||||
$doctorName = '李医生';
|
||||
$abbr = MedicineNameAbbrService::build($doctorName); // "lys"
|
||||
```
|
||||
|
||||
### 3. 诊断名称检索
|
||||
```php
|
||||
$diagnosis = '感冒';
|
||||
$abbr = MedicineNameAbbrService::build($diagnosis); // "gm"
|
||||
```
|
||||
|
||||
## 总结
|
||||
|
||||
- **核心类:** `MedicineNameAbbrService::build()`
|
||||
- **依赖包:** `overtrue/pinyin`
|
||||
- **存储字段:** `name_pinyin_abbr`
|
||||
- **批量命令:** `php think sync_medicine_pinyin_abbr`
|
||||
- **应用场景:** 药材快速检索、模糊搜索
|
||||
|
||||
这个功能大大提升了中文药材名称的搜索体验,用户可以通过拼音首字母快速找到目标药材。
|
||||
@@ -0,0 +1,283 @@
|
||||
# 处方用量功能完整实现总结
|
||||
|
||||
## 概述
|
||||
在两个处方页面中实现了用量、单位和代煎功能:
|
||||
1. **处方组件** (`admin/src/components/tcm-prescription/index.vue`) - 创建/编辑处方
|
||||
2. **处方列表** (`admin/src/views/consumer/prescription/index.vue`) - 查看/编辑处方
|
||||
|
||||
## 已实现的功能
|
||||
|
||||
### 1. 数据库层 ✅
|
||||
**文件**: `add_prescription_dosage_fields.sql`
|
||||
|
||||
```sql
|
||||
ALTER TABLE `zyt_tcm_prescription`
|
||||
ADD COLUMN `dosage_amount` decimal(10,2) DEFAULT NULL COMMENT '用量数值',
|
||||
ADD COLUMN `dosage_unit` varchar(10) NOT NULL DEFAULT '' COMMENT '用量单位(g/ml)',
|
||||
ADD COLUMN `need_decoction` tinyint(1) NOT NULL DEFAULT 0 COMMENT '是否代煎(0否1是,仅饮片)';
|
||||
```
|
||||
|
||||
### 2. 后端层 ✅
|
||||
|
||||
#### 模型 (`server/app/common/model/tcm/Prescription.php`)
|
||||
```php
|
||||
protected $type = [
|
||||
'dosage_amount' => 'float',
|
||||
'need_decoction' => 'integer',
|
||||
];
|
||||
```
|
||||
|
||||
#### 业务逻辑 (`server/app/adminapi/logic/tcm/PrescriptionLogic.php`)
|
||||
- ✅ `add()` 方法:保存新处方
|
||||
- ✅ `edit()` 方法:编辑处方
|
||||
|
||||
### 3. 前端层 ✅
|
||||
|
||||
#### 3.1 处方组件 (`admin/src/components/tcm-prescription/index.vue`)
|
||||
|
||||
**数据结构**:
|
||||
```typescript
|
||||
interface PrescriptionForm {
|
||||
prescription_type: string
|
||||
dosage_amount: number | undefined
|
||||
dosage_unit: string
|
||||
need_decoction: boolean
|
||||
// ...
|
||||
}
|
||||
```
|
||||
|
||||
**响应式数据**:
|
||||
```typescript
|
||||
const formData = reactive<PrescriptionForm>({
|
||||
prescription_type: '浓缩水丸',
|
||||
dosage_amount: 1,
|
||||
dosage_unit: 'g',
|
||||
need_decoction: false,
|
||||
// ...
|
||||
})
|
||||
```
|
||||
|
||||
**自动切换逻辑**:
|
||||
```typescript
|
||||
watch(() => formData.prescription_type, (newType) => {
|
||||
if (newType === '浓缩水丸') {
|
||||
formData.dosage_unit = 'g'
|
||||
formData.dosage_amount = 1
|
||||
formData.need_decoction = false
|
||||
} else if (newType === '饮片') {
|
||||
formData.dosage_unit = 'ml'
|
||||
formData.dosage_amount = 50
|
||||
} else {
|
||||
formData.dosage_unit = 'g'
|
||||
formData.dosage_amount = undefined
|
||||
formData.need_decoction = false
|
||||
}
|
||||
})
|
||||
```
|
||||
|
||||
#### 3.2 处方列表 (`admin/src/views/consumer/prescription/index.vue`)
|
||||
|
||||
**数据结构**:
|
||||
```typescript
|
||||
const editForm = reactive({
|
||||
prescription_type: '浓缩水丸',
|
||||
dosage_amount: 1 as number | undefined,
|
||||
dosage_unit: 'g',
|
||||
need_decoction: false,
|
||||
// ...
|
||||
})
|
||||
```
|
||||
|
||||
**自动切换逻辑**:
|
||||
```typescript
|
||||
watch(() => editForm.prescription_type, (newType) => {
|
||||
if (newType === '浓缩水丸') {
|
||||
editForm.dosage_unit = 'g'
|
||||
editForm.dosage_amount = 1
|
||||
editForm.need_decoction = false
|
||||
} else if (newType === '饮片') {
|
||||
editForm.dosage_unit = 'ml'
|
||||
editForm.dosage_amount = 50
|
||||
} else {
|
||||
editForm.dosage_unit = 'g'
|
||||
editForm.dosage_amount = undefined
|
||||
editForm.need_decoction = false
|
||||
}
|
||||
})
|
||||
```
|
||||
|
||||
## UI 组件实现
|
||||
|
||||
### 浓缩水丸 (1-10g)
|
||||
```vue
|
||||
<el-select v-model="formData.dosage_amount">
|
||||
<el-option label="1g" :value="1" />
|
||||
<el-option label="2g" :value="2" />
|
||||
<!-- ... 3-10g -->
|
||||
</el-select>
|
||||
```
|
||||
|
||||
### 饮片 (50-250ml)
|
||||
```vue
|
||||
<el-select v-model="formData.dosage_amount">
|
||||
<el-option label="50ml" :value="50" />
|
||||
<el-option label="100ml" :value="100" />
|
||||
<el-option label="150ml" :value="150" />
|
||||
<el-option label="200ml" :value="200" />
|
||||
<el-option label="250ml" :value="250" />
|
||||
</el-select>
|
||||
|
||||
<!-- 代煎选项 -->
|
||||
<el-radio-group v-model="formData.need_decoction">
|
||||
<el-radio :label="true">代煎</el-radio>
|
||||
<el-radio :label="false">不代煎</el-radio>
|
||||
</el-radio-group>
|
||||
```
|
||||
|
||||
### 其他类型
|
||||
```vue
|
||||
<el-input-number
|
||||
v-model="formData.dosage_amount"
|
||||
:min="0"
|
||||
:precision="2"
|
||||
/>
|
||||
```
|
||||
|
||||
## 功能规则
|
||||
|
||||
### 1. 浓缩水丸
|
||||
- **单位**: g (克)
|
||||
- **范围**: 1-10g
|
||||
- **UI**: 下拉选择
|
||||
- **代煎**: 不显示
|
||||
|
||||
### 2. 饮片
|
||||
- **单位**: ml (毫升)
|
||||
- **范围**: 50-250ml (步进50ml)
|
||||
- **UI**: 下拉选择
|
||||
- **代煎**: 显示单选按钮(代煎/不代煎)
|
||||
|
||||
### 3. 其他类型(颗粒、丸剂、散剂、膏方、汤剂)
|
||||
- **单位**: g (克)
|
||||
- **范围**: 不限制
|
||||
- **UI**: 数字输入框(支持小数)
|
||||
- **代煎**: 不显示
|
||||
|
||||
## 数据流转示例
|
||||
|
||||
### 创建浓缩水丸处方
|
||||
```
|
||||
用户操作:
|
||||
1. 选择"浓缩水丸"
|
||||
2. 自动设置: 单位=g, 用量=1g
|
||||
3. 选择用量: 5g
|
||||
4. 保存
|
||||
|
||||
数据库存储:
|
||||
prescription_type: "浓缩水丸"
|
||||
dosage_amount: 5.00
|
||||
dosage_unit: "g"
|
||||
need_decoction: 0
|
||||
```
|
||||
|
||||
### 创建饮片处方(代煎)
|
||||
```
|
||||
用户操作:
|
||||
1. 选择"饮片"
|
||||
2. 自动设置: 单位=ml, 用量=50ml
|
||||
3. 选择用量: 150ml
|
||||
4. 选择"代煎"
|
||||
5. 保存
|
||||
|
||||
数据库存储:
|
||||
prescription_type: "饮片"
|
||||
dosage_amount: 150.00
|
||||
dosage_unit: "ml"
|
||||
need_decoction: 1
|
||||
```
|
||||
|
||||
## 部署清单
|
||||
|
||||
### 1. 数据库迁移 ⚠️ 必须执行
|
||||
```bash
|
||||
mysql -u用户名 -p数据库名 < add_prescription_dosage_fields.sql
|
||||
```
|
||||
|
||||
### 2. 后端文件
|
||||
- ✅ `server/app/common/model/tcm/Prescription.php`
|
||||
- ✅ `server/app/adminapi/logic/tcm/PrescriptionLogic.php`
|
||||
|
||||
### 3. 前端文件
|
||||
- ✅ `admin/src/components/tcm-prescription/index.vue`
|
||||
- ✅ `admin/src/views/consumer/prescription/index.vue`
|
||||
|
||||
## 测试清单
|
||||
|
||||
### 处方组件测试
|
||||
- [ ] 创建浓缩水丸处方,选择1-10g
|
||||
- [ ] 创建饮片处方,选择50-250ml
|
||||
- [ ] 饮片处方选择"代煎"
|
||||
- [ ] 饮片处方选择"不代煎"
|
||||
- [ ] 创建颗粒处方,输入自定义用量
|
||||
- [ ] 切换处方类型,验证用量自动调整
|
||||
- [ ] 保存处方,验证数据正确存储
|
||||
|
||||
### 处方列表测试
|
||||
- [ ] 编辑浓缩水丸处方,修改用量
|
||||
- [ ] 编辑饮片处方,修改用量和代煎选项
|
||||
- [ ] 编辑其他类型处方,输入自定义用量
|
||||
- [ ] 切换处方类型,验证用量自动调整
|
||||
- [ ] 保存修改,验证数据正确更新
|
||||
- [ ] 查看处方详情,验证显示正确
|
||||
|
||||
### 数据验证测试
|
||||
- [ ] 浓缩水丸用量范围:1-10g
|
||||
- [ ] 饮片用量范围:50-250ml
|
||||
- [ ] 饮片用量步进:50ml
|
||||
- [ ] 代煎选项仅在饮片时显示
|
||||
- [ ] 切换类型时代煎选项自动隐藏
|
||||
|
||||
## 文件清单
|
||||
|
||||
### 数据库
|
||||
- `add_prescription_dosage_fields.sql` - 数据库迁移文件
|
||||
|
||||
### 后端
|
||||
- `server/app/common/model/tcm/Prescription.php` - 处方模型
|
||||
- `server/app/adminapi/logic/tcm/PrescriptionLogic.php` - 处方业务逻辑
|
||||
|
||||
### 前端
|
||||
- `admin/src/components/tcm-prescription/index.vue` - 处方组件
|
||||
- `admin/src/views/consumer/prescription/index.vue` - 处方列表
|
||||
|
||||
### 文档
|
||||
- `PRESCRIPTION_DOSAGE_FEATURE.md` - 功能说明
|
||||
- `PRESCRIPTION_DOSAGE_DATABASE_IMPLEMENTATION.md` - 数据库实现
|
||||
- `PRESCRIPTION_DOSAGE_COMPLETE_SUMMARY.md` - 本文档
|
||||
|
||||
## 注意事项
|
||||
|
||||
1. **数据库迁移必须先执行**,否则保存时会报错
|
||||
2. **旧数据兼容**:旧处方的用量字段为NULL,不影响显示
|
||||
3. **类型切换**:切换处方类型时,用量和单位会自动调整
|
||||
4. **代煎选项**:仅在选择"饮片"时显示
|
||||
5. **数据验证**:前端使用下拉选择确保数据有效性
|
||||
|
||||
## 后续优化建议
|
||||
|
||||
1. **后端验证**:添加用量范围的后端验证逻辑
|
||||
2. **打印显示**:在处方打印时显示用量和代煎信息
|
||||
3. **统计报表**:按处方类型和用量统计
|
||||
4. **价格联动**:根据用量自动计算处方价格
|
||||
5. **库存检查**:代煎选项关联药房库存
|
||||
|
||||
## 总结
|
||||
|
||||
✅ **数据库层**:3个字段已定义
|
||||
✅ **后端层**:模型和业务逻辑已实现
|
||||
✅ **前端层**:2个页面都已实现
|
||||
✅ **UI组件**:下拉选择器和单选按钮
|
||||
✅ **自动切换**:watch监听实现
|
||||
✅ **数据流转**:完整的保存和读取
|
||||
|
||||
**状态**: 功能完整实现,只需执行数据库迁移即可使用!🎉
|
||||
@@ -0,0 +1,366 @@
|
||||
# 处方用量数据库实现完整指南
|
||||
|
||||
## 概述
|
||||
为处方系统添加用量、单位和代煎字段的完整数据库实现。
|
||||
|
||||
## 1. 数据库迁移
|
||||
|
||||
### 执行SQL文件
|
||||
**文件**: `add_prescription_dosage_fields.sql`
|
||||
|
||||
```sql
|
||||
-- 添加处方用量相关字段
|
||||
ALTER TABLE `zyt_tcm_prescription`
|
||||
ADD COLUMN `dosage_amount` decimal(10,2) DEFAULT NULL COMMENT '用量数值' AFTER `prescription_type`,
|
||||
ADD COLUMN `dosage_unit` varchar(10) NOT NULL DEFAULT '' COMMENT '用量单位(g/ml)' AFTER `dosage_amount`,
|
||||
ADD COLUMN `need_decoction` tinyint(1) NOT NULL DEFAULT 0 COMMENT '是否代煎(0否1是,仅饮片)' AFTER `dosage_unit`;
|
||||
```
|
||||
|
||||
### 执行方式
|
||||
```bash
|
||||
mysql -u用户名 -p数据库名 < add_prescription_dosage_fields.sql
|
||||
```
|
||||
|
||||
### 字段说明
|
||||
|
||||
| 字段名 | 类型 | 默认值 | 说明 |
|
||||
|--------|------|--------|------|
|
||||
| `dosage_amount` | decimal(10,2) | NULL | 用量数值,支持小数 |
|
||||
| `dosage_unit` | varchar(10) | '' | 用量单位 (g/ml) |
|
||||
| `need_decoction` | tinyint(1) | 0 | 是否代煎 (0=否, 1=是) |
|
||||
|
||||
## 2. 后端实现
|
||||
|
||||
### 2.1 模型层 (Model)
|
||||
**文件**: `server/app/common/model/tcm/Prescription.php`
|
||||
|
||||
```php
|
||||
class Prescription extends BaseModel
|
||||
{
|
||||
// ... 其他配置
|
||||
|
||||
// 字段类型转换
|
||||
protected $type = [
|
||||
'dosage_amount' => 'float',
|
||||
'need_decoction' => 'integer',
|
||||
];
|
||||
}
|
||||
```
|
||||
|
||||
**作用**: 自动将数据库字段转换为正确的PHP类型。
|
||||
|
||||
### 2.2 业务逻辑层 (Logic)
|
||||
**文件**: `server/app/adminapi/logic/tcm/PrescriptionLogic.php`
|
||||
|
||||
#### add() 方法 - 添加处方
|
||||
```php
|
||||
$data = [
|
||||
// ... 其他字段
|
||||
'prescription_type' => $params['prescription_type'] ?? '浓缩水丸',
|
||||
'dosage_amount' => isset($params['dosage_amount']) ? (float)$params['dosage_amount'] : null,
|
||||
'dosage_unit' => $params['dosage_unit'] ?? '',
|
||||
'need_decoction' => (int)($params['need_decoction'] ?? 0),
|
||||
// ... 其他字段
|
||||
];
|
||||
```
|
||||
|
||||
#### edit() 方法 - 编辑处方
|
||||
```php
|
||||
$data = [
|
||||
// ... 其他字段
|
||||
'prescription_type' => $params['prescription_type'] ?? $prescription->prescription_type,
|
||||
'dosage_amount' => isset($params['dosage_amount']) ? (float)$params['dosage_amount'] : $prescription->dosage_amount,
|
||||
'dosage_unit' => $params['dosage_unit'] ?? $prescription->dosage_unit,
|
||||
'need_decoction' => isset($params['need_decoction']) ? (int)$params['need_decoction'] : (int)($prescription->need_decoction ?? 0),
|
||||
// ... 其他字段
|
||||
];
|
||||
```
|
||||
|
||||
## 3. 前端实现
|
||||
|
||||
### 3.1 TypeScript 接口
|
||||
**文件**: `admin/src/components/tcm-prescription/index.vue`
|
||||
|
||||
```typescript
|
||||
interface PrescriptionForm {
|
||||
// ... 其他字段
|
||||
prescription_type: string
|
||||
dosage_amount: number | undefined
|
||||
dosage_unit: string
|
||||
need_decoction: boolean
|
||||
// ... 其他字段
|
||||
}
|
||||
```
|
||||
|
||||
### 3.2 响应式数据
|
||||
```typescript
|
||||
const formData = reactive<PrescriptionForm>({
|
||||
// ... 其他字段
|
||||
prescription_type: '浓缩水丸',
|
||||
dosage_amount: 1,
|
||||
dosage_unit: 'g',
|
||||
need_decoction: false,
|
||||
// ... 其他字段
|
||||
})
|
||||
```
|
||||
|
||||
### 3.3 自动切换逻辑
|
||||
```typescript
|
||||
watch(() => formData.prescription_type, (newType) => {
|
||||
if (newType === '浓缩水丸') {
|
||||
formData.dosage_unit = 'g'
|
||||
formData.dosage_amount = 1
|
||||
formData.need_decoction = false
|
||||
} else if (newType === '饮片') {
|
||||
formData.dosage_unit = 'ml'
|
||||
formData.dosage_amount = 50
|
||||
} else {
|
||||
formData.dosage_unit = 'g'
|
||||
formData.dosage_amount = undefined
|
||||
formData.need_decoction = false
|
||||
}
|
||||
})
|
||||
```
|
||||
|
||||
## 4. 数据流转
|
||||
|
||||
### 4.1 保存流程
|
||||
```
|
||||
前端表单
|
||||
↓
|
||||
{
|
||||
prescription_type: "浓缩水丸",
|
||||
dosage_amount: 5,
|
||||
dosage_unit: "g",
|
||||
need_decoction: false
|
||||
}
|
||||
↓
|
||||
后端 PrescriptionLogic::add()
|
||||
↓
|
||||
数据库 zyt_tcm_prescription
|
||||
↓
|
||||
dosage_amount: 5.00
|
||||
dosage_unit: "g"
|
||||
need_decoction: 0
|
||||
```
|
||||
|
||||
### 4.2 读取流程
|
||||
```
|
||||
数据库查询
|
||||
↓
|
||||
Prescription Model (类型转换)
|
||||
↓
|
||||
{
|
||||
dosage_amount: 5.0, // float
|
||||
dosage_unit: "g", // string
|
||||
need_decoction: 0 // int
|
||||
}
|
||||
↓
|
||||
前端接收
|
||||
↓
|
||||
{
|
||||
dosage_amount: 5,
|
||||
dosage_unit: "g",
|
||||
need_decoction: false
|
||||
}
|
||||
```
|
||||
|
||||
## 5. 数据示例
|
||||
|
||||
### 5.1 浓缩水丸处方
|
||||
```json
|
||||
{
|
||||
"prescription_type": "浓缩水丸",
|
||||
"dosage_amount": 5.00,
|
||||
"dosage_unit": "g",
|
||||
"need_decoction": 0
|
||||
}
|
||||
```
|
||||
|
||||
### 5.2 饮片处方(代煎)
|
||||
```json
|
||||
{
|
||||
"prescription_type": "饮片",
|
||||
"dosage_amount": 150.00,
|
||||
"dosage_unit": "ml",
|
||||
"need_decoction": 1
|
||||
}
|
||||
```
|
||||
|
||||
### 5.3 颗粒处方
|
||||
```json
|
||||
{
|
||||
"prescription_type": "颗粒",
|
||||
"dosage_amount": 15.50,
|
||||
"dosage_unit": "g",
|
||||
"need_decoction": 0
|
||||
}
|
||||
```
|
||||
|
||||
## 6. 部署步骤
|
||||
|
||||
### 步骤 1: 备份数据库
|
||||
```bash
|
||||
mysqldump -u用户名 -p数据库名 > backup_$(date +%Y%m%d_%H%M%S).sql
|
||||
```
|
||||
|
||||
### 步骤 2: 执行数据库迁移
|
||||
```bash
|
||||
mysql -u用户名 -p数据库名 < add_prescription_dosage_fields.sql
|
||||
```
|
||||
|
||||
### 步骤 3: 验证字段添加
|
||||
```sql
|
||||
SHOW COLUMNS FROM `zyt_tcm_prescription` LIKE 'dosage%';
|
||||
SHOW COLUMNS FROM `zyt_tcm_prescription` LIKE 'need_decoction';
|
||||
```
|
||||
|
||||
### 步骤 4: 部署后端代码
|
||||
```bash
|
||||
# 上传修改的文件
|
||||
- server/app/common/model/tcm/Prescription.php
|
||||
- server/app/adminapi/logic/tcm/PrescriptionLogic.php
|
||||
```
|
||||
|
||||
### 步骤 5: 部署前端代码
|
||||
```bash
|
||||
# 构建前端
|
||||
cd admin
|
||||
npm run build
|
||||
|
||||
# 上传构建产物
|
||||
# 上传 dist/ 目录到服务器
|
||||
```
|
||||
|
||||
### 步骤 6: 清除缓存(如需要)
|
||||
```bash
|
||||
cd server
|
||||
php think clear
|
||||
```
|
||||
|
||||
### 步骤 7: 测试功能
|
||||
1. 创建新处方,选择不同类型
|
||||
2. 验证用量字段正确保存
|
||||
3. 编辑处方,验证数据正确加载
|
||||
4. 查看处方详情,验证显示正确
|
||||
|
||||
## 7. 数据验证
|
||||
|
||||
### 7.1 前端验证
|
||||
```typescript
|
||||
// 浓缩水丸:1-10g
|
||||
if (formData.prescription_type === '浓缩水丸') {
|
||||
if (formData.dosage_amount < 1 || formData.dosage_amount > 10) {
|
||||
// 错误提示
|
||||
}
|
||||
}
|
||||
|
||||
// 饮片:50-250ml,步进50
|
||||
if (formData.prescription_type === '饮片') {
|
||||
if (formData.dosage_amount % 50 !== 0) {
|
||||
// 错误提示
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### 7.2 后端验证(建议添加)
|
||||
```php
|
||||
// 在 PrescriptionLogic::add() 中添加
|
||||
if ($params['prescription_type'] === '浓缩水丸') {
|
||||
$amount = (float)($params['dosage_amount'] ?? 0);
|
||||
if ($amount < 1 || $amount > 10) {
|
||||
self::setError('浓缩水丸用量必须在1-10g之间');
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
if ($params['prescription_type'] === '饮片') {
|
||||
$amount = (float)($params['dosage_amount'] ?? 0);
|
||||
if ($amount < 50 || $amount > 250 || $amount % 50 !== 0) {
|
||||
self::setError('饮片用量必须为50-250ml,且为50的倍数');
|
||||
return null;
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## 8. 常见问题
|
||||
|
||||
### Q1: 字段已存在错误
|
||||
```
|
||||
ERROR 1060 (42S21): Duplicate column name 'dosage_amount'
|
||||
```
|
||||
**解决**: 字段已存在,跳过此步骤或先删除字段再添加。
|
||||
|
||||
### Q2: 旧数据如何处理?
|
||||
**方案**: 旧数据的 `dosage_amount` 为 NULL,前端显示为空,不影响功能。
|
||||
|
||||
### Q3: 如何批量更新旧数据?
|
||||
```sql
|
||||
-- 为浓缩水丸类型设置默认用量
|
||||
UPDATE `zyt_tcm_prescription`
|
||||
SET
|
||||
`dosage_amount` = 1,
|
||||
`dosage_unit` = 'g'
|
||||
WHERE `prescription_type` = '浓缩水丸'
|
||||
AND `dosage_amount` IS NULL;
|
||||
|
||||
-- 为饮片类型设置默认用量
|
||||
UPDATE `zyt_tcm_prescription`
|
||||
SET
|
||||
`dosage_amount` = 50,
|
||||
`dosage_unit` = 'ml'
|
||||
WHERE `prescription_type` = '饮片'
|
||||
AND `dosage_amount` IS NULL;
|
||||
```
|
||||
|
||||
## 9. 回滚方案
|
||||
|
||||
如果需要回滚,执行以下SQL:
|
||||
|
||||
```sql
|
||||
-- 删除添加的字段
|
||||
ALTER TABLE `zyt_tcm_prescription`
|
||||
DROP COLUMN `dosage_amount`,
|
||||
DROP COLUMN `dosage_unit`,
|
||||
DROP COLUMN `need_decoction`;
|
||||
```
|
||||
|
||||
## 10. 相关文件清单
|
||||
|
||||
### 数据库
|
||||
- `add_prescription_dosage_fields.sql` - 数据库迁移文件
|
||||
|
||||
### 后端
|
||||
- `server/app/common/model/tcm/Prescription.php` - 处方模型
|
||||
- `server/app/adminapi/logic/tcm/PrescriptionLogic.php` - 处方业务逻辑
|
||||
|
||||
### 前端
|
||||
- `admin/src/components/tcm-prescription/index.vue` - 处方组件
|
||||
|
||||
### 文档
|
||||
- `PRESCRIPTION_DOSAGE_FEATURE.md` - 功能说明文档
|
||||
- `PRESCRIPTION_DOSAGE_DATABASE_IMPLEMENTATION.md` - 本文档
|
||||
|
||||
## 11. 测试清单
|
||||
|
||||
- [ ] 数据库字段添加成功
|
||||
- [ ] 创建浓缩水丸处方,用量1-10g
|
||||
- [ ] 创建饮片处方,用量50-250ml
|
||||
- [ ] 饮片处方代煎选项正常
|
||||
- [ ] 编辑处方,数据正确加载
|
||||
- [ ] 切换处方类型,用量自动调整
|
||||
- [ ] 保存处方,数据正确存储
|
||||
- [ ] 查看处方详情,显示正确
|
||||
- [ ] 旧数据兼容性测试
|
||||
|
||||
## 总结
|
||||
|
||||
此实现完整覆盖了从数据库到前端的所有层次:
|
||||
1. ✅ 数据库字段添加
|
||||
2. ✅ 模型层类型转换
|
||||
3. ✅ 业务逻辑层数据处理
|
||||
4. ✅ 前端表单交互
|
||||
5. ✅ 自动切换逻辑
|
||||
6. ✅ 数据验证
|
||||
|
||||
所有代码已实现,只需执行数据库迁移即可使用!
|
||||
@@ -0,0 +1,307 @@
|
||||
# 处方用量功能实现
|
||||
|
||||
## 概述
|
||||
根据处方类型自动调整用量单位和范围,并为饮片类型添加代煎选项。
|
||||
|
||||
## 功能说明
|
||||
|
||||
### 1. 浓缩水丸
|
||||
- **单位**: g (克)
|
||||
- **范围**: 1-10g
|
||||
- **步进**: 1g
|
||||
- **默认值**: 1g
|
||||
|
||||
### 2. 饮片
|
||||
- **单位**: ml (毫升)
|
||||
- **范围**: 50-250ml
|
||||
- **步进**: 50ml (每50ml一个单位)
|
||||
- **默认值**: 50ml
|
||||
- **特殊选项**: 是否代煎(代煎/不代煎)
|
||||
|
||||
### 3. 其他类型(颗粒、丸剂、散剂、膏方、汤剂)
|
||||
- **单位**: g (克)
|
||||
- **范围**: 不限制
|
||||
- **精度**: 小数点后2位
|
||||
- **默认值**: 无
|
||||
|
||||
## 前端实现
|
||||
|
||||
### 文件:`admin/src/components/tcm-prescription/index.vue`
|
||||
|
||||
#### 1. TypeScript 接口
|
||||
```typescript
|
||||
interface PrescriptionForm {
|
||||
// ... 其他字段
|
||||
prescription_type: string // 处方类型
|
||||
dosage_amount: number | undefined // 用量数值
|
||||
dosage_unit: string // 用量单位 (g 或 ml)
|
||||
need_decoction: boolean // 是否代煎(仅饮片)
|
||||
// ...
|
||||
}
|
||||
```
|
||||
|
||||
#### 2. 响应式数据
|
||||
```typescript
|
||||
const formData = reactive<PrescriptionForm>({
|
||||
// ...
|
||||
prescription_type: '浓缩水丸',
|
||||
dosage_amount: 1,
|
||||
dosage_unit: 'g',
|
||||
need_decoction: false,
|
||||
// ...
|
||||
})
|
||||
```
|
||||
|
||||
#### 3. 自动切换逻辑
|
||||
```typescript
|
||||
watch(() => formData.prescription_type, (newType) => {
|
||||
if (newType === '浓缩水丸') {
|
||||
formData.dosage_unit = 'g'
|
||||
formData.dosage_amount = 1
|
||||
formData.need_decoction = false
|
||||
} else if (newType === '饮片') {
|
||||
formData.dosage_unit = 'ml'
|
||||
formData.dosage_amount = 50
|
||||
// need_decoction 保持用户选择
|
||||
} else {
|
||||
formData.dosage_unit = 'g'
|
||||
formData.dosage_amount = undefined
|
||||
formData.need_decoction = false
|
||||
}
|
||||
})
|
||||
```
|
||||
|
||||
#### 4. UI 组件
|
||||
```vue
|
||||
<!-- 用量字段 -->
|
||||
<el-col :span="12">
|
||||
<el-form-item label="用量" prop="dosage_amount">
|
||||
<!-- 浓缩水丸:1-10g -->
|
||||
<el-input-number
|
||||
v-if="formData.prescription_type === '浓缩水丸'"
|
||||
v-model="formData.dosage_amount"
|
||||
:min="1"
|
||||
:max="10"
|
||||
:step="1"
|
||||
class="!w-32"
|
||||
/>
|
||||
<!-- 饮片:50-250ml,步进50 -->
|
||||
<el-input-number
|
||||
v-else-if="formData.prescription_type === '饮片'"
|
||||
v-model="formData.dosage_amount"
|
||||
:min="50"
|
||||
:max="250"
|
||||
:step="50"
|
||||
class="!w-32"
|
||||
/>
|
||||
<!-- 其他类型:自由输入 -->
|
||||
<el-input-number
|
||||
v-else
|
||||
v-model="formData.dosage_amount"
|
||||
:min="0"
|
||||
:precision="2"
|
||||
class="!w-32"
|
||||
/>
|
||||
<span class="ml-2">{{ formData.dosage_unit }}</span>
|
||||
</el-form-item>
|
||||
</el-col>
|
||||
|
||||
<!-- 饮片代煎选项 -->
|
||||
<el-col v-if="formData.prescription_type === '饮片'" :span="12">
|
||||
<el-form-item label="是否代煎" prop="need_decoction">
|
||||
<el-radio-group v-model="formData.need_decoction">
|
||||
<el-radio :label="true">代煎</el-radio>
|
||||
<el-radio :label="false">不代煎</el-radio>
|
||||
</el-radio-group>
|
||||
</el-form-item>
|
||||
</el-col>
|
||||
```
|
||||
|
||||
## 用户交互流程
|
||||
|
||||
### 场景 1:选择浓缩水丸
|
||||
1. 用户选择"浓缩水丸"
|
||||
2. 系统自动设置:
|
||||
- 用量单位:g
|
||||
- 默认用量:1g
|
||||
- 用量范围:1-10g
|
||||
- 隐藏代煎选项
|
||||
|
||||
### 场景 2:选择饮片
|
||||
1. 用户选择"饮片"
|
||||
2. 系统自动设置:
|
||||
- 用量单位:ml
|
||||
- 默认用量:50ml
|
||||
- 用量范围:50-250ml(步进50ml)
|
||||
- 显示代煎选项(默认"不代煎")
|
||||
3. 用户可选择:
|
||||
- 用量:50ml, 100ml, 150ml, 200ml, 250ml
|
||||
- 是否代煎:代煎 / 不代煎
|
||||
|
||||
### 场景 3:选择其他类型
|
||||
1. 用户选择"颗粒"、"丸剂"等
|
||||
2. 系统自动设置:
|
||||
- 用量单位:g
|
||||
- 默认用量:空
|
||||
- 用量范围:不限制(可输入小数)
|
||||
- 隐藏代煎选项
|
||||
|
||||
## 数据示例
|
||||
|
||||
### 浓缩水丸处方
|
||||
```json
|
||||
{
|
||||
"prescription_type": "浓缩水丸",
|
||||
"dosage_amount": 5,
|
||||
"dosage_unit": "g",
|
||||
"need_decoction": false
|
||||
}
|
||||
```
|
||||
|
||||
### 饮片处方(代煎)
|
||||
```json
|
||||
{
|
||||
"prescription_type": "饮片",
|
||||
"dosage_amount": 150,
|
||||
"dosage_unit": "ml",
|
||||
"need_decoction": true
|
||||
}
|
||||
```
|
||||
|
||||
### 饮片处方(不代煎)
|
||||
```json
|
||||
{
|
||||
"prescription_type": "饮片",
|
||||
"dosage_amount": 200,
|
||||
"dosage_unit": "ml",
|
||||
"need_decoction": false
|
||||
}
|
||||
```
|
||||
|
||||
### 颗粒处方
|
||||
```json
|
||||
{
|
||||
"prescription_type": "颗粒",
|
||||
"dosage_amount": 15.5,
|
||||
"dosage_unit": "g",
|
||||
"need_decoction": false
|
||||
}
|
||||
```
|
||||
|
||||
## 后端存储建议
|
||||
|
||||
### 数据库字段
|
||||
建议在处方表中添加以下字段:
|
||||
|
||||
```sql
|
||||
ALTER TABLE `zyt_tcm_prescription`
|
||||
ADD COLUMN `dosage_amount` decimal(10,2) DEFAULT NULL COMMENT '用量数值',
|
||||
ADD COLUMN `dosage_unit` varchar(10) NOT NULL DEFAULT '' COMMENT '用量单位(g/ml)',
|
||||
ADD COLUMN `need_decoction` tinyint(1) NOT NULL DEFAULT 0 COMMENT '是否代煎(0否1是,仅饮片)';
|
||||
```
|
||||
|
||||
### 后端验证逻辑
|
||||
```php
|
||||
// 浓缩水丸验证
|
||||
if ($prescriptionType === '浓缩水丸') {
|
||||
if ($dosageAmount < 1 || $dosageAmount > 10) {
|
||||
throw new Exception('浓缩水丸用量必须在1-10g之间');
|
||||
}
|
||||
if ($dosageUnit !== 'g') {
|
||||
throw new Exception('浓缩水丸单位必须为g');
|
||||
}
|
||||
}
|
||||
|
||||
// 饮片验证
|
||||
if ($prescriptionType === '饮片') {
|
||||
if ($dosageAmount < 50 || $dosageAmount > 250 || $dosageAmount % 50 !== 0) {
|
||||
throw new Exception('饮片用量必须为50-250ml,且为50的倍数');
|
||||
}
|
||||
if ($dosageUnit !== 'ml') {
|
||||
throw new Exception('饮片单位必须为ml');
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## UI 布局
|
||||
|
||||
```
|
||||
┌─────────────────────────────────────────────────────────┐
|
||||
│ 处方类型: [浓缩水丸 ▼] 用量: [1] g │
|
||||
└─────────────────────────────────────────────────────────┘
|
||||
|
||||
┌─────────────────────────────────────────────────────────┐
|
||||
│ 处方类型: [饮片 ▼] 用量: [150] ml │
|
||||
│ 是否代煎: ⦿ 代煎 ○ 不代煎 │
|
||||
└─────────────────────────────────────────────────────────┘
|
||||
|
||||
┌─────────────────────────────────────────────────────────┐
|
||||
│ 处方类型: [颗粒 ▼] 用量: [15.5] g │
|
||||
└─────────────────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
## 业务规则
|
||||
|
||||
### 1. 浓缩水丸
|
||||
- 用于浓缩制剂,按克计量
|
||||
- 通常用量较小,1-10g范围合理
|
||||
- 不需要代煎
|
||||
|
||||
### 2. 饮片
|
||||
- 用于传统中药饮片
|
||||
- 按煎煮后的药液体积计量(ml)
|
||||
- 每次50ml为一个标准单位
|
||||
- 可选择是否由药房代煎
|
||||
- 代煎:药房负责煎煮,患者直接服用
|
||||
- 不代煎:患者自行煎煮
|
||||
|
||||
### 3. 其他类型
|
||||
- 根据实际情况灵活设置用量
|
||||
- 支持小数点精度
|
||||
|
||||
## 注意事项
|
||||
|
||||
1. **单位自动切换**:切换处方类型时,单位和默认值会自动更新
|
||||
2. **代煎选项**:仅在选择"饮片"时显示
|
||||
3. **用量限制**:不同类型有不同的用量范围限制
|
||||
4. **数据验证**:前端和后端都应进行用量范围验证
|
||||
5. **用户体验**:使用 `el-input-number` 组件,支持键盘上下键调整
|
||||
|
||||
## 扩展建议
|
||||
|
||||
1. **价格联动**:根据用量自动计算处方价格
|
||||
2. **库存检查**:代煎选项可关联药房库存
|
||||
3. **历史记录**:记录患者常用的用量设置
|
||||
4. **模板保存**:将用量设置保存到处方模板
|
||||
5. **打印显示**:在处方打印时显示用量和代煎信息
|
||||
|
||||
## 相关文件
|
||||
|
||||
- `admin/src/components/tcm-prescription/index.vue` - 处方组件主文件
|
||||
|
||||
## 测试用例
|
||||
|
||||
### 测试 1:浓缩水丸用量
|
||||
- 选择"浓缩水丸"
|
||||
- 验证用量范围:1-10g
|
||||
- 验证步进:1g
|
||||
- 验证代煎选项隐藏
|
||||
|
||||
### 测试 2:饮片用量
|
||||
- 选择"饮片"
|
||||
- 验证用量范围:50-250ml
|
||||
- 验证步进:50ml
|
||||
- 验证代煎选项显示
|
||||
- 测试代煎/不代煎切换
|
||||
|
||||
### 测试 3:类型切换
|
||||
- 从"浓缩水丸"切换到"饮片"
|
||||
- 验证单位从g变为ml
|
||||
- 验证用量自动调整
|
||||
- 验证代煎选项出现
|
||||
|
||||
### 测试 4:数据保存
|
||||
- 填写完整处方信息
|
||||
- 保存处方
|
||||
- 验证用量数据正确存储
|
||||
- 验证代煎选项正确存储
|
||||
@@ -0,0 +1,251 @@
|
||||
# 处方字段完整修复总结
|
||||
|
||||
## 问题描述
|
||||
前端添加了新字段(用量、代煎、每天几次),但后端没有相应处理,导致编辑保存时数据丢失。
|
||||
|
||||
## 修复内容
|
||||
|
||||
### 1. 数据库字段 ✅
|
||||
|
||||
**文件**: `add_prescription_complete_fields.sql`
|
||||
|
||||
```sql
|
||||
-- 用量相关字段
|
||||
ALTER TABLE `zyt_tcm_prescription`
|
||||
ADD COLUMN IF NOT EXISTS `dosage_amount` decimal(10,2) DEFAULT NULL COMMENT '用量数值',
|
||||
ADD COLUMN IF NOT EXISTS `dosage_unit` varchar(10) NOT NULL DEFAULT '' COMMENT '用量单位(g/ml)',
|
||||
ADD COLUMN IF NOT EXISTS `need_decoction` tinyint(1) NOT NULL DEFAULT 0 COMMENT '是否代煎(0否1是,仅饮片)';
|
||||
|
||||
-- 每天几次字段
|
||||
ALTER TABLE `zyt_tcm_prescription`
|
||||
ADD COLUMN IF NOT EXISTS `times_per_day` int(11) NOT NULL DEFAULT 2 COMMENT '每天服用次数';
|
||||
```
|
||||
|
||||
### 2. 后端修复 ✅
|
||||
|
||||
#### 文件: `server/app/adminapi/logic/tcm/PrescriptionLogic.php`
|
||||
|
||||
**add() 方法** - 添加处方时保存新字段:
|
||||
```php
|
||||
'dosage_amount' => isset($params['dosage_amount']) ? (float)$params['dosage_amount'] : null,
|
||||
'dosage_unit' => $params['dosage_unit'] ?? '',
|
||||
'need_decoction' => (int)($params['need_decoction'] ?? 0),
|
||||
'times_per_day' => (int)($params['times_per_day'] ?? 2),
|
||||
```
|
||||
|
||||
**edit() 方法** - 编辑处方时更新新字段:
|
||||
```php
|
||||
'dosage_amount' => isset($params['dosage_amount']) ? (float)$params['dosage_amount'] : $prescription->dosage_amount,
|
||||
'dosage_unit' => $params['dosage_unit'] ?? $prescription->dosage_unit,
|
||||
'need_decoction' => isset($params['need_decoction']) ? (int)$params['need_decoction'] : (int)($prescription->need_decoction ?? 0),
|
||||
'times_per_day' => (int)($params['times_per_day'] ?? $prescription->times_per_day ?? 2),
|
||||
```
|
||||
|
||||
### 3. 前端修复 ✅
|
||||
|
||||
#### 文件: `admin/src/views/consumer/prescription/index.vue`
|
||||
|
||||
**editForm 定义** - 添加新字段:
|
||||
```typescript
|
||||
const editForm = reactive({
|
||||
// ...
|
||||
dosage_amount: 1 as number | undefined,
|
||||
dosage_unit: 'g',
|
||||
need_decoction: false,
|
||||
times_per_day: 2,
|
||||
// ...
|
||||
})
|
||||
```
|
||||
|
||||
**handleEdit() 方法** - 加载数据时填充新字段:
|
||||
```typescript
|
||||
editForm.dosage_amount = row.dosage_amount ?? undefined
|
||||
editForm.dosage_unit = row.dosage_unit || ''
|
||||
editForm.need_decoction = row.need_decoction === 1 || row.need_decoction === true
|
||||
editForm.times_per_day = row.times_per_day ?? 2
|
||||
```
|
||||
|
||||
**watch 监听** - 处方类型变化时自动调整:
|
||||
```typescript
|
||||
watch(() => editForm.prescription_type, (newType) => {
|
||||
if (newType === '浓缩水丸') {
|
||||
editForm.dosage_unit = 'g'
|
||||
editForm.dosage_amount = 1
|
||||
editForm.need_decoction = false
|
||||
} else if (newType === '饮片') {
|
||||
editForm.dosage_unit = 'ml'
|
||||
editForm.dosage_amount = 50
|
||||
} else {
|
||||
editForm.dosage_unit = 'g'
|
||||
editForm.dosage_amount = undefined
|
||||
editForm.need_decoction = false
|
||||
}
|
||||
})
|
||||
```
|
||||
|
||||
## 字段说明
|
||||
|
||||
| 字段名 | 类型 | 默认值 | 说明 |
|
||||
|--------|------|--------|------|
|
||||
| `dosage_amount` | decimal(10,2) | NULL | 用量数值 |
|
||||
| `dosage_unit` | varchar(10) | '' | 用量单位 (g/ml) |
|
||||
| `need_decoction` | tinyint(1) | 0 | 是否代煎 (0=否, 1=是) |
|
||||
| `times_per_day` | int(11) | 2 | 每天服用次数 |
|
||||
|
||||
## 数据流转
|
||||
|
||||
### 创建处方
|
||||
```
|
||||
前端表单
|
||||
↓
|
||||
{
|
||||
prescription_type: "浓缩水丸",
|
||||
dosage_amount: 5,
|
||||
dosage_unit: "g",
|
||||
need_decoction: false,
|
||||
times_per_day: 2
|
||||
}
|
||||
↓
|
||||
PrescriptionLogic::add()
|
||||
↓
|
||||
数据库保存
|
||||
```
|
||||
|
||||
### 编辑处方
|
||||
```
|
||||
数据库读取
|
||||
↓
|
||||
{
|
||||
dosage_amount: 5.00,
|
||||
dosage_unit: "g",
|
||||
need_decoction: 0,
|
||||
times_per_day: 2
|
||||
}
|
||||
↓
|
||||
前端 editForm 填充
|
||||
↓
|
||||
用户修改
|
||||
↓
|
||||
PrescriptionLogic::edit()
|
||||
↓
|
||||
数据库更新
|
||||
```
|
||||
|
||||
## 部署步骤
|
||||
|
||||
### 1. 备份数据库 ⚠️
|
||||
```bash
|
||||
mysqldump -u用户名 -p数据库名 > backup_$(date +%Y%m%d_%H%M%S).sql
|
||||
```
|
||||
|
||||
### 2. 执行数据库迁移 ⚠️ 必须
|
||||
```bash
|
||||
mysql -u用户名 -p数据库名 < add_prescription_complete_fields.sql
|
||||
```
|
||||
|
||||
### 3. 验证字段添加
|
||||
```sql
|
||||
SHOW COLUMNS FROM `zyt_tcm_prescription` LIKE 'dosage%';
|
||||
SHOW COLUMNS FROM `zyt_tcm_prescription` LIKE 'need_decoction';
|
||||
SHOW COLUMNS FROM `zyt_tcm_prescription` LIKE 'times_per_day';
|
||||
```
|
||||
|
||||
### 4. 部署后端代码
|
||||
```bash
|
||||
# 上传修改的文件
|
||||
- server/app/adminapi/logic/tcm/PrescriptionLogic.php
|
||||
- server/app/common/model/tcm/Prescription.php
|
||||
```
|
||||
|
||||
### 5. 部署前端代码
|
||||
```bash
|
||||
cd admin
|
||||
npm run build
|
||||
# 上传 dist/ 目录
|
||||
```
|
||||
|
||||
### 6. 清除缓存(如需要)
|
||||
```bash
|
||||
cd server
|
||||
php think clear
|
||||
```
|
||||
|
||||
## 测试清单
|
||||
|
||||
### 创建处方测试
|
||||
- [ ] 创建浓缩水丸处方,设置用量5g,每天2次
|
||||
- [ ] 创建饮片处方,设置用量150ml,代煎,每天3次
|
||||
- [ ] 创建颗粒处方,设置用量15.5g,每天2次
|
||||
- [ ] 保存后查看数据库,验证字段正确存储
|
||||
|
||||
### 编辑处方测试
|
||||
- [ ] 编辑已有处方,修改用量
|
||||
- [ ] 编辑饮片处方,修改代煎选项
|
||||
- [ ] 编辑处方,修改每天几次
|
||||
- [ ] 切换处方类型,验证用量自动调整
|
||||
- [ ] 保存后查看数据库,验证字段正确更新
|
||||
|
||||
### 数据加载测试
|
||||
- [ ] 打开编辑对话框,验证用量正确显示
|
||||
- [ ] 验证代煎选项正确显示
|
||||
- [ ] 验证每天几次正确显示
|
||||
- [ ] 验证旧数据(无新字段)不报错
|
||||
|
||||
## 修复的问题
|
||||
|
||||
### 问题 1: 用量字段不保存 ✅
|
||||
**原因**: 后端 `add()` 和 `edit()` 方法没有处理 `dosage_amount`, `dosage_unit`, `need_decoction` 字段
|
||||
**修复**: 在两个方法中添加字段处理逻辑
|
||||
|
||||
### 问题 2: 每天几次不保存 ✅
|
||||
**原因**:
|
||||
1. 数据库没有 `times_per_day` 字段
|
||||
2. 后端没有处理该字段
|
||||
3. 前端 editForm 定义错误(空字符串而非数字)
|
||||
|
||||
**修复**:
|
||||
1. 添加数据库字段
|
||||
2. 后端添加字段处理
|
||||
3. 前端修正默认值为数字 2
|
||||
|
||||
### 问题 3: 编辑时数据不加载 ✅
|
||||
**原因**: `handleEdit()` 方法没有加载新字段
|
||||
**修复**: 在加载数据时填充所有新字段
|
||||
|
||||
## 相关文件
|
||||
|
||||
### 数据库
|
||||
- `add_prescription_complete_fields.sql` - 完整字段迁移文件
|
||||
|
||||
### 后端
|
||||
- `server/app/adminapi/logic/tcm/PrescriptionLogic.php` - 处方业务逻辑
|
||||
- `server/app/common/model/tcm/Prescription.php` - 处方模型
|
||||
|
||||
### 前端
|
||||
- `admin/src/components/tcm-prescription/index.vue` - 处方组件
|
||||
- `admin/src/views/consumer/prescription/index.vue` - 处方列表
|
||||
|
||||
### 文档
|
||||
- `PRESCRIPTION_DOSAGE_FEATURE.md` - 功能说明
|
||||
- `PRESCRIPTION_DOSAGE_DATABASE_IMPLEMENTATION.md` - 数据库实现
|
||||
- `PRESCRIPTION_DOSAGE_COMPLETE_SUMMARY.md` - 完整总结
|
||||
- `PRESCRIPTION_FIELDS_FIX_SUMMARY.md` - 本文档
|
||||
|
||||
## 注意事项
|
||||
|
||||
1. **必须先执行数据库迁移**,否则保存时会报错
|
||||
2. **旧数据兼容**:旧处方的新字段为 NULL 或默认值,不影响显示
|
||||
3. **类型转换**:
|
||||
- `dosage_amount`: float
|
||||
- `need_decoction`: boolean (前端) → int (后端/数据库)
|
||||
- `times_per_day`: int
|
||||
4. **数据验证**:前端使用下拉和数字输入框确保数据有效性
|
||||
|
||||
## 总结
|
||||
|
||||
✅ **数据库**: 4个字段已添加
|
||||
✅ **后端**: add() 和 edit() 方法已更新
|
||||
✅ **前端**: editForm 定义和数据加载已修复
|
||||
✅ **数据流转**: 完整的保存和读取流程
|
||||
|
||||
**状态**: 所有问题已修复,功能完整可用!🎉
|
||||
@@ -0,0 +1,199 @@
|
||||
# 处方"每天几次"功能添加说明
|
||||
|
||||
## 功能描述
|
||||
|
||||
在中医处方单中添加"每天几次"字段,用于记录患者每天需要服用药物的次数。
|
||||
|
||||
## 修改内容
|
||||
|
||||
### 1. 表单界面(admin/src/components/tcm-prescription/index.vue)
|
||||
|
||||
#### 添加表单字段(第 139-145 行)
|
||||
|
||||
在"服用天数"字段后面添加"每天几次"输入框:
|
||||
|
||||
```vue
|
||||
<el-col :span="8">
|
||||
<el-form-item label="服用天数" prop="usage_days">
|
||||
<el-input-number v-model="formData.usage_days" :min="1" :max="365" class="!w-full" />
|
||||
</el-form-item>
|
||||
</el-col>
|
||||
<el-col :span="8">
|
||||
<el-form-item label="每天几次" prop="times_per_day">
|
||||
<el-input-number v-model="formData.times_per_day" :min="1" :max="10" class="!w-full" placeholder="每天服用次数" />
|
||||
</el-form-item>
|
||||
</el-col>
|
||||
```
|
||||
|
||||
**字段说明:**
|
||||
- 字段名:`times_per_day`
|
||||
- 类型:数字输入框
|
||||
- 最小值:1
|
||||
- 最大值:10
|
||||
- 默认值:2
|
||||
|
||||
### 2. TypeScript 类型定义(第 580-615 行)
|
||||
|
||||
在 `PrescriptionForm` 接口中添加字段:
|
||||
|
||||
```typescript
|
||||
interface PrescriptionForm {
|
||||
// ... 其他字段
|
||||
usage_days: number
|
||||
times_per_day: number // 新增
|
||||
usage_instruction: string
|
||||
// ... 其他字段
|
||||
}
|
||||
```
|
||||
|
||||
### 3. 数据模型初始化(第 650-680 行)
|
||||
|
||||
在 `formData` 中添加默认值:
|
||||
|
||||
```typescript
|
||||
const formData = reactive<PrescriptionForm>({
|
||||
// ... 其他字段
|
||||
usage_days: 7,
|
||||
times_per_day: 2, // 新增,默认每天2次
|
||||
usage_instruction: '水煎服,一日二次',
|
||||
// ... 其他字段
|
||||
})
|
||||
```
|
||||
|
||||
### 4. 处方预览显示(第 280-285 行)
|
||||
|
||||
在处方打印预览中显示"每天几次"信息:
|
||||
|
||||
```vue
|
||||
<div class="footer-dosage">
|
||||
服用{{ savedPrescription.usage_days || savedPrescription.dose_count }}天{{ savedPrescription.times_per_day ? ',每天' + savedPrescription.times_per_day + '次' : '' }}, {{ savedPrescription.prescription_type }}
|
||||
{{ savedPrescription.usage_instruction }}
|
||||
</div>
|
||||
```
|
||||
|
||||
**显示效果:**
|
||||
- 如果设置了 `times_per_day`:显示"服用7天,每天2次, 浓缩水丸 水煎服,一日二次"
|
||||
- 如果未设置:显示"服用7天, 浓缩水丸 水煎服,一日二次"
|
||||
|
||||
## 后端数据库支持
|
||||
|
||||
### 需要添加的数据库字段
|
||||
|
||||
如果后端数据库表中还没有 `times_per_day` 字段,需要执行以下 SQL:
|
||||
|
||||
```sql
|
||||
-- 在处方表中添加 times_per_day 字段
|
||||
ALTER TABLE `zyt_prescription`
|
||||
ADD COLUMN `times_per_day` INT(11) DEFAULT 2 COMMENT '每天服用次数' AFTER `usage_days`;
|
||||
|
||||
-- 如果需要更新现有数据的默认值
|
||||
UPDATE `zyt_prescription` SET `times_per_day` = 2 WHERE `times_per_day` IS NULL;
|
||||
```
|
||||
|
||||
### 后端 API 修改
|
||||
|
||||
确保后端接口支持 `times_per_day` 字段:
|
||||
|
||||
1. **创建/更新处方接口**:接收 `times_per_day` 参数
|
||||
2. **查询处方接口**:返回 `times_per_day` 字段
|
||||
3. **验证规则**:`times_per_day` 应该在 1-10 之间
|
||||
|
||||
## 使用说明
|
||||
|
||||
### 医生操作流程
|
||||
|
||||
1. 打开处方单编辑界面
|
||||
2. 填写患者信息和诊断信息
|
||||
3. 添加中药处方
|
||||
4. 在"服用天数"字段输入天数(如:7)
|
||||
5. 在"每天几次"字段输入次数(如:2)
|
||||
6. 系统会在处方预览中显示:"服用7天,每天2次"
|
||||
|
||||
### 常见用法
|
||||
|
||||
- **每天2次**:早晚各一次(最常见)
|
||||
- **每天3次**:早中晚各一次
|
||||
- **每天1次**:每日一次
|
||||
- **每天4次**:每6小时一次
|
||||
|
||||
## 测试要点
|
||||
|
||||
### 前端测试
|
||||
|
||||
1. ✅ 输入框显示正常
|
||||
2. ✅ 最小值限制(不能小于1)
|
||||
3. ✅ 最大值限制(不能大于10)
|
||||
4. ✅ 默认值为2
|
||||
5. ✅ 预览显示正确
|
||||
6. ✅ 保存后数据正确
|
||||
|
||||
### 后端测试
|
||||
|
||||
1. ✅ 创建处方时保存 `times_per_day`
|
||||
2. ✅ 更新处方时更新 `times_per_day`
|
||||
3. ✅ 查询处方时返回 `times_per_day`
|
||||
4. ✅ 数据验证(1-10范围)
|
||||
|
||||
### 集成测试
|
||||
|
||||
1. ✅ 创建处方 → 保存 → 查看预览 → 确认显示正确
|
||||
2. ✅ 编辑处方 → 修改次数 → 保存 → 确认更新成功
|
||||
3. ✅ 打印处方 → 确认打印内容包含"每天X次"
|
||||
|
||||
## 界面效果
|
||||
|
||||
### 编辑界面
|
||||
|
||||
```
|
||||
┌─────────────────────────────────────────────────┐
|
||||
│ 处方类型: [浓缩水丸 ▼] │
|
||||
├─────────────────────────────────────────────────┤
|
||||
│ 服用天数: [ 7 ] 每天几次: [ 2 ] │
|
||||
├─────────────────────────────────────────────────┤
|
||||
│ 用法: 水煎服,一日二次 │
|
||||
└─────────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
### 预览界面
|
||||
|
||||
```
|
||||
服用7天,每天2次, 浓缩水丸 水煎服,一日二次
|
||||
服用时间: 饭前 服用方式: 温水送服
|
||||
```
|
||||
|
||||
## 注意事项
|
||||
|
||||
1. **默认值**:新建处方时默认为每天2次(最常见的用法)
|
||||
2. **范围限制**:限制在1-10次之间,超出范围的输入会被自动调整
|
||||
3. **可选字段**:如果不填写,预览中不会显示"每天X次"
|
||||
4. **与用法说明的关系**:`times_per_day` 是结构化数据,`usage_instruction` 是文字描述,两者可以互补
|
||||
|
||||
## 兼容性
|
||||
|
||||
- ✅ 兼容现有处方数据(旧数据没有此字段时不显示)
|
||||
- ✅ 不影响现有功能
|
||||
- ✅ 向后兼容
|
||||
|
||||
## 后续优化建议
|
||||
|
||||
1. **智能提示**:根据处方类型自动推荐合适的服用次数
|
||||
2. **关联验证**:与"用法"字段进行一致性检查
|
||||
3. **统计分析**:统计不同处方类型的常用服用次数
|
||||
4. **模板支持**:在处方模板中包含 `times_per_day` 字段
|
||||
|
||||
## 完成状态
|
||||
|
||||
- ✅ 前端界面添加完成
|
||||
- ✅ 数据模型更新完成
|
||||
- ✅ 类型定义更新完成
|
||||
- ✅ 预览显示更新完成
|
||||
- ⏳ 等待后端数据库字段添加
|
||||
- ⏳ 等待后端 API 支持
|
||||
|
||||
## 相关文件
|
||||
|
||||
- `admin/src/components/tcm-prescription/index.vue` - 处方组件主文件
|
||||
- 后端需要修改的文件(待确认):
|
||||
- 处方模型文件
|
||||
- 处方控制器文件
|
||||
- 数据库迁移文件
|
||||
@@ -0,0 +1,357 @@
|
||||
# 省市区选择器功能说明
|
||||
|
||||
## 功能描述
|
||||
|
||||
在创建处方业务订单时,添加省市区级联选择器,方便用户选择收货地址的省市区信息。
|
||||
|
||||
## 修改内容
|
||||
|
||||
### 1. 表单界面(admin/src/views/consumer/prescription/index.vue)
|
||||
|
||||
#### 添加省市区选择器(第 792-810 行)
|
||||
|
||||
```vue
|
||||
<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="region">
|
||||
<el-cascader
|
||||
v-model="createOrderForm.region"
|
||||
:options="regionOptions"
|
||||
:props="{ value: 'name', label: 'name', children: 'children', emitPath: false, checkStrictly: false }"
|
||||
placeholder="请选择省/市/区"
|
||||
clearable
|
||||
filterable
|
||||
class="w-full"
|
||||
@change="handleRegionChange"
|
||||
/>
|
||||
</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>
|
||||
```
|
||||
|
||||
**组件配置说明:**
|
||||
- `v-model="createOrderForm.region"`:绑定选中的省市区数组
|
||||
- `:options="regionOptions"`:省市区数据源
|
||||
- `value: 'name'`:使用 name 字段作为值
|
||||
- `label: 'name'`:使用 name 字段作为显示文本
|
||||
- `emitPath: false`:只返回最后一级的值(区/县)
|
||||
- `checkStrictly: false`:必须选择到最后一级
|
||||
- `clearable`:可清空
|
||||
- `filterable`:可搜索
|
||||
|
||||
### 2. 数据模型更新(第 1175-1195 行)
|
||||
|
||||
```typescript
|
||||
const createOrderForm = reactive({
|
||||
patient_id: '' as number | string,
|
||||
recipient_name: '',
|
||||
recipient_phone: '',
|
||||
region: [] as string[], // 新增:省市区数组
|
||||
shipping_address: '', // 改为详细地址
|
||||
// ... 其他字段
|
||||
})
|
||||
```
|
||||
|
||||
### 3. 省市区数据(第 1195-1340 行)
|
||||
|
||||
添加了常用省市区数据:
|
||||
- 四川省(成都市、绵阳市)
|
||||
- 北京市
|
||||
- 上海市
|
||||
- 广东省(广州市、深圳市)
|
||||
|
||||
```typescript
|
||||
const regionOptions = ref([
|
||||
{
|
||||
name: '四川省',
|
||||
children: [
|
||||
{
|
||||
name: '成都市',
|
||||
children: [
|
||||
{ name: '锦江区' },
|
||||
{ name: '青羊区' },
|
||||
// ... 更多区县
|
||||
]
|
||||
}
|
||||
]
|
||||
},
|
||||
// ... 更多省份
|
||||
])
|
||||
```
|
||||
|
||||
### 4. 事件处理方法
|
||||
|
||||
```typescript
|
||||
const handleRegionChange = (value: string[]) => {
|
||||
console.log('选择的省市区:', value)
|
||||
}
|
||||
```
|
||||
|
||||
## 使用说明
|
||||
|
||||
### 用户操作流程
|
||||
|
||||
1. 点击"创建业务订单"按钮
|
||||
2. 选择诊单患者
|
||||
3. 填写收货人和收货手机
|
||||
4. **点击"省市区"选择器**
|
||||
5. 依次选择:省 → 市 → 区/县
|
||||
6. 在"详细地址"中填写街道、门牌号等信息
|
||||
7. 填写其他订单信息
|
||||
8. 提交订单
|
||||
|
||||
### 界面效果
|
||||
|
||||
**编辑时:**
|
||||
```
|
||||
┌─────────────────────────────────────────────────┐
|
||||
│ 收货人: [张三] 收货手机: [138****0000] │
|
||||
├─────────────────────────────────────────────────┤
|
||||
│ 省市区: [四川省 / 成都市 / 双流区 ▼] │
|
||||
├─────────────────────────────────────────────────┤
|
||||
│ 详细地址: │
|
||||
│ ┌─────────────────────────────────────────────┐ │
|
||||
│ │ 黄甲街道黄龙大道二段280号 │ │
|
||||
│ └─────────────────────────────────────────────┘ │
|
||||
└─────────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
**完整地址组合:**
|
||||
```
|
||||
四川省成都市双流区黄甲街道黄龙大道二段280号
|
||||
```
|
||||
|
||||
## 后端支持
|
||||
|
||||
### 数据库字段建议
|
||||
|
||||
如果需要单独存储省市区信息,可以添加以下字段:
|
||||
|
||||
```sql
|
||||
-- 在 prescription_order 表中添加省市区字段
|
||||
ALTER TABLE `zyt_prescription_order`
|
||||
ADD COLUMN `province` VARCHAR(50) DEFAULT '' COMMENT '省份' AFTER `recipient_phone`,
|
||||
ADD COLUMN `city` VARCHAR(50) DEFAULT '' COMMENT '城市' AFTER `province`,
|
||||
ADD COLUMN `district` VARCHAR(50) DEFAULT '' COMMENT '区/县' AFTER `city`;
|
||||
|
||||
-- 或者保持现有的 shipping_address 字段,前端组合完整地址
|
||||
```
|
||||
|
||||
### 后端 API 修改
|
||||
|
||||
#### 方案 A:前端组合完整地址(推荐)
|
||||
|
||||
前端在提交时将省市区和详细地址组合:
|
||||
|
||||
```typescript
|
||||
const submitOrder = async () => {
|
||||
const fullAddress = createOrderForm.region.join('') + createOrderForm.shipping_address
|
||||
|
||||
await createPrescriptionOrder({
|
||||
...createOrderForm,
|
||||
shipping_address: fullAddress // 四川省成都市双流区黄甲街道...
|
||||
})
|
||||
}
|
||||
```
|
||||
|
||||
#### 方案 B:分别存储省市区
|
||||
|
||||
后端接收独立的省市区字段:
|
||||
|
||||
```php
|
||||
// 创建订单时
|
||||
$order->province = $params['province'] ?? '';
|
||||
$order->city = $params['city'] ?? '';
|
||||
$order->district = $params['district'] ?? '';
|
||||
$order->shipping_address = $params['shipping_address'] ?? '';
|
||||
|
||||
// 查询时返回
|
||||
return [
|
||||
'province' => $order->province,
|
||||
'city' => $order->city,
|
||||
'district' => $order->district,
|
||||
'shipping_address' => $order->shipping_address,
|
||||
'full_address' => $order->province . $order->city . $order->district . $order->shipping_address
|
||||
];
|
||||
```
|
||||
|
||||
## 扩展功能
|
||||
|
||||
### 1. 使用完整的省市区数据
|
||||
|
||||
当前只包含了部分常用省市区,实际项目中应该使用完整的数据。
|
||||
|
||||
**方案 A:使用 npm 包**
|
||||
|
||||
```bash
|
||||
npm install element-china-area-data
|
||||
```
|
||||
|
||||
```typescript
|
||||
import { regionData } from 'element-china-area-data'
|
||||
|
||||
const regionOptions = ref(regionData)
|
||||
```
|
||||
|
||||
**方案 B:从后端 API 获取**
|
||||
|
||||
```typescript
|
||||
import { getRegionTree } from '@/api/common'
|
||||
|
||||
const regionOptions = ref([])
|
||||
|
||||
onMounted(async () => {
|
||||
const res = await getRegionTree()
|
||||
regionOptions.value = res.data
|
||||
})
|
||||
```
|
||||
|
||||
### 2. 自动填充历史地址
|
||||
|
||||
根据患者历史订单自动填充地址:
|
||||
|
||||
```typescript
|
||||
const loadPatientLastAddress = async (patientId: number) => {
|
||||
const res = await getPatientLastOrder(patientId)
|
||||
if (res.data) {
|
||||
createOrderForm.region = [res.data.province, res.data.city, res.data.district]
|
||||
createOrderForm.shipping_address = res.data.detail_address
|
||||
createOrderForm.recipient_name = res.data.recipient_name
|
||||
createOrderForm.recipient_phone = res.data.recipient_phone
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### 3. 地址簿功能
|
||||
|
||||
保存常用地址,快速选择:
|
||||
|
||||
```vue
|
||||
<el-select v-model="selectedAddressId" placeholder="选择常用地址" @change="fillAddress">
|
||||
<el-option
|
||||
v-for="addr in addressBook"
|
||||
:key="addr.id"
|
||||
:label="`${addr.name} ${addr.phone} ${addr.full_address}`"
|
||||
:value="addr.id"
|
||||
/>
|
||||
</el-select>
|
||||
```
|
||||
|
||||
### 4. 地址验证
|
||||
|
||||
验证地址的完整性和合法性:
|
||||
|
||||
```typescript
|
||||
const validateAddress = () => {
|
||||
if (createOrderForm.region.length !== 3) {
|
||||
feedback.msgError('请选择完整的省市区')
|
||||
return false
|
||||
}
|
||||
if (!createOrderForm.shipping_address.trim()) {
|
||||
feedback.msgError('请填写详细地址')
|
||||
return false
|
||||
}
|
||||
return true
|
||||
}
|
||||
```
|
||||
|
||||
## 注意事项
|
||||
|
||||
1. **数据格式**:
|
||||
- `region` 是数组:`['四川省', '成都市', '双流区']`
|
||||
- 提交时需要组合成完整地址字符串
|
||||
|
||||
2. **必填验证**:
|
||||
- 省市区选择器应该设置为必填
|
||||
- 详细地址也应该必填
|
||||
|
||||
3. **字符限制**:
|
||||
- 详细地址限制 200 字符
|
||||
- 建议省市区 + 详细地址总长度不超过 250 字符
|
||||
|
||||
4. **兼容性**:
|
||||
- 兼容现有的 `shipping_address` 字段
|
||||
- 旧数据可能没有省市区信息,需要做好兼容处理
|
||||
|
||||
5. **性能优化**:
|
||||
- 省市区数据较大时,考虑懒加载
|
||||
- 使用虚拟滚动优化长列表
|
||||
|
||||
## 表单验证规则
|
||||
|
||||
更新验证规则:
|
||||
|
||||
```typescript
|
||||
const createOrderRules: FormRules = {
|
||||
// ... 其他规则
|
||||
region: [
|
||||
{
|
||||
required: true,
|
||||
message: '请选择省市区',
|
||||
trigger: 'change',
|
||||
validator: (rule, value, callback) => {
|
||||
if (!value || value.length !== 3) {
|
||||
callback(new Error('请选择完整的省市区'))
|
||||
} else {
|
||||
callback()
|
||||
}
|
||||
}
|
||||
}
|
||||
],
|
||||
shipping_address: [
|
||||
{ required: true, message: '请输入详细地址', trigger: 'blur' },
|
||||
{ min: 5, message: '详细地址至少5个字符', trigger: 'blur' }
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
## 测试要点
|
||||
|
||||
### 前端测试
|
||||
|
||||
1. ✅ 省市区选择器显示正常
|
||||
2. ✅ 可以正常选择省市区
|
||||
3. ✅ 搜索功能正常
|
||||
4. ✅ 清空功能正常
|
||||
5. ✅ 必填验证正常
|
||||
6. ✅ 数据绑定正确
|
||||
|
||||
### 集成测试
|
||||
|
||||
1. ✅ 创建订单 → 选择省市区 → 填写详细地址 → 提交成功
|
||||
2. ✅ 查看订单 → 地址显示完整
|
||||
3. ✅ 编辑订单 → 省市区回显正确
|
||||
4. ✅ 打印订单 → 地址格式正确
|
||||
|
||||
## 完成状态
|
||||
|
||||
- ✅ 前端界面添加完成
|
||||
- ✅ 数据模型更新完成
|
||||
- ✅ 省市区数据添加完成(简化版)
|
||||
- ✅ 事件处理方法添加完成
|
||||
- ⏳ 等待完整省市区数据集成
|
||||
- ⏳ 等待后端 API 支持
|
||||
- ⏳ 等待表单验证规则更新
|
||||
|
||||
## 相关文件
|
||||
|
||||
- `admin/src/views/consumer/prescription/index.vue` - 处方订单列表页面
|
||||
- 后端需要修改的文件(待确认):
|
||||
- 订单模型文件
|
||||
- 订单控制器文件
|
||||
- 数据库迁移文件(如果需要单独存储省市区)
|
||||
@@ -0,0 +1,226 @@
|
||||
# 撤回处方审核功能修复
|
||||
|
||||
## 问题描述
|
||||
撤回处方审核时,只更新了业务订单表(`zyt_tcm_prescription_order`)的 `prescription_audit_status`,但没有同步更新处方表(`zyt_tcm_prescription`)的 `audit_status`,导致处方表的审核状态不一致。
|
||||
|
||||
## 影响
|
||||
- 业务订单显示"待审核",但处方表仍显示"已通过"
|
||||
- 处方列表页面显示的审核状态与实际不符
|
||||
- 可能导致业务流程混乱
|
||||
|
||||
## 修复方案
|
||||
|
||||
### 文件:`server/app/adminapi/logic/tcm/PrescriptionOrderLogic.php`
|
||||
|
||||
在 `revokeRxAudit()` 方法中添加同步处方表审核状态的逻辑:
|
||||
|
||||
```php
|
||||
// 同步撤回处方表的审核状态
|
||||
$prescriptionId = (int) $order->prescription_id;
|
||||
if ($prescriptionId > 0) {
|
||||
try {
|
||||
$prescription = Prescription::where('id', $prescriptionId)->whereNull('delete_time')->find();
|
||||
if ($prescription) {
|
||||
$prescription->audit_status = 0;
|
||||
$prescription->audit_remark = '';
|
||||
$prescription->audit_time = null;
|
||||
$prescription->audit_by = null;
|
||||
$prescription->audit_by_name = '';
|
||||
$prescription->save();
|
||||
}
|
||||
} catch (\Throwable $e) {
|
||||
// 记录日志但不影响主流程
|
||||
Log::error('撤回处方审核时同步处方表失败: ' . $e->getMessage(), [
|
||||
'prescription_id' => $prescriptionId,
|
||||
'order_id' => $id
|
||||
]);
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## 修复内容
|
||||
|
||||
### 1. 业务订单表更新(原有逻辑)
|
||||
**表**: `zyt_tcm_prescription_order`
|
||||
|
||||
```php
|
||||
$order->prescription_audit_status = 0; // 重置为待审核
|
||||
$order->prescription_audit_remark = ''; // 清空审核意见
|
||||
```
|
||||
|
||||
### 2. 处方表同步更新(新增逻辑)
|
||||
**表**: `zyt_tcm_prescription`
|
||||
|
||||
```php
|
||||
$prescription->audit_status = 0; // 重置为待审核
|
||||
$prescription->audit_remark = ''; // 清空审核意见
|
||||
$prescription->audit_time = null; // 清空审核时间
|
||||
$prescription->audit_by = null; // 清空审核人ID
|
||||
$prescription->audit_by_name = ''; // 清空审核人姓名
|
||||
```
|
||||
|
||||
## 数据流转
|
||||
|
||||
### 撤回前
|
||||
```
|
||||
业务订单表 (zyt_tcm_prescription_order):
|
||||
prescription_audit_status: 1 (已通过)
|
||||
prescription_audit_remark: "审核通过"
|
||||
|
||||
处方表 (zyt_tcm_prescription):
|
||||
audit_status: 1 (已通过)
|
||||
audit_remark: "审核通过"
|
||||
audit_time: 1713168000
|
||||
audit_by: 5
|
||||
audit_by_name: "张医生"
|
||||
```
|
||||
|
||||
### 撤回后
|
||||
```
|
||||
业务订单表 (zyt_tcm_prescription_order):
|
||||
prescription_audit_status: 0 (待审核)
|
||||
prescription_audit_remark: ""
|
||||
|
||||
处方表 (zyt_tcm_prescription):
|
||||
audit_status: 0 (待审核)
|
||||
audit_remark: ""
|
||||
audit_time: null
|
||||
audit_by: null
|
||||
audit_by_name: ""
|
||||
```
|
||||
|
||||
## 审核状态说明
|
||||
|
||||
| 状态值 | 说明 |
|
||||
|--------|------|
|
||||
| 0 | 待审核 |
|
||||
| 1 | 已通过 |
|
||||
| 2 | 已驳回 |
|
||||
|
||||
## 业务流程
|
||||
|
||||
```
|
||||
创建订单
|
||||
↓
|
||||
处方待审核 (audit_status = 0)
|
||||
↓ 审核通过
|
||||
处方已通过 (audit_status = 1)
|
||||
↓ 撤回审核
|
||||
处方待审核 (audit_status = 0) ← 两个表都重置
|
||||
↓ 重新审核
|
||||
处方已通过 (audit_status = 1)
|
||||
```
|
||||
|
||||
## 错误处理
|
||||
|
||||
### 容错设计
|
||||
```php
|
||||
try {
|
||||
// 更新处方表
|
||||
$prescription->save();
|
||||
} catch (\Throwable $e) {
|
||||
// 记录日志但不影响主流程
|
||||
Log::error('撤回处方审核时同步处方表失败: ' . $e->getMessage());
|
||||
}
|
||||
```
|
||||
|
||||
**说明**:
|
||||
- 即使处方表更新失败,业务订单表的撤回仍然成功
|
||||
- 错误会被记录到日志中,便于排查
|
||||
- 不会因为处方表问题导致整个撤回操作失败
|
||||
|
||||
## 相关表结构
|
||||
|
||||
### 业务订单表 (zyt_tcm_prescription_order)
|
||||
```sql
|
||||
prescription_id int(11) unsigned NOT NULL DEFAULT 0 COMMENT '消费者处方ID'
|
||||
prescription_audit_status tinyint(2) unsigned NOT NULL DEFAULT 0 COMMENT '处方审核 0待审1通过2驳回'
|
||||
prescription_audit_remark varchar(500) NOT NULL DEFAULT '' COMMENT '处方审核意见'
|
||||
```
|
||||
|
||||
### 处方表 (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_by_name varchar(50) NOT NULL DEFAULT '' COMMENT '审核人姓名'
|
||||
audit_remark varchar(500) NOT NULL DEFAULT '' COMMENT '审核意见'
|
||||
```
|
||||
|
||||
## 测试清单
|
||||
|
||||
### 正常流程测试
|
||||
- [ ] 创建订单,处方待审核
|
||||
- [ ] 审核通过处方
|
||||
- [ ] 验证业务订单表和处方表状态都为"已通过"
|
||||
- [ ] 撤回处方审核
|
||||
- [ ] 验证业务订单表和处方表状态都为"待审核"
|
||||
- [ ] 验证处方表的审核时间、审核人等字段已清空
|
||||
|
||||
### 边界情况测试
|
||||
- [ ] 撤回时处方不存在(已删除)
|
||||
- [ ] 撤回时处方ID为0
|
||||
- [ ] 撤回时数据库连接失败
|
||||
- [ ] 验证错误被正确记录到日志
|
||||
|
||||
### 权限测试
|
||||
- [ ] 无审核权限的用户不能撤回
|
||||
- [ ] 有审核权限的用户可以撤回
|
||||
- [ ] 订单已结束时不能撤回
|
||||
|
||||
## 日志记录
|
||||
|
||||
### 成功日志
|
||||
```
|
||||
操作日志 (zyt_tcm_prescription_order_log):
|
||||
action: "revoke_rx_audit"
|
||||
summary: "撤回处方审核,状态重置为待审核"
|
||||
```
|
||||
|
||||
### 错误日志
|
||||
```
|
||||
系统日志:
|
||||
level: ERROR
|
||||
message: "撤回处方审核时同步处方表失败: [错误信息]"
|
||||
context: {
|
||||
"prescription_id": 123,
|
||||
"order_id": 456
|
||||
}
|
||||
```
|
||||
|
||||
## 相关功能
|
||||
|
||||
### 1. 审核处方
|
||||
**方法**: `PrescriptionOrderLogic::auditPrescription()`
|
||||
- 同时更新业务订单表和处方表的审核状态
|
||||
|
||||
### 2. 撤回处方审核
|
||||
**方法**: `PrescriptionOrderLogic::revokeRxAudit()`
|
||||
- 同时重置业务订单表和处方表的审核状态(本次修复)
|
||||
|
||||
### 3. 撤回支付单审核
|
||||
**方法**: `PrescriptionOrderLogic::revokePayAudit()`
|
||||
- 只更新业务订单表的支付审核状态
|
||||
|
||||
## 注意事项
|
||||
|
||||
1. **数据一致性**:确保两个表的审核状态始终保持一致
|
||||
2. **容错处理**:处方表更新失败不影响主流程
|
||||
3. **日志记录**:所有错误都会被记录,便于排查
|
||||
4. **权限控制**:只有有审核权限的用户才能撤回
|
||||
5. **状态限制**:订单已结束时不能撤回
|
||||
|
||||
## 相关文件
|
||||
|
||||
- `server/app/adminapi/logic/tcm/PrescriptionOrderLogic.php` - 业务逻辑
|
||||
- `server/app/adminapi/controller/tcm/PrescriptionOrderController.php` - 控制器
|
||||
- `admin/src/views/consumer/prescription/order_list.vue` - 前端页面
|
||||
|
||||
## 总结
|
||||
|
||||
✅ **修复前**:撤回审核只更新业务订单表
|
||||
✅ **修复后**:撤回审核同时更新业务订单表和处方表
|
||||
✅ **容错处理**:处方表更新失败不影响主流程
|
||||
✅ **日志记录**:错误会被记录到系统日志
|
||||
|
||||
这样可以确保数据一致性,避免审核状态不同步的问题。
|
||||
@@ -0,0 +1,179 @@
|
||||
# 收货地址省市区功能实现
|
||||
|
||||
## 概述
|
||||
为处方业务订单编辑表单添加省/市/区选择器,将地址信息结构化存储。
|
||||
|
||||
## 数据库变更
|
||||
|
||||
### 新增字段
|
||||
在 `zyt_tcm_prescription_order` 表中添加三个字段:
|
||||
|
||||
```sql
|
||||
ALTER TABLE `zyt_tcm_prescription_order`
|
||||
ADD COLUMN `shipping_province` varchar(50) NOT NULL DEFAULT '' COMMENT '收货省份' AFTER `recipient_phone`,
|
||||
ADD COLUMN `shipping_city` varchar(50) NOT NULL DEFAULT '' COMMENT '收货城市' AFTER `shipping_province`,
|
||||
ADD COLUMN `shipping_district` varchar(50) NOT NULL DEFAULT '' COMMENT '收货区县' AFTER `shipping_city`;
|
||||
```
|
||||
|
||||
**执行方式:**
|
||||
```bash
|
||||
mysql -u用户名 -p数据库名 < add_shipping_region_fields.sql
|
||||
```
|
||||
|
||||
## 后端变更
|
||||
|
||||
### 文件:`server/app/adminapi/logic/tcm/PrescriptionOrderLogic.php`
|
||||
|
||||
在 `edit()` 方法中添加省市区字段处理:
|
||||
|
||||
```php
|
||||
// 处理省市区字段
|
||||
if (isset($params['shipping_province'])) {
|
||||
$order->shipping_province = (string) $params['shipping_province'];
|
||||
}
|
||||
if (isset($params['shipping_city'])) {
|
||||
$order->shipping_city = (string) $params['shipping_city'];
|
||||
}
|
||||
if (isset($params['shipping_district'])) {
|
||||
$order->shipping_district = (string) $params['shipping_district'];
|
||||
}
|
||||
```
|
||||
|
||||
## 前端变更
|
||||
|
||||
### 文件:`admin/src/views/consumer/prescription/order_list.vue`
|
||||
|
||||
#### 1. 数据结构
|
||||
在 `editForm` 中添加 `region` 字段:
|
||||
```typescript
|
||||
const editForm = reactive({
|
||||
// ... 其他字段
|
||||
region: [] as string[], // 新增
|
||||
shipping_address: '',
|
||||
// ...
|
||||
})
|
||||
```
|
||||
|
||||
#### 2. UI 组件
|
||||
在收货手机下方添加省市区选择器:
|
||||
```vue
|
||||
<el-form-item label="省/市/区" prop="region">
|
||||
<el-cascader
|
||||
v-model="editForm.region"
|
||||
:options="regionOptions"
|
||||
placeholder="请选择省/市/区"
|
||||
clearable
|
||||
class="w-full"
|
||||
/>
|
||||
</el-form-item>
|
||||
```
|
||||
|
||||
#### 3. 数据提交
|
||||
在 `submitEdit()` 中将 region 数组拆分为三个字段:
|
||||
```typescript
|
||||
if (editForm.region && editForm.region.length > 0) {
|
||||
payload.shipping_province = editForm.region[0] || ''
|
||||
payload.shipping_city = editForm.region[1] || ''
|
||||
payload.shipping_district = editForm.region[2] || ''
|
||||
} else {
|
||||
payload.shipping_province = ''
|
||||
payload.shipping_city = ''
|
||||
payload.shipping_district = ''
|
||||
}
|
||||
```
|
||||
|
||||
#### 4. 数据加载
|
||||
在 `openEdit()` 中从后端数据填充 region:
|
||||
```typescript
|
||||
const province = d.shipping_province || ''
|
||||
const city = d.shipping_city || ''
|
||||
const district = d.shipping_district || ''
|
||||
if (province || city || district) {
|
||||
editForm.region = [province, city, district].filter(v => v !== '')
|
||||
} else {
|
||||
editForm.region = []
|
||||
}
|
||||
```
|
||||
|
||||
## 省市区数据
|
||||
|
||||
当前使用简化的示例数据,包含:
|
||||
- 四川省(成都市、绵阳市)
|
||||
- 北京市
|
||||
- 上海市
|
||||
- 广东省(广州市、深圳市)
|
||||
|
||||
### 生产环境建议
|
||||
|
||||
**方案 1:使用完整数据集**
|
||||
- 集成中国行政区划完整数据(约 3000+ 区县)
|
||||
- 推荐数据源:
|
||||
- [element-china-area-data](https://www.npmjs.com/package/element-china-area-data)
|
||||
- [china-division](https://github.com/modood/Administrative-divisions-of-China)
|
||||
|
||||
**方案 2:使用 API 服务**
|
||||
- 调用第三方地址 API(如高德地图、腾讯地图)
|
||||
- 优点:数据实时更新,减少前端体积
|
||||
- 缺点:依赖网络,需要 API Key
|
||||
|
||||
## 使用流程
|
||||
|
||||
1. **执行数据库迁移**
|
||||
```bash
|
||||
mysql -u用户名 -p数据库名 < add_shipping_region_fields.sql
|
||||
```
|
||||
|
||||
2. **重启后端服务**(如需要)
|
||||
|
||||
3. **测试功能**
|
||||
- 打开处方业务订单列表
|
||||
- 点击"编辑"按钮
|
||||
- 选择省/市/区
|
||||
- 填写详细地址
|
||||
- 保存并验证数据已正确存储
|
||||
|
||||
## 数据结构
|
||||
|
||||
### 前端存储格式
|
||||
```typescript
|
||||
region: ['四川省', '成都市', '武侯区']
|
||||
```
|
||||
|
||||
### 后端存储格式
|
||||
```php
|
||||
[
|
||||
'shipping_province' => '四川省',
|
||||
'shipping_city' => '成都市',
|
||||
'shipping_district' => '武侯区',
|
||||
'shipping_address' => '天府大道中段123号' // 详细地址
|
||||
]
|
||||
```
|
||||
|
||||
### 数据库存储
|
||||
```
|
||||
shipping_province: 四川省
|
||||
shipping_city: 成都市
|
||||
shipping_district: 武侯区
|
||||
shipping_address: 天府大道中段123号
|
||||
```
|
||||
|
||||
## 注意事项
|
||||
|
||||
1. **向后兼容**:旧数据的省市区字段为空字符串,不影响现有功能
|
||||
2. **可选字段**:省市区选择器可清空,允许用户只填写详细地址
|
||||
3. **数据验证**:前端使用 cascader 确保选择的层级关系正确
|
||||
4. **字段长度**:省市区字段各限制 50 字符
|
||||
|
||||
## 相关文件
|
||||
|
||||
- `add_shipping_region_fields.sql` - 数据库迁移文件
|
||||
- `admin/src/views/consumer/prescription/order_list.vue` - 前端订单列表页面
|
||||
- `server/app/adminapi/logic/tcm/PrescriptionOrderLogic.php` - 后端业务逻辑
|
||||
|
||||
## 后续优化建议
|
||||
|
||||
1. 在订单详情页面也显示省市区信息
|
||||
2. 在订单列表的收货人列中显示省市信息
|
||||
3. 添加按省市区筛选订单的功能
|
||||
4. 集成完整的中国行政区划数据
|
||||
5. 考虑添加邮政编码字段
|
||||
@@ -0,0 +1,13 @@
|
||||
-- 添加剂量单位和剂数字段到处方业务订单表
|
||||
-- 用于记录订单的剂量单位和剂数信息
|
||||
|
||||
ALTER TABLE `zyt_tcm_prescription_order`
|
||||
ADD COLUMN `dose_unit` varchar(20) NOT NULL DEFAULT '剂' COMMENT '剂量单位:剂、丸、袋、盒、瓶、膏、贴' AFTER `medication_days`,
|
||||
ADD COLUMN `dose_count` int(11) NOT NULL DEFAULT 1 COMMENT '剂数' AFTER `dose_unit`;
|
||||
|
||||
-- 验证字段是否添加成功
|
||||
SELECT COLUMN_NAME, DATA_TYPE, COLUMN_DEFAULT, COLUMN_COMMENT
|
||||
FROM information_schema.COLUMNS
|
||||
WHERE TABLE_SCHEMA = DATABASE()
|
||||
AND TABLE_NAME = 'zyt_tcm_prescription_order'
|
||||
AND COLUMN_NAME IN ('dose_unit', 'dose_count');
|
||||
@@ -0,0 +1,12 @@
|
||||
-- 添加甘草订单回调相关字段
|
||||
-- 在 zyt_prescription_order 表中添加
|
||||
|
||||
ALTER TABLE `zyt_prescription_order`
|
||||
ADD COLUMN `gancao_order_state` INT(11) DEFAULT 0 COMMENT '甘草订单状态:10=审核中,11=审核通过,110=制作中,20=物流中,30=完成,90=拦截,91=撤单,92=驳回' AFTER `gancao_submit_time`,
|
||||
ADD COLUMN `gancao_flow_name` VARCHAR(100) DEFAULT '' COMMENT '甘草订单流程名称(如:派单、审方、调配、复核、浸泡、煎药、包装、发货等)' AFTER `gancao_order_state`,
|
||||
ADD COLUMN `gancao_supplier` VARCHAR(100) DEFAULT '' COMMENT '甘草供应商/药房名称' AFTER `gancao_flow_name`,
|
||||
ADD COLUMN `gancao_remark` VARCHAR(500) DEFAULT '' COMMENT '甘草订单备注(拦截原因、驳回原因等)' AFTER `gancao_supplier`;
|
||||
|
||||
-- 添加索引
|
||||
ALTER TABLE `zyt_prescription_order`
|
||||
ADD INDEX `idx_gancao_order_state` (`gancao_order_state`);
|
||||
@@ -0,0 +1,20 @@
|
||||
-- 添加处方完整字段(用量、代煎、每天几次)
|
||||
-- 执行前请先备份数据库
|
||||
|
||||
-- 1. 添加用量相关字段(如果不存在)
|
||||
ALTER TABLE `zyt_tcm_prescription`
|
||||
ADD COLUMN IF NOT EXISTS `dosage_amount` decimal(10,2) DEFAULT NULL COMMENT '用量数值' AFTER `prescription_type`,
|
||||
ADD COLUMN IF NOT EXISTS `dosage_unit` varchar(10) NOT NULL DEFAULT '' COMMENT '用量单位(g/ml)' AFTER `dosage_amount`,
|
||||
ADD COLUMN IF NOT EXISTS `need_decoction` tinyint(1) NOT NULL DEFAULT 0 COMMENT '是否代煎(0否1是,仅饮片)' AFTER `dosage_unit`;
|
||||
|
||||
-- 2. 添加每天几次字段(如果不存在)
|
||||
ALTER TABLE `zyt_tcm_prescription`
|
||||
ADD COLUMN IF NOT EXISTS `times_per_day` int(11) NOT NULL DEFAULT 2 COMMENT '每天服用次数' AFTER `usage_days`;
|
||||
|
||||
-- 验证字段是否添加成功
|
||||
SELECT COLUMN_NAME, DATA_TYPE, COLUMN_DEFAULT, COLUMN_COMMENT
|
||||
FROM information_schema.COLUMNS
|
||||
WHERE TABLE_SCHEMA = DATABASE()
|
||||
AND TABLE_NAME = 'zyt_tcm_prescription'
|
||||
AND COLUMN_NAME IN ('dosage_amount', 'dosage_unit', 'need_decoction', 'times_per_day')
|
||||
ORDER BY ORDINAL_POSITION;
|
||||
@@ -0,0 +1,15 @@
|
||||
-- 添加处方用量相关字段
|
||||
-- 用于存储处方类型对应的用量、单位和代煎信息
|
||||
|
||||
ALTER TABLE `zyt_tcm_prescription`
|
||||
ADD COLUMN `dosage_amount` decimal(10,2) DEFAULT NULL COMMENT '用量数值' AFTER `prescription_type`,
|
||||
ADD COLUMN `dosage_unit` varchar(10) NOT NULL DEFAULT '' COMMENT '用量单位(g/ml)' AFTER `dosage_amount`,
|
||||
ADD COLUMN `need_decoction` tinyint(1) NOT NULL DEFAULT 0 COMMENT '是否代煎(0否1是,仅饮片)' AFTER `dosage_unit`;
|
||||
|
||||
-- 验证字段是否添加成功
|
||||
SELECT COLUMN_NAME, DATA_TYPE, COLUMN_DEFAULT, COLUMN_COMMENT
|
||||
FROM information_schema.COLUMNS
|
||||
WHERE TABLE_SCHEMA = DATABASE()
|
||||
AND TABLE_NAME = 'zyt_tcm_prescription'
|
||||
AND COLUMN_NAME IN ('dosage_amount', 'dosage_unit', 'need_decoction')
|
||||
ORDER BY ORDINAL_POSITION;
|
||||
@@ -0,0 +1,5 @@
|
||||
-- 添加省市区字段到处方业务订单表
|
||||
ALTER TABLE `zyt_tcm_prescription_order`
|
||||
ADD COLUMN `shipping_province` varchar(50) NOT NULL DEFAULT '' COMMENT '收货省份' AFTER `recipient_phone`,
|
||||
ADD COLUMN `shipping_city` varchar(50) NOT NULL DEFAULT '' COMMENT '收货城市' AFTER `shipping_province`,
|
||||
ADD COLUMN `shipping_district` varchar(50) NOT NULL DEFAULT '' COMMENT '收货区县' AFTER `shipping_city`;
|
||||
@@ -326,6 +326,16 @@ export function prescriptionOrderComplete(params: { id: number }) {
|
||||
return request.post({ url: '/tcm.prescriptionOrder/complete', params })
|
||||
}
|
||||
|
||||
/** 甘草药管家:中药处方下单(服务端 CTM_PREVIEW → CTM_SUBMIT_RECIPEL) */
|
||||
export function prescriptionOrderSubmitGancaoRecipel(params: { id: number }) {
|
||||
return request.post({ url: '/tcm.prescriptionOrder/submitGancaoRecipel', params })
|
||||
}
|
||||
|
||||
/** 甘草药管家:预下单测试(仅 CTM_PREVIEW,不提交订单) */
|
||||
export function prescriptionOrderPreviewGancaoRecipel(params: { id: number; dose_count?: number; medication_days?: number }) {
|
||||
return request.post({ url: '/tcm.prescriptionOrder/previewGancaoRecipel', params })
|
||||
}
|
||||
|
||||
/** 订单操作日志 */
|
||||
export function prescriptionOrderLogs(params: { id: number }) {
|
||||
return request.get({ url: '/tcm.prescriptionOrder/logs', params })
|
||||
|
||||
@@ -135,11 +135,72 @@
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
</el-col>
|
||||
|
||||
<!-- 用量字段 -->
|
||||
<el-col :span="12">
|
||||
<el-form-item label="用量" prop="dosage_amount">
|
||||
<!-- 浓缩水丸:1-10g 下拉选择 -->
|
||||
<el-select
|
||||
v-if="formData.prescription_type === '浓缩水丸'"
|
||||
v-model="formData.dosage_amount"
|
||||
placeholder="请选择用量"
|
||||
class="!w-32"
|
||||
>
|
||||
<el-option :label="1 + 'g'" :value="1" />
|
||||
<el-option :label="2 + 'g'" :value="2" />
|
||||
<el-option :label="3 + 'g'" :value="3" />
|
||||
<el-option :label="4 + 'g'" :value="4" />
|
||||
<el-option :label="5 + 'g'" :value="5" />
|
||||
<el-option :label="6 + 'g'" :value="6" />
|
||||
<el-option :label="7 + 'g'" :value="7" />
|
||||
<el-option :label="8 + 'g'" :value="8" />
|
||||
<el-option :label="9 + 'g'" :value="9" />
|
||||
<el-option :label="10 + 'g'" :value="10" />
|
||||
</el-select>
|
||||
<!-- 饮片:50-250ml 下拉选择,步进50 -->
|
||||
<el-select
|
||||
v-else-if="formData.prescription_type === '饮片'"
|
||||
v-model="formData.dosage_amount"
|
||||
placeholder="请选择用量"
|
||||
class="!w-32"
|
||||
>
|
||||
<el-option label="50ml" :value="50" />
|
||||
<el-option label="100ml" :value="100" />
|
||||
<el-option label="150ml" :value="150" />
|
||||
<el-option label="200ml" :value="200" />
|
||||
<el-option label="250ml" :value="250" />
|
||||
</el-select>
|
||||
<!-- 其他类型:自由输入 -->
|
||||
<el-input-number
|
||||
v-else
|
||||
v-model="formData.dosage_amount"
|
||||
:min="0"
|
||||
:precision="2"
|
||||
class="!w-32"
|
||||
/>
|
||||
<span v-if="formData.prescription_type !== '浓缩水丸' && formData.prescription_type !== '饮片'" class="ml-2">{{ formData.dosage_unit }}</span>
|
||||
</el-form-item>
|
||||
</el-col>
|
||||
|
||||
<!-- 饮片代煎选项 -->
|
||||
<el-col v-if="formData.prescription_type === '饮片'" :span="12">
|
||||
<el-form-item label="是否代煎" prop="need_decoction">
|
||||
<el-radio-group v-model="formData.need_decoction">
|
||||
<el-radio :label="true">代煎</el-radio>
|
||||
<el-radio :label="false">不代煎</el-radio>
|
||||
</el-radio-group>
|
||||
</el-form-item>
|
||||
</el-col>
|
||||
<el-col :span="8">
|
||||
<el-form-item label="服用天数" prop="usage_days">
|
||||
<el-input-number v-model="formData.usage_days" :min="1" :max="365" class="!w-full" />
|
||||
</el-form-item>
|
||||
</el-col>
|
||||
<el-col :span="8">
|
||||
<el-form-item label="每天几次" prop="times_per_day">
|
||||
<el-input-number v-model="formData.times_per_day" :min="1" :max="10" class="!w-full" placeholder="每天服用次数" />
|
||||
</el-form-item>
|
||||
</el-col>
|
||||
<!-- <el-col :span="8">
|
||||
<el-form-item label="剂数" prop="dose_count">
|
||||
<el-input-number v-model="formData.dose_count" :min="1" :max="999" class="!w-full" />
|
||||
@@ -319,7 +380,7 @@
|
||||
<div class="prescription-footer">
|
||||
<div class="footer-left">
|
||||
<div class="footer-dosage">
|
||||
服用{{ savedPrescription.usage_days || savedPrescription.dose_count }}天, {{ savedPrescription.prescription_type }}
|
||||
服用{{ savedPrescription.usage_days || savedPrescription.dose_count }}天{{ savedPrescription.times_per_day ? ',每天' + savedPrescription.times_per_day + '次' : '' }}, {{ savedPrescription.prescription_type }}
|
||||
{{ savedPrescription.usage_instruction }}
|
||||
|
||||
</div>
|
||||
@@ -577,6 +638,9 @@ interface PrescriptionForm {
|
||||
appointment_id: number
|
||||
prescription_name: string
|
||||
prescription_type: string
|
||||
dosage_amount: number | undefined // 用量数值
|
||||
dosage_unit: string // 用量单位 (g 或 ml)
|
||||
need_decoction: boolean // 是否代煎(仅饮片)
|
||||
patient_name: string
|
||||
gender: number
|
||||
gender_desc: string
|
||||
@@ -594,6 +658,7 @@ interface PrescriptionForm {
|
||||
dose_count: number
|
||||
dose_unit: string
|
||||
usage_days: number
|
||||
times_per_day: number
|
||||
usage_instruction: string
|
||||
usage_time: string
|
||||
usage_way: string
|
||||
@@ -653,6 +718,9 @@ const formData = reactive<PrescriptionForm>({
|
||||
appointment_id: 0,
|
||||
prescription_name: '',
|
||||
prescription_type: '浓缩水丸',
|
||||
dosage_amount: 1,
|
||||
dosage_unit: 'g',
|
||||
need_decoction: false,
|
||||
patient_name: '',
|
||||
gender: 0,
|
||||
gender_desc: '女',
|
||||
@@ -670,6 +738,7 @@ const formData = reactive<PrescriptionForm>({
|
||||
dose_count: 1,
|
||||
dose_unit: '剂',
|
||||
usage_days: 7,
|
||||
times_per_day: 2,
|
||||
usage_instruction: '水煎服,一日二次',
|
||||
usage_time: '饭前',
|
||||
usage_way: '温水送服',
|
||||
@@ -1054,6 +1123,23 @@ watch(showLibraryDialog, (newVal) => {
|
||||
}
|
||||
})
|
||||
|
||||
// 监听处方类型变化,自动调整用量单位和默认值
|
||||
watch(() => formData.prescription_type, (newType) => {
|
||||
if (newType === '浓缩水丸') {
|
||||
formData.dosage_unit = 'g'
|
||||
formData.dosage_amount = 1
|
||||
formData.need_decoction = false
|
||||
} else if (newType === '饮片') {
|
||||
formData.dosage_unit = 'ml'
|
||||
formData.dosage_amount = 50
|
||||
// need_decoction 保持用户选择
|
||||
} else {
|
||||
formData.dosage_unit = 'g'
|
||||
formData.dosage_amount = undefined
|
||||
formData.need_decoction = false
|
||||
}
|
||||
})
|
||||
|
||||
const handleSave = async () => {
|
||||
if (!formData.clinical_diagnosis?.trim()) {
|
||||
feedback.msgWarning('请输入临床诊断')
|
||||
@@ -1101,7 +1187,11 @@ const handleSave = async () => {
|
||||
herbs: formData.herbs,
|
||||
dose_count: formData.dose_count,
|
||||
dose_unit: formData.dose_unit,
|
||||
dosage_amount: formData.dosage_amount,
|
||||
dosage_unit: formData.dosage_unit,
|
||||
need_decoction: formData.need_decoction,
|
||||
usage_days: formData.usage_days,
|
||||
times_per_day: formData.times_per_day,
|
||||
usage_instruction: formData.usage_instruction,
|
||||
usage_time: formData.usage_time,
|
||||
usage_way: formData.usage_way,
|
||||
|
||||
@@ -576,6 +576,66 @@
|
||||
<el-option label="汤剂" value="汤剂" />
|
||||
</el-select>
|
||||
</el-form-item> </el-col>
|
||||
|
||||
<!-- 用量字段 -->
|
||||
<el-col :span="8">
|
||||
<el-form-item label="用量" prop="dosage_amount">
|
||||
<!-- 浓缩水丸:1-10g 下拉选择 -->
|
||||
<el-select
|
||||
v-if="editForm.prescription_type === '浓缩水丸'"
|
||||
v-model="editForm.dosage_amount"
|
||||
placeholder="请选择用量"
|
||||
class="w-full"
|
||||
>
|
||||
<el-option :label="1 + 'g'" :value="1" />
|
||||
<el-option :label="2 + 'g'" :value="2" />
|
||||
<el-option :label="3 + 'g'" :value="3" />
|
||||
<el-option :label="4 + 'g'" :value="4" />
|
||||
<el-option :label="5 + 'g'" :value="5" />
|
||||
<el-option :label="6 + 'g'" :value="6" />
|
||||
<el-option :label="7 + 'g'" :value="7" />
|
||||
<el-option :label="8 + 'g'" :value="8" />
|
||||
<el-option :label="9 + 'g'" :value="9" />
|
||||
<el-option :label="10 + 'g'" :value="10" />
|
||||
</el-select>
|
||||
<!-- 饮片:50-250ml 下拉选择,步进50 -->
|
||||
<el-select
|
||||
v-else-if="editForm.prescription_type === '饮片'"
|
||||
v-model="editForm.dosage_amount"
|
||||
placeholder="请选择用量"
|
||||
class="w-full"
|
||||
>
|
||||
<el-option label="50ml" :value="50" />
|
||||
<el-option label="100ml" :value="100" />
|
||||
<el-option label="150ml" :value="150" />
|
||||
<el-option label="200ml" :value="200" />
|
||||
<el-option label="250ml" :value="250" />
|
||||
</el-select>
|
||||
<!-- 其他类型:自由输入 -->
|
||||
<el-input-number
|
||||
v-else
|
||||
v-model="editForm.dosage_amount"
|
||||
:min="0"
|
||||
:precision="2"
|
||||
class="w-full"
|
||||
/>
|
||||
</el-form-item>
|
||||
</el-col>
|
||||
|
||||
<!-- 饮片代煎选项 -->
|
||||
<el-col v-if="editForm.prescription_type === '饮片'" :span="8">
|
||||
<el-form-item label="是否代煎" prop="need_decoction">
|
||||
<el-radio-group v-model="editForm.need_decoction">
|
||||
<el-radio :label="true">代煎</el-radio>
|
||||
<el-radio :label="false">不代煎</el-radio>
|
||||
</el-radio-group>
|
||||
</el-form-item>
|
||||
</el-col>
|
||||
<el-col :span="8">
|
||||
<el-form-item label="每天几次" prop="times_per_day">
|
||||
<el-input-number v-model="editForm.times_per_day" :min="1" :max="10" class="!w-full" placeholder="每天服用次数" />
|
||||
</el-form-item>
|
||||
</el-col>
|
||||
<el-col :span="8">
|
||||
<el-form-item label="服用天数" prop="usage_days">
|
||||
<el-input-number
|
||||
@@ -795,12 +855,26 @@
|
||||
</el-form-item>
|
||||
</el-col>
|
||||
<el-col :span="24">
|
||||
<el-form-item label="收货地址" prop="shipping_address">
|
||||
<el-form-item label="省市区" prop="region">
|
||||
<el-cascader
|
||||
v-model="createOrderForm.region"
|
||||
:options="regionOptions"
|
||||
:props="{ value: 'name', label: 'name', children: 'children', emitPath: false, checkStrictly: false }"
|
||||
placeholder="请选择省/市/区"
|
||||
clearable
|
||||
filterable
|
||||
class="w-full"
|
||||
@change="handleRegionChange"
|
||||
/>
|
||||
</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="完整收货地址"
|
||||
placeholder="请输入街道、门牌号等详细地址"
|
||||
maxlength="200"
|
||||
show-word-limit
|
||||
/>
|
||||
@@ -1176,6 +1250,7 @@ const createOrderForm = reactive({
|
||||
patient_id: '' as number | string,
|
||||
recipient_name: '',
|
||||
recipient_phone: '',
|
||||
region: [] as string[],
|
||||
shipping_address: '',
|
||||
is_follow_up: 0,
|
||||
medication_days: undefined as number | undefined,
|
||||
@@ -1191,6 +1266,146 @@ const createOrderForm = reactive({
|
||||
pay_order_ids: [] as number[]
|
||||
})
|
||||
|
||||
// 省市区数据(简化版,实际项目中应该从 API 获取或使用完整的省市区数据)
|
||||
const regionOptions = ref([
|
||||
{
|
||||
name: '四川省',
|
||||
children: [
|
||||
{
|
||||
name: '成都市',
|
||||
children: [
|
||||
{ name: '锦江区' },
|
||||
{ name: '青羊区' },
|
||||
{ name: '金牛区' },
|
||||
{ name: '武侯区' },
|
||||
{ name: '成华区' },
|
||||
{ name: '龙泉驿区' },
|
||||
{ name: '青白江区' },
|
||||
{ name: '新都区' },
|
||||
{ name: '温江区' },
|
||||
{ name: '双流区' },
|
||||
{ name: '郫都区' },
|
||||
{ name: '新津区' },
|
||||
{ name: '都江堰市' },
|
||||
{ name: '彭州市' },
|
||||
{ name: '邛崃市' },
|
||||
{ name: '崇州市' },
|
||||
{ name: '简阳市' },
|
||||
{ name: '金堂县' },
|
||||
{ name: '大邑县' },
|
||||
{ name: '蒲江县' }
|
||||
]
|
||||
},
|
||||
{
|
||||
name: '绵阳市',
|
||||
children: [
|
||||
{ name: '涪城区' },
|
||||
{ name: '游仙区' },
|
||||
{ name: '安州区' },
|
||||
{ name: '江油市' },
|
||||
{ name: '三台县' },
|
||||
{ name: '盐亭县' },
|
||||
{ name: '梓潼县' },
|
||||
{ name: '北川羌族自治县' },
|
||||
{ name: '平武县' }
|
||||
]
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
name: '北京市',
|
||||
children: [
|
||||
{
|
||||
name: '北京市',
|
||||
children: [
|
||||
{ name: '东城区' },
|
||||
{ name: '西城区' },
|
||||
{ name: '朝阳区' },
|
||||
{ name: '丰台区' },
|
||||
{ name: '石景山区' },
|
||||
{ name: '海淀区' },
|
||||
{ name: '门头沟区' },
|
||||
{ name: '房山区' },
|
||||
{ name: '通州区' },
|
||||
{ name: '顺义区' },
|
||||
{ name: '昌平区' },
|
||||
{ name: '大兴区' },
|
||||
{ name: '怀柔区' },
|
||||
{ name: '平谷区' },
|
||||
{ name: '密云区' },
|
||||
{ name: '延庆区' }
|
||||
]
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
name: '上海市',
|
||||
children: [
|
||||
{
|
||||
name: '上海市',
|
||||
children: [
|
||||
{ name: '黄浦区' },
|
||||
{ name: '徐汇区' },
|
||||
{ name: '长宁区' },
|
||||
{ name: '静安区' },
|
||||
{ name: '普陀区' },
|
||||
{ name: '虹口区' },
|
||||
{ name: '杨浦区' },
|
||||
{ name: '闵行区' },
|
||||
{ name: '宝山区' },
|
||||
{ name: '嘉定区' },
|
||||
{ name: '浦东新区' },
|
||||
{ name: '金山区' },
|
||||
{ name: '松江区' },
|
||||
{ name: '青浦区' },
|
||||
{ name: '奉贤区' },
|
||||
{ name: '崇明区' }
|
||||
]
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
name: '广东省',
|
||||
children: [
|
||||
{
|
||||
name: '广州市',
|
||||
children: [
|
||||
{ name: '荔湾区' },
|
||||
{ name: '越秀区' },
|
||||
{ name: '海珠区' },
|
||||
{ name: '天河区' },
|
||||
{ name: '白云区' },
|
||||
{ name: '黄埔区' },
|
||||
{ name: '番禺区' },
|
||||
{ name: '花都区' },
|
||||
{ name: '南沙区' },
|
||||
{ name: '从化区' },
|
||||
{ name: '增城区' }
|
||||
]
|
||||
},
|
||||
{
|
||||
name: '深圳市',
|
||||
children: [
|
||||
{ name: '罗湖区' },
|
||||
{ name: '福田区' },
|
||||
{ name: '南山区' },
|
||||
{ name: '宝安区' },
|
||||
{ name: '龙岗区' },
|
||||
{ name: '盐田区' },
|
||||
{ name: '龙华区' },
|
||||
{ name: '坪山区' },
|
||||
{ name: '光明区' }
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
])
|
||||
|
||||
// 处理省市区选择变化
|
||||
const handleRegionChange = (value: string[]) => {
|
||||
console.log('选择的省市区:', value)
|
||||
}
|
||||
|
||||
const createOrderLinkedPaidTotal = computed(() => {
|
||||
const ids = new Set(createOrderForm.pay_order_ids.map((x) => Number(x)))
|
||||
let s = 0
|
||||
@@ -1454,6 +1669,9 @@ const slipDietaryText = computed(() => {
|
||||
const editForm = reactive({
|
||||
id: 0,
|
||||
prescription_type: '浓缩水丸',
|
||||
dosage_amount: 1 as number | undefined,
|
||||
dosage_unit: 'g',
|
||||
need_decoction: false,
|
||||
patient_name: '',
|
||||
gender: 1,
|
||||
age: 0,
|
||||
@@ -1483,7 +1701,25 @@ const editForm = reactive({
|
||||
diagnosis_id: 0,
|
||||
/** 列表带入:业务订单「处方审核」驳回(消费者处方本身可能仍为已通过) */
|
||||
business_prescription_audit_rejected: 0,
|
||||
business_prescription_audit_remark: ''
|
||||
business_prescription_audit_remark: '',
|
||||
times_per_day: 2
|
||||
})
|
||||
|
||||
// 监听处方类型变化,自动调整用量单位和默认值
|
||||
watch(() => editForm.prescription_type, (newType) => {
|
||||
if (newType === '浓缩水丸') {
|
||||
editForm.dosage_unit = 'g'
|
||||
editForm.dosage_amount = 1
|
||||
editForm.need_decoction = false
|
||||
} else if (newType === '饮片') {
|
||||
editForm.dosage_unit = 'ml'
|
||||
editForm.dosage_amount = 50
|
||||
// need_decoction 保持用户选择
|
||||
} else {
|
||||
editForm.dosage_unit = 'g'
|
||||
editForm.dosage_amount = undefined
|
||||
editForm.need_decoction = false
|
||||
}
|
||||
})
|
||||
|
||||
// 表单验证规则
|
||||
@@ -1863,6 +2099,9 @@ const handleEdit = (row: any) => {
|
||||
// 深拷贝数据到表单
|
||||
editForm.id = row.id
|
||||
editForm.prescription_type = row.prescription_type || '浓缩水丸'
|
||||
editForm.dosage_amount = row.dosage_amount ?? undefined
|
||||
editForm.dosage_unit = row.dosage_unit || ''
|
||||
editForm.need_decoction = row.need_decoction === 1 || row.need_decoction === true
|
||||
editForm.patient_name = row.patient_name || ''
|
||||
editForm.gender = row.gender ?? 1
|
||||
editForm.age = row.age ?? 0
|
||||
@@ -1877,6 +2116,7 @@ const handleEdit = (row: any) => {
|
||||
editForm.dose_count = row.dose_count ?? 7
|
||||
editForm.dose_unit = row.dose_unit || '剂'
|
||||
editForm.usage_days = row.usage_days ?? 7
|
||||
editForm.times_per_day = row.times_per_day ?? 2
|
||||
editForm.usage_instruction = row.usage_instruction || '水煎服,一日二次'
|
||||
editForm.usage_time = row.usage_time || '饭前'
|
||||
editForm.usage_way = row.usage_way || '温水送服'
|
||||
|
||||
@@ -184,6 +184,14 @@
|
||||
link
|
||||
@click="confirmWithdraw(row)"
|
||||
>撤回</el-button>
|
||||
<el-button
|
||||
v-if="canUploadGancaoRow(row)"
|
||||
v-perms="['tcm.prescriptionOrder/submitGancaoRecipel']"
|
||||
type="warning"
|
||||
link
|
||||
:loading="gancaoSubmitId === row.id"
|
||||
@click="confirmSubmitGancaoRecipel(row)"
|
||||
>上传药方</el-button>
|
||||
</div>
|
||||
</template>
|
||||
</el-table-column>
|
||||
@@ -206,6 +214,13 @@
|
||||
<div class="flex items-center">
|
||||
<span class="po-detail-drawer-title text-gray-800 font-semibold text-lg">业务订单详情</span>
|
||||
<el-tag v-if="detailData?.order_no" type="primary" effect="plain" round class="ml-3">{{ detailData.order_no }}</el-tag>
|
||||
<el-tag
|
||||
v-if="detailData?.gancao_reciperl_order_no"
|
||||
type="success"
|
||||
effect="plain"
|
||||
round
|
||||
class="ml-2"
|
||||
>甘草 {{ detailData.gancao_reciperl_order_no }}</el-tag>
|
||||
</div>
|
||||
<div v-if="detailData" class="flex items-center gap-2 mr-6">
|
||||
<el-button
|
||||
@@ -364,6 +379,17 @@
|
||||
>
|
||||
详细
|
||||
</el-button>
|
||||
<el-button
|
||||
v-if="detailData.id"
|
||||
type="warning"
|
||||
size="small"
|
||||
link
|
||||
:loading="gancaoPreviewLoading"
|
||||
:icon="Search"
|
||||
@click="testGancaoPreviewFromDetail"
|
||||
>
|
||||
测试价格
|
||||
</el-button>
|
||||
</div>
|
||||
<span class="text-xs text-gray-400">ID: {{ detailData.prescription_id || '—' }}</span>
|
||||
</div>
|
||||
@@ -391,7 +417,7 @@
|
||||
{{ detailPrescription.clinical_diagnosis || '—' }}
|
||||
</el-descriptions-item>
|
||||
<el-descriptions-item label="剂数 / 用法" :span="2">
|
||||
{{ detailPrescription.dose_count ?? '—' }} 剂 ·
|
||||
{{ detailData.dose_count ?? detailPrescription.dose_count ?? '—' }} {{ detailData.dose_unit || '剂' }} ·
|
||||
{{ detailPrescription.usage_instruction || detailPrescription.usage_method || '—' }}
|
||||
</el-descriptions-item>
|
||||
<el-descriptions-item label="处方金额">
|
||||
@@ -402,6 +428,23 @@
|
||||
{{ consumerRxAuditText(detailPrescription.audit_status) }}
|
||||
</el-tag>
|
||||
</el-descriptions-item>
|
||||
<el-descriptions-item label="用量">
|
||||
<template v-if="detailPrescription.dosage_amount">
|
||||
{{ detailPrescription.dosage_amount }}{{ detailPrescription.dosage_unit || 'g' }}
|
||||
<span v-if="detailPrescription.prescription_type === '饮片' && detailPrescription.need_decoction !== null" class="ml-2 text-gray-500">
|
||||
({{ detailPrescription.need_decoction ? '代煎' : '不代煎' }})
|
||||
</span>
|
||||
</template>
|
||||
<template v-else>—</template>
|
||||
</el-descriptions-item>
|
||||
<el-descriptions-item label="服用方式">
|
||||
<template v-if="detailPrescription.usage_days || detailPrescription.times_per_day">
|
||||
<span v-if="detailPrescription.usage_days">服用{{ detailPrescription.usage_days }}天</span>
|
||||
<span v-if="detailPrescription.usage_days && detailPrescription.times_per_day">,</span>
|
||||
<span v-if="detailPrescription.times_per_day">每天{{ detailPrescription.times_per_day }}次</span>
|
||||
</template>
|
||||
<template v-else>—</template>
|
||||
</el-descriptions-item>
|
||||
<el-descriptions-item label="状态" :span="2">
|
||||
<el-tag :type="Number(detailPrescription.void_status) === 1 ? 'danger' : 'success'" size="small">
|
||||
{{ Number(detailPrescription.void_status) === 1 ? '已作废' : '正常' }}
|
||||
@@ -710,7 +753,17 @@
|
||||
</el-col>
|
||||
</el-row>
|
||||
|
||||
<el-form-item label="收货地址" prop="shipping_address">
|
||||
<el-form-item label="省/市/区" prop="region">
|
||||
<el-cascader
|
||||
v-model="editForm.region"
|
||||
:options="regionOptions"
|
||||
placeholder="请选择省/市/区"
|
||||
clearable
|
||||
class="w-full"
|
||||
/>
|
||||
</el-form-item>
|
||||
|
||||
<el-form-item label="详细地址" prop="shipping_address">
|
||||
<el-input v-model="editForm.shipping_address" type="textarea" :rows="2" maxlength="500" show-word-limit />
|
||||
</el-form-item>
|
||||
|
||||
@@ -722,7 +775,55 @@
|
||||
</el-col>
|
||||
<el-col :span="12">
|
||||
<el-form-item label="用药疗程">
|
||||
<el-input-number v-model="editForm.medication_days" :min="1" :max="999" :step="1" class="w-full" placeholder="天数(选填)" />
|
||||
<el-input-number v-model="editForm.medication_days" :min="1" :max="999" :step="1" class="w-full" placeholder="天数(选填)">
|
||||
<template #append>天</template>
|
||||
</el-input-number>
|
||||
</el-form-item>
|
||||
</el-col>
|
||||
</el-row>
|
||||
|
||||
<el-row :gutter="24">
|
||||
<el-col :span="12">
|
||||
<el-form-item label="剂数" prop="dose_count">
|
||||
<el-input-number
|
||||
v-model="editForm.dose_count"
|
||||
:min="1"
|
||||
placeholder="剂数"
|
||||
class="w-full"
|
||||
/>
|
||||
</el-form-item>
|
||||
</el-col>
|
||||
<el-col :span="12">
|
||||
<el-form-item label="剂量单位" prop="dose_unit">
|
||||
<el-select v-model="editForm.dose_unit" placeholder="请选择" class="w-full">
|
||||
<el-option label="剂" value="剂" />
|
||||
<el-option label="丸" value="丸" />
|
||||
<el-option label="袋" value="袋" />
|
||||
<el-option label="盒" value="盒" />
|
||||
<el-option label="瓶" value="瓶" />
|
||||
<el-option label="膏" value="膏" />
|
||||
<el-option label="贴" value="贴" />
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
</el-col>
|
||||
</el-row>
|
||||
|
||||
<!-- 甘草预下单测试按钮 -->
|
||||
<el-row v-if="editForm.id" :gutter="24">
|
||||
<el-col :span="24">
|
||||
<el-form-item label="甘草预下单">
|
||||
<el-button
|
||||
type="primary"
|
||||
plain
|
||||
:loading="gancaoPreviewLoading"
|
||||
:icon="Search"
|
||||
@click="testGancaoPreview"
|
||||
>
|
||||
测试价格
|
||||
</el-button>
|
||||
<span class="ml-3 text-xs text-gray-400">
|
||||
测试当前配置的甘草预下单价格(不会真实提交订单)
|
||||
</span>
|
||||
</el-form-item>
|
||||
</el-col>
|
||||
</el-row>
|
||||
@@ -1194,12 +1295,142 @@
|
||||
</div>
|
||||
</div>
|
||||
</el-drawer>
|
||||
|
||||
<!-- 甘草预下单结果抽屉 -->
|
||||
<el-drawer
|
||||
v-model="gancaoPreviewDrawerVisible"
|
||||
title="甘草预下单测试结果"
|
||||
size="800px"
|
||||
destroy-on-close
|
||||
>
|
||||
<div v-if="gancaoPreviewData" class="px-4">
|
||||
<!-- 价格信息 -->
|
||||
<el-card shadow="never" class="mb-4">
|
||||
<template #header>
|
||||
<div class="flex items-center justify-between">
|
||||
<span class="font-semibold">价格信息</span>
|
||||
<el-tag type="success">预下单成功</el-tag>
|
||||
</div>
|
||||
</template>
|
||||
<el-descriptions :column="2" border>
|
||||
<el-descriptions-item label="药材成本">
|
||||
<span class="text-lg font-semibold text-orange-600">¥{{ gancaoPreviewData.fee?.m_cost || '0.00' }}</span>
|
||||
</el-descriptions-item>
|
||||
<el-descriptions-item label="制作费">
|
||||
<span class="text-lg font-semibold text-blue-600">¥{{ ((gancaoPreviewData.fee?.proces_cost || 0) ).toFixed(2) }}</span>
|
||||
</el-descriptions-item>
|
||||
<el-descriptions-item label="物流费">
|
||||
<span class="text-lg font-semibold text-green-600">¥{{ ((gancaoPreviewData.fee?.lis_cost || 0) ).toFixed(2) }}</span>
|
||||
</el-descriptions-item>
|
||||
<el-descriptions-item label="总计">
|
||||
<span class="text-xl font-bold text-red-600">
|
||||
¥{{ calculateTotalFee(gancaoPreviewData.fee) }}
|
||||
</span>
|
||||
</el-descriptions-item>
|
||||
</el-descriptions>
|
||||
</el-card>
|
||||
|
||||
<!-- 配送信息 -->
|
||||
<el-card shadow="never" class="mb-4">
|
||||
<template #header>
|
||||
<span class="font-semibold">配送信息</span>
|
||||
</template>
|
||||
<el-descriptions :column="2" border>
|
||||
<el-descriptions-item label="调配中心">
|
||||
{{ gancaoPreviewData.result?.ds_name || '—' }}
|
||||
</el-descriptions-item>
|
||||
<el-descriptions-item label="调配中心ID">
|
||||
{{ gancaoPreviewData.result?.ds_id || '—' }}
|
||||
</el-descriptions-item>
|
||||
<el-descriptions-item label="服用天数范围" :span="2">
|
||||
{{ gancaoPreviewData.result?.take_days?.min || '—' }} -
|
||||
{{ gancaoPreviewData.result?.take_days?.max || '—' }} 天
|
||||
(默认: {{ gancaoPreviewData.result?.take_days?.default || '—' }} 天)
|
||||
</el-descriptions-item>
|
||||
</el-descriptions>
|
||||
</el-card>
|
||||
|
||||
<!-- 规则检查/医嘱备注 -->
|
||||
<el-card v-if="gancaoPreviewData.result?.rule_check?.length" shadow="never" class="mb-4">
|
||||
<template #header>
|
||||
<span class="font-semibold">医嘱备注</span>
|
||||
</template>
|
||||
<div class="text-sm text-gray-700 leading-relaxed">
|
||||
{{ gancaoPreviewData.result.rule_check.map(r => r.msg).join(' ') }}
|
||||
</div>
|
||||
</el-card>
|
||||
|
||||
<!-- 药材明细 -->
|
||||
<el-card shadow="never">
|
||||
<template #header>
|
||||
<div class="flex items-center justify-between">
|
||||
<span class="font-semibold">药材明细</span>
|
||||
<span class="text-sm text-gray-500">共 {{ gancaoPreviewData.result?.m_list?.length || 0 }} 味药</span>
|
||||
</div>
|
||||
</template>
|
||||
<el-table
|
||||
:data="gancaoPreviewData.result?.m_list || []"
|
||||
border
|
||||
stripe
|
||||
max-height="400"
|
||||
>
|
||||
<el-table-column type="index" label="#" width="50" align="center" />
|
||||
<el-table-column prop="title" label="药材名称" min-width="120" show-overflow-tooltip />
|
||||
<el-table-column prop="quantity" label="用量" width="80" align="right">
|
||||
<template #default="{ row }">
|
||||
{{ row.quantity }}{{ row.unit }}
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="price" label="单价(元/克)" width="110" align="right">
|
||||
<template #default="{ row }">
|
||||
¥{{ Number(row.price).toFixed(4) }}
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="小计" width="100" align="right">
|
||||
<template #default="{ row }">
|
||||
<span class="font-medium text-orange-600">
|
||||
¥{{ (Number(row.price) * Number(row.quantity)).toFixed(2) }}
|
||||
</span>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="is_available" label="库存状态" width="100" align="center">
|
||||
<template #default="{ row }">
|
||||
<el-tag :type="row.is_available === 1 ? 'success' : 'danger'" size="small">
|
||||
{{ row.is_available === 1 ? '有货' : '缺货' }}
|
||||
</el-tag>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="brief" label="备注" min-width="120" show-overflow-tooltip />
|
||||
</el-table>
|
||||
</el-card>
|
||||
|
||||
<!-- 订单信息 -->
|
||||
<el-card shadow="never" class="mt-4">
|
||||
<template #header>
|
||||
<span class="font-semibold">订单信息</span>
|
||||
</template>
|
||||
<el-descriptions :column="2" border>
|
||||
<el-descriptions-item label="订单号" :span="2">
|
||||
{{ gancaoPreviewData.order_no || '—' }}
|
||||
</el-descriptions-item>
|
||||
<el-descriptions-item label="剂数">
|
||||
<span class="text-base font-medium">{{ gancaoPreviewData.dose_count || '—' }}剂</span>
|
||||
</el-descriptions-item>
|
||||
<el-descriptions-item label="单剂量">
|
||||
<span class="text-base font-medium">
|
||||
{{ calculateSingleDoseWeight(gancaoPreviewData.result?.m_list) }}克
|
||||
</span>
|
||||
</el-descriptions-item>
|
||||
</el-descriptions>
|
||||
</el-card>
|
||||
</div>
|
||||
</el-drawer>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script lang="ts" setup name="prescriptionOrderList">
|
||||
import { computed, onMounted, reactive, ref, nextTick } from 'vue'
|
||||
import { Refresh, Loading, ArrowDown, InfoFilled } from '@element-plus/icons-vue'
|
||||
import { Refresh, Loading, ArrowDown, InfoFilled, Search } from '@element-plus/icons-vue'
|
||||
import {
|
||||
prescriptionOrderAuditPayment,
|
||||
prescriptionOrderAuditPrescription,
|
||||
@@ -1216,6 +1447,8 @@ import {
|
||||
prescriptionOrderRevokeRxAudit,
|
||||
prescriptionOrderRevokePayAudit,
|
||||
prescriptionOrderLinkPayOrder,
|
||||
prescriptionOrderSubmitGancaoRecipel,
|
||||
prescriptionOrderPreviewGancaoRecipel,
|
||||
prescriptionDetail
|
||||
} from '@/api/tcm'
|
||||
import { usePaging } from '@/hooks/usePaging'
|
||||
@@ -1238,6 +1471,92 @@ const canViewFinanceFields = () => {
|
||||
return FINANCE_ROLE_IDS.some((rid) => ids.includes(rid))
|
||||
}
|
||||
|
||||
// 省市区数据
|
||||
const regionOptions = [
|
||||
{
|
||||
value: '四川省',
|
||||
label: '四川省',
|
||||
children: [
|
||||
{
|
||||
value: '成都市',
|
||||
label: '成都市',
|
||||
children: [
|
||||
{ value: '武侯区', label: '武侯区' },
|
||||
{ value: '锦江区', label: '锦江区' },
|
||||
{ value: '青羊区', label: '青羊区' },
|
||||
{ value: '金牛区', label: '金牛区' },
|
||||
{ value: '成华区', label: '成华区' },
|
||||
{ value: '高新区', label: '高新区' }
|
||||
]
|
||||
},
|
||||
{
|
||||
value: '绵阳市',
|
||||
label: '绵阳市',
|
||||
children: [
|
||||
{ value: '涪城区', label: '涪城区' },
|
||||
{ value: '游仙区', label: '游仙区' }
|
||||
]
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
value: '北京市',
|
||||
label: '北京市',
|
||||
children: [
|
||||
{
|
||||
value: '北京市',
|
||||
label: '北京市',
|
||||
children: [
|
||||
{ value: '朝阳区', label: '朝阳区' },
|
||||
{ value: '海淀区', label: '海淀区' },
|
||||
{ value: '东城区', label: '东城区' },
|
||||
{ value: '西城区', label: '西城区' }
|
||||
]
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
value: '上海市',
|
||||
label: '上海市',
|
||||
children: [
|
||||
{
|
||||
value: '上海市',
|
||||
label: '上海市',
|
||||
children: [
|
||||
{ value: '浦东新区', label: '浦东新区' },
|
||||
{ value: '黄浦区', label: '黄浦区' },
|
||||
{ value: '徐汇区', label: '徐汇区' },
|
||||
{ value: '静安区', label: '静安区' }
|
||||
]
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
value: '广东省',
|
||||
label: '广东省',
|
||||
children: [
|
||||
{
|
||||
value: '广州市',
|
||||
label: '广州市',
|
||||
children: [
|
||||
{ value: '天河区', label: '天河区' },
|
||||
{ value: '越秀区', label: '越秀区' },
|
||||
{ value: '海珠区', label: '海珠区' }
|
||||
]
|
||||
},
|
||||
{
|
||||
value: '深圳市',
|
||||
label: '深圳市',
|
||||
children: [
|
||||
{ value: '南山区', label: '南山区' },
|
||||
{ value: '福田区', label: '福田区' },
|
||||
{ value: '罗湖区', label: '罗湖区' }
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
|
||||
const queryParams = reactive({
|
||||
order_no: '',
|
||||
prescription_id: '' as string | number,
|
||||
@@ -1403,6 +1722,16 @@ function canQuickTrackRow(row: { fulfillment_status?: number }) {
|
||||
return Number(row.fulfillment_status) === 5
|
||||
}
|
||||
|
||||
function canUploadGancaoRow(row: {
|
||||
prescription_audit_status?: number
|
||||
gancao_reciperl_order_no?: string
|
||||
}) {
|
||||
// 处方审核已通过(1) 且没有甘草订单号时才显示
|
||||
const rxStatus = Number(row.prescription_audit_status)
|
||||
const gancaoOrderNo = String(row.gancao_reciperl_order_no || '').trim()
|
||||
return rxStatus === 1 && gancaoOrderNo === ''
|
||||
}
|
||||
|
||||
function orderStatusText(s: number | undefined) {
|
||||
const m: Record<number, string> = {
|
||||
1: '待支付',
|
||||
@@ -1666,9 +1995,12 @@ const editForm = reactive({
|
||||
diagnosis_id: 0,
|
||||
recipient_name: '',
|
||||
recipient_phone: '',
|
||||
region: [] as string[],
|
||||
shipping_address: '',
|
||||
is_follow_up: 0,
|
||||
medication_days: undefined as number | string | undefined,
|
||||
dose_count: 1,
|
||||
dose_unit: '剂',
|
||||
prev_staff: '',
|
||||
service_channel: '',
|
||||
service_package: '',
|
||||
@@ -1773,9 +2105,22 @@ async function openEdit(row: { id: number }) {
|
||||
editForm.diagnosis_id = Number(d.diagnosis_id) || 0
|
||||
editForm.recipient_name = d.recipient_name || ''
|
||||
editForm.recipient_phone = d.recipient_phone || ''
|
||||
|
||||
// 填充省市区字段
|
||||
const province = d.shipping_province || ''
|
||||
const city = d.shipping_city || ''
|
||||
const district = d.shipping_district || ''
|
||||
if (province || city || district) {
|
||||
editForm.region = [province, city, district].filter(v => v !== '')
|
||||
} else {
|
||||
editForm.region = []
|
||||
}
|
||||
|
||||
editForm.shipping_address = d.shipping_address || ''
|
||||
editForm.is_follow_up = d.is_follow_up ? 1 : 0
|
||||
editForm.medication_days = d.medication_days > 0 ? d.medication_days : undefined
|
||||
editForm.dose_count = d.dose_count > 0 ? d.dose_count : 1
|
||||
editForm.dose_unit = d.dose_unit || '剂'
|
||||
editForm.prev_staff = d.prev_staff || ''
|
||||
editForm.service_channel = d.service_channel || ''
|
||||
editForm.service_package = d.service_package || ''
|
||||
@@ -1836,9 +2181,22 @@ async function submitEdit() {
|
||||
|
||||
// BUG FIX: 修复之前无法清空部分选填字段的问题
|
||||
medication_days: (editForm.medication_days != null && String(editForm.medication_days).trim() !== '') ? editForm.medication_days : '',
|
||||
dose_unit: editForm.dose_unit || '剂',
|
||||
dose_count: editForm.dose_count || 1,
|
||||
internal_cost: (editForm.internal_cost != null && String(editForm.internal_cost).trim() !== '') ? editForm.internal_cost : '',
|
||||
}
|
||||
|
||||
// 添加省市区字段
|
||||
if (editForm.region && editForm.region.length > 0) {
|
||||
payload.shipping_province = editForm.region[0] || ''
|
||||
payload.shipping_city = editForm.region[1] || ''
|
||||
payload.shipping_district = editForm.region[2] || ''
|
||||
} else {
|
||||
payload.shipping_province = ''
|
||||
payload.shipping_city = ''
|
||||
payload.shipping_district = ''
|
||||
}
|
||||
|
||||
payload.pay_order_ids = [...editForm.pay_order_ids]
|
||||
|
||||
await prescriptionOrderEdit(payload)
|
||||
@@ -1923,6 +2281,121 @@ async function confirmWithdraw(row: { id: number }) {
|
||||
}
|
||||
}
|
||||
|
||||
const gancaoSubmitId = ref(0)
|
||||
const gancaoPreviewDrawerVisible = ref(false)
|
||||
const gancaoPreviewLoading = ref(false)
|
||||
const gancaoPreviewData = ref<any>(null)
|
||||
|
||||
// 计算总费用
|
||||
function calculateTotalFee(fee: any) {
|
||||
if (!fee) return '0.00'
|
||||
const mCost = Number(fee.m_cost) || 0
|
||||
const procesCost = (Number(fee.proces_cost) || 0)
|
||||
const lisCost = (Number(fee.lis_cost) || 0)
|
||||
return (mCost + procesCost + lisCost).toFixed(2)
|
||||
}
|
||||
|
||||
// 计算单剂量(所有药材用量总和)
|
||||
function calculateSingleDoseWeight(mList: any[]) {
|
||||
if (!Array.isArray(mList) || mList.length === 0) return '0'
|
||||
const total = mList.reduce((sum, item) => {
|
||||
return sum + (Number(item.quantity) || 0)
|
||||
}, 0)
|
||||
return total.toFixed(0)
|
||||
}
|
||||
|
||||
async function testGancaoPreview() {
|
||||
if (!editForm.id) {
|
||||
feedback.msgError('请先保存订单')
|
||||
return
|
||||
}
|
||||
|
||||
gancaoPreviewLoading.value = true
|
||||
|
||||
try {
|
||||
const res: any = await prescriptionOrderPreviewGancaoRecipel({
|
||||
id: editForm.id,
|
||||
dose_count: editForm.dose_count || 1,
|
||||
medication_days: editForm.medication_days || undefined
|
||||
})
|
||||
const d = res?.data ?? res
|
||||
|
||||
if (d && d.success) {
|
||||
gancaoPreviewData.value = d
|
||||
gancaoPreviewDrawerVisible.value = true
|
||||
feedback.msgSuccess('预下单测试成功')
|
||||
} else {
|
||||
feedback.msgError(d?.error || '预下单失败')
|
||||
}
|
||||
} catch (e: any) {
|
||||
feedback.msgError(e?.message || '预下单失败')
|
||||
} finally {
|
||||
gancaoPreviewLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
// 从详情页测试甘草价格
|
||||
async function testGancaoPreviewFromDetail() {
|
||||
if (!detailData.value?.id) {
|
||||
feedback.msgError('订单数据不存在')
|
||||
return
|
||||
}
|
||||
|
||||
gancaoPreviewLoading.value = true
|
||||
|
||||
try {
|
||||
const res: any = await prescriptionOrderPreviewGancaoRecipel({
|
||||
id: detailData.value.id,
|
||||
dose_count: detailData.value.dose_count || 1,
|
||||
medication_days: detailData.value.medication_days || undefined
|
||||
})
|
||||
const d = res?.data ?? res
|
||||
|
||||
if (d && d.success) {
|
||||
gancaoPreviewData.value = d
|
||||
gancaoPreviewDrawerVisible.value = true
|
||||
feedback.msgSuccess('预下单测试成功')
|
||||
} else {
|
||||
feedback.msgError(d?.error || '预下单失败')
|
||||
}
|
||||
} catch (e: any) {
|
||||
feedback.msgError(e?.message || '预下单失败')
|
||||
} finally {
|
||||
gancaoPreviewLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function confirmSubmitGancaoRecipel(row: { id: number }) {
|
||||
try {
|
||||
await ElMessageBox.confirm(
|
||||
'将把本单关联处方提交至甘草药管家开放平台(先 CTM_PREVIEW 预检查再 CTM_SUBMIT_RECIPEL 正式下单;成功后将按甘草侧规则扣费,详见 https://apidoc.igancao.com/service-doc/scm-outer-recipel.html )。确定继续?',
|
||||
'上传甘草药方',
|
||||
{ type: 'warning', confirmButtonText: '确定上传', cancelButtonText: '取消' }
|
||||
)
|
||||
gancaoSubmitId.value = row.id
|
||||
const res: any = await prescriptionOrderSubmitGancaoRecipel({ id: row.id })
|
||||
const d = res?.data ?? res
|
||||
const no = d?.recipel_order_no != null ? String(d.recipel_order_no) : ''
|
||||
feedback.msgSuccess(no ? `上传成功,甘草处方单号:${no}` : '上传成功')
|
||||
getLists()
|
||||
if (detailVisible.value && Number(detailData.value?.id) === row.id) {
|
||||
try {
|
||||
const r: any = await prescriptionOrderDetail({ id: row.id })
|
||||
const dd = r?.data ?? r ?? null
|
||||
if (dd) detailData.value = dd
|
||||
} catch {
|
||||
/* 静默 */
|
||||
}
|
||||
}
|
||||
} catch (e: any) {
|
||||
if (e !== 'cancel' && e !== 'close') {
|
||||
/* 拦截器已提示 */
|
||||
}
|
||||
} finally {
|
||||
gancaoSubmitId.value = 0
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
getLists()
|
||||
})
|
||||
|
||||
@@ -0,0 +1,113 @@
|
||||
#!/bin/bash
|
||||
|
||||
# 甘草 API 修复部署脚本
|
||||
# 使用方法: ./deploy_gancao_fix.sh [server_user@server_ip] [project_path]
|
||||
|
||||
set -e
|
||||
|
||||
# 颜色定义
|
||||
RED='\033[0;31m'
|
||||
GREEN='\033[0;32m'
|
||||
YELLOW='\033[1;33m'
|
||||
NC='\033[0m' # No Color
|
||||
|
||||
echo -e "${GREEN}=== 甘草 API 修复部署脚本 ===${NC}\n"
|
||||
|
||||
# 检查参数
|
||||
if [ $# -lt 2 ]; then
|
||||
echo -e "${RED}错误:缺少参数${NC}"
|
||||
echo "使用方法: $0 [server_user@server_ip] [project_path]"
|
||||
echo "示例: $0 root@192.168.1.100 /www/wwwroot/your-project"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
SERVER=$1
|
||||
PROJECT_PATH=$2
|
||||
|
||||
echo -e "${YELLOW}服务器:${NC}$SERVER"
|
||||
echo -e "${YELLOW}项目路径:${NC}$PROJECT_PATH\n"
|
||||
|
||||
# 确认
|
||||
read -p "确认部署到以上服务器?(y/n) " -n 1 -r
|
||||
echo
|
||||
if [[ ! $REPLY =~ ^[Yy]$ ]]; then
|
||||
echo "已取消"
|
||||
exit 0
|
||||
fi
|
||||
|
||||
echo -e "\n${GREEN}步骤 1: 上传修复文件${NC}"
|
||||
|
||||
# 上传 PHP 文件
|
||||
echo "上传 GancaoScmRecipelService.php..."
|
||||
scp server/app/common/service/gancao/GancaoScmRecipelService.php \
|
||||
$SERVER:$PROJECT_PATH/server/app/common/service/gancao/
|
||||
|
||||
echo "上传 GancaoOpenApiTransport.php..."
|
||||
scp server/app/common/service/gancao/GancaoOpenApiTransport.php \
|
||||
$SERVER:$PROJECT_PATH/server/app/common/service/gancao/
|
||||
|
||||
echo "上传 PrescriptionOrderLogic.php..."
|
||||
scp server/app/adminapi/logic/tcm/PrescriptionOrderLogic.php \
|
||||
$SERVER:$PROJECT_PATH/server/app/adminapi/logic/tcm/
|
||||
|
||||
echo "上传 PrescriptionOrderController.php..."
|
||||
scp server/app/adminapi/controller/tcm/PrescriptionOrderController.php \
|
||||
$SERVER:$PROJECT_PATH/server/app/adminapi/controller/tcm/
|
||||
|
||||
# 上传测试脚本
|
||||
echo "上传测试脚本..."
|
||||
scp test_gancao_config.php $SERVER:$PROJECT_PATH/
|
||||
scp test_gancao_ssl.php $SERVER:$PROJECT_PATH/
|
||||
scp diagnose_gancao_payload.php $SERVER:$PROJECT_PATH/
|
||||
|
||||
echo -e "${GREEN}✓ 文件上传完成${NC}\n"
|
||||
|
||||
echo -e "${GREEN}步骤 2: 在服务器上执行操作${NC}"
|
||||
|
||||
ssh $SERVER << EOF
|
||||
set -e
|
||||
cd $PROJECT_PATH
|
||||
|
||||
echo "检查文件..."
|
||||
ls -la server/app/common/service/gancao/GancaoScmRecipelService.php
|
||||
ls -la server/app/common/service/gancao/GancaoOpenApiTransport.php
|
||||
|
||||
echo ""
|
||||
echo "清除缓存..."
|
||||
cd server
|
||||
php think clear || true
|
||||
rm -rf runtime/cache/* || true
|
||||
rm -rf runtime/temp/* || true
|
||||
cd ..
|
||||
|
||||
echo ""
|
||||
echo "运行配置检查..."
|
||||
php test_gancao_config.php || true
|
||||
|
||||
echo ""
|
||||
echo "运行 SSL 测试..."
|
||||
php test_gancao_ssl.php || true
|
||||
|
||||
echo ""
|
||||
echo "重启服务..."
|
||||
sudo systemctl restart php-fpm || sudo systemctl restart php7.4-fpm || sudo systemctl restart php8.0-fpm || sudo systemctl restart php8.1-fpm || true
|
||||
sudo systemctl restart nginx || true
|
||||
|
||||
echo ""
|
||||
echo "部署完成!"
|
||||
EOF
|
||||
|
||||
echo -e "\n${GREEN}=== 部署完成 ===${NC}\n"
|
||||
|
||||
echo -e "${YELLOW}下一步:${NC}"
|
||||
echo "1. 查看日志:"
|
||||
echo " ssh $SERVER 'tail -f $PROJECT_PATH/server/runtime/log/\$(date +%Y%m%d).log | grep -i gancao'"
|
||||
echo ""
|
||||
echo "2. 测试接口:"
|
||||
echo " curl -X POST https://your-domain.com/api/tcm.prescriptionOrder/submitGancaoRecipel \\"
|
||||
echo " -H 'Content-Type: application/json' \\"
|
||||
echo " -H 'token: your_token' \\"
|
||||
echo " -d '{\"id\": 订单ID}'"
|
||||
echo ""
|
||||
echo "3. 如果仍有问题,查看详细文档:"
|
||||
echo " cat GANCAO_FINAL_FIX.md"
|
||||
@@ -0,0 +1,216 @@
|
||||
<?php
|
||||
/**
|
||||
* 甘草负载诊断脚本
|
||||
* 用于检查构建的 API 负载中是否有数组转字符串的问题
|
||||
*/
|
||||
|
||||
require __DIR__ . '/server/vendor/autoload.php';
|
||||
|
||||
$app = new think\App();
|
||||
$app->initialize();
|
||||
|
||||
echo "=== 甘草负载诊断 ===\n\n";
|
||||
|
||||
// 模拟处方数据
|
||||
$rx = [
|
||||
'id' => 1,
|
||||
'diagnosis_id' => 1,
|
||||
'patient_name' => '测试患者',
|
||||
'age' => 30,
|
||||
'gender' => 0,
|
||||
'phone' => '13800138000',
|
||||
'clinical_diagnosis' => '中医辨证论治',
|
||||
'doctor_name' => '测试医生',
|
||||
'doctor_phone' => '13900139000',
|
||||
'usage_time' => '饭后半小时服用',
|
||||
'dietary_taboo' => '忌辛辣',
|
||||
'usage_way' => '口服',
|
||||
'usage_instruction' => '温水送服',
|
||||
'usage_notes' => '注意休息',
|
||||
'dose_count' => 7,
|
||||
'herbs' => [
|
||||
[
|
||||
'name' => '当归',
|
||||
'dosage' => 10,
|
||||
'gc_id' => 1001,
|
||||
'brief' => '',
|
||||
],
|
||||
[
|
||||
'name' => '黄芪',
|
||||
'dosage' => 15,
|
||||
'gc_id' => 1002,
|
||||
'brief' => '',
|
||||
],
|
||||
],
|
||||
];
|
||||
|
||||
// 模拟订单数据
|
||||
$order = [
|
||||
'id' => 1,
|
||||
'recipient_name' => '测试患者',
|
||||
'recipient_phone' => '13800138000',
|
||||
'shipping_address' => '四川省成都市武侯区测试街道123号',
|
||||
'remark_extra' => '测试备注',
|
||||
];
|
||||
|
||||
echo "1. 测试配置读取:\n";
|
||||
$config = think\facade\Config::get('gancao_scm', []);
|
||||
|
||||
$criticalFields = ['express_type', 'callback_url', 'cradle_store', 'df_id'];
|
||||
foreach ($criticalFields as $field) {
|
||||
$value = $config[$field] ?? null;
|
||||
$type = gettype($value);
|
||||
echo " - {$field}: {$type}";
|
||||
|
||||
if ($type === 'array') {
|
||||
echo " ❌ 错误!这个字段不应该是数组\n";
|
||||
echo " 值: " . json_encode($value, JSON_UNESCAPED_UNICODE) . "\n";
|
||||
} else {
|
||||
echo " ✓\n";
|
||||
}
|
||||
}
|
||||
|
||||
echo "\n2. 测试 buildPreviewPayload:\n";
|
||||
|
||||
try {
|
||||
$token = 'test_token_' . time();
|
||||
$previewPayload = \app\common\service\gancao\GancaoScmRecipelService::buildPreviewPayload($rx, $order, $token);
|
||||
|
||||
echo " ✓ buildPreviewPayload 成功\n";
|
||||
echo " 字段数量: " . count($previewPayload) . "\n";
|
||||
|
||||
// 检查每个字段的类型
|
||||
echo "\n 检查字段类型:\n";
|
||||
foreach ($previewPayload as $key => $value) {
|
||||
$type = gettype($value);
|
||||
echo " - {$key}: {$type}";
|
||||
|
||||
if ($type === 'array') {
|
||||
echo " (包含 " . count($value) . " 个元素)";
|
||||
|
||||
// 检查数组内部是否有问题
|
||||
$hasArrayValue = false;
|
||||
foreach ($value as $subKey => $subValue) {
|
||||
if (is_array($subValue) && !in_array($key, ['m_list', 'df101ext', 'df102ext', 'df103ext', 'df104ext'])) {
|
||||
$hasArrayValue = true;
|
||||
echo "\n ⚠️ 子字段 '{$subKey}' 是数组: " . json_encode($subValue, JSON_UNESCAPED_UNICODE);
|
||||
}
|
||||
}
|
||||
}
|
||||
echo "\n";
|
||||
}
|
||||
|
||||
} catch (\Throwable $e) {
|
||||
echo " ❌ 失败: " . $e->getMessage() . "\n";
|
||||
echo " 文件: " . $e->getFile() . ":" . $e->getLine() . "\n";
|
||||
}
|
||||
|
||||
echo "\n3. 测试 buildSubmitPayload:\n";
|
||||
|
||||
try {
|
||||
$token = 'test_token_' . time();
|
||||
$addrParts = \app\common\service\gancao\GancaoScmRecipelService::splitCnAddress($order['shipping_address']);
|
||||
$appNo = 'PO' . $order['id'];
|
||||
|
||||
$submitPayload = \app\common\service\gancao\GancaoScmRecipelService::buildSubmitPayload(
|
||||
$rx,
|
||||
$order,
|
||||
$token,
|
||||
$addrParts,
|
||||
$appNo
|
||||
);
|
||||
|
||||
echo " ✓ buildSubmitPayload 成功\n";
|
||||
echo " 字段数量: " . count($submitPayload) . "\n";
|
||||
|
||||
// 检查关键字段
|
||||
echo "\n 检查关键字段:\n";
|
||||
$keyFields = ['express_type', 'callback_url', 'app_order_no', 'cradle_store', 'express_to', 'patient', 'doctor', 'doct_advice'];
|
||||
|
||||
foreach ($keyFields as $field) {
|
||||
if (!isset($submitPayload[$field])) {
|
||||
echo " - {$field}: ❌ 缺失\n";
|
||||
continue;
|
||||
}
|
||||
|
||||
$value = $submitPayload[$field];
|
||||
$type = gettype($value);
|
||||
echo " - {$field}: {$type}";
|
||||
|
||||
if ($type === 'string') {
|
||||
if (strlen($value) > 50) {
|
||||
echo " (长度: " . strlen($value) . ")";
|
||||
} else {
|
||||
echo " = " . var_export($value, true);
|
||||
}
|
||||
} elseif ($type === 'array') {
|
||||
echo " (包含 " . count($value) . " 个元素)";
|
||||
|
||||
// 检查数组内部
|
||||
foreach ($value as $subKey => $subValue) {
|
||||
$subType = gettype($subValue);
|
||||
if ($subType === 'array') {
|
||||
echo "\n ⚠️ 子字段 '{$subKey}' 是数组(可能有问题)";
|
||||
}
|
||||
}
|
||||
}
|
||||
echo "\n";
|
||||
}
|
||||
|
||||
} catch (\Throwable $e) {
|
||||
echo " ❌ 失败: " . $e->getMessage() . "\n";
|
||||
echo " 文件: " . $e->getFile() . ":" . $e->getLine() . "\n";
|
||||
}
|
||||
|
||||
echo "\n4. 测试 JSON 编码:\n";
|
||||
|
||||
try {
|
||||
$token = 'test_token_' . time();
|
||||
$addrParts = \app\common\service\gancao\GancaoScmRecipelService::splitCnAddress($order['shipping_address']);
|
||||
$appNo = 'PO' . $order['id'];
|
||||
|
||||
$submitPayload = \app\common\service\gancao\GancaoScmRecipelService::buildSubmitPayload(
|
||||
$rx,
|
||||
$order,
|
||||
$token,
|
||||
$addrParts,
|
||||
$appNo
|
||||
);
|
||||
|
||||
$json = json_encode($submitPayload, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES);
|
||||
|
||||
if ($json === false) {
|
||||
echo " ❌ JSON 编码失败: " . json_last_error_msg() . "\n";
|
||||
} else {
|
||||
echo " ✓ JSON 编码成功\n";
|
||||
echo " 大小: " . strlen($json) . " 字节\n";
|
||||
|
||||
// 检查是否包含 "Array" 字符串
|
||||
if (strpos($json, '"Array"') !== false) {
|
||||
echo " ❌ 警告:JSON 中包含 'Array' 字符串,说明有数组被转换为字符串\n";
|
||||
|
||||
// 尝试找出是哪个字段
|
||||
$decoded = json_decode($json, true);
|
||||
echo "\n 查找包含 'Array' 的字段:\n";
|
||||
array_walk_recursive($decoded, function($value, $key) {
|
||||
if (is_string($value) && $value === 'Array') {
|
||||
echo " - 字段 '{$key}' 的值是 'Array'\n";
|
||||
}
|
||||
});
|
||||
} else {
|
||||
echo " ✓ JSON 内容正常\n";
|
||||
}
|
||||
|
||||
// 保存到文件以便检查
|
||||
$filename = 'gancao_payload_' . date('YmdHis') . '.json';
|
||||
file_put_contents($filename, json_encode($submitPayload, JSON_UNESCAPED_UNICODE | JSON_PRETTY_PRINT));
|
||||
echo " 负载已保存到: {$filename}\n";
|
||||
}
|
||||
|
||||
} catch (\Throwable $e) {
|
||||
echo " ❌ 异常: " . $e->getMessage() . "\n";
|
||||
echo " 文件: " . $e->getFile() . ":" . $e->getLine() . "\n";
|
||||
echo " 堆栈:\n" . $e->getTraceAsString() . "\n";
|
||||
}
|
||||
|
||||
echo "\n=== 诊断完成 ===\n";
|
||||
@@ -51,3 +51,17 @@ LOGISTICS_KUAIDI100_CUSTOMER =
|
||||
LOGISTICS_KUAIDI100_KEY =
|
||||
# LOGISTICS_KUAIDI100_DISABLE = false
|
||||
# LOGISTICS_KUAIDI100_QUERY_URL = https://poll.kuaidi100.com/poll/query.do
|
||||
|
||||
; 甘草药管家 SCM(须写在任意 [APP]、[trtc] 等分区之前;误放在 [trtc] 下会变成 TRTC_GANCAO_SCM_*,框架已兼容读取)
|
||||
; 也可使用独立分区:[GANCAO_SCM] 下写 ENABLED=、GATEWAY_AK= 等(见 server/config/gancao_scm.php)
|
||||
; GANCAO_SCM_ENABLED = true
|
||||
; GANCAO_SCM_GATEWAY_URL = https://oapi.igancao.com
|
||||
; GANCAO_SCM_GATEWAY_AK =
|
||||
; GANCAO_SCM_GATEWAY_SK =
|
||||
; 业务层(MAKE_TOKEN);常与 GANCAO_SCM_GATEWAY_* 不同,勿默认留空复用错误 sk
|
||||
; GANCAO_SCM_BIZ_AK =
|
||||
; GANCAO_SCM_BIZ_SK =
|
||||
; GANCAO_SCM_CALLBACK_URL = https://你的公网域名/gancao/recipel-notify
|
||||
; GANCAO_SCM_CRADLE_STORE = 甄养堂互联网医院
|
||||
; GANCAO_SCM_DF_ID = 101
|
||||
; GANCAO_SCM_EXPRESS_TYPE = general
|
||||
@@ -263,4 +263,66 @@ class PrescriptionOrderController extends BaseAdminController
|
||||
|
||||
return $this->success('订单已完成', $result);
|
||||
}
|
||||
|
||||
/**
|
||||
* 甘草药管家:处方下单(CTM_PREVIEW → CTM_SUBMIT_RECIPEL)
|
||||
*
|
||||
* @see https://apidoc.igancao.com/service-doc/scm-outer-recipel.html
|
||||
*/
|
||||
public function submitGancaoRecipel()
|
||||
{
|
||||
try {
|
||||
$params = (new PrescriptionOrderValidate())->post()->goCheck('submitGancaoRecipel');
|
||||
$result = PrescriptionOrderLogic::submitGancaoRecipel((int) $params['id'], $this->adminId, $this->adminInfo);
|
||||
|
||||
if ($result === false) {
|
||||
$error = PrescriptionOrderLogic::getError();
|
||||
\think\facade\Log::error('submitGancaoRecipel failed', ['error' => $error, 'params' => $params]);
|
||||
return $this->fail($error);
|
||||
}
|
||||
|
||||
// 确保返回的数据是可序列化的
|
||||
if (!is_array($result)) {
|
||||
\think\facade\Log::error('submitGancaoRecipel result is not array', ['result' => $result]);
|
||||
return $this->fail('返回数据格式错误');
|
||||
}
|
||||
|
||||
return $this->success('甘草药方上传成功', $result);
|
||||
} catch (\Throwable $e) {
|
||||
\think\facade\Log::error('submitGancaoRecipel exception', [
|
||||
'message' => $e->getMessage(),
|
||||
'file' => $e->getFile(),
|
||||
'line' => $e->getLine(),
|
||||
'trace' => $e->getTraceAsString()
|
||||
]);
|
||||
return $this->fail('系统错误:' . $e->getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 甘草药管家:预下单测试(仅 CTM_PREVIEW,不提交订单)
|
||||
* 用于在编辑订单时测试价格和配置
|
||||
*/
|
||||
public function previewGancaoRecipel()
|
||||
{
|
||||
try {
|
||||
$params = (new PrescriptionOrderValidate())->post()->goCheck('previewGancaoRecipel');
|
||||
$result = PrescriptionOrderLogic::previewGancaoRecipel((int) $params['id'], $this->adminId, $this->adminInfo, $params);
|
||||
|
||||
if ($result === false) {
|
||||
$error = PrescriptionOrderLogic::getError();
|
||||
\think\facade\Log::error('previewGancaoRecipel failed', ['error' => $error, 'params' => $params]);
|
||||
return $this->fail($error);
|
||||
}
|
||||
|
||||
return $this->success('预下单成功', $result);
|
||||
} catch (\Throwable $e) {
|
||||
\think\facade\Log::error('previewGancaoRecipel exception', [
|
||||
'message' => $e->getMessage(),
|
||||
'file' => $e->getFile(),
|
||||
'line' => $e->getLine()
|
||||
]);
|
||||
return $this->fail('系统错误:' . $e->getMessage());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -12,6 +12,7 @@ use app\common\model\Order;
|
||||
use app\common\model\tcm\Diagnosis;
|
||||
use app\common\model\tcm\PrescriptionOrder;
|
||||
use app\common\model\tcm\PrescriptionOrderPayOrder;
|
||||
use app\common\service\gancao\GancaoScmRecipelService;
|
||||
|
||||
class PrescriptionOrderLists extends BaseAdminDataLists implements ListsSearchInterface, ListsExtendInterface
|
||||
{
|
||||
@@ -119,6 +120,11 @@ class PrescriptionOrderLists extends BaseAdminDataLists implements ListsSearchIn
|
||||
}
|
||||
|
||||
$depMin = PrescriptionOrderLogic::depositMinAmount();
|
||||
$diagIdsForAssist = array_values(array_unique(array_filter(array_map('intval', array_column($lists, 'diagnosis_id')))));
|
||||
$assistantByDiag = [];
|
||||
if ($diagIdsForAssist !== []) {
|
||||
$assistantByDiag = Diagnosis::whereIn('id', $diagIdsForAssist)->whereNull('delete_time')->column('assistant_id', 'id');
|
||||
}
|
||||
foreach ($lists as &$item) {
|
||||
PrescriptionOrderLogic::maskInternalCostIfNeeded($item, $this->adminInfo);
|
||||
$pid = (int) ($item['id'] ?? 0);
|
||||
@@ -128,6 +134,12 @@ class PrescriptionOrderLists extends BaseAdminDataLists implements ListsSearchIn
|
||||
)->count();
|
||||
$item['linked_pay_paid_total'] = $paidSumByPo[$pid] ?? 0.0;
|
||||
$item['deposit_min_amount'] = $depMin;
|
||||
$item['can_upload_gancao_reciperl'] = PrescriptionOrderLogic::canUploadGancaoRecipel(
|
||||
$item,
|
||||
$this->adminId,
|
||||
$this->adminInfo,
|
||||
$assistantByDiag
|
||||
);
|
||||
|
||||
// 添加创建人姓名
|
||||
$creatorId = (int) ($item['creator_id'] ?? 0);
|
||||
@@ -146,6 +158,7 @@ class PrescriptionOrderLists extends BaseAdminDataLists implements ListsSearchIn
|
||||
{
|
||||
return [
|
||||
'deposit_min_amount' => PrescriptionOrderLogic::depositMinAmount(),
|
||||
'gancao_scm_enabled' => GancaoScmRecipelService::isConfigured(),
|
||||
];
|
||||
}
|
||||
|
||||
|
||||
@@ -227,6 +227,9 @@ class PrescriptionLogic
|
||||
'sn' => $sn,
|
||||
'prescription_name' => $params['prescription_name'] ?? '',
|
||||
'prescription_type' => $params['prescription_type'] ?? '浓缩水丸',
|
||||
'dosage_amount' => isset($params['dosage_amount']) ? (float)$params['dosage_amount'] : null,
|
||||
'dosage_unit' => $params['dosage_unit'] ?? '',
|
||||
'need_decoction' => (int)($params['need_decoction'] ?? 0),
|
||||
'diagnosis_id' => (int)($params['diagnosis_id'] ?? 0),
|
||||
'appointment_id' => (int)($params['appointment_id'] ?? 0),
|
||||
'patient_id' => (int)($params['patient_id'] ?? 0),
|
||||
@@ -246,6 +249,7 @@ class PrescriptionLogic
|
||||
'dose_count' => (int)($params['dose_count'] ?? 1),
|
||||
'dose_unit' => $params['dose_unit'] ?? '剂',
|
||||
'usage_days' => (int)($params['usage_days'] ?? 7),
|
||||
'times_per_day' => (int)($params['times_per_day'] ?? 2),
|
||||
'usage_instruction' => $params['usage_instruction'] ?? '水煎服一日二次',
|
||||
'usage_time' => $params['usage_time'] ?? '饭前',
|
||||
'usage_way' => $params['usage_way'] ?? '温水送服',
|
||||
@@ -351,6 +355,9 @@ class PrescriptionLogic
|
||||
'assistant_id' => $assistantIdForRx,
|
||||
'prescription_name' => $params['prescription_name'] ?? $prescription->prescription_name,
|
||||
'prescription_type' => $params['prescription_type'] ?? $prescription->prescription_type,
|
||||
'dosage_amount' => isset($params['dosage_amount']) ? (float)$params['dosage_amount'] : $prescription->dosage_amount,
|
||||
'dosage_unit' => $params['dosage_unit'] ?? $prescription->dosage_unit,
|
||||
'need_decoction' => isset($params['need_decoction']) ? (int)$params['need_decoction'] : (int)($prescription->need_decoction ?? 0),
|
||||
'patient_name' => $params['patient_name'] ?? $prescription->patient_name,
|
||||
'gender' => (int)($params['gender'] ?? $prescription->gender),
|
||||
'age' => (int)($params['age'] ?? $prescription->age),
|
||||
@@ -365,6 +372,7 @@ class PrescriptionLogic
|
||||
'dose_count' => (int)($params['dose_count'] ?? $prescription->dose_count),
|
||||
'dose_unit' => $params['dose_unit'] ?? $prescription->dose_unit,
|
||||
'usage_days' => (int)($params['usage_days'] ?? $prescription->usage_days),
|
||||
'times_per_day' => (int)($params['times_per_day'] ?? $prescription->times_per_day ?? 2),
|
||||
'usage_instruction' => $params['usage_instruction'] ?? $prescription->usage_instruction,
|
||||
'usage_time' => $params['usage_time'] ?? $prescription->usage_time,
|
||||
'usage_way' => $params['usage_way'] ?? $prescription->usage_way,
|
||||
|
||||
@@ -14,7 +14,9 @@ use app\common\model\tcm\PrescriptionOrderLog;
|
||||
use app\common\model\tcm\PrescriptionOrderPayOrder;
|
||||
use app\common\model\auth\Admin;
|
||||
use app\common\service\ExpressTrackService;
|
||||
use app\common\service\gancao\GancaoScmRecipelService;
|
||||
use think\facade\Config;
|
||||
use think\facade\Log;
|
||||
|
||||
class PrescriptionOrderLogic
|
||||
{
|
||||
@@ -672,9 +674,23 @@ class PrescriptionOrderLogic
|
||||
|
||||
$order->recipient_name = (string) $params['recipient_name'];
|
||||
$order->recipient_phone = (string) $params['recipient_phone'];
|
||||
|
||||
// 处理省市区字段
|
||||
if (isset($params['shipping_province'])) {
|
||||
$order->shipping_province = (string) $params['shipping_province'];
|
||||
}
|
||||
if (isset($params['shipping_city'])) {
|
||||
$order->shipping_city = (string) $params['shipping_city'];
|
||||
}
|
||||
if (isset($params['shipping_district'])) {
|
||||
$order->shipping_district = (string) $params['shipping_district'];
|
||||
}
|
||||
|
||||
$order->shipping_address = (string) $params['shipping_address'];
|
||||
$order->is_follow_up = (int) ($params['is_follow_up'] ?? 0) === 1 ? 1 : 0;
|
||||
$order->medication_days = $medDays > 0 ? $medDays : null;
|
||||
$order->dose_unit = (string) ($params['dose_unit'] ?? '剂');
|
||||
$order->dose_count = isset($params['dose_count']) && (int)$params['dose_count'] > 0 ? (int)$params['dose_count'] : 1;
|
||||
$order->prev_staff = (string) ($params['prev_staff'] ?? '');
|
||||
$order->service_channel = (string) ($params['service_channel'] ?? '');
|
||||
$order->service_package = (string) ($params['service_package'] ?? '');
|
||||
@@ -1154,6 +1170,28 @@ class PrescriptionOrderLogic
|
||||
return false;
|
||||
}
|
||||
|
||||
// 同步撤回处方表的审核状态
|
||||
$prescriptionId = (int) $order->prescription_id;
|
||||
if ($prescriptionId > 0) {
|
||||
try {
|
||||
$prescription = Prescription::where('id', $prescriptionId)->whereNull('delete_time')->find();
|
||||
if ($prescription) {
|
||||
$prescription->audit_status = 0;
|
||||
$prescription->audit_remark = '';
|
||||
$prescription->audit_time = null;
|
||||
$prescription->audit_by = null;
|
||||
$prescription->audit_by_name = '';
|
||||
$prescription->save();
|
||||
}
|
||||
} catch (\Throwable $e) {
|
||||
// 记录日志但不影响主流程
|
||||
Log::error('撤回处方审核时同步处方表失败: ' . $e->getMessage(), [
|
||||
'prescription_id' => $prescriptionId,
|
||||
'order_id' => $id
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
self::writeLog((int) $order->id, $adminId, $adminInfo, 'revoke_rx_audit', '撤回处方审核,状态重置为待审核');
|
||||
|
||||
$out = $order->toArray();
|
||||
@@ -1478,7 +1516,17 @@ class PrescriptionOrderLogic
|
||||
return false;
|
||||
}
|
||||
|
||||
// 计算关联订单总额
|
||||
$linkedPayOrderIds = self::linkedPayOrderIdList($id);
|
||||
$linkedPayOrders = self::linkedPayOrdersPayload($linkedPayOrderIds);
|
||||
$linkedPayPaidTotal = 0.0;
|
||||
foreach ($linkedPayOrders as $o) {
|
||||
$linkedPayPaidTotal += (float) ($o['amount'] ?? 0);
|
||||
}
|
||||
$linkedPayPaidTotal = round($linkedPayPaidTotal, 2);
|
||||
|
||||
$order->fulfillment_status = 3;
|
||||
$order->paid = $linkedPayPaidTotal;
|
||||
|
||||
try {
|
||||
$order->save();
|
||||
@@ -1487,7 +1535,7 @@ class PrescriptionOrderLogic
|
||||
return false;
|
||||
}
|
||||
|
||||
self::writeLog($id, $adminId, $adminInfo, 'complete', '订单完成,状态变更为「已完成」');
|
||||
self::writeLog($id, $adminId, $adminInfo, 'complete', '订单完成,状态变更为「已完成」,实付金额更新为 ¥' . $linkedPayPaidTotal);
|
||||
|
||||
$out = $order->toArray();
|
||||
self::maskInternalCostIfNeeded($out, $adminInfo);
|
||||
@@ -1496,6 +1544,338 @@ class PrescriptionOrderLogic
|
||||
return $out;
|
||||
}
|
||||
|
||||
/**
|
||||
* 列表行是否展示「上传甘草药方」(需配置甘草、处方审核通过、未上传过、有权限;不要求支付审核)。
|
||||
*
|
||||
* @param array<string,mixed> $item
|
||||
* @param array<int, int|string> $assistantByDiag diagnosis_id => assistant_id
|
||||
*/
|
||||
public static function canUploadGancaoRecipel(array $item, int $adminId, array $adminInfo, array $assistantByDiag = []): bool
|
||||
{
|
||||
if (!GancaoScmRecipelService::isConfigured()) {
|
||||
return false;
|
||||
}
|
||||
$pa = (int) ($item['prescription_audit_status'] ?? 0);
|
||||
$fs = (int) ($item['fulfillment_status'] ?? 0);
|
||||
if ($pa !== 1) {
|
||||
return false;
|
||||
}
|
||||
if ($fs === 3 || $fs === 4) {
|
||||
return false;
|
||||
}
|
||||
if (trim((string) ($item['gancao_reciperl_order_no'] ?? '')) !== '') {
|
||||
return false;
|
||||
}
|
||||
if (self::canSeeAllPrescriptionOrders($adminInfo)) {
|
||||
return true;
|
||||
}
|
||||
if ((int) ($item['creator_id'] ?? 0) === $adminId) {
|
||||
return true;
|
||||
}
|
||||
$did = (int) ($item['diagnosis_id'] ?? 0);
|
||||
$aid = (int) ($assistantByDiag[$did] ?? 0);
|
||||
|
||||
return $aid === $adminId && $adminId > 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* 将当前业务订单关联处方提交至甘草药管家(CTM_PREVIEW → CTM_SUBMIT_RECIPEL)。
|
||||
*
|
||||
* @return array<string,mixed>|false 成功返回甘草 result 主要字段
|
||||
*/
|
||||
public static function submitGancaoRecipel(int $id, int $adminId, array $adminInfo)
|
||||
{
|
||||
|
||||
self::$error = '';
|
||||
if (!GancaoScmRecipelService::isConfigured()) {
|
||||
$why = GancaoScmRecipelService::whyNotConfigured();
|
||||
self::$error = $why !== '' ? $why : '甘草处方上传未启用或未配置完整,请检查 .env(GANCAO_SCM_*)须写在任意 [分区] 之前,或使用 [GANCAO_SCM] 分区';
|
||||
|
||||
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;
|
||||
}
|
||||
$item = $order->toArray();
|
||||
$assistantByDiag = Diagnosis::where('id', (int) $order->diagnosis_id)->whereNull('delete_time')->column('assistant_id', 'id');
|
||||
|
||||
if (!self::canUploadGancaoRecipel($item, $adminId, $adminInfo, $assistantByDiag)) {
|
||||
self::$error = '须处方审核通过后方可上传甘草;已完成/已取消订单不可上传;同一订单仅可上传一次';
|
||||
|
||||
return false;
|
||||
}
|
||||
$rxId = (int) $order->prescription_id;
|
||||
|
||||
if ($rxId <= 0) {
|
||||
self::$error = '订单未关联处方';
|
||||
|
||||
return false;
|
||||
}
|
||||
$rx = PrescriptionLogic::detail($rxId, $adminId, $adminInfo);
|
||||
if ($rx === null) {
|
||||
self::$error = PrescriptionLogic::getError() !== '' ? PrescriptionLogic::getError() : '处方不存在';
|
||||
|
||||
return false;
|
||||
}
|
||||
$c = Config::get('gancao_scm', []);
|
||||
$nameMap = is_array($c['herb_id_map'] ?? null) ? $c['herb_id_map'] : [];
|
||||
$herbs = is_array($rx['herbs'] ?? null) ? $rx['herbs'] : [];
|
||||
$docGidMap = GancaoScmRecipelService::doctorMedicineGidMapForHerbs($herbs);
|
||||
|
||||
[, $missing] = GancaoScmRecipelService::buildMList($herbs, $nameMap, $docGidMap);
|
||||
if ($missing !== []) {
|
||||
self::$error = '以下药材无法匹配甘草药ID(请核对医师药品库 zyt_doctor_medicine 中同名字段的 gid,或在处方 herbs 中写 gc_id / config 的 herb_id_map):' . implode('、', $missing);
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
$token = GancaoScmRecipelService::getToken(false);
|
||||
if ($token === null || $token === '') {
|
||||
$why = GancaoScmRecipelService::getLastGetTokenError();
|
||||
self::$error = $why !== '' ? $why : '获取甘草开放平台 token 失败,请检查 GANCAO_SCM_GATEWAY_*(网关 OpenAPI)与 GANCAO_SCM_BIZ_*(业务账号,常与网关不同)、网关地址及服务器时间';
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
$orderArr = $order->toArray();
|
||||
|
||||
// dump($rx);
|
||||
// dump($orderArr);
|
||||
$previewPayload = GancaoScmRecipelService::buildPreviewPayload($rx, $orderArr, $token);
|
||||
|
||||
$prevRet = GancaoScmRecipelService::ctmPreview($previewPayload);
|
||||
|
||||
if ((int) ($prevRet['state'] ?? 0) !== 1) {
|
||||
self::$error = '甘草预检查通信失败:' . (string) ($prevRet['msg'] ?? '');
|
||||
|
||||
return false;
|
||||
}
|
||||
$prevBody = $prevRet['body'] ?? [];
|
||||
|
||||
if (!GancaoScmRecipelService::isApiSuccess($prevBody)) {
|
||||
self::$error = '甘草预检查未通过:' . GancaoScmRecipelService::apiStatusMessage($prevBody);
|
||||
|
||||
return false;
|
||||
}
|
||||
$result = $prevBody['result'] ?? [];
|
||||
|
||||
if (is_array($result)) {
|
||||
$rules = $result['rule_check'] ?? [];
|
||||
if (is_array($rules)) {
|
||||
foreach ($rules as $ru) {
|
||||
if (!is_array($ru)) {
|
||||
continue;
|
||||
}
|
||||
$t = (int) ($ru['type'] ?? 0);
|
||||
if ($t >= 2) {
|
||||
self::$error = '甘草预检查规则拦截:' . (string) ($ru['msg'] ?? '') . '(code:' . (string) ($ru['code'] ?? '') . ')';
|
||||
|
||||
return false;
|
||||
}
|
||||
}
|
||||
}
|
||||
$mListOut = $result['m_list'] ?? [];
|
||||
if (is_array($mListOut)) {
|
||||
foreach ($mListOut as $rowM) {
|
||||
if (is_array($rowM) && isset($rowM['is_available']) && (int) $rowM['is_available'] === 0) {
|
||||
$tn = (string) ($rowM['title'] ?? '');
|
||||
self::$error = '甘草药库缺药,请调整处方:' . ($tn !== '' ? $tn : '未知药材');
|
||||
|
||||
return false;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
$addrParts = GancaoScmRecipelService::splitCnAddress((string) $order->shipping_address);
|
||||
|
||||
$appNo = (string) $order->order_no;
|
||||
|
||||
$submitPayload = GancaoScmRecipelService::buildSubmitPayload($rx, $orderArr, $token, $addrParts, $appNo);
|
||||
|
||||
$subRet = GancaoScmRecipelService::ctmSubmit($submitPayload);
|
||||
|
||||
if ((int) ($subRet['state'] ?? 0) !== 1) {
|
||||
$errMsg = (string) ($subRet['msg'] ?? '');
|
||||
$response = isset($subRet['response']) ? mb_substr((string) $subRet['response'], 0, 500) : '';
|
||||
self::$error = '甘草下单通信失败:' . $errMsg . ($response !== '' ? ';响应:' . $response : '');
|
||||
Log::error('Gancao CTM_SUBMIT failed', [
|
||||
'subRet' => $subRet,
|
||||
'payload' => json_encode(GancaoScmRecipelService::maskSensitiveData($submitPayload), JSON_UNESCAPED_UNICODE)
|
||||
]);
|
||||
|
||||
return false;
|
||||
}
|
||||
$subBody = $subRet['body'] ?? [];
|
||||
|
||||
if (!GancaoScmRecipelService::isApiSuccess($subBody)) {
|
||||
self::$error = '甘草下单失败:' . GancaoScmRecipelService::apiStatusMessage($subBody);
|
||||
Log::error('Gancao CTM_SUBMIT api error', [
|
||||
'body' => $subBody,
|
||||
'payload' => json_encode(GancaoScmRecipelService::maskSensitiveData($submitPayload), JSON_UNESCAPED_UNICODE)
|
||||
]);
|
||||
|
||||
return false;
|
||||
}
|
||||
$gcNo = (string) (($subBody['result'] ?? [])['recipel_order_no'] ?? '');
|
||||
if ($gcNo === '') {
|
||||
self::$error = '甘草返回缺少 recipel_order_no';
|
||||
|
||||
return false;
|
||||
}
|
||||
$order->gancao_reciperl_order_no = mb_substr($gcNo, 0, 32);
|
||||
$order->gancao_submit_time = time();
|
||||
try {
|
||||
$order->save();
|
||||
} catch (\Throwable $e) {
|
||||
self::$error = '本地保存甘草单号失败:' . $e->getMessage();
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
self::writeLog($id, $adminId, $adminInfo, 'gancao_submit', '上传甘草药方成功 ' . $gcNo);
|
||||
|
||||
$fee = $subBody['result']['fee'] ?? [];
|
||||
$appNo = $submitPayload['app_order_no'] ?? ('PO' . (string) $order->id);
|
||||
|
||||
return [
|
||||
'recipel_order_no' => $gcNo,
|
||||
'app_order_no' => $appNo,
|
||||
'fee' => is_array($fee) ? $fee : [],
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* 甘草预下单测试(仅 CTM_PREVIEW,不提交订单)
|
||||
* 用于在编辑订单时测试价格和配置
|
||||
*
|
||||
* @param array $params 包含 id 和可选的 dose_count
|
||||
* @return array<string,mixed>|false 成功返回预览结果(包含价格信息)
|
||||
*/
|
||||
public static function previewGancaoRecipel(int $id, int $adminId, array $adminInfo, array $params = [])
|
||||
{
|
||||
self::$error = '';
|
||||
if (!GancaoScmRecipelService::isConfigured()) {
|
||||
$why = GancaoScmRecipelService::whyNotConfigured();
|
||||
self::$error = $why !== '' ? $why : '甘草处方上传未启用或未配置完整';
|
||||
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;
|
||||
}
|
||||
|
||||
$rxId = (int) $order->prescription_id;
|
||||
if ($rxId <= 0) {
|
||||
self::$error = '订单未关联处方';
|
||||
return false;
|
||||
}
|
||||
|
||||
$rx = PrescriptionLogic::detail($rxId, $adminId, $adminInfo);
|
||||
if ($rx === null) {
|
||||
self::$error = PrescriptionLogic::getError() !== '' ? PrescriptionLogic::getError() : '处方不存在';
|
||||
return false;
|
||||
}
|
||||
|
||||
$c = Config::get('gancao_scm', []);
|
||||
$nameMap = is_array($c['herb_id_map'] ?? null) ? $c['herb_id_map'] : [];
|
||||
$herbs = is_array($rx['herbs'] ?? null) ? $rx['herbs'] : [];
|
||||
$docGidMap = GancaoScmRecipelService::doctorMedicineGidMapForHerbs($herbs);
|
||||
|
||||
[, $missing] = GancaoScmRecipelService::buildMList($herbs, $nameMap, $docGidMap);
|
||||
if ($missing !== []) {
|
||||
self::$error = '以下药材无法匹配甘草药ID:' . implode('、', $missing);
|
||||
return false;
|
||||
}
|
||||
|
||||
$token = GancaoScmRecipelService::getToken(false);
|
||||
if ($token === null || $token === '') {
|
||||
$why = GancaoScmRecipelService::getLastGetTokenError();
|
||||
self::$error = $why !== '' ? $why : '获取甘草开放平台 token 失败';
|
||||
return false;
|
||||
}
|
||||
|
||||
$orderArr = $order->toArray();
|
||||
|
||||
// 如果传递了 dose_count 参数,使用传递的值覆盖订单中的值
|
||||
if (isset($params['dose_count']) && (int)$params['dose_count'] > 0) {
|
||||
$orderArr['dose_count'] = (int)$params['dose_count'];
|
||||
}
|
||||
|
||||
// 如果传递了 medication_days 参数,使用传递的值覆盖订单中的值
|
||||
if (isset($params['medication_days']) && (int)$params['medication_days'] > 0) {
|
||||
$orderArr['medication_days'] = (int)$params['medication_days'];
|
||||
}
|
||||
|
||||
$previewPayload = GancaoScmRecipelService::buildPreviewPayload($rx, $orderArr, $token);
|
||||
$prevRet = GancaoScmRecipelService::ctmPreview($previewPayload);
|
||||
|
||||
if ((int) ($prevRet['state'] ?? 0) !== 1) {
|
||||
self::$error = '甘草预检查通信失败:' . (string) ($prevRet['msg'] ?? '');
|
||||
return false;
|
||||
}
|
||||
|
||||
$prevBody = $prevRet['body'] ?? [];
|
||||
if (!GancaoScmRecipelService::isApiSuccess($prevBody)) {
|
||||
self::$error = '甘草预检查未通过:' . GancaoScmRecipelService::apiStatusMessage($prevBody);
|
||||
return false;
|
||||
}
|
||||
|
||||
$result = $prevBody['result'] ?? [];
|
||||
|
||||
// 检查规则拦截
|
||||
if (is_array($result)) {
|
||||
$rules = $result['rule_check'] ?? [];
|
||||
if (is_array($rules)) {
|
||||
foreach ($rules as $ru) {
|
||||
if (!is_array($ru)) continue;
|
||||
$t = (int) ($ru['type'] ?? 0);
|
||||
if ($t >= 2) {
|
||||
self::$error = '甘草预检查规则拦截:' . (string) ($ru['msg'] ?? '');
|
||||
return false;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 检查药材可用性
|
||||
$mListOut = $result['m_list'] ?? [];
|
||||
if (is_array($mListOut)) {
|
||||
foreach ($mListOut as $rowM) {
|
||||
if (is_array($rowM) && isset($rowM['is_available']) && (int) $rowM['is_available'] === 0) {
|
||||
$tn = (string) ($rowM['title'] ?? '');
|
||||
self::$error = '甘草药库缺药:' . ($tn !== '' ? $tn : '未知药材');
|
||||
return false;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 返回预览结果
|
||||
$fee = $result['fee'] ?? [];
|
||||
return [
|
||||
'success' => true,
|
||||
'fee' => is_array($fee) ? $fee : [],
|
||||
'result' => $result,
|
||||
'order_no' => (string) $order->order_no,
|
||||
'dose_count' => (int) ($orderArr['dose_count'] ?? $rx['dose_count'] ?? 0),
|
||||
];
|
||||
}
|
||||
|
||||
private static function writeLog(int $orderId, int $adminId, array $adminInfo, string $action, string $summary): void
|
||||
{
|
||||
$adminName = $adminInfo['name'] ?? '';
|
||||
|
||||
@@ -65,5 +65,7 @@ class PrescriptionOrderValidate extends BaseValidate
|
||||
'addPayOrder' => ['id', 'order_type', 'pay_amount', 'pay_remark'],
|
||||
'linkPayOrder' => ['id', 'pay_order_id'],
|
||||
'complete' => ['id'],
|
||||
'submitGancaoRecipel' => ['id'],
|
||||
'previewGancaoRecipel' => ['id'],
|
||||
];
|
||||
}
|
||||
|
||||
@@ -0,0 +1,349 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace app\api\controller;
|
||||
|
||||
use app\BaseController;
|
||||
use app\common\model\tcm\PrescriptionOrder;
|
||||
use app\common\model\tcm\PrescriptionOrderLog;
|
||||
use think\facade\Config;
|
||||
use think\facade\Log;
|
||||
use think\Response;
|
||||
|
||||
/**
|
||||
* 甘草订单状态回调控制器
|
||||
*
|
||||
* @see https://apidoc.igancao.com/service-doc/scm-outer-recipel.html#订单状态回调
|
||||
*/
|
||||
class GancaoCallbackController extends BaseController
|
||||
{
|
||||
/**
|
||||
* 甘草订单状态回调接口
|
||||
*
|
||||
* 回调地址在【中药处方下单】时用 callback_url 字段传入
|
||||
* 当订单状态发生变化后会触发业务回调
|
||||
*
|
||||
* @return Response
|
||||
*/
|
||||
public function orderStatus(): Response
|
||||
{
|
||||
try {
|
||||
// 获取原始请求体
|
||||
$rawBody = file_get_contents('php://input');
|
||||
|
||||
// 获取请求头
|
||||
$headers = $this->request->header();
|
||||
$accessAppkey = $headers['access-appkey'] ?? '';
|
||||
$accessNonce = $headers['access-nonce'] ?? '';
|
||||
$accessTimestamp = $headers['access-timestamp'] ?? '';
|
||||
$accessSign = $headers['access-sign'] ?? '';
|
||||
|
||||
// 记录回调请求
|
||||
Log::info('Gancao callback received', [
|
||||
'headers' => [
|
||||
'access-appkey' => $accessAppkey,
|
||||
'access-nonce' => $accessNonce,
|
||||
'access-timestamp' => $accessTimestamp,
|
||||
'access-sign' => $accessSign,
|
||||
],
|
||||
'body' => $rawBody,
|
||||
]);
|
||||
|
||||
// 验证签名
|
||||
if (!$this->verifySign($accessAppkey, $accessNonce, $accessTimestamp, $accessSign, $rawBody)) {
|
||||
Log::warning('Gancao callback sign verification failed', [
|
||||
'access-appkey' => $accessAppkey,
|
||||
'access-sign' => $accessSign,
|
||||
]);
|
||||
return $this->response('sign verification failed', 403);
|
||||
}
|
||||
|
||||
// 解析回调数据
|
||||
$data = json_decode($rawBody, true);
|
||||
if (!is_array($data)) {
|
||||
Log::error('Gancao callback invalid json', ['body' => $rawBody]);
|
||||
return $this->response('invalid json', 400);
|
||||
}
|
||||
|
||||
// 处理回调数据
|
||||
$this->handleCallback($data);
|
||||
|
||||
// 必须在5秒内返回 "ok",否则会认为回调失败
|
||||
return $this->response('ok');
|
||||
|
||||
} catch (\Throwable $e) {
|
||||
Log::error('Gancao callback exception', [
|
||||
'message' => $e->getMessage(),
|
||||
'file' => $e->getFile(),
|
||||
'line' => $e->getLine(),
|
||||
'trace' => $e->getTraceAsString(),
|
||||
]);
|
||||
|
||||
// 即使出错也要返回 ok,避免甘草重试
|
||||
return $this->response('ok');
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 验证回调签名
|
||||
*
|
||||
* 签名算法:md5(access-appkey + secret-key + access-nonce + access-timestamp + $sBody)
|
||||
*
|
||||
* @param string $appkey
|
||||
* @param string $nonce
|
||||
* @param string $timestamp
|
||||
* @param string $sign
|
||||
* @param string $body
|
||||
* @return bool
|
||||
*/
|
||||
private function verifySign(string $appkey, string $nonce, string $timestamp, string $sign, string $body): bool
|
||||
{
|
||||
$config = Config::get('gancao_scm', []);
|
||||
|
||||
// 获取配置的 appkey 和 secret-key
|
||||
$configAppkey = (string) ($config['callback_appkey'] ?? $config['biz_ak'] ?? '');
|
||||
$secretKey = (string) ($config['callback_secret_key'] ?? $config['biz_sk'] ?? '');
|
||||
|
||||
// 验证 appkey
|
||||
if ($appkey !== $configAppkey) {
|
||||
Log::warning('Gancao callback appkey mismatch', [
|
||||
'received' => $appkey,
|
||||
'expected' => $configAppkey,
|
||||
]);
|
||||
return false;
|
||||
}
|
||||
|
||||
// 计算签名
|
||||
$expectedSign = md5($appkey . $secretKey . $nonce . $timestamp . $body);
|
||||
|
||||
// 验证签名
|
||||
if ($sign !== $expectedSign) {
|
||||
Log::warning('Gancao callback sign mismatch', [
|
||||
'received' => $sign,
|
||||
'expected' => $expectedSign,
|
||||
]);
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* 处理回调数据
|
||||
*
|
||||
* @param array<string,mixed> $data
|
||||
* @return void
|
||||
*/
|
||||
private function handleCallback(array $data): void
|
||||
{
|
||||
$recipelOrderNo = (string) ($data['recipel_order_no'] ?? '');
|
||||
$state = (int) ($data['state'] ?? 0);
|
||||
$ext = $data['ext'] ?? [];
|
||||
|
||||
if ($recipelOrderNo === '') {
|
||||
Log::warning('Gancao callback missing recipel_order_no', ['data' => $data]);
|
||||
return;
|
||||
}
|
||||
|
||||
// 查找订单
|
||||
$order = PrescriptionOrder::where('gancao_reciperl_order_no', $recipelOrderNo)
|
||||
->whereNull('delete_time')
|
||||
->find();
|
||||
|
||||
if (!$order) {
|
||||
Log::warning('Gancao callback order not found', [
|
||||
'recipel_order_no' => $recipelOrderNo,
|
||||
]);
|
||||
return;
|
||||
}
|
||||
|
||||
// 更新订单状态
|
||||
$this->updateOrderStatus($order, $state, $ext);
|
||||
|
||||
// 记录日志
|
||||
$this->writeCallbackLog($order, $state, $ext);
|
||||
|
||||
Log::info('Gancao callback processed', [
|
||||
'order_id' => $order->id,
|
||||
'recipel_order_no' => $recipelOrderNo,
|
||||
'state' => $state,
|
||||
'ext' => $ext,
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* 更新订单状态
|
||||
*
|
||||
* 状态说明:
|
||||
* - 10: 系统审核中
|
||||
* - 11: 系统审核通过
|
||||
* - 110: 订单药房流转制作中
|
||||
* - 20: 物流中
|
||||
* - 30: 完成(终态)
|
||||
* - 90: 拦截(终止流转:可恢复)
|
||||
* - 91: 主动撤单(退费:终态)
|
||||
* - 92: 驳回(无法制作并退费:终态)
|
||||
*
|
||||
* @param PrescriptionOrder $order
|
||||
* @param int $state
|
||||
* @param array<string,mixed> $ext
|
||||
* @return void
|
||||
*/
|
||||
private function updateOrderStatus(PrescriptionOrder $order, int $state, array $ext): void
|
||||
{
|
||||
// 保存甘草订单状态
|
||||
$order->gancao_order_state = $state;
|
||||
|
||||
// 根据状态更新订单履约状态
|
||||
switch ($state) {
|
||||
case 10: // 系统审核中
|
||||
case 11: // 系统审核通过
|
||||
// 保持当前状态
|
||||
break;
|
||||
|
||||
case 110: // 订单药房流转制作中
|
||||
$flowName = (string) ($ext['flow_name'] ?? '');
|
||||
$supplier = (string) ($ext['supplier'] ?? '');
|
||||
|
||||
// 记录流程信息
|
||||
$order->gancao_flow_name = mb_substr($flowName, 0, 100);
|
||||
$order->gancao_supplier = mb_substr($supplier, 0, 100);
|
||||
|
||||
// 如果是发货流程,更新履约状态
|
||||
if (str_contains($flowName, '发货') || str_contains($flowName, '寄出')) {
|
||||
if ((int) $order->fulfillment_status === 2) {
|
||||
$order->fulfillment_status = 5; // 已发货
|
||||
}
|
||||
}
|
||||
break;
|
||||
|
||||
case 20: // 物流中
|
||||
$shippingName = (string) ($ext['shipping_name'] ?? '');
|
||||
$nu = (string) ($ext['nu'] ?? '');
|
||||
$supplier = (string) ($ext['supplier'] ?? '');
|
||||
|
||||
// 更新物流信息
|
||||
if ($nu !== '') {
|
||||
$order->tracking_number = mb_substr($nu, 0, 100);
|
||||
}
|
||||
if ($shippingName !== '') {
|
||||
// 转换快递公司名称
|
||||
$expressMap = [
|
||||
'顺丰' => 'sf',
|
||||
'京东' => 'jd',
|
||||
'极兔' => 'jt',
|
||||
];
|
||||
foreach ($expressMap as $name => $code) {
|
||||
if (str_contains($shippingName, $name)) {
|
||||
$order->express_company = $code;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 更新履约状态为已发货
|
||||
if ((int) $order->fulfillment_status === 2) {
|
||||
$order->fulfillment_status = 5;
|
||||
}
|
||||
break;
|
||||
|
||||
case 30: // 完成
|
||||
// 更新履约状态为已完成
|
||||
if ((int) $order->fulfillment_status !== 4) {
|
||||
$order->fulfillment_status = 3;
|
||||
}
|
||||
break;
|
||||
|
||||
case 90: // 拦截
|
||||
// 记录拦截原因
|
||||
$order->gancao_remark = '订单被拦截';
|
||||
break;
|
||||
|
||||
case 91: // 主动撤单
|
||||
// 更新履约状态为已取消
|
||||
if ((int) $order->fulfillment_status !== 3) {
|
||||
$order->fulfillment_status = 4;
|
||||
}
|
||||
$order->gancao_remark = '主动撤单(已退费)';
|
||||
break;
|
||||
|
||||
case 92: // 驳回
|
||||
// 更新履约状态为已取消
|
||||
if ((int) $order->fulfillment_status !== 3) {
|
||||
$order->fulfillment_status = 4;
|
||||
}
|
||||
$order->gancao_remark = '订单被驳回(无法制作并退费)';
|
||||
break;
|
||||
}
|
||||
|
||||
try {
|
||||
$order->save();
|
||||
} catch (\Throwable $e) {
|
||||
Log::error('Gancao callback update order failed', [
|
||||
'order_id' => $order->id,
|
||||
'error' => $e->getMessage(),
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 记录回调日志
|
||||
*
|
||||
* @param PrescriptionOrder $order
|
||||
* @param int $state
|
||||
* @param array<string,mixed> $ext
|
||||
* @return void
|
||||
*/
|
||||
private function writeCallbackLog(PrescriptionOrder $order, int $state, array $ext): void
|
||||
{
|
||||
$stateMap = [
|
||||
10 => '系统审核中',
|
||||
11 => '系统审核通过',
|
||||
110 => '订单药房流转制作中',
|
||||
20 => '物流中',
|
||||
30 => '完成',
|
||||
90 => '拦截',
|
||||
91 => '主动撤单',
|
||||
92 => '驳回',
|
||||
];
|
||||
|
||||
$stateName = $stateMap[$state] ?? "状态{$state}";
|
||||
|
||||
$summary = "甘草订单状态更新:{$stateName}";
|
||||
|
||||
// 添加扩展信息
|
||||
if (isset($ext['flow_name'])) {
|
||||
$summary .= ",流程:{$ext['flow_name']}";
|
||||
}
|
||||
if (isset($ext['shipping_name']) && isset($ext['nu'])) {
|
||||
$summary .= ",物流:{$ext['shipping_name']} {$ext['nu']}";
|
||||
}
|
||||
|
||||
$log = new PrescriptionOrderLog();
|
||||
$log->prescription_order_id = (int) $order->id;
|
||||
$log->admin_id = 0; // 系统回调
|
||||
$log->admin_name = '甘草系统';
|
||||
$log->action = 'gancao_callback';
|
||||
$log->summary = mb_substr($summary, 0, 500);
|
||||
$log->create_time = time();
|
||||
|
||||
try {
|
||||
$log->save();
|
||||
} catch (\Throwable $e) {
|
||||
// 忽略日志写入错误
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 返回响应
|
||||
*
|
||||
* @param string $message
|
||||
* @param int $code
|
||||
* @return Response
|
||||
*/
|
||||
private function response(string $message, int $code = 200): Response
|
||||
{
|
||||
return response($message, $code, [], 'html');
|
||||
}
|
||||
}
|
||||
@@ -23,6 +23,8 @@ class Medicine extends BaseModel
|
||||
'stock' => 'int',
|
||||
'image' => 'string',
|
||||
'status' => 'int',
|
||||
'type' => 'string',
|
||||
'gid' => 'string',
|
||||
'remark' => 'string',
|
||||
'create_time' => 'int',
|
||||
'update_time' => 'int',
|
||||
|
||||
@@ -21,6 +21,12 @@ class Prescription extends BaseModel
|
||||
protected $json = ['herbs', 'case_record'];
|
||||
protected $jsonAssoc = true;
|
||||
|
||||
// 字段类型转换
|
||||
protected $type = [
|
||||
'dosage_amount' => 'float',
|
||||
'need_decoction' => 'integer',
|
||||
];
|
||||
|
||||
// 追加字段
|
||||
protected $append = ['gender_desc'];
|
||||
|
||||
|
||||
@@ -0,0 +1,164 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace app\common\service\gancao;
|
||||
|
||||
/**
|
||||
* 甘草开放平台网关传输(AES-128-ECB + HTTP 头),对齐官方 GcOpenApi.php。
|
||||
*/
|
||||
final class GancaoOpenApiTransport
|
||||
{
|
||||
private string $url;
|
||||
|
||||
private string $ak;
|
||||
|
||||
private string $sk;
|
||||
|
||||
private string $userAgent;
|
||||
|
||||
public function __construct(string $url, string $ak, string $sk, string $userAgent = 'zyt-admin/1.0')
|
||||
{
|
||||
$this->url = rtrim($url, '/');
|
||||
$this->ak = $ak;
|
||||
$this->sk = $sk;
|
||||
$this->userAgent = $userAgent;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string,mixed> $payload 已含 package、class 及业务字段
|
||||
* @return array{state:int,msg:string,body?:array<string,mixed>,response?:string}
|
||||
*/
|
||||
public function post(array $payload): array
|
||||
{
|
||||
// 与甘草网关规范一致:签名/头里的时间戳须与 body 内业务字段 timestamp(若有)一致,避免跨秒不一致导致签名校验失败
|
||||
$sigTs = time();
|
||||
if (isset($payload['timestamp']) && is_numeric($payload['timestamp'])) {
|
||||
$sigTs = (int) $payload['timestamp'];
|
||||
}
|
||||
if ($sigTs <= 0) {
|
||||
$sigTs = time();
|
||||
}
|
||||
$json = json_encode($payload, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES);
|
||||
if ($json === false) {
|
||||
return ['state' => 0, 'msg' => 'json_encode 失败'];
|
||||
}
|
||||
$noise = self::randStr(8);
|
||||
$signature = sha1($json . $sigTs . $noise . $this->sk);
|
||||
$cipher = self::encrypt($json, $this->sk);
|
||||
if ($cipher === '') {
|
||||
return ['state' => 0, 'msg' => 'AES 加密失败'];
|
||||
}
|
||||
|
||||
$headers = [
|
||||
'Connection: close',
|
||||
'Content-Type: application/json; charset=utf-8',
|
||||
'Content-length: ' . strlen($cipher),
|
||||
'Cache-Control: no-cache',
|
||||
'AK: ' . $this->ak,
|
||||
'Signature: ' . $signature,
|
||||
'UTC-Timestamp: ' . $sigTs,
|
||||
'NOISE: ' . $noise,
|
||||
'Expect:',
|
||||
];
|
||||
|
||||
$ch = curl_init($this->url);
|
||||
curl_setopt($ch, CURLOPT_HTTP_VERSION, CURL_HTTP_VERSION_1_1); // 改用 HTTP/1.1
|
||||
curl_setopt($ch, CURLOPT_HTTPHEADER, $headers);
|
||||
curl_setopt($ch, CURLOPT_USERAGENT, $this->userAgent);
|
||||
curl_setopt($ch, CURLOPT_RETURNTRANSFER, true);
|
||||
curl_setopt($ch, CURLOPT_POST, true);
|
||||
curl_setopt($ch, CURLOPT_ENCODING, 'gzip');
|
||||
curl_setopt($ch, CURLOPT_IPRESOLVE, CURL_IPRESOLVE_V4);
|
||||
curl_setopt($ch, CURLOPT_CONNECTTIMEOUT, 10); // 增加连接超时
|
||||
curl_setopt($ch, CURLOPT_TIMEOUT, 120);
|
||||
curl_setopt($ch, CURLOPT_POSTFIELDS, $cipher);
|
||||
curl_setopt($ch, CURLOPT_HEADER, true);
|
||||
if (str_starts_with($this->url, 'https:')) {
|
||||
curl_setopt($ch, CURLOPT_SSL_VERIFYPEER, false);
|
||||
curl_setopt($ch, CURLOPT_SSL_VERIFYHOST, 0); // 改为 0,完全禁用主机验证
|
||||
// 添加 SSL 相关选项
|
||||
curl_setopt($ch, CURLOPT_SSLVERSION, CURL_SSLVERSION_TLSv1_2); // 强制使用 TLS 1.2
|
||||
curl_setopt($ch, CURLOPT_SSL_CIPHER_LIST, 'DEFAULT@SECLEVEL=1'); // 降低安全级别
|
||||
}
|
||||
// 添加 TCP keepalive
|
||||
curl_setopt($ch, CURLOPT_TCP_KEEPALIVE, 1);
|
||||
curl_setopt($ch, CURLOPT_TCP_KEEPIDLE, 120);
|
||||
curl_setopt($ch, CURLOPT_TCP_KEEPINTVL, 60);
|
||||
|
||||
$raw = curl_exec($ch);
|
||||
$info = curl_getinfo($ch);
|
||||
$curlErr = curl_errno($ch);
|
||||
$curlMsg = curl_error($ch);
|
||||
curl_close($ch);
|
||||
|
||||
if ($raw === false || (int) ($info['http_code'] ?? 0) !== 200) {
|
||||
$http = (int) ($info['http_code'] ?? 0);
|
||||
$err = is_string($raw) ? $raw : '';
|
||||
|
||||
return [
|
||||
'state' => 0,
|
||||
'msg' => '通信失败:HTTP ' . $http . ($curlErr !== 0 ? ' curl#' . $curlErr . ' ' . $curlMsg : ''),
|
||||
'response' => $err,
|
||||
];
|
||||
}
|
||||
|
||||
$bodyRaw = substr($raw, strpos($raw, "\r\n\r\n") + 4);
|
||||
$plain = self::decrypt($bodyRaw, $this->sk);
|
||||
if ($plain === '') {
|
||||
$maybeJson = json_decode($bodyRaw, true);
|
||||
if (is_array($maybeJson) && isset($maybeJson['status'])) {
|
||||
return ['state' => 1, 'msg' => '成功(明文)', 'body' => $maybeJson];
|
||||
}
|
||||
|
||||
return ['state' => -1, 'msg' => '解密失败(请核对网关 SK 是否为 16 位且与 AK 匹配)', 'response' => mb_substr($bodyRaw, 0, 500)];
|
||||
}
|
||||
|
||||
$decoded = json_decode($plain, true);
|
||||
|
||||
return ['state' => 1, 'msg' => '成功', 'body' => is_array($decoded) ? $decoded : []];
|
||||
}
|
||||
|
||||
private static function encrypt(string $string, string $key): string
|
||||
{
|
||||
$out = openssl_encrypt($string, 'AES-128-ECB', $key, OPENSSL_RAW_DATA);
|
||||
|
||||
return $out !== false ? base64_encode($out) : '';
|
||||
}
|
||||
|
||||
private static function decrypt(string $string, string $key): string
|
||||
{
|
||||
$bin = base64_decode($string, true);
|
||||
if ($bin === false) {
|
||||
return '';
|
||||
}
|
||||
$out = openssl_decrypt($bin, 'AES-128-ECB', $key, OPENSSL_RAW_DATA);
|
||||
|
||||
return $out !== false ? $out : '';
|
||||
}
|
||||
|
||||
private static function randStr(int $length = 8): string
|
||||
{
|
||||
$chars = 'ABCDEFGHIJKLMNPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz1234567890';
|
||||
$chars = str_shuffle($chars);
|
||||
$end = strlen($chars) - 1;
|
||||
$buf = [];
|
||||
while (true) {
|
||||
$c = $chars[random_int(0, $end)];
|
||||
if ($c !== '0') {
|
||||
$buf[] = $c;
|
||||
break;
|
||||
}
|
||||
}
|
||||
$n = 1;
|
||||
while ($n < $length) {
|
||||
$r = $chars[random_int(0, $end)];
|
||||
if ($r !== $buf[count($buf) - 1]) {
|
||||
$buf[] = $r;
|
||||
++$n;
|
||||
}
|
||||
}
|
||||
|
||||
return implode('', $buf);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,603 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace app\common\service\gancao;
|
||||
|
||||
use app\common\model\doctor\Medicine;
|
||||
use think\facade\Cache;
|
||||
use think\facade\Config;
|
||||
use think\facade\Log;
|
||||
|
||||
/**
|
||||
* 甘草 SCM 处方:MAKE_TOKEN、CTM_PREVIEW、CTM_SUBMIT_RECIPEL。
|
||||
*/
|
||||
final class GancaoScmRecipelService
|
||||
{
|
||||
private const CACHE_KEY = 'gancao_scm_api_token';
|
||||
|
||||
private static string $lastGetTokenError = '';
|
||||
|
||||
public static function getLastGetTokenError(): string
|
||||
{
|
||||
return self::$lastGetTokenError;
|
||||
}
|
||||
|
||||
public static function isConfigured(): bool
|
||||
{
|
||||
return self::whyNotConfigured() === '';
|
||||
}
|
||||
|
||||
/**
|
||||
* 未就绪时返回中文原因(多条用分号分隔),就绪返回空串。
|
||||
*/
|
||||
public static function whyNotConfigured(): string
|
||||
{
|
||||
$c = Config::get('gancao_scm', []);
|
||||
if (empty($c['enabled'])) {
|
||||
return '未启用:请在 .env 顶层或 [GANCAO_SCM] 中设置 GANCAO_SCM_ENABLED=true(勿写在 [trtc] 等分区内,否则会变成 TRTC_GANCAO_SCM_* 读不到)';
|
||||
}
|
||||
$need = ['gateway_url', 'gateway_ak', 'gateway_sk', 'biz_ak', 'biz_sk', 'callback_url'];
|
||||
$labels = [
|
||||
'gateway_url' => 'GANCAO_SCM_GATEWAY_URL',
|
||||
'gateway_ak' => 'GANCAO_SCM_GATEWAY_AK',
|
||||
'gateway_sk' => 'GANCAO_SCM_GATEWAY_SK',
|
||||
'biz_ak' => 'GANCAO_SCM_BIZ_AK(可留空则与网关 AK 相同)',
|
||||
'biz_sk' => 'GANCAO_SCM_BIZ_SK(可留空则与网关 SK 相同)',
|
||||
'callback_url' => 'GANCAO_SCM_CALLBACK_URL(须 https,甘草订单状态回调)',
|
||||
];
|
||||
$miss = [];
|
||||
foreach ($need as $k) {
|
||||
if (trim((string) ($c[$k] ?? '')) === '') {
|
||||
$miss[] = $labels[$k] ?? $k;
|
||||
}
|
||||
}
|
||||
|
||||
return $miss === [] ? '' : '缺少或未配置:' . implode(';', $miss);
|
||||
}
|
||||
|
||||
public static function apiStatusMessage(?array $body): string
|
||||
{
|
||||
|
||||
if (!is_array($body)) {
|
||||
return '响应异常';
|
||||
}
|
||||
|
||||
$code = (string) ($body['status']['code'] ?? '');
|
||||
$msg = $body['status']['msg'] ?? '';
|
||||
|
||||
// 确保 msg 是字符串
|
||||
if (is_array($msg)) {
|
||||
$msg = json_encode($msg, JSON_UNESCAPED_UNICODE);
|
||||
} else {
|
||||
$msg = (string) $msg;
|
||||
}
|
||||
|
||||
return $code !== '' ? "[{$code}] {$msg}" : ($msg !== '' ? $msg : '未知错误');
|
||||
}
|
||||
|
||||
public static function isApiSuccess(?array $body): bool
|
||||
{
|
||||
return is_array($body) && (string) ($body['status']['code'] ?? '') === '00000';
|
||||
}
|
||||
|
||||
public static function getToken(bool $forceRefresh = false): ?string
|
||||
{
|
||||
self::$lastGetTokenError = '';
|
||||
if (!self::isConfigured()) {
|
||||
self::$lastGetTokenError = self::whyNotConfigured();
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
if (!$forceRefresh) {
|
||||
$cached = Cache::get(self::CACHE_KEY);
|
||||
if (is_string($cached) && strlen($cached) >= 10) {
|
||||
return $cached;
|
||||
}
|
||||
}
|
||||
|
||||
$c = Config::get('gancao_scm', []);
|
||||
$transport = new GancaoOpenApiTransport(
|
||||
(string) $c['gateway_url'],
|
||||
(string) $c['gateway_ak'],
|
||||
(string) $c['gateway_sk']
|
||||
);
|
||||
$ts = time();
|
||||
$bizAk = (string) $c['biz_ak'];
|
||||
$bizSk = (string) $c['biz_sk'];
|
||||
$gwAk = (string) $c['gateway_ak'];
|
||||
$gwSk = (string) $c['gateway_sk'];
|
||||
$pwd = md5($ts . $bizSk);
|
||||
$ret = $transport->post([
|
||||
'ak' => $bizAk,
|
||||
'timestamp' => $ts,
|
||||
'pwd' => $pwd,
|
||||
'package' => 'igc_scm.ops.api.auth',
|
||||
'class' => 'MAKE_TOKEN',
|
||||
]);
|
||||
|
||||
if ((int) ($ret['state'] ?? 0) !== 1) {
|
||||
$hint = (string) ($ret['msg'] ?? '');
|
||||
$tail = isset($ret['response']) ? mb_substr((string) $ret['response'], 0, 200) : '';
|
||||
self::$lastGetTokenError = '甘草网关通信失败:' . $hint . ($tail !== '' ? ';响应片段:' . $tail : '');
|
||||
Log::warning('Gancao MAKE_TOKEN transport failed', ['msg' => $hint, 'ret' => $ret]);
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
$body = $ret['body'] ?? [];
|
||||
|
||||
if (!self::isApiSuccess($body)) {
|
||||
$apiMsg = self::apiStatusMessage($body);
|
||||
$code = (string) ($body['status']['code'] ?? '');
|
||||
self::$lastGetTokenError = 'MAKE_TOKEN 失败:' . $apiMsg;
|
||||
|
||||
// 确保 $apiMsg 是字符串,避免 Array to string conversion 错误
|
||||
$apiMsgStr = is_string($apiMsg) ? $apiMsg : json_encode($apiMsg, JSON_UNESCAPED_UNICODE);
|
||||
|
||||
if ($code === '10103' || str_contains($apiMsgStr, '10103')) {
|
||||
self::$lastGetTokenError .= '。多为「业务 ak/sk」与 pwd=md5(时间戳+业务sk) 不匹配:请在 .env 配置与网关 OpenAPI 不同的 GANCAO_SCM_BIZ_AK、GANCAO_SCM_BIZ_SK(甘草控制台「业务账号」)。若业务与网关确为同一套,再检查 BIZ 是否与网关一致。';
|
||||
}
|
||||
if ($code === '10101' || str_contains($apiMsgStr, '10101')) {
|
||||
self::$lastGetTokenError .= '。请核对 GANCAO_SCM_GATEWAY_AK 与甘草分配的 OpenAPI 网关账号一致。';
|
||||
}
|
||||
if ($bizAk === $gwAk && $bizSk === $gwSk) {
|
||||
self::$lastGetTokenError .= ' 当前 BIZ 与网关相同;若仍失败,请向甘草索取独立的业务层 ak/sk 并填入 GANCAO_SCM_BIZ_AK / GANCAO_SCM_BIZ_SK。';
|
||||
}
|
||||
Log::warning('Gancao MAKE_TOKEN api error', ['body' => $body, 'apiMsg' => $apiMsg]);
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
$token = (string) ($body['result']['token'] ?? '');
|
||||
|
||||
if ($token === '') {
|
||||
self::$lastGetTokenError = 'MAKE_TOKEN 返回无 token 字段';
|
||||
|
||||
return null;
|
||||
}
|
||||
Cache::set(self::CACHE_KEY, $token, 50 * 60);
|
||||
|
||||
return $token;
|
||||
}
|
||||
|
||||
/**
|
||||
* 从医师药品库 `doctor_medicine`(name + gid)解析甘草药材 id,仅 status=1 且未删除。
|
||||
*
|
||||
* @param array<int, array<string,mixed>> $herbs 处方 herbs
|
||||
* @return array<string, int> 药材名 => 甘草 id
|
||||
*/
|
||||
/**
|
||||
* 手机号脱敏处理
|
||||
*
|
||||
* @param string $phone 手机号
|
||||
* @return string 脱敏后的手机号(如:138****0000)
|
||||
*/
|
||||
public static function maskPhone(string $phone): string
|
||||
{
|
||||
$phone = trim($phone);
|
||||
if (strlen($phone) !== 11) {
|
||||
return $phone;
|
||||
}
|
||||
return substr($phone, 0, 3) . '****' . substr($phone, -4);
|
||||
}
|
||||
|
||||
/**
|
||||
* 脱敏数据用于日志记录
|
||||
*
|
||||
* @param array<string,mixed> $data 原始数据
|
||||
* @return array<string,mixed> 脱敏后的数据
|
||||
*/
|
||||
public static function maskSensitiveData(array $data): array
|
||||
{
|
||||
$masked = $data;
|
||||
|
||||
// 脱敏手机号字段
|
||||
$phoneFields = ['phone', 'recipient_phone', 'patient_phone', 'doctor_phone'];
|
||||
foreach ($phoneFields as $field) {
|
||||
if (isset($masked[$field]) && is_string($masked[$field])) {
|
||||
$masked[$field] = self::maskPhone($masked[$field]);
|
||||
}
|
||||
}
|
||||
|
||||
// 递归处理嵌套数组
|
||||
foreach ($masked as $key => $value) {
|
||||
if (is_array($value)) {
|
||||
$masked[$key] = self::maskSensitiveData($value);
|
||||
}
|
||||
}
|
||||
|
||||
return $masked;
|
||||
}
|
||||
|
||||
public static function doctorMedicineGidMapForHerbs(array $herbs): array
|
||||
{
|
||||
$names = [];
|
||||
foreach ($herbs as $h) {
|
||||
if (!is_array($h)) {
|
||||
continue;
|
||||
}
|
||||
$n = trim((string) ($h['name'] ?? ''));
|
||||
if ($n !== '') {
|
||||
$names[] = $n;
|
||||
}
|
||||
}
|
||||
$names = array_values(array_unique($names));
|
||||
if ($names === []) {
|
||||
return [];
|
||||
}
|
||||
$rows = Medicine::whereNull('delete_time')
|
||||
->where('status', 1)
|
||||
->whereIn('name', $names)
|
||||
->column('gid', 'name');
|
||||
if (!is_array($rows)) {
|
||||
return [];
|
||||
}
|
||||
$map = [];
|
||||
foreach ($rows as $nameKey => $gidRaw) {
|
||||
$nameKey = trim((string) $nameKey);
|
||||
$g = trim((string) $gidRaw);
|
||||
if ($nameKey === '' || $g === '') {
|
||||
continue;
|
||||
}
|
||||
if (!preg_match('/^\d+$/', $g)) {
|
||||
continue;
|
||||
}
|
||||
$id = (int) $g;
|
||||
if ($id > 0) {
|
||||
$map[$nameKey] = $id;
|
||||
}
|
||||
}
|
||||
|
||||
return $map;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<int, array<string,mixed>> $herbs
|
||||
* @param array<string, int|string> $nameToIdMap 药材名 => 甘草 id(config herb_id_map)
|
||||
* @param array<string, int> $doctorNameToGid 医师库 name => gid(甘草)
|
||||
* @return array{0: list<array{id:int,name:string,quantity:float|string,brief:string}>, 1: list<string>} [m_list, missing_names]
|
||||
*/
|
||||
public static function buildMList(array $herbs, array $nameToIdMap, array $doctorNameToGid = []): array
|
||||
{
|
||||
$mList = [];
|
||||
$missing = [];
|
||||
foreach ($herbs as $h) {
|
||||
if (!is_array($h)) {
|
||||
continue;
|
||||
}
|
||||
$name = trim((string) ($h['name'] ?? ''));
|
||||
if ($name === '') {
|
||||
continue;
|
||||
}
|
||||
$id = (int) ($h['gc_id'] ?? $h['gancao_id'] ?? 0);
|
||||
if ($id <= 0 && isset($nameToIdMap[$name])) {
|
||||
$id = (int) $nameToIdMap[$name];
|
||||
}
|
||||
if ($id <= 0 && isset($doctorNameToGid[$name])) {
|
||||
$id = (int) $doctorNameToGid[$name];
|
||||
}
|
||||
if ($id <= 0) {
|
||||
$missing[] = $name;
|
||||
continue;
|
||||
}
|
||||
$qty = (float) ($h['dosage'] ?? $h['quantity'] ?? 0);
|
||||
if ($qty < 0.1) {
|
||||
$qty = 0.1;
|
||||
}
|
||||
$qty = round($qty, 1);
|
||||
$brief = trim((string) ($h['brief'] ?? $h['process'] ?? ''));
|
||||
|
||||
$mList[] = [
|
||||
'id' => $id,
|
||||
'name' => $name,
|
||||
'quantity' => $qty,
|
||||
'brief' => $brief,
|
||||
];
|
||||
}
|
||||
|
||||
return [$mList, $missing];
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array{0:string,1:string,2:string} province, city, addr
|
||||
*/
|
||||
public static function splitCnAddress(string $full): array
|
||||
{
|
||||
$full = trim($full);
|
||||
if ($full === '') {
|
||||
return ['', '', ''];
|
||||
}
|
||||
$province = '';
|
||||
$rest = $full;
|
||||
if (preg_match('/^(.*?(?:省|自治区))(.*)$/u', $full, $m)) {
|
||||
$province = $m[1];
|
||||
$rest = trim($m[2]);
|
||||
} elseif (preg_match('/^(北京市|天津市|上海市|重庆市)(.*)$/u', $full, $m2)) {
|
||||
$province = $m2[1];
|
||||
$rest = trim($m2[2]);
|
||||
}
|
||||
$city = '';
|
||||
$addr = $rest;
|
||||
if ($rest !== '') {
|
||||
if (preg_match('/^(.*?(?:市|州|盟|地区))(.*)$/u', $rest, $m3)) {
|
||||
$city = $m3[1];
|
||||
$addr = trim($m3[2]);
|
||||
}
|
||||
}
|
||||
if ($city === '' && $province !== '' && preg_match('/市$/u', $province)) {
|
||||
$city = $province;
|
||||
}
|
||||
if ($addr === '') {
|
||||
$addr = $full;
|
||||
}
|
||||
|
||||
return [$province, $city, $addr];
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string,mixed> $previewPayload token、df_id、amount、m_list、df101ext…+ package/class 由调用方组装
|
||||
*/
|
||||
public static function ctmPreview(array $previewPayload): array
|
||||
{
|
||||
$transport = self::transport();
|
||||
|
||||
return $transport->post($previewPayload);
|
||||
}
|
||||
|
||||
public static function ctmSubmit(array $submitPayload): array
|
||||
{
|
||||
$transport = self::transport();
|
||||
|
||||
// Log the payload for debugging (with sensitive data masked)
|
||||
try {
|
||||
$maskedPayload = self::maskSensitiveData($submitPayload);
|
||||
//Log::info('Gancao CTM_SUBMIT_RECIPEL payload', ['payload' => json_encode($maskedPayload, JSON_UNESCAPED_UNICODE)]);
|
||||
} catch (\Throwable $e) {
|
||||
|
||||
// 忽略日志错误,不影响主流程
|
||||
// Log::warning('Failed to log Gancao payload: ' . $e->getMessage());
|
||||
}
|
||||
|
||||
|
||||
return $transport->post($submitPayload);
|
||||
}
|
||||
|
||||
private static function transport(): GancaoOpenApiTransport
|
||||
{
|
||||
$c = Config::get('gancao_scm', []);
|
||||
|
||||
return new GancaoOpenApiTransport(
|
||||
(string) $c['gateway_url'],
|
||||
(string) $c['gateway_ak'],
|
||||
(string) $c['gateway_sk']
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string,mixed> $rx 处方详情 toArray
|
||||
* @param array<string,mixed> $order 业务订单 toArray
|
||||
* @param string $token
|
||||
* @return array<string,mixed>
|
||||
*/
|
||||
public static function buildPreviewPayload(array $rx, array $order, string $token): array
|
||||
{
|
||||
$c = Config::get('gancao_scm', []);
|
||||
$type=0;
|
||||
|
||||
if(array_key_exists($rx['prescription_type'], $c['df_ids'])){
|
||||
$type=$c['df_ids'][$rx['prescription_type']];
|
||||
}
|
||||
|
||||
$dfId =$type? (int) $type:(int) $c['df_id'];
|
||||
|
||||
$herbs = is_array($rx['herbs'] ?? null) ? $rx['herbs'] : [];
|
||||
$docMap = self::doctorMedicineGidMapForHerbs($herbs);
|
||||
[$mList] = self::buildMList(
|
||||
$herbs,
|
||||
is_array($c['herb_id_map'] ?? null) ? $c['herb_id_map'] : [],
|
||||
$docMap
|
||||
);
|
||||
$amount = (int) ($order['dose_count']?$order['dose_count'] :$rx['dose_count'] );
|
||||
if ($amount < 1) {
|
||||
$amount =3;
|
||||
}
|
||||
|
||||
$base = [
|
||||
'token' => $token,
|
||||
'df_id' => $dfId,
|
||||
'amount' => $amount,
|
||||
'm_list' => $mList,
|
||||
'package' => 'igc_scm.ops.api.order',
|
||||
'class' => 'CTM_PREVIEW',
|
||||
];
|
||||
dd($rx);
|
||||
if ($dfId === 101) {
|
||||
$isDecoct = (int) $c['default_is_decoct'] ? 1 : 0;
|
||||
$base['df101ext'] = [
|
||||
'times_per_day' => max(1, min(10, (int) $c['default_times_per_day'])),
|
||||
'is_decoct' => $isDecoct,
|
||||
'num_per_pack' => max(1, min(9, (int) $c['default_num_per_pack'])),
|
||||
'is_special_writing' => 0,
|
||||
'dose' => max(50, min(250, (int) $c['default_dose_ml'])),
|
||||
'usage_mode' => 'ORAL',
|
||||
'ds_type' => 1
|
||||
];
|
||||
}
|
||||
if ($dfId === 102) {
|
||||
$isDecoct = (int) $c['default_is_decoct'] ? 1 : 0;
|
||||
$base['df102ext'] = [
|
||||
'times_per_day' =>$rx['times_per_day'],
|
||||
'take_days'=>$order['medication_days']?$order['medication_days']:$rx['usage_days'],
|
||||
"pill_type"=>$rx['prescription_type']?'WATER':'HONEY',
|
||||
"dose"=>$rx['dosage_amount']
|
||||
];
|
||||
$base['doct_advice']=[
|
||||
'taboo'=>$rx['dietary_taboo'],
|
||||
'usage_time'=>$rx['usage_time'],
|
||||
'usage_brief'=>$rx['usage_instruction'],
|
||||
'others'=>$rx['usage_notes']
|
||||
];
|
||||
$base['express_type']="general";
|
||||
$base['cradle_store']="线上接诊";
|
||||
$base['app_order_no']=$order['order_no'];
|
||||
$base['express_to']=[
|
||||
'name'=>$order['recipient_name'],
|
||||
'phone'=>$order['recipient_phone'],
|
||||
'province'=>$order['shipping_province'],
|
||||
'city'=>$order['shipping_city'],
|
||||
'addr'=>$order['shipping_province'].$order['shipping_city'].$order['shipping_city'].$order['shipping_address']
|
||||
];
|
||||
$base['callback_url']= $c['callback_url'];
|
||||
$base['diagnosis']= $rx['clinical_diagnosis']||'无';
|
||||
$base['disease']= $rx['clinical_diagnosis']||'无';
|
||||
$base['doctor']=[
|
||||
'name'=>$rx['doctor_name'],
|
||||
'phone'=>''
|
||||
];
|
||||
|
||||
$base['patient']=[
|
||||
'name'=>$rx['patient_name'],
|
||||
'age'=>$rx['age'],
|
||||
'sex'=>$rx['gender_desc']=='男'?1:0
|
||||
];
|
||||
}
|
||||
|
||||
|
||||
return $base;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string,mixed> $rx
|
||||
* @param array<string,mixed> $order
|
||||
* @param array{0:string,1:string,2:string} $addrParts province,city,addr
|
||||
*/
|
||||
public static function buildSubmitPayload(
|
||||
array $rx,
|
||||
array $order,
|
||||
string $token,
|
||||
array $addrParts,
|
||||
string $appOrderNo
|
||||
): array {
|
||||
$c = Config::get('gancao_scm', []);
|
||||
$preview = self::buildPreviewPayload($rx, $order, $token);
|
||||
unset($preview['class']);
|
||||
$preview['class'] = 'CTM_SUBMIT_RECIPEL';
|
||||
|
||||
$phone = preg_replace('/\D/', '', (string) ($order['recipient_phone'] ?? ''));
|
||||
if (strlen($phone) !== 11) {
|
||||
$phone = preg_replace('/\D/', '', (string) ($rx['phone'] ?? ''));
|
||||
}
|
||||
if (strlen($phone) !== 11) {
|
||||
$phone = '';
|
||||
}
|
||||
|
||||
[$p, $ct, $ad] = $addrParts;
|
||||
if (mb_strlen($p) < 2) {
|
||||
$p = '四川省';
|
||||
}
|
||||
if (mb_strlen($ct) < 2) {
|
||||
$ct = '成都市';
|
||||
}
|
||||
if (mb_strlen($ad) < 4) {
|
||||
$ad = (string) ($order['shipping_address'] ?? '');
|
||||
}
|
||||
|
||||
$patientName = mb_substr(trim((string) ($rx['patient_name'] ?? $order['recipient_name'] ?? '患者')), 0, 30);
|
||||
if ($patientName === '') {
|
||||
$patientName = '患者';
|
||||
}
|
||||
$ageInt = (int) ($rx['age'] ?? 30);
|
||||
if ($ageInt < 0) {
|
||||
$ageInt = 0;
|
||||
}
|
||||
if ($ageInt > 120) {
|
||||
$ageInt = 120;
|
||||
}
|
||||
$patientAge = (string) $ageInt;
|
||||
$sex = (int) ($rx['gender'] ?? 0) === 1 ? 1 : 0;
|
||||
$patientPhone = preg_replace('/\D/', '', (string) ($rx['phone'] ?? ''));
|
||||
if (strlen($patientPhone) !== 11) {
|
||||
$patientPhone = $phone;
|
||||
}
|
||||
|
||||
$clinical = trim((string) ($rx['clinical_diagnosis'] ?? ''));
|
||||
if ($clinical === '') {
|
||||
$clinical = '中医辨证论治';
|
||||
}
|
||||
$clinical = mb_substr($clinical, 0, 128);
|
||||
|
||||
$doctorName = mb_substr(trim((string) ($rx['doctor_name'] ?? '医师')), 0, 10);
|
||||
$doctorBlock = ['name' => $doctorName];
|
||||
$docPhone = preg_replace('/\D/', '', (string) ($rx['doctor_phone'] ?? ''));
|
||||
if (strlen($docPhone) === 11) {
|
||||
$doctorBlock['phone'] = $docPhone;
|
||||
}
|
||||
|
||||
$usageTime = trim((string) ($rx['usage_time'] ?? '饭后半小时服用'));
|
||||
if ($usageTime === '') {
|
||||
$usageTime = '饭后半小时服用';
|
||||
}
|
||||
$usageTime = mb_substr($usageTime, 0, 32);
|
||||
|
||||
$taboo = mb_substr(trim((string) ($rx['dietary_taboo'] ?? '')), 0, 128);
|
||||
$usageBrief = trim((string) ($rx['usage_way'] ?? '') . ' ' . (string) ($rx['usage_instruction'] ?? ''));
|
||||
$usageBrief = mb_substr(trim($usageBrief), 0, 128);
|
||||
|
||||
// 确保 express_type 是字符串
|
||||
$expressType = isset($c['express_type']) ? (string) $c['express_type'] : 'sf';
|
||||
if ($expressType === '' || is_array($c['express_type'] ?? null)) {
|
||||
$expressType = 'sf'; // 默认顺丰
|
||||
}
|
||||
|
||||
$preview['express_type'] = $expressType;
|
||||
// $preview['express_to'] = [
|
||||
// 'name' => mb_substr(trim((string) ($order['recipient_name'] ?? $patientName)), 0, 16),
|
||||
// 'phone' => $phone,
|
||||
// 'province' => mb_substr($p, 0, 16),
|
||||
// 'city' => mb_substr($ct, 0, 16),
|
||||
// 'addr' => mb_substr($ad, 0, 64),
|
||||
// ];
|
||||
$preview['app_order_no'] = mb_substr($appOrderNo, 0, 32);
|
||||
|
||||
$preview['cradle_store'] = mb_substr(trim((string) ($c['cradle_store'] ?? '')), 0, 32);
|
||||
if ($preview['cradle_store'] === '') {
|
||||
$preview['cradle_store'] = 'default';
|
||||
}
|
||||
|
||||
// 确保 callback_url 是字符串
|
||||
$callbackUrl = isset($c['callback_url']) ? (string) $c['callback_url'] : '';
|
||||
if ($callbackUrl === '' || is_array($c['callback_url'] ?? null)) {
|
||||
Log::error('Gancao callback_url is invalid', ['callback_url' => $c['callback_url'] ?? null]);
|
||||
$callbackUrl = 'https://example.com/callback'; // 临时默认值,实际应该配置正确
|
||||
}
|
||||
$preview['callback_url'] = $callbackUrl;
|
||||
$preview['disease'] = $clinical;
|
||||
$preview['diagnosis'] = $clinical;
|
||||
|
||||
// Ensure all doct_advice fields are strings, not empty
|
||||
$tabooStr = $taboo !== '' ? $taboo : '无';
|
||||
$usageTimeStr = $usageTime;
|
||||
$usageBriefStr = $usageBrief !== '' ? $usageBrief : '遵医嘱';
|
||||
$othersStr = trim((string) ($rx['usage_notes'] ?? ''));
|
||||
$notesDoctorStr = trim((string) ($order['remark_extra'] ?? ''));
|
||||
|
||||
$preview['doct_advice'] = [
|
||||
'taboo' => $tabooStr,
|
||||
'usage_time' => $usageTimeStr,
|
||||
'usage_brief' => $usageBriefStr,
|
||||
'others' => $othersStr !== '' ? $othersStr : '',
|
||||
'notes_doctor' => $notesDoctorStr !== '' ? $notesDoctorStr : '',
|
||||
];
|
||||
$preview['doctor'] = $doctorBlock;
|
||||
$preview['patient'] = [
|
||||
'name' => $patientName,
|
||||
'age' => $patientAge,
|
||||
'sex' => $sex,
|
||||
'phone' => $patientPhone,
|
||||
];
|
||||
|
||||
return $preview;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,76 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
/**
|
||||
* 甘草药管家 SCM 开放平台(处方下单)
|
||||
*
|
||||
* @see https://apidoc.igancao.com/service-doc/scm-outer-recipel.html
|
||||
*
|
||||
* .env 注意:ThinkPHP 使用 parse_ini_file 分区后,写在 `[trtc]` 等分区**下面**的键会
|
||||
* 变成 `TRTC_XXX` 而不是顶层 `GANCAO_SCM_XXX`。请把 GANCAO_SCM_* 写在文件最前(任意
|
||||
* `[分区]` 之前),或使用独立分区 `[GANCAO_SCM]`(键为 ENABLED、GATEWAY_AK 等)。
|
||||
*
|
||||
* 网关层:与 GcOpenApi.php 一致(AK/SK + AES 请求体)。
|
||||
* 业务层:MAKE_TOKEN 使用 biz_ak / biz_sk(pwd=md5(timestamp+业务sk))。**常与网关 OpenAPI AK/SK 不是同一套**;
|
||||
* 若甘草返回 10103 等,请在 .env 单独配置 GANCAO_SCM_BIZ_AK / GANCAO_SCM_BIZ_SK。仅当业务与网关确为同一套时才可留空 BIZ 以复用网关。
|
||||
*
|
||||
* 药材 id:甘草接口 m_list[].id 为平台药 id。解析顺序:处方 herbs 的 gc_id / gancao_id →
|
||||
* 下方 herb_id_map → 医师药品库 zyt_doctor_medicine(name 精确匹配、status=1、gid 为纯数字)。
|
||||
*/
|
||||
if (!function_exists('zyt_gancao_scm_env')) {
|
||||
/**
|
||||
* 读取环境变量:优先 GANCAO_SCM_*;兼容误写在 [trtc] 下的 TRTC_GANCAO_SCM_*。
|
||||
*
|
||||
* @param mixed $default
|
||||
*/
|
||||
function zyt_gancao_scm_env(string $suffix, mixed $default = null): mixed
|
||||
{
|
||||
$primary = env('GANCAO_SCM_' . $suffix, null);
|
||||
if ($primary !== null && $primary !== '' && $primary !== false) {
|
||||
return is_string($primary) ? trim($primary, " \t\n\r\0\x0B\"") : $primary;
|
||||
}
|
||||
$fallback = env('TRTC_GANCAO_SCM_' . $suffix, $default);
|
||||
if (is_string($fallback)) {
|
||||
return trim($fallback, " \t\n\r\0\x0B\"");
|
||||
}
|
||||
|
||||
return $fallback;
|
||||
}
|
||||
}
|
||||
// 测试网关:http://dev-gapis-base.igancao.com/oapi ;生产:https://oapi.igancao.com(见甘草《网关对接规范》)
|
||||
$gwUrl = rtrim((string) zyt_gancao_scm_env('GATEWAY_URL', 'https://oapi.igancao.com'), '/');
|
||||
if (str_contains($gwUrl, 'dev-gapis-base.igancao.com') && !preg_match('#/oapi$#i', $gwUrl)) {
|
||||
$gwUrl .= '/oapi';
|
||||
}
|
||||
$gwAk = (string) zyt_gancao_scm_env('GATEWAY_AK', '');
|
||||
$gwSk = (string) zyt_gancao_scm_env('GATEWAY_SK', '');
|
||||
$bizAk = (string) zyt_gancao_scm_env('BIZ_AK', '');
|
||||
$bizSk = (string) zyt_gancao_scm_env('BIZ_SK', '');
|
||||
if ($bizAk === '') {
|
||||
$bizAk = $gwAk;
|
||||
}
|
||||
if ($bizSk === '') {
|
||||
$bizSk = $gwSk;
|
||||
}
|
||||
$CALLBACK_URL = (string) zyt_gancao_scm_env('GANCAO_SCM_CALLBACK_URL', '');
|
||||
$enabledRaw = zyt_gancao_scm_env('ENABLED', false);
|
||||
|
||||
return [
|
||||
'enabled' => filter_var($enabledRaw, FILTER_VALIDATE_BOOLEAN),
|
||||
'gateway_url' => $gwUrl,
|
||||
'gateway_ak' => $gwAk,
|
||||
'gateway_sk' => $gwSk,
|
||||
'biz_ak' => $bizAk,
|
||||
'biz_sk' => $bizSk,
|
||||
'callback_url' => (string) zyt_gancao_scm_env('CALLBACK_URL', ''),
|
||||
'cradle_store' => (string) zyt_gancao_scm_env('CRADLE_STORE', '甄养堂互联网医院'),
|
||||
'df_id' => (int) zyt_gancao_scm_env('DF_ID', 102),
|
||||
'express_type' => (string) zyt_gancao_scm_env('EXPRESS_TYPE', 'general'),
|
||||
'default_is_decoct' => (int) zyt_gancao_scm_env('DEFAULT_IS_DECOCT', 1),
|
||||
'default_times_per_day' => (int) zyt_gancao_scm_env('TIMES_PER_DAY', 2),
|
||||
'default_num_per_pack' => (int) zyt_gancao_scm_env('NUM_PER_PACK', 2),
|
||||
'default_dose_ml' => (int) zyt_gancao_scm_env('DOSE_ML', 150),
|
||||
'herb_id_map' => [],
|
||||
'df_ids'=>['饮片'=>'101','浓缩水丸'=>'102']
|
||||
];
|
||||
@@ -0,0 +1,17 @@
|
||||
-- 甘草药管家处方单号回写 + 菜单权限
|
||||
ALTER TABLE `zyt_tcm_prescription_order`
|
||||
ADD COLUMN `gancao_reciperl_order_no` varchar(32) NOT NULL DEFAULT '' COMMENT '甘草处方订单号 recipel_order_no' AFTER `remark_extra`,
|
||||
ADD COLUMN `gancao_submit_time` int(10) unsigned NOT NULL DEFAULT 0 COMMENT '提交甘草成功时间戳' AFTER `gancao_reciperl_order_no`;
|
||||
|
||||
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/submitGancaoRecipel', '', '',
|
||||
'', '', 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/submitGancaoRecipel');
|
||||
@@ -0,0 +1,11 @@
|
||||
<?php
|
||||
/**
|
||||
* 甘草回调路由
|
||||
*
|
||||
* 这些路由不需要登录验证,因为是甘草系统主动回调
|
||||
*/
|
||||
|
||||
use think\facade\Route;
|
||||
|
||||
// 甘草订单状态回调
|
||||
Route::post('gancao/callback/order-status', 'api.GancaoCallback/orderStatus');
|
||||
Binary file not shown.
@@ -0,0 +1,193 @@
|
||||
<?php
|
||||
/**
|
||||
* 甘草配置检查脚本
|
||||
* 用于检查 .env 配置是否正确,特别是检查是否有数组值导致 "Array to string conversion" 错误
|
||||
*/
|
||||
|
||||
// 加载 ThinkPHP
|
||||
require __DIR__ . '/server/vendor/autoload.php';
|
||||
|
||||
$app = new think\App();
|
||||
$app->initialize();
|
||||
|
||||
echo "=== 甘草配置检查 ===\n\n";
|
||||
|
||||
// 获取配置
|
||||
$config = think\facade\Config::get('gancao_scm', []);
|
||||
|
||||
echo "1. 检查配置是否存在:\n";
|
||||
if (empty($config)) {
|
||||
echo " ❌ 配置为空,请检查 .env 文件\n";
|
||||
exit(1);
|
||||
} else {
|
||||
echo " ✓ 配置已加载\n\n";
|
||||
}
|
||||
|
||||
echo "2. 检查各项配置:\n";
|
||||
|
||||
$requiredFields = [
|
||||
'enabled' => 'boolean',
|
||||
'gateway_url' => 'string',
|
||||
'gateway_ak' => 'string',
|
||||
'gateway_sk' => 'string',
|
||||
'biz_ak' => 'string',
|
||||
'biz_sk' => 'string',
|
||||
'callback_url' => 'string',
|
||||
'express_type' => 'string',
|
||||
'cradle_store' => 'string',
|
||||
'df_id' => 'integer',
|
||||
];
|
||||
|
||||
$hasError = false;
|
||||
|
||||
foreach ($requiredFields as $field => $expectedType) {
|
||||
$value = $config[$field] ?? null;
|
||||
$actualType = gettype($value);
|
||||
|
||||
echo " - {$field}: ";
|
||||
|
||||
if ($value === null) {
|
||||
echo "❌ 未配置\n";
|
||||
$hasError = true;
|
||||
continue;
|
||||
}
|
||||
|
||||
// 检查类型
|
||||
if ($actualType === 'array') {
|
||||
echo "❌ 错误!值是数组,应该是 {$expectedType}\n";
|
||||
echo " 实际值: " . json_encode($value, JSON_UNESCAPED_UNICODE) . "\n";
|
||||
$hasError = true;
|
||||
continue;
|
||||
}
|
||||
|
||||
// 转换类型检查
|
||||
$typeMatch = false;
|
||||
switch ($expectedType) {
|
||||
case 'string':
|
||||
$typeMatch = is_string($value) || is_numeric($value);
|
||||
break;
|
||||
case 'integer':
|
||||
$typeMatch = is_int($value) || is_numeric($value);
|
||||
break;
|
||||
case 'boolean':
|
||||
$typeMatch = is_bool($value) || in_array($value, [0, 1, '0', '1', 'true', 'false'], true);
|
||||
break;
|
||||
}
|
||||
|
||||
if (!$typeMatch) {
|
||||
echo "⚠️ 类型不匹配(期望: {$expectedType}, 实际: {$actualType})\n";
|
||||
echo " 值: " . var_export($value, true) . "\n";
|
||||
} else {
|
||||
echo "✓ {$actualType}";
|
||||
if ($expectedType === 'string' && strlen((string)$value) > 50) {
|
||||
echo " (长度: " . strlen((string)$value) . ", 前50字符: " . substr((string)$value, 0, 50) . "...)";
|
||||
} else {
|
||||
echo " = " . var_export($value, true);
|
||||
}
|
||||
echo "\n";
|
||||
}
|
||||
}
|
||||
|
||||
echo "\n3. 检查可选配置:\n";
|
||||
|
||||
$optionalFields = [
|
||||
'default_is_decoct' => 'boolean',
|
||||
'default_times_per_day' => 'integer',
|
||||
'default_num_per_pack' => 'integer',
|
||||
'default_dose_ml' => 'integer',
|
||||
'herb_id_map' => 'array',
|
||||
];
|
||||
|
||||
foreach ($optionalFields as $field => $expectedType) {
|
||||
$value = $config[$field] ?? null;
|
||||
$actualType = gettype($value);
|
||||
|
||||
echo " - {$field}: ";
|
||||
|
||||
if ($value === null) {
|
||||
echo "未配置(可选)\n";
|
||||
continue;
|
||||
}
|
||||
|
||||
if ($expectedType === 'array' && !is_array($value)) {
|
||||
echo "⚠️ 应该是数组,实际是 {$actualType}\n";
|
||||
} elseif ($expectedType !== 'array' && is_array($value)) {
|
||||
echo "❌ 错误!值是数组,应该是 {$expectedType}\n";
|
||||
$hasError = true;
|
||||
} else {
|
||||
echo "✓ {$actualType}\n";
|
||||
}
|
||||
}
|
||||
|
||||
echo "\n4. 测试 JSON 编码:\n";
|
||||
|
||||
// 模拟构建提交负载
|
||||
$testPayload = [
|
||||
'token' => 'test_token_123',
|
||||
'df_id' => (int)($config['df_id'] ?? 101),
|
||||
'amount' => 7,
|
||||
'express_type' => isset($config['express_type']) ? (string)$config['express_type'] : 'sf',
|
||||
'callback_url' => isset($config['callback_url']) ? (string)$config['callback_url'] : '',
|
||||
'cradle_store' => isset($config['cradle_store']) ? (string)$config['cradle_store'] : 'default',
|
||||
'express_to' => [
|
||||
'name' => '测试患者',
|
||||
'phone' => '13800138000',
|
||||
'province' => '四川省',
|
||||
'city' => '成都市',
|
||||
'addr' => '测试地址123号',
|
||||
],
|
||||
'patient' => [
|
||||
'name' => '测试患者',
|
||||
'age' => '30',
|
||||
'sex' => 0,
|
||||
'phone' => '13800138000',
|
||||
],
|
||||
'doctor' => [
|
||||
'name' => '测试医生',
|
||||
],
|
||||
'doct_advice' => [
|
||||
'taboo' => '无',
|
||||
'usage_time' => '饭后半小时服用',
|
||||
'usage_brief' => '遵医嘱',
|
||||
'others' => '',
|
||||
'notes_doctor' => '',
|
||||
],
|
||||
'm_list' => [
|
||||
['id' => 1, 'quantity' => 10.0, 'brief' => ''],
|
||||
],
|
||||
];
|
||||
|
||||
try {
|
||||
$json = json_encode($testPayload, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES);
|
||||
if ($json === false) {
|
||||
echo " ❌ JSON 编码失败: " . json_last_error_msg() . "\n";
|
||||
$hasError = true;
|
||||
} else {
|
||||
echo " ✓ JSON 编码成功\n";
|
||||
echo " 负载大小: " . strlen($json) . " 字节\n";
|
||||
|
||||
// 检查是否包含 "Array" 字符串(可能是数组转字符串的迹象)
|
||||
if (strpos($json, '"Array"') !== false || strpos($json, 'Array') !== false) {
|
||||
echo " ⚠️ 警告:JSON 中包含 'Array' 字符串,可能存在数组转字符串问题\n";
|
||||
$hasError = true;
|
||||
}
|
||||
}
|
||||
} catch (\Throwable $e) {
|
||||
echo " ❌ 异常: " . $e->getMessage() . "\n";
|
||||
$hasError = true;
|
||||
}
|
||||
|
||||
echo "\n";
|
||||
|
||||
if ($hasError) {
|
||||
echo "=== 检查结果: ❌ 发现错误 ===\n";
|
||||
echo "\n请修复以上错误后重试。\n";
|
||||
echo "\n常见问题:\n";
|
||||
echo "1. 配置值被错误地设置为数组\n";
|
||||
echo "2. .env 文件格式错误(例如:GANCAO_SCM_EXPRESS_TYPE=[\"sf\"] 应该是 GANCAO_SCM_EXPRESS_TYPE=sf)\n";
|
||||
echo "3. 配置文件中有多余的引号或括号\n";
|
||||
exit(1);
|
||||
} else {
|
||||
echo "=== 检查结果: ✓ 配置正常 ===\n";
|
||||
exit(0);
|
||||
}
|
||||
@@ -0,0 +1,174 @@
|
||||
<?php
|
||||
/**
|
||||
* 甘草 SSL 连接测试脚本
|
||||
* 用于诊断 SSL/TLS 连接问题
|
||||
*/
|
||||
|
||||
require __DIR__ . '/server/vendor/autoload.php';
|
||||
|
||||
$app = new think\App();
|
||||
$app->initialize();
|
||||
|
||||
echo "=== 甘草 SSL 连接测试 ===\n\n";
|
||||
|
||||
// 获取配置
|
||||
$config = think\facade\Config::get('gancao_scm', []);
|
||||
$gatewayUrl = $config['gateway_url'] ?? '';
|
||||
|
||||
if ($gatewayUrl === '') {
|
||||
echo "❌ 错误:未配置 GANCAO_SCM_GATEWAY_URL\n";
|
||||
exit(1);
|
||||
}
|
||||
|
||||
echo "网关地址: {$gatewayUrl}\n\n";
|
||||
|
||||
// 解析 URL
|
||||
$urlParts = parse_url($gatewayUrl);
|
||||
$host = $urlParts['host'] ?? '';
|
||||
$port = $urlParts['port'] ?? ($urlParts['scheme'] === 'https' ? 443 : 80);
|
||||
|
||||
echo "1. DNS 解析测试:\n";
|
||||
$ip = gethostbyname($host);
|
||||
if ($ip === $host) {
|
||||
echo " ❌ DNS 解析失败\n";
|
||||
} else {
|
||||
echo " ✓ {$host} -> {$ip}\n";
|
||||
}
|
||||
|
||||
echo "\n2. TCP 连接测试:\n";
|
||||
$socket = @fsockopen($host, $port, $errno, $errstr, 10);
|
||||
if (!$socket) {
|
||||
echo " ❌ TCP 连接失败: [{$errno}] {$errstr}\n";
|
||||
} else {
|
||||
echo " ✓ TCP 连接成功\n";
|
||||
fclose($socket);
|
||||
}
|
||||
|
||||
echo "\n3. SSL/TLS 支持检测:\n";
|
||||
$sslVersions = [
|
||||
'SSLv2' => STREAM_CRYPTO_METHOD_SSLv2_CLIENT,
|
||||
'SSLv3' => STREAM_CRYPTO_METHOD_SSLv3_CLIENT,
|
||||
'TLS 1.0' => STREAM_CRYPTO_METHOD_TLSv1_0_CLIENT,
|
||||
'TLS 1.1' => STREAM_CRYPTO_METHOD_TLSv1_1_CLIENT,
|
||||
'TLS 1.2' => STREAM_CRYPTO_METHOD_TLSv1_2_CLIENT,
|
||||
];
|
||||
|
||||
if (defined('STREAM_CRYPTO_METHOD_TLSv1_3_CLIENT')) {
|
||||
$sslVersions['TLS 1.3'] = STREAM_CRYPTO_METHOD_TLSv1_3_CLIENT;
|
||||
}
|
||||
|
||||
foreach ($sslVersions as $name => $method) {
|
||||
echo " - {$name}: ";
|
||||
$context = stream_context_create([
|
||||
'ssl' => [
|
||||
'verify_peer' => false,
|
||||
'verify_peer_name' => false,
|
||||
]
|
||||
]);
|
||||
$fp = @stream_socket_client(
|
||||
"ssl://{$host}:{$port}",
|
||||
$errno,
|
||||
$errstr,
|
||||
10,
|
||||
STREAM_CLIENT_CONNECT,
|
||||
$context
|
||||
);
|
||||
if ($fp) {
|
||||
echo "✓ 支持\n";
|
||||
fclose($fp);
|
||||
} else {
|
||||
echo "✗ 不支持\n";
|
||||
}
|
||||
}
|
||||
|
||||
echo "\n4. cURL SSL 测试:\n";
|
||||
|
||||
$testConfigs = [
|
||||
'HTTP/1.0 + TLS 1.2' => [
|
||||
CURLOPT_HTTP_VERSION => CURL_HTTP_VERSION_1_0,
|
||||
CURLOPT_SSLVERSION => CURL_SSLVERSION_TLSv1_2,
|
||||
],
|
||||
'HTTP/1.1 + TLS 1.2' => [
|
||||
CURLOPT_HTTP_VERSION => CURL_HTTP_VERSION_1_1,
|
||||
CURLOPT_SSLVERSION => CURL_SSLVERSION_TLSv1_2,
|
||||
],
|
||||
'HTTP/1.1 + TLS 1.2 + SECLEVEL=1' => [
|
||||
CURLOPT_HTTP_VERSION => CURL_HTTP_VERSION_1_1,
|
||||
CURLOPT_SSLVERSION => CURL_SSLVERSION_TLSv1_2,
|
||||
CURLOPT_SSL_CIPHER_LIST => 'DEFAULT@SECLEVEL=1',
|
||||
],
|
||||
'HTTP/2 + TLS 1.2' => [
|
||||
CURLOPT_HTTP_VERSION => CURL_HTTP_VERSION_2_0,
|
||||
CURLOPT_SSLVERSION => CURL_SSLVERSION_TLSv1_2,
|
||||
],
|
||||
];
|
||||
|
||||
if (defined('CURL_SSLVERSION_TLSv1_3')) {
|
||||
$testConfigs['HTTP/1.1 + TLS 1.3'] = [
|
||||
CURLOPT_HTTP_VERSION => CURL_HTTP_VERSION_1_1,
|
||||
CURLOPT_SSLVERSION => CURL_SSLVERSION_TLSv1_3,
|
||||
];
|
||||
}
|
||||
|
||||
foreach ($testConfigs as $name => $options) {
|
||||
echo " 测试 {$name}:\n";
|
||||
|
||||
$ch = curl_init($gatewayUrl);
|
||||
curl_setopt($ch, CURLOPT_RETURNTRANSFER, true);
|
||||
curl_setopt($ch, CURLOPT_SSL_VERIFYPEER, false);
|
||||
curl_setopt($ch, CURLOPT_SSL_VERIFYHOST, 0);
|
||||
curl_setopt($ch, CURLOPT_CONNECTTIMEOUT, 10);
|
||||
curl_setopt($ch, CURLOPT_TIMEOUT, 30);
|
||||
curl_setopt($ch, CURLOPT_HEADER, true);
|
||||
curl_setopt($ch, CURLOPT_NOBODY, true); // HEAD 请求
|
||||
|
||||
foreach ($options as $opt => $val) {
|
||||
curl_setopt($ch, $opt, $val);
|
||||
}
|
||||
|
||||
$result = curl_exec($ch);
|
||||
$httpCode = curl_getinfo($ch, CURLINFO_HTTP_CODE);
|
||||
$errno = curl_errno($ch);
|
||||
$error = curl_error($ch);
|
||||
|
||||
if ($errno === 0) {
|
||||
echo " ✓ 成功 (HTTP {$httpCode})\n";
|
||||
} else {
|
||||
echo " ❌ 失败: [{$errno}] {$error}\n";
|
||||
}
|
||||
|
||||
curl_close($ch);
|
||||
}
|
||||
|
||||
echo "\n5. OpenSSL 版本信息:\n";
|
||||
$opensslVersion = OPENSSL_VERSION_TEXT;
|
||||
echo " 版本: {$opensslVersion}\n";
|
||||
|
||||
echo "\n6. cURL 版本信息:\n";
|
||||
$curlVersion = curl_version();
|
||||
echo " 版本: {$curlVersion['version']}\n";
|
||||
echo " SSL 版本: {$curlVersion['ssl_version']}\n";
|
||||
echo " 支持的协议: " . implode(', ', $curlVersion['protocols']) . "\n";
|
||||
|
||||
echo "\n7. 测试实际 API 调用 (MAKE_TOKEN):\n";
|
||||
|
||||
try {
|
||||
$token = \app\common\service\gancao\GancaoScmRecipelService::getToken(true);
|
||||
if ($token !== null && $token !== '') {
|
||||
echo " ✓ 成功获取 token (长度: " . strlen($token) . ")\n";
|
||||
echo " Token 前20字符: " . substr($token, 0, 20) . "...\n";
|
||||
} else {
|
||||
$error = \app\common\service\gancao\GancaoScmRecipelService::getLastGetTokenError();
|
||||
echo " ❌ 获取 token 失败: {$error}\n";
|
||||
}
|
||||
} catch (\Throwable $e) {
|
||||
echo " ❌ 异常: " . $e->getMessage() . "\n";
|
||||
}
|
||||
|
||||
echo "\n=== 测试完成 ===\n\n";
|
||||
|
||||
echo "建议:\n";
|
||||
echo "1. 如果 TLS 1.2 测试失败,请升级 OpenSSL 到 1.0.2 或更高版本\n";
|
||||
echo "2. 如果所有测试都失败,请检查防火墙和网络连接\n";
|
||||
echo "3. 如果只有特定配置失败,请使用成功的配置\n";
|
||||
echo "4. 联系甘草技术支持确认他们的 SSL/TLS 要求\n";
|
||||
Reference in New Issue
Block a user