Files
zyt/server/app/common/service/ExpressTrackingService.php
T
2026-04-30 17:36:47 +08:00

680 lines
24 KiB
PHP
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;
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\Diagnosis;
use app\common\model\tcm\DiagnosisAssignLog;
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<string, mixed>|null 快递100 查询结果;自动任务时可能含 _assistant_sync(医助移除/跳过)
*/
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);
}
// 定时拉轨迹:业务订单已发货时释放诊单医助(有正式指派记录则跳过,与结案逻辑一致)
$assistantSync = null;
if ($isAuto) {
$assistantSync = self::maybeClearDiagnosisAssistantWhenShippedPrescription($tracking);
}
return $assistantSync !== null ? array_merge($result, ['_assistant_sync' => $assistantSync]) : $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,
]);
}
}
/**
* 业务订单已发货(5)/已签收(6)时清空关联诊单医助;存在 tcm_diagnosis_assign_log 则跳过。
* 供 express:auto-update、gancao:sync-logistics、甘草回调等共用。
*
* @param array{tracking_id?:int,tracking_number?:string,source?:string} $meta
* @return array{
* action: 'cleared'|'skipped_assign_log',
* source?: string,
* diagnosis_id: int,
* prescription_order_id: int,
* tracking_id: int,
* tracking_number: string,
* former_assistant_id?: string,
* assistant_id?: string,
* assign_log_count?: int,
* fulfillment_status?: int
* }|null
*/
public static function applyAssistantReleaseForShippedPrescriptionOrder(PrescriptionOrder $order, array $meta = []): ?array
{
$orderId = (int) $order->id;
if ($orderId <= 0) {
return null;
}
$source = (string) ($meta['source'] ?? '');
$trackingId = (int) ($meta['tracking_id'] ?? 0);
$tn = (string) ($meta['tracking_number'] ?? $order->tracking_number ?? '');
try {
$fs = (int) $order->fulfillment_status;
if (!in_array($fs, [5, 6], true)) {
return null;
}
$diagnosisId = (int) ($order->diagnosis_id ?? 0);
if ($diagnosisId <= 0) {
return null;
}
$diag = Diagnosis::where('id', $diagnosisId)->whereNull('delete_time')->find();
if (!$diag) {
return null;
}
$oldAssistant = (string) ($diag->getAttr('assistant_id') ?? '');
if ($oldAssistant === '' || $oldAssistant === '0') {
return null;
}
$assignLogCount = (int) DiagnosisAssignLog::where('diagnosis_id', $diagnosisId)
->whereNull('delete_time')
->count();
if ($assignLogCount > 0) {
return [
'action' => 'skipped_assign_log',
'source' => $source,
'diagnosis_id' => $diagnosisId,
'prescription_order_id' => $orderId,
'tracking_id' => $trackingId,
'tracking_number' => $tn,
'assistant_id' => $oldAssistant,
'assign_log_count' => $assignLogCount,
'fulfillment_status' => $fs,
];
}
Diagnosis::where('id', $diagnosisId)->whereNull('delete_time')->update(['assistant_id' => '']);
Log::info('Cleared diagnosis assistant (shipped/signed prescription order)', [
'source' => $source,
'diagnosis_id' => $diagnosisId,
'prescription_order_id' => $orderId,
'tracking_id' => $trackingId,
'fulfillment_status' => $fs,
'former_assistant_id' => $oldAssistant,
]);
return [
'action' => 'cleared',
'source' => $source,
'diagnosis_id' => $diagnosisId,
'prescription_order_id' => $orderId,
'tracking_id' => $trackingId,
'tracking_number' => $tn,
'former_assistant_id' => $oldAssistant,
'fulfillment_status' => $fs,
];
} catch (\Throwable $e) {
Log::warning('applyAssistantReleaseForShippedPrescriptionOrder failed: ' . $e->getMessage(), [
'source' => $source,
'order_id' => $orderId,
]);
return null;
}
}
/**
* @return array{
* action: 'cleared'|'skipped_assign_log',
* source?: string,
* diagnosis_id: int,
* prescription_order_id: int,
* tracking_id: int,
* tracking_number: string,
* former_assistant_id?: string,
* assistant_id?: string,
* assign_log_count?: int,
* fulfillment_status?: int
* }|null
*/
private static function maybeClearDiagnosisAssistantWhenShippedPrescription(ExpressTracking $tracking): ?array
{
if ((string) $tracking->order_type !== 'prescription') {
return null;
}
$orderId = (int) $tracking->order_id;
if ($orderId <= 0) {
return null;
}
try {
$order = PrescriptionOrder::where('id', $orderId)->whereNull('delete_time')->find();
if (!$order) {
return null;
}
return self::applyAssistantReleaseForShippedPrescriptionOrder($order, [
'tracking_id' => (int) $tracking->id,
'tracking_number' => (string) $tracking->tracking_number,
'source' => 'express_auto_update',
]);
} catch (\Throwable $e) {
Log::warning('maybeClearDiagnosisAssistantWhenShippedPrescription failed: ' . $e->getMessage(), [
'tracking_id' => (int) $tracking->id,
'order_id' => $orderId,
]);
return null;
}
}
/**
* 物流表未存收件人手机时,从关联业务订单回填(顺丰/京东等查件必填后四位)
*/
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{
* total: int,
* success: int,
* failed: int,
* assistant_cleared: int,
* assistant_skipped_assign_log: int,
* assistant_lines: list<string>
* }
*/
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;
$assistantCleared = 0;
$assistantSkippedAssign = 0;
$assistantLines = [];
foreach ($list as $tracking) {
try {
$ret = self::queryAndUpdate($tracking->id, true);
$success++;
if (is_array($ret) && isset($ret['_assistant_sync']) && is_array($ret['_assistant_sync'])) {
$sync = $ret['_assistant_sync'];
$act = (string) ($sync['action'] ?? '');
if ($act === 'cleared') {
$assistantCleared++;
$assistantLines[] = 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)
);
} elseif ($act === 'skipped_assign_log') {
$assistantSkippedAssign++;
$assistantLines[] = sprintf(
'[保留医助] 诊单=%d 业务订单=%d 运单=%s 医助ID=%s 指派记录=%d条 履约状态=%d',
(int) ($sync['diagnosis_id'] ?? 0),
(int) ($sync['prescription_order_id'] ?? 0),
(string) ($sync['tracking_number'] ?? ''),
(string) ($sync['assistant_id'] ?? ''),
(int) ($sync['assign_log_count'] ?? 0),
(int) ($sync['fulfillment_status'] ?? 0)
);
}
}
} 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,
'assistant_cleared' => $assistantCleared,
'assistant_skipped_assign_log' => $assistantSkippedAssign,
'assistant_lines' => $assistantLines,
];
}
/**
* 获取追踪详情(包含轨迹)
*/
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;
}
}