Files
zyt/server/app/common/service/gancao/GancaoLogisticsRouteService.php
T
2026-05-20 15:40:01 +08:00

532 lines
21 KiB
PHP
Executable File
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<?php
declare(strict_types=1);
namespace app\common\service\gancao;
use app\common\model\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, 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<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', '<>', '');
});
// 排除已取消(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<string>, details:array<int, array<string,mixed>>} $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(),
]);
}
}
}