337 lines
13 KiB
PHP
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)),
|
|
]);
|
|
}
|
|
}
|