validateOrderData($orderData); if (config('dhl.use_queue')) { CreateShipmentJob::dispatch($validatedData); return ['queued' => true]; } return DB::transaction(function () use ($validatedData) { $payload = $this->buildShipmentPayload($validatedData); Log::info('[DHL API] Sending payload to DHL', $this->buildPayloadLogContext($payload, $validatedData)); try { // Build query parameters for print format $query = array_filter([ 'printFormat' => $validatedData['print_format'] ?? null, 'retourePrintFormat' => $validatedData['retoure_print_format'] ?? null, 'mustEncode' => $this->shouldUseMustEncode($validatedData) ? 'true' : null, ]); $response = $this->client->request('post', '/parcel/de/shipping/v2/orders', $payload, $query); Log::info('[DHL API] Response received', $this->buildResponseLogContext($response)); $this->assertSuccessfulShipmentResponse($response, $this->shouldUseMustEncode($validatedData)); } catch (DhlValidationException $e) { if ($this->shouldUseMustEncode($validatedData) && $this->looksLikeAddressValidationError($e->getMessage())) { throw new DhlAddressValidationException($this->normalizeDhlAddressValidationMessage($e->getMessage()), (int) $e->getCode(), $e); } throw $e; } catch (Exception $e) { Log::error('[DHL API] Request failed', array_merge( $this->buildPayloadLogContext($payload, $validatedData), ['error' => $e->getMessage()] )); throw $e; } $shipmentNumber = $this->extractShipmentNumber($response); $labelBase64 = $this->extractLabelData($response); $shipment = $this->createShipmentRecord($validatedData, $payload, $response, $shipmentNumber); $labelPath = $this->saveLabelFile($shipment, $labelBase64, $payload['shipments'][0]['print']['format']); Log::info('Created shipment label', ['shipmentNumber' => $shipmentNumber]); return [ 'shipmentNumber' => $shipmentNumber, 'label_path' => $labelPath, 'shipment' => $shipment, 'raw' => $response, ]; }); } /** * Cancel an existing DHL shipment * * @param string $shipmentNumber DHL shipment number * @return bool Success status * * @throws Exception When cancellation fails */ public function cancelLabel(string $shipmentNumber): bool { if (empty($shipmentNumber)) { throw new InvalidArgumentException('Shipment number is required'); } $shipment = DhlShipment::where('dhl_shipment_no', $shipmentNumber)->first(); if (! $shipment) { throw new InvalidArgumentException('Shipment not found in database: '.$shipmentNumber); } if (! $shipment->canCancel()) { throw new InvalidArgumentException('Shipment cannot be canceled (current status: '.$shipment->status.')'); } Log::info('[DHL Package] Attempting to cancel shipment', [ 'shipmentNumber' => $shipmentNumber, 'shipment_id' => $shipment->id, 'status' => $shipment->status, 'endpoint' => "/parcel/de/shipping/v2/orders/{$shipmentNumber}", ]); try { $response = $this->client->request('delete', "/parcel/de/shipping/v2/orders/{$shipmentNumber}"); Log::info('[DHL Package] Shipment cancellation response', [ 'shipmentNumber' => $shipmentNumber, 'response' => $response, ]); $this->recordCancellationSuccess($shipment, $response); Log::info('[DHL Package] Canceled shipment successfully', [ 'shipmentNumber' => $shipmentNumber, 'shipment_id' => $shipment->id, ]); return true; } catch (\Exception $e) { $this->recordCancellationFailure($shipment, $e); Log::error('[DHL Package] Shipment cancellation failed', [ 'shipmentNumber' => $shipmentNumber, 'shipment_id' => $shipment->id, 'error' => $e->getMessage(), 'error_class' => get_class($e), ]); throw $e; } } private function recordCancellationSuccess(DhlShipment $shipment, array $response): void { $apiResponseData = $shipment->api_response_data ?? []; $apiResponseData['cancellation'] = [ 'status' => 'success', 'response' => $response, 'occurred_at' => now()->toISOString(), ]; $shipment->update([ 'status' => 'canceled', 'api_response_data' => $apiResponseData, ]); } private function recordCancellationFailure(DhlShipment $shipment, \Exception $exception): void { $apiResponseData = $shipment->api_response_data ?? []; $apiResponseData['cancellation_error'] = [ 'status' => 'failed', 'http_status' => $this->extractHttpStatus($exception->getMessage()), 'dhl_code' => $this->extractDhlErrorCode($exception->getMessage()), 'detail' => $exception->getMessage(), 'exception_class' => $exception::class, 'occurred_at' => now()->toISOString(), ]; $shipment->update(['api_response_data' => $apiResponseData]); } private function extractHttpStatus(string $message): ?int { if (preg_match('/\b([45][0-9]{2})\b/', $message, $matches)) { return (int) $matches[1]; } return null; } private function extractDhlErrorCode(string $message): ?string { if (preg_match('/\b([A-Z]{2}-[A-Za-z0-9_-]+)\b/', $message, $matches)) { return $matches[1]; } return null; } /** * Build a redacted DHL request log context. * * Logs only routing-relevant metadata and never the full payload, since * the payload contains personal address data and billing numbers. * * @param array $payload * @param array $validatedData * @return array */ private function buildPayloadLogContext(array $payload, array $validatedData): array { $billingNumber = data_get($payload, 'shipments.0.billingNumber'); return [ 'endpoint' => '/parcel/de/shipping/v2/orders', 'order_id' => $validatedData['order_id'] ?? null, 'product_code' => data_get($payload, 'shipments.0.product'), 'billing_number_suffix' => is_string($billingNumber) ? mb_substr($billingNumber, -4) : null, 'weight_grams' => data_get($payload, 'shipments.0.details.weight.value'), 'consignee_country' => data_get($payload, 'shipments.0.consignee.country'), 'has_reference' => ! empty(data_get($payload, 'shipments.0.refNo')), 'must_encode' => $this->shouldUseMustEncode($validatedData), ]; } /** * Build a redacted DHL response log context. * * Drops the base64 label payload, which is large and not useful in logs. * * @param array $response * @return array */ private function buildResponseLogContext(array $response): array { return [ 'shipment_number' => $this->extractShipmentNumber($response), 'has_label' => $this->extractLabelData($response) !== null, 'routing_code' => $this->extractRoutingCode($response), 'item_status_code' => data_get($response, 'items.0.sstatus.statusCode') ?? data_get($response, 'status.statusCode'), 'item_status_title' => data_get($response, 'items.0.sstatus.title') ?? data_get($response, 'status.title'), ]; } /** * Validate required order data according to DHL API v2 specification */ private function validateOrderData(array $data): array { // Pre-process German addresses before validation $data = $this->preprocessAddresses($data); $validator = Validator::make($data, [ 'order_id' => 'nullable|integer', 'weight_kg' => 'required|numeric|min:0.1|max:31.5', // DHL weight limit 'product_code' => 'nullable|string|in:V01PAK,V53PAK,V62KP,V07PAK', 'label_format' => 'nullable|string|in:PDF,ZPL', 'print_format' => 'nullable|string', // Values like A4, 910-300-700 etc. 'retoure_print_format' => 'nullable|string', 'print_only_if_codeable' => 'nullable|boolean', // Shipper validation (sender) 'shipper' => 'required|array', 'shipper.name' => 'required|string|max:50', 'shipper.name2' => 'nullable|string|max:50', 'shipper.street' => 'required|string|max:50', 'shipper.houseNumber' => 'required|string|max:10', 'shipper.postalCode' => 'required|string|max:10', 'shipper.city' => 'required|string|max:50', 'shipper.country' => 'required|string|size:2', // ISO 3166-1 alpha-2 'shipper.email' => 'nullable|email|max:100', 'shipper.phone' => 'nullable|string|max:20', // Consignee validation (recipient) 'consignee' => 'required|array', 'consignee.name' => 'required|string|max:50', 'consignee.name2' => 'nullable|string|max:50', 'consignee.street' => 'required|string|max:50', 'consignee.houseNumber' => 'required_without:consignee.postNumber|string|max:10', 'consignee.postalCode' => 'required|string|max:10', 'consignee.city' => 'required|string|max:50', 'consignee.country' => 'required|string|size:2', 'consignee.email' => 'nullable|email|max:100', 'consignee.phone' => 'nullable|string|max:20', 'consignee.postNumber' => 'nullable|string|max:20', // DHL Postnummer für Packstation/Paketbox // Optional dimensions 'dimensions' => 'nullable|array', 'dimensions.length' => 'nullable|numeric|min:1|max:120', 'dimensions.width' => 'nullable|numeric|min:1|max:60', 'dimensions.height' => 'nullable|numeric|min:1|max:60', // Optional services and reference 'services' => 'nullable|array', 'reference' => 'nullable|string|max:35', // DHL reference field limit ]); if ($validator->fails()) { throw new InvalidArgumentException($validator->errors()->first()); } $validated = $validator->validated(); (new DhlShipmentWeightCalculator)->assertWithinProductLimit( (float) $validated['weight_kg'], $validated['product_code'] ?? null ); return $validated; } private function shouldUseMustEncode(array $orderData): bool { return (bool) ($orderData['print_only_if_codeable'] ?? config('dhl.print_only_if_codeable', true)) && strtoupper((string) ($orderData['consignee']['country'] ?? '')) === DhlProductResolver::DOMESTIC_COUNTRY; } private function assertSuccessfulShipmentResponse(array $response, bool $mustEncodeEnabled): void { $itemStatusCode = (int) (data_get($response, 'items.0.sstatus.statusCode') ?? data_get($response, 'items.0.sstatus.status') ?? data_get($response, 'status.statusCode') ?? data_get($response, 'status.status') ?? 200); if ($itemStatusCode < 400 && $this->extractShipmentNumber($response) !== null && $this->extractLabelData($response) !== null) { return; } $message = $this->extractResponseErrorMessage($response) ?: 'DHL hat kein Versandlabel erstellt.'; if ($mustEncodeEnabled || $this->looksLikeAddressValidationError($message)) { throw new DhlAddressValidationException($this->normalizeDhlAddressValidationMessage($message)); } throw new DhlValidationException($message); } private function extractResponseErrorMessage(array $response): ?string { $message = data_get($response, 'items.0.sstatus.detail') ?? data_get($response, 'items.0.sstatus.title') ?? data_get($response, 'status.detail') ?? data_get($response, 'status.title') ?? data_get($response, 'detail') ?? data_get($response, 'message'); $validationMessages = data_get($response, 'items.0.validationMessages', []); if (is_array($validationMessages) && $validationMessages !== []) { $messages = []; foreach ($validationMessages as $validationMessage) { $messages[] = $validationMessage['validationMessage'] ?? $validationMessage['message'] ?? $validationMessage['property'] ?? null; } $messages = array_values(array_filter($messages)); if ($messages !== []) { $message = implode('; ', $messages); } } return $message ? (string) $message : null; } private function looksLikeAddressValidationError(string $message): bool { return (bool) preg_match('/address|adresse|anschrift|leitcod|routing|route|codeable|codable|encodable|mustEncode|postal|postleitzahl|street|straße|strasse|house|hausnummer|city|ort/i', $message); } private function normalizeDhlAddressValidationMessage(string $message): string { $message = trim(preg_replace('/^DHL API validation error:\s*/i', '', $message)); $message = $message !== '' ? $message : 'DHL kann diese Adresse nicht leitcodieren.'; return 'DHL kann diese Adresse nicht leitcodieren. Bitte Straße, Hausnummer, PLZ und Ort prüfen. DHL-Meldung: '.$message; } /** * Preprocess addresses to extract house numbers from street field */ private function preprocessAddresses(array $data): array { // Process shipper address if (isset($data['shipper'])) { $data['shipper'] = $this->parseAddressFields($data['shipper']); } // Process consignee address if (isset($data['consignee'])) { $data['consignee'] = $this->parseAddressFields($data['consignee']); } return $data; } /** * Parse German address to extract street and house number */ private function parseAddressFields(array $addressData): array { // If houseNumber is already provided, use it if (! empty($addressData['houseNumber'])) { return $addressData; } // If no houseNumber provided, try to parse from street if (empty($addressData['street'])) { return $addressData; } $street = trim($addressData['street']); $parsed = $this->parseGermanAddress($street); // Only update if we successfully parsed both parts if ($parsed['street'] && $parsed['houseNumber']) { $addressData['street'] = $parsed['street']; $addressData['houseNumber'] = $parsed['houseNumber']; Log::info('Parsed German address', [ 'original_street_length' => strlen($street), 'parsed_street_length' => strlen($parsed['street']), 'parsed_house_number_length' => strlen($parsed['houseNumber']), ]); return $addressData; } // No house number could be parsed from the street. We must not invent // one (the previous `'1'` default caused parcels to be delivered to // the wrong address) and the DHL API rejects shipments without a // house number anyway. Surface a validation error so the operator can // fix the address before we ever hit DHL. Log::warning('Could not parse house number from address', [ 'street_length' => strlen($street), 'country' => $addressData['country'] ?? null, 'postal_prefix' => isset($addressData['postalCode']) && is_string($addressData['postalCode']) ? mb_substr($addressData['postalCode'], 0, 2) : null, ]); throw new InvalidArgumentException( 'Hausnummer fehlt in der Adresse und konnte nicht automatisch aus dem Strassenfeld ermittelt werden. Bitte Strasse und Hausnummer separat erfassen.' ); } /** * Parse German address string to extract street name and house number * Handles formats like: "Musterstraße 123", "Muster Str. 123a", "Am Markt 1-3" */ private function parseGermanAddress(string $address): array { $address = trim($address); // Pattern to match German addresses // Captures everything before the last word that contains numbers $patterns = [ // "Musterstraße 123a" -> street: "Musterstraße", number: "123a" '/^(.+?)\s+([0-9]+[a-zA-Z]?)\s*$/', // "Am Markt 1-3" -> street: "Am Markt", number: "1-3" '/^(.+?)\s+([0-9]+[-\/][0-9]+[a-zA-Z]?)\s*$/', // "Muster Str. 123" -> street: "Muster Str.", number: "123" '/^(.+?)\s+([0-9]+)\s*$/', ]; foreach ($patterns as $pattern) { if (preg_match($pattern, $address, $matches)) { return [ 'street' => trim($matches[1]), 'houseNumber' => trim($matches[2]), ]; } } // If no pattern matches, return original street with empty house number return [ 'street' => $address, 'houseNumber' => null, ]; } /** * Build DHL API v2 payload from order data * * Structure follows official DHL API v2 createOrders specification: * https://developer.dhl.com/api-reference/parcel-de-shipping-post-parcel-germany-v2 */ private function buildShipmentPayload(array $orderData): array { $resolver = new DhlProductResolver; $destination = $resolver->resolveForShipment( $orderData['consignee']['country'] ?? '', $orderData['product_code'] ?? null, config('dhl.default_product', 'V01PAK') ); $productCode = $destination['product_code']; $billingNumber = $resolver->assertBillingNumber($productCode, $this->getBillingNumberForProduct($productCode)); $payload = [ 'profile' => config('dhl.profile', 'STANDARD_GRUPPENPROFIL'), 'shipments' => [[ 'product' => $productCode, 'billingNumber' => $billingNumber, // Shipper information (sender) - separate street and house number as per official spec 'shipper' => array_filter([ 'name1' => $orderData['shipper']['name'] ?? '', 'name2' => ! empty($orderData['shipper']['name2']) ? $orderData['shipper']['name2'] : null, 'addressStreet' => $orderData['shipper']['street'] ?? '', 'addressHouse' => $orderData['shipper']['houseNumber'] ?? null, 'postalCode' => $orderData['shipper']['postalCode'] ?? '', 'city' => $orderData['shipper']['city'] ?? '', 'country' => $this->convertCountryCode($orderData['shipper']['country'] ?? ''), 'email' => ! empty($orderData['shipper']['email']) ? $orderData['shipper']['email'] : null, 'phone' => ! empty($orderData['shipper']['phone']) ? $orderData['shipper']['phone'] : null, ], function ($value) { return $value !== null; }), // Consignee information (recipient) 'consignee' => $this->buildConsigneePayload($orderData['consignee']), 'details' => [ 'weight' => [ 'value' => ($orderData['weight_kg'] ?? 1.0) * 1000, // Convert kg to grams 'uom' => 'g', ], ], 'print' => [ 'format' => $orderData['label_format'] ?? config('dhl.label_format', 'PDF'), ], ]], ]; // Add dimensions if provided (convert cm to mm) if (! empty($orderData['dimensions'])) { $payload['shipments'][0]['details']['dim'] = [ 'uom' => 'mm', 'length' => ($orderData['dimensions']['length'] ?? 30) * 10, // cm to mm 'width' => ($orderData['dimensions']['width'] ?? 25) * 10, // cm to mm 'height' => ($orderData['dimensions']['height'] ?? 10) * 10, // cm to mm ]; } // Add custom reference if provided if (! empty($orderData['reference'])) { $payload['shipments'][0]['refNo'] = $orderData['reference']; } return $payload; } /** * Build consignee payload - handles both regular addresses and Packstation/Paketbox * * @param array $consignee Consignee data from order * @return array Formatted consignee payload for DHL API */ private function buildConsigneePayload(array $consignee): array { // Check if this is a Packstation/Paketbox delivery (has postNumber) if (! empty($consignee['postNumber'])) { return $this->buildPackstationConsignee($consignee); } // Regular address return array_filter([ 'name1' => $consignee['name'] ?? '', 'name2' => ! empty($consignee['name2']) ? $consignee['name2'] : null, 'addressStreet' => $consignee['street'] ?? '', 'addressHouse' => $consignee['houseNumber'] ?? null, 'postalCode' => $consignee['postalCode'] ?? '', 'city' => $consignee['city'] ?? '', 'country' => $this->convertCountryCode($consignee['country'] ?? ''), 'email' => ! empty($consignee['email']) ? $consignee['email'] : null, 'phone' => ! empty($consignee['phone']) ? $consignee['phone'] : null, ], function ($value) { return $value !== null; }); } /** * Build Packstation/Paketbox consignee payload * * DHL API v2 uses the Locker schema for Packstation deliveries: * - name: Recipient name (max 50 chars) * - lockerID: Integer 100-999 (3-digit Packstation number) * - postNumber: String 6-10 digits (DHL Postnummer) * - postalCode, city, country: Location of the Packstation * * The street field should contain "Packstation XXX" or "Paketbox XXX" * * @see https://developer.dhl.com/api-reference/parcel-de-shipping-post-parcel-germany-v2#/components/schemas/Locker * * @param array $consignee Consignee data with postNumber * @return array Formatted Locker consignee payload for DHL API */ private function buildPackstationConsignee(array $consignee): array { // Extract locker number from multiple sources: // 1. From street field (e.g., "Packstation 145" -> "145") // 2. From houseNumber field (e.g., houseNumber: "145") // 3. Combined: street "Packstation" + houseNumber "145" $lockerNumber = $this->extractLockerNumber( $consignee['street'] ?? '', $consignee['houseNumber'] ?? '' ); // Convert to integer for DHL API (lockerID must be int 100-999) $lockerID = (int) $lockerNumber; Log::info('Building Packstation consignee payload (Locker schema)', [ 'postNumber' => $consignee['postNumber'], 'lockerID' => $lockerID, 'originalStreet' => $consignee['street'] ?? '', 'originalHouseNumber' => $consignee['houseNumber'] ?? '', ]); // Validate lockerID: must be integer between 100 and 999 if ($lockerID < 100 || $lockerID > 999) { Log::error('Invalid Packstation lockerID - must be 100-999', [ 'lockerID' => $lockerID, 'original_input' => $lockerNumber, 'street' => $consignee['street'] ?? '', 'houseNumber' => $consignee['houseNumber'] ?? '', ]); $errorMessage = 'PACKSTATION-FEHLER: Die Packstation-Nummer muss 3-stellig sein (100-999).'.PHP_EOL.PHP_EOL; $errorMessage .= 'Eingegeben wurde: "'.$lockerNumber.'"'.PHP_EOL.PHP_EOL; $errorMessage .= 'HINWEISE:'.PHP_EOL; $errorMessage .= '• Straße/Nr.: "Packstation 145" (nicht "Packstation 12345")'.PHP_EOL; $errorMessage .= '• Die Packstation-NUMMER ist die 3-stellige Nummer auf dem gelben DHL-Schild'.PHP_EOL; $errorMessage .= '• Die 5-10-stellige POSTNUMMER ist etwas anderes (kommt ins separate Feld!)'.PHP_EOL; $errorMessage .= '• Beispiel: Packstation 145, PLZ 12345, Postnummer 1234567890'; throw new \InvalidArgumentException($errorMessage); } // Validate postNumber: must be 6-10 digits $postNumber = $consignee['postNumber'] ?? ''; if (! preg_match('/^[0-9]{6,10}$/', $postNumber)) { Log::error('Invalid DHL Postnummer - must be 6-10 digits', [ 'postNumber' => $postNumber, ]); throw new \InvalidArgumentException( 'DHL Postnummer muss 6-10 Ziffern enthalten. Bitte prüfen Sie die Postnummer.' ); } // DHL Locker schema - flat structure, not nested return array_filter([ 'name' => $consignee['name'] ?? '', 'lockerID' => $lockerID, 'postNumber' => $postNumber, 'postalCode' => $consignee['postalCode'] ?? '', 'city' => $consignee['city'] ?? '', 'country' => $this->convertCountryCode($consignee['country'] ?? ''), ], function ($value) { return $value !== null && $value !== ''; }); } /** * Extract locker number from address string or houseNumber field * * Examples: * - "Packstation 145" -> "145" * - "Paketbox 123" -> "123" * - "PACKSTATION 987" -> "987" * - street: "Packstation", houseNumber: "145" -> "145" * * @param string $address Address containing Packstation/Paketbox number * @param string $houseNumber Optional house number field (may contain locker number) * @return string The extracted locker number */ private function extractLockerNumber(string $address, string $houseNumber = ''): string { // Match patterns like "Packstation 145", "Paketbox 123", etc. if (preg_match('/(?:packstation|paketbox)\s*(\d+)/i', $address, $matches)) { return $matches[1]; } // If address is just "Packstation" or "Paketbox" without number, // check if houseNumber contains the locker number if (preg_match('/^(?:packstation|paketbox)$/i', trim($address)) && ! empty($houseNumber)) { // houseNumber might be the locker number directly if (preg_match('/^\d+$/', trim($houseNumber))) { Log::info('Using houseNumber as locker number', [ 'address' => $address, 'houseNumber' => $houseNumber, ]); return trim($houseNumber); } } // If no pattern matches, try to extract any number from the address string if (preg_match('/(\d+)/', $address, $matches)) { return $matches[1]; } // Last resort: check if houseNumber contains any number if (! empty($houseNumber) && preg_match('/(\d+)/', $houseNumber, $matches)) { Log::info('Extracted locker number from houseNumber field', [ 'address' => $address, 'houseNumber' => $houseNumber, 'extracted' => $matches[1], ]); return $matches[1]; } // Fallback: return empty string (will trigger validation error with helpful message) Log::warning('Could not extract locker number from address', [ 'address' => $address, 'houseNumber' => $houseNumber, ]); return ''; } /** * Convert 2-letter country code to 3-letter country code for DHL API */ private function convertCountryCode(string $countryCode): string { return (new DhlProductResolver)->toDhlCountryCode($countryCode); } /** * Get the correct billing number for the given product code */ private function getBillingNumberForProduct(string $productCode): string { // Check if we're in test/sandbox mode $isTestMode = config('dhl.legacy.test_mode', false) || config('dhl.legacy.sandbox', false); if ($isTestMode) { // Use test billing number for sandbox mode $testBillingNumber = '33333333330102'; Log::info('Using DHL test billing number (sandbox mode)', [ 'product_code' => $productCode, 'billing_number' => $testBillingNumber, 'test_mode' => true, ]); return $testBillingNumber; } // Try to get from admin settings via Setting model first (database settings override config) $settingKey = 'dhl_account_'.strtolower($productCode); try { $accountNumber = \App\Models\Setting::getContentBySlug($settingKey); if ($accountNumber) { Log::info('Using DHL account number from database settings', [ 'product_code' => $productCode, 'account_number' => $accountNumber, ]); return $accountNumber; } } catch (\Exception $e) { Log::warning('Could not load DHL account number from settings', [ 'product_code' => $productCode, 'setting_key' => $settingKey, 'error' => $e->getMessage(), ]); } // Try to get account number from config by product code $accountNumber = config("dhl.account_numbers.{$productCode}"); if ($accountNumber) { Log::info('Using DHL account number from config file', [ 'product_code' => $productCode, 'account_number' => $accountNumber, ]); return $accountNumber; } // Fallback to default billing number $defaultBillingNumber = config('dhl.billing_number') ?: config('dhl.account_numbers.default'); Log::warning('Using default billing number for product code', [ 'product_code' => $productCode, 'billing_number' => $defaultBillingNumber, ]); return $defaultBillingNumber; } /** * Extract shipment number from API response */ private function extractShipmentNumber(array $response): ?string { return data_get($response, 'items.0.shipmentNo') ?? data_get($response, 'shipments.0.shipmentNo') ?? data_get($response, 'shipmentNo') ?? null; } /** * Extract base64 label data from API response */ private function extractLabelData(array $response): ?string { return data_get($response, 'items.0.label.b64') ?? data_get($response, 'shipments.0.label.b64') ?? data_get($response, 'shipments.0.label') ?? data_get($response, 'label'); } /** * Extract routing code from API response */ private function extractRoutingCode(array $response): ?string { return data_get($response, 'items.0.routingCode') ?? data_get($response, 'shipments.0.routingCode') ?? null; } /** * Create shipment database record */ private function createShipmentRecord(array $orderData, array $payload, array $response, ?string $shipmentNumber): DhlShipment { // Extract recipient data from orderData (can be modified in modal) $consignee = $orderData['consignee'] ?? []; // Parse name from consignee data $fullName = trim($consignee['name'] ?? ''); $nameParts = explode(' ', $fullName, 2); $firstname = $nameParts[0] ?? ''; $lastname = $nameParts[1] ?? ''; // If name is empty, try to get from separate fields if (empty($firstname) && empty($lastname)) { $firstname = $consignee['firstname'] ?? ''; $lastname = $consignee['lastname'] ?? ''; } // Extract email and postnumber $email = $consignee['email'] ?? ''; $postnumber = $consignee['postNumber'] ?? $consignee['postnumber'] ?? ''; // Prepare complete recipient address as JSON $recipientData = [ 'firstname' => $firstname, 'lastname' => $lastname, 'company' => $consignee['name2'] ?? '', 'street' => $consignee['street'] ?? '', 'houseNumber' => $consignee['houseNumber'] ?? '', 'postalCode' => $consignee['postalCode'] ?? '', 'city' => $consignee['city'] ?? '', 'country' => $consignee['country'] ?? '', 'email' => $email, 'phone' => $consignee['phone'] ?? '', 'postnumber' => $postnumber, ]; return DhlShipment::create([ 'order_id' => $orderData['order_id'] ?? null, 'dhl_shipment_no' => $shipmentNumber, 'routing_code' => $this->extractRoutingCode($response), 'reference' => $payload['shipments'][0]['refNo'] ?? null, 'type' => 'outbound', 'product_code' => $payload['shipments'][0]['product'], 'billing_number' => $payload['shipments'][0]['billingNumber'], 'weight_kg' => $payload['shipments'][0]['details']['weight']['value'] / 1000, 'status' => 'created', 'label_format' => $payload['shipments'][0]['print']['format'], 'label_path' => null, 'api_response_data' => $response, // Recipient data (can be modified in modal) 'firstname' => $firstname, 'lastname' => $lastname, 'company' => $consignee['name2'] ?? '', 'email' => $email, 'postnumber' => $postnumber, 'recipient' => $recipientData, ]); } /** * Save label file to storage and create label record */ private function saveLabelFile(DhlShipment $shipment, ?string $labelBase64, string $format): ?string { if (! $labelBase64) { Log::warning('No label data received for shipment', ['shipmentId' => $shipment->id]); return null; } $path = 'dhl/labels/'.$shipment->dhl_shipment_no.'.'.strtolower($format); $success = false; for ($attempt = 1; $attempt <= 3; $attempt++) { try { Storage::disk('local')->put($path, base64_decode($labelBase64)); $success = true; break; } catch (\Exception $e) { Log::warning('Storage put failed, retrying', ['attempt' => $attempt, 'error' => $e->getMessage()]); usleep(1000000); // 1 second in microseconds } } if (! $success) { throw new \Exception('Failed to save label after 3 attempts'); } $shipment->update(['label_path' => $path]); return $path; } }