订单
This commit is contained in:
+15
-1
@@ -50,4 +50,18 @@ PRESCRIPTION_ORDER_LINK_PAY_MIN_AMOUNT = 0
|
||||
LOGISTICS_KUAIDI100_CUSTOMER =
|
||||
LOGISTICS_KUAIDI100_KEY =
|
||||
# LOGISTICS_KUAIDI100_DISABLE = false
|
||||
# LOGISTICS_KUAIDI100_QUERY_URL = https://poll.kuaidi100.com/poll/query.do
|
||||
# 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.
Reference in New Issue
Block a user