524 lines
21 KiB
PHP
524 lines
21 KiB
PHP
<?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) {
|
||
$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(),
|
||
]);
|
||
}
|
||
}
|
||
|
||
return $stats;
|
||
}
|
||
}
|