Files
2026-09-15 10:35:56 +08:00

337 lines
13 KiB
PHP

<?php
declare(strict_types=1);
namespace Esun\SeventeenTrack\Cron;
use Esun\SeventeenTrack\Model\Api\Client;
use Esun\SeventeenTrack\Model\ApiLogWriter;
use Esun\SeventeenTrack\Model\Config;
use Esun\SeventeenTrack\Model\DeliveryStatusUpdater;
use Esun\SeventeenTrack\Model\SyncFailureNotifier;
use Esun\SeventeenTrack\Model\TrackingRepository;
use Magento\Framework\Serialize\Serializer\Json;
use Magento\Sales\Api\OrderRepositoryInterface;
use Psr\Log\LoggerInterface;
class SyncTracking
{
private const TRANSIENT_RETRY_MINUTES = 30;
private const QUOTA_RETRY_MINUTES = 360;
public function __construct(
private readonly Config $config,
private readonly Client $client,
private readonly ApiLogWriter $apiLog,
private readonly TrackingRepository $tracking,
private readonly SyncFailureNotifier $notifier,
private readonly DeliveryStatusUpdater $deliveryStatusUpdater,
private readonly OrderRepositoryInterface $orders,
private readonly Json $json,
private readonly LoggerInterface $logger
) {
}
public function execute(): void
{
if (!$this->config->isEnabled() || $this->config->getApiKey() === '') {
return;
}
try {
$this->tracking->backfill();
$failures = array_merge($this->registerPending(), $this->refreshTracking());
} catch (\Throwable $exception) {
$this->logger->error('17TRACK synchronization failed.', ['exception' => $exception]);
$this->notifySafely($this->getApiErrorCode($exception), $exception->getMessage());
throw $exception;
}
if ($failures !== []) {
foreach ($failures as $failure) {
$this->notifySafely($failure['code'], $failure['message']);
}
$message = sprintf(
'17TRACK synchronization completed with %d failed tracking number(s): %s',
count($failures),
implode(', ', array_unique(array_column($failures, 'number')))
);
$this->logger->error($message, ['failures' => $failures]);
throw new \RuntimeException($message);
}
}
private function registerPending(): array
{
$rows = $this->tracking->getPending($this->config->getBatchSize());
if (!$rows) {
return [];
}
$failures = [];
$payload = array_map(fn(array $row): array => $this->buildRegistrationPayload($row), $rows);
$payloadByNumber = [];
foreach ($payload as $item) {
$payloadByNumber[(string)$item['number']] = $item;
}
$batchId = bin2hex(random_bytes(8));
$startedAt = microtime(true);
try {
$response = $this->client->register($payload);
} catch (\Throwable $exception) {
$code = $this->getApiErrorCode($exception);
foreach ($rows as $row) {
$this->tracking->markError(
(string)$row['tracking_number'],
$exception->getMessage(),
$code,
$this->retryAt($this->getRetryMinutes($code))
);
$number = (string)$row['tracking_number'];
$this->writeRegistrationLog(
$row,
$payloadByNumber[$number] ?? ['number' => $number],
$this->client->getLastBody(),
'error',
$batchId,
$startedAt,
$exception->getMessage(),
$code
);
}
throw $exception;
}
$handled = [];
foreach ((array)($response['data']['accepted'] ?? []) as $accepted) {
$number = trim((string)($accepted['number'] ?? ''));
if ($number === '') {
continue;
}
$this->tracking->markRegistered($number, isset($accepted['carrier']) ? (int)$accepted['carrier'] : null);
$handled[$number] = true;
$row = $this->findRow($rows, $number);
$this->writeRegistrationLog(
$row,
$payloadByNumber[$number] ?? ['number' => $number],
$accepted,
'success',
$batchId,
$startedAt,
'Tracking number registered.'
);
}
foreach ((array)($response['data']['rejected'] ?? []) as $rejected) {
$message = (string)($rejected['error']['message'] ?? 'Tracking number registration was rejected.');
$code = (int)($rejected['error']['code'] ?? 0);
$number = trim((string)($rejected['number'] ?? ''));
if ($number === '') {
continue;
}
$handled[$number] = true;
$status = 'error';
if ($code === -18019901) {
// Existing registrations can be queried normally.
$carrier = isset($rejected['carrier']) ? (int)$rejected['carrier'] : null;
$this->tracking->markRegistered($number, $carrier);
$status = 'success';
} elseif (in_array($code, [-18010003, -18019907, -18019908], true)) {
$minutes = $this->getRetryMinutes($code);
$this->tracking->markError($number, $message, $code, $this->retryAt($minutes));
$failures[] = ['number' => $number, 'code' => $code, 'message' => $message];
} else {
$this->tracking->markRejected($number, $message, $code);
$failures[] = ['number' => $number, 'code' => $code, 'message' => $message];
}
$this->writeRegistrationLog(
$this->findRow($rows, $number),
$payloadByNumber[$number] ?? ['number' => $number],
$rejected,
$status,
$batchId,
$startedAt,
$message,
$code
);
}
foreach ($rows as $row) {
$number = (string)$row['tracking_number'];
if (isset($handled[$number])) {
continue;
}
$message = '17TRACK registration response did not contain this tracking number.';
$this->tracking->markError($number, $message, null, $this->retryAt(self::TRANSIENT_RETRY_MINUTES));
$failures[] = ['number' => $number, 'code' => null, 'message' => $message];
$this->writeRegistrationLog(
$row,
$payloadByNumber[$number] ?? ['number' => $number],
$response,
'error',
$batchId,
$startedAt,
$message
);
}
return $failures;
}
private function refreshTracking(): array
{
$rows = $this->tracking->getTracking($this->config->getBatchSize());
if (!$rows) {
return [];
}
$failures = [];
$payload = [];
foreach ($rows as $row) {
$item = ['number' => (string)$row['tracking_number']];
if ((int)$row['carrier_code'] > 0) {
$item['carrier'] = (int)$row['carrier_code'];
}
$payload[] = $item;
}
try {
$response = $this->client->getTrackInfo($payload);
} catch (\Throwable $exception) {
$code = $this->getApiErrorCode($exception);
foreach ($rows as $row) {
$this->tracking->markQueryError(
(string)$row['tracking_number'],
$exception->getMessage(),
$code,
$this->retryAt($this->getRetryMinutes($code))
);
}
throw $exception;
}
$orderIds = [];
foreach ((array)($response['data']['accepted'] ?? []) as $info) {
if (empty($info['number'])) {
continue;
}
$ids = $this->tracking->saveTrackingInfo(
(string)$info['number'],
$info,
$this->json->serialize($info)
);
$orderIds = array_merge($orderIds, $ids);
}
foreach ((array)($response['data']['rejected'] ?? []) as $rejected) {
$number = (string)($rejected['number'] ?? '');
$message = (string)($rejected['error']['message'] ?? 'Tracking query was rejected.');
$code = (int)($rejected['error']['code'] ?? 0);
if ($code === -18019902) {
$this->tracking->markPending(
$number,
$message,
$code,
$this->retryAt(self::TRANSIENT_RETRY_MINUTES)
);
$failures[] = ['number' => $number, 'code' => $code, 'message' => $message];
} else {
$minutes = $this->getRetryMinutes($code);
$this->tracking->markQueryError($number, $message, $code, $this->retryAt($minutes));
if ($code !== -18019909) {
$failures[] = ['number' => $number, 'code' => $code, 'message' => $message];
}
}
}
$this->deliveryStatusUpdater->execute($orderIds);
return $failures;
}
private function buildRegistrationPayload(array $row): array
{
$payload = ['number' => (string)$row['tracking_number'], 'tag' => (string)$row['order_id']];
try {
$order = $this->orders->get((int)$row['order_id']);
$payload['tag'] = (string)$order->getIncrementId();
$payload['order_no'] = (string)$order->getIncrementId();
$payload['order_time'] = (string)$order->getCreatedAt();
$address = $order->getShippingAddress();
if ($address) {
$optional = [
'destination_country' => strtoupper((string)$address->getCountryId()),
'destination_postal_code' => (string)$address->getPostcode(),
'destination_city' => (string)$address->getCity(),
];
$phone = preg_replace('/\D+/', '', (string)$address->getTelephone()) ?: '';
if ($phone !== '') {
$optional['phone_number_last_4'] = substr($phone, -4);
}
foreach ($optional as $key => $value) {
if ($value !== '') {
$payload[$key] = $value;
}
}
}
} catch (\Throwable $exception) {
$this->logger->warning('Could not add order metadata to a 17TRACK registration.', [
'order_id' => (int)$row['order_id'],
'exception' => $exception,
]);
}
return $payload;
}
private function retryAt(int $minutes): string
{
return gmdate('Y-m-d H:i:s', time() + ($minutes * 60));
}
private function getApiErrorCode(\Throwable $exception): ?int
{
$code = (int)$exception->getCode();
return $code !== 0 ? $code : null;
}
private function getRetryMinutes(?int $code): int
{
return in_array($code, [-18019907, -18019908], true)
? self::QUOTA_RETRY_MINUTES
: self::TRANSIENT_RETRY_MINUTES;
}
private function notifySafely(?int $code, string $message): void
{
try {
$this->notifier->notify($code, $message);
} catch (\Throwable $exception) {
$this->logger->warning('Unable to create a 17TRACK admin notification.', ['exception' => $exception]);
}
}
private function findRow(array $rows, string $number): array
{
foreach ($rows as $row) {
if ((string)($row['tracking_number'] ?? '') === $number) {
return $row;
}
}
return ['tracking_number' => $number];
}
private function writeRegistrationLog(
array $row,
array $request,
mixed $response,
string $status,
string $batchId,
float $startedAt,
string $message,
?int $errorCode = null
): void {
$this->apiLog->write([
'direction' => 'outbound',
'operation' => 'register',
'api_path' => $this->apiLog->getRegistrationPath(),
'batch_id' => $batchId,
'order_id' => isset($row['order_id']) ? (int)$row['order_id'] : null,
'order_number' => isset($request['order_no']) ? (string)$request['order_no'] : null,
'tracking_number' => isset($request['number']) ? (string)$request['number'] : null,
'status' => $status,
'http_status' => $this->client->getLastStatus(),
'error_code' => $errorCode,
'message' => $message,
'request_data' => $request,
'response_data' => $response,
'duration_ms' => max(0, (int)round((microtime(true) - $startedAt) * 1000)),
]);
}
}