921 lines
36 KiB
PHP
921 lines
36 KiB
PHP
<?php
|
|
|
|
namespace Acme\Dhl\Services;
|
|
|
|
use Acme\Dhl\Exceptions\DhlAddressValidationException;
|
|
use Acme\Dhl\Exceptions\DhlValidationException;
|
|
use Acme\Dhl\Jobs\CreateShipmentJob;
|
|
use Acme\Dhl\Models\DhlShipment;
|
|
use Acme\Dhl\Support\DhlClient;
|
|
use App\Services\DhlProductResolver;
|
|
use App\Services\DhlShipmentService;
|
|
use App\Services\DhlShipmentWeightCalculator;
|
|
use Exception;
|
|
use Illuminate\Support\Facades\DB;
|
|
use Illuminate\Support\Facades\Log;
|
|
use Illuminate\Support\Facades\Storage;
|
|
use Illuminate\Support\Facades\Validator;
|
|
use InvalidArgumentException;
|
|
|
|
/**
|
|
* 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('[DHL Shipping] createLabel called', DhlShipmentService::sanitizeOrderDataForLog($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);
|
|
|
|
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<string, mixed> $payload
|
|
* @param array<string, mixed> $validatedData
|
|
* @return array<string, mixed>
|
|
*/
|
|
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<string, mixed> $response
|
|
* @return array<string, mixed>
|
|
*/
|
|
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;
|
|
}
|
|
}
|