diff --git a/server/app/command/GancaoSyncLogisticsRoute.php b/server/app/command/GancaoSyncLogisticsRoute.php new file mode 100644 index 00000000..2742f673 --- /dev/null +++ b/server/app/command/GancaoSyncLogisticsRoute.php @@ -0,0 +1,101 @@ +> runtime/log/gancao_sync.log 2>&1 + */ +class GancaoSyncLogisticsRoute extends Command +{ + protected function configure() + { + $this->setName('gancao:sync-logistics') + ->setDescription('同步甘草订单的物流路由(GET_TASK_ROUTE_LIST)到本地物流追踪表') + ->addOption('limit', 'l', Option::VALUE_OPTIONAL, '本次最多处理多少条订单', 100) + ->addOption('order-id', null, Option::VALUE_OPTIONAL, '只同步指定 prescription_order.id', null) + ->addOption('detail', 'd', Option::VALUE_NONE, '打印每单详细结果'); + } + + protected function execute(Input $input, Output $output) + { + $limit = max(1, (int) $input->getOption('limit')); + $onlyOrderId = $input->getOption('order-id'); + $onlyOrderId = $onlyOrderId !== null ? (int) $onlyOrderId : null; + $verbose = (bool) $input->getOption('detail'); + + $output->writeln('========================================'); + $output->writeln('甘草物流路由同步'); + $output->writeln('========================================'); + + if (!GancaoScmRecipelService::isConfigured()) { + $output->error('甘草 SCM 未配置:' . GancaoScmRecipelService::whyNotConfigured()); + return 1; + } + + $start = microtime(true); + $output->writeln('开始同步...(limit=' . $limit . ($onlyOrderId ? ', order_id=' . $onlyOrderId : '') . ')'); + + try { + $stats = GancaoLogisticsRouteService::syncBatch($limit, $onlyOrderId); + } catch (\Throwable $e) { + $output->error('同步异常:' . $e->getMessage()); + return 1; + } + + $duration = round(microtime(true) - $start, 2); + + if ($verbose && !empty($stats['details'])) { + $output->writeln(''); + $output->writeln('--- 详细 ---'); + foreach ($stats['details'] as $row) { + $tag = !empty($row['success']) ? '[OK]' : '[FAIL]'; + $line = sprintf( + '%s order_id=%s order_no=%s app_order_no=%s tn=%s state=%s traces=+%s msg=%s', + $tag, + $row['order_id'] ?? '', + $row['order_no'] ?? '', + $row['app_order_no'] ?? '', + $row['tracking_number'] ?? '', + $row['state'] ?? '', + $row['traces'] ?? 0, + $row['message'] ?? '' + ); + $output->writeln($line); + } + } + + $output->writeln(''); + $output->writeln('========================================'); + $output->writeln('同步完成'); + $output->writeln('========================================'); + $output->writeln('总数:' . $stats['total']); + $output->writeln('成功:' . $stats['success']); + $output->writeln('失败:' . $stats['failed']); + $output->writeln('耗时:' . $duration . 's'); + + return 0; + } +} diff --git a/server/app/common/service/gancao/GancaoLogisticsRouteService.php b/server/app/common/service/gancao/GancaoLogisticsRouteService.php new file mode 100644 index 00000000..df9a3272 --- /dev/null +++ b/server/app/common/service/gancao/GancaoLogisticsRouteService.php @@ -0,0 +1,474 @@ + 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} + */ + 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']; + $trackingNumber = trim((string) ($result['sp_order_no'] ?? ($order->tracking_number ?? ''))); + $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); + + // 回写订单的快递单号 / 物流公司(如果之前空着) + $orderDirty = false; + if (trim((string) $order->tracking_number) === '') { + $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; + } + if ($orderDirty) { + try { + $order->save(); + } catch (\Throwable $e) { + Log::warning('Gancao route: backfill order failed: ' . $e->getMessage(), ['order_id' => $order->id]); + } + } + + $tracesCount = self::saveRoutes($tracking, $routeList); + $newState = self::resolveCurrentState($routeList); + self::updateTrackingHead($tracking, $newState, $routeList, $result); + + self::logQuery($order, $trackingNumber, 'auto', true, $resp['runtime_ms'], '', count($routeList)); + + return [ + 'success' => true, + 'message' => 'ok', + 'traces_count' => $tracesCount, + 'state' => $newState, + 'raw' => $result, + ]; + } + + /** + * 调用甘草 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, details:array>} + */ + public static function syncBatch(int $limit = 100, ?int $onlyOrderId = null): array + { + $stats = ['total' => 0, 'success' => 0, 'failed' => 0, 'skipped' => 0, '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', '<>', ''); + }); + $q->whereNotIn('fulfillment_status', [4]); // 排除已取消;已完成(3)也跑一次以补齐路由 + } + + $orders = $q->order('id', 'desc')->limit($limit)->select(); + foreach ($orders as $order) { + $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']++; + } 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(), + ]); + } + } + + return $stats; + } +}