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)), ]); } }