Files
zyt/server/app/common/service/ExpressTrackingService.php
T
2026-05-05 18:25:14 +08:00

738 lines
27 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\Db;
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);
}
// 定时拉轨迹:履约已发货/已签收时与甘草侧共用 applyAssistantReleaseForShippedPrescriptionOrder,释放诊单医助并写指派日志
$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。
* 快递 100 定时拉轨迹、甘草路由、甘草回调与履约核对共用同一规则。
* 例外:指派日志中若存在「从某医助改派到另一医助」(原、新医助 ID 均大于 0)的记录,视为人工指派链,自动释放跳过。
*
* @param array{tracking_id?:int,tracking_number?:string,source?:string} $meta
* @return array{
* action: 'cleared',
* source?: string,
* diagnosis_id: int,
* prescription_order_id: int,
* tracking_id: int,
* tracking_number: string,
* former_assistant_id?: string,
* 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;
}
$fromAssistantId = (int) ($diag->getAttr('assistant_id') ?? 0);
if ($fromAssistantId <= 0) {
return null;
}
if (self::diagnosisHasFormalAssistantReassignInAssignLog($diagnosisId)) {
Log::info('Skipped auto assistant release (shipped/signed): formal assistant reassign in assign log', [
'diagnosis_id' => $diagnosisId,
'prescription_order_id' => $orderId,
'source' => $source,
'current_assistant_id' => $fromAssistantId,
]);
return null;
}
$operatorName = match ($source) {
'express_auto_update' => '系统·物流自动同步',
'gancao_route', 'gancao_route_sync' => '系统·甘草路由同步',
'gancao_callback' => '系统·甘草回调',
'shipped_fulfillment_reconcile' => '系统·已发货履约核对',
default => '系统·订单已发货/签收',
};
Db::startTrans();
try {
Diagnosis::where('id', $diagnosisId)->whereNull('delete_time')->update(['assistant_id' => 0]);
DiagnosisAssignLog::create([
'diagnosis_id' => $diagnosisId,
'from_assistant_id' => $fromAssistantId,
'to_assistant_id' => 0,
'operator_admin_id' => 0,
'operator_name' => $operatorName,
'operator_account' => '',
'ip' => '',
'create_time' => time(),
]);
Db::commit();
} catch (\Throwable $e) {
Db::rollback();
throw $e;
}
Log::info('Cleared diagnosis assistant (prescription shipped/signed), assign log written', [
'source' => $source,
'diagnosis_id' => $diagnosisId,
'prescription_order_id' => $orderId,
'tracking_id' => $trackingId,
'fulfillment_status' => $fs,
'former_assistant_id' => $fromAssistantId,
]);
return [
'action' => 'cleared',
'source' => $source,
'diagnosis_id' => $diagnosisId,
'prescription_order_id' => $orderId,
'tracking_id' => $trackingId,
'tracking_number' => $tn,
'former_assistant_id' => (string) $fromAssistantId,
'fulfillment_status' => $fs,
];
} catch (\Throwable $e) {
Log::warning('applyAssistantReleaseForShippedPrescriptionOrder failed: ' . $e->getMessage(), [
'source' => $source,
'order_id' => $orderId,
]);
return null;
}
}
/**
* 是否存在「由原医助改派到新医助」的指派记录(两端 admin 均大于 0)。有此记录则已发货自动释放医助不执行。
*/
private static function diagnosisHasFormalAssistantReassignInAssignLog(int $diagnosisId): bool
{
if ($diagnosisId <= 0) {
return false;
}
return DiagnosisAssignLog::where('diagnosis_id', $diagnosisId)
->where('from_assistant_id', '>', 0)
->where('to_assistant_id', '>', 0)
->count() > 0;
}
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();
}
/**
* 按处方订单履约核对:已发货(5)/已签收(6) 时诊单仍挂在医助的,一律释放并写指派日志。
* 与快递 100 / 甘草来自哪个物流渠道无关,保障「已发货」后患者侧医助被清掉。
*
* @return array{scanned: int, cleared: int, lines: list<string>}
*/
public static function reconcileAssistantReleaseForShippedPrescriptionOrders(int $limit = 200): array
{
$dTable = (new Diagnosis())->getTable();
$orders = PrescriptionOrder::alias('po')
->join($dTable . ' d', 'd.id = po.diagnosis_id')
->whereNull('po.delete_time')
->whereNull('d.delete_time')
->whereIn('po.fulfillment_status', [5, 6])
->where('po.diagnosis_id', '>', 0)
->where('d.assistant_id', '>', 0)
->order('po.id', 'asc')
->limit($limit)
->field('po.*')
->select();
$cleared = 0;
$lines = [];
foreach ($orders as $order) {
$sync = self::applyAssistantReleaseForShippedPrescriptionOrder($order, [
'source' => 'shipped_fulfillment_reconcile',
'tracking_number' => (string) ($order->tracking_number ?? ''),
]);
if (is_array($sync) && ($sync['action'] ?? '') === 'cleared') {
$cleared++;
$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)
);
}
}
return [
'scanned' => count($orders),
'cleared' => $cleared,
'lines' => $lines,
];
}
/**
* 批量自动更新(定时任务调用)
*
* @param int $limit 每次处理数量
* @return array{
* total: int,
* success: int,
* failed: int,
* assistant_cleared: int,
* assistant_skipped_assign_log: int,
* assistant_lines: list<string>
* } assistant_skipped_assign_log 保留兼容恒为 0(已发货/签收一律写指派日志并清空医助)
*/
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;
$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)
);
}
}
} 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' => 0,
'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;
}
}