Files
anxinyan/server-api/app/support/OrderLogisticsSyncService.php
2026-05-26 17:08:33 +08:00

558 lines
19 KiB
PHP

<?php
namespace app\support;
use support\Log;
use support\think\Db;
class OrderLogisticsSyncService
{
private const PROVIDER = 'kuaidi100';
public function __construct(
private ?Kuaidi100ConfigService $configService = null,
private ?Kuaidi100Client $client = null
) {
$this->configService ??= new Kuaidi100ConfigService();
$this->client ??= new Kuaidi100Client($this->configService);
$this->ensureTable();
}
public function subscribeAsync(int $logisticsId): void
{
try {
$this->subscribe($logisticsId);
} catch (\Throwable $e) {
Log::warning('kuaidi100 subscribe skipped', [
'logistics_id' => $logisticsId,
'message' => $e->getMessage(),
]);
}
}
public function subscribe(int $logisticsId): array
{
$logistics = $this->findLogistics($logisticsId);
if (!$logistics) {
throw new \RuntimeException('物流记录不存在');
}
$sync = $this->ensureSyncRow($logistics);
if (!$this->configService->getConfig()['enabled']) {
$this->updateSync((int)$sync['id'], [
'subscription_status' => 'disabled',
'last_error' => '',
]);
return $this->formatSyncStatus((int)$logistics['id']);
}
$companyCode = trim((string)($sync['provider_com'] ?? ''));
try {
$this->updateSync((int)$sync['id'], [
'subscription_status' => 'subscribing',
'last_error' => '',
]);
$response = $this->client->subscribe(
$companyCode,
(string)$logistics['tracking_no'],
$this->resolvePhoneForLogistics($logistics)
);
$status = $this->subscriptionSucceeded($response) ? 'subscribed' : 'failed';
$this->updateSync((int)$sync['id'], [
'subscription_status' => $status,
'raw_status' => (string)($response['result'] ?? $response['returnCode'] ?? ''),
'last_error' => $status === 'subscribed' ? '' : $this->errorMessageFromResponse($response),
'raw_summary' => $this->encodeRawSummary($response),
]);
} catch (\Throwable $e) {
$this->updateSync((int)$sync['id'], [
'subscription_status' => 'failed',
'last_error' => $e->getMessage(),
]);
throw $e;
}
return $this->formatSyncStatus((int)$logistics['id']);
}
public function syncByLogisticsId(int $logisticsId, bool $force = false): array
{
$logistics = $this->findLogistics($logisticsId);
if (!$logistics) {
throw new \RuntimeException('物流记录不存在');
}
$sync = $this->ensureSyncRow($logistics);
if (!$this->configService->isReadyForQuery()) {
$this->markError((int)$sync['id'], '快递100实时查询配置未完成', ['last_query_at' => date('Y-m-d H:i:s')]);
return $this->formatSyncStatus((int)$logistics['id']);
}
if (!$force && !$this->shouldQuery($sync)) {
return $this->formatSyncStatus((int)$logistics['id']);
}
$companyCode = trim((string)($sync['provider_com'] ?? ''));
if ($companyCode === '') {
$companyCode = $this->resolveCompanyCode($logistics);
if ($companyCode !== '') {
$this->updateSync((int)$sync['id'], [
'provider_com' => $companyCode,
]);
} else {
$this->updateSync((int)$sync['id'], [
'last_query_at' => date('Y-m-d H:i:s'),
'last_error' => '',
]);
return $this->formatSyncStatus((int)$logistics['id']);
}
}
try {
$response = $this->client->query(
$companyCode,
(string)$logistics['tracking_no'],
$this->resolvePhoneForLogistics($logistics)
);
$this->applyTrackPayload($logistics, $response, 'query');
} catch (\Throwable $e) {
$this->markError((int)$sync['id'], $e->getMessage(), ['last_query_at' => date('Y-m-d H:i:s')]);
throw $e;
}
return $this->formatSyncStatus((int)$logistics['id']);
}
public function syncDue(int $limit = 50): int
{
if (!$this->configService->isReadyForQuery()) {
return 0;
}
$config = $this->configService->getConfig();
$before = date('Y-m-d H:i:s', time() - $config['query_min_interval_minutes'] * 60);
$rows = Db::name('order_logistics')
->alias('l')
->leftJoin('order_logistics_syncs s', 's.logistics_id = l.id AND s.provider = "' . self::PROVIDER . '"')
->field(['l.id'])
->where('l.tracking_no', '<>', '')
->where('l.tracking_status', '<>', 'received')
->whereRaw('(s.last_query_at IS NULL OR s.last_query_at <= :before)', ['before' => $before])
->order('l.id', 'desc')
->limit($limit)
->select()
->toArray();
$count = 0;
foreach ($rows as $row) {
try {
$this->syncByLogisticsId((int)$row['id'], true);
$count++;
} catch (\Throwable $e) {
Log::warning('kuaidi100 due sync failed', [
'logistics_id' => (int)$row['id'],
'message' => $e->getMessage(),
]);
}
}
return $count;
}
public function handleCallback(array $payload): array
{
$result = $this->extractTrackResult($payload);
$trackingNo = trim((string)($result['nu'] ?? $result['number'] ?? ''));
if ($trackingNo === '') {
throw new \InvalidArgumentException('快递100回调缺少运单号');
}
$rows = Db::name('order_logistics')
->where('tracking_no', $trackingNo)
->order('id', 'desc')
->select()
->toArray();
if (!$rows) {
throw new \RuntimeException('未找到对应物流记录');
}
$updatedIds = [];
foreach ($rows as $logistics) {
$this->ensureSyncRow($logistics, trim((string)($result['com'] ?? '')));
$this->applyTrackPayload($logistics, $result, 'push');
$updatedIds[] = (int)$logistics['id'];
}
return [
'tracking_no' => $trackingNo,
'updated_ids' => $updatedIds,
];
}
public function formatSyncStatus(int $logisticsId): array
{
$sync = Db::name('order_logistics_syncs')
->where('logistics_id', $logisticsId)
->where('provider', self::PROVIDER)
->find();
if (!$sync) {
return [
'provider_status_text' => '',
'sync_status_text' => '未同步',
'sync_error' => '',
];
}
$error = trim((string)($sync['last_error'] ?? ''));
$providerCom = trim((string)($sync['provider_com'] ?? ''));
$subscriptionStatus = (string)($sync['subscription_status'] ?? '');
if ($error !== '') {
$syncStatusText = '同步异常';
} elseif ($subscriptionStatus === 'subscribing') {
$syncStatusText = '同步中';
} elseif ($subscriptionStatus === 'subscribed') {
$syncStatusText = '已订阅';
} elseif ($subscriptionStatus === 'disabled') {
$syncStatusText = '未启用';
} elseif ($providerCom === '') {
$syncStatusText = '未识别';
} elseif (!empty($sync['last_push_at']) || !empty($sync['last_query_at'])) {
$syncStatusText = '已同步';
} else {
$syncStatusText = '待同步';
}
return [
'provider_status_text' => (string)($sync['provider_status_text'] ?? ''),
'sync_status_text' => $syncStatusText,
'sync_error' => $error,
];
}
public function nodesForLogistics(int $logisticsId): array
{
if ($logisticsId <= 0) {
return [];
}
return Db::name('order_logistics_nodes')
->where('logistics_id', $logisticsId)
->order('node_time', 'desc')
->select()
->toArray();
}
private function applyTrackPayload(array $logistics, array $result, string $source): void
{
$logisticsId = (int)$logistics['id'];
$sync = $this->ensureSyncRow($logistics, trim((string)($result['com'] ?? '')));
$nodes = $this->normalizeNodes((array)($result['data'] ?? []));
$now = date('Y-m-d H:i:s');
Db::startTrans();
try {
foreach ($nodes as $node) {
$exists = Db::name('order_logistics_nodes')
->where('logistics_id', $logisticsId)
->where('node_time', $node['node_time'])
->where('node_desc', $node['node_desc'])
->where('node_location', $node['node_location'])
->find();
if ($exists) {
continue;
}
Db::name('order_logistics_nodes')->insert([
'logistics_id' => $logisticsId,
'node_time' => $node['node_time'],
'node_desc' => $node['node_desc'],
'node_location' => $node['node_location'],
'created_at' => $now,
]);
}
$latest = $nodes[0] ?? null;
if ($latest) {
Db::name('order_logistics')->where('id', $logisticsId)->update([
'latest_desc' => $latest['node_desc'],
'latest_time' => $latest['node_time'],
'updated_at' => $now,
]);
}
$syncPayload = [
'provider_com' => trim((string)($result['com'] ?? $sync['provider_com'] ?? '')),
'provider_state' => (string)($result['state'] ?? ''),
'provider_status_text' => $this->providerStateText((string)($result['state'] ?? ''), (string)($result['ischeck'] ?? '')),
'raw_status' => (string)($result['status'] ?? ''),
'last_error' => '',
'raw_summary' => $this->encodeRawSummary($result),
];
if ($source === 'push') {
$syncPayload['last_push_at'] = $now;
} else {
$syncPayload['last_query_at'] = $now;
}
$this->updateSync((int)$sync['id'], $syncPayload);
Db::commit();
} catch (\Throwable $e) {
Db::rollback();
throw $e;
}
}
private function normalizeNodes(array $items): array
{
$nodes = [];
foreach ($items as $item) {
if (!is_array($item)) {
continue;
}
$time = trim((string)($item['time'] ?? $item['ftime'] ?? ''));
$desc = trim((string)($item['context'] ?? $item['status'] ?? ''));
if ($time === '' || $desc === '') {
continue;
}
$nodes[] = [
'node_time' => $time,
'node_desc' => $desc,
'node_location' => trim((string)($item['areaName'] ?? $item['areaCenter'] ?? $item['location'] ?? '')),
];
}
usort($nodes, static fn (array $left, array $right) => strcmp($right['node_time'], $left['node_time']));
return $nodes;
}
private function extractTrackResult(array $payload): array
{
if (isset($payload['lastResult']) && is_array($payload['lastResult'])) {
return $payload['lastResult'];
}
if (isset($payload['data']) && is_array($payload['data']) && isset($payload['data']['lastResult']) && is_array($payload['data']['lastResult'])) {
return $payload['data']['lastResult'];
}
return $payload;
}
private function ensureSyncRow(array $logistics, string $providerCom = ''): array
{
$logisticsId = (int)$logistics['id'];
$providerCom = $providerCom !== '' ? $providerCom : $this->resolveCompanyCode($logistics);
$row = Db::name('order_logistics_syncs')
->where('logistics_id', $logisticsId)
->where('provider', self::PROVIDER)
->find();
$now = date('Y-m-d H:i:s');
if ($row) {
if ($providerCom !== '' && (string)($row['provider_com'] ?? '') === '') {
Db::name('order_logistics_syncs')->where('id', (int)$row['id'])->update([
'provider_com' => $providerCom,
'updated_at' => $now,
]);
$row['provider_com'] = $providerCom;
}
return $row;
}
$id = (int)Db::name('order_logistics_syncs')->insertGetId([
'logistics_id' => $logisticsId,
'provider' => self::PROVIDER,
'provider_com' => $providerCom,
'subscription_status' => '',
'provider_state' => '',
'provider_status_text' => '',
'last_error' => '',
'raw_status' => '',
'raw_summary' => null,
'created_at' => $now,
'updated_at' => $now,
]);
return Db::name('order_logistics_syncs')->where('id', $id)->find() ?: [];
}
private function resolveCompanyCode(array $logistics): string
{
$company = trim((string)($logistics['express_company'] ?? ''));
if ($company === '') {
return '';
}
$trackingNo = trim((string)($logistics['tracking_no'] ?? ''));
$resolved = (new ExpressCompanyService())->resolveCompanyCode($company, $trackingNo);
if ($resolved !== '') {
return $resolved;
}
$row = Db::name('express_companies')->where('company_name', $company)->find();
$code = trim((string)($row['company_code'] ?? ''));
$aliasCode = $this->companyAliasCode($company, $code);
if ($aliasCode !== '') {
return $aliasCode;
}
return preg_match('/^[a-z0-9]+$/', $code) === 1 && !str_starts_with($code, 'express_') ? $code : '';
}
private function companyAliasCode(string $companyName, string $companyCode = ''): string
{
$aliases = [
'顺丰速运' => 'shunfeng',
'顺丰' => 'shunfeng',
'sf_express' => 'shunfeng',
'京东快递' => 'jd',
'京东物流' => 'jd',
'EMS' => 'ems',
'ems' => 'ems',
'中通快递' => 'zhongtong',
'圆通速递' => 'yuantong',
'圆通快递' => 'yuantong',
'申通快递' => 'shentong',
'韵达快递' => 'yunda',
'极兔速递' => 'jtexpress',
'极兔快递' => 'jtexpress',
'德邦快递' => 'debangwuliu',
'邮政快递包裹' => 'youzhengguonei',
];
return $aliases[$companyCode] ?? $aliases[$companyName] ?? '';
}
private function resolvePhoneForLogistics(array $logistics): string
{
$orderId = (int)($logistics['order_id'] ?? 0);
if ($orderId <= 0) {
return '';
}
if (($logistics['logistics_type'] ?? '') === 'return_to_user') {
$mobile = Db::name('order_return_addresses')->where('order_id', $orderId)->value('mobile');
return trim((string)$mobile);
}
$order = Db::name('orders')->where('id', $orderId)->find();
if (!$order) {
return '';
}
$mobile = Db::name('users')->where('id', (int)$order['user_id'])->value('mobile');
return trim((string)$mobile);
}
private function shouldQuery(array $sync): bool
{
$lastQueryAt = trim((string)($sync['last_query_at'] ?? ''));
if ($lastQueryAt === '') {
return true;
}
$config = $this->configService->getConfig();
return strtotime($lastQueryAt) <= time() - $config['query_min_interval_minutes'] * 60;
}
private function providerStateText(string $state, string $isCheck): string
{
if ($isCheck === '1') {
return '已签收';
}
return match ($state) {
'0' => '在途',
'1' => '已揽收',
'2' => '疑难',
'3' => '已签收',
'4' => '退签',
'5' => '派件中',
'6' => '退回中',
'7' => '转投',
'8' => '清关中',
'14' => '拒签',
default => $state === '' ? '' : '物流状态 ' . $state,
};
}
private function subscriptionSucceeded(array $response): bool
{
$result = $response['result'] ?? null;
$returnCode = (string)($response['returnCode'] ?? $response['status'] ?? '');
return $result === true || $result === 'true' || $returnCode === '200';
}
private function errorMessageFromResponse(array $response): string
{
return trim((string)($response['message'] ?? $response['returnMessage'] ?? '快递100返回失败'));
}
private function markError(int $syncId, string $message, array $extra = []): void
{
$this->updateSync($syncId, array_merge($extra, [
'last_error' => mb_substr($message, 0, 500, 'UTF-8'),
]));
}
private function updateSync(int $syncId, array $payload): void
{
if ($syncId <= 0) {
return;
}
$payload['updated_at'] = date('Y-m-d H:i:s');
if (isset($payload['last_error'])) {
$payload['last_error'] = mb_substr((string)$payload['last_error'], 0, 500, 'UTF-8');
}
Db::name('order_logistics_syncs')->where('id', $syncId)->update($payload);
}
private function findLogistics(int $logisticsId): ?array
{
if ($logisticsId <= 0) {
return null;
}
$row = Db::name('order_logistics')->where('id', $logisticsId)->find();
return $row ?: null;
}
private function encodeRawSummary(array $payload): string
{
$json = json_encode($payload, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES);
return mb_substr(is_string($json) ? $json : '', 0, 8000, 'UTF-8');
}
private function ensureTable(): void
{
Db::execute(<<<'SQL'
CREATE TABLE IF NOT EXISTS order_logistics_syncs (
id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT,
logistics_id BIGINT UNSIGNED NOT NULL,
provider VARCHAR(32) NOT NULL DEFAULT 'kuaidi100',
provider_com VARCHAR(64) NOT NULL DEFAULT '',
subscription_status VARCHAR(32) NOT NULL DEFAULT '',
provider_state VARCHAR(32) NOT NULL DEFAULT '',
provider_status_text VARCHAR(64) NOT NULL DEFAULT '',
last_query_at DATETIME NULL DEFAULT NULL,
last_push_at DATETIME NULL DEFAULT NULL,
last_error VARCHAR(500) NOT NULL DEFAULT '',
raw_status VARCHAR(32) NOT NULL DEFAULT '',
raw_summary LONGTEXT NULL,
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
PRIMARY KEY (id),
UNIQUE KEY uk_order_logistics_syncs_provider (logistics_id, provider),
KEY idx_order_logistics_syncs_provider_com (provider, provider_com),
KEY idx_order_logistics_syncs_last_query (last_query_at)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='订单物流第三方同步状态'
SQL);
}
}