This commit is contained in:
Your Name
2026-04-17 16:45:52 +08:00
parent 2d9355e51f
commit 4ef6a0dbcb
2 changed files with 575 additions and 0 deletions
@@ -0,0 +1,101 @@
<?php
declare(strict_types=1);
namespace app\command;
use app\common\service\gancao\GancaoLogisticsRouteService;
use app\common\service\gancao\GancaoScmRecipelService;
use think\console\Command;
use think\console\Input;
use think\console\input\Option;
use think\console\Output;
/**
* 同步甘草订单的物流路由信息到本地物流追踪表
*
* 数据流:
* zyt_tcm_prescription_order (甘草已上传)
* ↓ 调用 igc_scm.logistics.client_opt.pull / GET_TASK_ROUTE_LIST
* zyt_express_tracking + zyt_express_trace + zyt_express_state_log + zyt_express_query_log
*
* 使用方法:
* php think gancao:sync-logistics 默认拉 100 单
* php think gancao:sync-logistics --limit=200 自定义拉取上限
* php think gancao:sync-logistics --order-id=123 只跑指定订单
* php think gancao:sync-logistics --detail 打印每单详细
*
* 建议 crontab(每 30 分钟执行一次):
* 0,30 * * * * cd /path/to/server && php think gancao:sync-logistics --limit=200 >> 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;
}
}
@@ -0,0 +1,474 @@
<?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 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}
*/
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<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, 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, '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;
}
}