mivita/app/Services/DhlApiService.php
2025-08-22 18:18:26 +02:00

1312 lines
No EOL
52 KiB
PHP

<?php
namespace App\Services;
use App\Models\DhlShipment;
use App\Models\ShoppingOrder;
use ChristophSchaeffer\Dhl\BusinessShipping\MultiClient;
use ChristophSchaeffer\Dhl\BusinessShipping\ShippingClient;
use ChristophSchaeffer\Dhl\BusinessShipping\TrackingClient;
use ChristophSchaeffer\Dhl\BusinessShipping\Credentials\ShippingClientCredentials;
use ChristophSchaeffer\Dhl\BusinessShipping\Credentials\TrackingClientCredentials;
use ChristophSchaeffer\Dhl\BusinessShipping\Resource\ShipmentOrder;
use ChristophSchaeffer\Dhl\BusinessShipping\Request\Shipping\createShipmentOrder;
use ChristophSchaeffer\Dhl\BusinessShipping\Request\Shipping\deleteShipmentOrder;
use ChristophSchaeffer\Dhl\BusinessShipping\Request\Tracking\getPieceDetail;
use ChristophSchaeffer\Dhl\BusinessShipping\Request\Shipping\getVersion;
use ChristophSchaeffer\Dhl\BusinessShipping\Response\Shipping\createShipmentOrder as CreateShipmentOrderResponse;
use ChristophSchaeffer\Dhl\BusinessShipping\Response\Shipping\deleteShipmentOrder as DeleteShipmentOrderResponse;
use ChristophSchaeffer\Dhl\BusinessShipping\Exception\DhlException;
use ChristophSchaeffer\Dhl\BusinessShipping\Resource\ShipmentOrder\Shipment\Shipper;
use ChristophSchaeffer\Dhl\BusinessShipping\Resource\ShipmentOrder\Shipment\Shipper\Name as ShipperName;
use ChristophSchaeffer\Dhl\BusinessShipping\Resource\ShipmentOrder\Shipment\Shipper\Address as ShipperAddress;
use ChristophSchaeffer\Dhl\BusinessShipping\Resource\ShipmentOrder\Shipment\Shipper\Address\Origin as ShipperOrigin;
use ChristophSchaeffer\Dhl\BusinessShipping\Resource\ShipmentOrder\Shipment\Receiver;
use ChristophSchaeffer\Dhl\BusinessShipping\Resource\ShipmentOrder\Shipment\Receiver\Name as ReceiverName;
use ChristophSchaeffer\Dhl\BusinessShipping\Resource\ShipmentOrder\Shipment\Receiver\Address as ReceiverAddress;
use ChristophSchaeffer\Dhl\BusinessShipping\Resource\ShipmentOrder\Shipment\Receiver\Address\Origin as ReceiverOrigin;
use Exception;
use Illuminate\Support\Facades\Log;
use Illuminate\Support\Facades\Storage;
use Illuminate\Support\Str;
use Illuminate\Support\Facades\DB;
/**
* DHL API Service
*
* Central service class that wraps the DHL SDK and provides a clean interface
* for all DHL operations including shipping, tracking, and return labels.
*/
class DhlApiService
{
/**
* @var ShippingClient
*/
private $shippingClient;
/**
* @var TrackingClient
*/
private $trackingClient;
/**
* @var array DHL configuration
*/
private $config;
/**
* @var bool Whether to use sandbox mode
*/
private $isSandbox;
/**
* Constructor
*/
public function __construct()
{
$this->config = config('dhl');
$this->isSandbox = $this->config['api']['sandbox'] ?? true;
$this->initializeClients();
}
/**
* Initialize DHL API clients
*
* @throws Exception
*/
private function initializeClients(): void
{
try {
// Get all credentials from the config.
$apiKey = $this->config['api']['api_key'] ?? null;
$apiSecret = $this->config['api']['api_secret'] ?? null;
$username = $this->config['api']['username'] ?? null;
$password = $this->config['api']['password'] ?? null;
// The ChristophSchaeffer library for the "Geschäftskundenversand API" (SOAP)
// always requires both sets of credentials:
// 1. API Key/Secret for the Application's identity.
// 2. Username/Password for the Business Customer Portal user's identity.
// We must validate that all four are present.
if (empty($apiKey) || empty($apiSecret)) {
throw new Exception('DHL API Key (DHL_API_KEY) and API Secret (DHL_API_SECRET) are missing. Please get them from the DHL Developer Portal.');
}
if (empty($username) || empty($password)) {
throw new Exception('DHL API Username (DHL_API_USERNAME) and Password (DHL_API_PASSWORD) are missing. Please get them from your customer\'s DHL Business Customer Portal.');
}
$shippingCredentials = new ShippingClientCredentials(
$apiKey,
$apiSecret,
$username,
$password
);
$this->shippingClient = new ShippingClient(
$shippingCredentials,
$this->isSandbox,
MultiClient::LANGUAGE_LOCALE_GERMAN_DE
);
// Initialize Tracking Client (if enabled)
if ($this->config['tracking']['enabled']) {
// For tracking, the ZT-Token is often used as the "user" context
$trackingUser = $this->config['tracking']['username'] ?? 'zt12345'; // Sandbox default
$trackingPass = $this->config['tracking']['password'] ?? 'geheim'; // Sandbox default
$trackingCredentials = new TrackingClientCredentials(
$apiKey,
$apiSecret,
$trackingUser,
$trackingPass
);
$this->trackingClient = new TrackingClient(
$trackingCredentials,
$this->isSandbox
);
}
} catch (Exception $e) {
$this->logError('Failed to initialize DHL clients', $e);
throw new Exception('DHL API initialization failed: ' . $e->getMessage());
}
}
/**
* Create a shipment for an order
*
* @param ShoppingOrder $order The shopping order
* @param float $weight Package weight in kg
* @param array $options Additional options
* @return DhlShipment The created shipment
* @throws Exception
*/
public function createShipment(ShoppingOrder $order, float $weight = 1.0, array $options = []): DhlShipment
{
try {
$this->logInfo('Creating DHL shipment', [
'order_id' => $order->id,
'weight' => $weight,
'options' => $options
]);
// Build shipment data from order, including address parsing
$shipmentData = $this->buildShipmentData($order, $weight, $options);
\Log::info('shipmentData', $shipmentData);
// Validate the built data before proceeding
$this->validateShipmentData($shipmentData, $order->id);
// Create DHL shipment record first
$dhlShipment = $this->createDhlShipmentRecord($order, $shipmentData);
// Create shipment order for DHL API
$shipmentOrder = $this->buildShipmentOrder($shipmentData, $dhlShipment);
// Call DHL API
$request = new createShipmentOrder([$shipmentOrder]); // Constructor expects array of ShipmentOrder objects
try {
$response = $this->shippingClient->createShipmentOrder($request);
// Enhanced debug logging for troubleshooting
$this->logInfo('DHL API Response received', [
'response_class' => get_class($response),
'has_errors' => !$response->hasNoErrors(),
'creation_states_count' => count($response->CreationStates ?? [])
]);
// Log detailed response structure for debugging
if (!$response->hasNoErrors()) {
$this->logResponseStructure($response);
}
} catch (\ErrorException $e) {
if (str_contains($e->getMessage(), 'Attempt to read property "Version" on null')) {
throw new \Exception(
'The DHL API returned an unexpected response. This often indicates an authentication problem with the Business Customer API. ' .
'Please double-check your DHL_API_USERNAME and DHL_API_PASSWORD in the .env file. ' .
'Original error: ' . $e->getMessage(), 0, $e
);
}
throw $e;
}
// Process response
$this->processShipmentResponse($dhlShipment, $response, $request);
$this->logInfo('DHL shipment created successfully', [
'shipment_id' => $dhlShipment->id,
'shipment_number' => $dhlShipment->shipment_number
]);
return $dhlShipment->fresh();
} catch (DhlException $e) {
$this->logError('DHL API error during shipment creation', $e, ['order_id' => $order->id]);
throw new Exception('DHL shipment creation failed: ' . $e->getMessage());
} catch (Exception $e) {
$this->logError('General error during shipment creation', $e, ['order_id' => $order->id]);
throw $e;
}
}
/**
* Test DHL API Login Credentials by creating a dummy shipment.
*
* @return array
*/
public function testLogin(): array
{
try {
$this->logInfo('Starting DHL API connection test.');
// Check basic configuration first
if (!$this->isConfigured()) {
return [
'success' => false,
'message' => 'DHL API ist nicht vollständig konfiguriert. Prüfen Sie die .env Einstellungen.',
];
}
// 1. Find a test order (preferably with complete shipping data)
$testOrder = ShoppingOrder::with(['shopping_user', 'shopping_order_items'])
->whereHas('shopping_user')
->where('id', 35511) // Use specific test order
->first();
if (!$testOrder) {
return [
'success' => false,
'message' => 'Test-Bestellung (ID: 35511) nicht gefunden. Bitte verwenden Sie eine existierende Bestellung für den Test.',
];
}
$this->logInfo('Using test order for API test', [
'order_id' => $testOrder->id,
'has_user' => !is_null($testOrder->shopping_user),
'has_items' => $testOrder->shopping_order_items->count() > 0
]);
// 2. Define test options with realistic data
$weight = 1.0;
$options = [
'product_code' => 'V01PAK',
'is_test' => true // Mark as test to avoid any side effects
];
// 3. Wrap in transaction to avoid database pollution
DB::beginTransaction();
try {
$dhlShipment = $this->createShipment($testOrder, $weight, $options);
// If we get here, the API call was successful
$success = !empty($dhlShipment->shipment_number);
if ($success) {
$this->logInfo('DHL API test successful', [
'shipment_number' => $dhlShipment->shipment_number,
'status' => $dhlShipment->status
]);
$result = [
'success' => true,
'message' => 'DHL API Test erfolgreich! Verbindung und Authentifizierung funktionieren.',
'details' => [
'shipment_number' => $dhlShipment->shipment_number,
'status' => $dhlShipment->status,
'api_type' => $this->config['api']['api_type'] ?? 'unknown',
'sandbox' => $this->isSandbox
]
];
} else {
$result = [
'success' => false,
'message' => 'DHL API Test unvollständig: Sendung erstellt aber keine Sendungsnummer erhalten.',
'details' => [
'status' => $dhlShipment->status ?? 'unknown',
'errors' => $dhlShipment->api_errors ?? 'No specific errors'
]
];
}
} catch (Exception $apiException) {
$this->logError('DHL API test failed during createShipment', $apiException, [
'order_id' => $testOrder->id
]);
$result = [
'success' => false,
'message' => 'DHL API Test fehlgeschlagen: ' . $apiException->getMessage(),
'details' => [
'error_type' => get_class($apiException),
'api_type' => $this->config['api']['api_type'] ?? 'unknown',
'sandbox' => $this->isSandbox
]
];
}
// Always rollback transaction for tests
DB::rollBack();
return $result;
} catch (Exception $e) {
DB::rollBack(); // Ensure rollback on any failure
$this->logError('DHL API test failed with general error', $e);
return [
'success' => false,
'message' => 'DHL API Test fehlgeschlagen: ' . $e->getMessage(),
'details' => [
'error_type' => get_class($e),
'configured' => $this->isConfigured(),
'sandbox' => $this->isSandbox
]
];
}
}
/**
* Validates required shipment data fields to prevent API errors.
*
* @param array $shipmentData
* @param int $orderId
* @throws \Exception
*/
private function validateShipmentData(array $shipmentData, int $orderId): void
{
$requiredFields = [
'recipient_street' => 'streetName',
'recipient_street_number' => 'streetNumber',
'recipient_postal_code' => 'postal code',
'recipient_city' => 'city',
'recipient_country' => 'countryISOCode',
];
$errors = [];
foreach ($requiredFields as $field => $errorName) {
if (empty(trim($shipmentData[$field]))) {
$errors[] = $errorName;
}
}
if (empty(trim($shipmentData['recipient_name'])) && empty(trim($shipmentData['recipient_company']))) {
$errors[] = 'recipient name or company';
}
if (!empty($errors)) {
$errorMessage = 'Shipment data is missing required fields for Order ID ' . $orderId . ': ' . implode(', ', $errors);
throw new \Exception($errorMessage);
}
}
/**
* Cancel a shipment
*
* @param DhlShipment $shipment
* @return bool Success status
* @throws Exception
*/
public function cancelShipment(DhlShipment $shipment): bool
{
try {
if (!$shipment->canBeCancelled()) {
throw new Exception('Shipment cannot be cancelled in current status: ' . $shipment->status);
}
$this->logInfo('Cancelling DHL shipment', [
'shipment_id' => $shipment->id,
'shipment_number' => $shipment->shipment_number
]);
$request = new deleteShipmentOrder([$shipment->shipment_number]); // Constructor expects array of shipment numbers
$response = $this->shippingClient->deleteShipmentOrder($request);
if ($this->isResponseSuccessful($response)) {
$shipment->update([
'status' => DhlShipment::STATUS_CANCELLED,
'api_response_data' => $this->extractResponseData($response),
]);
$this->logInfo('DHL shipment cancelled successfully', [
'shipment_id' => $shipment->id
]);
return true;
} else {
$errorMessage = $this->extractErrorMessage($response);
$shipment->update([
'api_errors' => $errorMessage,
]);
throw new Exception('DHL cancellation failed: ' . $errorMessage);
}
} catch (DhlException $e) {
$this->logError('DHL API error during cancellation', $e, ['shipment_id' => $shipment->id]);
throw new Exception('DHL shipment cancellation failed: ' . $e->getMessage());
} catch (Exception $e) {
$this->logError('General error during cancellation', $e, ['shipment_id' => $shipment->id]);
throw $e;
}
}
/**
* Create a return label
*
* @param DhlShipment $originalShipment The original outbound shipment
* @param array $options Additional options
* @return DhlShipment The return shipment
* @throws Exception
*/
public function createReturnLabel(DhlShipment $originalShipment, array $options = []): DhlShipment
{
try {
if (!$this->config['returns']['enabled']) {
throw new Exception('Return labels are disabled in configuration');
}
$this->logInfo('Creating DHL return label', [
'original_shipment_id' => $originalShipment->id,
'options' => $options
]);
// Create return shipment using regular createShipment but with return data
$returnShipment = $this->createShipment(
$originalShipment->shoppingOrder,
$originalShipment->weight,
array_merge($options, ['is_return' => true, 'original_shipment' => $originalShipment])
);
$this->logInfo('DHL return label created successfully', [
'return_shipment_id' => $returnShipment->id,
'original_shipment_id' => $originalShipment->id
]);
return $returnShipment;
} catch (Exception $e) {
$this->logError('Error creating return label', $e, [
'original_shipment_id' => $originalShipment->id
]);
throw $e;
}
}
/**
* Get tracking details for a shipment
*
* @param DhlShipment $shipment
* @return array Tracking details
* @throws Exception
*/
public function getTrackingDetails(DhlShipment $shipment): array
{
try {
if (!$this->config['tracking']['enabled']) {
throw new Exception('Tracking is disabled in configuration');
}
if (!$shipment->hasTracking()) {
throw new Exception('Shipment has no tracking number');
}
if (!$this->trackingClient) {
throw new Exception('Tracking client not initialized');
}
$this->logInfo('Getting tracking details', [
'shipment_id' => $shipment->id,
'tracking_number' => $shipment->tracking_number
]);
$request = new getPieceDetail();
$request->pieceCode = $shipment->tracking_number;
$response = $this->trackingClient->getPieceDetail($request);
if ($response && is_array($response) && count($response) > 0) {
$trackingData = method_exists($response[0], 'toArray') ? $response[0]->toArray() : (array)$response[0];
// Update shipment with latest tracking info
$shipment->update([
'tracking_details' => $trackingData,
'last_tracked_at' => now(),
'tracking_status' => $trackingData['status'] ?? null,
]);
$this->logInfo('Tracking details updated', [
'shipment_id' => $shipment->id,
'status' => $trackingData['status'] ?? 'unknown'
]);
return $trackingData;
} else {
throw new Exception('No tracking data available');
}
} catch (DhlException $e) {
$this->logError('DHL API error during tracking', $e, ['shipment_id' => $shipment->id]);
throw new Exception('DHL tracking failed: ' . $e->getMessage());
} catch (Exception $e) {
$this->logError('General error during tracking', $e, ['shipment_id' => $shipment->id]);
throw $e;
}
}
/**
* Build shipment data from order
*
* @param ShoppingOrder $order
* @param float $weight
* @param array $options
* @return array
*/
private function buildShipmentData(ShoppingOrder $order, float $weight, array $options): array
{
$isReturn = $options['is_return'] ?? false;
$originalShipment = $options['original_shipment'] ?? null;
$shoppingUser = $order->shopping_user;
// Data placeholders
$recipientName = '';
$recipientCompany = null;
$streetName = '';
$streetNumber = '';
$recipientPostalCode = '';
$recipientCity = '';
$recipientCountryCode = '';
$recipientEmail = $shoppingUser->email ?? null; // Fallback to user's main email
$recipientPhone = null;
$customAddress = $options['shipping_address'] ?? null;
if ($customAddress) {
// Use custom address from options (e.g., modal)
$recipientName = trim(($customAddress['firstname'] ?? '') . ' ' . ($customAddress['lastname'] ?? ''));
$recipientCompany = $customAddress['company'] ?? null;
$streetName = $customAddress['address'] ?? '';
$streetNumber = $customAddress['address_2'] ?? '';
$recipientPostalCode = $customAddress['zipcode'] ?? '';
$recipientCity = $customAddress['city'] ?? '';
$recipientPhone = $customAddress['phone'] ?? null;
if (!empty($customAddress['country_id'])) {
$country = \App\Models\Country::find($customAddress['country_id']);
if ($country) {
$recipientCountryCode = $country->code;
}
}
} else {
// Fallback to shopping_user data
$useShipping = !($shoppingUser->same_as_billing ?? true);
$firstname = $useShipping ? ($shoppingUser->shipping_firstname ?? '') : ($shoppingUser->billing_firstname ?? '');
$lastname = $useShipping ? ($shoppingUser->shipping_lastname ?? '') : ($shoppingUser->billing_lastname ?? '');
$company = $useShipping ? ($shoppingUser->shipping_company ?? '') : ($shoppingUser->billing_company ?? '');
$address = $useShipping ? ($shoppingUser->shipping_address ?? '') : ($shoppingUser->billing_address ?? '');
$address_2 = $useShipping ? ($shoppingUser->shipping_address_2 ?? '') : ($shoppingUser->billing_address_2 ?? '');
$zipcode = $useShipping ? ($shoppingUser->shipping_zipcode ?? '') : ($shoppingUser->billing_zipcode ?? '');
$city = $useShipping ? ($shoppingUser->shipping_city ?? '') : ($shoppingUser->billing_city ?? '');
$country = $useShipping ? ($shoppingUser->shipping_country ?? null) : ($shoppingUser->billing_country ?? null);
$phone = $useShipping ? ($shoppingUser->shipping_phone ?? '') : ($shoppingUser->billing_phone ?? '');
$email = $shoppingUser->billing_email ?? '';
$recipientName = trim($firstname . ' ' . $lastname);
$recipientCompany = $company;
$streetName = $address;
//$streetNumber = $address_2;
$recipientPostalCode = $zipcode;
$recipientCity = $city;
$recipientCountryCode = $country->code ?? '';
$recipientPhone = $phone;
$recipientEmail = $email;
}
// Universal address parsing for combined street/number fields
if (empty($streetNumber) && !empty($streetName)) {
if (preg_match('/^([^\d]*[^\d\s])\s*(\d.*)$/', $streetName, $matches) && count($matches) === 3) {
$streetName = trim($matches[1]);
$streetNumber = trim($matches[2]);
}
}
// --- START FIX: Automatically determine product code based on destination country ---
$isDomestic = strtoupper($recipientCountryCode) === 'DE';
$productCode = $options['product_code'] ?? ($isDomestic
? $this->config['defaults']['product']
: $this->config['defaults']['product_international']);
// --- END FIX ---
return [
'order_id' => $order->id,
'type' => $isReturn ? DhlShipment::TYPE_RETURN : DhlShipment::TYPE_OUTBOUND,
'related_shipment_id' => $originalShipment?->id,
'weight' => $weight,
'length' => $options['length'] ?? $this->config['defaults']['dimensions']['length'],
'width' => $options['width'] ?? $this->config['defaults']['dimensions']['width'],
'height' => $options['height'] ?? $this->config['defaults']['dimensions']['height'],
'product_code' => $productCode,
'services' => $options['services'] ?? [],
// Recipient address
'recipient_name' => $recipientName,
'recipient_company' => $recipientCompany,
'recipient_street' => $streetName,
'recipient_street_number' => $streetNumber,
'recipient_postal_code' => $recipientPostalCode,
'recipient_city' => $recipientCity,
'recipient_state' => null, // Not used
'recipient_country' => $recipientCountryCode,
'recipient_email' => $recipientEmail,
'recipient_phone' => $recipientPhone,
];
}
/**
* Create DHL shipment record in database
*
* @param ShoppingOrder $order
* @param array $shipmentData
* @return DhlShipment
*/
private function createDhlShipmentRecord(ShoppingOrder $order, array $shipmentData): DhlShipment
{
return DhlShipment::create([
'shopping_order_id' => $order->id,
'type' => $shipmentData['type'],
'related_shipment_id' => $shipmentData['related_shipment_id'],
'weight' => $shipmentData['weight'],
'length' => $shipmentData['length'],
'width' => $shipmentData['width'],
'height' => $shipmentData['height'],
'product_code' => $shipmentData['product_code'],
'services' => $shipmentData['services'],
'status' => DhlShipment::STATUS_CREATED,
// Recipient data
'recipient_name' => $shipmentData['recipient_name'],
'recipient_company' => $shipmentData['recipient_company'],
'recipient_street' => $shipmentData['recipient_street'],
'recipient_street_number' => $shipmentData['recipient_street_number'],
'recipient_postal_code' => $shipmentData['recipient_postal_code'],
'recipient_city' => $shipmentData['recipient_city'],
'recipient_state' => $shipmentData['recipient_state'],
'recipient_country' => $shipmentData['recipient_country'],
'recipient_email' => $shipmentData['recipient_email'],
'recipient_phone' => $shipmentData['recipient_phone'],
]);
}
/**
* Build ShipmentOrder object for DHL API
*
* @param array $shipmentData
* @param DhlShipment $dhlShipment
* @return ShipmentOrder
*/
private function buildShipmentOrder(array $shipmentData, DhlShipment $dhlShipment): ShipmentOrder
{
$shipmentOrder = new ShipmentOrder();
$isReturn = $shipmentData['type'] === DhlShipment::TYPE_RETURN;
$productCode = $shipmentData['product_code'];
// --- DYNAMIC ACCOUNT NUMBER SELECTION ---
$accountNumber = $this->getAccountNumberForProduct($productCode);
// --- VALIDATE COUNTRY CODES ---
// Ensure sender country code is valid
$senderCountry = 'DE';
if (!empty($this->config['sender']['country']) && strlen(trim($this->config['sender']['country'])) === 2) {
$senderCountry = strtoupper(trim($this->config['sender']['country']));
}
// Ensure recipient country code is valid
$recipientCountry = 'DE';
if (!empty($shipmentData['recipient_country']) && strlen(trim($shipmentData['recipient_country'])) === 2) {
$recipientCountry = strtoupper(trim($shipmentData['recipient_country']));
}
// --- END VALIDATION ---
// Set basic shipment details
$shipmentOrder->sequenceNumber = $dhlShipment->id;
// Set shipment details
$shipmentOrder->Shipment->ShipmentDetails->product = $productCode;
$shipmentOrder->Shipment->ShipmentDetails->accountNumber = $accountNumber;
$shipmentOrder->Shipment->ShipmentDetails->customerReference = ($isReturn ? 'Return-' : 'Order-') . $shipmentData['order_id'];
$shipmentOrder->Shipment->ShipmentDetails->shipmentDate = date('Y-m-d');
// --- ROBUST NAME & ADDRESS HANDLING ---
if ($isReturn) {
// RETURN LABEL: Customer ships TO warehouse
// Shipper = Customer (original recipient)
$this->setAddressBlock(
$shipmentOrder->Shipment->Shipper,
$shipmentData['recipient_name'],
$shipmentData['recipient_company'],
$shipmentData['recipient_street'],
$shipmentData['recipient_street_number'],
$shipmentData['recipient_postal_code'],
$shipmentData['recipient_city'],
$recipientCountry
);
// Receiver = Warehouse (original sender)
$this->setAddressBlock(
$shipmentOrder->Shipment->Receiver,
$this->config['sender']['name'] ?? '',
$this->config['sender']['company'] ?? '',
$this->config['sender']['street'] ?? '',
$this->config['sender']['street_number'] ?? '',
$this->config['sender']['postal_code'] ?? '',
$this->config['sender']['city'] ?? '',
$senderCountry,
true // isSender=true to throw exception on config error
);
} else {
// OUTBOUND LABEL: Warehouse ships TO customer (normal flow)
// Shipper = Warehouse
$this->setAddressBlock(
$shipmentOrder->Shipment->Shipper,
$this->config['sender']['name'] ?? '',
$this->config['sender']['company'] ?? '',
$this->config['sender']['street'] ?? '',
$this->config['sender']['street_number'] ?? '',
$this->config['sender']['postal_code'] ?? '',
$this->config['sender']['city'] ?? '',
$senderCountry,
true // isSender=true to throw exception on config error
);
// Receiver = Customer
$this->setAddressBlock(
$shipmentOrder->Shipment->Receiver,
$shipmentData['recipient_name'],
$shipmentData['recipient_company'],
$shipmentData['recipient_street'],
$shipmentData['recipient_street_number'],
$shipmentData['recipient_postal_code'],
$shipmentData['recipient_city'],
$recipientCountry
);
}
// Set package details
$shipmentOrder->Shipment->ShipmentDetails->ShipmentItem->weightInKG = $shipmentData['weight'];
$shipmentOrder->Shipment->ShipmentDetails->ShipmentItem->lengthInCM = $shipmentData['length'];
$shipmentOrder->Shipment->ShipmentDetails->ShipmentItem->widthInCM = $shipmentData['width'];
$shipmentOrder->Shipment->ShipmentDetails->ShipmentItem->heightInCM = $shipmentData['height'];
// Configure minimal services to avoid SDK issues with dynamic properties
$this->configureShipmentServices($shipmentOrder, $shipmentData);
return $shipmentOrder;
}
/**
* Sets the address and name details for a shipper or receiver block.
*
* @param Shipper|Receiver $addressBlock The Shipper or Receiver object from the SDK
* @param string $name
* @param string|null $company
* @param string $street
* @param string $streetNumber
* @param string $postalCode
* @param string $city
* @param string $countryCode
* @param bool $isSender
* @throws \Exception
*/
private function setAddressBlock(Shipper|Receiver &$addressBlock, string $name, ?string $company, string $street, string $streetNumber, string $postalCode, string $city, string $countryCode, bool $isSender = false): void
{
$name = trim($name);
$company = trim($company ?? '');
$name1 = $name;
$name2 = $company;
if (empty($name1)) {
// If personal name is empty, company MUST be name1
$name1 = $name2;
$name2 = '';
}
if (empty($name1)) {
if ($isSender) {
throw new \Exception('DHL Sender Name (name1) is not configured. Please set DHL_SENDER_NAME or DHL_SENDER_COMPANY in your .env file.');
}
return;
}
// Handle the structural difference between Shipper (has ->Name object) and Receiver (has ->name1 directly)
if ($addressBlock instanceof Shipper) {
if (is_null($addressBlock->Name)) $addressBlock->Name = new ShipperName();
$addressBlock->Name->name1 = $name1;
if (!empty($name2)) $addressBlock->Name->name2 = $name2;
if (is_null($addressBlock->Address)) $addressBlock->Address = new ShipperAddress();
if (is_null($addressBlock->Address->Origin)) $addressBlock->Address->Origin = new ShipperOrigin();
} elseif ($addressBlock instanceof Receiver) {
$addressBlock->name1 = $name1; // Assign directly
if (is_null($addressBlock->Address)) $addressBlock->Address = new ReceiverAddress();
if (is_null($addressBlock->Address->Origin)) $addressBlock->Address->Origin = new ReceiverOrigin();
}
$addressBlock->Address->streetName = $street;
$addressBlock->Address->streetNumber = $streetNumber;
$addressBlock->Address->zip = $postalCode;
$addressBlock->Address->city = $city;
$addressBlock->Address->Origin->countryISOCode = $countryCode;
}
/**
* Configure shipment services to avoid SDK dynamic property issues
*
* @param ShipmentOrder $shipmentOrder
* @param array $shipmentData
*/
private function configureShipmentServices(ShipmentOrder $shipmentOrder, array $shipmentData): void
{
// Initialize basic services that are commonly used and properly supported
$services = $shipmentData['services'] ?? [];
// Only configure services that are explicitly requested and known to work
// This prevents the SDK from creating dynamic properties like ShipmentHandling
if (isset($services['premium']) && $services['premium']) {
$shipmentOrder->Shipment->ShipmentDetails->Service->Premium->active = true;
}
if (isset($services['endorsement']) && $services['endorsement']) {
$shipmentOrder->Shipment->ShipmentDetails->Service->Endorsement->active = true;
$shipmentOrder->Shipment->ShipmentDetails->Service->Endorsement->type = $services['endorsement'];
}
if (isset($services['bulky_goods']) && $services['bulky_goods']) {
$shipmentOrder->Shipment->ShipmentDetails->Service->BulkyGoods->active = true;
}
if (isset($services['return_receipt']) && $services['return_receipt']) {
$shipmentOrder->Shipment->ShipmentDetails->Service->ReturnReceipt->active = true;
}
// Avoid problematic services that cause dynamic property warnings
// Do NOT set ShipmentHandling or other services that the SDK dynamically creates
$this->logInfo('Configured DHL services', [
'requested_services' => array_keys($services),
'product_code' => $shipmentData['product_code']
]);
}
/**
* Get the correct DHL account number for a given product code.
*
* @param string $productCode
* @return string
* @throws \Exception
*/
private function getAccountNumberForProduct(string $productCode): string
{
$productAccounts = $this->config['api']['product_accounts'] ?? [];
if (isset($productAccounts[$productCode]) && !empty($productAccounts[$productCode])) {
return $productAccounts[$productCode];
}
$defaultAccount = $this->config['api']['account_number_default'] ?? null;
if (!empty($defaultAccount)) {
return $defaultAccount;
}
throw new \Exception("DHL Abrechnungsnummer for product '{$productCode}' is not configured, and no default account number is set.");
}
/**
* Process shipment response from DHL API
*
* @param DhlShipment $dhlShipment
* @param CreateShipmentOrderResponse $response
* @param createShipmentOrder $request
* @throws Exception
*/
private function processShipmentResponse(DhlShipment $dhlShipment, $response, $request): void
{
// Store request and response data
$dhlShipment->update([
'api_request_data' => $this->extractRequestData($request),
'api_response_data' => $this->extractResponseData($response),
]);
$isSuccessful = $this->isResponseSuccessful($response);
$this->logInfo('Processing DHL shipment response', [
'shipment_id' => $dhlShipment->id,
'is_successful' => $isSuccessful,
'has_creation_states' => !empty($response->CreationStates),
'creation_states_count' => count($response->CreationStates ?? [])
]);
if ($isSuccessful) {
// Extract shipment and tracking information
$shipmentData = $this->extractShipmentData($response);
$this->logInfo('Extracted shipment data', [
'shipment_id' => $dhlShipment->id,
'shipment_number' => $shipmentData['shipment_number'] ?? 'not_found',
'has_label_data' => !empty($shipmentData['label_data'])
]);
// Validate that we got essential data
if (empty($shipmentData['shipment_number'])) {
$this->logError('No shipment number in successful response', new Exception('Missing shipment number'), [
'shipment_id' => $dhlShipment->id,
'response_data' => $this->extractResponseData($response)
]);
$dhlShipment->update([
'status' => DhlShipment::STATUS_FAILED,
'api_errors' => 'DHL API reported success but no shipment number was provided',
]);
throw new Exception('DHL API reported success but no shipment number was provided');
}
// Save label if provided
$labelPath = null;
if (isset($shipmentData['label_data']) && $shipmentData['label_data']) {
try {
$labelPath = $this->saveLabelFile($dhlShipment, $shipmentData['label_data']);
} catch (Exception $e) {
$this->logError('Failed to save label file', $e, ['shipment_id' => $dhlShipment->id]);
// Don't fail the whole process for label save issues
}
}
// Update shipment with success data
$dhlShipment->update([
'shipment_number' => $shipmentData['shipment_number'],
'tracking_number' => $shipmentData['tracking_number'] ?? $shipmentData['shipment_number'],
'label_path' => $labelPath,
'status' => DhlShipment::STATUS_SUBMITTED,
'shipped_at' => now(),
]);
} else {
// Handle API error
$errorMessage = $this->extractErrorMessage($response);
$this->logInfo('DHL API returned errors', [
'shipment_id' => $dhlShipment->id,
'error_message' => $errorMessage
]);
$dhlShipment->update([
'status' => DhlShipment::STATUS_FAILED,
'api_errors' => $errorMessage,
]);
throw new Exception('DHL API error: ' . $errorMessage);
}
}
/**
* Extract shipment data from response
*
* @param CreateShipmentOrderResponse $response
* @return array
*/
private function extractShipmentData($response): array
{
$data = [];
// Use the SDK's proper structure with CreationStates
if ($response instanceof CreateShipmentOrderResponse && !empty($response->CreationStates)) {
foreach ($response->CreationStates as $creationState) {
if (isset($creationState->shipmentNumber)) {
$data['shipment_number'] = $creationState->shipmentNumber;
}
if (isset($creationState->LabelData->labelData)) {
$data['label_data'] = $creationState->LabelData->labelData;
}
// Only process the first creation state for now (single shipment)
break;
}
}
return $data;
}
/**
* Check if response indicates success
*
* @param CreateShipmentOrderResponse|DeleteShipmentOrderResponse $response
* @return bool
*/
private function isResponseSuccessful($response): bool
{
// Use the SDK's built-in success check methods
if ($response instanceof CreateShipmentOrderResponse) {
return $response->hasNoErrors();
} elseif ($response instanceof DeleteShipmentOrderResponse) {
return $response->hasNoErrors();
}
return false;
}
/**
* Extract error message from response
*
* @param CreateShipmentOrderResponse|DeleteShipmentOrderResponse $response
* @return string
*/
private function extractErrorMessage($response): string
{
$messages = [];
// Check for top-level status messages
if (is_object($response) && property_exists($response, 'Status') && !empty($response->Status)) {
$statusArray = is_array($response->Status) ? $response->Status : [$response->Status];
foreach ($statusArray as $status) {
if (is_object($status)) {
// Try different status property names
$statusProps = ['statusText', 'statusMessage', 'statusCode'];
foreach ($statusProps as $prop) {
if (property_exists($status, $prop) && !empty($status->{$prop})) {
$value = $status->{$prop};
$messages[] = is_array($value) ? implode(', ', $value) : (string)$value;
break;
}
}
}
}
}
// Check for messages within CreationStates
if ($response instanceof CreateShipmentOrderResponse && !empty($response->CreationStates)) {
foreach ($response->CreationStates as $creationState) {
// Check LabelData Status
if (isset($creationState->LabelData->Status)) {
$statusArray = is_array($creationState->LabelData->Status)
? $creationState->LabelData->Status
: [$creationState->LabelData->Status];
foreach ($statusArray as $status) {
if (is_object($status)) {
$statusProps = ['statusText', 'statusMessage', 'statusCode'];
foreach ($statusProps as $prop) {
if (property_exists($status, $prop) && !empty($status->{$prop})) {
$value = $status->{$prop};
$messages[] = is_array($value) ? implode('; ', $value) : (string)$value;
break;
}
}
}
}
}
// Check direct status on CreationState
if (isset($creationState->sequenceNumber) && isset($creationState->LabelData)) {
// This might be a successful creation state, not an error
continue;
}
}
}
// If we still have no messages, check if this might actually be a success
if (empty($messages) && $response instanceof CreateShipmentOrderResponse) {
if ($response->hasNoErrors()) {
return 'No errors found - response appears successful';
} else {
// Try to extract any available information from the response
$responseData = $this->extractResponseData($response);
$this->logInfo('Could not extract error message, full response data:', $responseData ?? []);
return 'DHL API returned errors but no specific error message could be extracted. Check logs for full response.';
}
}
return !empty($messages) ? implode('; ', array_unique($messages)) : 'Unknown DHL API error';
}
/**
* Extract request data for logging
*
* @param createShipmentOrder|deleteShipmentOrder $request
* @return array|null
*/
private function extractRequestData($request): ?array
{
// Safely convert complex SDK objects to arrays for logging
return json_decode(json_encode($request), true);
}
/**
* Extract response data for logging
*
* @param CreateShipmentOrderResponse|DeleteShipmentOrderResponse $response
* @return array|null
*/
private function extractResponseData($response): ?array
{
// Safely convert complex SDK objects to arrays for logging
return json_decode(json_encode($response), true);
}
/**
* Save label file to storage
*
* @param DhlShipment $dhlShipment
* @param string $labelData Base64 encoded label data
* @return string The saved file path
*/
private function saveLabelFile(DhlShipment $dhlShipment, string $labelData): string
{
$filename = 'dhl_label_' . $dhlShipment->id . '_' . time() . '.pdf';
$path = 'dhl/labels/' . $filename;
Storage::put($path, base64_decode($labelData));
return $path;
}
/**
* Log info message
*
* @param string $message
* @param array $context
*/
private function logInfo(string $message, array $context = []): void
{
if ($this->config['logging']['enabled']) {
Log::info('[DHL API] ' . $message, $context);
}
}
/**
* Log error message
*
* @param string $message
* @param Exception $exception
* @param array $context
*/
private function logError(string $message, Exception $exception, array $context = []): void
{
if ($this->config['logging']['enabled']) {
Log::error('[DHL API] ' . $message, array_merge($context, [
'exception' => $exception->getMessage(),
'trace' => $exception->getTraceAsString(),
]));
}
}
/**
* Log detailed response structure for debugging
*
* @param CreateShipmentOrderResponse $response
*/
private function logResponseStructure($response): void
{
if (!$this->config['logging']['enabled']) {
return;
}
$structure = [
'class' => get_class($response),
'hasNoErrors' => $response->hasNoErrors(),
'properties' => []
];
// Get all public properties
$reflection = new \ReflectionClass($response);
foreach ($reflection->getProperties(\ReflectionProperty::IS_PUBLIC) as $property) {
$name = $property->getName();
try {
$value = $property->getValue($response);
if (is_object($value)) {
$structure['properties'][$name] = [
'type' => 'object',
'class' => get_class($value),
'properties' => $this->extractObjectProperties($value, 2) // Limit depth
];
} elseif (is_array($value)) {
$structure['properties'][$name] = [
'type' => 'array',
'count' => count($value),
'sample' => count($value) > 0 ? $this->extractObjectProperties($value[0], 1) : null
];
} else {
$structure['properties'][$name] = [
'type' => gettype($value),
'value' => $value
];
}
} catch (\Exception $e) {
$structure['properties'][$name] = [
'type' => 'error',
'error' => $e->getMessage()
];
}
}
Log::info('[DHL API] Response structure analysis:', $structure);
}
/**
* Extract object properties for debugging (with depth limit)
*
* @param mixed $object
* @param int $maxDepth
* @return array|mixed
*/
private function extractObjectProperties($object, int $maxDepth = 1)
{
if ($maxDepth <= 0 || !is_object($object)) {
return is_object($object) ? get_class($object) : $object;
}
$properties = [];
$reflection = new \ReflectionClass($object);
foreach ($reflection->getProperties(\ReflectionProperty::IS_PUBLIC) as $property) {
$name = $property->getName();
try {
$value = $property->getValue($object);
if (is_object($value)) {
$properties[$name] = $this->extractObjectProperties($value, $maxDepth - 1);
} elseif (is_array($value)) {
$properties[$name] = [
'type' => 'array',
'count' => count($value)
];
} else {
$properties[$name] = $value;
}
} catch (\Exception $e) {
$properties[$name] = 'Error: ' . $e->getMessage();
}
}
return $properties;
}
/**
* Check if service is properly configured
*
* @return bool
*/
public function isConfigured(): bool
{
$apiType = $this->config['api']['api_type'];
if ($apiType === 'developer') {
return !empty($this->config['api']['api_key']) &&
!empty($this->config['api']['api_secret']);
} else {
return !empty($this->config['api']['username']) &&
!empty($this->config['api']['password']) &&
!empty($this->config['api']['account_number']);
}
}
/**
* Test API connection
*
* @return array Test results
*/
public function testConnection(): array
{
try {
// Simple test - check if clients are initialized
if ($this->shippingClient) {
return [
'success' => true,
'message' => 'DHL API clients initialized successfully',
'sandbox' => $this->isSandbox,
'configured' => $this->isConfigured(),
'api_type' => $this->config['api']['api_type'],
];
} else {
throw new Exception('Shipping client not initialized');
}
} catch (Exception $e) {
return [
'success' => false,
'message' => 'DHL API connection failed: ' . $e->getMessage(),
'sandbox' => $this->isSandbox,
'configured' => $this->isConfigured(),
];
}
}
}