DHL Modul v0.5 Shipping Label ok
This commit is contained in:
parent
480fdc65ed
commit
8fdaa0ba1d
122 changed files with 17938 additions and 2239 deletions
40
packages/acme-laravel-dhl/src/DhlManager.php
Normal file
40
packages/acme-laravel-dhl/src/DhlManager.php
Normal file
|
|
@ -0,0 +1,40 @@
|
|||
<?php
|
||||
|
||||
namespace Acme\Dhl;
|
||||
|
||||
class DhlManager
|
||||
{
|
||||
public function __construct(protected Support\DhlClient $client, protected Services\ShippingService $shipping, protected Services\TrackingService $tracking, protected Services\ReturnsService $returns) {}
|
||||
/**
|
||||
* @param array $orderData
|
||||
* @return array
|
||||
*/
|
||||
public function createLabel(array $orderData): array
|
||||
{
|
||||
return $this->shipping->createLabel($orderData);
|
||||
}
|
||||
/**
|
||||
* @param string $shipmentNumber
|
||||
* @return bool
|
||||
*/
|
||||
public function cancelLabel(string $shipmentNumber): bool
|
||||
{
|
||||
return $this->shipping->cancelLabel($shipmentNumber);
|
||||
}
|
||||
/**
|
||||
* @param string $trackingNumber
|
||||
* @return array
|
||||
*/
|
||||
public function track(string $trackingNumber): array
|
||||
{
|
||||
return $this->tracking->fetchStatus($trackingNumber);
|
||||
}
|
||||
/**
|
||||
* @param array $returnData
|
||||
* @return array
|
||||
*/
|
||||
public function createReturn(array $returnData): array
|
||||
{
|
||||
return $this->returns->createReturn($returnData);
|
||||
}
|
||||
}
|
||||
55
packages/acme-laravel-dhl/src/DhlServiceProvider.php
Normal file
55
packages/acme-laravel-dhl/src/DhlServiceProvider.php
Normal file
|
|
@ -0,0 +1,55 @@
|
|||
<?php
|
||||
|
||||
namespace Acme\Dhl;
|
||||
|
||||
use Illuminate\Support\ServiceProvider;
|
||||
|
||||
class DhlServiceProvider extends ServiceProvider
|
||||
{
|
||||
public function register(): void
|
||||
{
|
||||
$this->mergeConfigFrom(__DIR__ . '/../config/dhl.php', 'dhl');
|
||||
|
||||
// Bind DhlClient as a singleton
|
||||
$this->app->singleton(Support\DhlClient::class, function ($app) {
|
||||
return new Support\DhlClient(
|
||||
config('dhl.base_url'),
|
||||
config('dhl.api_key'),
|
||||
config('dhl.username'),
|
||||
config('dhl.password')
|
||||
);
|
||||
});
|
||||
|
||||
// Bind services as singletons
|
||||
$this->app->singleton(Services\ShippingService::class, function ($app) {
|
||||
return new Services\ShippingService($app->make(Support\DhlClient::class));
|
||||
});
|
||||
|
||||
$this->app->singleton(Services\TrackingService::class, function ($app) {
|
||||
return new Services\TrackingService($app->make(Support\DhlClient::class));
|
||||
});
|
||||
|
||||
$this->app->singleton(Services\ReturnsService::class, function ($app) {
|
||||
return new Services\ReturnsService($app->make(Support\DhlClient::class));
|
||||
});
|
||||
|
||||
// Bind the main manager class
|
||||
$this->app->singleton(DhlManager::class, function ($app) {
|
||||
return new DhlManager(
|
||||
$app->make(Support\DhlClient::class),
|
||||
$app->make(Services\ShippingService::class),
|
||||
$app->make(Services\TrackingService::class),
|
||||
$app->make(Services\ReturnsService::class)
|
||||
);
|
||||
});
|
||||
}
|
||||
public function boot(): void
|
||||
{
|
||||
$this->publishes([__DIR__ . '/../config/dhl.php' => config_path('dhl.php')], 'dhl-config');
|
||||
$this->publishes([__DIR__ . '/../database/migrations/' => database_path('migrations')], 'dhl-migrations');
|
||||
$this->loadMigrationsFrom(__DIR__ . '/../database/migrations');
|
||||
if (config('dhl.webhook.enabled')) {
|
||||
$this->loadRoutesFrom(__DIR__ . '/../routes/api.php');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,8 @@
|
|||
<?php
|
||||
|
||||
namespace Acme\Dhl\Exceptions;
|
||||
|
||||
class DhlApiException extends \Exception
|
||||
{
|
||||
//
|
||||
}
|
||||
|
|
@ -0,0 +1,8 @@
|
|||
<?php
|
||||
|
||||
namespace Acme\Dhl\Exceptions;
|
||||
|
||||
class DhlAuthenticationException extends DhlApiException
|
||||
{
|
||||
//
|
||||
}
|
||||
|
|
@ -0,0 +1,8 @@
|
|||
<?php
|
||||
|
||||
namespace Acme\Dhl\Exceptions;
|
||||
|
||||
class DhlValidationException extends DhlApiException
|
||||
{
|
||||
//
|
||||
}
|
||||
13
packages/acme-laravel-dhl/src/Facades/DHL.php
Normal file
13
packages/acme-laravel-dhl/src/Facades/DHL.php
Normal file
|
|
@ -0,0 +1,13 @@
|
|||
<?php
|
||||
|
||||
namespace Acme\Dhl\Facades;
|
||||
|
||||
use Illuminate\Support\Facades\Facade;
|
||||
|
||||
class DHL extends Facade
|
||||
{
|
||||
protected static function getFacadeAccessor()
|
||||
{
|
||||
return \Acme\Dhl\DhlManager::class;
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,43 @@
|
|||
<?php
|
||||
|
||||
namespace Acme\Dhl\Http\Controllers;
|
||||
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Routing\Controller;
|
||||
use Illuminate\Support\Facades\Log;
|
||||
use Acme\Dhl\Models\DhlShipment;
|
||||
use Acme\Dhl\Models\DhlTrackingEvent;
|
||||
|
||||
class TrackingWebhookController extends Controller
|
||||
{
|
||||
public function handle(Request $r)
|
||||
{
|
||||
$secret = config('dhl.webhook.secret');
|
||||
if ($secret && $r->header('X-DHL-Secret') !== $secret) {
|
||||
abort(401, 'Invalid webhook secret');
|
||||
}
|
||||
$payload = $r->all();
|
||||
Log::info('DHL webhook received', ['payload' => $payload]);
|
||||
$events = data_get($payload, 'events', []);
|
||||
foreach ($events as $e) {
|
||||
$tracking = $e['trackingNumber'] ?? null;
|
||||
if (!$tracking) continue;
|
||||
$s = DhlShipment::firstOrCreate(['dhl_shipment_no' => $tracking], ['carrier' => 'dhl', 'status' => 'unknown',]);
|
||||
DhlTrackingEvent::create(['shipment_id' => $s->id, 'status_code' => $e['statusCode'] ?? null, 'status_text' => $e['status'] ?? null, 'location' => data_get($e, 'location'), 'event_time' => $e['timestamp'] ?? now(), 'raw' => $e,]);
|
||||
$statusCode = $e['statusCode'] ?? '';
|
||||
$newStatus = match (strtolower($statusCode)) {
|
||||
'delivered' => 'delivered',
|
||||
'returned' => 'returned',
|
||||
'exception', 'failed' => 'failed',
|
||||
'in_transit', 'transit' => 'in_transit',
|
||||
default => $s->status
|
||||
};
|
||||
if ($newStatus !== $s->status) {
|
||||
$s->status = $newStatus;
|
||||
$s->save();
|
||||
Log::info('Webhook updated shipment status', ['tracking' => $tracking, 'newStatus' => $newStatus]);
|
||||
}
|
||||
}
|
||||
return response()->json(['ok' => true]);
|
||||
}
|
||||
}
|
||||
29
packages/acme-laravel-dhl/src/Jobs/CreateReturnLabelJob.php
Normal file
29
packages/acme-laravel-dhl/src/Jobs/CreateReturnLabelJob.php
Normal file
|
|
@ -0,0 +1,29 @@
|
|||
<?php
|
||||
|
||||
namespace Acme\Dhl\Jobs;
|
||||
|
||||
use Illuminate\Bus\Queueable;
|
||||
use Illuminate\Contracts\Queue\ShouldQueue;
|
||||
use Illuminate\Foundation\Bus\Dispatchable;
|
||||
use Illuminate\Queue\InteractsWithQueue;
|
||||
use Illuminate\Queue\SerializesModels;
|
||||
use Acme\Dhl\Services\ReturnsService;
|
||||
use Illuminate\Support\Facades\Log;
|
||||
|
||||
class CreateReturnLabelJob implements ShouldQueue
|
||||
{
|
||||
use Dispatchable, InteractsWithQueue, Queueable, SerializesModels;
|
||||
|
||||
public function __construct(public array $returnData) {}
|
||||
|
||||
public function handle(ReturnsService $service): void
|
||||
{
|
||||
try {
|
||||
$service->createReturn($this->returnData);
|
||||
Log::info('Queued return label creation successful');
|
||||
} catch (\Exception $e) {
|
||||
Log::error('Queued return label creation failed', ['error' => $e->getMessage()]);
|
||||
$this->fail($e);
|
||||
}
|
||||
}
|
||||
}
|
||||
29
packages/acme-laravel-dhl/src/Jobs/CreateShipmentJob.php
Normal file
29
packages/acme-laravel-dhl/src/Jobs/CreateShipmentJob.php
Normal file
|
|
@ -0,0 +1,29 @@
|
|||
<?php
|
||||
|
||||
namespace Acme\Dhl\Jobs;
|
||||
|
||||
use Illuminate\Bus\Queueable;
|
||||
use Illuminate\Contracts\Queue\ShouldQueue;
|
||||
use Illuminate\Foundation\Bus\Dispatchable;
|
||||
use Illuminate\Queue\InteractsWithQueue;
|
||||
use Illuminate\Queue\SerializesModels;
|
||||
use Acme\Dhl\Services\ShippingService;
|
||||
use Illuminate\Support\Facades\Log;
|
||||
|
||||
class CreateShipmentJob implements ShouldQueue
|
||||
{
|
||||
use Dispatchable, InteractsWithQueue, Queueable, SerializesModels;
|
||||
|
||||
public function __construct(public array $orderData) {}
|
||||
|
||||
public function handle(ShippingService $service): void
|
||||
{
|
||||
try {
|
||||
$service->createLabel($this->orderData);
|
||||
Log::info('Queued shipment creation successful');
|
||||
} catch (\Exception $e) {
|
||||
Log::error('Queued shipment creation failed', ['error' => $e->getMessage()]);
|
||||
$this->fail($e);
|
||||
}
|
||||
}
|
||||
}
|
||||
29
packages/acme-laravel-dhl/src/Jobs/SyncTrackingJob.php
Normal file
29
packages/acme-laravel-dhl/src/Jobs/SyncTrackingJob.php
Normal file
|
|
@ -0,0 +1,29 @@
|
|||
<?php
|
||||
|
||||
namespace Acme\Dhl\Jobs;
|
||||
|
||||
use Illuminate\Bus\Queueable;
|
||||
use Illuminate\Contracts\Queue\ShouldQueue;
|
||||
use Illuminate\Foundation\Bus\Dispatchable;
|
||||
use Illuminate\Queue\InteractsWithQueue;
|
||||
use Illuminate\Queue\SerializesModels;
|
||||
use Acme\Dhl\Services\TrackingService;
|
||||
use Illuminate\Support\Facades\Log;
|
||||
|
||||
class SyncTrackingJob implements ShouldQueue
|
||||
{
|
||||
use Dispatchable, InteractsWithQueue, Queueable, SerializesModels;
|
||||
public function __construct(public array $trackingNumbers) {}
|
||||
public function handle(TrackingService $t): void
|
||||
{
|
||||
foreach ($this->trackingNumbers as $trackingNumber) {
|
||||
try {
|
||||
$t->fetchStatus($trackingNumber);
|
||||
Log::info('Synced tracking', ['trackingNumber' => $trackingNumber]);
|
||||
} catch (\Exception $e) {
|
||||
Log::error('Tracking sync failed', ['trackingNumber' => $trackingNumber, 'error' => $e->getMessage()]);
|
||||
$this->fail($e);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
149
packages/acme-laravel-dhl/src/Models/DhlShipment.php
Normal file
149
packages/acme-laravel-dhl/src/Models/DhlShipment.php
Normal file
|
|
@ -0,0 +1,149 @@
|
|||
<?php
|
||||
|
||||
namespace Acme\Dhl\Models;
|
||||
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Database\Eloquent\Relations\HasMany;
|
||||
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||
use App\Models\ShoppingOrder;
|
||||
|
||||
/**
|
||||
* DHL Shipment Model for both outbound shipments and returns
|
||||
*/
|
||||
class DhlShipment extends Model
|
||||
{
|
||||
protected $table = 'dhl_package_shipments';
|
||||
|
||||
protected $fillable = [
|
||||
'order_id',
|
||||
'dhl_shipment_no',
|
||||
'routing_code',
|
||||
'type',
|
||||
'related_shipment_id',
|
||||
'product_code',
|
||||
'billing_number',
|
||||
'weight_kg',
|
||||
'label_format',
|
||||
'label_path',
|
||||
'status',
|
||||
'tracking_status',
|
||||
'last_tracked_at',
|
||||
'api_response_data'
|
||||
];
|
||||
|
||||
protected $casts = [
|
||||
'api_response_data' => 'array',
|
||||
'last_tracked_at' => 'datetime',
|
||||
'weight_kg' => 'decimal:3'
|
||||
];
|
||||
|
||||
public const STATUS_MAP = [
|
||||
'pre-transit' => 'created',
|
||||
'transit' => 'in_transit',
|
||||
'out_for_delivery' => 'out_for_delivery',
|
||||
'delivered' => 'delivered',
|
||||
'exception' => 'exception',
|
||||
'returned' => 'returned',
|
||||
'failed' => 'failed',
|
||||
'unknown' => 'unknown'
|
||||
];
|
||||
|
||||
/**
|
||||
* Get the tracking events for this shipment
|
||||
*/
|
||||
public function trackingEvents(): HasMany
|
||||
{
|
||||
return $this->hasMany(DhlTrackingEvent::class, 'shipment_id');
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the related shopping order for this shipment
|
||||
*/
|
||||
public function shoppingOrder(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(ShoppingOrder::class, 'order_id');
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the related shipment (for returns)
|
||||
*/
|
||||
public function relatedShipment(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(self::class, 'related_shipment_id');
|
||||
}
|
||||
|
||||
/**
|
||||
* Get returns for this shipment (if this is an outbound shipment)
|
||||
*/
|
||||
public function returns(): HasMany
|
||||
{
|
||||
return $this->hasMany(self::class, 'related_shipment_id');
|
||||
}
|
||||
|
||||
/**
|
||||
* Scope for outbound shipments
|
||||
*/
|
||||
public function scopeOutbound($query)
|
||||
{
|
||||
return $query->where('type', 'outbound');
|
||||
}
|
||||
|
||||
/**
|
||||
* Scope for return shipments
|
||||
*/
|
||||
public function scopeReturns($query)
|
||||
{
|
||||
return $query->where('type', 'return');
|
||||
}
|
||||
|
||||
/**
|
||||
* Scope for shipments by order
|
||||
*/
|
||||
public function scopeForOrder($query, int $orderId)
|
||||
{
|
||||
return $query->where('order_id', $orderId);
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if this is a return shipment
|
||||
*/
|
||||
public function isReturn(): bool
|
||||
{
|
||||
return $this->type === 'return';
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if this is an outbound shipment
|
||||
*/
|
||||
public function isOutbound(): bool
|
||||
{
|
||||
return $this->type === 'outbound';
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the latest tracking status
|
||||
*/
|
||||
public function getLatestStatus(): ?string
|
||||
{
|
||||
return $this->trackingEvents()
|
||||
->orderBy('event_time', 'desc')
|
||||
->first()
|
||||
?->status_text;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if shipment can be canceled
|
||||
*/
|
||||
public function canCancel(): bool
|
||||
{
|
||||
return in_array($this->status, ['created', 'pending']);
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if shipment is delivered
|
||||
*/
|
||||
public function isDelivered(): bool
|
||||
{
|
||||
return $this->status === 'delivered';
|
||||
}
|
||||
}
|
||||
52
packages/acme-laravel-dhl/src/Models/DhlTrackingEvent.php
Normal file
52
packages/acme-laravel-dhl/src/Models/DhlTrackingEvent.php
Normal file
|
|
@ -0,0 +1,52 @@
|
|||
<?php
|
||||
|
||||
namespace Acme\Dhl\Models;
|
||||
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||
|
||||
/**
|
||||
* DHL Tracking Event Model for detailed tracking history
|
||||
*/
|
||||
class DhlTrackingEvent extends Model
|
||||
{
|
||||
protected $table = 'dhl_package_tracking_events';
|
||||
|
||||
protected $fillable = [
|
||||
'shipment_id',
|
||||
'status_code',
|
||||
'status_text',
|
||||
'location',
|
||||
'event_time',
|
||||
'raw'
|
||||
];
|
||||
|
||||
protected $casts = [
|
||||
'raw' => 'array',
|
||||
'event_time' => 'datetime'
|
||||
];
|
||||
|
||||
/**
|
||||
* Get the shipment this event belongs to
|
||||
*/
|
||||
public function shipment(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(DhlShipment::class, 'shipment_id');
|
||||
}
|
||||
|
||||
/**
|
||||
* Scope for events ordered by time
|
||||
*/
|
||||
public function scopeOrderedByTime($query, string $direction = 'desc')
|
||||
{
|
||||
return $query->orderBy('event_time', $direction);
|
||||
}
|
||||
|
||||
/**
|
||||
* Scope for recent events
|
||||
*/
|
||||
public function scopeRecent($query, int $days = 30)
|
||||
{
|
||||
return $query->where('event_time', '>=', now()->subDays($days));
|
||||
}
|
||||
}
|
||||
10
packages/acme-laravel-dhl/src/Models/ReturnLabel.php
Normal file
10
packages/acme-laravel-dhl/src/Models/ReturnLabel.php
Normal file
|
|
@ -0,0 +1,10 @@
|
|||
<?php
|
||||
|
||||
namespace Acme\Dhl\Models;
|
||||
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
|
||||
class ReturnLabel extends Model
|
||||
{
|
||||
protected $fillable = ['order_id', 'dhl_return_no', 'label_path'];
|
||||
}
|
||||
19
packages/acme-laravel-dhl/src/Models/Shipment.php
Normal file
19
packages/acme-laravel-dhl/src/Models/Shipment.php
Normal file
|
|
@ -0,0 +1,19 @@
|
|||
<?php
|
||||
|
||||
namespace Acme\Dhl\Models;
|
||||
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
|
||||
class Shipment extends Model
|
||||
{
|
||||
protected $fillable = ['order_id', 'carrier', 'dhl_shipment_no', 'product_code', 'billing_number', 'weight_kg', 'status', 'label_format', 'label_path', 'meta'];
|
||||
protected $casts = ['meta' => 'array',];
|
||||
public function labels()
|
||||
{
|
||||
return $this->hasMany(ShipmentLabel::class);
|
||||
}
|
||||
public function events()
|
||||
{
|
||||
return $this->hasMany(TrackingEvent::class);
|
||||
}
|
||||
}
|
||||
14
packages/acme-laravel-dhl/src/Models/ShipmentLabel.php
Normal file
14
packages/acme-laravel-dhl/src/Models/ShipmentLabel.php
Normal file
|
|
@ -0,0 +1,14 @@
|
|||
<?php
|
||||
|
||||
namespace Acme\Dhl\Models;
|
||||
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
|
||||
class ShipmentLabel extends Model
|
||||
{
|
||||
protected $fillable = ['shipment_id', 'format', 'path'];
|
||||
public function shipment()
|
||||
{
|
||||
return $this->belongsTo(Shipment::class);
|
||||
}
|
||||
}
|
||||
16
packages/acme-laravel-dhl/src/Models/TrackingEvent.php
Normal file
16
packages/acme-laravel-dhl/src/Models/TrackingEvent.php
Normal file
|
|
@ -0,0 +1,16 @@
|
|||
<?php
|
||||
|
||||
namespace Acme\Dhl\Models;
|
||||
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
|
||||
class TrackingEvent extends Model
|
||||
{
|
||||
public $timestamps = false;
|
||||
protected $fillable = ['shipment_id', 'status_code', 'status_text', 'location', 'event_time', 'raw'];
|
||||
protected $casts = ['raw' => 'array', 'event_time' => 'datetime',];
|
||||
public function shipment()
|
||||
{
|
||||
return $this->belongsTo(Shipment::class);
|
||||
}
|
||||
}
|
||||
180
packages/acme-laravel-dhl/src/Services/ReturnsService.php
Normal file
180
packages/acme-laravel-dhl/src/Services/ReturnsService.php
Normal file
|
|
@ -0,0 +1,180 @@
|
|||
<?php
|
||||
|
||||
namespace Acme\Dhl\Services;
|
||||
|
||||
use Acme\Dhl\Support\DhlClient;
|
||||
use Acme\Dhl\Models\DhlShipment;
|
||||
use Illuminate\Support\Facades\Storage;
|
||||
use InvalidArgumentException;
|
||||
use Exception;
|
||||
use Acme\Dhl\Jobs\CreateReturnLabelJob;
|
||||
use Illuminate\Support\Facades\Log;
|
||||
use Illuminate\Support\Facades\Validator;
|
||||
|
||||
/**
|
||||
* DHL Returns Service for creating and managing return labels
|
||||
*/
|
||||
class ReturnsService
|
||||
{
|
||||
public function __construct(protected DhlClient $client) {}
|
||||
|
||||
/**
|
||||
* Create a return label for a shipment
|
||||
*
|
||||
* @param array $returnData Return shipment data
|
||||
* @return array Return label details including number and path
|
||||
* @throws InvalidArgumentException When required data is missing
|
||||
* @throws Exception When API request fails
|
||||
*/
|
||||
public function createReturn(array $returnData): array
|
||||
{
|
||||
$validatedData = $this->validateReturnData($returnData);
|
||||
if (config('dhl.use_queue')) {
|
||||
CreateReturnLabelJob::dispatch($validatedData);
|
||||
return ['queued' => true];
|
||||
}
|
||||
|
||||
$payload = $this->buildReturnPayload($returnData);
|
||||
$response = $this->client->request('post', '/parcel/de/returns/v1/labels', $payload);
|
||||
|
||||
$returnNumber = $this->extractReturnNumber($response);
|
||||
$labelBase64 = $this->extractLabelData($response);
|
||||
|
||||
$labelPath = $this->saveLabelFile($returnNumber, $labelBase64, $payload['labelFormat']);
|
||||
$returnShipment = $this->createReturnRecord($returnData, $returnNumber, $labelPath, $response);
|
||||
|
||||
Log::info('Created return label', ['returnNumber' => $returnNumber]);
|
||||
|
||||
return [
|
||||
'returnNumber' => $returnNumber,
|
||||
'label_path' => $labelPath,
|
||||
'returnShipment' => $returnShipment,
|
||||
'raw' => $response
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Get return shipment by return number
|
||||
*
|
||||
* @param string $returnNumber DHL return number
|
||||
* @return DhlShipment|null Return shipment model or null if not found
|
||||
*/
|
||||
public function getReturnShipment(string $returnNumber): ?DhlShipment
|
||||
{
|
||||
return DhlShipment::where('dhl_shipment_no', $returnNumber)
|
||||
->where('type', 'return')
|
||||
->first();
|
||||
}
|
||||
|
||||
/**
|
||||
* Get all return shipments for an order
|
||||
*
|
||||
* @param int $orderId Order ID
|
||||
* @return \Illuminate\Database\Eloquent\Collection Return shipments collection
|
||||
*/
|
||||
public function getOrderReturns(int $orderId): \Illuminate\Database\Eloquent\Collection
|
||||
{
|
||||
return DhlShipment::where('order_id', $orderId)
|
||||
->where('type', 'return')
|
||||
->get();
|
||||
}
|
||||
|
||||
/**
|
||||
* Get returns for a specific outbound shipment
|
||||
*
|
||||
* @param int $shipmentId Original outbound shipment ID
|
||||
* @return \Illuminate\Database\Eloquent\Collection Related return shipments
|
||||
*/
|
||||
public function getShipmentReturns(int $shipmentId): \Illuminate\Database\Eloquent\Collection
|
||||
{
|
||||
return DhlShipment::where('related_shipment_id', $shipmentId)
|
||||
->where('type', 'return')
|
||||
->get();
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate required return data
|
||||
*/
|
||||
private function validateReturnData(array $data): array
|
||||
{
|
||||
$validator = Validator::make($data, [
|
||||
'original_shipment_id' => 'nullable|integer|exists:dhl_shipments,id',
|
||||
'weight_kg' => 'nullable|numeric|min:0.1',
|
||||
'shipper' => 'required|array',
|
||||
'shipper.name' => 'required|string|max:50',
|
||||
// Add similar rules as in ShippingService
|
||||
'consignee' => 'required|array',
|
||||
'consignee.name' => 'required|string|max:50',
|
||||
// ... more fields
|
||||
]);
|
||||
if ($validator->fails()) {
|
||||
throw new InvalidArgumentException($validator->errors()->first());
|
||||
}
|
||||
if (empty(config('dhl.billing_number'))) {
|
||||
throw new InvalidArgumentException('DHL billing number must be configured');
|
||||
}
|
||||
return $validator->validated();
|
||||
}
|
||||
|
||||
/**
|
||||
* Build DHL return API payload
|
||||
*/
|
||||
private function buildReturnPayload(array $returnData): array
|
||||
{
|
||||
return [
|
||||
'labelFormat' => $returnData['label_format'] ?? 'PDF',
|
||||
'shipper' => $returnData['shipper'],
|
||||
'consignee' => $returnData['consignee'],
|
||||
'billingNumber' => config('dhl.billing_number')
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract return number from API response
|
||||
*/
|
||||
private function extractReturnNumber(array $response): ?string
|
||||
{
|
||||
return data_get($response, 'returnShipmentNo')
|
||||
?? data_get($response, 'shipments.0.returnShipmentNo');
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract base64 label data from API response
|
||||
*/
|
||||
private function extractLabelData(array $response): ?string
|
||||
{
|
||||
return data_get($response, 'label');
|
||||
}
|
||||
|
||||
/**
|
||||
* Save return label file to storage
|
||||
*/
|
||||
private function saveLabelFile(?string $returnNumber, ?string $labelBase64, string $format): ?string
|
||||
{
|
||||
if (!$labelBase64 || !$returnNumber) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$path = 'dhl/returns/' . $returnNumber . '.' . strtolower($format);
|
||||
Storage::disk('local')->put($path, base64_decode($labelBase64));
|
||||
|
||||
return $path;
|
||||
}
|
||||
|
||||
/**
|
||||
* Create return shipment database record
|
||||
*/
|
||||
private function createReturnRecord(array $returnData, ?string $returnNumber, ?string $labelPath, array $response): DhlShipment
|
||||
{
|
||||
return DhlShipment::create([
|
||||
'order_id' => $returnData['order_id'] ?? null,
|
||||
'dhl_shipment_no' => $returnNumber,
|
||||
'type' => 'return',
|
||||
'related_shipment_id' => $returnData['original_shipment_id'] ?? null,
|
||||
'label_format' => $returnData['label_format'] ?? 'PDF',
|
||||
'label_path' => $labelPath,
|
||||
'status' => 'created',
|
||||
'api_response_data' => $response
|
||||
]);
|
||||
}
|
||||
}
|
||||
480
packages/acme-laravel-dhl/src/Services/ShippingService.php
Normal file
480
packages/acme-laravel-dhl/src/Services/ShippingService.php
Normal 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;
|
||||
}
|
||||
}
|
||||
180
packages/acme-laravel-dhl/src/Services/TrackingService.php
Normal file
180
packages/acme-laravel-dhl/src/Services/TrackingService.php
Normal file
|
|
@ -0,0 +1,180 @@
|
|||
<?php
|
||||
|
||||
namespace Acme\Dhl\Services;
|
||||
|
||||
use Acme\Dhl\Support\DhlClient;
|
||||
use Acme\Dhl\Models\DhlShipment;
|
||||
use Acme\Dhl\Models\DhlTrackingEvent;
|
||||
use InvalidArgumentException;
|
||||
use Exception;
|
||||
use Acme\Dhl\Jobs\SyncTrackingJob;
|
||||
use Illuminate\Support\Facades\Log;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
|
||||
/**
|
||||
* DHL Tracking Service for fetching and managing tracking information
|
||||
*/
|
||||
class TrackingService
|
||||
{
|
||||
public function __construct(protected DhlClient $client) {}
|
||||
|
||||
/**
|
||||
* Fetch tracking status for a shipment
|
||||
*
|
||||
* @param string $trackingNumber DHL tracking number
|
||||
* @return array Tracking data from DHL API
|
||||
* @throws InvalidArgumentException When tracking number is empty
|
||||
* @throws Exception When API request fails
|
||||
*/
|
||||
public function fetchStatus(string $trackingNumber): array
|
||||
{
|
||||
if (empty($trackingNumber)) {
|
||||
throw new InvalidArgumentException('Tracking number is required');
|
||||
}
|
||||
if (config('dhl.use_queue')) {
|
||||
SyncTrackingJob::dispatch($trackingNumber);
|
||||
return ['queued' => true];
|
||||
}
|
||||
|
||||
$response = $this->client->request('get', '/post-tracking/api/shipments', [], [
|
||||
'trackingNumber' => $trackingNumber
|
||||
]);
|
||||
Log::info('Fetched tracking status', ['trackingNumber' => $trackingNumber]);
|
||||
|
||||
$events = data_get($response, 'shipments.0.events', []);
|
||||
$shipment = $this->findOrCreateShipment($trackingNumber);
|
||||
|
||||
$this->updateTrackingEvents($shipment, $events);
|
||||
|
||||
return $response;
|
||||
}
|
||||
|
||||
/**
|
||||
* Update tracking status for multiple shipments
|
||||
*
|
||||
* @param array $trackingNumbers Array of tracking numbers to update
|
||||
* @return array Results for each tracking number
|
||||
*/
|
||||
public function updateMultipleStatus(array $trackingNumbers): array
|
||||
{
|
||||
$results = [];
|
||||
|
||||
foreach ($trackingNumbers as $trackingNumber) {
|
||||
try {
|
||||
$results[$trackingNumber] = $this->fetchStatus($trackingNumber);
|
||||
} catch (Exception $e) {
|
||||
Log::error('Tracking update failed', ['trackingNumber' => $trackingNumber, 'error' => $e->getMessage()]);
|
||||
$results[$trackingNumber] = ['error' => $e->getMessage()];
|
||||
}
|
||||
}
|
||||
|
||||
return $results;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get latest tracking status for a shipment
|
||||
*
|
||||
* @param string $trackingNumber DHL tracking number
|
||||
* @return array|null Latest tracking event or null if not found
|
||||
*/
|
||||
public function getLatestStatus(string $trackingNumber): ?array
|
||||
{
|
||||
$shipment = DhlShipment::where('dhl_shipment_no', $trackingNumber)->first();
|
||||
|
||||
if (!$shipment) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$latestEvent = $shipment->trackingEvents()
|
||||
->orderBy('event_time', 'desc')
|
||||
->first();
|
||||
|
||||
return $latestEvent ? $latestEvent->toArray() : null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Find existing shipment or create new one
|
||||
*/
|
||||
private function findOrCreateShipment(string $trackingNumber): DhlShipment
|
||||
{
|
||||
return DB::transaction(function () use ($trackingNumber) {
|
||||
return DhlShipment::firstOrCreate(
|
||||
['dhl_shipment_no' => $trackingNumber],
|
||||
[
|
||||
'type' => 'outbound',
|
||||
'status' => 'unknown'
|
||||
]
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Update tracking events for a shipment
|
||||
*/
|
||||
private function updateTrackingEvents(DhlShipment $shipment, array $events): void
|
||||
{
|
||||
foreach ($events as $eventData) {
|
||||
$this->createOrUpdateTrackingEvent($shipment, $eventData);
|
||||
}
|
||||
|
||||
// Update shipment status based on latest event
|
||||
$this->updateShipmentStatus($shipment, $events);
|
||||
}
|
||||
|
||||
/**
|
||||
* Create or update a tracking event
|
||||
*/
|
||||
private function createOrUpdateTrackingEvent(DhlShipment $shipment, array $eventData): void
|
||||
{
|
||||
DhlTrackingEvent::updateOrCreate(
|
||||
[
|
||||
'shipment_id' => $shipment->id,
|
||||
'status_code' => $eventData['statusCode'] ?? null,
|
||||
'event_time' => $eventData['timestamp'] ?? null
|
||||
],
|
||||
[
|
||||
'status_text' => $eventData['status'] ?? null,
|
||||
'location' => $this->extractLocation($eventData),
|
||||
'raw' => $eventData
|
||||
]
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract location from event data
|
||||
*/
|
||||
private function extractLocation(array $eventData): ?string
|
||||
{
|
||||
return data_get($eventData, 'location.address.addressLocality')
|
||||
?? data_get($eventData, 'location')
|
||||
?? null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Update shipment status based on latest tracking event
|
||||
*/
|
||||
private function updateShipmentStatus(DhlShipment $shipment, array $events): void
|
||||
{
|
||||
if (empty($events)) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Sort events by timestamp to get the latest
|
||||
usort($events, function ($a, $b) {
|
||||
return strtotime($b['timestamp'] ?? '0') <=> strtotime($a['timestamp'] ?? '0');
|
||||
});
|
||||
|
||||
$latestEvent = $events[0];
|
||||
$statusCode = $latestEvent['statusCode'] ?? null;
|
||||
|
||||
// Map DHL status codes to our internal status
|
||||
$status = DhlShipment::STATUS_MAP[$statusCode] ?? 'unknown';
|
||||
|
||||
$shipment->update([
|
||||
'status' => $status,
|
||||
'tracking_status' => $latestEvent['status'] ?? null,
|
||||
'last_tracked_at' => now()
|
||||
]);
|
||||
Log::info('Updated shipment status', ['shipmentId' => $shipment->id, 'newStatus' => $status]);
|
||||
}
|
||||
}
|
||||
198
packages/acme-laravel-dhl/src/Support/DhlClient.php
Normal file
198
packages/acme-laravel-dhl/src/Support/DhlClient.php
Normal file
|
|
@ -0,0 +1,198 @@
|
|||
<?php
|
||||
|
||||
namespace Acme\Dhl\Support;
|
||||
|
||||
use Illuminate\Support\Facades\Http;
|
||||
use Illuminate\Http\Client\RequestException;
|
||||
use Illuminate\Http\Client\ConnectionException;
|
||||
use Acme\Dhl\Exceptions\DhlApiException;
|
||||
use Acme\Dhl\Exceptions\DhlAuthenticationException;
|
||||
use Acme\Dhl\Exceptions\DhlValidationException;
|
||||
use Exception;
|
||||
use Illuminate\Support\Facades\Log;
|
||||
|
||||
/**
|
||||
* DHL API Client for handling HTTP requests to DHL services
|
||||
*/
|
||||
class DhlClient
|
||||
{
|
||||
public function __construct(
|
||||
protected string $baseUrl,
|
||||
protected ?string $apiKey,
|
||||
protected ?string $username,
|
||||
protected ?string $password
|
||||
) {}
|
||||
|
||||
/**
|
||||
* Make HTTP request to DHL API
|
||||
*
|
||||
* @param string $method HTTP method (get, post, put, delete)
|
||||
* @param string $uri API endpoint URI
|
||||
* @param array $payload Request body data
|
||||
* @param array $query Query parameters
|
||||
* @return array Response data as array
|
||||
* @throws Exception When API request fails or returns error
|
||||
*/
|
||||
public function request(string $method, string $uri, array $payload = [], array $query = []): array
|
||||
{
|
||||
try {
|
||||
$request = Http::baseUrl($this->baseUrl)
|
||||
->withHeaders($this->buildHeaders())
|
||||
->timeout(30)
|
||||
->retry(3, 300, function ($exception, $attempt) {
|
||||
if ($exception instanceof RequestException && $exception->response->status() === 429) {
|
||||
$delay = min(1000000 * $attempt, 10000000); // Max 10 seconds
|
||||
usleep($delay); // Microseconds
|
||||
return true;
|
||||
}
|
||||
return $exception instanceof ConnectionException ||
|
||||
($exception instanceof RequestException && in_array($exception->response->status(), [500, 502, 503, 504]));
|
||||
}, false);
|
||||
|
||||
// Add authentication if provided
|
||||
if ($this->username && $this->password) {
|
||||
$request = $request->withBasicAuth($this->username, $this->password);
|
||||
}
|
||||
|
||||
// Make the request
|
||||
$response = match (strtolower($method)) {
|
||||
'get' => $request->get($uri, $query),
|
||||
'post' => $request->post($uri . '?' . http_build_query($query), $payload),
|
||||
'put' => $request->put($uri, $payload),
|
||||
'delete' => $request->delete($uri),
|
||||
default => throw new Exception("Unsupported HTTP method: {$method}")
|
||||
};
|
||||
|
||||
// Handle response
|
||||
if ($response->failed()) {
|
||||
// Log additional debug info for 400 errors
|
||||
if ($response->status() === 400) {
|
||||
Log::error('[DHL API] HTTP 400 Bad Request Details', [
|
||||
'method' => $method,
|
||||
'uri' => $uri,
|
||||
'request_payload' => $payload,
|
||||
'response_body' => $response->body(),
|
||||
'response_json' => $response->json(),
|
||||
'status_code' => $response->status(),
|
||||
'headers' => $response->headers()
|
||||
]);
|
||||
}
|
||||
|
||||
$this->handleErrorResponse($response, $method, $uri);
|
||||
}
|
||||
|
||||
return $response->json() ?? [];
|
||||
} catch (RequestException $e) {
|
||||
Log::error('DHL API request failed', ['error' => $e->getMessage(), 'method' => $method, 'uri' => $uri]);
|
||||
throw new DhlApiException("DHL API request failed: {$e->getMessage()}", $e->getCode(), $e);
|
||||
} catch (Exception $e) {
|
||||
Log::error('DHL API error', ['error' => $e->getMessage()]);
|
||||
// Re-throw our own exceptions
|
||||
if ($e instanceof DhlApiException) {
|
||||
throw $e;
|
||||
}
|
||||
throw new DhlApiException("DHL API error: {$e->getMessage()}", $e->getCode(), $e);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Build HTTP headers for DHL API requests
|
||||
*/
|
||||
private function buildHeaders(): array
|
||||
{
|
||||
$headers = [
|
||||
'Accept' => 'application/json',
|
||||
'Content-Type' => 'application/json',
|
||||
'User-Agent' => 'acme-laravel-dhl/1.0'
|
||||
];
|
||||
|
||||
if ($this->apiKey) {
|
||||
$headers['dhl-api-key'] = $this->apiKey;
|
||||
}
|
||||
|
||||
return $headers;
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle error responses from DHL API
|
||||
*/
|
||||
private function handleErrorResponse($response, string $method, string $uri): void
|
||||
{
|
||||
$status = $response->status();
|
||||
$body = $response->json();
|
||||
|
||||
$errorMessage = $this->extractErrorMessage($body) ?? "HTTP {$status} error";
|
||||
|
||||
match (true) {
|
||||
$status === 401 => throw new DhlAuthenticationException("DHL API authentication failed: {$errorMessage}"),
|
||||
$status === 403 => throw new DhlAuthenticationException("DHL API access forbidden: {$errorMessage}"),
|
||||
$status === 404 => throw new DhlApiException("DHL API endpoint not found: {$method} {$uri}"),
|
||||
$status === 422 => throw new DhlValidationException("DHL API validation error: {$errorMessage}"),
|
||||
$status === 429 => throw new DhlApiException("DHL API rate limit exceeded. Please try again later."),
|
||||
$status >= 500 => throw new DhlApiException("DHL API server error: {$errorMessage}"),
|
||||
default => throw new DhlApiException("DHL API error ({$status}): {$errorMessage}")
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract error message from DHL API response
|
||||
*/
|
||||
private function extractErrorMessage(?array $body): ?string
|
||||
{
|
||||
if (!$body) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// Try different possible error message fields
|
||||
return $body['message']
|
||||
?? $body['error']
|
||||
?? $body['detail']
|
||||
?? data_get($body, 'errors.0.message')
|
||||
?? data_get($body, 'error.message')
|
||||
?? null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Test connection to DHL API
|
||||
*
|
||||
* @return bool True if connection successful
|
||||
*/
|
||||
public function testConnection(): bool
|
||||
{
|
||||
try {
|
||||
// Check basic connectivity and authentication
|
||||
// Use the simplest possible endpoint to minimize permission issues
|
||||
$response = Http::baseUrl($this->baseUrl)
|
||||
->withHeaders($this->buildHeaders())
|
||||
->withBasicAuth($this->username, $this->password)
|
||||
->timeout(10)
|
||||
->get('/');
|
||||
|
||||
// If we get any response (even 404), the connection and auth are working
|
||||
if ($response->status() === 401) {
|
||||
Log::error('DHL API authentication failed: Invalid username/password');
|
||||
return false;
|
||||
}
|
||||
|
||||
if ($response->status() === 403 && str_contains($response->body(), 'api-key')) {
|
||||
Log::error('DHL API authentication failed: Invalid API key');
|
||||
return false;
|
||||
}
|
||||
|
||||
// Any other response code (including 404, 403 for endpoint access) means connection works
|
||||
Log::info('DHL API connection test successful', [
|
||||
'status' => $response->status(),
|
||||
'has_api_key' => !empty($this->apiKey),
|
||||
'has_credentials' => !empty($this->username) && !empty($this->password)
|
||||
]);
|
||||
|
||||
return true;
|
||||
} catch (Exception $e) {
|
||||
Log::error('DHL API connection test failed', [
|
||||
'error' => $e->getMessage(),
|
||||
'base_url' => $this->baseUrl
|
||||
]);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue