Files
zyt/server/app/common/service/gancao/GancaoScmRecipelService.php
T
2026-05-11 17:49:38 +08:00

636 lines
22 KiB
PHP
Executable File
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<?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 药材名 => 甘草 idconfig 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',
];
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<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;
}
}