first commit
This commit is contained in:
@@ -0,0 +1,592 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace app\common\service\gancao;
|
||||
|
||||
use app\common\model\ExpressQueryLog;
|
||||
use app\common\model\ExpressStateLog;
|
||||
use app\common\model\ExpressTrace;
|
||||
use app\common\model\ExpressTracking;
|
||||
use app\common\model\tcm\PrescriptionOrder;
|
||||
use app\common\service\ExpressTrackingService;
|
||||
use think\facade\Log;
|
||||
|
||||
/**
|
||||
* 甘草 SCM「获取物流路由信息」(GET_TASK_ROUTE_LIST)
|
||||
*
|
||||
* 入参:app_order_no = 甘草处方订单号 recipel_order_no(不是我方 order_no),get_all=1
|
||||
* 出参 result.route_list[] = [{accept_time, remark, route_type, route_type_name}]
|
||||
*
|
||||
* 路由状态(route_type):
|
||||
* 0 任务创建
|
||||
* 10 已下单(待揽件)
|
||||
* 11 已揽件
|
||||
* 12 转运中
|
||||
* 13 已送达
|
||||
* 20 取消订单
|
||||
* 21 派件异常
|
||||
*
|
||||
* @see https://apidoc.igancao.com/service-doc/scm-outer-recipel.html#%E8%8E%B7%E5%8F%96%E7%89%A9%E6%B5%81%E8%B7%AF%E7%94%B1%E4%BF%A1%E6%81%AF
|
||||
*/
|
||||
final class GancaoLogisticsRouteService
|
||||
{
|
||||
/**
|
||||
* 路由状态 → 系统物流状态(与 ExpressTracking 常量对齐)
|
||||
*/
|
||||
private const ROUTE_TYPE_TO_STATE = [
|
||||
0 => ExpressTracking::STATE_IN_TRANSIT, // 任务创建 → 在途
|
||||
10 => ExpressTracking::STATE_IN_TRANSIT, // 已下单待揽件 → 在途
|
||||
11 => ExpressTracking::STATE_COLLECTED, // 已揽件 → 揽收
|
||||
12 => ExpressTracking::STATE_IN_TRANSIT, // 转运中 → 在途
|
||||
13 => ExpressTracking::STATE_SIGNED, // 已送达 → 签收
|
||||
20 => ExpressTracking::STATE_RETURN_SIGNED, // 取消订单 → 退签
|
||||
21 => ExpressTracking::STATE_PROBLEM, // 派件异常 → 疑难
|
||||
];
|
||||
|
||||
private const ROUTE_TYPE_NAMES = [
|
||||
0 => '任务创建',
|
||||
10 => '已下单(待揽件)',
|
||||
11 => '已揽件',
|
||||
12 => '转运中',
|
||||
13 => '已送达',
|
||||
20 => '取消订单',
|
||||
21 => '派件异常',
|
||||
];
|
||||
|
||||
/**
|
||||
* 拉取并落库单个订单的物流路由
|
||||
*
|
||||
* @param PrescriptionOrder $order
|
||||
* @return array{success:bool, message:string, traces_count:int, state:string, raw?:array, assistant_sync?:array|null}
|
||||
*/
|
||||
public static function syncOne(PrescriptionOrder $order): array
|
||||
{
|
||||
$appOrderNo = trim((string) ($order->gancao_reciperl_order_no ?? ''));
|
||||
if ($appOrderNo === '') {
|
||||
return ['success' => false, 'message' => '订单未关联甘草处方单号', 'traces_count' => 0, 'state' => ''];
|
||||
}
|
||||
|
||||
$resp = self::callRouteApi($appOrderNo);
|
||||
if (!$resp['success']) {
|
||||
self::logQuery($order, '', 'auto', false, (int) ($resp['runtime_ms'] ?? 0), $resp['message']);
|
||||
if (self::shouldFallbackToKuaidi100($resp)) {
|
||||
return self::syncOneViaKuaidi100($order, (string) $resp['message']);
|
||||
}
|
||||
|
||||
return ['success' => false, 'message' => $resp['message'], 'traces_count' => 0, 'state' => ''];
|
||||
}
|
||||
|
||||
$result = $resp['result'];
|
||||
$apiTracking = trim((string) ($result['sp_order_no'] ?? ''));
|
||||
$localTracking = trim((string) ($order->tracking_number ?? ''));
|
||||
// 业务订单已录快递单号时:不改用甘草返回的 sp_order_no,轨迹仍写入本地单号对应的运单记录
|
||||
if ($localTracking !== '') {
|
||||
$trackingNumber = mb_substr($localTracking, 0, 80);
|
||||
} else {
|
||||
$trackingNumber = mb_substr($apiTracking, 0, 80);
|
||||
}
|
||||
$spName = trim((string) ($result['sp_name'] ?? ($order->gancao_shipping_name ?? '')));
|
||||
$routeList = is_array($result['route_list'] ?? null) ? $result['route_list'] : [];
|
||||
|
||||
if ($trackingNumber === '') {
|
||||
self::logQuery($order, '', 'auto', false, $resp['runtime_ms'], '甘草未返回 sp_order_no(快递单号),可能尚未发货');
|
||||
if (trim((string) ($order->tracking_number ?? '')) !== '') {
|
||||
return self::syncOneViaKuaidi100($order, '甘草未返回快递单号,业务单已填写运单号');
|
||||
}
|
||||
|
||||
return ['success' => false, 'message' => '尚未发货(无快递单号)', 'traces_count' => 0, 'state' => ''];
|
||||
}
|
||||
|
||||
$tracking = self::ensureTracking($order, $trackingNumber, $spName);
|
||||
|
||||
// 回写订单的快递单号 / 物流公司(如果之前空着)+ 签收时升级 fulfillment_status
|
||||
$orderDirty = false;
|
||||
if ($localTracking === '') {
|
||||
$order->tracking_number = mb_substr($trackingNumber, 0, 80);
|
||||
$orderDirty = true;
|
||||
}
|
||||
if ($spName !== '' && trim((string) ($order->gancao_shipping_name ?? '')) === '') {
|
||||
$order->gancao_shipping_name = mb_substr($spName, 0, 50);
|
||||
$orderDirty = true;
|
||||
}
|
||||
|
||||
$tracesCount = self::saveRoutes($tracking, $routeList);
|
||||
$newState = self::resolveCurrentState($routeList);
|
||||
self::updateTrackingHead($tracking, $newState, $routeList, $result);
|
||||
|
||||
// 签收 → 订单 fulfillment_status = 6(已签收),仅在处于发货/履约中阶段时升级,避免覆盖已完成/已取消/已签收
|
||||
if ($newState === ExpressTracking::STATE_SIGNED) {
|
||||
$currentFs = (int) ($order->fulfillment_status ?? 0);
|
||||
if (in_array($currentFs, [1, 2, 5], true)) {
|
||||
$order->fulfillment_status = 6;
|
||||
$orderDirty = true;
|
||||
}
|
||||
}
|
||||
|
||||
if ($orderDirty) {
|
||||
try {
|
||||
$order->save();
|
||||
} catch (\Throwable $e) {
|
||||
Log::warning('Gancao route: backfill order failed: ' . $e->getMessage(), ['order_id' => $order->id]);
|
||||
}
|
||||
}
|
||||
|
||||
self::logQuery($order, $trackingNumber, 'auto', true, $resp['runtime_ms'], '', count($routeList));
|
||||
|
||||
$assistantSync = ExpressTrackingService::applyAssistantReleaseForShippedPrescriptionOrder($order, [
|
||||
'tracking_number' => $trackingNumber,
|
||||
'source' => 'gancao_route_sync',
|
||||
]);
|
||||
|
||||
return [
|
||||
'success' => true,
|
||||
'message' => 'ok',
|
||||
'traces_count' => $tracesCount,
|
||||
'state' => $newState,
|
||||
'source' => 'gancao',
|
||||
'raw' => $result,
|
||||
'assistant_sync' => $assistantSync,
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* 甘草侧无快递任务 / 路由拉取失败时,按业务单运单号降级快递100(自发货)
|
||||
*
|
||||
* @return array{success:bool, message:string, traces_count:int, state:string, source?:string, assistant_sync?:array|null}
|
||||
*/
|
||||
private static function syncOneViaKuaidi100(PrescriptionOrder $order, string $gancaoReason): array
|
||||
{
|
||||
$ret = ExpressTrackingService::queryKuaidiForPrescriptionOrder($order, true);
|
||||
if (!$ret['success']) {
|
||||
$ret['message'] = trim((string) $ret['message']) . ';甘草:' . mb_substr($gancaoReason, 0, 200);
|
||||
}
|
||||
|
||||
return $ret;
|
||||
}
|
||||
|
||||
/**
|
||||
* 是否因甘草「快递任务」类错误降级走快递100
|
||||
*
|
||||
* @param array{success?:bool, message?:string, api_code?:string} $gancaoResp
|
||||
*/
|
||||
private static function shouldFallbackToKuaidi100(array $gancaoResp): bool
|
||||
{
|
||||
$code = trim((string) ($gancaoResp['api_code'] ?? ''));
|
||||
if ($code === '10101') {
|
||||
return true;
|
||||
}
|
||||
|
||||
$msg = (string) ($gancaoResp['message'] ?? '');
|
||||
if ($msg === '') {
|
||||
return false;
|
||||
}
|
||||
|
||||
$needles = ['快递任务', '任务id不存在', '任务不存在', '无快递任务', '物流任务'];
|
||||
foreach ($needles as $needle) {
|
||||
if (mb_strpos($msg, $needle) !== false) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* 调用甘草 GET_TASK_ROUTE_LIST 接口
|
||||
*
|
||||
* @return array{success:bool, message:string, result?:array, runtime_ms:int}
|
||||
*/
|
||||
private static function callRouteApi(string $appOrderNo): array
|
||||
{
|
||||
$token = GancaoScmRecipelService::getToken();
|
||||
if ($token === null || $token === '') {
|
||||
return [
|
||||
'success' => false,
|
||||
'message' => '获取 token 失败:' . GancaoScmRecipelService::getLastGetTokenError(),
|
||||
'runtime_ms' => 0,
|
||||
];
|
||||
}
|
||||
|
||||
$payload = [
|
||||
'token' => $token,
|
||||
'app_order_no' => $appOrderNo,
|
||||
'get_all' => 1,
|
||||
'package' => 'igc_scm.logistics.client_opt.pull',
|
||||
'class' => 'GET_TASK_ROUTE_LIST',
|
||||
];
|
||||
|
||||
$start = microtime(true);
|
||||
$ret = self::transport()->post($payload);
|
||||
$runtimeMs = (int) ((microtime(true) - $start) * 1000);
|
||||
|
||||
if ((int) ($ret['state'] ?? 0) !== 1) {
|
||||
return [
|
||||
'success' => false,
|
||||
'message' => '通信失败:' . (string) ($ret['msg'] ?? ''),
|
||||
'runtime_ms' => $runtimeMs,
|
||||
];
|
||||
}
|
||||
$body = $ret['body'] ?? [];
|
||||
if (!GancaoScmRecipelService::isApiSuccess($body)) {
|
||||
return [
|
||||
'success' => false,
|
||||
'message' => 'API 错误:' . GancaoScmRecipelService::apiStatusMessage($body),
|
||||
'api_code' => (string) ($body['status']['code'] ?? ''),
|
||||
'runtime_ms' => $runtimeMs,
|
||||
];
|
||||
}
|
||||
$result = is_array($body['result'] ?? null) ? $body['result'] : [];
|
||||
|
||||
return [
|
||||
'success' => true,
|
||||
'message' => 'ok',
|
||||
'result' => $result,
|
||||
'runtime_ms' => $runtimeMs,
|
||||
];
|
||||
}
|
||||
|
||||
private static function transport(): GancaoOpenApiTransport
|
||||
{
|
||||
$c = \think\facade\Config::get('gancao_scm', []);
|
||||
|
||||
return new GancaoOpenApiTransport(
|
||||
(string) $c['gateway_url'],
|
||||
(string) $c['gateway_ak'],
|
||||
(string) $c['gateway_sk']
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* 保证 zyt_express_tracking 主记录存在
|
||||
*/
|
||||
private static function ensureTracking(PrescriptionOrder $order, string $trackingNumber, string $spName): ExpressTracking
|
||||
{
|
||||
$tracking = ExpressTracking::where('tracking_number', $trackingNumber)
|
||||
->whereNull('delete_time')
|
||||
->find();
|
||||
$now = time();
|
||||
|
||||
if (!$tracking) {
|
||||
$tracking = new ExpressTracking();
|
||||
$tracking->tracking_number = $trackingNumber;
|
||||
$tracking->create_time = $now;
|
||||
$tracking->next_update_time = $now;
|
||||
}
|
||||
|
||||
$tracking->order_id = (int) $order->id;
|
||||
$tracking->order_type = 'prescription';
|
||||
$tracking->express_company = self::expressCodeFromGancaoName($spName) ?: 'auto';
|
||||
$tracking->express_company_name = $spName !== '' ? mb_substr($spName, 0, 100) : (string) $tracking->express_company_name;
|
||||
$tracking->recipient_phone = (string) ($order->recipient_phone ?? '');
|
||||
$tracking->recipient_name = (string) ($order->recipient_name ?? '');
|
||||
$tracking->recipient_address = (string) ($order->shipping_address ?? '');
|
||||
$tracking->update_time = $now;
|
||||
$tracking->save();
|
||||
|
||||
return $tracking;
|
||||
}
|
||||
|
||||
/**
|
||||
* 把甘草的 route_list 写入 zyt_express_trace(按 (tracking_id, trace_time, trace_context) 去重)
|
||||
*/
|
||||
private static function saveRoutes(ExpressTracking $tracking, array $routeList): int
|
||||
{
|
||||
$inserted = 0;
|
||||
foreach ($routeList as $route) {
|
||||
$time = trim((string) ($route['accept_time'] ?? ''));
|
||||
$remark = trim((string) ($route['remark'] ?? ''));
|
||||
if ($time === '' && $remark === '') {
|
||||
continue;
|
||||
}
|
||||
$routeType = (int) ($route['route_type'] ?? -1);
|
||||
$routeName = trim((string) ($route['route_type_name'] ?? '')) ?: (self::ROUTE_TYPE_NAMES[$routeType] ?? '');
|
||||
|
||||
$exists = ExpressTrace::where('tracking_id', $tracking->id)
|
||||
->where('trace_time', $time)
|
||||
->where('trace_context', $remark)
|
||||
->count();
|
||||
if ($exists > 0) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$model = new ExpressTrace();
|
||||
$model->tracking_id = (int) $tracking->id;
|
||||
$model->tracking_number = (string) $tracking->tracking_number;
|
||||
$model->trace_time = mb_substr($time, 0, 50);
|
||||
$model->trace_time_stamp = $time !== '' ? (strtotime($time) ?: time()) : time();
|
||||
$model->trace_context = mb_substr($remark, 0, 1000);
|
||||
$model->status = mb_substr($routeName, 0, 50);
|
||||
$model->status_code = (string) $routeType;
|
||||
$model->location = '';
|
||||
$model->extra_data = json_encode($route, JSON_UNESCAPED_UNICODE);
|
||||
$model->create_time = time();
|
||||
$model->save();
|
||||
$inserted++;
|
||||
}
|
||||
return $inserted;
|
||||
}
|
||||
|
||||
/**
|
||||
* 用最新的 route 更新 tracking 头部(current_state、最新轨迹、签收等)
|
||||
*
|
||||
* @param array<int, array<string,mixed>> $routeList 原始路由数组
|
||||
* @param array<string, mixed> $result 完整 result(含 t_status 等)
|
||||
*/
|
||||
private static function updateTrackingHead(ExpressTracking $tracking, string $newState, array $routeList, array $result): void
|
||||
{
|
||||
$now = time();
|
||||
$oldState = (string) $tracking->current_state;
|
||||
|
||||
$tracking->current_state = $newState !== '' ? $newState : (string) $tracking->current_state;
|
||||
$tracking->current_state_text = ExpressTracking::getStateText($tracking->current_state);
|
||||
$tracking->data_source = 'gancao';
|
||||
$tracking->last_query_time = $now;
|
||||
$tracking->query_count = (int) $tracking->query_count + 1;
|
||||
|
||||
$latest = self::pickLatestRoute($routeList);
|
||||
if ($latest !== null) {
|
||||
$tracking->latest_trace_time = mb_substr((string) ($latest['accept_time'] ?? ''), 0, 50);
|
||||
$tracking->latest_trace_context = mb_substr((string) ($latest['remark'] ?? ''), 0, 500);
|
||||
$tracking->latest_location = '';
|
||||
}
|
||||
|
||||
$tracking->route_info = json_encode($result, JSON_UNESCAPED_UNICODE);
|
||||
|
||||
if (ExpressTracking::isFinalState($tracking->current_state)) {
|
||||
$tracking->is_signed = 1;
|
||||
$tracking->sign_time = $latest && !empty($latest['accept_time']) ? (strtotime((string) $latest['accept_time']) ?: $now) : $now;
|
||||
$tracking->auto_update = 0;
|
||||
}
|
||||
|
||||
if ((int) $tracking->auto_update === 1 && !ExpressTracking::isFinalState($tracking->current_state)) {
|
||||
$interval = (int) ($tracking->update_interval ?: 1800);
|
||||
$tracking->next_update_time = $now + $interval;
|
||||
}
|
||||
|
||||
$tracking->update_time = $now;
|
||||
$tracking->save();
|
||||
|
||||
if ($oldState !== '' && $oldState !== $tracking->current_state) {
|
||||
$log = new ExpressStateLog();
|
||||
$log->tracking_id = (int) $tracking->id;
|
||||
$log->tracking_number = (string) $tracking->tracking_number;
|
||||
$log->old_state = $oldState;
|
||||
$log->old_state_text = ExpressTracking::getStateText($oldState);
|
||||
$log->new_state = (string) $tracking->current_state;
|
||||
$log->new_state_text = (string) $tracking->current_state_text;
|
||||
$log->change_time = $now;
|
||||
$log->change_reason = (string) $tracking->latest_trace_context;
|
||||
$log->create_time = $now;
|
||||
try {
|
||||
$log->save();
|
||||
} catch (\Throwable $e) {
|
||||
Log::warning('Gancao route: state log save failed: ' . $e->getMessage());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 取 route_list 中时间最新的一条
|
||||
*
|
||||
* @param array<int, array<string,mixed>> $routeList
|
||||
* @return array<string, mixed>|null
|
||||
*/
|
||||
private static function pickLatestRoute(array $routeList): ?array
|
||||
{
|
||||
if (empty($routeList)) {
|
||||
return null;
|
||||
}
|
||||
usort($routeList, function ($a, $b) {
|
||||
$ta = strtotime((string) ($a['accept_time'] ?? '')) ?: 0;
|
||||
$tb = strtotime((string) ($b['accept_time'] ?? '')) ?: 0;
|
||||
return $tb <=> $ta;
|
||||
});
|
||||
return $routeList[0];
|
||||
}
|
||||
|
||||
/**
|
||||
* 根据所有路由确定当前最终状态:取出现过的 route_type 中最高优先级
|
||||
*/
|
||||
private static function resolveCurrentState(array $routeList): string
|
||||
{
|
||||
$hasSigned = false;
|
||||
$hasProblem = false;
|
||||
$hasCancel = false;
|
||||
$hasCollected = false;
|
||||
$hasInTransit = false;
|
||||
foreach ($routeList as $r) {
|
||||
$t = (int) ($r['route_type'] ?? -1);
|
||||
if ($t === 13) $hasSigned = true;
|
||||
elseif ($t === 21) $hasProblem = true;
|
||||
elseif ($t === 20) $hasCancel = true;
|
||||
elseif ($t === 11) $hasCollected = true;
|
||||
elseif (in_array($t, [0, 10, 12], true)) $hasInTransit = true;
|
||||
}
|
||||
if ($hasSigned) return ExpressTracking::STATE_SIGNED;
|
||||
if ($hasCancel) return ExpressTracking::STATE_RETURN_SIGNED;
|
||||
if ($hasProblem) return ExpressTracking::STATE_PROBLEM;
|
||||
if ($hasCollected) return ExpressTracking::STATE_COLLECTED;
|
||||
if ($hasInTransit) return ExpressTracking::STATE_IN_TRANSIT;
|
||||
return ExpressTracking::STATE_IN_TRANSIT;
|
||||
}
|
||||
|
||||
/**
|
||||
* 甘草 sp_name → 系统 express_company 编码(与 GancaoCallbackController::EXPRESS_MAP 对齐)
|
||||
*/
|
||||
private static function expressCodeFromGancaoName(string $name): string
|
||||
{
|
||||
$map = [
|
||||
'顺丰' => 'sf',
|
||||
'京东' => 'jd',
|
||||
'极兔' => 'jt',
|
||||
'圆通' => 'yt',
|
||||
'中通' => 'zt',
|
||||
'韵达' => 'yd',
|
||||
'申通' => 'st',
|
||||
'邮政' => 'yz',
|
||||
'EMS' => 'ems',
|
||||
];
|
||||
foreach ($map as $kw => $code) {
|
||||
if ($name !== '' && mb_strpos($name, $kw) !== false) {
|
||||
return $code;
|
||||
}
|
||||
}
|
||||
return '';
|
||||
}
|
||||
|
||||
/**
|
||||
* 写一条 zyt_express_query_log(沿用现有结构,便于在管理后台统一查询)
|
||||
*/
|
||||
private static function logQuery(
|
||||
PrescriptionOrder $order,
|
||||
string $trackingNumber,
|
||||
string $queryType,
|
||||
bool $success,
|
||||
int $runtimeMs,
|
||||
string $errMsg,
|
||||
int $traceCount = 0
|
||||
): void {
|
||||
try {
|
||||
$log = new ExpressQueryLog();
|
||||
$log->tracking_number = $trackingNumber !== '' ? $trackingNumber : (string) ($order->tracking_number ?? '');
|
||||
$log->express_company = (string) ($order->express_company ?? 'auto');
|
||||
$log->query_type = $queryType;
|
||||
$log->query_source = 'gancao';
|
||||
$log->query_time = time();
|
||||
$log->is_success = $success ? 1 : 0;
|
||||
$log->error_code = $success ? '' : '500';
|
||||
$log->error_message = mb_substr($errMsg, 0, 500);
|
||||
$log->response_time = $runtimeMs;
|
||||
$log->trace_count = $traceCount;
|
||||
$log->create_time = time();
|
||||
$log->save();
|
||||
} catch (\Throwable $e) {
|
||||
Log::warning('Gancao route: query log save failed: ' . $e->getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 批量同步:选取候选订单(已上传甘草、且未完成/未签收/未取消的)
|
||||
*
|
||||
* @return array{total:int, success:int, failed:int, skipped:int, assistant_cleared:int, assistant_skipped_assign_log:int, assistant_lines:list<string>, details:array<int, array<string,mixed>>}
|
||||
*/
|
||||
public static function syncBatch(int $limit = 100, ?int $onlyOrderId = null): array
|
||||
{
|
||||
$stats = [
|
||||
'total' => 0,
|
||||
'success' => 0,
|
||||
'failed' => 0,
|
||||
'skipped' => 0,
|
||||
'assistant_cleared' => 0,
|
||||
'assistant_skipped_assign_log' => 0,
|
||||
'assistant_lines' => [],
|
||||
'details' => [],
|
||||
];
|
||||
|
||||
$q = PrescriptionOrder::whereNull('delete_time')
|
||||
->where('gancao_reciperl_order_no', '<>', '');
|
||||
|
||||
if ($onlyOrderId !== null && $onlyOrderId > 0) {
|
||||
$q->where('id', $onlyOrderId);
|
||||
} else {
|
||||
// 仅同步未完成/未取消、且甘草已进入物流或更晚阶段的(state 110 / 20 / 30 / 90)
|
||||
// state 含义见 GancaoCallbackController::STATE_MAP
|
||||
$q->where(function ($w) {
|
||||
$w->whereIn('gancao_order_state', [110, 20, 30, 90])
|
||||
->whereOr('tracking_number', '<>', '');
|
||||
});
|
||||
// 排除已完成(3)、已取消(4)、已签收(6)
|
||||
$q->whereNotIn('fulfillment_status', [3, 4, 6]);
|
||||
}
|
||||
|
||||
$orders = $q->order('id', 'desc')->limit($limit)->select();
|
||||
foreach ($orders as $order) {
|
||||
self::appendSyncOneResult($stats, $order);
|
||||
}
|
||||
|
||||
return $stats;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array{total:int, success:int, failed:int, skipped:int, assistant_cleared:int, assistant_skipped_assign_log:int, assistant_lines:list<string>, details:array<int, array<string,mixed>>} $stats
|
||||
*/
|
||||
private static function appendSyncOneResult(array &$stats, PrescriptionOrder $order): void
|
||||
{
|
||||
$fs = (int) ($order->fulfillment_status ?? 0);
|
||||
if (in_array($fs, [3, 6], true)) {
|
||||
$stats['skipped']++;
|
||||
return;
|
||||
}
|
||||
|
||||
$stats['total']++;
|
||||
try {
|
||||
$r = self::syncOne($order);
|
||||
$detail = [
|
||||
'order_id' => (int) $order->id,
|
||||
'order_no' => (string) $order->order_no,
|
||||
'app_order_no' => (string) $order->gancao_reciperl_order_no,
|
||||
'tracking_number' => (string) $order->tracking_number,
|
||||
'success' => $r['success'],
|
||||
'message' => $r['message'],
|
||||
'traces' => $r['traces_count'],
|
||||
'state' => $r['state'],
|
||||
'source' => $r['source'] ?? 'gancao',
|
||||
];
|
||||
if ($r['success']) {
|
||||
$stats['success']++;
|
||||
if (!empty($r['assistant_sync']) && is_array($r['assistant_sync'])) {
|
||||
$sync = $r['assistant_sync'];
|
||||
$act = (string) ($sync['action'] ?? '');
|
||||
if ($act === 'cleared') {
|
||||
$stats['assistant_cleared']++;
|
||||
$channel = (($r['source'] ?? '') === 'kuaidi100_fallback') ? '快递100降级' : '甘草路由';
|
||||
$stats['assistant_lines'][] = sprintf(
|
||||
'[移除医助+指派日志][%s] 诊单=%d 业务订单=%d 运单=%s 原医助ID=%s 履约状态=%d',
|
||||
$channel,
|
||||
(int) ($sync['diagnosis_id'] ?? 0),
|
||||
(int) ($sync['prescription_order_id'] ?? 0),
|
||||
(string) ($sync['tracking_number'] ?? ''),
|
||||
(string) ($sync['former_assistant_id'] ?? ''),
|
||||
(int) ($sync['fulfillment_status'] ?? 0)
|
||||
);
|
||||
}
|
||||
}
|
||||
} else {
|
||||
$stats['failed']++;
|
||||
}
|
||||
$stats['details'][] = $detail;
|
||||
} catch (\Throwable $e) {
|
||||
$stats['failed']++;
|
||||
$stats['details'][] = [
|
||||
'order_id' => (int) $order->id,
|
||||
'success' => false,
|
||||
'message' => '异常:' . $e->getMessage(),
|
||||
];
|
||||
Log::error('Gancao route sync exception: ' . $e->getMessage(), [
|
||||
'order_id' => (int) $order->id,
|
||||
'trace' => $e->getTraceAsString(),
|
||||
]);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,179 @@
|
||||
<?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:',
|
||||
];
|
||||
|
||||
// cURL 常量在部分精简构建里可能未注册,统一用 defined() 做优雅降级,值取自官方枚举
|
||||
$httpVer11 = defined('CURL_HTTP_VERSION_1_1') ? CURL_HTTP_VERSION_1_1 : 2;
|
||||
$ipv4Only = defined('CURL_IPRESOLVE_V4') ? CURL_IPRESOLVE_V4 : 1;
|
||||
|
||||
$ch = curl_init($this->url);
|
||||
curl_setopt($ch, CURLOPT_HTTP_VERSION, $httpVer11); // 改用 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, $ipv4Only);
|
||||
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,完全禁用主机验证
|
||||
// 强制 TLS 1.2;常量值为 6,但部分 PHP/cURL 构建未注册该常量,运行时未定义时退化为默认 TLS
|
||||
$tls12 = defined('CURL_SSLVERSION_TLSv1_2') ? CURL_SSLVERSION_TLSv1_2 : 6;
|
||||
curl_setopt($ch, CURLOPT_SSLVERSION, $tls12);
|
||||
// 可选 cipher list。默认不设(交给 cURL 自带默认,兼容性最好)。
|
||||
// 需要兼容弱加密服务器时,在 config/gancao_scm.php 或 .env 里设:
|
||||
// GANCAO_SCM_TLS_CIPHERS="DEFAULT@SECLEVEL=1" // 仅 OpenSSL 构建的 cURL 支持此语法
|
||||
// GANCAO_SCM_TLS_CIPHERS="DEFAULT:!aNULL:!eNULL" // 通用写法
|
||||
// cURL 是 LibreSSL/NSS/GnuTLS/BoringSSL 时,@SECLEVEL= 会直接报 CURLE_SSL_CIPHER(59)。
|
||||
$cipherList = trim((string) \think\facade\Config::get('gancao_scm.tls_ciphers', ''));
|
||||
if ($cipherList !== '') {
|
||||
curl_setopt($ch, CURLOPT_SSL_CIPHER_LIST, $cipherList);
|
||||
}
|
||||
}
|
||||
// 添加 TCP keepalive(部分旧 libcurl 可能没注册 CURLOPT_TCP_KEEPALIVE,守一下)
|
||||
if (defined('CURLOPT_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,635 @@
|
||||
<?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',
|
||||
];
|
||||
|
||||
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;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user