492 lines
17 KiB
PHP
492 lines
17 KiB
PHP
<?php
|
||
|
||
declare(strict_types=1);
|
||
|
||
namespace app\common\service;
|
||
|
||
use app\common\model\ExpressTracking;
|
||
use app\common\model\ExpressTrace;
|
||
use app\common\model\ExpressStateLog;
|
||
use app\common\model\ExpressQueryLog;
|
||
use app\common\model\tcm\PrescriptionOrder;
|
||
use app\common\model\tcm\PrescriptionOrderLog;
|
||
use think\facade\Log;
|
||
|
||
/**
|
||
* 物流追踪服务(存储和自动更新)
|
||
*/
|
||
class ExpressTrackingService
|
||
{
|
||
/**
|
||
* 创建或更新物流追踪记录
|
||
*
|
||
* @param array $params [
|
||
* 'order_id' => 订单ID,
|
||
* 'order_type' => 订单类型,
|
||
* 'tracking_number' => 快递单号,
|
||
* 'express_company' => 快递公司,
|
||
* 'recipient_phone' => 收件人手机,
|
||
* 'recipient_name' => 收件人姓名,
|
||
* 'recipient_address' => 收件地址,
|
||
* ]
|
||
* @return ExpressTracking|null
|
||
*/
|
||
public static function createOrUpdate(array $params): ?ExpressTracking
|
||
{
|
||
$trackingNumber = trim($params['tracking_number'] ?? '');
|
||
if ($trackingNumber === '') {
|
||
return null;
|
||
}
|
||
|
||
$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->order_id = (int) ($params['order_id'] ?? 0);
|
||
$tracking->order_type = (string) ($params['order_type'] ?? 'prescription');
|
||
$tracking->express_company = (string) ($params['express_company'] ?? 'auto');
|
||
$tracking->recipient_phone = (string) ($params['recipient_phone'] ?? '');
|
||
$tracking->recipient_name = (string) ($params['recipient_name'] ?? '');
|
||
$tracking->recipient_address = (string) ($params['recipient_address'] ?? '');
|
||
$tracking->update_time = $now;
|
||
|
||
// 设置下次更新时间(立即查询)
|
||
if ($tracking->next_update_time === 0) {
|
||
$tracking->next_update_time = $now;
|
||
}
|
||
|
||
$tracking->save();
|
||
|
||
// 立即查询一次物流信息
|
||
self::queryAndUpdate($tracking->id);
|
||
|
||
return $tracking;
|
||
}
|
||
|
||
/**
|
||
* 查询并更新物流信息
|
||
*
|
||
* @param int $trackingId
|
||
* @param bool $isAuto 是否自动查询
|
||
* @return array|null
|
||
*/
|
||
public static function queryAndUpdate(int $trackingId, bool $isAuto = false): ?array
|
||
{
|
||
$tracking = ExpressTracking::where('id', $trackingId)
|
||
->whereNull('delete_time')
|
||
->find();
|
||
|
||
if (!$tracking) {
|
||
return null;
|
||
}
|
||
|
||
self::backfillRecipientPhoneFromPrescriptionOrder($tracking);
|
||
|
||
$startTime = microtime(true);
|
||
|
||
// 调用快递100查询(顺丰等单号需带收件人手机号后四位,见 ExpressTrackService)
|
||
$result = ExpressTrackService::query(
|
||
$tracking->express_company,
|
||
$tracking->tracking_number,
|
||
(string) $tracking->recipient_phone
|
||
);
|
||
|
||
$responseTime = (int) ((microtime(true) - $startTime) * 1000);
|
||
|
||
// 记录查询日志
|
||
self::logQuery(
|
||
$tracking->tracking_number,
|
||
$tracking->express_company,
|
||
$isAuto ? 'auto' : 'manual',
|
||
$result,
|
||
$responseTime
|
||
);
|
||
|
||
// 更新追踪记录
|
||
$now = time();
|
||
$oldState = $tracking->current_state;
|
||
|
||
$tracking->express_company_name = $result['carrier_label'] ?? '';
|
||
$tracking->current_state = $result['state'] ?? '0';
|
||
$tracking->current_state_text = $result['state_text'] ?? '';
|
||
$tracking->data_source = $result['source'] ?? '';
|
||
$tracking->last_query_time = $now;
|
||
$tracking->query_count = $tracking->query_count + 1;
|
||
|
||
// 更新预计信息
|
||
if (!empty($result['arrivalTime'])) {
|
||
$tracking->estimated_arrival_time = $result['arrivalTime'];
|
||
}
|
||
if (!empty($result['totalTime'])) {
|
||
$tracking->total_time = $result['totalTime'];
|
||
}
|
||
if (!empty($result['remainTime'])) {
|
||
$tracking->remain_time = $result['remainTime'];
|
||
}
|
||
|
||
// 更新路由信息
|
||
if (!empty($result['routeInfo'])) {
|
||
$tracking->route_info = json_encode($result['routeInfo'], JSON_UNESCAPED_UNICODE);
|
||
}
|
||
|
||
// 更新最新轨迹
|
||
if (!empty($result['traces']) && is_array($result['traces'])) {
|
||
$latestTrace = $result['traces'][0];
|
||
$tracking->latest_trace_time = $latestTrace['time'] ?? '';
|
||
$tracking->latest_trace_context = $latestTrace['context'] ?? '';
|
||
$tracking->latest_location = $latestTrace['location'] ?? '';
|
||
|
||
// 保存轨迹明细
|
||
self::saveTraces($tracking->id, $tracking->tracking_number, $result['traces']);
|
||
}
|
||
|
||
// 检查是否签收
|
||
if (ExpressTracking::isFinalState($tracking->current_state)) {
|
||
$tracking->is_signed = 1;
|
||
$tracking->sign_time = $now;
|
||
$tracking->auto_update = 0; // 停止自动更新
|
||
}
|
||
|
||
// 物流为「签收」(STATE_SIGNED) → 自动推进关联业务订单到「已签收」(fulfillment_status=6)
|
||
// 排除「退签/拒签」等其它终态,避免误把退/拒签的订单标成已签收
|
||
if ($tracking->current_state === ExpressTracking::STATE_SIGNED) {
|
||
self::autoMarkOrderAsSigned($tracking);
|
||
}
|
||
|
||
// 计算下次更新时间
|
||
if ($tracking->auto_update == 1 && !ExpressTracking::isFinalState($tracking->current_state)) {
|
||
$tracking->next_update_time = $now + $tracking->update_interval;
|
||
}
|
||
|
||
$tracking->update_time = $now;
|
||
$tracking->save();
|
||
|
||
// 记录状态变更
|
||
if ($oldState !== $tracking->current_state) {
|
||
self::logStateChange($tracking, $oldState);
|
||
}
|
||
|
||
return $result;
|
||
}
|
||
|
||
/**
|
||
* 物流签收时,自动将关联处方业务订单的履约状态推进到「已签收」(6)
|
||
*
|
||
* 仅在当前 fulfillment_status 属于「待双审通过(1)/待发货(2)/已发货(5)」时升级,
|
||
* 避免覆盖「已完成(3)/已取消(4)/已签收(6)/拒收(9)/退款(10)」等业务终态。
|
||
*/
|
||
private static function autoMarkOrderAsSigned(ExpressTracking $tracking): void
|
||
{
|
||
if ((string) $tracking->order_type !== 'prescription') {
|
||
return;
|
||
}
|
||
$orderId = (int) $tracking->order_id;
|
||
if ($orderId <= 0) {
|
||
return;
|
||
}
|
||
|
||
try {
|
||
$order = PrescriptionOrder::where('id', $orderId)->whereNull('delete_time')->find();
|
||
if (!$order) {
|
||
return;
|
||
}
|
||
|
||
$currentFs = (int) $order->fulfillment_status;
|
||
if (!in_array($currentFs, [1, 2, 5], true)) {
|
||
return;
|
||
}
|
||
|
||
$order->fulfillment_status = 6;
|
||
$order->save();
|
||
|
||
try {
|
||
$log = new PrescriptionOrderLog();
|
||
$log->prescription_order_id = $orderId;
|
||
$log->admin_id = 0;
|
||
$log->admin_name = '系统';
|
||
$log->action = 'auto_sign';
|
||
$log->summary = mb_substr(
|
||
'物流签收(运单:' . (string) $tracking->tracking_number . '),订单状态自动更新为「已签收」',
|
||
0,
|
||
500
|
||
);
|
||
$log->create_time = time();
|
||
$log->save();
|
||
} catch (\Throwable $e) {
|
||
Log::warning('Auto sign order log save failed: ' . $e->getMessage(), [
|
||
'order_id' => $orderId,
|
||
]);
|
||
}
|
||
} catch (\Throwable $e) {
|
||
Log::warning('Auto sign order failed: ' . $e->getMessage(), [
|
||
'tracking_id' => (int) $tracking->id,
|
||
'order_id' => $orderId,
|
||
]);
|
||
}
|
||
}
|
||
|
||
/**
|
||
* 物流表未存收件人手机时,从关联业务订单回填(顺丰/京东等查件必填后四位)
|
||
*/
|
||
private static function backfillRecipientPhoneFromPrescriptionOrder(ExpressTracking $tracking): void
|
||
{
|
||
$current = trim((string) $tracking->recipient_phone);
|
||
if ($current !== '') {
|
||
return;
|
||
}
|
||
if ((string) $tracking->order_type !== 'prescription' || (int) $tracking->order_id <= 0) {
|
||
return;
|
||
}
|
||
$po = PrescriptionOrder::where('id', (int) $tracking->order_id)
|
||
->whereNull('delete_time')
|
||
->find();
|
||
if ($po === null) {
|
||
return;
|
||
}
|
||
$p = trim((string) ($po->recipient_phone ?? ''));
|
||
if ($p !== '') {
|
||
$tracking->recipient_phone = $p;
|
||
}
|
||
}
|
||
|
||
/**
|
||
* 保存轨迹明细
|
||
*/
|
||
private static function saveTraces(int $trackingId, string $trackingNumber, array $traces): void
|
||
{
|
||
foreach ($traces as $trace) {
|
||
$traceTime = $trace['time'] ?? $trace['ftime'] ?? '';
|
||
if ($traceTime === '') {
|
||
continue;
|
||
}
|
||
|
||
// 检查是否已存在
|
||
$exists = ExpressTrace::where('tracking_id', $trackingId)
|
||
->where('trace_time', $traceTime)
|
||
->where('trace_context', $trace['context'] ?? '')
|
||
->count();
|
||
|
||
if ($exists > 0) {
|
||
continue;
|
||
}
|
||
|
||
$model = new ExpressTrace();
|
||
$model->tracking_id = $trackingId;
|
||
$model->tracking_number = $trackingNumber;
|
||
$model->trace_time = $traceTime;
|
||
$model->trace_time_stamp = strtotime($traceTime) ?: time();
|
||
$model->trace_context = $trace['context'] ?? '';
|
||
$model->status = $trace['status'] ?? '';
|
||
$model->status_code = $trace['statusCode'] ?? '';
|
||
$model->location = $trace['location'] ?? '';
|
||
$model->area_code = $trace['areaCode'] ?? '';
|
||
$model->area_name = $trace['areaName'] ?? '';
|
||
$model->area_center = $trace['areaCenter'] ?? '';
|
||
$model->extra_data = json_encode($trace, JSON_UNESCAPED_UNICODE);
|
||
$model->create_time = time();
|
||
$model->save();
|
||
}
|
||
}
|
||
|
||
/**
|
||
* 记录状态变更
|
||
*/
|
||
private static function logStateChange(ExpressTracking $tracking, string $oldState): void
|
||
{
|
||
$log = new ExpressStateLog();
|
||
$log->tracking_id = $tracking->id;
|
||
$log->tracking_number = $tracking->tracking_number;
|
||
$log->old_state = $oldState;
|
||
$log->old_state_text = ExpressTracking::getStateText($oldState);
|
||
$log->new_state = $tracking->current_state;
|
||
$log->new_state_text = $tracking->current_state_text;
|
||
$log->change_time = time();
|
||
$log->change_reason = $tracking->latest_trace_context;
|
||
$log->create_time = time();
|
||
$log->save();
|
||
|
||
// 检查是否需要通知
|
||
$shouldNotify = false;
|
||
|
||
// 签收通知
|
||
if ($tracking->notify_on_sign == 1 && ExpressTracking::isFinalState($tracking->current_state)) {
|
||
$shouldNotify = true;
|
||
}
|
||
|
||
// 异常通知
|
||
if ($tracking->notify_on_problem == 1 && ExpressTracking::isProblemState($tracking->current_state)) {
|
||
$shouldNotify = true;
|
||
}
|
||
|
||
if ($shouldNotify) {
|
||
self::sendNotification($tracking, $log);
|
||
}
|
||
}
|
||
|
||
/**
|
||
* 发送通知(可扩展对接企业微信、短信等)
|
||
*/
|
||
private static function sendNotification(ExpressTracking $tracking, ExpressStateLog $log): void
|
||
{
|
||
// TODO: 对接通知渠道
|
||
// 1. 企业微信消息
|
||
// 2. 短信通知
|
||
// 3. 系统内通知
|
||
|
||
Log::info('Express state changed notification', [
|
||
'tracking_number' => $tracking->tracking_number,
|
||
'old_state' => $log->old_state_text,
|
||
'new_state' => $log->new_state_text,
|
||
'context' => $log->change_reason,
|
||
]);
|
||
|
||
$log->is_notified = 1;
|
||
$log->notify_time = time();
|
||
$log->notify_result = 'logged';
|
||
$log->save();
|
||
}
|
||
|
||
/**
|
||
* 记录查询日志
|
||
*/
|
||
private static function logQuery(
|
||
string $trackingNumber,
|
||
string $expressCompany,
|
||
string $queryType,
|
||
array $result,
|
||
int $responseTime
|
||
): void {
|
||
$log = new ExpressQueryLog();
|
||
$log->tracking_number = $trackingNumber;
|
||
$log->express_company = $expressCompany;
|
||
$log->query_type = $queryType;
|
||
$log->query_source = $result['source'] ?? '';
|
||
$log->query_time = time();
|
||
$log->is_success = !empty($result['traces']) ? 1 : 0;
|
||
$log->error_code = $result['hint'] ? '400' : '';
|
||
$log->error_message = $result['hint'] ?? '';
|
||
$log->response_time = $responseTime;
|
||
$log->trace_count = count($result['traces'] ?? []);
|
||
$log->create_time = time();
|
||
$log->save();
|
||
}
|
||
|
||
/**
|
||
* 批量自动更新(定时任务调用)
|
||
*
|
||
* @param int $limit 每次处理数量
|
||
* @return array
|
||
*/
|
||
public static function autoUpdateBatch(int $limit = 50): array
|
||
{
|
||
$now = time();
|
||
$poTable = (new PrescriptionOrder())->getTable();
|
||
|
||
// 查询需要更新的记录
|
||
// 条件:
|
||
// 1. auto_update = 1(启用自动更新)
|
||
// 2. next_update_time <= now(到达更新时间)
|
||
// 3. is_signed = 0(未签收)
|
||
// 4. current_state 不是终态(排除已签收、退签、拒签)
|
||
// 5. 关联业务订单 tcm_prescription_order:已存在甘草单号 gancao_reciperl_order_no 的不再走本任务(由甘草侧物流拉取)
|
||
// 6. 关联业务订单 fulfillment_status 不在终态白名单:3/4/6/8/9/10/11/12(已完成/已取消/已签收/暂不制药/拒收/退款/保留药方/制药缓发)
|
||
$list = ExpressTracking::alias('et')
|
||
->leftJoin("{$poTable} po", "et.order_id = po.id AND et.order_type = 'prescription'")
|
||
->where('et.auto_update', 1)
|
||
->where('et.next_update_time', '<=', $now)
|
||
->where('et.is_signed', 0)
|
||
->whereNotIn('et.current_state', [
|
||
ExpressTracking::STATE_SIGNED,
|
||
ExpressTracking::STATE_RETURN_SIGNED,
|
||
ExpressTracking::STATE_REJECTED,
|
||
])
|
||
->whereNull('et.delete_time')
|
||
->whereRaw(
|
||
"(et.order_type <> ? OR TRIM(COALESCE(po.gancao_reciperl_order_no, '')) = '')",
|
||
['prescription']
|
||
)
|
||
->whereRaw(
|
||
"(et.order_type <> ? OR po.id IS NULL OR po.fulfillment_status NOT IN (3,4,6,8,9,10,11,12))",
|
||
['prescription']
|
||
)
|
||
->field('et.*')
|
||
->limit($limit)
|
||
->select();
|
||
|
||
$success = 0;
|
||
$failed = 0;
|
||
|
||
foreach ($list as $tracking) {
|
||
try {
|
||
self::queryAndUpdate($tracking->id, true);
|
||
$success++;
|
||
} catch (\Throwable $e) {
|
||
$failed++;
|
||
Log::error('Auto update express tracking failed', [
|
||
'tracking_id' => $tracking->id,
|
||
'tracking_number' => $tracking->tracking_number,
|
||
'error' => $e->getMessage(),
|
||
]);
|
||
}
|
||
}
|
||
|
||
return [
|
||
'total' => count($list),
|
||
'success' => $success,
|
||
'failed' => $failed,
|
||
];
|
||
}
|
||
|
||
/**
|
||
* 获取追踪详情(包含轨迹)
|
||
*/
|
||
public static function getDetail(int $trackingId): ?array
|
||
{
|
||
$tracking = ExpressTracking::with(['traces', 'stateLogs'])
|
||
->where('id', $trackingId)
|
||
->whereNull('delete_time')
|
||
->find();
|
||
|
||
if (!$tracking) {
|
||
return null;
|
||
}
|
||
|
||
$data = $tracking->toArray();
|
||
$data['traces'] = $tracking->traces ? $tracking->traces->toArray() : [];
|
||
$data['state_logs'] = $tracking->stateLogs ? $tracking->stateLogs->toArray() : [];
|
||
|
||
return $data;
|
||
}
|
||
|
||
/**
|
||
* 根据快递单号获取追踪详情(包含轨迹)
|
||
*/
|
||
public static function getDetailByTrackingNumber(string $trackingNumber): ?array
|
||
{
|
||
$tracking = ExpressTracking::with(['traces' => function($query) {
|
||
// 按时间倒序排列(最新的在前)
|
||
$query->order('trace_time_stamp', 'desc');
|
||
}])
|
||
->where('tracking_number', $trackingNumber)
|
||
->whereNull('delete_time')
|
||
->find();
|
||
|
||
if (!$tracking) {
|
||
return null;
|
||
}
|
||
|
||
$data = $tracking->toArray();
|
||
$data['traces'] = $tracking->traces ? $tracking->traces->toArray() : [];
|
||
|
||
return $data;
|
||
}
|
||
}
|