'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> $herbs 处方 herbs * @return array 药材名 => 甘草 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 $data 原始数据 * @return array 脱敏后的数据 */ 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> $herbs * @param array $nameToIdMap 药材名 => 甘草 id(config herb_id_map) * @param array $doctorNameToGid 医师库 name => gid(甘草) * @return array{0: list, 1: list} [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 $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 $rx 处方详情 toArray * @param array $order 业务订单 toArray * @param string $token * @return array */ 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', ]; if ($dfId === 101) { $base['df101ext'] = [ 'times_per_day' =>$rx['times_per_day'], 'is_decoct' => $rx['need_decoction'], 'num_per_pack' => $rx['bags_per_dose'], 'is_special_writing' => $rx['bags_per_dose']==$rx['times_per_day']?0:1, 'dose' => $rx['dosage_amount'], 'usage_mode' => 'ORAL', 'ds_type' => 1 ]; $base['doct_advice']=[ 'taboo'=>$rx['dietary_taboo'], 'usage_time'=>$rx['usage_time'], 'usage_brief'=>$rx['usage_instruction'], 'others'=>$rx['usage_notes'], 'notes_doctor'=>$order['remark_extra'] ]; $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 ]; } if ($dfId === 102) { $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'], 'notes_doctor'=>$order['remark_extra'] ]; $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 $rx * @param array $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; } }