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, 0, $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(快递单号),可能尚未发货'); 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, 'raw' => $result, 'assistant_sync' => $assistantSync, ]; } /** * 调用甘草 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), '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> $routeList 原始路由数组 * @param array $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> $routeList * @return array|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, details:array>} */ 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', '<>', ''); }); // 排除已取消(4)、已签收(6);已完成(3)仍跑一次以补齐路由 $q->whereNotIn('fulfillment_status', [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, details:array>} $stats */ private static function appendSyncOneResult(array &$stats, PrescriptionOrder $order): void { $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'], ]; 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']++; $stats['assistant_lines'][] = sprintf( '[移除医助+指派日志][甘草路由] 诊单=%d 业务订单=%d 运单=%s 原医助ID=%s 履约状态=%d', (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(), ]); } } }