DHL Modul v0.5 Shipping Label ok

This commit is contained in:
Kevin Adametz 2025-08-22 18:18:26 +02:00
parent 480fdc65ed
commit 8fdaa0ba1d
122 changed files with 17938 additions and 2239 deletions

View file

@ -0,0 +1,480 @@
<?php
namespace Acme\Dhl\Services;
use Acme\Dhl\Support\DhlClient;
use Acme\Dhl\Models\DhlShipment;
use Illuminate\Support\Facades\Storage;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Validator;
use InvalidArgumentException;
use Exception;
use Acme\Dhl\Jobs\CreateShipmentJob;
use Illuminate\Support\Facades\Log;
/**
* DHL Shipping Service for creating and managing shipment labels
*/
class ShippingService
{
public function __construct(protected DhlClient $client) {}
/**
* Create a new DHL shipment label
*
* @param array $orderData Order and shipping data
* @return array Shipment details including number and label path
* @throws InvalidArgumentException When required data is missing
* @throws Exception When API request fails
*/
public function createLabel(array $orderData): array
{
Log::info('createLabel', $orderData);
$validatedData = $this->validateOrderData($orderData);
if (config('dhl.use_queue')) {
CreateShipmentJob::dispatch($validatedData);
return ['queued' => true];
}
return DB::transaction(function () use ($validatedData) {
$payload = $this->buildShipmentPayload($validatedData);
// Debug logging: Log the exact payload being sent to DHL API
Log::info('[DHL API] Sending payload to DHL', [
'endpoint' => '/parcel/de/shipping/v2/orders',
'payload' => $payload,
'payload_json' => json_encode($payload, JSON_PRETTY_PRINT | JSON_UNESCAPED_UNICODE)
]);
try {
// Build query parameters for print format
$query = array_filter([
'printFormat' => $validatedData['print_format'] ?? null,
'retourePrintFormat' => $validatedData['retoure_print_format'] ?? null,
]);
$response = $this->client->request('post', '/parcel/de/shipping/v2/orders', $payload, $query);
Log::info('[DHL API] Response received', [
'response' => $response
]);
} catch (Exception $e) {
Log::error('[DHL API] Request failed', [
'error' => $e->getMessage(),
'payload' => $payload
]);
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 || !$shipment->canCancel()) {
throw new InvalidArgumentException('Shipment cannot be canceled');
}
$this->client->request('delete', "/parcel/de/shipping/v2/orders/{$shipmentNumber}");
$shipment->update(['status' => 'canceled']);
Log::info('Canceled shipment', ['shipmentNumber' => $shipmentNumber]);
return true;
}
/**
* 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,V62WP,V07PAK',
'label_format' => 'nullable|string|in:PDF,ZPL',
'print_format' => 'nullable|string', // Values like A4, 910-300-700 etc.
'retoure_print_format' => 'nullable|string',
// 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|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',
// 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());
}
return $validator->validated();
}
/**
* 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,
'parsed_street' => $parsed['street'],
'parsed_houseNumber' => $parsed['houseNumber']
]);
} elseif (!$parsed['houseNumber']) {
// If we can't parse house number, use a default
$addressData['houseNumber'] = '1';
Log::warning('Could not parse house number from address, using default', [
'street' => $street
]);
}
return $addressData;
}
/**
* 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
{
$productCode = $orderData['product_code'] ?? config('dhl.default_product', 'V01PAK');
$billingNumber = $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'] ?? 'DE'),
'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) - separate street and house number as per official spec
'consignee' => array_filter([
'name1' => $orderData['consignee']['name'] ?? '',
'name2' => !empty($orderData['consignee']['name2']) ? $orderData['consignee']['name2'] : null,
'addressStreet' => $orderData['consignee']['street'] ?? '',
'addressHouse' => $orderData['consignee']['houseNumber'] ?? null,
'postalCode' => $orderData['consignee']['postalCode'] ?? '',
'city' => $orderData['consignee']['city'] ?? '',
'country' => $this->convertCountryCode($orderData['consignee']['country'] ?? 'DE'),
'email' => !empty($orderData['consignee']['email']) ? $orderData['consignee']['email'] : null,
'phone' => !empty($orderData['consignee']['phone']) ? $orderData['consignee']['phone'] : null,
], function ($value) {
return $value !== null;
}),
'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;
}
/**
* Convert 2-letter country code to 3-letter country code for DHL API
*/
private function convertCountryCode(string $countryCode): string
{
$countryMap = [
'DE' => 'DEU',
'AT' => 'AUT',
'CH' => 'CHE',
'US' => 'USA',
'GB' => 'GBR',
'FR' => 'FRA',
'IT' => 'ITA',
'ES' => 'ESP',
'NL' => 'NLD',
'BE' => 'BEL',
'PL' => 'POL',
'CZ' => 'CZE',
'DK' => 'DNK',
'SE' => 'SWE',
'NO' => 'NOR',
];
return $countryMap[strtoupper($countryCode)] ?? 'DEU';
}
/**
* Get the correct billing number for the given product code
*/
private function getBillingNumberForProduct(string $productCode): string
{
// Try to get account number from config by product code
$accountNumber = config("dhl.account_numbers.{$productCode}");
if ($accountNumber) {
return $accountNumber;
}
// Try to get from admin settings via Setting model
try {
$settingKey = 'dhl_account_' . strtolower($productCode);
$accountNumber = \App\Models\Setting::getContentBySlug($settingKey);
if ($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()
]);
}
// 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
{
return DhlShipment::create([
'order_id' => $orderData['order_id'] ?? null,
'dhl_shipment_no' => $shipmentNumber,
'routing_code' => $this->extractRoutingCode($response),
'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
]);
}
/**
* 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;
}
}