apiKey = config('courier.api_key'); $this->baseUrl = config('courier.api_base_url', 'https://api.shiplogic.com'); } /** * Create shipment with full validation, rates fetching, and document retrieval * * @param Order $order * @return array{waybill_id: string, tracking_number: string, sticker_path: ?string, waybill_path: ?string} * @throws \Exception */ public function createShipmentForOrder(Order $order): array { Log::info('Creating shipment for order', ['order_uuid' => $order->uuid]); // Guard 1: Validate packing if (! $this->validatePacking($order)) { throw new \Exception('Order not yet packed with valid dimensions'); } Log::info('Packing validation passed', ['order_uuid' => $order->uuid]); // Guard 2: Validate payment/approval if (! $this->validatePayment($order)) { throw new \Exception( $order->is_custom_order ? 'Custom order requires proof approved and balance paid' : 'Standard order must be fully paid' ); } Log::info('Payment validation passed', ['order_uuid' => $order->uuid]); // Guard 3: Validate order status if ($order->status !== 'ready_to_ship') { throw new \Exception('Order must be in Ready to Ship state'); } Log::info('Order status validation passed', ['order_uuid' => $order->uuid]); // Guard 4: Prevent duplicates if ($order->courier_waybill_id) { throw new \Exception('Shipment already exists for this order'); } Log::info('Duplicate shipment validation passed', ['order_uuid' => $order->uuid]); try { // Step 1: Get rates and select ECO (cheapest) service level Log::info('Fetching shipping rates', ['order_uuid' => $order->uuid]); $rates = $this->getRates($order); if (empty($rates)) { throw new \Exception('No shipping rates available for this route'); } Log::info('Shipping rates retrieved', [ 'order_uuid' => $order->uuid, 'rate_count' => count($rates), ]); // Select ECO rate (should be cheapest) $selectedRate = $this->selectEcoRate($rates); if (!$selectedRate) { throw new \Exception('ECO service level not available for this route'); } Log::info('Selected ECO service level', [ 'order_uuid' => $order->uuid, 'service_level' => $selectedRate['service_level']['code'], 'rate' => $selectedRate['rate'], ]); // Step 2: Build delivery address from order fields $deliveryAddress = $this->buildDeliveryAddress($order); Log::info('Built delivery address', [ 'order_uuid' => $order->uuid, 'delivery_address' => $deliveryAddress, ]); // Step 3: Determine collection and delivery minimum dates [$collectionMinDate, $deliveryMinDate] = $this->getMinimumDates(); Log::info('Determined minimum dates', [ 'order_uuid' => $order->uuid, 'collection_min_date' => $collectionMinDate, 'delivery_min_date' => $deliveryMinDate, ]); // Step 4: Call API to create shipment with all proper data $shipmentData = $this->callCreateShipmentApi( $order, $deliveryAddress, $selectedRate, $collectionMinDate, $deliveryMinDate, ); Log::info('Shipment created via API', [ 'order_uuid' => $order->uuid, 'shipment_id' => $shipmentData['shipment_id'], 'waybill_id' => $shipmentData['waybill_id'], 'tracking_number' => $shipmentData['tracking_number'], ]); // Step 5: Save shipment details to database $order->update([ 'courier_shipment_id' => $shipmentData['shipment_id'], 'courier_waybill_id' => $shipmentData['waybill_id'], 'courier_tracking_number' => $shipmentData['tracking_number'], 'courier_rate' => $selectedRate['rate'], 'courier_service_level_code' => $selectedRate['service_level']['code'], 'courier_service_level_id' => $selectedRate['service_level']['id'], 'courier_collection_min_date' => $collectionMinDate, 'courier_delivery_min_date' => $deliveryMinDate, 'courier_status' => 'awaiting_collection', 'status' => 'awaiting_collection', ]); Log::info('Shipment created successfully', [ 'order_uuid' => $order->uuid, 'shipment_id' => $shipmentData['shipment_id'], 'waybill_id' => $shipmentData['waybill_id'], 'service_level' => $selectedRate['service_level']['code'], 'rate' => $selectedRate['rate'], ]); // Step 6: Fetch shipping documents $stickerPath = $this->fetchAndStoreSticker($shipmentData['shipment_id'], $order->uuid); $waybillPath = $this->fetchAndStoreWaybill($shipmentData['shipment_id'], $order->uuid); return [ 'shipment_id' => $shipmentData['shipment_id'], 'waybill_id' => $shipmentData['waybill_id'], 'tracking_number' => $shipmentData['tracking_number'], 'sticker_path' => $stickerPath, 'waybill_path' => $waybillPath, ]; } catch (\Exception $e) { Log::error('Failed to create shipment', [ 'order_uuid' => $order->uuid, 'error' => $e->getMessage(), ]); throw $e; } } /** * Get available shipping rates for an order */ private function getRates(Order $order): array { if (! $this->isConfigured()) { throw new \Exception('Courier API not configured'); } try { $deliveryAddress = $this->buildDeliveryAddress($order); Log::info('Built delivery address for rates', [ 'order_uuid' => $order->uuid, 'delivery_address' => $deliveryAddress, ]); $collectionAddress = config('services.shiplogic.collection_address'); Log::info('Using collection address for rates', [ 'order_uuid' => $order->uuid, 'collection_address' => $collectionAddress, ]); $payload = [ 'collection_address' => $collectionAddress, 'delivery_address' => $deliveryAddress, 'parcels' => [ [ 'submitted_length_cm' => (float) $order->packing_length, 'submitted_width_cm' => (float) $order->packing_width, 'submitted_height_cm' => (float) ($order->packing_height ?? 10), 'submitted_weight_kg' => (float) $order->packing_weight, ], ], ]; Log::info('Prepared rates request payload', [ 'order_uuid' => $order->uuid, 'payload' => $payload, ]); $ratesUrl = "{$this->baseUrl}/rates"; Log::info('Fetching shipping rates from Shiplogic', [ 'order_uuid' => $order->uuid, 'base_url' => $this->baseUrl, 'full_url' => $ratesUrl, 'api_key_set' => ! empty($this->apiKey), 'api_key_length' => strlen($this->apiKey ?? ''), 'collection_address' => $collectionAddress, 'delivery_address' => $deliveryAddress, 'parcel_dimensions' => [ 'length' => $order->packing_length, 'width' => $order->packing_width, 'height' => $order->packing_height ?? 10, 'weight' => $order->packing_weight, ], ]); $response = Http::withHeaders([ 'Authorization' => "Bearer {$this->apiKey}", ])->post($ratesUrl, $payload); Log::info('Rates API response received', [ 'order_uuid' => $order->uuid, 'status' => $response->status(), 'successful' => $response->successful(), ]); if (! $response->successful()) { $errorData = $response->json(); $errorMessage = $errorData['error']['message'] ?? $errorData['message'] ?? 'Unknown error'; Log::error('Rates API error response', [ 'order_uuid' => $order->uuid, 'status' => $response->status(), 'error_message' => $errorMessage, 'full_response' => $response->json(), ]); throw new \Exception("Failed to fetch rates: {$errorMessage}"); } $data = $response->json(); Log::info('Rates fetched successfully', [ 'order_uuid' => $order->uuid, 'rate_count' => count($data['rates'] ?? []), ]); return $data['rates'] ?? []; } catch (\Exception $e) { Log::error('Failed to get shipping rates', [ 'order_uuid' => $order->uuid, 'error' => $e->getMessage(), 'trace' => $e->getTraceAsString(), ]); throw $e; } } /** * Select the ECO (Economy) service level - should be the cheapest */ private function selectEcoRate(array $rates): ?array { foreach ($rates as $rate) { if (isset($rate['service_level']) && $rate['service_level']['code'] === 'ECO') { return $rate; } } // If ECO not found, return the cheapest rate available if (empty($rates)) { return null; } usort($rates, function ($a, $b) { return ($a['rate'] ?? PHP_INT_MAX) <=> ($b['rate'] ?? PHP_INT_MAX); }); return $rates[0] ?? null; } /** * Build complete delivery address from order shipping fields */ private function buildDeliveryAddress(Order $order): array { // Combine unit number and street address $streetAddress = $order->shipping_street_address; if ($order->shipping_unit_number) { $streetAddress = "{$order->shipping_unit_number}, {$streetAddress}"; } return [ 'type' => $order->shipping_type ?? 'residential', 'company' => $order->business_name ?? '', 'street_address' => $streetAddress, 'local_area' => $order->shipping_local_area ?? '', 'city' => $order->shipping_city ?? '', 'zone' => $order->shipping_zone ?? '', 'code' => $order->shipping_postcode ?? '', 'country' => $order->shipping_country ?? 'ZA', ]; } /** * Determine collection and delivery minimum dates * If before noon: today * If after noon: tomorrow */ private function getMinimumDates(): array { $now = Carbon::now(); $noon = Carbon::now()->setHour(12)->setMinute(0)->setSecond(0); if ($now->isBefore($noon)) { $date = $now->startOfDay(); } else { $date = $now->addDay()->startOfDay(); } return [$date, $date]; } /** * Create a shipment with Shiplogic API */ private function callCreateShipmentApi( Order $order, array $deliveryAddress, array $selectedRate, \DateTime $collectionMinDate, \DateTime $deliveryMinDate, ): array { if (! $this->isConfigured()) { throw new \Exception('Courier API not configured'); } Log::info('Preparing to create shipment via API', [ 'order_uuid' => $order->uuid, 'order_number' => $order->order_number, ]); // Validate required shipping info if (! $order->customer_name || ! $order->shipping_street_address) { throw new \Exception('Order missing required customer name or shipping address'); } Log::info('Validated required shipping info', [ 'order_uuid' => $order->uuid, 'customer_name' => $order->customer_name, 'shipping_street_address' => $order->shipping_street_address, ]); if (! $order->customer_email && ! $order->customer_phone) { throw new \Exception('Order must have at least email or phone number'); } Log::info('Validated contact information', [ 'order_uuid' => $order->uuid, 'customer_email' => $order->customer_email, 'customer_phone' => $order->customer_phone, ]); try { $collectionAddress = config('services.shiplogic.collection_address'); $collectionContact = config('services.shiplogic.collection_contact'); $payload = [ 'collection_address' => $collectionAddress, 'collection_contact' => $collectionContact, 'delivery_address' => $deliveryAddress, 'delivery_contact' => [ 'name' => $order->customer_name, 'email' => $order->customer_email ?? '', 'mobile_number' => $order->customer_phone ?? '', ], 'parcels' => [ [ 'parcel_description' => $order->order_number, 'submitted_length_cm' => (float) $order->packing_length, 'submitted_width_cm' => (float) $order->packing_width, 'submitted_height_cm' => (float) ($order->packing_height ?? 10), 'submitted_weight_kg' => (float) $order->packing_weight, ], ], 'service_level_code' => $selectedRate['service_level']['code'], 'collection_min_date' => $collectionMinDate->format(DATE_ATOM), 'delivery_min_date' => $deliveryMinDate->format(DATE_ATOM), 'customer_reference' => $order->order_number, 'mute_notifications' => false, ]; $shipmentsUrl = "{$this->baseUrl}/shipments"; Log::info('Creating Shiplogic shipment', [ 'order_uuid' => $order->uuid, 'order_number' => $order->order_number, 'customer' => $order->customer_name, 'delivery_address' => $deliveryAddress['street_address'], 'service_level' => $selectedRate['service_level']['code'], 'base_url' => $this->baseUrl, 'full_url' => $shipmentsUrl, 'api_key_set' => ! empty($this->apiKey), 'api_key_length' => strlen($this->apiKey ?? ''), 'payload' => $payload, ]); $response = Http::withHeaders([ 'Authorization' => "Bearer {$this->apiKey}", ])->post($shipmentsUrl, $payload); Log::info('Shipment API response received', [ 'order_uuid' => $order->uuid, 'status' => $response->status(), 'successful' => $response->successful(), ]); if (! $response->successful()) { $errorData = $response->json(); $errorMessage = $errorData['error']['message'] ?? $errorData['message'] ?? 'Unknown error'; Log::error('Shipment API error response', [ 'order_uuid' => $order->uuid, 'status' => $response->status(), 'error_message' => $errorMessage, 'full_response' => $response->json(), ]); throw new \Exception("Courier API error: {$errorMessage}"); } $data = $response->json(); Log::info('Shipment created in API', [ 'order_uuid' => $order->uuid, 'shipment_id' => $data['id'] ?? null, 'tracking_reference' => $data['short_tracking_reference'] ?? null, ]); return [ 'shipment_id' => $data['id'] ?? null, 'waybill_id' => $data['short_tracking_reference'] ?? $data['id'], 'tracking_number' => $data['short_tracking_reference'] ?? null, ]; } catch (\Exception $e) { Log::error('Failed to call courier shipment API', [ 'order_uuid' => $order->uuid, 'error' => $e->getMessage(), 'trace' => $e->getTraceAsString(), ]); throw $e; } } /** * Fetch and store shipment label (waybill) PDF from Shiplogic */ private function fetchAndStoreWaybill(string $shipmentId, string $orderUuid): ?string { if (! $this->isConfigured()) { return null; } try { $url = "{$this->baseUrl}/shipments/label?id={$shipmentId}"; Log::info('Fetching waybill PDF', [ 'shipment_id' => $shipmentId, 'order_uuid' => $orderUuid, 'url' => $url, ]); $response = Http::withHeaders([ 'Authorization' => "Bearer {$this->apiKey}", ])->get($url); Log::info('Waybill PDF response received', [ 'shipment_id' => $shipmentId, 'status' => $response->status(), 'successful' => $response->successful(), 'content_type' => $response->header('Content-Type'), ]); if ($response->successful()) { $data = $response->json(); // API returns a JSON with S3 URL, need to download the actual PDF if (isset($data['url'])) { Log::info('Got S3 URL for waybill PDF', [ 'shipment_id' => $shipmentId, 's3_url' => $data['url'], 'filename' => $data['filename'] ?? 'unknown', 'file_size' => $data['file_size'] ?? 'unknown', ]); // Download the actual PDF from S3 $pdfResponse = Http::get($data['url']); if ($pdfResponse->successful()) { $directory = "shipments/{$orderUuid}"; $path = "{$directory}/Shipment Label.pdf"; // Store the binary PDF content $content = $pdfResponse->body(); Storage::disk('public')->put($path, $content); Log::info('Waybill PDF stored successfully', [ 'shipment_id' => $shipmentId, 'path' => $path, 'file_size' => strlen($content), 'exists' => Storage::disk('public')->exists($path), ]); return $path; } else { Log::error('Failed to download waybill PDF from S3', [ 'shipment_id' => $shipmentId, 's3_url' => $data['url'], 'status' => $pdfResponse->status(), ]); return null; } } else { Log::error('No S3 URL in waybill response', [ 'shipment_id' => $shipmentId, 'response' => $data, ]); return null; } } Log::warning('Failed to fetch waybill from courier', [ 'shipment_id' => $shipmentId, 'status' => $response->status(), 'response' => $response->json(), ]); return null; } catch (\Exception $e) { Log::error('Exception fetching waybill', [ 'shipment_id' => $shipmentId, 'error' => $e->getMessage(), 'trace' => $e->getTraceAsString(), ]); return null; } } /** * Fetch and store shipment sticker label PDF from Shiplogic */ private function fetchAndStoreSticker(string $shipmentId, string $orderUuid): ?string { if (! $this->isConfigured()) { return null; } try { $url = "{$this->baseUrl}/shipments/label/stickers?id={$shipmentId}"; Log::info('Fetching sticker PDF', [ 'shipment_id' => $shipmentId, 'order_uuid' => $orderUuid, 'url' => $url, ]); $response = Http::withHeaders([ 'Authorization' => "Bearer {$this->apiKey}", ])->get($url); Log::info('Sticker PDF response received', [ 'shipment_id' => $shipmentId, 'status' => $response->status(), 'successful' => $response->successful(), 'content_type' => $response->header('Content-Type'), ]); if ($response->successful()) { $data = $response->json(); // API returns a JSON with S3 URL, need to download the actual PDF if (isset($data['url'])) { Log::info('Got S3 URL for sticker PDF', [ 'shipment_id' => $shipmentId, 's3_url' => $data['url'], 'filename' => $data['filename'] ?? 'unknown', 'file_size' => $data['file_size'] ?? 'unknown', ]); // Download the actual PDF from S3 $pdfResponse = Http::get($data['url']); if ($pdfResponse->successful()) { $directory = "shipments/{$orderUuid}"; $path = "{$directory}/Shipment Sticker.pdf"; // Store the binary PDF content $content = $pdfResponse->body(); Storage::disk('public')->put($path, $content); Log::info('Sticker PDF stored successfully', [ 'shipment_id' => $shipmentId, 'path' => $path, 'file_size' => strlen($content), 'exists' => Storage::disk('public')->exists($path), ]); return $path; } else { Log::error('Failed to download sticker PDF from S3', [ 'shipment_id' => $shipmentId, 's3_url' => $data['url'], 'status' => $pdfResponse->status(), ]); return null; } } else { Log::error('No S3 URL in sticker response', [ 'shipment_id' => $shipmentId, 'response' => $data, ]); return null; } } Log::warning('Failed to fetch sticker from courier', [ 'shipment_id' => $shipmentId, 'status' => $response->status(), 'response' => $response->json(), ]); return null; } catch (\Exception $e) { Log::error('Exception fetching sticker', [ 'shipment_id' => $shipmentId, 'error' => $e->getMessage(), 'trace' => $e->getTraceAsString(), ]); return null; } } /** * Validate packing gate: order must be packed with dimensions */ private function validatePacking(Order $order): bool { $valid = $order->packing_completed_at !== null && $order->packing_width > 0 && $order->packing_length > 0 && $order->packing_weight > 0; if (! $valid) { Log::warning('Packing validation failed', [ 'order_uuid' => $order->uuid, 'packing_completed_at' => $order->packing_completed_at, 'width' => $order->packing_width, 'length' => $order->packing_length, 'weight' => $order->packing_weight, ]); } return $valid; } /** * Validate payment and approval gate */ private function validatePayment(Order $order): bool { // Standard orders: must be fully paid if ($order->is_custom_order === false) { $valid = $order->payment_status === 'paid'; } else { // Custom orders: proof must be approved and balance must be paid // TODO: Add proof_approved and balance_status fields to CustomOrder model $valid = true; // Placeholder } if (! $valid) { Log::warning('Payment validation failed', [ 'order_uuid' => $order->uuid, 'is_custom_order' => $order->is_custom_order, 'payment_status' => $order->payment_status, ]); } return $valid; } /** * Check if courier is configured */ protected function isConfigured(): bool { return ! empty($this->apiKey); } /** * Public method to re-download shipment PDFs for an existing shipment * * @param Order $order * @return array{success: bool, sticker_path: ?string, waybill_path: ?string, message: string} */ public function redownloadShipmentPdfs(Order $order): array { if (! $order->courier_shipment_id) { return [ 'success' => false, 'message' => 'No shipment exists for this order', 'sticker_path' => null, 'waybill_path' => null, ]; } try { Log::info('Re-downloading shipment PDFs', [ 'order_uuid' => $order->uuid, 'shipment_id' => $order->courier_shipment_id, ]); $stickerPath = $this->fetchAndStoreSticker($order->courier_shipment_id, $order->uuid); $waybillPath = $this->fetchAndStoreWaybill($order->courier_shipment_id, $order->uuid); Log::info('Shipment PDFs re-downloaded successfully', [ 'order_uuid' => $order->uuid, 'sticker_path' => $stickerPath, 'waybill_path' => $waybillPath, ]); return [ 'success' => true, 'message' => 'PDFs re-downloaded successfully', 'sticker_path' => $stickerPath, 'waybill_path' => $waybillPath, ]; } catch (\Exception $e) { Log::error('Failed to re-download shipment PDFs', [ 'order_uuid' => $order->uuid, 'error' => $e->getMessage(), 'trace' => $e->getTraceAsString(), ]); return [ 'success' => false, 'message' => 'Failed to re-download PDFs: ' . $e->getMessage(), 'sticker_path' => null, 'waybill_path' => null, ]; } } }