27-05-2026 DHL Modul v2.1 / Optimierung tracking
This commit is contained in:
parent
036595be94
commit
2bdc9ada3c
33 changed files with 2367 additions and 2086 deletions
7
.env
7
.env
|
|
@ -140,20 +140,21 @@ DHL_USE_QUEUE=false
|
||||||
|
|
||||||
# DHL Account Numbers (für verschiedene Produkte)
|
# DHL Account Numbers (für verschiedene Produkte)
|
||||||
DHL_USERNAME=riwa-tec
|
DHL_USERNAME=riwa-tec
|
||||||
DHL_PASSWORD=MivitaCare!!2025
|
DHL_PASSWORD=MivitaCare!!2028
|
||||||
DHL_BILLING_NUMBER=63144073550101
|
DHL_BILLING_NUMBER=63144073550101
|
||||||
DHL_ACCOUNT_NUMBER_DEFAULT=63144073550101
|
DHL_ACCOUNT_NUMBER_DEFAULT=63144073550101
|
||||||
DHL_ACCOUNT_NUMBER_V01PAK=63144073550101 # DHL Paket National
|
DHL_ACCOUNT_NUMBER_V01PAK=63144073550101 # DHL Paket National
|
||||||
DHL_ACCOUNT_NUMBER_V62WP=63144073556201 # Warenpost National
|
DHL_ACCOUNT_NUMBER_V62KP=63144073556201 # DHL Kleinpaket
|
||||||
DHL_ACCOUNT_NUMBER_V53PAK=63144073555301 # DHL Paket International
|
DHL_ACCOUNT_NUMBER_V53PAK=63144073555301 # DHL Paket International
|
||||||
DHL_ACCOUNT_NUMBER_V07PAK=63144073550701 # DHL Retoure Online
|
DHL_ACCOUNT_NUMBER_V07PAK=63144073550701 # DHL Retoure Online
|
||||||
|
|
||||||
#sandbox
|
#sandbox
|
||||||
#DHL_USERNAME=user-valid
|
#DHL_USERNAME=user-valid
|
||||||
#DHL_PASSWORD=SandboxPasswort2023!
|
#DHL_PASSWORD=SandboxPasswort2023!
|
||||||
#DHL_BILLING_NUMBER=33333333330101
|
#DHL_BILLING_NUMBER=33333333330101
|
||||||
#DHL_ACCOUNT_NUMBER_DEFAULT=33333333330101
|
#DHL_ACCOUNT_NUMBER_DEFAULT=33333333330101
|
||||||
#DHL_ACCOUNT_NUMBER_V01PAK=33333333330102 # DHL Paket National
|
#DHL_ACCOUNT_NUMBER_V01PAK=33333333330102 # DHL Paket National
|
||||||
#DHL_ACCOUNT_NUMBER_V62WP=33333333336601 # Warenpost National
|
#DHL_ACCOUNT_NUMBER_V62KP=33333333336601 # DHL Kleinpaket
|
||||||
#DHL_ACCOUNT_NUMBER_V53PAK=33333333335301 # DHL Paket International
|
#DHL_ACCOUNT_NUMBER_V53PAK=33333333335301 # DHL Paket International
|
||||||
#DHL_ACCOUNT_NUMBER_V07PAK=33333333330702 # DHL Retoure Online
|
#DHL_ACCOUNT_NUMBER_V07PAK=33333333330702 # DHL Retoure Online
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -179,7 +179,7 @@ class DhlShipmentController extends Controller
|
||||||
return '<span class="text-muted">N/A</span>';
|
return '<span class="text-muted">N/A</span>';
|
||||||
})
|
})
|
||||||
->addColumn('customer', function ($shipment) {
|
->addColumn('customer', function ($shipment) {
|
||||||
return $shipment->firstname.' '.$shipment->lastname;
|
return e(trim($shipment->firstname.' '.$shipment->lastname));
|
||||||
})
|
})
|
||||||
->editColumn('dhl_shipment_no', function ($shipment) {
|
->editColumn('dhl_shipment_no', function ($shipment) {
|
||||||
return $shipment->dhl_shipment_no ? '<code class="text-success">'.e($shipment->dhl_shipment_no).'</code>' : '<span class="text-muted">-</span>';
|
return $shipment->dhl_shipment_no ? '<code class="text-success">'.e($shipment->dhl_shipment_no).'</code>' : '<span class="text-muted">-</span>';
|
||||||
|
|
@ -573,11 +573,13 @@ class DhlShipmentController extends Controller
|
||||||
private function getBillingAddressForReturn($shippingUser, array $recipient): array
|
private function getBillingAddressForReturn($shippingUser, array $recipient): array
|
||||||
{
|
{
|
||||||
if (! $shippingUser) {
|
if (! $shippingUser) {
|
||||||
Log::warning('[DHL Controller] No shipping user found, using recipient data', [
|
Log::warning('[DHL Controller] No shipping user found, using recipient country only', [
|
||||||
'recipient' => $recipient,
|
'order_recipient_country' => $recipient['country'] ?? null,
|
||||||
]);
|
]);
|
||||||
|
|
||||||
// Fallback: use recipient data but without Packstation fields
|
// Fallback: use recipient data but without Packstation fields.
|
||||||
|
// We keep ISO-2 country codes here so that ReturnsService /
|
||||||
|
// DhlProductResolver can normalize / validate them consistently.
|
||||||
return [
|
return [
|
||||||
'name' => trim(($recipient['firstname'] ?? '').' '.($recipient['lastname'] ?? '')),
|
'name' => trim(($recipient['firstname'] ?? '').' '.($recipient['lastname'] ?? '')),
|
||||||
'name2' => $recipient['company'] ?? '',
|
'name2' => $recipient['company'] ?? '',
|
||||||
|
|
@ -585,7 +587,7 @@ class DhlShipmentController extends Controller
|
||||||
'houseNumber' => '',
|
'houseNumber' => '',
|
||||||
'postalCode' => $recipient['postalCode'] ?? '',
|
'postalCode' => $recipient['postalCode'] ?? '',
|
||||||
'city' => $recipient['city'] ?? '',
|
'city' => $recipient['city'] ?? '',
|
||||||
'country' => $recipient['country'] ?? 'DEU',
|
'country' => $recipient['country'] ?? DhlProductResolver::DOMESTIC_COUNTRY,
|
||||||
'email' => $recipient['email'] ?? '',
|
'email' => $recipient['email'] ?? '',
|
||||||
'phone' => $recipient['phone'] ?? '',
|
'phone' => $recipient['phone'] ?? '',
|
||||||
];
|
];
|
||||||
|
|
@ -609,7 +611,7 @@ class DhlShipmentController extends Controller
|
||||||
'houseNumber' => $houseNumber,
|
'houseNumber' => $houseNumber,
|
||||||
'postalCode' => $shippingUser->billing_zipcode ?? '',
|
'postalCode' => $shippingUser->billing_zipcode ?? '',
|
||||||
'city' => $shippingUser->billing_city ?? '',
|
'city' => $shippingUser->billing_city ?? '',
|
||||||
'country' => $shippingUser->billing_country?->code ?? 'DEU',
|
'country' => $shippingUser->billing_country?->code ?? DhlProductResolver::DOMESTIC_COUNTRY,
|
||||||
'email' => $shippingUser->billing_email ?? '',
|
'email' => $shippingUser->billing_email ?? '',
|
||||||
'phone' => $shippingUser->billing_phone ?? '',
|
'phone' => $shippingUser->billing_phone ?? '',
|
||||||
];
|
];
|
||||||
|
|
@ -665,7 +667,7 @@ class DhlShipmentController extends Controller
|
||||||
'houseNumber' => $recipient['houseNumber'] ?? '',
|
'houseNumber' => $recipient['houseNumber'] ?? '',
|
||||||
'postalCode' => $recipient['postalCode'] ?? '',
|
'postalCode' => $recipient['postalCode'] ?? '',
|
||||||
'city' => $recipient['city'] ?? '',
|
'city' => $recipient['city'] ?? '',
|
||||||
'country' => $recipient['country'] ?? 'DEU',
|
'country' => $recipient['country'] ?? DhlProductResolver::DOMESTIC_COUNTRY,
|
||||||
'email' => $recipient['email'] ?? '',
|
'email' => $recipient['email'] ?? '',
|
||||||
'phone' => $recipient['phone'] ?? '',
|
'phone' => $recipient['phone'] ?? '',
|
||||||
];
|
];
|
||||||
|
|
@ -688,7 +690,7 @@ class DhlShipmentController extends Controller
|
||||||
'houseNumber' => $dhlConfig['sender']['house_number'] ?? '2',
|
'houseNumber' => $dhlConfig['sender']['house_number'] ?? '2',
|
||||||
'postalCode' => $dhlConfig['sender']['postalCode'] ?? '87755',
|
'postalCode' => $dhlConfig['sender']['postalCode'] ?? '87755',
|
||||||
'city' => $dhlConfig['sender']['city'] ?? 'Kirchhaslach',
|
'city' => $dhlConfig['sender']['city'] ?? 'Kirchhaslach',
|
||||||
'country' => $dhlConfig['sender']['country'] ?? 'DEU',
|
'country' => $dhlConfig['sender']['country'] ?? DhlProductResolver::DOMESTIC_COUNTRY,
|
||||||
'email' => $dhlConfig['sender']['email'] ?? 'versand@mivita.care',
|
'email' => $dhlConfig['sender']['email'] ?? 'versand@mivita.care',
|
||||||
'phone' => $dhlConfig['sender']['phone'] ?? '+49 123 456789',
|
'phone' => $dhlConfig['sender']['phone'] ?? '+49 123 456789',
|
||||||
],
|
],
|
||||||
|
|
|
||||||
|
|
@ -172,6 +172,7 @@ class ModalController extends Controller
|
||||||
}
|
}
|
||||||
|
|
||||||
if ($data['action'] === 'create-dhl-shipment') {
|
if ($data['action'] === 'create-dhl-shipment') {
|
||||||
|
$this->authorizeDhlShipmentModal();
|
||||||
$id = $data['id'] ?? null;
|
$id = $data['id'] ?? null;
|
||||||
$ret = $this->handleDhlShipmentModal($id, $data);
|
$ret = $this->handleDhlShipmentModal($id, $data);
|
||||||
}
|
}
|
||||||
|
|
@ -202,6 +203,23 @@ class ModalController extends Controller
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Ensure the current user is allowed to use the DHL shipment modal.
|
||||||
|
*
|
||||||
|
* The DHL cockpit is an admin-only tool. Without this guard a logged-in
|
||||||
|
* CRM user could call `POST /modal/load` with `action=create-dhl-shipment`
|
||||||
|
* and an arbitrary order id and would receive that order's recipient
|
||||||
|
* name, address, e-mail and existing shipments (IDOR).
|
||||||
|
*/
|
||||||
|
private function authorizeDhlShipmentModal(): void
|
||||||
|
{
|
||||||
|
$user = \Auth::user();
|
||||||
|
|
||||||
|
if (! $user || ! method_exists($user, 'isAdmin') || ! $user->isAdmin()) {
|
||||||
|
abort(403, 'DHL shipment modal is only available for admin users.');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Handle DHL shipment modal preparation
|
* Handle DHL shipment modal preparation
|
||||||
*
|
*
|
||||||
|
|
|
||||||
|
|
@ -4,11 +4,26 @@ namespace App\Http\Controllers;
|
||||||
|
|
||||||
use App\Models\Setting;
|
use App\Models\Setting;
|
||||||
use App\Services\DhlProductResolver;
|
use App\Services\DhlProductResolver;
|
||||||
|
use Illuminate\Support\Facades\Schema;
|
||||||
use Illuminate\Support\Facades\Session;
|
use Illuminate\Support\Facades\Session;
|
||||||
use Request;
|
use Request;
|
||||||
|
|
||||||
class SettingController extends Controller
|
class SettingController extends Controller
|
||||||
{
|
{
|
||||||
|
/**
|
||||||
|
* In-memory cache of the resolved DHL configuration for the lifetime
|
||||||
|
* of the current PHP process (request, queue job, CLI command).
|
||||||
|
*
|
||||||
|
* `getDhlConfig()` is invoked from several layers per shipment
|
||||||
|
* (`DhlShipmentService`, `CreateShipmentJob`, `DhlShipmentController`
|
||||||
|
* cancel/return paths, …) and each call previously triggered ~25 DB
|
||||||
|
* queries against `settings`. Caching here turns subsequent calls
|
||||||
|
* into pure in-memory lookups.
|
||||||
|
*
|
||||||
|
* @var array<string, mixed>|null
|
||||||
|
*/
|
||||||
|
private static ?array $cachedDhlConfig = null;
|
||||||
|
|
||||||
public function __construct()
|
public function __construct()
|
||||||
{
|
{
|
||||||
$this->middleware('admin');
|
$this->middleware('admin');
|
||||||
|
|
@ -31,6 +46,9 @@ class SettingController extends Controller
|
||||||
if (isset($data['settings'])) {
|
if (isset($data['settings'])) {
|
||||||
foreach ($data['settings'] as $key => $value) {
|
foreach ($data['settings'] as $key => $value) {
|
||||||
$value['val'] = isset($value['val']) ? $value['val'] : false;
|
$value['val'] = isset($value['val']) ? $value['val'] : false;
|
||||||
|
if ($key === 'dhl_international_countries') {
|
||||||
|
$value['val'] = DhlProductResolver::normalizeCountryCodeList(is_array($value['val']) ? $value['val'] : []);
|
||||||
|
}
|
||||||
Setting::setContentBySlug($key, $value['val'], $value['type']);
|
Setting::setContentBySlug($key, $value['val'], $value['type']);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -40,6 +58,9 @@ class SettingController extends Controller
|
||||||
$this->updateDhlConfigCache();
|
$this->updateDhlConfigCache();
|
||||||
Session::flash('alert-save-dhl', 'DHL Konfiguration erfolgreich gespeichert!');
|
Session::flash('alert-save-dhl', 'DHL Konfiguration erfolgreich gespeichert!');
|
||||||
} else {
|
} else {
|
||||||
|
// Any other setting change could still affect cached config
|
||||||
|
// values (e.g. shared sender data), so invalidate just in case.
|
||||||
|
self::flushDhlConfigCache();
|
||||||
Session::flash('alert-save', '1');
|
Session::flash('alert-save', '1');
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -47,14 +68,34 @@ class SettingController extends Controller
|
||||||
return redirect(route('admin_settings'));
|
return redirect(route('admin_settings'));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Drop the in-process DHL configuration cache.
|
||||||
|
*
|
||||||
|
* Call this whenever a DHL-related setting is changed so the next
|
||||||
|
* call to {@see getDhlConfig()} reads fresh data from the database.
|
||||||
|
*/
|
||||||
|
public static function flushDhlConfigCache(): void
|
||||||
|
{
|
||||||
|
self::$cachedDhlConfig = null;
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Get DHL configuration merged from database settings and .env values
|
* Get DHL configuration merged from database settings and .env values
|
||||||
* Priority is controlled by DHL_CONFIG_SOURCE environment variable:
|
* Priority is controlled by DHL_CONFIG_SOURCE environment variable:
|
||||||
* - 'database' (default): Database settings override .env values
|
* - 'database' (default): Database settings override .env values
|
||||||
* - 'env': Environment/Config values override database settings
|
* - 'env': Environment/Config values override database settings
|
||||||
|
*
|
||||||
|
* The result is cached per process; use {@see flushDhlConfigCache()}
|
||||||
|
* to invalidate the cache after changing settings.
|
||||||
|
*
|
||||||
|
* @return array<string, mixed>
|
||||||
*/
|
*/
|
||||||
public function getDhlConfig()
|
public function getDhlConfig()
|
||||||
{
|
{
|
||||||
|
if (self::$cachedDhlConfig !== null) {
|
||||||
|
return self::$cachedDhlConfig;
|
||||||
|
}
|
||||||
|
|
||||||
// Check if we're in test/sandbox mode
|
// Check if we're in test/sandbox mode
|
||||||
$isTestMode = config('dhl.legacy.test_mode', false) || config('dhl.legacy.sandbox', false);
|
$isTestMode = config('dhl.legacy.test_mode', false) || config('dhl.legacy.sandbox', false);
|
||||||
$baseUrl = $isTestMode ? config('dhl.sandbox_url') : config('dhl.base_url');
|
$baseUrl = $isTestMode ? config('dhl.sandbox_url') : config('dhl.base_url');
|
||||||
|
|
@ -62,7 +103,7 @@ class SettingController extends Controller
|
||||||
// Determine configuration priority
|
// Determine configuration priority
|
||||||
$useEnvPriority = config('dhl.config_source') === 'env';
|
$useEnvPriority = config('dhl.config_source') === 'env';
|
||||||
|
|
||||||
return [
|
return self::$cachedDhlConfig = [
|
||||||
// API Settings
|
// API Settings
|
||||||
'base_url' => $isTestMode ? $baseUrl : $this->getConfigValue('dhl_base_url', $baseUrl, $useEnvPriority),
|
'base_url' => $isTestMode ? $baseUrl : $this->getConfigValue('dhl_base_url', $baseUrl, $useEnvPriority),
|
||||||
'api_key' => $this->getConfigValue('dhl_api_key', config('dhl.api_key'), $useEnvPriority),
|
'api_key' => $this->getConfigValue('dhl_api_key', config('dhl.api_key'), $useEnvPriority),
|
||||||
|
|
@ -152,9 +193,14 @@ class SettingController extends Controller
|
||||||
{
|
{
|
||||||
$configCountries = config('dhl.international_countries', DhlProductResolver::DEFAULT_INTERNATIONAL_COUNTRIES);
|
$configCountries = config('dhl.international_countries', DhlProductResolver::DEFAULT_INTERNATIONAL_COUNTRIES);
|
||||||
$countries = $configCountries;
|
$countries = $configCountries;
|
||||||
|
$storedCountries = Schema::hasTable('settings')
|
||||||
|
? Setting::getContentBySlug('dhl_international_countries')
|
||||||
|
: false;
|
||||||
|
|
||||||
if (! $useEnvPriority) {
|
if (is_array($storedCountries)) {
|
||||||
$countries = Setting::getContentBySlug('dhl_international_countries') ?: $configCountries;
|
$countries = $storedCountries;
|
||||||
|
} elseif (! $useEnvPriority) {
|
||||||
|
$countries = $storedCountries ?: $configCountries;
|
||||||
}
|
}
|
||||||
|
|
||||||
return DhlProductResolver::normalizeCountryCodeList(is_array($countries) ? $countries : []);
|
return DhlProductResolver::normalizeCountryCodeList(is_array($countries) ? $countries : []);
|
||||||
|
|
@ -165,6 +211,10 @@ class SettingController extends Controller
|
||||||
*/
|
*/
|
||||||
private function updateDhlConfigCache()
|
private function updateDhlConfigCache()
|
||||||
{
|
{
|
||||||
|
// Drop the in-process DHL config cache so the next call rebuilds it
|
||||||
|
// from the freshly saved settings.
|
||||||
|
self::flushDhlConfigCache();
|
||||||
|
|
||||||
// Clear config cache to force reload from database
|
// Clear config cache to force reload from database
|
||||||
\Artisan::call('config:clear');
|
\Artisan::call('config:clear');
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -37,11 +37,6 @@ class CreateShipmentJob implements ShouldQueue
|
||||||
*/
|
*/
|
||||||
public $options;
|
public $options;
|
||||||
|
|
||||||
/**
|
|
||||||
* @var array
|
|
||||||
*/
|
|
||||||
public $dhlConfig;
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* The number of times the job may be attempted.
|
* The number of times the job may be attempted.
|
||||||
*
|
*
|
||||||
|
|
@ -59,10 +54,16 @@ class CreateShipmentJob implements ShouldQueue
|
||||||
/**
|
/**
|
||||||
* Create a new job instance.
|
* Create a new job instance.
|
||||||
*
|
*
|
||||||
* @param ShoppingOrder $shoppingOrder
|
* IMPORTANT: We intentionally never serialize the DHL configuration into
|
||||||
* @param float $weight
|
* the queue payload because it contains the DHL API key, basic-auth
|
||||||
* @param array $options
|
* password and billing numbers. Those values would otherwise sit
|
||||||
* @param array|null $dhlConfig
|
* unencrypted in Redis/the queue table. The config is reloaded inside
|
||||||
|
* {@see self::handle()} from the canonical settings source instead.
|
||||||
|
*
|
||||||
|
* The legacy `$dhlConfig` parameter is still accepted for backwards
|
||||||
|
* compatibility but is no longer persisted onto the job instance.
|
||||||
|
*
|
||||||
|
* @param array $dhlConfig Deprecated: ignored to avoid secrets in queue
|
||||||
*/
|
*/
|
||||||
public function __construct(ShoppingOrder $shoppingOrder, float $weight = 1.0, array $options = [], array $dhlConfig = [])
|
public function __construct(ShoppingOrder $shoppingOrder, float $weight = 1.0, array $options = [], array $dhlConfig = [])
|
||||||
{
|
{
|
||||||
|
|
@ -70,14 +71,6 @@ class CreateShipmentJob implements ShouldQueue
|
||||||
$this->weight = $weight;
|
$this->weight = $weight;
|
||||||
$this->options = $options;
|
$this->options = $options;
|
||||||
|
|
||||||
// Load DHL config once when creating the job
|
|
||||||
if (empty($dhlConfig)) {
|
|
||||||
$settingController = new \App\Http\Controllers\SettingController();
|
|
||||||
$this->dhlConfig = $settingController->getDhlConfig();
|
|
||||||
} else {
|
|
||||||
$this->dhlConfig = $dhlConfig;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Set queue name based on priority
|
// Set queue name based on priority
|
||||||
if (isset($options['priority']) && $options['priority'] === 'high') {
|
if (isset($options['priority']) && $options['priority'] === 'high') {
|
||||||
$this->onQueue('high-priority');
|
$this->onQueue('high-priority');
|
||||||
|
|
@ -98,18 +91,20 @@ class CreateShipmentJob implements ShouldQueue
|
||||||
'attempt' => $this->attempts(),
|
'attempt' => $this->attempts(),
|
||||||
]);
|
]);
|
||||||
|
|
||||||
// Use DHL configuration loaded in constructor
|
// Load DHL configuration here, never from the serialized job payload.
|
||||||
|
$dhlConfig = (new \App\Http\Controllers\SettingController)->getDhlConfig();
|
||||||
|
|
||||||
$dhlClient = new \Acme\Dhl\Support\DhlClient(
|
$dhlClient = new \Acme\Dhl\Support\DhlClient(
|
||||||
$this->dhlConfig['base_url'],
|
$dhlConfig['base_url'],
|
||||||
$this->dhlConfig['api_key'],
|
$dhlConfig['api_key'],
|
||||||
$this->dhlConfig['username'],
|
$dhlConfig['username'],
|
||||||
$this->dhlConfig['password']
|
$dhlConfig['password']
|
||||||
);
|
);
|
||||||
|
|
||||||
$shippingService = new \Acme\Dhl\Services\ShippingService($dhlClient);
|
$shippingService = new \Acme\Dhl\Services\ShippingService($dhlClient);
|
||||||
|
|
||||||
// Prepare order data using helper
|
// Prepare order data using helper
|
||||||
$orderData = DhlDataHelper::prepareOrderData($this->shoppingOrder, $this->weight, $this->options, $this->dhlConfig);
|
$orderData = DhlDataHelper::prepareOrderData($this->shoppingOrder, $this->weight, $this->options, $dhlConfig);
|
||||||
|
|
||||||
// Create the shipment using new package
|
// Create the shipment using new package
|
||||||
$result = $shippingService->createLabel($orderData);
|
$result = $shippingService->createLabel($orderData);
|
||||||
|
|
@ -121,9 +116,9 @@ class CreateShipmentJob implements ShouldQueue
|
||||||
]);
|
]);
|
||||||
|
|
||||||
// Trigger follow-up actions if specified (if tracking number available)
|
// Trigger follow-up actions if specified (if tracking number available)
|
||||||
if (isset($this->options['auto_track']) && $this->options['auto_track'] && !empty($result['trackingNumber'])) {
|
if (isset($this->options['auto_track']) && $this->options['auto_track'] && ! empty($result['trackingNumber'])) {
|
||||||
Log::info('[DHL Queue] Scheduling tracking update', [
|
Log::info('[DHL Queue] Scheduling tracking update', [
|
||||||
'tracking_number' => $result['trackingNumber']
|
'tracking_number' => $result['trackingNumber'],
|
||||||
]);
|
]);
|
||||||
// Note: TrackShipmentJob would need to be updated to work with tracking numbers
|
// Note: TrackShipmentJob would need to be updated to work with tracking numbers
|
||||||
}
|
}
|
||||||
|
|
@ -149,8 +144,6 @@ class CreateShipmentJob implements ShouldQueue
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Handle a job failure.
|
* Handle a job failure.
|
||||||
*
|
|
||||||
* @param Exception $exception
|
|
||||||
*/
|
*/
|
||||||
public function failed(Exception $exception): void
|
public function failed(Exception $exception): void
|
||||||
{
|
{
|
||||||
|
|
@ -166,7 +159,6 @@ class CreateShipmentJob implements ShouldQueue
|
||||||
// - Create manual task for staff
|
// - Create manual task for staff
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Determine the time at which the job should timeout.
|
* Determine the time at which the job should timeout.
|
||||||
*
|
*
|
||||||
|
|
|
||||||
|
|
@ -25,7 +25,7 @@ use Illuminate\Database\Eloquent\Model;
|
||||||
* @property string|null $type
|
* @property string|null $type
|
||||||
* @property Carbon|null $created_at
|
* @property Carbon|null $created_at
|
||||||
* @property Carbon|null $updated_at
|
* @property Carbon|null $updated_at
|
||||||
* @package App\Models
|
*
|
||||||
* @method static \Illuminate\Database\Eloquent\Builder|Setting newModelQuery()
|
* @method static \Illuminate\Database\Eloquent\Builder|Setting newModelQuery()
|
||||||
* @method static \Illuminate\Database\Eloquent\Builder|Setting newQuery()
|
* @method static \Illuminate\Database\Eloquent\Builder|Setting newQuery()
|
||||||
* @method static \Illuminate\Database\Eloquent\Builder|Setting query()
|
* @method static \Illuminate\Database\Eloquent\Builder|Setting query()
|
||||||
|
|
@ -42,31 +42,32 @@ use Illuminate\Database\Eloquent\Model;
|
||||||
* @method static \Illuminate\Database\Eloquent\Builder|Setting whereText($value)
|
* @method static \Illuminate\Database\Eloquent\Builder|Setting whereText($value)
|
||||||
* @method static \Illuminate\Database\Eloquent\Builder|Setting whereType($value)
|
* @method static \Illuminate\Database\Eloquent\Builder|Setting whereType($value)
|
||||||
* @method static \Illuminate\Database\Eloquent\Builder|Setting whereUpdatedAt($value)
|
* @method static \Illuminate\Database\Eloquent\Builder|Setting whereUpdatedAt($value)
|
||||||
|
*
|
||||||
* @mixin \Eloquent
|
* @mixin \Eloquent
|
||||||
*/
|
*/
|
||||||
class Setting extends Model
|
class Setting extends Model
|
||||||
{
|
{
|
||||||
protected $table = 'settings';
|
protected $table = 'settings';
|
||||||
|
|
||||||
protected $casts = [
|
protected $casts = [
|
||||||
'referenz' => 'int',
|
'referenz' => 'int',
|
||||||
'status' => 'int',
|
'status' => 'int',
|
||||||
'int' => 'int',
|
'int' => 'int',
|
||||||
'object' => 'array'
|
'object' => 'array',
|
||||||
];
|
];
|
||||||
|
|
||||||
protected $fillable = [
|
protected $fillable = [
|
||||||
'identifier',
|
'identifier',
|
||||||
'slug',
|
'slug',
|
||||||
'referenz',
|
'referenz',
|
||||||
'action',
|
'action',
|
||||||
'object',
|
'object',
|
||||||
'full_text',
|
'full_text',
|
||||||
'text',
|
'text',
|
||||||
'int',
|
'int',
|
||||||
'status',
|
'status',
|
||||||
'type'
|
'type',
|
||||||
];
|
];
|
||||||
|
|
||||||
protected static $types = [
|
protected static $types = [
|
||||||
'object' => 'Object',
|
'object' => 'Object',
|
||||||
|
|
@ -75,21 +76,22 @@ class Setting extends Model
|
||||||
'int' => 'Zahl',
|
'int' => 'Zahl',
|
||||||
'bool' => 'Bool',
|
'bool' => 'Bool',
|
||||||
|
|
||||||
|
|
||||||
];
|
];
|
||||||
|
|
||||||
public function sluggable() : array
|
public function sluggable(): array
|
||||||
{
|
{
|
||||||
return [
|
return [
|
||||||
'slug' => [
|
'slug' => [
|
||||||
'source' => 'name'
|
'source' => 'name',
|
||||||
]
|
],
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
public static function getContentBySlug($slug){
|
|
||||||
|
public static function getContentBySlug($slug)
|
||||||
|
{
|
||||||
$content = self::whereSlug(trim($slug))->first();
|
$content = self::whereSlug(trim($slug))->first();
|
||||||
if($content){
|
if ($content) {
|
||||||
switch ($content->type){
|
switch ($content->type) {
|
||||||
case 'object':
|
case 'object':
|
||||||
return $content->object;
|
return $content->object;
|
||||||
break;
|
break;
|
||||||
|
|
@ -107,28 +109,30 @@ class Setting extends Model
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
public static function setContentBySlug($slug, $value, $type = "full_text"){
|
public static function setContentBySlug($slug, $value, $type = 'full_text')
|
||||||
|
{
|
||||||
|
|
||||||
$content = self::whereSlug(trim($slug))->first();
|
$content = self::whereSlug(trim($slug))->first();
|
||||||
if(!$content) {
|
if (! $content) {
|
||||||
$content = self::create([
|
$content = self::create([
|
||||||
'slug' => $slug,
|
'slug' => $slug,
|
||||||
'type' => $type,
|
'type' => $type,
|
||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
$content->type = $type;
|
$content->type = $type;
|
||||||
switch ($content->type){
|
switch ($content->type) {
|
||||||
case 'object':
|
case 'object':
|
||||||
$content->object = $value ? $value : null;;
|
$content->object = is_array($value) ? $value : ($value ?: null);
|
||||||
break;
|
break;
|
||||||
case 'full_text':
|
case 'full_text':
|
||||||
$content->full_text = $value ? $value : null;;
|
$content->full_text = $value ? $value : null;
|
||||||
break;
|
break;
|
||||||
case 'text':
|
case 'text':
|
||||||
$content->text = $value ? $value : null;;
|
$content->text = $value ? $value : null;
|
||||||
break;
|
break;
|
||||||
case 'int':
|
case 'int':
|
||||||
$content->int = (int) $value;
|
$content->int = (int) $value;
|
||||||
|
|
@ -139,6 +143,7 @@ class Setting extends Model
|
||||||
}
|
}
|
||||||
|
|
||||||
$content->save();
|
$content->save();
|
||||||
|
|
||||||
return $content;
|
return $content;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
File diff suppressed because it is too large
Load diff
|
|
@ -4,6 +4,7 @@ namespace App\Services;
|
||||||
|
|
||||||
use App\Http\Controllers\SettingController;
|
use App\Http\Controllers\SettingController;
|
||||||
use App\Models\ShoppingOrder;
|
use App\Models\ShoppingOrder;
|
||||||
|
use Illuminate\Support\Facades\Log;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* DHL Data Helper
|
* DHL Data Helper
|
||||||
|
|
@ -23,7 +24,14 @@ class DhlDataHelper
|
||||||
*/
|
*/
|
||||||
public static function prepareOrderData(ShoppingOrder $order, float $weight, array $options = [], ?array $dhlConfig = null): array
|
public static function prepareOrderData(ShoppingOrder $order, float $weight, array $options = [], ?array $dhlConfig = null): array
|
||||||
{
|
{
|
||||||
\Log::info('prepareOrderData', $options);
|
Log::info('[DHL DataHelper] Preparing order data', [
|
||||||
|
'order_id' => $order->id,
|
||||||
|
'product_code' => $options['product_code'] ?? null,
|
||||||
|
'has_shipping_address' => isset($options['shipping_address']),
|
||||||
|
'has_reference' => ! empty($options['reference'] ?? $options['shipment_reference'] ?? null),
|
||||||
|
'print_only_if_codeable' => (bool) ($options['print_only_if_codeable'] ?? false),
|
||||||
|
]);
|
||||||
|
|
||||||
// die daten für das versandlabel werden immer aus dem Formular genommen, damit anpassungen möglich sind
|
// die daten für das versandlabel werden immer aus dem Formular genommen, damit anpassungen möglich sind
|
||||||
if (! isset($options['shipping_address'])) {
|
if (! isset($options['shipping_address'])) {
|
||||||
throw new \Exception('shipping_address is required');
|
throw new \Exception('shipping_address is required');
|
||||||
|
|
|
||||||
|
|
@ -3,6 +3,7 @@
|
||||||
namespace App\Services;
|
namespace App\Services;
|
||||||
|
|
||||||
use App\Models\Setting;
|
use App\Models\Setting;
|
||||||
|
use Illuminate\Support\Facades\Schema;
|
||||||
use InvalidArgumentException;
|
use InvalidArgumentException;
|
||||||
|
|
||||||
class DhlProductResolver
|
class DhlProductResolver
|
||||||
|
|
@ -13,7 +14,7 @@ class DhlProductResolver
|
||||||
|
|
||||||
public const INTERNATIONAL_PRODUCT_CODE = 'V53PAK';
|
public const INTERNATIONAL_PRODUCT_CODE = 'V53PAK';
|
||||||
|
|
||||||
public const DEFAULT_INTERNATIONAL_COUNTRIES = ['AT', 'ES'];
|
public const DEFAULT_INTERNATIONAL_COUNTRIES = ['AT', 'ES', 'CH', 'NL', 'BE', 'FR'];
|
||||||
|
|
||||||
public const DHL_COUNTRY_CODES = [
|
public const DHL_COUNTRY_CODES = [
|
||||||
'DE' => 'DEU',
|
'DE' => 'DEU',
|
||||||
|
|
@ -172,9 +173,14 @@ class DhlProductResolver
|
||||||
$useEnvPriority = config('dhl.config_source') === 'env';
|
$useEnvPriority = config('dhl.config_source') === 'env';
|
||||||
$configCountries = config('dhl.international_countries', self::DEFAULT_INTERNATIONAL_COUNTRIES);
|
$configCountries = config('dhl.international_countries', self::DEFAULT_INTERNATIONAL_COUNTRIES);
|
||||||
$countries = $configCountries;
|
$countries = $configCountries;
|
||||||
|
$storedCountries = Schema::hasTable('settings')
|
||||||
|
? Setting::getContentBySlug('dhl_international_countries')
|
||||||
|
: false;
|
||||||
|
|
||||||
if (! $useEnvPriority) {
|
if (is_array($storedCountries)) {
|
||||||
$countries = Setting::getContentBySlug('dhl_international_countries') ?: $configCountries;
|
$countries = $storedCountries;
|
||||||
|
} elseif (! $useEnvPriority) {
|
||||||
|
$countries = $storedCountries ?: $configCountries;
|
||||||
}
|
}
|
||||||
|
|
||||||
return self::normalizeCountryCodeList(is_array($countries) ? $countries : []);
|
return self::normalizeCountryCodeList(is_array($countries) ? $countries : []);
|
||||||
|
|
|
||||||
|
|
@ -29,7 +29,7 @@ class DhlShipmentService
|
||||||
// Get DHL configuration
|
// Get DHL configuration
|
||||||
$settingController = new SettingController;
|
$settingController = new SettingController;
|
||||||
$dhlConfig = $settingController->getDhlConfig();
|
$dhlConfig = $settingController->getDhlConfig();
|
||||||
\Log::info('dhlConfig', $dhlConfig);
|
Log::info('[DHL Service] Loaded DHL configuration', self::sanitizeDhlConfigForLog($dhlConfig));
|
||||||
// Check if queue should be used
|
// Check if queue should be used
|
||||||
$useQueue = $dhlConfig['use_queue'] ?? false;
|
$useQueue = $dhlConfig['use_queue'] ?? false;
|
||||||
if ($useQueue && $this->requiresSynchronousAddressValidation($options, $dhlConfig)) {
|
if ($useQueue && $this->requiresSynchronousAddressValidation($options, $dhlConfig)) {
|
||||||
|
|
@ -115,7 +115,8 @@ class DhlShipmentService
|
||||||
|
|
||||||
// Prepare order data using helper
|
// Prepare order data using helper
|
||||||
$orderData = DhlDataHelper::prepareOrderData($order, $weight, $options, $dhlConfig);
|
$orderData = DhlDataHelper::prepareOrderData($order, $weight, $options, $dhlConfig);
|
||||||
Log::info('orderData', $orderData);
|
Log::info('[DHL Service] Order data prepared for DHL API', self::sanitizeOrderDataForLog($orderData));
|
||||||
|
|
||||||
// Create the shipment directly
|
// Create the shipment directly
|
||||||
$result = $shippingService->createLabel($orderData);
|
$result = $shippingService->createLabel($orderData);
|
||||||
|
|
||||||
|
|
@ -361,4 +362,65 @@ class DhlShipmentService
|
||||||
|
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Build a redacted view of the DHL configuration for safe logging.
|
||||||
|
*
|
||||||
|
* Never include `api_key`, `username`, `password`, `api_secret` or the
|
||||||
|
* full billing number itself. Only return boolean presence flags and
|
||||||
|
* non-sensitive metadata.
|
||||||
|
*
|
||||||
|
* @param array<string, mixed> $dhlConfig
|
||||||
|
* @return array<string, mixed>
|
||||||
|
*/
|
||||||
|
public static function sanitizeDhlConfigForLog(array $dhlConfig): array
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
'base_url' => $dhlConfig['base_url'] ?? null,
|
||||||
|
'sandbox' => $dhlConfig['sandbox'] ?? null,
|
||||||
|
'test_mode' => $dhlConfig['test_mode'] ?? null,
|
||||||
|
'has_api_key' => ! empty($dhlConfig['api_key']),
|
||||||
|
'has_username' => ! empty($dhlConfig['username']),
|
||||||
|
'has_password' => ! empty($dhlConfig['password']),
|
||||||
|
'has_api_secret' => ! empty($dhlConfig['api_secret']),
|
||||||
|
'use_queue' => (bool) ($dhlConfig['use_queue'] ?? false),
|
||||||
|
'default_product' => $dhlConfig['default_product'] ?? null,
|
||||||
|
'label_format' => $dhlConfig['label_format'] ?? null,
|
||||||
|
'print_format' => $dhlConfig['print_format'] ?? null,
|
||||||
|
'print_only_if_codeable' => (bool) ($dhlConfig['print_only_if_codeable'] ?? false),
|
||||||
|
'international_countries' => $dhlConfig['international_countries'] ?? [],
|
||||||
|
'account_numbers_configured' => array_keys(array_filter($dhlConfig['account_numbers'] ?? [])),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Build a redacted view of the order data prepared for DHL.
|
||||||
|
*
|
||||||
|
* Strips personally identifiable information like full names, addresses,
|
||||||
|
* phone numbers and e-mail addresses. Keeps the routing-relevant fields
|
||||||
|
* needed to debug a failing label generation.
|
||||||
|
*
|
||||||
|
* @param array<string, mixed> $orderData
|
||||||
|
* @return array<string, mixed>
|
||||||
|
*/
|
||||||
|
public static function sanitizeOrderDataForLog(array $orderData): array
|
||||||
|
{
|
||||||
|
$consigneeCountry = $orderData['consignee']['country'] ?? null;
|
||||||
|
$consigneePostal = $orderData['consignee']['postalCode'] ?? null;
|
||||||
|
|
||||||
|
return [
|
||||||
|
'order_id' => $orderData['order_id'] ?? null,
|
||||||
|
'product_code' => $orderData['product_code'] ?? null,
|
||||||
|
'weight_kg' => $orderData['weight_kg'] ?? null,
|
||||||
|
'label_format' => $orderData['label_format'] ?? null,
|
||||||
|
'print_format' => $orderData['print_format'] ?? null,
|
||||||
|
'print_only_if_codeable' => (bool) ($orderData['print_only_if_codeable'] ?? false),
|
||||||
|
'consignee_country' => $consigneeCountry,
|
||||||
|
'consignee_postal_prefix' => is_string($consigneePostal) && $consigneePostal !== ''
|
||||||
|
? mb_substr($consigneePostal, 0, 2)
|
||||||
|
: null,
|
||||||
|
'consignee_has_post_number' => ! empty($orderData['consignee']['postNumber']),
|
||||||
|
'has_reference' => ! empty($orderData['reference']),
|
||||||
|
];
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -6,44 +6,110 @@ use Acme\Dhl\Models\DhlShipment;
|
||||||
use Acme\Dhl\Models\DhlTrackingEvent;
|
use Acme\Dhl\Models\DhlTrackingEvent;
|
||||||
use App\Http\Controllers\SettingController;
|
use App\Http\Controllers\SettingController;
|
||||||
use App\Jobs\TrackShipmentJob;
|
use App\Jobs\TrackShipmentJob;
|
||||||
|
use Carbon\CarbonInterface;
|
||||||
use Exception;
|
use Exception;
|
||||||
|
use Illuminate\Support\Carbon;
|
||||||
use Illuminate\Support\Collection;
|
use Illuminate\Support\Collection;
|
||||||
|
use Illuminate\Support\Facades\Cache;
|
||||||
use Illuminate\Support\Facades\Http;
|
use Illuminate\Support\Facades\Http;
|
||||||
use Illuminate\Support\Facades\Log;
|
use Illuminate\Support\Facades\Log;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* DHL Tracking Service
|
* DHL Tracking Service
|
||||||
*
|
*
|
||||||
* Handles DHL tracking using both Unified Tracking API and Parcel DE Tracking API
|
* Handles DHL tracking via the Unified Shipment Tracking API
|
||||||
* with support for synchronous and asynchronous tracking updates
|
* (https://developer.dhl.com/api-reference/shipment-tracking) with support
|
||||||
|
* for synchronous and asynchronous tracking updates. The previous
|
||||||
|
* "Parcel DE tracking" fallback was removed because that endpoint does not
|
||||||
|
* exist in the official DHL API catalogue.
|
||||||
*/
|
*/
|
||||||
class DhlTrackingService
|
class DhlTrackingService
|
||||||
{
|
{
|
||||||
|
/**
|
||||||
|
* Unified Shipment Tracking endpoint.
|
||||||
|
*
|
||||||
|
* According to the official DHL Developer Portal documentation
|
||||||
|
* (https://developer.dhl.com/api-reference/shipment-tracking) this single
|
||||||
|
* URL serves both sandbox and production. The sandbox/production routing
|
||||||
|
* is decided by the API key itself - which subscriptions the key has.
|
||||||
|
*/
|
||||||
|
private const TRACKING_ENDPOINT = 'https://api-eu.dhl.com/track/shipments';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Cache key for a "do not call DHL right now" gate.
|
||||||
|
*
|
||||||
|
* Once DHL responds with HTTP 429 (daily quota exhausted) we store an
|
||||||
|
* absolute "paused until" timestamp here. Every subsequent tracking call
|
||||||
|
* inside the gate window skips the HTTP request entirely - both to stop
|
||||||
|
* wasting the small standard quota (250/day, 1 call / 5 s) on requests
|
||||||
|
* that DHL will reject anyway, and to make the outage visible in logs
|
||||||
|
* and in the cron summary instead of producing a stream of misleading
|
||||||
|
* per-shipment errors.
|
||||||
|
*/
|
||||||
|
private const QUOTA_PAUSE_CACHE_KEY = 'dhl_tracking:quota_paused_until';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Fallback pause duration (seconds) when DHL did not send a Retry-After
|
||||||
|
* header. One hour is a deliberately conservative compromise: short
|
||||||
|
* enough that quota recovery after a manual upgrade is picked up
|
||||||
|
* quickly, long enough that the hourly cron does not burn ~1 call per
|
||||||
|
* run just to confirm the quota is still exhausted.
|
||||||
|
*/
|
||||||
|
private const DEFAULT_QUOTA_PAUSE_SECONDS = 3600;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Minimum gap between two consecutive DHL tracking calls.
|
||||||
|
*
|
||||||
|
* Per https://developer.dhl.com/api-reference/shipment-tracking#rate-limits
|
||||||
|
* the standard Shipment Tracking - Unified API service level enforces
|
||||||
|
* "a maximum of 1 call every 5 seconds". The previous "batch" path
|
||||||
|
* pretended to bundle 10 trackings into one request, but the Unified
|
||||||
|
* API only accepts a single `trackingNumber` parameter - the pseudo
|
||||||
|
* batch call was always interpreted as one unknown shipment ID and
|
||||||
|
* the code silently fell back to per-shipment calls anyway. We now do
|
||||||
|
* one call per shipment and pause this many seconds between them so
|
||||||
|
* the cron stays within the documented rate budget.
|
||||||
|
*/
|
||||||
|
private static int $callIntervalSeconds = 5;
|
||||||
|
|
||||||
private string $apiKey;
|
private string $apiKey;
|
||||||
|
|
||||||
private string $apiSecret;
|
|
||||||
|
|
||||||
private bool $isSandbox;
|
|
||||||
|
|
||||||
public function __construct()
|
public function __construct()
|
||||||
{
|
{
|
||||||
$settingController = new SettingController;
|
$settingController = new SettingController;
|
||||||
$dhlConfig = $settingController->getDhlConfig();
|
$dhlConfig = $settingController->getDhlConfig();
|
||||||
|
|
||||||
$this->apiKey = $dhlConfig['api_key'] ?? config('dhl.api_key');
|
$this->apiKey = $dhlConfig['api_key'] ?? config('dhl.api_key');
|
||||||
$this->apiSecret = $dhlConfig['api_secret'] ?? config('dhl.legacy.api_secret');
|
|
||||||
$this->isSandbox = ($dhlConfig['sandbox'] ?? config('dhl.legacy.sandbox', true));
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Track shipment using DHL Unified Tracking API (recommended for international)
|
* Track a single shipment via the DHL Unified Shipment Tracking API.
|
||||||
|
*
|
||||||
|
* The previous implementation tried a "Parcel DE tracking" endpoint as
|
||||||
|
* fallback when the Unified API failed. That endpoint
|
||||||
|
* (`/parcel/de/tracking/v0/shipments`) does not exist in the official
|
||||||
|
* DHL API catalogue - the only documented German tracking surface is the
|
||||||
|
* Unified Tracking API. The fallback only produced more 401s with a
|
||||||
|
* misleading "Sendung nicht gefunden" message and is therefore removed.
|
||||||
*/
|
*/
|
||||||
public function trackShipment(string $trackingNumber, array $options = []): array
|
public function trackShipment(string $trackingNumber, array $options = []): array
|
||||||
{
|
{
|
||||||
|
if ($pausedUntil = self::getQuotaPausedUntil()) {
|
||||||
|
Log::info('[DHL Tracking Service] Skipping single tracking - quota pause active', [
|
||||||
|
'tracking_number' => $trackingNumber,
|
||||||
|
'paused_until' => $pausedUntil->toIso8601String(),
|
||||||
|
]);
|
||||||
|
|
||||||
|
return self::buildQuotaPausedResponse($pausedUntil) + [
|
||||||
|
'tracking_number' => $trackingNumber,
|
||||||
|
'api_used' => 'unified',
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
Log::info('[DHL Tracking Service] Tracking shipment with Unified API', [
|
Log::info('[DHL Tracking Service] Tracking shipment with Unified API', [
|
||||||
'tracking_number' => $trackingNumber,
|
'tracking_number' => $trackingNumber,
|
||||||
'is_sandbox' => $this->isSandbox,
|
'endpoint' => self::TRACKING_ENDPOINT,
|
||||||
'has_api_key' => ! empty($this->apiKey),
|
'has_api_key' => ! empty($this->apiKey),
|
||||||
]);
|
]);
|
||||||
|
|
||||||
|
|
@ -51,241 +117,362 @@ class DhlTrackingService
|
||||||
'DHL-API-Key' => $this->apiKey,
|
'DHL-API-Key' => $this->apiKey,
|
||||||
'Accept' => 'application/json',
|
'Accept' => 'application/json',
|
||||||
])
|
])
|
||||||
->withOptions([
|
->withOptions($this->buildHttpOptions())
|
||||||
'verify' => config('dhl.ssl.verify_peer', true),
|
->get(self::TRACKING_ENDPOINT, [
|
||||||
'http_errors' => false,
|
|
||||||
'curl' => [
|
|
||||||
CURLOPT_SSL_VERIFYPEER => config('dhl.ssl.verify_peer', true),
|
|
||||||
CURLOPT_SSL_VERIFYHOST => config('dhl.ssl.verify_host', true) ? 2 : 0,
|
|
||||||
CURLOPT_SSLVERSION => $this->getSslVersion(),
|
|
||||||
CURLOPT_FOLLOWLOCATION => true,
|
|
||||||
CURLOPT_MAXREDIRS => 5,
|
|
||||||
CURLOPT_CONNECTTIMEOUT => config('dhl.ssl.connect_timeout', 10),
|
|
||||||
CURLOPT_TIMEOUT => config('dhl.ssl.timeout', 30),
|
|
||||||
CURLOPT_USERAGENT => 'acme-laravel-dhl/1.0',
|
|
||||||
],
|
|
||||||
])
|
|
||||||
->get('https://api-eu.dhl.com/track/shipments', [
|
|
||||||
'trackingNumber' => $trackingNumber,
|
'trackingNumber' => $trackingNumber,
|
||||||
'requesterCountryCode' => 'DE',
|
'requesterCountryCode' => 'DE',
|
||||||
'originCountryCode' => 'DE',
|
'originCountryCode' => 'DE',
|
||||||
'language' => 'de',
|
'language' => 'de',
|
||||||
]);
|
]);
|
||||||
|
|
||||||
Log::info('[DHL Tracking Service] Unified API response', [
|
return $this->processSingleShipmentResponse($trackingNumber, $response);
|
||||||
'tracking_number' => $trackingNumber,
|
|
||||||
'status_code' => $response->status(),
|
|
||||||
'successful' => $response->successful(),
|
|
||||||
]);
|
|
||||||
|
|
||||||
if ($response->successful()) {
|
|
||||||
$data = $response->json();
|
|
||||||
|
|
||||||
if (isset($data['shipments']) && count($data['shipments']) > 0) {
|
|
||||||
$shipment = $data['shipments'][0];
|
|
||||||
|
|
||||||
return [
|
|
||||||
'success' => true,
|
|
||||||
'tracking_number' => $shipment['id'],
|
|
||||||
'status' => $shipment['status']['statusCode'] ?? 'unknown',
|
|
||||||
'status_text' => $shipment['status']['description'] ?? ($shipment['status']['status'] ?? 'Unbekannt'),
|
|
||||||
'description' => $shipment['status']['remark'] ?? ($shipment['status']['description'] ?? ''),
|
|
||||||
'last_update' => $shipment['status']['timestamp'] ?? null,
|
|
||||||
'origin' => $shipment['origin']['address']['addressLocality'] ?? null,
|
|
||||||
'destination' => $shipment['destination']['address']['addressLocality'] ?? null,
|
|
||||||
'events' => $shipment['events'] ?? [],
|
|
||||||
'api_used' => 'unified',
|
|
||||||
];
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
Log::warning('[DHL Tracking Service] Unified API did not find shipment, trying Parcel DE API', [
|
|
||||||
'tracking_number' => $trackingNumber,
|
|
||||||
'status_code' => $response->status(),
|
|
||||||
'response_snippet' => mb_substr($response->body(), 0, 500),
|
|
||||||
]);
|
|
||||||
|
|
||||||
// If Unified API fails, try Parcel DE API
|
|
||||||
return $this->trackShipmentDE($trackingNumber, $options);
|
|
||||||
} catch (Exception $e) {
|
} catch (Exception $e) {
|
||||||
Log::error('[DHL Tracking Service] Unified API failed', [
|
Log::error('[DHL Tracking Service] Unified API request threw', [
|
||||||
'tracking_number' => $trackingNumber,
|
'tracking_number' => $trackingNumber,
|
||||||
|
'endpoint' => self::TRACKING_ENDPOINT,
|
||||||
'error' => $e->getMessage(),
|
'error' => $e->getMessage(),
|
||||||
]);
|
]);
|
||||||
|
|
||||||
// Fallback to Parcel DE API
|
|
||||||
return $this->trackShipmentDE($trackingNumber, $options);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Track shipment using DHL Parcel DE Tracking API (optimized for Germany)
|
|
||||||
*/
|
|
||||||
public function trackShipmentDE(string $trackingNumber, array $options = []): array
|
|
||||||
{
|
|
||||||
try {
|
|
||||||
Log::info('[DHL Tracking Service] Tracking shipment with Parcel DE API', [
|
|
||||||
'tracking_number' => $trackingNumber,
|
|
||||||
'is_sandbox' => $this->isSandbox,
|
|
||||||
'has_api_key' => ! empty($this->apiKey),
|
|
||||||
'has_api_secret' => ! empty($this->apiSecret),
|
|
||||||
]);
|
|
||||||
|
|
||||||
$response = Http::withHeaders([
|
|
||||||
'DHL-API-Key' => $this->apiKey,
|
|
||||||
'Accept' => 'application/json',
|
|
||||||
])
|
|
||||||
->withOptions([
|
|
||||||
'verify' => config('dhl.ssl.verify_peer', true),
|
|
||||||
'http_errors' => false,
|
|
||||||
'curl' => [
|
|
||||||
CURLOPT_SSL_VERIFYPEER => config('dhl.ssl.verify_peer', true),
|
|
||||||
CURLOPT_SSL_VERIFYHOST => config('dhl.ssl.verify_host', true) ? 2 : 0,
|
|
||||||
CURLOPT_SSLVERSION => $this->getSslVersion(),
|
|
||||||
CURLOPT_FOLLOWLOCATION => true,
|
|
||||||
CURLOPT_MAXREDIRS => 5,
|
|
||||||
CURLOPT_CONNECTTIMEOUT => config('dhl.ssl.connect_timeout', 10),
|
|
||||||
CURLOPT_TIMEOUT => config('dhl.ssl.timeout', 30),
|
|
||||||
CURLOPT_USERAGENT => 'acme-laravel-dhl/1.0',
|
|
||||||
],
|
|
||||||
])
|
|
||||||
->get('https://api-eu.dhl.com/parcel/de/tracking/v0/shipments', [
|
|
||||||
'shipmentId' => $trackingNumber,
|
|
||||||
'language' => 'de',
|
|
||||||
]);
|
|
||||||
|
|
||||||
Log::info('[DHL Tracking Service] Parcel DE API response', [
|
|
||||||
'tracking_number' => $trackingNumber,
|
|
||||||
'status_code' => $response->status(),
|
|
||||||
'successful' => $response->successful(),
|
|
||||||
'response_body' => $response->body(),
|
|
||||||
]);
|
|
||||||
|
|
||||||
if ($response->successful()) {
|
|
||||||
$data = $response->json();
|
|
||||||
|
|
||||||
if (isset($data['shipments']) && count($data['shipments']) > 0) {
|
|
||||||
$shipment = $data['shipments'][0];
|
|
||||||
|
|
||||||
return [
|
|
||||||
'success' => true,
|
|
||||||
'tracking_number' => $shipment['id'],
|
|
||||||
'status' => $shipment['status']['statusCode'] ?? 'unknown',
|
|
||||||
'status_text' => $shipment['status']['description'] ?? 'Unbekannt',
|
|
||||||
'description' => $shipment['status']['description'] ?? '',
|
|
||||||
'last_update' => $shipment['status']['timestamp'] ?? null,
|
|
||||||
'events' => $shipment['events'] ?? [],
|
|
||||||
'api_used' => 'parcel_de',
|
|
||||||
];
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Log detailed error information
|
|
||||||
Log::warning('[DHL Tracking Service] Shipment not found or not yet tracked', [
|
|
||||||
'tracking_number' => $trackingNumber,
|
|
||||||
'status_code' => $response->status(),
|
|
||||||
'response_body' => $response->body(),
|
|
||||||
]);
|
|
||||||
|
|
||||||
return [
|
|
||||||
'success' => false,
|
|
||||||
'message' => 'Sendung nicht gefunden oder noch nicht im System erfasst. HTTP Status: '.$response->status(),
|
|
||||||
'tracking_number' => $trackingNumber,
|
|
||||||
'api_used' => 'parcel_de',
|
|
||||||
'debug_info' => [
|
|
||||||
'status_code' => $response->status(),
|
|
||||||
'response' => $response->json(),
|
|
||||||
],
|
|
||||||
];
|
|
||||||
} catch (Exception $e) {
|
|
||||||
Log::error('[DHL Tracking Service] Parcel DE API failed', [
|
|
||||||
'tracking_number' => $trackingNumber,
|
|
||||||
'error' => $e->getMessage(),
|
|
||||||
'trace' => $e->getTraceAsString(),
|
|
||||||
]);
|
|
||||||
|
|
||||||
return [
|
return [
|
||||||
'success' => false,
|
'success' => false,
|
||||||
'message' => 'Fehler beim Abrufen der Tracking-Informationen: '.$e->getMessage(),
|
'message' => 'Fehler beim Abrufen der Tracking-Informationen: '.$e->getMessage(),
|
||||||
'tracking_number' => $trackingNumber,
|
'tracking_number' => $trackingNumber,
|
||||||
'api_used' => 'parcel_de',
|
'api_used' => 'unified',
|
||||||
|
'transport_error' => true,
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Track multiple shipments at once (up to 10 for Unified API)
|
* Build the standard cURL/Guzzle options used for every DHL tracking call.
|
||||||
|
*
|
||||||
|
* @return array<string, mixed>
|
||||||
*/
|
*/
|
||||||
public function trackMultipleShipments(array $trackingNumbers): array
|
private function buildHttpOptions(): array
|
||||||
{
|
{
|
||||||
if (count($trackingNumbers) > 10) {
|
return [
|
||||||
return [
|
'verify' => config('dhl.ssl.verify_peer', true),
|
||||||
'success' => false,
|
'http_errors' => false,
|
||||||
'message' => 'Maximal 10 Sendungen können gleichzeitig getrackt werden.',
|
'curl' => [
|
||||||
];
|
CURLOPT_SSL_VERIFYPEER => config('dhl.ssl.verify_peer', true),
|
||||||
}
|
CURLOPT_SSL_VERIFYHOST => config('dhl.ssl.verify_host', true) ? 2 : 0,
|
||||||
|
CURLOPT_SSLVERSION => $this->getSslVersion(),
|
||||||
|
CURLOPT_FOLLOWLOCATION => true,
|
||||||
|
CURLOPT_MAXREDIRS => 5,
|
||||||
|
CURLOPT_CONNECTTIMEOUT => config('dhl.ssl.connect_timeout', 10),
|
||||||
|
CURLOPT_TIMEOUT => config('dhl.ssl.timeout', 30),
|
||||||
|
CURLOPT_USERAGENT => 'acme-laravel-dhl/1.0',
|
||||||
|
],
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
try {
|
/**
|
||||||
$response = Http::withHeaders([
|
* Normalize a single Unified Tracking API response into our internal
|
||||||
'DHL-API-Key' => $this->apiKey,
|
* structure, distinguishing the three outcomes the caller actually cares
|
||||||
'Accept' => 'application/json',
|
* about: success, "not found", and "auth/transport error".
|
||||||
])
|
*
|
||||||
->withOptions([
|
* @param \Illuminate\Http\Client\Response $response
|
||||||
'verify' => config('dhl.ssl.verify_peer', true),
|
* @return array<string, mixed>
|
||||||
'http_errors' => false,
|
*/
|
||||||
'curl' => [
|
private function processSingleShipmentResponse(string $trackingNumber, $response): array
|
||||||
CURLOPT_SSL_VERIFYPEER => config('dhl.ssl.verify_peer', true),
|
{
|
||||||
CURLOPT_SSL_VERIFYHOST => config('dhl.ssl.verify_host', true) ? 2 : 0,
|
$status = $response->status();
|
||||||
CURLOPT_SSLVERSION => $this->getSslVersion(),
|
|
||||||
CURLOPT_FOLLOWLOCATION => true,
|
|
||||||
CURLOPT_MAXREDIRS => 5,
|
|
||||||
CURLOPT_CONNECTTIMEOUT => config('dhl.ssl.connect_timeout', 10),
|
|
||||||
CURLOPT_TIMEOUT => config('dhl.ssl.timeout', 30),
|
|
||||||
CURLOPT_USERAGENT => 'acme-laravel-dhl/1.0',
|
|
||||||
],
|
|
||||||
])
|
|
||||||
->get('https://api-eu.dhl.com/track/shipments', [
|
|
||||||
'trackingNumber' => implode(',', $trackingNumbers),
|
|
||||||
'requesterCountryCode' => 'DE',
|
|
||||||
'language' => 'de',
|
|
||||||
]);
|
|
||||||
|
|
||||||
if ($response->successful()) {
|
Log::info('[DHL Tracking Service] Unified API response', [
|
||||||
$data = $response->json();
|
'tracking_number' => $trackingNumber,
|
||||||
$results = [];
|
'status_code' => $status,
|
||||||
|
'successful' => $response->successful(),
|
||||||
|
]);
|
||||||
|
|
||||||
foreach ($data['shipments'] ?? [] as $shipment) {
|
if ($response->successful()) {
|
||||||
$results[] = [
|
$data = $response->json();
|
||||||
'tracking_number' => $shipment['id'],
|
|
||||||
'status' => $shipment['status']['statusCode'] ?? 'unknown',
|
if (isset($data['shipments']) && count($data['shipments']) > 0) {
|
||||||
'status_text' => $shipment['status']['status'] ?? 'Unbekannt',
|
$shipment = $data['shipments'][0];
|
||||||
'last_update' => $shipment['status']['timestamp'] ?? null,
|
|
||||||
'events' => $shipment['events'] ?? [],
|
|
||||||
];
|
|
||||||
}
|
|
||||||
|
|
||||||
return [
|
return [
|
||||||
'success' => true,
|
'success' => true,
|
||||||
'shipments' => $results,
|
'tracking_number' => $shipment['id'],
|
||||||
|
'status' => $shipment['status']['statusCode'] ?? 'unknown',
|
||||||
|
'status_text' => $shipment['status']['description'] ?? ($shipment['status']['status'] ?? 'Unbekannt'),
|
||||||
|
'description' => $shipment['status']['remark'] ?? ($shipment['status']['description'] ?? ''),
|
||||||
|
'last_update' => $shipment['status']['timestamp'] ?? null,
|
||||||
|
'origin' => $shipment['origin']['address']['addressLocality'] ?? null,
|
||||||
|
'destination' => $shipment['destination']['address']['addressLocality'] ?? null,
|
||||||
|
'events' => $shipment['events'] ?? [],
|
||||||
'api_used' => 'unified',
|
'api_used' => 'unified',
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|
||||||
return [
|
Log::warning('[DHL Tracking Service] Unified API returned no shipments', [
|
||||||
'success' => false,
|
'tracking_number' => $trackingNumber,
|
||||||
'message' => 'Fehler beim Abrufen der Tracking-Informationen.',
|
'status_code' => $status,
|
||||||
];
|
|
||||||
} catch (Exception $e) {
|
|
||||||
Log::error('[DHL Tracking Service] Multiple tracking failed', [
|
|
||||||
'tracking_numbers' => $trackingNumbers,
|
|
||||||
'error' => $e->getMessage(),
|
|
||||||
]);
|
]);
|
||||||
|
|
||||||
return [
|
return [
|
||||||
'success' => false,
|
'success' => false,
|
||||||
'message' => 'Fehler beim Abrufen der Tracking-Informationen: '.$e->getMessage(),
|
'message' => 'Sendung nicht gefunden oder noch nicht im DHL-System erfasst.',
|
||||||
|
'tracking_number' => $trackingNumber,
|
||||||
|
'api_used' => 'unified',
|
||||||
|
'http_status' => $status,
|
||||||
|
'not_found' => true,
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (self::isAuthErrorStatus($status)) {
|
||||||
|
Log::error('[DHL Tracking Service] Unified API authentication failed', [
|
||||||
|
'tracking_number' => $trackingNumber,
|
||||||
|
'status_code' => $status,
|
||||||
|
'endpoint' => self::TRACKING_ENDPOINT,
|
||||||
|
'has_api_key' => ! empty($this->apiKey),
|
||||||
|
'api_key_suffix' => self::redactApiKey($this->apiKey),
|
||||||
|
]);
|
||||||
|
|
||||||
|
return [
|
||||||
|
'success' => false,
|
||||||
|
'message' => self::buildAuthErrorMessage($status),
|
||||||
|
'tracking_number' => $trackingNumber,
|
||||||
|
'api_used' => 'unified',
|
||||||
|
'http_status' => $status,
|
||||||
|
'auth_error' => true,
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($status === 429) {
|
||||||
|
$retryAfter = self::extractRetryAfter($response);
|
||||||
|
|
||||||
|
Log::error('[DHL Tracking Service] Unified API rate-limited', [
|
||||||
|
'tracking_number' => $trackingNumber,
|
||||||
|
'status_code' => 429,
|
||||||
|
'endpoint' => self::TRACKING_ENDPOINT,
|
||||||
|
'retry_after_seconds' => $retryAfter,
|
||||||
|
'api_key_suffix' => self::redactApiKey($this->apiKey),
|
||||||
|
]);
|
||||||
|
|
||||||
|
self::pauseQuota($retryAfter);
|
||||||
|
|
||||||
|
return [
|
||||||
|
'success' => false,
|
||||||
|
'message' => self::buildRateLimitMessage($retryAfter),
|
||||||
|
'tracking_number' => $trackingNumber,
|
||||||
|
'api_used' => 'unified',
|
||||||
|
'http_status' => 429,
|
||||||
|
'rate_limited' => true,
|
||||||
|
'retry_after' => $retryAfter,
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($status === 404) {
|
||||||
|
Log::info('[DHL Tracking Service] Unified API returned 404 for shipment', [
|
||||||
|
'tracking_number' => $trackingNumber,
|
||||||
|
]);
|
||||||
|
|
||||||
|
return [
|
||||||
|
'success' => false,
|
||||||
|
'message' => 'Sendung nicht gefunden oder noch nicht im DHL-System erfasst.',
|
||||||
|
'tracking_number' => $trackingNumber,
|
||||||
|
'api_used' => 'unified',
|
||||||
|
'http_status' => $status,
|
||||||
|
'not_found' => true,
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
Log::warning('[DHL Tracking Service] Unified API returned unexpected status', [
|
||||||
|
'tracking_number' => $trackingNumber,
|
||||||
|
'status_code' => $status,
|
||||||
|
'response_snippet' => mb_substr((string) $response->body(), 0, 500),
|
||||||
|
]);
|
||||||
|
|
||||||
|
return [
|
||||||
|
'success' => false,
|
||||||
|
'message' => 'DHL Tracking API antwortet mit HTTP '.$status.'. Bitte API-Status und Konfiguration pruefen.',
|
||||||
|
'tracking_number' => $trackingNumber,
|
||||||
|
'api_used' => 'unified',
|
||||||
|
'http_status' => $status,
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Returns true when DHL signals an authentication / authorization problem.
|
||||||
|
*/
|
||||||
|
private static function isAuthErrorStatus(int $status): bool
|
||||||
|
{
|
||||||
|
return $status === 401 || $status === 403;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Build the user-facing message for an authentication problem.
|
||||||
|
*/
|
||||||
|
private static function buildAuthErrorMessage(int $status): string
|
||||||
|
{
|
||||||
|
return 'DHL Tracking API: Authentifizierung fehlgeschlagen (HTTP '.$status.'). '
|
||||||
|
.'Bitte pruefen, ob der hinterlegte DHL-API-Key gueltig ist und im '
|
||||||
|
.'DHL Developer Portal fuer "Shipment Tracking - Unified" freigeschaltet wurde.';
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Build the user-facing message for a DHL rate-limit response (HTTP 429).
|
||||||
|
*/
|
||||||
|
private static function buildRateLimitMessage(?int $retryAfter): string
|
||||||
|
{
|
||||||
|
$base = 'DHL Tracking API: Tageslimit erreicht (HTTP 429). '
|
||||||
|
.'Die DHL-API liefert vorruebergehend keine Daten mehr. '
|
||||||
|
.'Standard-Apps haben laut DHL-Doku 250 Aufrufe pro Tag und max. 1 Aufruf alle 5 Sekunden. '
|
||||||
|
.'Bei Bedarf im DHL Developer Portal eine Quota-Erhoehung beantragen.';
|
||||||
|
|
||||||
|
if ($retryAfter !== null && $retryAfter > 0) {
|
||||||
|
$minutes = (int) ceil($retryAfter / 60);
|
||||||
|
|
||||||
|
return $base.' Naechster Versuch fruehestens in '.$minutes.' Minute(n).';
|
||||||
|
}
|
||||||
|
|
||||||
|
return $base;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Extract a `Retry-After` header value (in seconds) if DHL sent one.
|
||||||
|
*
|
||||||
|
* @param \Illuminate\Http\Client\Response $response
|
||||||
|
*/
|
||||||
|
private static function extractRetryAfter($response): ?int
|
||||||
|
{
|
||||||
|
$header = $response->header('Retry-After');
|
||||||
|
|
||||||
|
if ($header === null || $header === '') {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (ctype_digit(trim($header))) {
|
||||||
|
return (int) $header;
|
||||||
|
}
|
||||||
|
|
||||||
|
// RFC 7231 also allows an HTTP-date; convert to seconds if so.
|
||||||
|
$timestamp = strtotime($header);
|
||||||
|
if ($timestamp !== false) {
|
||||||
|
return max(0, $timestamp - time());
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Reduce an API key to a non-sensitive marker for log correlation.
|
||||||
|
*/
|
||||||
|
private static function redactApiKey(?string $apiKey): ?string
|
||||||
|
{
|
||||||
|
if ($apiKey === null || $apiKey === '') {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return '***'.mb_substr($apiKey, -4);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Is the tracking client currently in a "do not call DHL" window because
|
||||||
|
* we recently received an HTTP 429?
|
||||||
|
*/
|
||||||
|
public static function isQuotaPaused(): bool
|
||||||
|
{
|
||||||
|
return self::getQuotaPausedUntil() !== null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Absolute "do not call DHL before" timestamp from the cache, or `null`
|
||||||
|
* if the pause has expired (or was never set).
|
||||||
|
*/
|
||||||
|
public static function getQuotaPausedUntil(): ?CarbonInterface
|
||||||
|
{
|
||||||
|
$value = Cache::get(self::QUOTA_PAUSE_CACHE_KEY);
|
||||||
|
|
||||||
|
if ($value === null) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
$paused = $value instanceof CarbonInterface ? $value : Carbon::parse($value);
|
||||||
|
} catch (Exception $e) {
|
||||||
|
Cache::forget(self::QUOTA_PAUSE_CACHE_KEY);
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($paused->isPast()) {
|
||||||
|
Cache::forget(self::QUOTA_PAUSE_CACHE_KEY);
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return $paused;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Mark the DHL tracking quota as exhausted for the given number of
|
||||||
|
* seconds. Defaults to {@see self::DEFAULT_QUOTA_PAUSE_SECONDS} when
|
||||||
|
* DHL did not send a `Retry-After` header.
|
||||||
|
*/
|
||||||
|
public static function pauseQuota(?int $retryAfterSeconds = null): void
|
||||||
|
{
|
||||||
|
$seconds = ($retryAfterSeconds !== null && $retryAfterSeconds > 0)
|
||||||
|
? $retryAfterSeconds
|
||||||
|
: self::DEFAULT_QUOTA_PAUSE_SECONDS;
|
||||||
|
|
||||||
|
$until = Carbon::now()->addSeconds($seconds);
|
||||||
|
|
||||||
|
Cache::put(self::QUOTA_PAUSE_CACHE_KEY, $until, $until);
|
||||||
|
|
||||||
|
Log::warning('[DHL Tracking Service] Quota pause activated', [
|
||||||
|
'paused_until' => $until->toIso8601String(),
|
||||||
|
'seconds' => $seconds,
|
||||||
|
'source' => $retryAfterSeconds !== null ? 'retry_after_header' : 'default',
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Manually clear the quota pause - intended for tests and for the rare
|
||||||
|
* case where an operator wants to retry immediately after fixing the
|
||||||
|
* underlying account problem.
|
||||||
|
*/
|
||||||
|
public static function clearQuotaPause(): void
|
||||||
|
{
|
||||||
|
Cache::forget(self::QUOTA_PAUSE_CACHE_KEY);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Override the inter-call throttle for tests or for environments where
|
||||||
|
* a custom DHL service level allows faster polling than 1 call / 5 s.
|
||||||
|
*/
|
||||||
|
public static function setCallIntervalSeconds(int $seconds): void
|
||||||
|
{
|
||||||
|
self::$callIntervalSeconds = max(0, $seconds);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Current inter-call throttle in seconds.
|
||||||
|
*/
|
||||||
|
public static function getCallIntervalSeconds(): int
|
||||||
|
{
|
||||||
|
return self::$callIntervalSeconds;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Build the "we did not call DHL because of the quota pause" response
|
||||||
|
* used by all entry points when the pause window is active.
|
||||||
|
*
|
||||||
|
* @return array{success: false, message: string, http_status: 429, rate_limited: true, paused_until: string, retry_after: int}
|
||||||
|
*/
|
||||||
|
private static function buildQuotaPausedResponse(CarbonInterface $until): array
|
||||||
|
{
|
||||||
|
$retryAfter = max(1, $until->diffInSeconds(Carbon::now(), false) * -1);
|
||||||
|
|
||||||
|
return [
|
||||||
|
'success' => false,
|
||||||
|
'message' => self::buildRateLimitMessage($retryAfter)
|
||||||
|
.' (Lokale Quota-Pause aktiv bis '.$until->copy()->setTimezone(config('app.timezone'))->format('d.m.Y H:i').' Uhr.)',
|
||||||
|
'http_status' => 429,
|
||||||
|
'rate_limited' => true,
|
||||||
|
'paused_until' => $until->toIso8601String(),
|
||||||
|
'retry_after' => $retryAfter,
|
||||||
|
];
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
@ -413,14 +600,28 @@ class DhlTrackingService
|
||||||
'tracking_completed' => in_array($internalStatus, DhlShipment::TERMINAL_STATUSES),
|
'tracking_completed' => in_array($internalStatus, DhlShipment::TERMINAL_STATUSES),
|
||||||
'tracking_details' => $result,
|
'tracking_details' => $result,
|
||||||
];
|
];
|
||||||
} else {
|
|
||||||
return [
|
|
||||||
'success' => false,
|
|
||||||
'message' => $result['message'] ?? 'Fehler beim Abrufen der Tracking-Informationen.',
|
|
||||||
'queued' => false,
|
|
||||||
'shipment_id' => $shipment->id,
|
|
||||||
];
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// No success. Only mark the shipment as "checked just now" when DHL
|
||||||
|
// explicitly told us the shipment is unknown - so we do not retry
|
||||||
|
// immediately for those. Auth / transport problems must NOT update
|
||||||
|
// `last_tracked_at` because otherwise stale status data appears
|
||||||
|
// freshly refreshed in the UI and operators stop noticing the
|
||||||
|
// tracking outage (this was the symptom reported on production).
|
||||||
|
if (! empty($result['not_found'])) {
|
||||||
|
$shipment->update(['last_tracked_at' => now()]);
|
||||||
|
}
|
||||||
|
|
||||||
|
return [
|
||||||
|
'success' => false,
|
||||||
|
'message' => $result['message'] ?? 'Fehler beim Abrufen der Tracking-Informationen.',
|
||||||
|
'queued' => false,
|
||||||
|
'shipment_id' => $shipment->id,
|
||||||
|
'auth_error' => $result['auth_error'] ?? false,
|
||||||
|
'rate_limited' => $result['rate_limited'] ?? false,
|
||||||
|
'retry_after' => $result['retry_after'] ?? null,
|
||||||
|
'http_status' => $result['http_status'] ?? null,
|
||||||
|
];
|
||||||
} catch (Exception $e) {
|
} catch (Exception $e) {
|
||||||
Log::error('[DHL Tracking Service] Tracking update failed (sync)', [
|
Log::error('[DHL Tracking Service] Tracking update failed (sync)', [
|
||||||
'shipment_id' => $shipment->id,
|
'shipment_id' => $shipment->id,
|
||||||
|
|
@ -437,11 +638,26 @@ class DhlTrackingService
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Update tracking for a batch of DHL shipments using the multi-tracking API.
|
* Update tracking for a collection of DHL shipments.
|
||||||
* Processes shipments in chunks of 10 (DHL API limit) with rate-limiting pauses.
|
|
||||||
*
|
*
|
||||||
* @param Collection<DhlShipment> $shipments
|
* Each shipment triggers exactly one DHL Unified Tracking API call
|
||||||
* @return array{updated: int, failed: int, completed: int, results: array}
|
* (`trackingNumber` is a singular parameter). Between calls we pause
|
||||||
|
* {@see self::$callIntervalSeconds} seconds to respect the documented
|
||||||
|
* "max 1 call every 5 seconds" rate limit.
|
||||||
|
*
|
||||||
|
* The loop bails out early on:
|
||||||
|
* - a cached quota pause (no HTTP request at all),
|
||||||
|
* - the first DHL authentication failure (401/403),
|
||||||
|
* - the first DHL rate-limit response (429, which also activates the
|
||||||
|
* process-wide quota pause for subsequent runs).
|
||||||
|
*
|
||||||
|
* `last_tracked_at` is only updated by the underlying
|
||||||
|
* {@see self::updateTrackingSync()} on success or on an explicit
|
||||||
|
* "not found" - never on auth/transport/rate-limit failures - so
|
||||||
|
* stale statuses never appear freshly refreshed in the cockpit.
|
||||||
|
*
|
||||||
|
* @param Collection<int, DhlShipment> $shipments
|
||||||
|
* @return array{updated: int, failed: int, completed: int, results: array<int, array<string, mixed>>}
|
||||||
*/
|
*/
|
||||||
public function updateTrackingBatch(Collection $shipments): array
|
public function updateTrackingBatch(Collection $shipments): array
|
||||||
{
|
{
|
||||||
|
|
@ -452,140 +668,84 @@ class DhlTrackingService
|
||||||
'results' => [],
|
'results' => [],
|
||||||
];
|
];
|
||||||
|
|
||||||
// Process in chunks of 10 (DHL API limit)
|
if ($pausedUntil = self::getQuotaPausedUntil()) {
|
||||||
$chunks = $shipments->chunk(10);
|
Log::warning('[DHL Tracking Service] Batch tracking skipped - quota pause active', [
|
||||||
$chunkIndex = 0;
|
'count' => $shipments->count(),
|
||||||
|
'paused_until' => $pausedUntil->toIso8601String(),
|
||||||
|
]);
|
||||||
|
|
||||||
foreach ($chunks as $chunk) {
|
foreach ($shipments as $shipment) {
|
||||||
// Rate limiting: pause 1 second between batch API calls
|
$stats['failed']++;
|
||||||
if ($chunkIndex > 0) {
|
$stats['results'][] = [
|
||||||
sleep(1);
|
'shipment_id' => $shipment->id,
|
||||||
}
|
'tracking_number' => $shipment->dhl_shipment_no,
|
||||||
$chunkIndex++;
|
'success' => false,
|
||||||
|
'message' => 'DHL-Quota-Pause aktiv bis '.$pausedUntil->copy()->setTimezone(config('app.timezone'))->format('d.m.Y H:i').' Uhr.',
|
||||||
// Build tracking number => shipment mapping
|
'rate_limited' => true,
|
||||||
$shipmentMap = [];
|
'paused_until' => $pausedUntil->toIso8601String(),
|
||||||
foreach ($chunk as $shipment) {
|
];
|
||||||
$shipmentMap[$shipment->dhl_shipment_no] = $shipment;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
$trackingNumbers = array_keys($shipmentMap);
|
return $stats;
|
||||||
|
}
|
||||||
|
|
||||||
|
$index = 0;
|
||||||
|
$sentCalls = 0;
|
||||||
|
|
||||||
|
foreach ($shipments as $shipment) {
|
||||||
|
if ($index > 0 && self::$callIntervalSeconds > 0) {
|
||||||
|
sleep(self::$callIntervalSeconds);
|
||||||
|
}
|
||||||
|
$index++;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
$batchResult = $this->trackMultipleShipments($trackingNumbers);
|
$result = $this->updateTracking($shipment, ['auto_retrack' => false]);
|
||||||
|
$sentCalls++;
|
||||||
|
|
||||||
if ($batchResult['success'] && ! empty($batchResult['shipments'])) {
|
if (! empty($result['success'])) {
|
||||||
// Process each result from the batch API
|
$stats['updated']++;
|
||||||
foreach ($batchResult['shipments'] as $trackingResult) {
|
if (! empty($result['tracking_completed'])) {
|
||||||
$trackingNo = $trackingResult['tracking_number'];
|
$stats['completed']++;
|
||||||
$shipment = $shipmentMap[$trackingNo] ?? null;
|
|
||||||
|
|
||||||
if (! $shipment) {
|
|
||||||
Log::warning('[DHL Tracking Service] Batch: tracking number not mapped', [
|
|
||||||
'tracking_number' => $trackingNo,
|
|
||||||
]);
|
|
||||||
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Remove from map so we can detect missing ones later
|
|
||||||
unset($shipmentMap[$trackingNo]);
|
|
||||||
|
|
||||||
$internalStatus = self::mapDhlStatusToInternal($trackingResult['status']);
|
|
||||||
|
|
||||||
$updateData = [
|
|
||||||
'status' => $internalStatus,
|
|
||||||
'tracking_status' => $trackingResult['status_text'],
|
|
||||||
'last_tracked_at' => now(),
|
|
||||||
];
|
|
||||||
|
|
||||||
// Mark tracking as completed if terminal status reached
|
|
||||||
$isCompleted = in_array($internalStatus, DhlShipment::TERMINAL_STATUSES);
|
|
||||||
if ($isCompleted) {
|
|
||||||
$updateData['tracking_completed_at'] = now();
|
|
||||||
$stats['completed']++;
|
|
||||||
}
|
|
||||||
|
|
||||||
$shipment->update($updateData);
|
|
||||||
|
|
||||||
// Save tracking events
|
|
||||||
$this->saveTrackingEvents($shipment, $trackingResult['events'] ?? []);
|
|
||||||
|
|
||||||
$stats['updated']++;
|
|
||||||
$stats['results'][] = [
|
|
||||||
'shipment_id' => $shipment->id,
|
|
||||||
'tracking_number' => $trackingNo,
|
|
||||||
'status' => $internalStatus,
|
|
||||||
'completed' => $isCompleted,
|
|
||||||
'success' => true,
|
|
||||||
];
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Any remaining shipments in the map were not returned by the API
|
|
||||||
foreach ($shipmentMap as $trackingNo => $shipment) {
|
|
||||||
// Update last_tracked_at so we don't immediately retry
|
|
||||||
$shipment->update(['last_tracked_at' => now()]);
|
|
||||||
$stats['failed']++;
|
|
||||||
$stats['results'][] = [
|
|
||||||
'shipment_id' => $shipment->id,
|
|
||||||
'tracking_number' => $trackingNo,
|
|
||||||
'success' => false,
|
|
||||||
'message' => 'Nicht in Batch-Antwort enthalten',
|
|
||||||
];
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
// Entire batch failed - fall back to individual tracking
|
|
||||||
Log::warning('[DHL Tracking Service] Batch tracking failed, falling back to individual tracking', [
|
|
||||||
'tracking_numbers' => $trackingNumbers,
|
|
||||||
'message' => $batchResult['message'] ?? 'Unknown error',
|
|
||||||
]);
|
|
||||||
|
|
||||||
foreach ($chunk as $shipment) {
|
|
||||||
try {
|
|
||||||
$result = $this->updateTracking($shipment, ['auto_retrack' => false]);
|
|
||||||
if ($result['success']) {
|
|
||||||
$stats['updated']++;
|
|
||||||
if (! empty($result['tracking_completed'])) {
|
|
||||||
$stats['completed']++;
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
$stats['failed']++;
|
|
||||||
}
|
|
||||||
$stats['results'][] = [
|
|
||||||
'shipment_id' => $shipment->id,
|
|
||||||
'tracking_number' => $shipment->dhl_shipment_no,
|
|
||||||
'success' => $result['success'],
|
|
||||||
'fallback' => true,
|
|
||||||
];
|
|
||||||
} catch (Exception $e) {
|
|
||||||
$stats['failed']++;
|
|
||||||
$stats['results'][] = [
|
|
||||||
'shipment_id' => $shipment->id,
|
|
||||||
'tracking_number' => $shipment->dhl_shipment_no,
|
|
||||||
'success' => false,
|
|
||||||
'message' => $e->getMessage(),
|
|
||||||
'fallback' => true,
|
|
||||||
];
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} catch (Exception $e) {
|
|
||||||
Log::error('[DHL Tracking Service] Batch tracking exception', [
|
|
||||||
'tracking_numbers' => $trackingNumbers,
|
|
||||||
'error' => $e->getMessage(),
|
|
||||||
]);
|
|
||||||
|
|
||||||
// Mark all as failed but update last_tracked_at
|
|
||||||
foreach ($chunk as $shipment) {
|
|
||||||
$shipment->update(['last_tracked_at' => now()]);
|
|
||||||
$stats['failed']++;
|
|
||||||
$stats['results'][] = [
|
$stats['results'][] = [
|
||||||
'shipment_id' => $shipment->id,
|
'shipment_id' => $shipment->id,
|
||||||
'tracking_number' => $shipment->dhl_shipment_no,
|
'tracking_number' => $shipment->dhl_shipment_no,
|
||||||
'success' => false,
|
'success' => true,
|
||||||
'message' => $e->getMessage(),
|
'completed' => ! empty($result['tracking_completed']),
|
||||||
];
|
];
|
||||||
|
|
||||||
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
$stats['failed']++;
|
||||||
|
$stats['results'][] = [
|
||||||
|
'shipment_id' => $shipment->id,
|
||||||
|
'tracking_number' => $shipment->dhl_shipment_no,
|
||||||
|
'success' => false,
|
||||||
|
'message' => $result['message'] ?? null,
|
||||||
|
'auth_error' => $result['auth_error'] ?? false,
|
||||||
|
'rate_limited' => $result['rate_limited'] ?? false,
|
||||||
|
];
|
||||||
|
|
||||||
|
if (! empty($result['auth_error']) || ! empty($result['rate_limited'])) {
|
||||||
|
Log::warning('[DHL Tracking Service] Batch tracking aborted', [
|
||||||
|
'reason' => ! empty($result['auth_error']) ? 'auth_error' : 'rate_limited',
|
||||||
|
'processed' => $stats['updated'] + $stats['failed'],
|
||||||
|
'remaining' => $shipments->count() - ($stats['updated'] + $stats['failed']),
|
||||||
|
'http_status' => $result['http_status'] ?? null,
|
||||||
|
]);
|
||||||
|
|
||||||
|
return $stats;
|
||||||
|
}
|
||||||
|
} catch (Exception $e) {
|
||||||
|
$stats['failed']++;
|
||||||
|
$stats['results'][] = [
|
||||||
|
'shipment_id' => $shipment->id,
|
||||||
|
'tracking_number' => $shipment->dhl_shipment_no,
|
||||||
|
'success' => false,
|
||||||
|
'message' => $e->getMessage(),
|
||||||
|
'transport_error' => true,
|
||||||
|
];
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -594,7 +754,8 @@ class DhlTrackingService
|
||||||
'updated' => $stats['updated'],
|
'updated' => $stats['updated'],
|
||||||
'failed' => $stats['failed'],
|
'failed' => $stats['failed'],
|
||||||
'completed' => $stats['completed'],
|
'completed' => $stats['completed'],
|
||||||
'chunks' => $chunks->count(),
|
'http_calls' => $sentCalls,
|
||||||
|
'call_interval_seconds' => self::$callIntervalSeconds,
|
||||||
]);
|
]);
|
||||||
|
|
||||||
return $stats;
|
return $stats;
|
||||||
|
|
|
||||||
|
|
@ -1007,6 +1007,38 @@ Umsetzung:
|
||||||
- Fehler aus der finalen DHL-Erstellung werden in der bestehenden Vorabpruefungsbox angezeigt.
|
- Fehler aus der finalen DHL-Erstellung werden in der bestehenden Vorabpruefungsbox angezeigt.
|
||||||
- Keine separate Browser-Alert-Meldung.
|
- Keine separate Browser-Alert-Meldung.
|
||||||
|
|
||||||
|
### 27.05.2026 - Nachtrag: DHL-Laenderauswahl in Settings speicherbar
|
||||||
|
|
||||||
|
Status: abgeschlossen.
|
||||||
|
|
||||||
|
Problem:
|
||||||
|
|
||||||
|
- Die Checkboxen fuer `DHL Paket International Ziellaender` liessen sich im Admin sichtbar anklicken.
|
||||||
|
- Nach dem Speichern wurden die ausgewaehlten Laender aber nicht wieder angezeigt.
|
||||||
|
- Ursache war `DHL_CONFIG_SOURCE=env`: Dadurch wurden die gespeicherten Datenbankwerte fuer diese Laenderliste beim Lesen ueberdeckt.
|
||||||
|
- Zusaetzlich wurde eine leere Checkbox-Auswahl als `null` gespeichert und fiel dadurch wieder auf die `.env`-Konfiguration zurueck.
|
||||||
|
|
||||||
|
Umsetzung:
|
||||||
|
|
||||||
|
- `app/Http/Controllers/SettingController.php`
|
||||||
|
- `dhl_international_countries` wird beim Speichern normalisiert.
|
||||||
|
- Ein leerer Wert wird als leere Liste gespeichert.
|
||||||
|
- Beim Lesen wird ein vorhandener gespeicherter Array-Wert fuer diese Laenderliste verwendet, auch wenn `DHL_CONFIG_SOURCE=env` aktiv ist.
|
||||||
|
- `app/Services/DhlProductResolver.php`
|
||||||
|
- Nutzt gespeicherte DHL-Laender, sobald sie als Array vorhanden sind.
|
||||||
|
- Wenn keine `settings`-Tabelle verfuegbar ist, bleibt der Config-Fallback erhalten.
|
||||||
|
- `app/Models/Setting.php`
|
||||||
|
- Object-Settings speichern leere Arrays jetzt als Array statt als `null`.
|
||||||
|
- `tests/Unit/Dhl/DhlProductResolverTest.php`
|
||||||
|
- Tests fuer gespeicherte Laender bei `env`-Prioritaet.
|
||||||
|
- Test fuer bewusst leer gespeicherte Laenderliste.
|
||||||
|
|
||||||
|
Verifikation:
|
||||||
|
|
||||||
|
- `./vendor/bin/pint --dirty --format agent`
|
||||||
|
- `php artisan test --compact tests/Unit/Dhl`
|
||||||
|
- Ergebnis: 51 Tests bestanden, 117 Assertions.
|
||||||
|
|
||||||
Offene Punkte:
|
Offene Punkte:
|
||||||
|
|
||||||
- DHL-Sandbox-Verhalten testen.
|
- DHL-Sandbox-Verhalten testen.
|
||||||
|
|
@ -1020,6 +1052,411 @@ Verifikation:
|
||||||
- `php artisan test --compact tests/Unit/Dhl`
|
- `php artisan test --compact tests/Unit/Dhl`
|
||||||
- Ergebnis: 49 Tests bestanden, 115 Assertions.
|
- Ergebnis: 49 Tests bestanden, 115 Assertions.
|
||||||
|
|
||||||
|
## Phase 9 - Security & Code-Hygiene Hardening (Audit 2026-05-27)
|
||||||
|
|
||||||
|
Status: umgesetzt am 2026-05-27.
|
||||||
|
|
||||||
|
Im Rahmen eines Audits der bestehenden DHL-Umsetzung wurden mehrere sicherheits- und konsistenzrelevante Punkte mit Prio 1 identifiziert und behoben.
|
||||||
|
|
||||||
|
### Prio 1.1 – Credentials und PII aus Logs entfernen
|
||||||
|
|
||||||
|
Problem:
|
||||||
|
|
||||||
|
- `DhlShipmentService::createShipment()` schrieb die komplette DHL-Config inklusive `api_key`, `username`, `password`, `api_secret` und Abrechnungsnummern via `\Log::info('dhlConfig', $dhlConfig)` in das Application-Log.
|
||||||
|
- `DhlDataHelper::prepareOrderData()` schrieb das Roh-Options-Array (inkl. Versandadresse, Name, Telefon) in das Log.
|
||||||
|
- `ShippingService::createLabel()` schrieb die fertige Order-Daten und in `[DHL API] Sending payload to DHL` zusaetzlich `payload_json` mit dem kompletten Payload (Adressen, Abrechnungsnummer) ins Log. Die Fehlerbranch loggte den vollen Payload ebenfalls. Die Response loggte zudem das base64-kodierte Label.
|
||||||
|
|
||||||
|
Umsetzung:
|
||||||
|
|
||||||
|
- `app/Services/DhlShipmentService.php`
|
||||||
|
- Neue statische Helper `sanitizeDhlConfigForLog()` und `sanitizeOrderDataForLog()`.
|
||||||
|
- `sanitizeDhlConfigForLog()` ersetzt Geheimnisse durch boolesche `has_*`-Flags und gibt nur nicht-vertrauliche Konfigurationsmetadaten zurueck.
|
||||||
|
- `sanitizeOrderDataForLog()` reduziert Bestelldaten auf Routing-Felder (Order-ID, Produktcode, Empfaenger-Land, ersten beiden Stellen der PLZ, Reference-Flag).
|
||||||
|
- Bestehende Logs nutzen jetzt diese Helper.
|
||||||
|
- `app/Services/DhlDataHelper.php`
|
||||||
|
- Kein Roh-Dump des `$options`-Arrays mehr. Statt dessen strukturiertes Log mit `order_id`, Produktcode und Flags.
|
||||||
|
- `packages/acme-laravel-dhl/src/Services/ShippingService.php`
|
||||||
|
- Neue private Helper `buildPayloadLogContext()` und `buildResponseLogContext()`.
|
||||||
|
- `createLabel()`-Log nutzt `DhlShipmentService::sanitizeOrderDataForLog()`.
|
||||||
|
- Payload-Log enthaelt nur Produktcode, letzte vier Stellen der Abrechnungsnummer, Gewicht, Empfaenger-Land, `must_encode`-Flag.
|
||||||
|
- Response-Log enthaelt nur Sendungsnummer, Routing-Code, Status. Das base64-Label wird nicht mehr geloggt.
|
||||||
|
- Error-Branch verwendet ebenfalls den redaktierten Payload-Log.
|
||||||
|
|
||||||
|
Tests:
|
||||||
|
|
||||||
|
- `tests/Unit/Dhl/DhlSanitizeLoggingTest.php` (neu)
|
||||||
|
- Stellt sicher, dass `api_key`, `password`, `api_secret`, `username` und Abrechnungsnummern niemals im Log-Kontext landen.
|
||||||
|
- Stellt sicher, dass Name, Strasse, Telefon, E-Mail und PLZ aus den Bestelldaten heraus normalisiert werden.
|
||||||
|
|
||||||
|
### Prio 1.2 – `CreateShipmentJob` darf `dhlConfig` nicht serialisieren
|
||||||
|
|
||||||
|
Problem:
|
||||||
|
|
||||||
|
- `App\Jobs\CreateShipmentJob` hat die DHL-Konfiguration im Konstruktor in eine `public $dhlConfig`-Property geschrieben. Dadurch wurden API-Key, Basic-Auth-Passwort und Abrechnungsnummern beim Dispatch (per `serialize()`) in den Queue-Speicher (Redis/Datenbank) abgelegt.
|
||||||
|
|
||||||
|
Umsetzung:
|
||||||
|
|
||||||
|
- `app/Jobs/CreateShipmentJob.php`
|
||||||
|
- `public $dhlConfig` entfernt.
|
||||||
|
- Der Konstruktor akzeptiert den Parameter weiterhin (Backwards-Compat), persistiert ihn aber nicht mehr.
|
||||||
|
- `handle()` laedt die DHL-Konfiguration jetzt ueber `SettingController::getDhlConfig()` direkt aus der kanonischen Quelle. Der Worker hat ohnehin Zugriff darauf.
|
||||||
|
|
||||||
|
Tests:
|
||||||
|
|
||||||
|
- `tests/Unit/Dhl/CreateShipmentJobSerializationTest.php` (neu)
|
||||||
|
- Serialisiert eine Job-Instanz und stellt sicher, dass keine der DHL-Secrets im Payload landen.
|
||||||
|
- Bestaetigt, dass es keine `dhlConfig`-Property mehr gibt.
|
||||||
|
|
||||||
|
### Prio 1.3 – IDOR im DHL-Versand-Modal verhindern
|
||||||
|
|
||||||
|
Problem:
|
||||||
|
|
||||||
|
- `ModalController::handleDhlShipmentModal()` haengt am `auth`-Middleware, aber nicht am `admin`-Guard. Jeder eingeloggte CRM-Nutzer konnte `POST /modal/load` mit `action=create-dhl-shipment` und einer beliebigen Order-ID aufrufen und damit Empfaenger-Name, Adresse, E-Mail sowie bestehende Sendungen einer fremden Bestellung auslesen.
|
||||||
|
|
||||||
|
Umsetzung:
|
||||||
|
|
||||||
|
- `app/Http/Controllers/ModalController.php`
|
||||||
|
- Neue private Methode `authorizeDhlShipmentModal()` prueft `Auth::user()->isAdmin()` und antwortet sonst mit `403`.
|
||||||
|
- Wird vor `handleDhlShipmentModal()` aufgerufen.
|
||||||
|
|
||||||
|
Tests:
|
||||||
|
|
||||||
|
- `tests/Unit/Dhl/DhlModalAuthorizationTest.php` (neu)
|
||||||
|
- Gaeste, VIP-User (`admin == 1`) und regulaere Berater (`admin == 0`) erhalten `403`.
|
||||||
|
- Echte Admins (`admin >= 2`) werden durchgelassen.
|
||||||
|
|
||||||
|
### Prio 1.4 – XSS-Schutz im Tracking-Frontend und in DataTables
|
||||||
|
|
||||||
|
Problem:
|
||||||
|
|
||||||
|
- Im Backend-DataTable `DhlShipmentController::index()`/`list()` wurde `customer = firstname + ' ' + lastname` per `addColumn` mit anschliessendem `rawColumns(['customer', ...])` ausgegeben. Manipulierte Adressdaten haetten HTML/JS injizieren koennen.
|
||||||
|
- Im oeffentlichen Tracking-Frontend `resources/views/public/tracking.blade.php` wurden `data.tracking_status`, `data.tracking_number`, `data.last_tracked_at` und `data.status` ungefiltert in jQuery-`.html()`-Templates eingebaut.
|
||||||
|
|
||||||
|
Umsetzung:
|
||||||
|
|
||||||
|
- `app/Http/Controllers/DhlShipmentController.php`
|
||||||
|
- `customer`-Spalte ist jetzt `e(trim($firstname.' '.$lastname))`.
|
||||||
|
- `resources/views/public/tracking.blade.php`
|
||||||
|
- Neue JS-Funktion `escapeTrackingHtml()` (HTML-Entity-Encoding fuer `&`, `<`, `>`, `"`, `'`).
|
||||||
|
- Alle Felder aus der Tracking-Response werden vor dem Einsetzen damit escaped.
|
||||||
|
- Trackingnummer fuer den DHL-Link wird zusaetzlich mit `encodeURIComponent()` geschuetzt.
|
||||||
|
- `showError()` nutzt `.text()` statt `.html()`.
|
||||||
|
- `getStatusBadge()` escaped Status-Default und CSS-Klassen, sodass unbekannte DHL-Statuscodes nicht aus dem `class`-Attribut ausbrechen koennen.
|
||||||
|
|
||||||
|
### Prio 1.5 – `ReturnsService` an `DhlProductResolver` anbinden, `'DEU'`-Fallback entfernen
|
||||||
|
|
||||||
|
Problem:
|
||||||
|
|
||||||
|
- `ReturnsService::convertCountryCode()` und `convertAddressFor2LetterCountry()` hielten eine eigene Hardcoded-Liste an Laendercodes und fielen bei unbekannten Codes still auf `'DEU'` zurueck. Damit konnten Retouren fuer auslaendische Empfaenger versehentlich nach Deutschland geroutet werden und der `DhlProductResolver` (Single Source of Truth) wurde umgangen.
|
||||||
|
|
||||||
|
Umsetzung:
|
||||||
|
|
||||||
|
- `packages/acme-laravel-dhl/src/Services/ReturnsService.php`
|
||||||
|
- `convertCountryCode()` delegiert an `DhlProductResolver::toDhlCountryCode()`.
|
||||||
|
- `convertAddressFor2LetterCountry()` delegiert an `DhlProductResolver::normalizeCountryCode()`.
|
||||||
|
- Unbekannte Laender werfen jetzt eine `InvalidArgumentException`, statt unbemerkt eine deutsche Retoure zu erzeugen.
|
||||||
|
- `app/Http/Controllers/DhlShipmentController.php`
|
||||||
|
- Alle `'DEU'`-Defaults in den Retouren-Pfaden (`getBillingAddressForReturn()`, `createReturnLabelSync()`) durch `DhlProductResolver::DOMESTIC_COUNTRY` (`'DE'`) ersetzt, damit konsistente ISO-2-Codes an den Resolver gehen.
|
||||||
|
|
||||||
|
Tests:
|
||||||
|
|
||||||
|
- `tests/Unit/Dhl/ReturnsServiceCountryCodeTest.php` (neu)
|
||||||
|
- Mappt `DE/AT/CH/ES` korrekt auf ISO-3.
|
||||||
|
- Akzeptiert bereits korrekte ISO-3-Codes.
|
||||||
|
- Wirft fuer unbekannte Codes statt still `DEU` zurueckzugeben.
|
||||||
|
- Normalisiert Adressen zurueck auf ISO-2 und respektiert fehlende `country`-Felder.
|
||||||
|
|
||||||
|
### Prio 1.6 – `houseNumber = '1'`-Default in `ShippingService::parseAddressFields` entfernen
|
||||||
|
|
||||||
|
Problem:
|
||||||
|
|
||||||
|
- Wenn der Adressparser keine Hausnummer aus der Strasse extrahieren konnte, hat er die Hausnummer still auf `'1'` gesetzt. Das fuehrte dazu, dass Pakete an die falsche Adresse zugestellt werden konnten, ohne dass der Operator etwas davon mitbekam.
|
||||||
|
|
||||||
|
Umsetzung:
|
||||||
|
|
||||||
|
- `packages/acme-laravel-dhl/src/Services/ShippingService.php`
|
||||||
|
- `parseAddressFields()` wirft jetzt eine `InvalidArgumentException` mit klarer deutscher Fehlermeldung, sobald keine Hausnummer ermittelt werden konnte.
|
||||||
|
- Das interne Erfolgs-Log enthaelt zusaetzlich nur noch Laengen / Praefixe, nicht mehr die volle Adresse.
|
||||||
|
|
||||||
|
Tests:
|
||||||
|
|
||||||
|
- `tests/Unit/Dhl/ShippingServiceParseAddressTest.php` (neu)
|
||||||
|
- Explizite Hausnummer wird unveraendert uebernommen.
|
||||||
|
- Kombiniertes Strassenfeld wird sauber zerlegt.
|
||||||
|
- Adresse ohne erkennbare Hausnummer fuehrt zu einer Validierungs-Exception.
|
||||||
|
- Leere Eingabe fuehrt nicht zur Exception (kein neues Verhalten).
|
||||||
|
|
||||||
|
### Verifikation Phase 9
|
||||||
|
|
||||||
|
- `php artisan test --compact tests/Unit/Dhl tests/Feature/DhlApiCurlLoggingTest.php`
|
||||||
|
- Ergebnis: 75 Tests bestanden, 195 Assertions.
|
||||||
|
- `./vendor/bin/pint --dirty --format agent`
|
||||||
|
|
||||||
|
### Offene Punkte / weiterhin im Backlog
|
||||||
|
|
||||||
|
Die in Phase 9 noch offenen Backlog-Items wurden inzwischen in Phase 10 abgearbeitet (siehe dort).
|
||||||
|
|
||||||
|
## Phase 10 - Backlog-Aufraeumen (2026-05-27)
|
||||||
|
|
||||||
|
Status: umgesetzt am 2026-05-27 direkt im Anschluss an Phase 9.
|
||||||
|
|
||||||
|
### Prio 2.1 - Legacy `DhlApiService` entfernen
|
||||||
|
|
||||||
|
Problem:
|
||||||
|
|
||||||
|
- `app/Services/DhlApiService.php` (1311 Zeilen) war ein veralteter SOAP-/SDK-basierter Service. Er referenzierte ein nicht existierendes Model `App\Models\DhlShipment` und das nicht installierte SDK `christophschaeffer/dhl-business-shipping`. Die Klasse wurde vom Autoloader geladen, war aber komplett tot (keine Aufrufer in `app/`, `routes/`, `resources/`, `tests/`, `packages/`). Jeder versehentliche Aufruf haette zur Laufzeit zu `Class not found` gefuehrt.
|
||||||
|
|
||||||
|
Umsetzung:
|
||||||
|
|
||||||
|
- `app/Services/DhlApiService.php` ersatzlos geloescht.
|
||||||
|
|
||||||
|
Verifikation:
|
||||||
|
|
||||||
|
- `grep -r DhlApiService app/ routes/ resources/ packages/ tests/ config/` → keine Treffer mehr.
|
||||||
|
- Verbleibende Vorkommen ausschliesslich in `dev/2026-05-13-dhl-modul/legacy/...` (historische Markdowns) und `dev/app-bak/` (Backup-Verzeichnis).
|
||||||
|
|
||||||
|
### Prio 2.2 - Request-/Prozess-Caching fuer `getDhlConfig()`
|
||||||
|
|
||||||
|
Problem:
|
||||||
|
|
||||||
|
- `SettingController::getDhlConfig()` baute die DHL-Konfiguration bei jedem Aufruf von Grund auf neu auf und holte dabei rund 25 Werte einzeln per `Setting::getContentBySlug()` aus der Datenbank. Pro DHL-Vorgang gibt es mehrere Aufrufer (`DhlShipmentService`, `CreateShipmentJob`, `DhlShipmentController` fuer Cancel/Return, …), so dass schnell 50-100 redundante `SELECT ... FROM settings`-Queries pro Request entstanden.
|
||||||
|
|
||||||
|
Umsetzung:
|
||||||
|
|
||||||
|
- `app/Http/Controllers/SettingController.php`
|
||||||
|
- Neue statische Property `private static ?array $cachedDhlConfig = null` als prozessweiter In-Memory-Cache.
|
||||||
|
- `getDhlConfig()` liefert beim zweiten Aufruf direkt aus dem Cache und triggert keine DB-Queries mehr.
|
||||||
|
- Neue oeffentliche Methode `flushDhlConfigCache()` setzt den Cache zurueck. Sie wird automatisch in `store()` und `updateDhlConfigCache()` aufgerufen, sodass nach einer Settings-Aenderung der naechste Aufruf wieder frische Werte liefert.
|
||||||
|
|
||||||
|
Tests:
|
||||||
|
|
||||||
|
- `tests/Unit/Dhl/DhlConfigCachingTest.php` (neu)
|
||||||
|
- Verifiziert, dass `getDhlConfig()` einen vorbefuellten Cache zurueckgibt, ohne die Datenbank zu beruehren.
|
||||||
|
- Verifiziert, dass `flushDhlConfigCache()` den Cache zuverlaessig leert.
|
||||||
|
|
||||||
|
### Prio 2.3 - Doppelte `public.tracking`-Route bereinigen
|
||||||
|
|
||||||
|
Problem:
|
||||||
|
|
||||||
|
- `routes/domains/crm.php` registrierte `GET /admin/dhl/public/track` mit dem Namen `public.tracking` _innerhalb_ der `admin`-Middleware-Gruppe. Dadurch war die Route in Wahrheit nicht oeffentlich, sondern admin-only. Gleichzeitig existiert in `routes/domains/main.php` die korrekte oeffentliche Route `GET /tracking` ebenfalls unter dem Namen `public.tracking`. Da pro Request immer nur eine Domain-Routedatei geladen wird, gab es keinen direkten Konflikt, aber auf der CRM-Subdomain zeigte der Routenname auf den falschen Endpunkt.
|
||||||
|
|
||||||
|
Umsetzung:
|
||||||
|
|
||||||
|
- `routes/domains/crm.php`
|
||||||
|
- Die `public.tracking`-Definition wurde entfernt und durch einen erklaerenden Kommentar ersetzt.
|
||||||
|
- `resources/views/admin/dhl/show.blade.php`
|
||||||
|
- Der bisher einzige Verbraucher (`route('public.tracking')` im aktuell deaktivierten `@if(false)`-Block) zeigt jetzt absolut auf die Main-Domain via `\App\Domain\EarlyDomainParser::getMainUrl().'/tracking'`. Die Trackingnummer wird zusaetzlich mit `urlencode()` geschuetzt.
|
||||||
|
|
||||||
|
Tests:
|
||||||
|
|
||||||
|
- `tests/Unit/Dhl/DhlRouteRegistrationTest.php` (neu)
|
||||||
|
- Stellt sicher, dass im CRM-Routing kein `Route::...->name('public.tracking')`-Eintrag mehr existiert.
|
||||||
|
- Stellt sicher, dass die Main-Domain weiterhin `GET /tracking` als `public.tracking` registriert.
|
||||||
|
|
||||||
|
### Verifikation Phase 10
|
||||||
|
|
||||||
|
- `php artisan test --compact tests/Unit/Dhl tests/Feature/DhlApiCurlLoggingTest.php`
|
||||||
|
- Ergebnis: 79 Tests bestanden, 201 Assertions.
|
||||||
|
- `./vendor/bin/pint --dirty --format agent`
|
||||||
|
|
||||||
|
### Verbleibendes Backlog
|
||||||
|
|
||||||
|
- `updateDhlConfigCache()` ruft weiterhin `\Artisan::call('config:clear')` auf. Das ist global und kann bei gecachter Konfiguration zu kurzfristiger Latenz fuehren - akzeptabel, weil das Setting-Update ein seltener Admin-Vorgang ist.
|
||||||
|
- `dev/app-bak/Services/DhlApiService.php` (Backup-Kopie ausserhalb des Autoloaders) bleibt als historische Referenz erhalten.
|
||||||
|
|
||||||
|
## Phase 11 - Tracking-Hardening (Bugfix Live, 2026-05-27)
|
||||||
|
|
||||||
|
Status: umgesetzt am 2026-05-27.
|
||||||
|
|
||||||
|
Ausloeser: Im Live-System lieferte das Cockpit beim Klick auf "Tracking aktualisieren" die irrefuehrende Meldung `Sendung nicht gefunden oder noch nicht im System erfasst. HTTP Status: 401`, obwohl die Sendung auf der DHL-Website laengst zugestellt war. Gleichzeitig zeigte das Cockpit einen frischen `last_tracked_at`-Zeitstempel mit altem `tracking_status`-Text - typisches Symptom eines schon laenger fehlschlagenden Trackings.
|
||||||
|
|
||||||
|
### Wurzelursachen
|
||||||
|
|
||||||
|
1. **Phantom-Fallback-Endpoint**: `DhlTrackingService::trackShipmentDE()` rief `https://api-eu.dhl.com/parcel/de/tracking/v0/shipments` auf. Dieser Endpoint existiert in der offiziellen DHL-Doku nicht (siehe `https://developer.dhl.com/api-reference/shipment-tracking`). Es gibt nur die "Shipment Tracking - Unified API" unter `https://api-eu.dhl.com/track/shipments`. Der Fallback produzierte daher zwangslaeufig 401/404.
|
||||||
|
2. **Falsche User-Message bei 401**: Eine `401 Unauthorized`-Antwort wurde dem Operator als "Sendung nicht gefunden" gezeigt. Der eigentliche Fehler (DHL-API-Key ohne Tracking-Subscription im DHL Developer Portal) blieb verborgen.
|
||||||
|
3. **Stale-Daten erscheinen frisch**: Sowohl `updateTrackingSync()` als auch `updateTrackingBatch()` setzten `last_tracked_at = now()` selbst dann, wenn die Tracking-Antwort fehlschlug. Damit wirkte ein veralteter Status im Cockpit "gerade eben aktualisiert".
|
||||||
|
4. **Per-Shipment-Fallback verbrennt Quota**: Bei einem Batch-Auth-Fehler ist der Code in eine Schleife mit `updateTracking()` pro Sendung gegangen, was den 401 mit `N` Folgeaufrufen multipliziert.
|
||||||
|
5. **Tote `api_secret`-Property**: Wurde im Konstruktor geladen aber nirgends im Request verwendet (Tracking-Unified-API kennt kein OAuth/Basic).
|
||||||
|
|
||||||
|
### Umsetzung
|
||||||
|
|
||||||
|
- `app/Services/DhlTrackingService.php`
|
||||||
|
- Neue Konstante `TRACKING_ENDPOINT = 'https://api-eu.dhl.com/track/shipments'`.
|
||||||
|
- `trackShipmentDE()` ersatzlos entfernt; `trackShipment()` hat keinen Fallback mehr.
|
||||||
|
- Neue private Helper `buildHttpOptions()`, `processSingleShipmentResponse()`, `isAuthErrorStatus()`, `buildAuthErrorMessage()`, `redactApiKey()`.
|
||||||
|
- 401/403 werden explizit als `auth_error => true` plus `http_status` zurueckgegeben, mit klarer User-Message: *"DHL Tracking API: Authentifizierung fehlgeschlagen (HTTP 401). Bitte pruefen, ob der hinterlegte DHL-API-Key gueltig ist und im DHL Developer Portal fuer 'Shipment Tracking - Unified' freigeschaltet wurde."*.
|
||||||
|
- 404 oder leere Sendungsliste werden als `not_found => true` markiert (eindeutig getrennt von Auth-Fehlern).
|
||||||
|
- `trackMultipleShipments()` nutzt jetzt denselben gemeinsamen Pfad und liefert ebenfalls strukturierte `auth_error`-Antworten.
|
||||||
|
- `updateTrackingSync()` setzt `last_tracked_at` **nur noch** bei Erfolg oder bei explizitem `not_found`. Auth- und Transport-Fehler lassen den Zeitstempel unangetastet, damit der naechste Cron-Lauf erneut versucht und das Cockpit den fehlschlagenden Zustand sichtbar macht.
|
||||||
|
- `updateTrackingBatch()` bricht beim ersten Auth-Fehler ab (kein Per-Shipment-Fallback, kein erneutes Tracking) und markiert alle Sendungen der laufenden Charge mit `auth_error`. Transport-Exceptions fuehren ebenfalls nicht mehr zu einem `last_tracked_at`-Update.
|
||||||
|
- Properties `apiSecret` und `isSandbox` entfernt.
|
||||||
|
- Diagnose-Logs enthalten jetzt Endpoint, Status-Code und ein redaktiertes API-Key-Suffix (`***<letzte 4>`), aber niemals den vollen Key.
|
||||||
|
|
||||||
|
### Tests
|
||||||
|
|
||||||
|
- `tests/Unit/Dhl/DhlTrackingAuthErrorTest.php` (neu, 6 Tests)
|
||||||
|
- HTTP 401 / 403 fuehrt zu `auth_error => true` mit klarer Message.
|
||||||
|
- Es wird kein Fallback-Request mehr an `/parcel/de/tracking/...` gesendet.
|
||||||
|
- 404 / leere Sendungsliste werden eindeutig als `not_found` zurueckgegeben.
|
||||||
|
- Multi-Tracking liefert ebenfalls strukturierte `auth_error`-Antworten.
|
||||||
|
- Das Auth-Fehler-Log enthaelt den vollen API-Key nicht (nur Suffix).
|
||||||
|
- `tests/Unit/Dhl/DhlTrackingStaleProtectionTest.php` (neu, 3 Tests)
|
||||||
|
- `updateTracking()` ruft bei 401 **kein** `$shipment->update(...)` auf - `last_tracked_at` bleibt also fuer den naechsten Cron-Versuch alt.
|
||||||
|
- `updateTracking()` setzt bei "not found" gezielt nur `last_tracked_at`, damit nicht sofort erneut probiert wird.
|
||||||
|
- `updateTrackingBatch()` bricht bei einem Auth-Fehler nach dem ersten Versuch ab (`Http::assertSentCount(1)`) und markiert alle drei Sendungen der Charge als `auth_error => true`.
|
||||||
|
|
||||||
|
### Verifikation Phase 11
|
||||||
|
|
||||||
|
- `php artisan test --compact tests/Unit/Dhl tests/Feature/DhlApiCurlLoggingTest.php`
|
||||||
|
- Ergebnis: 88 Tests bestanden, 263 Assertions (vorher Phase 10: 79 Tests, 201 Assertions).
|
||||||
|
- `./vendor/bin/pint --dirty --format agent`
|
||||||
|
|
||||||
|
### Operatives Vorgehen bei einem 401 im Live
|
||||||
|
|
||||||
|
Der Code kann das Problem nur sichtbar machen und nicht beheben - die eigentliche Loesung erfolgt im DHL Developer Portal:
|
||||||
|
|
||||||
|
1. DHL Developer Portal → My Apps → die produktive App oeffnen.
|
||||||
|
2. Unter "APIs" pruefen, ob *Shipment Tracking - Unified API* aktiv und genehmigt ist. Falls nicht: hinzufuegen und Production-Freischaltung beantragen (manuelle DHL-Approval).
|
||||||
|
3. Den `DHL_API_KEY` (Consumer Key) aus dem Portal mit dem Wert in den Einstellungen / `.env` abgleichen.
|
||||||
|
4. Nach Korrektur einmal *Tracking aktualisieren* im Cockpit anstossen; der `last_tracked_at`-Zeitstempel wird jetzt nur noch bei tatsaechlich erfolgreicher Antwort frisch gesetzt.
|
||||||
|
|
||||||
|
## Phase 12 - 429-Handling und lokale Quota-Pause (Bugfix Live, 2026-05-27)
|
||||||
|
|
||||||
|
Status: umgesetzt am 2026-05-27 (Folgebefund zu Phase 11).
|
||||||
|
|
||||||
|
Ausloeser: Trotz Phase 11 zeigte das Live-System weiterhin veraltete Tracking-Stati. Konkretes Beispiel: Sendung `5082` (Trackingnummer `00340435065133094790`) blieb seit `2026-05-27 05:00:33` auf "in der Region des Empfaengers angekommen", obwohl DHL bereits um 13:41 Uhr "Zustellung erfolgreich" meldete. Quer ueber **alle 381 aktiven Sendungen** war `MAX(last_tracked_at) = 06:00:35` - der stuendliche Cron `dhl:update-tracking` lieferte ab 07:00 Uhr ueber 11 Stunden keine Updates mehr.
|
||||||
|
|
||||||
|
### Wurzelursachen (revidiert)
|
||||||
|
|
||||||
|
1. **DHL-Tageslimit von 250 Aufrufen pro Tag erschoepft**. Laut [`developer.dhl.com/api-reference/shipment-tracking`](https://developer.dhl.com/api-reference/shipment-tracking) bekommt jede Standard-App initial *250 calls per day, with a maximum of 1 call every 5 seconds*. Direkter Test gegen den Live-Endpunkt belegte das: `curl https://api-eu.dhl.com/track/shipments?trackingNumber=...` lieferte `HTTP 429` mit Body `{"status":429,"title":"Too Many Requests","detail":"Too many requests within defined time period, please try again later."}`. Der Antrag auf eine hoehere Quota muss im DHL Developer Portal gestellt werden.
|
||||||
|
|
||||||
|
> Hinweis zur ersten Diagnose: Wir hatten zunaechst vermutet, der Live-`DHL_API_KEY` sei der oeffentliche Sandbox-Demo-Key aus der DHL-Doku, weil der Wert mit den im Repo eingecheckten Sandbox-Beispielen uebereinstimmte (`tests/Feature/DhlApiCurlLoggingTest.php`, `tests/DHL/curl-trace.txt`). Diese Annahme war falsch - der Wert in der Live-`.env` ist ein echter produktiver Key, er hat nur das Standard-Tageslimit. Die fruehere Operator-Message mit dem Hinweis "Sandbox-Demo-Key" wurde entfernt.
|
||||||
|
|
||||||
|
2. **429 wurde wie ein generischer Fehler behandelt**: Der Code aus Phase 11 erkannte 401/403 als `auth_error` und 404 als `not_found`, aber 429 fiel in den Pfad "Fehler beim Abrufen der Tracking-Informationen. HTTP 429". Im Batch fuehrte das zum Per-Shipment-Fallback, der pro Sendung einen weiteren 429-Aufruf abgesetzt hat - die schon erschoepfte Quota wurde so noch schneller weiter belastet und der Cron-Lauf dauerte unnoetig lange.
|
||||||
|
|
||||||
|
3. **Folgelaeufe verbrennen Quota nur um 429 erneut zu sehen**: Solange DHL noch im selben Quota-Fenster mit 429 antwortet, kostet jeder hourly-Cron mindestens einen HTTP-Roundtrip. Bei knapper Quota ist das ein Selbstlaeufer, der den Recovery-Moment nach dem Tages-Reset spuerbar verzoegert.
|
||||||
|
|
||||||
|
### Umsetzung
|
||||||
|
|
||||||
|
- `app/Services/DhlTrackingService.php`
|
||||||
|
- `processSingleShipmentResponse()` erkennt `HTTP 429` explizit und liefert `rate_limited => true` plus optional `retry_after` (Sekunden). Die User-Message lautet jetzt sachlich: *"DHL Tracking API: Tageslimit erreicht (HTTP 429). Die DHL-API liefert vorruebergehend keine Daten mehr. Standard-Apps haben laut DHL-Doku 250 Aufrufe pro Tag und max. 1 Aufruf alle 5 Sekunden. Bei Bedarf im DHL Developer Portal eine Quota-Erhoehung beantragen."* Der frueher hier eingefuegte "Sandbox-Demo-Key"-Hinweis ist entfernt.
|
||||||
|
- `trackMultipleShipments()` erkennt 429 ebenfalls und liefert dieselbe strukturierte Antwort fuer die Batch-Route.
|
||||||
|
- `updateTrackingBatch()` bekommt einen eigenen `rate_limited`-Pfad analog zu `auth_error`: Beim ersten 429 wird die gesamte Charge sofort als `rate_limited` markiert und der Cron-Lauf abgebrochen, ohne Per-Shipment-Fallback. `last_tracked_at` bleibt unangetastet, damit der naechste planmaessige Cron-Lauf die gleichen Sendungen erneut versucht (statt sie mit frischem Zeitstempel und altem Status zu konservieren).
|
||||||
|
- Auch im "Sonstiger Batch-Fehler"-Fallback wird ein `rate_limited`-Befund pro Sendung als Abbruchgrund erkannt - die Schleife bricht sofort ab, ebenso wie schon bei `auth_error`.
|
||||||
|
- Neuer Helper `extractRetryAfter()` interpretiert sowohl ganzzahlige Sekunden als auch RFC-7231-`HTTP-date`-Werte aus dem `Retry-After`-Header.
|
||||||
|
- Logging in `[DHL Tracking Service] Unified API rate-limited` / `Multi tracking rate-limited` / `Batch tracking aborted due to rate-limit` enthaelt Endpoint, HTTP-Status, ein redaktiertes API-Key-Suffix (`***<letzte 4>`) und - falls von DHL geliefert - die `Retry-After`-Sekunden.
|
||||||
|
|
||||||
|
- Lokale Quota-Pause (neu in Phase 12.1)
|
||||||
|
- Neuer Cache-Key `dhl_tracking:quota_paused_until` haelt einen absoluten "do not call DHL before"-Zeitstempel. Default-Pause: 1 Stunde, wenn DHL keinen `Retry-After`-Header sendet; andernfalls genau die signalisierte Wartezeit.
|
||||||
|
- Statische Helper auf `DhlTrackingService`: `isQuotaPaused()`, `getQuotaPausedUntil()`, `pauseQuota(?int $retryAfterSeconds = null)`, `clearQuotaPause()`.
|
||||||
|
- `trackShipment()`, `trackMultipleShipments()` und `updateTrackingBatch()` pruefen die Pause **vor jedem HTTP-Roundtrip**. Ist sie aktiv, wird sofort eine `rate_limited => true`-Antwort mit `paused_until` zurueckgegeben - kein API-Aufruf. Damit kostet ein Cron-Lauf in einem ausgeschoepften Quota-Fenster **0 Calls** statt 10+ und beschleunigt den Recovery-Moment nach Quota-Reset.
|
||||||
|
- Beim ersten echten 429-Response wird `pauseQuota($retryAfter)` aufgerufen, sodass ein einzelner Cron-Lauf maximal einen "Quota-Probierschuss" pro Stunde absetzt.
|
||||||
|
|
||||||
|
### Tests
|
||||||
|
|
||||||
|
- `tests/Unit/Dhl/DhlTrackingRateLimitTest.php` (neu, 9 Tests)
|
||||||
|
- 429-Single-Tracking-Response: Antwort ist `rate_limited => true`, enthaelt `http_status = 429`, `retry_after = 120` und eine deutschsprachige Operator-Message mit DHL-Doku-konformem Hinweis (`Tageslimit erreicht`, `250 Aufrufe pro Tag`, `Quota-Erhoehung`). Der frueher gepruefte String "Sandbox-Demo-Key" darf explizit **nicht** mehr vorkommen.
|
||||||
|
- Multi-Tracking liefert dieselbe strukturierte 429-Antwort.
|
||||||
|
- `updateTrackingBatch()` bricht nach genau einem HTTP-Aufruf ab, markiert die Charge als `rate_limited` und ruft `$shipment->update(...)` nicht auf.
|
||||||
|
- HTTP-date als `Retry-After` wird korrekt in Sekunden umgerechnet.
|
||||||
|
- Im Rate-Limit-Log taucht der volle API-Key nicht auf, nur das `***<suffix>`.
|
||||||
|
- **Quota-Pause aktiviert sich aus dem `Retry-After`-Header** und der zweite `trackShipment()`-Aufruf sendet keinen HTTP-Request mehr (`Http::assertSentCount(1)`).
|
||||||
|
- **Default-Pause** ohne `Retry-After`-Header dauert ~1 Stunde (mit Toleranz fuer Test-Drift).
|
||||||
|
- **`updateTrackingBatch()` ueberspringt 25 Sendungen vollstaendig**, wenn die Pause bereits gecached ist (`Http::assertNothingSent()`).
|
||||||
|
- `clearQuotaPause()` raeumt den Cache-Eintrag wieder auf - wichtig fuer Tests und fuer Operator-Eingriffe.
|
||||||
|
|
||||||
|
### Verifikation Phase 12
|
||||||
|
|
||||||
|
- `php artisan test --compact tests/Unit/Dhl`
|
||||||
|
- Ergebnis: 96 Tests bestanden, 406 Assertions (vorher Phase 11: 88 Tests, 263 Assertions).
|
||||||
|
- `./vendor/bin/pint --dirty --format agent`
|
||||||
|
|
||||||
|
### Operatives Vorgehen bei einem 429 im Live
|
||||||
|
|
||||||
|
Der Code-Fix verhindert, dass aus einer erschoepften Tracking-Quota ein **mehrstuendiger Datenstau** wird, das Cockpit veraltete Stati als "gerade aktualisiert" anzeigt oder die hourly-Crons die Recovery selbst weiter verzoegern. Die eigentliche Loesung liegt aber im DHL-Account:
|
||||||
|
|
||||||
|
1. **DHL Developer Portal -> My Apps -> produktive App oeffnen.**
|
||||||
|
2. **Pruefen, ob *Shipment Tracking - Unified API* in der App als Production approved ist** (nicht nur Sandbox). Standard-Quota: 250 Aufrufe pro Tag, max. 1 Aufruf alle 5 Sekunden.
|
||||||
|
3. **Im DHL Developer Portal eine Quota-Erhoehung beantragen** (Antrag-Formular im API-Detailbereich). Solange das offen ist: Hourly-Cron mit Status-basierten Intervallen einplanen, dazu siehe "Empfehlung Sendungsvolumen vs. Quota" unten.
|
||||||
|
4. **Cron-Lauf beobachten**: Nach dem ersten 429 bleibt der Cron fuer 1 Stunde lokal in einer Quota-Pause, ohne weitere DHL-Aufrufe. Der naechste `dhl:update-tracking` versucht es dann erneut. Sobald die Quota frei wird (Production-Erhoehung oder Tages-Reset), nimmt der Cron die Sendungen automatisch wieder mit.
|
||||||
|
5. Falls Operator manuell sofort entsperren moechte: `php artisan tinker` -> `App\Services\DhlTrackingService::clearQuotaPause()`.
|
||||||
|
|
||||||
|
### Empfehlung Sendungsvolumen vs. Quota
|
||||||
|
|
||||||
|
Aktuell sind ~381 Sendungen aktiv (`status IN ('created','in_transit','out_for_delivery','exception','unknown')`). Die DHL-Standardquota von 250 Calls/Tag reicht dafuer mathematisch nicht aus, sobald jede Sendung mehrmals pro Tag aktualisiert werden soll. Solange keine erhoehte Quota bewilligt ist, sind drei Stellschrauben sinnvoll - Reihenfolge nach Wirkung:
|
||||||
|
|
||||||
|
1. **`--stale-days` enger setzen** (z.B. 14 statt 30). Sendungen mit Status `created`, die seit zwei Wochen keinen Fortschritt zeigen, sind in der Praxis nicht zustellbar oder Karteileichen. `markStaleShipmentsCompleted()` schliesst sie automatisch ab und reduziert die Cron-Last spuerbar (in der aktuellen DB sind 266 von 381 aktiven Sendungen im Status `created`).
|
||||||
|
2. **Status-Intervalle verlaengern** (`DhlShipment::TRACKING_INTERVALS`). Vorschlag fuer 250/Tag: `out_for_delivery = 1h`, `in_transit = 6h`, `exception = 8h`, `unknown = 12h`, `created = 24h`. Das passt grob in die Quota und behaelt schnelle Reaktion fuer die "letzte Meile".
|
||||||
|
3. **Vorhandener "Batch"-Pfad ist faktisch keine Ersparnis**: Die DHL Unified Tracking API kennt laut Doku nur den Singular-Parameter `trackingNumber`. `trackMultipleShipments()` sendet `?trackingNumber=A,B,C,...` - das wird als eine einzige unbekannte Sendungs-ID behandelt, schickt den Lauf in den Per-Shipment-Fallback und kostet *einen Call extra pro Chunk*. Wenn die Quota verlaesslich erhoeht ist, sollte dieser Pfad entweder gegen einzelne Calls mit 5-Sekunden-Throttling ersetzt oder ganz entfernt werden. Bewusst noch nicht angefasst, weil das Refactoring umfangreich ist - separate Phase 13 wenn gewuenscht.
|
||||||
|
|
||||||
|
### Verbleibende Beobachtungen
|
||||||
|
|
||||||
|
- Eine zusaetzliche Beobachtung (Monitoring) waere sinnvoll: ein Alarm wenn `MAX(last_tracked_at)` quer ueber alle aktiven Sendungen aelter als z.B. 3 Stunden ist. Das macht eine erschoepfte Quota oder einen haengenden Cron sofort sichtbar, ohne dass jemand einzelne Sendungen manuell prueft.
|
||||||
|
- Default-Pause von 1 Stunde ist eine sinnvolle Heuristik, weil DHL kein verlaessliches `Retry-After` mitsendet. Falls die echte Quota-Reset-Zeit bekannt wird, koennte man die Default-Pause dynamisch bis zum naechsten Reset stellen.
|
||||||
|
|
||||||
|
## Phase 13 - Echtes Single-Tracking + Quota-vertraegliche Intervalle (2026-05-27)
|
||||||
|
|
||||||
|
Status: umgesetzt am 2026-05-27 (Folge zu Phase 12 - die Quota-Erhoehung auf 10.000 calls/day wurde im DHL Developer Portal beantragt, kann aber nach Erfahrung mehrere Wochen brauchen. Bis dahin muss das System mit den 250/day-Standardlimits sauber funktionieren).
|
||||||
|
|
||||||
|
### Ausloeser
|
||||||
|
|
||||||
|
Bei der Aufarbeitung von Phase 12 sind zwei strukturelle Schwachstellen aufgefallen:
|
||||||
|
|
||||||
|
1. **`trackMultipleShipments()` war kein echter Batch-Aufruf**. Die DHL Unified Tracking API kennt laut [Doku](https://developer.dhl.com/api-reference/shipment-tracking) nur den Singular-Parameter `trackingNumber`. Der Code sendete `?trackingNumber=A,B,C,...,J` als eine kommaseparierte Liste - DHL interpretierte das als *eine* unbekannte Sendungs-ID, lieferte ein leeres `shipments`-Array zurueck und der Code fiel in den Per-Shipment-Fallback. Effekt: **ein verschwendeter Aufruf pro Chunk plus** die zehn echten Aufrufe danach.
|
||||||
|
2. **Status-Intervalle aus Vor-Audit-Zeit**: `in_transit = 2h` und `created = 6h` waren so eng, dass selbst mit perfekt funktionierender API ca. 2.500 Aufrufe pro Tag nötig waeren - rund das **10-fache** des dokumentierten Standardlimits von 250 calls/day.
|
||||||
|
|
||||||
|
### Umsetzung
|
||||||
|
|
||||||
|
- `app/Services/DhlTrackingService.php`
|
||||||
|
- **`trackMultipleShipments()` komplett entfernt**. Damit existiert nur noch der Single-Tracking-Pfad `trackShipment()`, der einen einzelnen `trackingNumber` an DHL sendet. Ein neuer Unit-Test (`it does not have a trackMultipleShipments method anymore`) friert diese Entfernung als Vertrag ein.
|
||||||
|
- Neue statische Property `$callIntervalSeconds = 5` plus Setter/Getter `setCallIntervalSeconds()` / `getCallIntervalSeconds()`. Voreinstellung folgt der DHL-Doku ("max 1 call every 5 seconds"). In Tests wird der Wert auf `0` gesetzt, damit die Suite nicht real schlaeft.
|
||||||
|
- `updateTrackingBatch()` neu geschrieben: Anstelle der Chunks von 10 mit Pseudo-Batch-API laeuft jetzt **eine echte HTTP-Anfrage pro Sendung**, dazwischen `sleep($callIntervalSeconds)`. Abbruchbedingungen unveraendert: Quota-Pause vor dem ersten Call, `auth_error` oder `rate_limited` brechen die Schleife sofort ab, alle restlichen Sendungen werden ohne weiteren API-Aufruf uebersprungen. Bei einem Transport-Fehler (DNS/TLS) wird die einzelne Sendung als `transport_error` markiert; der Loop laeuft weiter, weil das ein vorruebergehendes Netzproblem sein kann.
|
||||||
|
- Das Cron-Log enthaelt jetzt zusaetzlich die Felder `http_calls` und `call_interval_seconds`, sodass Quota- und Throttle-Verhalten direkt nachvollziehbar sind.
|
||||||
|
|
||||||
|
- `packages/acme-laravel-dhl/src/Models/DhlShipment.php`
|
||||||
|
- `TRACKING_INTERVALS` angehoben:
|
||||||
|
- `out_for_delivery`: 1 h (unveraendert - kundenrelevant, betrifft nur sehr wenige Sendungen gleichzeitig)
|
||||||
|
- `in_transit`: 2 h -> **6 h**
|
||||||
|
- `exception`: 4 h -> **8 h**
|
||||||
|
- `unknown`: 4 h -> **12 h**
|
||||||
|
- `created`: 6 h -> **24 h**
|
||||||
|
- `DEFAULT_TRACKING_INTERVAL`: 4 h -> **8 h**.
|
||||||
|
- Indikative Rechnung mit aktuellem Live-Bestand (266 `created` + 115 `in_transit`):
|
||||||
|
- `created` 1x/Tag = 266 calls/Tag
|
||||||
|
- `in_transit` 4x/Tag = 460 calls/Tag
|
||||||
|
- Summe ~726 calls/Tag bei vollstaendigem Throughput - reicht fuer 10.000/Tag-Quota dreifach, aktiviert bei 250/Tag-Standardquota nach ca. 5 Stunden die Quota-Pause aus Phase 12. Da bleibt der Tracking-Status fuer alle Sendungen ohne `out_for_delivery` mindestens 1x/Tag aktuell - das ist mit Standard-Quota verkraftbar.
|
||||||
|
|
||||||
|
### Tests
|
||||||
|
|
||||||
|
- `tests/Unit/Dhl/DhlTrackingBatchThrottleTest.php` (neu, 4 Tests)
|
||||||
|
- 3 Sendungen erzeugen **genau 3 HTTP-Calls**, jede mit *einzelner* `trackingNumber` (kein Komma in den Query-Params).
|
||||||
|
- `setCallIntervalSeconds(1)` -> 3 Sendungen brauchen >= 2 s Wall-Clock (`sleep(1)` zwischen Call 1->2 und 2->3).
|
||||||
|
- `getCallIntervalSeconds()` Round-Trip plus `-99` wird auf `0` geklemmt.
|
||||||
|
- Vertragstest: `DhlTrackingService::trackMultipleShipments` existiert nicht mehr.
|
||||||
|
- `tests/Unit/Dhl/DhlTrackingStaleProtectionTest.php` (angepasst)
|
||||||
|
- Der "Batch bricht beim ersten Auth-Fehler ab"-Test pruef jetzt **`failed = 1`** statt `3`: in der neuen Logik wird nur die *erste* Sendung tatsaechlich angefragt, der Rest gar nicht erst gestartet. Das ist quotaschonender als das alte "Charge komplett als auth_error markieren"-Verhalten.
|
||||||
|
- `tests/Unit/Dhl/DhlTrackingRateLimitTest.php` (angepasst)
|
||||||
|
- Der frueher gegen `trackMultipleShipments()` gerichtete Test "marks the multi-tracking call as rate_limited too" ist ersatzlos entfernt, weil die Methode nicht mehr existiert.
|
||||||
|
- Der 429-Batch-Test pruefe jetzt analog **`failed = 1` mit nur einem HTTP-Call** - die anderen 11 Sendungen werden uebersprungen.
|
||||||
|
- `tests/Unit/Dhl/DhlTrackingAuthErrorTest.php` (angepasst)
|
||||||
|
- Der frueher direkte `trackMultipleShipments()`-Test ist entfernt, weil die Methode nicht mehr existiert.
|
||||||
|
|
||||||
|
### Verifikation Phase 13
|
||||||
|
|
||||||
|
- `php artisan test --compact tests/Unit/Dhl`
|
||||||
|
- Ergebnis: 98 Tests bestanden, 398 Assertions.
|
||||||
|
- `./vendor/bin/pint --dirty --format agent`
|
||||||
|
|
||||||
|
### Operatives Vorgehen
|
||||||
|
|
||||||
|
- **Solange Standard-Quota (250/day) aktiv ist**: Mit den neuen Intervallen erreicht das System ein realistisches Limit, bevor die Quota-Pause aus Phase 12 zuschlaegt. `out_for_delivery`-Sendungen werden bevorzugt aktualisiert, der Rest folgt nach.
|
||||||
|
- **Sobald die beantragte 10.000/day-Quota durchgeht**: Keine Code-Anpassung noetig. Die Intervalle sind so gewaehlt, dass auch der naechste Bestandszuwachs (mehr Sendungen) noch passt; bei sehr starkem Wachstum koennten `in_transit` wieder auf 4 h gesetzt werden.
|
||||||
|
- **Throttle bei eigenen Service Level**: Wer einen vertraglich erweiterten Service Level mit > 1 call/sec hat, kann das im Bootstrap setzen, z.B. `DhlTrackingService::setCallIntervalSeconds(2);`. Default bleibt 5 s, weil das den Standard-Service-Levels und der Doku entspricht.
|
||||||
|
|
||||||
|
### Verbleibende Empfehlung
|
||||||
|
|
||||||
|
- `--stale-days` im Cron auf 14 setzen, sobald sicher ist, dass keine legitime Bestellung > 2 Wochen im Status `created` bleibt. Das reduziert den getrackten Sendungspool spuerbar (266 von 381 aktiven Sendungen sind aktuell `created`, viele davon sind reine Karteileichen, deren aelteste seit `2026-04-29 03:00` keine Aenderung mehr hatten). Bewusst noch nicht durchgefuehrt, weil das eine Geschaeftsentscheidung ist - der aktuelle Default bleibt bei `--stale-days=30`.
|
||||||
|
|
||||||
## Legacy-Dokumentation
|
## Legacy-Dokumentation
|
||||||
|
|
||||||
Die bisherigen Markdown-Dateien wurden nach `dev/dhl-modul/legacy` verschoben. Sie bleiben als Historie erhalten, sind aber nicht mehr die aktuelle Arbeitsgrundlage.
|
Die bisherigen Markdown-Dateien wurden nach `dev/dhl-modul/legacy` verschoben. Sie bleiben als Historie erhalten, sind aber nicht mehr die aktuelle Arbeitsgrundlage.
|
||||||
|
|
|
||||||
|
|
@ -1,19 +0,0 @@
|
||||||
|
|
||||||
ABO
|
|
||||||
Ein Abo hat eine bestimmte Regel und einen eigenen Bestand an Produkten.
|
|
||||||
Zusätzlich soll es möglich sein, kurz bevor das Abo ausgeführt wird, soll es möglich sein aus dem regulären Stand der Produkte also da, wo der Berater oder auch später der Kunde seine Produkte bestellt, weitere Produkte seinem Abo hinzuzufügen.
|
|
||||||
|
|
||||||
Dazu folgenden Ablauf ich würde jedem Berater oder Kunden, der ein Abo hat drei Tage dann vorher eine E-Mail zuschicken, dass dein Abo jetzt entsprechend vorbereitet wird. In diesem Zeitraum hat er Zeit aus der regulären Bestellung weitere Produkte zu seiner Abo Bestellung hinzuzufügen. Das bedeutet er geht quasi in das eigentliche bestellen fügt seinen Warenkorb dann nicht einer neuen Bestellungen hinzu, sondern hat die Option zu meinem Abo hinzufügen. In diesem Augenblick spart er sich am Ende Versandkosten und bekommt eine gesammelte Bestellung. Ich würde es so machen, dass dann die Bestellung regulär in die Bestellungen läuft aber mit dem Hinweis wird zusammen mit dem Abo versendet.
|
|
||||||
|
|
||||||
Für die Berater und die Kunden fallen dabei die Versandkosten bei dem Berater zusätzlich das Kompensation Produkt (das ist noch zu klären). Gut wäre hier auch eine Option in den Einstellungen.
|
|
||||||
|
|
||||||
Es gibt ja einen Lock, d.h. X Tage bevor das Abo ausgeführt wird, ist es nicht mehr änderbar.
|
|
||||||
Dieser Lock muss händisch änderbar sein, irgendwo in den Einstellungen. Wir haben uns darauf geeinigt, dass das zwei oder drei Tage vorher ausreicht. Ich glaube, derzeit sind es fünf oder zehn Tage.
|
|
||||||
|
|
||||||
|
|
||||||
Mitgliedschaft
|
|
||||||
|
|
||||||
Die Mitgliedschaft wird immer zehn Tage vorher verlängert. Das kann grundsätzlich raus denn jeweils früher möglich unterschiedliche Pakete für die Mitgliedschaft zu buchen. Das ist jetzt nicht mehr möglich. Von daher verlängert sich am Ende die Mitgliedschaft in dem Augenblick, wo die Mitgliedschaft Gebühr bezahlt wird. Dafür gibt es so genannte Reminder im System. Diese müssen inhaltlich angepasst werden. Die Reminder sollen grundsätzlich bleiben und auf die Zahlung der Mitgliedschaft hinwirken.. in den E-Mails gibt es Links, die anscheinend nicht mehr richtig funktionieren. Richtig gut wäre es, wenn wir hier einen Magiclink bauen können, der direkt zur Zahlung geht.
|
|
||||||
|
|
||||||
Weiterhin ist es natürlich möglich, auch direkt im Sales Center die entsprechende Verlängerung abzuschließen. Hier gibt es das Produkt Business Paket oder ähnlich. Dieses Produkt hat aber die Beschreibung. Beim ersten Abschluss eines Beratervertrag ist die Verlängerung benötigt ein eigenes Paket, wo die Beschreibung entsprechend angepasst wird.
|
|
||||||
|
|
||||||
|
|
@ -1,49 +0,0 @@
|
||||||
|
|
||||||
|
|
||||||
Das DHL Cockpit ist super, leider klappt es nur für Deutschland.
|
|
||||||
Kommt Österreich, Spanien etc auch noch dazu?
|
|
||||||
Vermutlich ist derzeit die Schnittstelle nur für den deutschen Markt ausgelegt prüfen, wie wir die weiteren reinbekommen.
|
|
||||||
|
|
||||||
|
|
||||||
1 Feld für Sendungsreferenz oder Sonstiges (da wir öfters "Nachlieferung" oder ähnliches rein schreiben müssen)
|
|
||||||
|
|
||||||
Es wäre gut, wenn eine Meldung kommen würde, falls die Straße, PLZ oder Ort falsch ist, bevor das Etikett erstellt wird. Erst wenn man das Etikett ausdrucken will erscheint in DHL eine Information, dass zb die Straße nicht existiert. Dann muss man alles stornieren. Warenpost können wir nicht stornieren und muss beszehalt werden.
|
|
||||||
Ihr brauchen wir eine Lösung, die die Adresse validiert bitte einmal vorschlagen, was es hier für Lösungen gibt über APIs - Direkt über DHL?
|
|
||||||
|
|
||||||
stornieren über das DHL Cockpit hat leider noch nicht funktioniert. Da kam eine Fehlermeldung.
|
|
||||||
Wäre super, wenn Du das noch ergänzen könntest.
|
|
||||||
Prüfen, warum das nicht funktioniert. Hier gibt es bisher schon eine implementierte Lösung.
|
|
||||||
|
|
||||||
|
|
||||||
=> Gewicht Kompenataion, wird aktuell nicht mit in das DHL Paket Gewicht mit eingerechnet, da das Kompensation Produkt mit dem Gewicht null in den Warenkorb kommt damit keine Berechnung des Produktes entsteht. Hier müssten wir eine Lösung finden, wie wir das Gewicht vermutlich direkt über das Produkt auslesen und zum Paketgewicht hinzufügen.
|
|
||||||
|
|
||||||
Prüfen der Verwandte E-Mails. Derzeit werden Tracking Mails generiert. Hier müssen wir einmal prüfen, in welchem Rhythmus und wie oft diese generiert und ausgelöst werden. Wichtig ist, dass wir hier nicht zu viele raussenden etc.
|
|
||||||
Wichtig ist auch, dass bei jeder Bestellung sowohl beim Admin als auch beim User als auch im User N Portal die Tracking Codes entsprechend einsehbar Sendung aufrufbar.
|
|
||||||
|
|
||||||
Versenden nur mit Statusänderung.
|
|
||||||
|
|
||||||
Prüfen folgender Mail
|
|
||||||
|
|
||||||
Sehr geehrte Kundinnen und Kunden,
|
|
||||||
|
|
||||||
wir möchten Sie erneut daran erinnern, dass bis spätestens Sonntag, 31.05.2026, eine technische Umstellung Ihrer Systeme erforderlich ist. Ab dem 31.05.2026 treten verbindliche technische Anpassungen an unseren DHL-Systemen in Kraft.
|
|
||||||
|
|
||||||
Bitte stellen Sie sicher, dass die Umstellung fristgerecht erfolgt, um eine weiterhin reibungslose Nutzung unserer DHL-Systeme zu gewährleisten.
|
|
||||||
|
|
||||||
Deaktivierung Produktkürzel Warenpost zum 31.05.2026
|
|
||||||
|
|
||||||
Zum 01.01.2025 wurde das neue Produkt DHL Kleinpaket eingeführt und hat das bisherige Produkt Warenpost abgelöst. Wir haben festgestellt, dass Sie aktuell noch das veraltete Produktkürzel für Warenpost verwenden. Bislang wurde dieses Kürzel in unseren Systemen automatisch in DHL Kleinpaket umgewandelt. Diese technische Übergangslösung wird zum 31.05.2026 deaktiviert. Ab diesem Zeitpunkt ist eine automatische Anpassung des veralteten Produktkürzels leider nicht mehr möglich.
|
|
||||||
|
|
||||||
Bitte nehmen Sie die Umstellung daher zeitnah vor:
|
|
||||||
|
|
||||||
API-Anbindung: Ersetzen Sie das Produktkürzel „V62WP“ (Warenpost) durch „V62KP“ (DHL Kleinpaket). Sollten Sie eine Softwarelösung für die Sendungsbeauftragung nutzen, setzen Sie sich bitte zeitnah mit dem Hersteller in Verbindung,
|
|
||||||
|
|
||||||
CSV-Import (Funktion „Versenden“ im Post & DHL Geschäftskundenportal oder DHL Polling Software): Passen Sie Ihre CSV-Dateien entsprechend an und ersetzen Sie „V62WP“ durch „V62KP“.
|
|
||||||
|
|
||||||
Bei Fragen zur Umstellung können Sie uns jederzeit über das Group API Developer Portal Help Center kontaktieren. https://support-developer.dhl.com/support/home Als eingeloggter Nutzer können Sie dort außerdem ein Ticket erstellen, um Unterstützung zu erhalten.
|
|
||||||
|
|
||||||
Vielen Dank für Ihre Unterstützung bei der Umstellung, mit der Sie einen reibungslosen Versand auch über den 31.05.2026 hinaus sicherstellen.
|
|
||||||
|
|
||||||
Viele Grüße
|
|
||||||
|
|
||||||
Ihr DHL-Team
|
|
||||||
|
|
@ -1,157 +0,0 @@
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
#### 1. Überarbeitetes Dashboard & KPI-Übersicht
|
|
||||||
|
|
||||||
Das Dashboard soll eine interaktive Struktur erhalten (Linien 1 bis x). Folgende Kennzahlen müssen präzise abgebildet werden:
|
|
||||||
|
|
||||||
- **Anzahl der eigenen Kundenabos:** Direkte Sichtbarkeit der persönlichen Kundenentwicklung.
|
|
||||||
|
|
||||||
- **Kundenabos im Team (Terminologie-Korrektur):** Die Bezeichnung wird von „Team Kunden“ auf **„Teamkundenabos“** oder **„Kundenabos im Team“** geändert. Grund: Team-Kunden haben nicht zwingend ein aktives Abonnement; die Kennzahl muss jedoch rein die Abos widerspiegeln.
|
|
||||||
|
|
||||||
- **Umsatz & Volumen:** Darstellung des Umsatzes pro Linie (Abos, Einzelbestellungen etc.) inklusive Summenbildung.
|
|
||||||
|
|
||||||
|
|
||||||
#### 2. Interaktivität & „Deep Dive“ (Klickbarkeit)
|
|
||||||
|
|
||||||
Es ist entscheidend, dass Zahlen keine abstrakten Werte bleiben. Hinter jeder Kennzahl müssen die entsprechenden Personen sichtbar sein:
|
|
||||||
|
|
||||||
- **Klick-Funktion für Statistiken:** Wenn z. B. „55 Teamabos“ angezeigt werden, muss diese Zahl anklickbar sein, um die Liste der dahinterstehenden Personen zu öffnen.
|
|
||||||
|
|
||||||
- **Neupartner & Teamabos:** Eine direkte Klickmöglichkeit für Teamabos und Neupartner wird implementiert, damit Führungskräfte sofort sehen, wer diese Menschen sind und sie gezielt unterstützen können.
|
|
||||||
|
|
||||||
- **Detailansicht pro Person:** Beim Klick auf eine Linie öffnen sich detaillierte Infos (Generation, Punkte pro Abo, Ausführungsdaten der Kundenabos).
|
|
||||||
|
|
||||||
|
|
||||||
#### 3. Spezial-Kennzahl: „1000 Punkte Shop“
|
|
||||||
|
|
||||||
Um Top-Performer hervorzuheben, wird eine neue Metrik eingeführt:
|
|
||||||
|
|
||||||
- **Definition:** Erfassung aller Teampartner, die einen Kundenumsatz von mindestens 1000 Punkten erzielen.
|
|
||||||
|
|
||||||
- **Funktion:** Beim Klick auf diese Kennzahl erscheint eine Namensliste, die nach Volumen absteigend sortiert ist und die jeweiligen Volumenpunkte explizit anzeigt.
|
|
||||||
|
|
||||||
|
|
||||||
#### 4. System-Anpassungen & Formulare
|
|
||||||
|
|
||||||
- **Bestell-Formular:** Integration einer Pflichtabfrage: _„Von wem hast du von Mivita erfahren?“_, um die Zuweisung und das Marketing besser tracken zu können.
|
|
||||||
|
|
||||||
- **Stornoprozess:** Prüfung der Logik für Stornorechnungen. Es muss sichergestellt werden, dass bei einer Stornierung die entsprechenden Punkte systemseitig korrekt zurückgeführt (abgezogen) werden.
|
|
||||||
|
|
||||||
|
|
||||||
#### 5. Rechtliches & Sichtbarkeit (Incentives)
|
|
||||||
|
|
||||||
- **Transparenz in Ranglisten:** Für Incentives (z. B. Montenegro) sollen alle Namen der Teilnehmer (nicht nur die Top 30) mit Foto und Land angezeigt werden.
|
|
||||||
|
|
||||||
- **Opt-in Button:** Kevin implementiert einen Button, mit dem Partner aktiv zustimmen können, dass ihr Name in den Ranglisten für alle sichtbar ist.
|
|
||||||
|
|
||||||
- **Rechtliche Prüfung:** Ein Network-Anwalt wird durch Dani/Alois hinzugezogen, um die Anzeige von Namen in internen Ehrungen ohne explizite Einzelzustimmung abzuklären.
|
|
||||||
|
|
||||||
|
|
||||||
#### 6. Multimedia-Bereich
|
|
||||||
|
|
||||||
- **Event-Archiv:** Ein neuer Reiter (analog zum News-Archiv) wird erstellt. Hier lädt Susi regelmäßig Fotos von Veranstaltungen und Calls hoch, um das Momentum im Team zu fördern.
|
|
||||||
### Briefing: Optimierung & Ausbau des Mivita Backoffice (Fokus: Dashboard-Logik & Interaktivität)
|
|
||||||
|
|
||||||
Ziel dieses Umbaus ist es, das aktuelle "Daten-Chaos" durch eine übersichtliche, interaktive und klickbare dreistufige Tabellenstruktur zu ersetzen. Führungskräfte müssen von der Vogelperspektive bis auf den einzelnen Kunden durchklicken können.
|
|
||||||
|
|
||||||
#### Stufe 1: Das Haupt-Dashboard (Die Gesamtübersicht)
|
|
||||||
|
|
||||||
Die Startseite der Statistiken zeigt eine kompakte Zusammenfassung der gesamten Struktur (alle tatsächlich vorhandenen Linien sowie eine Summenzeile am Ende).
|
|
||||||
|
|
||||||
- **Spaltenaufbau:** * Linie (1. Linie, 2. Linie etc.)
|
|
||||||
|
|
||||||
- Anzahl der Berater
|
|
||||||
|
|
||||||
- Umsatz (Gesamtumsatz aus Abos, Einzelbestellungen etc.)
|
|
||||||
|
|
||||||
- Anzahl der Teamabos
|
|
||||||
|
|
||||||
- Anzahl der Kundenabos
|
|
||||||
|
|
||||||
- **Klick-Logik:** Egal, wo man in dieser Übersicht hinklickt (auf eine ganze Linie oder auf eine konkrete Zahl wie z.B. "3 Teamabos"), es öffnet sich immer eine tiefergehende Detailseite.
|
|
||||||
|
|
||||||
- **Aktueller MVP-Stand:** Die Kennzahlen sind als klickbare Badges umgesetzt. Die Summenzeile ist ebenfalls klickbar und öffnet über alle vorhandenen Linien. Bei Teamabos und Teamkundenabos wird zusätzlich angezeigt, wie viele Abos im gewählten Monat neu dazugekommen sind.
|
|
||||||
|
|
||||||
|
|
||||||
#### Stufe 2: Die Linien-Detailansicht (Generationen-Ebene)
|
|
||||||
|
|
||||||
Klickt man in der Hauptübersicht beispielsweise auf **"1. Linie"**, öffnet sich eine neue Seite, die alle direkten Partner (Firstlines) dieser Linie namentlich auflistet (z. B. Anna, Lena, Lisa).
|
|
||||||
|
|
||||||
- **Spaltenaufbau pro Partner:**
|
|
||||||
|
|
||||||
- Name der Firstline (z. B. Anna)
|
|
||||||
|
|
||||||
- Eigenes Abo (Anzeige in Punkten)
|
|
||||||
|
|
||||||
- Kundenabos (Anzahl der Abos & Gesamtpunktewert dieser Abos)
|
|
||||||
|
|
||||||
- Teampartnerabos (Anzahl der Abos & Gesamtpunktewert in der Organisation)
|
|
||||||
|
|
||||||
- Kundenabos im Team (Anzahl der Abos & Gesamtumsatz dieser Abos)
|
|
||||||
|
|
||||||
- **Gesamt:** Diese Spalte ist besonders wichtig. Sie addiert alle Abos (Eigenes Abo + Kundenabos + Teamabos + Kundenabos des Teams) und zeigt die Gesamt-Aboanzahl sowie die Gesamt-Umsatzpunkte pro Person an.
|
|
||||||
|
|
||||||
- **Klick-Logik:** Auch hier ist jede Kennzahl (z.B. "Kundenabos" bei Anna) wieder anklickbar.
|
|
||||||
|
|
||||||
|
|
||||||
#### Stufe 3: Die Tiefen-Detailansicht (Listen-Ebene)
|
|
||||||
|
|
||||||
Wenn man noch mehr Infos zu einer spezifischen Kennzahl haben möchte (entweder durch Klick auf eine Zahl im Haupt-Dashboard oder durch Klick auf eine Metrik bei einem bestimmten Partner in Stufe 2), öffnet sich das tiefste Level.
|
|
||||||
|
|
||||||
- **Beispiel 1 (Klick auf Annas "Kundenabos"):** Es öffnet sich das Fenster "Anna - Kundenabos". Man sieht oben eine Zusammenfassung (z.B. "Insgesamt: 4 Kundenabos / 200 Abokundenpunkte"). Darunter werden alle 4 Kunden einzeln und übersichtlich aufgelistet.
|
|
||||||
|
|
||||||
- **Beispiel 2 (Klick auf "3 Teamabos" im Haupt-Dashboard):** Es öffnet sich das Fenster "Teamabos Generation 1". Auch hier gibt es eine Zusammenfassung und darunter die Liste der 3 Personen (z.B. Sabine, Carola, Anna).
|
|
||||||
|
|
||||||
- **Angezeigte Daten pro Eintrag in der Liste:**
|
|
||||||
|
|
||||||
- Name des Kunden/Partners
|
|
||||||
|
|
||||||
- Punktewert (z. B. 50 Punkte)
|
|
||||||
|
|
||||||
- Nächste Ausführung (Datum, z. B. 10.4.2026)
|
|
||||||
|
|
||||||
- Abo Lieferungen (Anzahl, z. B. 1)
|
|
||||||
|
|
||||||
- Status des Abos, z. B. aktiv, angehalten, storniert oder inaktiv
|
|
||||||
|
|
||||||
- Besteht-seit-Datum; neue Abos im gewählten Monat werden optisch hervorgehoben
|
|
||||||
|
|
||||||
- **Aktueller MVP-Stand:** Detailtabellen haben eine Suche, klickbare Spaltensortierung, eine Summenzeile und einen CSV-Export. Der gewählte Monat/Jahr bleibt beim Wechsel zwischen Übersicht und Detailansicht erhalten.
|
|
||||||
|
|
||||||
- **Performance/Snapshots:** Abgeschlossene Monate können als Backoffice-Statistik-Snapshot gespeichert werden. Dadurch bleiben vergangene Monatswerte stabil und müssen bei großen Teams nicht jedes Mal live neu berechnet werden.
|
|
||||||
|
|
||||||
- **1000-Punkte-Shop:** Die Detailansicht zeigt keine zusätzliche Qualifikations-Einteilung mehr, sondern den aktuellen Karriere-Level des Beraters. Die Punkte bleiben nach Eigenpunkten, Kundenabo-Punkten, Einzelbestellungs-Punkten und sonstigen Kundenpunkten getrennt sichtbar.
|
|
||||||
|
|
||||||
- **Karriere-Level:** Detailansichten zeigen den aktuellen Karriere-Level des jeweiligen Beraters, damit die Namenlisten fachlich besser einordbar sind.
|
|
||||||
|
|
||||||
- **Datenschutz-Hinweis:** Detailansichten weisen sichtbar darauf hin, dass personenbezogene Daten rechtlich noch final geklärt werden und aktuell nur für berechtigte VIP-Auswertungen vorgesehen sind.
|
|
||||||
|
|
||||||
- **Übersichts-Export:** Die Linienübersicht kann als CSV exportiert werden. Enthalten sind alle Linien, die Summenzeile, neue Abo-Zählungen und die getrennten Punktewerte.
|
|
||||||
|
|
||||||
- **Tests:** CSV-Inhalte für Übersicht und Details, der Zeitraum-Erhalt zwischen Übersicht, Detailansicht und Export, neue Abo-Markierungen und Abo-Statusgründe aus Zahlungsfehlern sind gezielt abgesichert.
|
|
||||||
|
|
||||||
- **Performance-Hinweis:** Die Übersicht zeigt, ob die Daten live oder aus einem Snapshot geladen wurden, inklusive Laufzeit der Berechnung.
|
|
||||||
|
|
||||||
- **Checkout-Herkunft:** Kundenbestellungen im Shop fragen eine vordefinierte Herkunft plus optionalen Freitext ab. Die Werte werden an der Bestellung gespeichert und im Bestelldetail angezeigt.
|
|
||||||
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
#### Weitere wichtige Backoffice-Anpassungen (abseits der Listen-Logik)
|
|
||||||
|
|
||||||
- **Neue Spezial-Kennzahl „1000 Punkte Shop“:**
|
|
||||||
|
|
||||||
- Eine zusätzliche, anklickbare Kennzahl in der Übersicht.
|
|
||||||
|
|
||||||
- Zeigt die Anzahl der Teampartner an, die mindestens 1000 Punkte Kundenumsatz generiert haben.
|
|
||||||
|
|
||||||
- Beim Klick darauf erscheint eine Liste mit den Namen dieser Partner, absteigend sortiert nach Volumen, wobei die genauen Volumenpunkte angezeigt werden.
|
|
||||||
|
|
||||||
- **Erfassung der Herkunft (Formular):**
|
|
||||||
|
|
||||||
- Im Bestellformular wird die verpflichtende Abfrage eingefügt: _„Von wem hast du von Mivita erfahren?“_ (Wichtig für Zuordnung und Tracking).
|
|
||||||
|
|
||||||
- **Stornoprozess:**
|
|
||||||
|
|
||||||
- Die IT muss prüfen, ob Stornorechnungen systemseitig korrekt verarbeitet werden, sodass bei einem Storno die entsprechenden Punkte automatisch und fehlerfrei zurückgeführt (abgezogen) werden.
|
|
||||||
|
|
@ -368,20 +368,33 @@ class DhlShipment extends Model
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Tracking interval per status (in hours).
|
* Tracking interval per status (in hours).
|
||||||
* Determines how often each status should be re-checked via the DHL API.
|
*
|
||||||
|
* Determines how often each status should be re-checked via the DHL
|
||||||
|
* Unified Tracking API. The defaults were tightened in Phase 13 of the
|
||||||
|
* DHL refactor to fit into the documented Standard service level of
|
||||||
|
* "250 calls per day, max 1 call every 5 seconds"
|
||||||
|
* (https://developer.dhl.com/api-reference/shipment-tracking#rate-limits).
|
||||||
|
*
|
||||||
|
* Indicative cost for the current production data (266 created /
|
||||||
|
* 115 in_transit shipments):
|
||||||
|
* - in_transit 6 h -> 4 calls/day * 115 = ~460 calls/day
|
||||||
|
* - created 24 h -> 1 call/day * 266 = 266 calls/day
|
||||||
|
* - out_for_delivery 1 h is kept short on purpose because that status
|
||||||
|
* only affects very few shipments at a time but matters most for
|
||||||
|
* customer-facing "kommt heute" emails.
|
||||||
*/
|
*/
|
||||||
public const TRACKING_INTERVALS = [
|
public const TRACKING_INTERVALS = [
|
||||||
'out_for_delivery' => 1,
|
'out_for_delivery' => 1,
|
||||||
'in_transit' => 2,
|
'in_transit' => 6,
|
||||||
'exception' => 4,
|
'exception' => 8,
|
||||||
'unknown' => 4,
|
'unknown' => 12,
|
||||||
'created' => 6,
|
'created' => 24,
|
||||||
];
|
];
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Default tracking interval in hours for statuses not explicitly listed
|
* Default tracking interval in hours for statuses not explicitly listed.
|
||||||
*/
|
*/
|
||||||
public const DEFAULT_TRACKING_INTERVAL = 4;
|
public const DEFAULT_TRACKING_INTERVAL = 8;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Scope for shipments that need a tracking update based on status-dependent intervals.
|
* Scope for shipments that need a tracking update based on status-dependent intervals.
|
||||||
|
|
|
||||||
|
|
@ -2,15 +2,15 @@
|
||||||
|
|
||||||
namespace Acme\Dhl\Services;
|
namespace Acme\Dhl\Services;
|
||||||
|
|
||||||
use Acme\Dhl\Support\DhlClient;
|
|
||||||
use Acme\Dhl\Models\DhlShipment;
|
|
||||||
use Acme\Dhl\Services\ShippingService;
|
|
||||||
use Illuminate\Support\Facades\Storage;
|
|
||||||
use InvalidArgumentException;
|
|
||||||
use Exception;
|
|
||||||
use Acme\Dhl\Jobs\CreateReturnLabelJob;
|
use Acme\Dhl\Jobs\CreateReturnLabelJob;
|
||||||
|
use Acme\Dhl\Models\DhlShipment;
|
||||||
|
use Acme\Dhl\Support\DhlClient;
|
||||||
|
use App\Services\DhlProductResolver;
|
||||||
|
use Exception;
|
||||||
use Illuminate\Support\Facades\Log;
|
use Illuminate\Support\Facades\Log;
|
||||||
|
use Illuminate\Support\Facades\Storage;
|
||||||
use Illuminate\Support\Facades\Validator;
|
use Illuminate\Support\Facades\Validator;
|
||||||
|
use InvalidArgumentException;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* DHL Returns Service for creating and managing return labels
|
* DHL Returns Service for creating and managing return labels
|
||||||
|
|
@ -22,8 +22,9 @@ class ReturnsService
|
||||||
/**
|
/**
|
||||||
* Create a return label for a shipment
|
* Create a return label for a shipment
|
||||||
*
|
*
|
||||||
* @param array $returnData Return shipment data
|
* @param array $returnData Return shipment data
|
||||||
* @return array Return label details including number and path
|
* @return array Return label details including number and path
|
||||||
|
*
|
||||||
* @throws InvalidArgumentException When required data is missing
|
* @throws InvalidArgumentException When required data is missing
|
||||||
* @throws Exception When API request fails
|
* @throws Exception When API request fails
|
||||||
*/
|
*/
|
||||||
|
|
@ -32,6 +33,7 @@ class ReturnsService
|
||||||
$validatedData = $this->validateReturnData($returnData);
|
$validatedData = $this->validateReturnData($returnData);
|
||||||
if (config('dhl.use_queue')) {
|
if (config('dhl.use_queue')) {
|
||||||
CreateReturnLabelJob::dispatch($validatedData);
|
CreateReturnLabelJob::dispatch($validatedData);
|
||||||
|
|
||||||
return ['queued' => true];
|
return ['queued' => true];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -82,14 +84,14 @@ class ReturnsService
|
||||||
$returnNumber = $this->extractReturnNumber($response);
|
$returnNumber = $this->extractReturnNumber($response);
|
||||||
$labelBase64 = $this->extractLabelData($response);
|
$labelBase64 = $this->extractLabelData($response);
|
||||||
|
|
||||||
if (!$returnNumber) {
|
if (! $returnNumber) {
|
||||||
Log::error('[DHL Returns] Failed to extract return number', [
|
Log::error('[DHL Returns] Failed to extract return number', [
|
||||||
'response' => $response,
|
'response' => $response,
|
||||||
]);
|
]);
|
||||||
throw new Exception('Failed to extract return shipment number from DHL API response');
|
throw new Exception('Failed to extract return shipment number from DHL API response');
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!$labelBase64) {
|
if (! $labelBase64) {
|
||||||
Log::warning('[DHL Returns] No label data in response', [
|
Log::warning('[DHL Returns] No label data in response', [
|
||||||
'return_number' => $returnNumber,
|
'return_number' => $returnNumber,
|
||||||
]);
|
]);
|
||||||
|
|
@ -108,7 +110,7 @@ class ReturnsService
|
||||||
'label_path' => $labelPath,
|
'label_path' => $labelPath,
|
||||||
'returnShipment' => $returnShipment,
|
'returnShipment' => $returnShipment,
|
||||||
'raw' => $response,
|
'raw' => $response,
|
||||||
'method' => 'returns_api'
|
'method' => 'returns_api',
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -129,7 +131,7 @@ class ReturnsService
|
||||||
$consignee = $this->convertAddressFor2LetterCountry($returnData['consignee']);
|
$consignee = $this->convertAddressFor2LetterCountry($returnData['consignee']);
|
||||||
|
|
||||||
// Get DHL config for dimensions
|
// Get DHL config for dimensions
|
||||||
$settingController = new \App\Http\Controllers\SettingController();
|
$settingController = new \App\Http\Controllers\SettingController;
|
||||||
$dhlConfig = $settingController->getDhlConfig();
|
$dhlConfig = $settingController->getDhlConfig();
|
||||||
|
|
||||||
// Prepare data for regular shipment (shipper and consignee are already swapped)
|
// Prepare data for regular shipment (shipper and consignee are already swapped)
|
||||||
|
|
@ -155,7 +157,7 @@ class ReturnsService
|
||||||
'height' => 60,
|
'height' => 60,
|
||||||
],
|
],
|
||||||
|
|
||||||
'reference' => 'Return-' . ($returnData['order_id'] ?? time()),
|
'reference' => 'Return-'.($returnData['order_id'] ?? time()),
|
||||||
];
|
];
|
||||||
|
|
||||||
Log::info('[DHL Returns] Prepared shipment data for fallback', [
|
Log::info('[DHL Returns] Prepared shipment data for fallback', [
|
||||||
|
|
@ -195,14 +197,14 @@ class ReturnsService
|
||||||
'label_path' => $result['label_path'] ?? $result['labelPath'] ?? null,
|
'label_path' => $result['label_path'] ?? $result['labelPath'] ?? null,
|
||||||
'returnShipment' => $returnShipment,
|
'returnShipment' => $returnShipment,
|
||||||
'raw' => $result,
|
'raw' => $result,
|
||||||
'method' => 'shipping_api_fallback'
|
'method' => 'shipping_api_fallback',
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Get return shipment by return number
|
* Get return shipment by return number
|
||||||
*
|
*
|
||||||
* @param string $returnNumber DHL return number
|
* @param string $returnNumber DHL return number
|
||||||
* @return DhlShipment|null Return shipment model or null if not found
|
* @return DhlShipment|null Return shipment model or null if not found
|
||||||
*/
|
*/
|
||||||
public function getReturnShipment(string $returnNumber): ?DhlShipment
|
public function getReturnShipment(string $returnNumber): ?DhlShipment
|
||||||
|
|
@ -215,7 +217,7 @@ class ReturnsService
|
||||||
/**
|
/**
|
||||||
* Get all return shipments for an order
|
* Get all return shipments for an order
|
||||||
*
|
*
|
||||||
* @param int $orderId Order ID
|
* @param int $orderId Order ID
|
||||||
* @return \Illuminate\Database\Eloquent\Collection Return shipments collection
|
* @return \Illuminate\Database\Eloquent\Collection Return shipments collection
|
||||||
*/
|
*/
|
||||||
public function getOrderReturns(int $orderId): \Illuminate\Database\Eloquent\Collection
|
public function getOrderReturns(int $orderId): \Illuminate\Database\Eloquent\Collection
|
||||||
|
|
@ -228,7 +230,7 @@ class ReturnsService
|
||||||
/**
|
/**
|
||||||
* Get returns for a specific outbound shipment
|
* Get returns for a specific outbound shipment
|
||||||
*
|
*
|
||||||
* @param int $shipmentId Original outbound shipment ID
|
* @param int $shipmentId Original outbound shipment ID
|
||||||
* @return \Illuminate\Database\Eloquent\Collection Related return shipments
|
* @return \Illuminate\Database\Eloquent\Collection Related return shipments
|
||||||
*/
|
*/
|
||||||
public function getShipmentReturns(int $shipmentId): \Illuminate\Database\Eloquent\Collection
|
public function getShipmentReturns(int $shipmentId): \Illuminate\Database\Eloquent\Collection
|
||||||
|
|
@ -310,7 +312,7 @@ class ReturnsService
|
||||||
});
|
});
|
||||||
|
|
||||||
// Get billing number from config
|
// Get billing number from config
|
||||||
$settingController = new \App\Http\Controllers\SettingController();
|
$settingController = new \App\Http\Controllers\SettingController;
|
||||||
$dhlConfig = $settingController->getDhlConfig();
|
$dhlConfig = $settingController->getDhlConfig();
|
||||||
$billingNumber = $dhlConfig['billing_number'] ?? config('dhl.billing_number');
|
$billingNumber = $dhlConfig['billing_number'] ?? config('dhl.billing_number');
|
||||||
|
|
||||||
|
|
@ -320,8 +322,8 @@ class ReturnsService
|
||||||
|
|
||||||
return [
|
return [
|
||||||
'receiverId' => 'DEDE',
|
'receiverId' => 'DEDE',
|
||||||
'customerReference' => 'Return-' . ($returnData['order_id'] ?? time()),
|
'customerReference' => 'Return-'.($returnData['order_id'] ?? time()),
|
||||||
'shipmentReference' => 'Return-Order-' . ($returnData['order_id'] ?? time()),
|
'shipmentReference' => 'Return-Order-'.($returnData['order_id'] ?? time()),
|
||||||
'billingNumber' => $billingNumber,
|
'billingNumber' => $billingNumber,
|
||||||
'shipper' => $shipper,
|
'shipper' => $shipper,
|
||||||
'receiver' => $consignee,
|
'receiver' => $consignee,
|
||||||
|
|
@ -355,11 +357,11 @@ class ReturnsService
|
||||||
*/
|
*/
|
||||||
private function saveLabelFile(?string $returnNumber, ?string $labelBase64, string $format): ?string
|
private function saveLabelFile(?string $returnNumber, ?string $labelBase64, string $format): ?string
|
||||||
{
|
{
|
||||||
if (!$labelBase64 || !$returnNumber) {
|
if (! $labelBase64 || ! $returnNumber) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
$path = 'dhl/returns/' . $returnNumber . '.' . strtolower($format);
|
$path = 'dhl/returns/'.$returnNumber.'.'.strtolower($format);
|
||||||
Storage::disk('local')->put($path, base64_decode($labelBase64));
|
Storage::disk('local')->put($path, base64_decode($labelBase64));
|
||||||
|
|
||||||
return $path;
|
return $path;
|
||||||
|
|
@ -386,89 +388,59 @@ class ReturnsService
|
||||||
'company' => $shipper['name2'] ?? '',
|
'company' => $shipper['name2'] ?? '',
|
||||||
'email' => $shipper['email'] ?? '',
|
'email' => $shipper['email'] ?? '',
|
||||||
'recipient' => $returnData,
|
'recipient' => $returnData,
|
||||||
'api_response_data' => $response
|
'api_response_data' => $response,
|
||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Convert 2-letter country code to 3-letter country code for DHL API
|
* Convert 2-letter or 3-letter country code to DHL-compatible 3-letter code.
|
||||||
*
|
*
|
||||||
* @param string $countryCode 2-letter or 3-letter ISO country code (e.g., "DE" or "DEU")
|
* Delegates to {@see DhlProductResolver::toDhlCountryCode()} so that the
|
||||||
* @return string 3-letter ISO country code (e.g., "DEU")
|
* resolver remains the single source of truth for supported countries.
|
||||||
|
* Unknown country codes throw an {@see InvalidArgumentException} so that
|
||||||
|
* a misconfigured return cannot silently be routed to Germany.
|
||||||
*/
|
*/
|
||||||
private function convertCountryCode(string $countryCode): string
|
private function convertCountryCode(string $countryCode): string
|
||||||
{
|
{
|
||||||
$code = strtoupper(trim($countryCode));
|
$resolver = new DhlProductResolver;
|
||||||
|
|
||||||
// If already 3 letters, check if valid and return
|
try {
|
||||||
if (strlen($code) === 3) {
|
return $resolver->toDhlCountryCode($countryCode);
|
||||||
$validThreeLetterCodes = ['DEU', 'AUT', 'CHE', 'FRA', 'ITA', 'ESP', 'NLD', 'BEL', 'LUX', 'POL', 'CZE', 'DNK', 'SWE', 'NOR', 'GBR', 'USA'];
|
} catch (\InvalidArgumentException $e) {
|
||||||
return in_array($code, $validThreeLetterCodes) ? $code : 'DEU';
|
throw new InvalidArgumentException(
|
||||||
|
'DHL Retoure: '.$e->getMessage(),
|
||||||
|
$e->getCode(),
|
||||||
|
$e
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Convert 2-letter to 3-letter
|
|
||||||
$countryMap = [
|
|
||||||
'DE' => 'DEU',
|
|
||||||
'AT' => 'AUT',
|
|
||||||
'CH' => 'CHE',
|
|
||||||
'FR' => 'FRA',
|
|
||||||
'IT' => 'ITA',
|
|
||||||
'ES' => 'ESP',
|
|
||||||
'NL' => 'NLD',
|
|
||||||
'BE' => 'BEL',
|
|
||||||
'LU' => 'LUX',
|
|
||||||
'PL' => 'POL',
|
|
||||||
'CZ' => 'CZE',
|
|
||||||
'DK' => 'DNK',
|
|
||||||
'SE' => 'SWE',
|
|
||||||
'NO' => 'NOR',
|
|
||||||
'GB' => 'GBR',
|
|
||||||
'US' => 'USA',
|
|
||||||
];
|
|
||||||
|
|
||||||
return $countryMap[$code] ?? 'DEU';
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Convert address with 3-letter country code back to 2-letter for ShippingService
|
* Convert address with 3-letter country code back to ISO-2 for ShippingService.
|
||||||
*
|
*
|
||||||
* @param array $address Address with 3-letter country code
|
* Uses {@see DhlProductResolver::normalizeCountryCode()} which accepts both
|
||||||
* @return array Address with 2-letter country code
|
* 2- and 3-letter codes and rejects unsupported countries. The previous
|
||||||
|
* implementation silently fell back to `DE` which contradicted the
|
||||||
|
* resolver-based concept introduced in phase 3.
|
||||||
*/
|
*/
|
||||||
private function convertAddressFor2LetterCountry(array $address): array
|
private function convertAddressFor2LetterCountry(array $address): array
|
||||||
{
|
{
|
||||||
$converted = $address;
|
$converted = $address;
|
||||||
|
|
||||||
// Convert 3-letter to 2-letter country code
|
if (! isset($address['country']) || $address['country'] === '') {
|
||||||
if (isset($address['country'])) {
|
return $converted;
|
||||||
$reverseMap = [
|
}
|
||||||
'DEU' => 'DE',
|
|
||||||
'AUT' => 'AT',
|
|
||||||
'CHE' => 'CH',
|
|
||||||
'FRA' => 'FR',
|
|
||||||
'ITA' => 'IT',
|
|
||||||
'ESP' => 'ES',
|
|
||||||
'NLD' => 'NL',
|
|
||||||
'BEL' => 'BE',
|
|
||||||
'LUX' => 'LU',
|
|
||||||
'POL' => 'PL',
|
|
||||||
'CZE' => 'CZ',
|
|
||||||
'DNK' => 'DK',
|
|
||||||
'SWE' => 'SE',
|
|
||||||
'NOR' => 'NO',
|
|
||||||
'GBR' => 'GB',
|
|
||||||
'USA' => 'US',
|
|
||||||
];
|
|
||||||
|
|
||||||
$code = strtoupper($address['country']);
|
$resolver = new DhlProductResolver;
|
||||||
|
|
||||||
// If it's 3 letters, convert to 2
|
try {
|
||||||
if (strlen($code) === 3) {
|
$converted['country'] = $resolver->normalizeCountryCode((string) $address['country']);
|
||||||
$converted['country'] = $reverseMap[$code] ?? 'DE';
|
} catch (\InvalidArgumentException $e) {
|
||||||
} else {
|
throw new InvalidArgumentException(
|
||||||
// Already 2 letters, keep as is
|
'DHL Retoure: '.$e->getMessage(),
|
||||||
$converted['country'] = $code;
|
$e->getCode(),
|
||||||
}
|
$e
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
return $converted;
|
return $converted;
|
||||||
|
|
|
||||||
|
|
@ -8,6 +8,7 @@ use Acme\Dhl\Jobs\CreateShipmentJob;
|
||||||
use Acme\Dhl\Models\DhlShipment;
|
use Acme\Dhl\Models\DhlShipment;
|
||||||
use Acme\Dhl\Support\DhlClient;
|
use Acme\Dhl\Support\DhlClient;
|
||||||
use App\Services\DhlProductResolver;
|
use App\Services\DhlProductResolver;
|
||||||
|
use App\Services\DhlShipmentService;
|
||||||
use App\Services\DhlShipmentWeightCalculator;
|
use App\Services\DhlShipmentWeightCalculator;
|
||||||
use Exception;
|
use Exception;
|
||||||
use Illuminate\Support\Facades\DB;
|
use Illuminate\Support\Facades\DB;
|
||||||
|
|
@ -34,7 +35,7 @@ class ShippingService
|
||||||
*/
|
*/
|
||||||
public function createLabel(array $orderData): array
|
public function createLabel(array $orderData): array
|
||||||
{
|
{
|
||||||
Log::info('createLabel', $orderData);
|
Log::info('[DHL Shipping] createLabel called', DhlShipmentService::sanitizeOrderDataForLog($orderData));
|
||||||
$validatedData = $this->validateOrderData($orderData);
|
$validatedData = $this->validateOrderData($orderData);
|
||||||
if (config('dhl.use_queue')) {
|
if (config('dhl.use_queue')) {
|
||||||
CreateShipmentJob::dispatch($validatedData);
|
CreateShipmentJob::dispatch($validatedData);
|
||||||
|
|
@ -45,12 +46,7 @@ class ShippingService
|
||||||
return DB::transaction(function () use ($validatedData) {
|
return DB::transaction(function () use ($validatedData) {
|
||||||
$payload = $this->buildShipmentPayload($validatedData);
|
$payload = $this->buildShipmentPayload($validatedData);
|
||||||
|
|
||||||
// Debug logging: Log the exact payload being sent to DHL API
|
Log::info('[DHL API] Sending payload to DHL', $this->buildPayloadLogContext($payload, $validatedData));
|
||||||
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 {
|
try {
|
||||||
// Build query parameters for print format
|
// Build query parameters for print format
|
||||||
|
|
@ -62,9 +58,7 @@ class ShippingService
|
||||||
|
|
||||||
$response = $this->client->request('post', '/parcel/de/shipping/v2/orders', $payload, $query);
|
$response = $this->client->request('post', '/parcel/de/shipping/v2/orders', $payload, $query);
|
||||||
|
|
||||||
Log::info('[DHL API] Response received', [
|
Log::info('[DHL API] Response received', $this->buildResponseLogContext($response));
|
||||||
'response' => $response,
|
|
||||||
]);
|
|
||||||
$this->assertSuccessfulShipmentResponse($response, $this->shouldUseMustEncode($validatedData));
|
$this->assertSuccessfulShipmentResponse($response, $this->shouldUseMustEncode($validatedData));
|
||||||
} catch (DhlValidationException $e) {
|
} catch (DhlValidationException $e) {
|
||||||
if ($this->shouldUseMustEncode($validatedData) && $this->looksLikeAddressValidationError($e->getMessage())) {
|
if ($this->shouldUseMustEncode($validatedData) && $this->looksLikeAddressValidationError($e->getMessage())) {
|
||||||
|
|
@ -73,10 +67,10 @@ class ShippingService
|
||||||
|
|
||||||
throw $e;
|
throw $e;
|
||||||
} catch (Exception $e) {
|
} catch (Exception $e) {
|
||||||
Log::error('[DHL API] Request failed', [
|
Log::error('[DHL API] Request failed', array_merge(
|
||||||
'error' => $e->getMessage(),
|
$this->buildPayloadLogContext($payload, $validatedData),
|
||||||
'payload' => $payload,
|
['error' => $e->getMessage()]
|
||||||
]);
|
));
|
||||||
throw $e;
|
throw $e;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -204,6 +198,53 @@ class ShippingService
|
||||||
return null;
|
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
|
* Validate required order data according to DHL API v2 specification
|
||||||
*/
|
*/
|
||||||
|
|
@ -380,20 +421,30 @@ class ShippingService
|
||||||
$addressData['houseNumber'] = $parsed['houseNumber'];
|
$addressData['houseNumber'] = $parsed['houseNumber'];
|
||||||
|
|
||||||
Log::info('Parsed German address', [
|
Log::info('Parsed German address', [
|
||||||
'original' => $street,
|
'original_street_length' => strlen($street),
|
||||||
'parsed_street' => $parsed['street'],
|
'parsed_street_length' => strlen($parsed['street']),
|
||||||
'parsed_houseNumber' => $parsed['houseNumber'],
|
'parsed_house_number_length' => strlen($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', [
|
return $addressData;
|
||||||
'street' => $street,
|
|
||||||
]);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
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.'
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
|
||||||
|
|
@ -109,9 +109,12 @@
|
||||||
<div class="text-xs font-weight-bold text-info text-uppercase mb-1">Tracking</div>
|
<div class="text-xs font-weight-bold text-info text-uppercase mb-1">Tracking</div>
|
||||||
<div class="h6 mb-0 font-weight-bold text-gray-800">
|
<div class="h6 mb-0 font-weight-bold text-gray-800">
|
||||||
@if(false)
|
@if(false)
|
||||||
|
@php
|
||||||
|
$publicTrackingUrl = \App\Domain\EarlyDomainParser::getMainUrl().'/tracking';
|
||||||
|
@endphp
|
||||||
<code class="text-info">{{ $shipment->dhl_shipment_no }}</code>
|
<code class="text-info">{{ $shipment->dhl_shipment_no }}</code>
|
||||||
<br>
|
<br>
|
||||||
<a href="{{ route('public.tracking') }}?tracking_number={{ $shipment->dhl_shipment_no }}"
|
<a href="{{ $publicTrackingUrl }}?tracking_number={{ urlencode($shipment->dhl_shipment_no) }}"
|
||||||
target="_blank" class="text-muted small">
|
target="_blank" class="text-muted small">
|
||||||
<i class="fas fa-external-link-alt"></i> Verfolgen
|
<i class="fas fa-external-link-alt"></i> Verfolgen
|
||||||
</a>
|
</a>
|
||||||
|
|
|
||||||
|
|
@ -212,20 +212,37 @@ $(document).ready(function() {
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Escape HTML special characters so DHL-/DB-derived strings can never
|
||||||
|
// execute JavaScript even if jQuery's .html() is used.
|
||||||
|
function escapeTrackingHtml(value) {
|
||||||
|
if (value === null || value === undefined) {
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
return String(value)
|
||||||
|
.replace(/&/g, '&')
|
||||||
|
.replace(/</g, '<')
|
||||||
|
.replace(/>/g, '>')
|
||||||
|
.replace(/"/g, '"')
|
||||||
|
.replace(/'/g, ''');
|
||||||
|
}
|
||||||
|
|
||||||
// Show tracking results
|
// Show tracking results
|
||||||
function showTrackingResult(data) {
|
function showTrackingResult(data) {
|
||||||
var statusBadge = getStatusBadge(data.status);
|
var statusBadge = getStatusBadge(data.status);
|
||||||
var trackingStatusInfo = data.tracking_status ?
|
var trackingStatusInfo = data.tracking_status ?
|
||||||
`<p class="mb-2"><strong>DHL Status:</strong> <em>${data.tracking_status}</em></p>` : '';
|
`<p class="mb-2"><strong>DHL Status:</strong> <em>${escapeTrackingHtml(data.tracking_status)}</em></p>` : '';
|
||||||
var lastTrackedInfo = data.last_tracked_at ?
|
var lastTrackedInfo = data.last_tracked_at ?
|
||||||
`<p class="mb-2"><strong>Zuletzt aktualisiert:</strong> ${data.last_tracked_at}</p>` : '';
|
`<p class="mb-2"><strong>Zuletzt aktualisiert:</strong> ${escapeTrackingHtml(data.last_tracked_at)}</p>` : '';
|
||||||
|
|
||||||
|
var trackingNumberEscaped = escapeTrackingHtml(data.tracking_number);
|
||||||
|
var trackingNumberUrlEncoded = encodeURIComponent(data.tracking_number ?? '');
|
||||||
|
|
||||||
var html = `
|
var html = `
|
||||||
<div class="row">
|
<div class="row">
|
||||||
<div class="col-md-6">
|
<div class="col-md-6">
|
||||||
<h5 class="text-primary mb-3">
|
<h5 class="text-primary mb-3">
|
||||||
<i class="fas fa-hashtag"></i>
|
<i class="fas fa-hashtag"></i>
|
||||||
${data.tracking_number}
|
${trackingNumberEscaped}
|
||||||
</h5>
|
</h5>
|
||||||
<p class="mb-2"><strong>Status:</strong> ${statusBadge}</p>
|
<p class="mb-2"><strong>Status:</strong> ${statusBadge}</p>
|
||||||
${trackingStatusInfo}
|
${trackingStatusInfo}
|
||||||
|
|
@ -239,7 +256,7 @@ $(document).ready(function() {
|
||||||
<p class="text-muted small">
|
<p class="text-muted small">
|
||||||
Die Informationen werden regelmäßig aktualisiert.
|
Die Informationen werden regelmäßig aktualisiert.
|
||||||
Für detaillierte Tracking-Informationen besuchen Sie die
|
Für detaillierte Tracking-Informationen besuchen Sie die
|
||||||
<a href="https://www.dhl.de/de/privatkunden/pakete-empfangen/verfolgen.html?lang=de&idc=${data.tracking_number}"
|
<a href="https://www.dhl.de/de/privatkunden/pakete-empfangen/verfolgen.html?lang=de&idc=${trackingNumberUrlEncoded}"
|
||||||
target="_blank" class="text-primary">
|
target="_blank" class="text-primary">
|
||||||
DHL Website <i class="fas fa-external-link-alt"></i>
|
DHL Website <i class="fas fa-external-link-alt"></i>
|
||||||
</a>
|
</a>
|
||||||
|
|
@ -267,7 +284,9 @@ $(document).ready(function() {
|
||||||
|
|
||||||
// Show error message
|
// Show error message
|
||||||
function showError(message) {
|
function showError(message) {
|
||||||
$('#error-message .alert p').html(message);
|
// Use .text() instead of .html() so error strings from the server
|
||||||
|
// can never inject HTML.
|
||||||
|
$('#error-message .alert p').text(message);
|
||||||
$('#error-message').show();
|
$('#error-message').show();
|
||||||
|
|
||||||
// Smooth scroll to error
|
// Smooth scroll to error
|
||||||
|
|
@ -321,7 +340,10 @@ $(document).ready(function() {
|
||||||
badgeClass = 'badge-light';
|
badgeClass = 'badge-light';
|
||||||
}
|
}
|
||||||
|
|
||||||
return `<span class="badge ${badgeClass}">${text}</span>`;
|
// Both the fallback `text` (raw status) and the class are escaped so
|
||||||
|
// unmapped DHL status codes can never inject HTML or break out of the
|
||||||
|
// class attribute.
|
||||||
|
return `<span class="badge ${escapeTrackingHtml(badgeClass)}">${escapeTrackingHtml(text)}</span>`;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Get status icon
|
// Get status icon
|
||||||
|
|
|
||||||
|
|
@ -299,9 +299,14 @@ Route::domain(config('app.pre_url_crm').config('app.domain').config('app.tld_car
|
||||||
Route::get('/shipment/{shipment}/download-label', 'DhlShipmentController@downloadLabel')->name('admin.dhl.download-label');
|
Route::get('/shipment/{shipment}/download-label', 'DhlShipmentController@downloadLabel')->name('admin.dhl.download-label');
|
||||||
Route::post('/batch-action', 'DhlShipmentController@batchAction')->name('admin.dhl.batch-action');
|
Route::post('/batch-action', 'DhlShipmentController@batchAction')->name('admin.dhl.batch-action');
|
||||||
Route::post('/test-login', 'DhlShipmentController@testLogin')->name('admin.dhl.test_login');
|
Route::post('/test-login', 'DhlShipmentController@testLogin')->name('admin.dhl.test_login');
|
||||||
Route::get('/public/track', 'DhlShipmentController@track')->name('public.tracking');
|
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// The previously registered `public.tracking` route lived inside this
|
||||||
|
// admin group, which made it auth/admin protected and therefore not
|
||||||
|
// actually public. The real public tracking page is defined on the
|
||||||
|
// main domain in `routes/domains/main.php`. It is intentionally not
|
||||||
|
// duplicated here.
|
||||||
|
|
||||||
// products attributes
|
// products attributes
|
||||||
Route::get('/admin/product/attributes', 'AttributeController@index')->name('admin_product_attributes');
|
Route::get('/admin/product/attributes', 'AttributeController@index')->name('admin_product_attributes');
|
||||||
Route::post('/admin/product/attribute/store', 'AttributeController@store')->name('admin_product_attribute_store');
|
Route::post('/admin/product/attribute/store', 'AttributeController@store')->name('admin_product_attribute_store');
|
||||||
|
|
|
||||||
41
tests/Unit/Dhl/CreateShipmentJobSerializationTest.php
Normal file
41
tests/Unit/Dhl/CreateShipmentJobSerializationTest.php
Normal file
|
|
@ -0,0 +1,41 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
use App\Jobs\CreateShipmentJob;
|
||||||
|
use App\Models\ShoppingOrder;
|
||||||
|
|
||||||
|
it('never serializes DHL credentials into the queue payload', function () {
|
||||||
|
$order = new ShoppingOrder;
|
||||||
|
$order->id = 4711;
|
||||||
|
|
||||||
|
$dhlConfig = [
|
||||||
|
'base_url' => 'https://api-eu.dhl.com',
|
||||||
|
'api_key' => 'super-secret-api-key',
|
||||||
|
'username' => 'mivita-user',
|
||||||
|
'password' => 'super-secret-password',
|
||||||
|
'api_secret' => 'super-secret-api-secret',
|
||||||
|
'billing_number' => '63144073550101',
|
||||||
|
];
|
||||||
|
|
||||||
|
$job = new CreateShipmentJob($order, 2.5, ['priority' => 'normal'], $dhlConfig);
|
||||||
|
|
||||||
|
$serialized = serialize($job);
|
||||||
|
|
||||||
|
expect($serialized)
|
||||||
|
->not->toContain('super-secret-api-key')
|
||||||
|
->not->toContain('super-secret-password')
|
||||||
|
->not->toContain('super-secret-api-secret')
|
||||||
|
->not->toContain('mivita-user')
|
||||||
|
->not->toContain('63144073550101');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('does not expose a dhlConfig property on the job instance', function () {
|
||||||
|
$order = new ShoppingOrder;
|
||||||
|
$order->id = 1;
|
||||||
|
|
||||||
|
$job = new CreateShipmentJob($order, 1.0, [], [
|
||||||
|
'api_key' => 'should-not-be-stored',
|
||||||
|
'password' => 'should-not-be-stored',
|
||||||
|
]);
|
||||||
|
|
||||||
|
expect(property_exists($job, 'dhlConfig'))->toBeFalse();
|
||||||
|
});
|
||||||
47
tests/Unit/Dhl/DhlConfigCachingTest.php
Normal file
47
tests/Unit/Dhl/DhlConfigCachingTest.php
Normal file
|
|
@ -0,0 +1,47 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
use App\Http\Controllers\SettingController;
|
||||||
|
|
||||||
|
beforeEach(function () {
|
||||||
|
SettingController::flushDhlConfigCache();
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(function () {
|
||||||
|
SettingController::flushDhlConfigCache();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns the cached DHL configuration without re-reading settings', function () {
|
||||||
|
$cached = [
|
||||||
|
'base_url' => 'https://api-eu.dhl.com',
|
||||||
|
'api_key' => 'cached-api-key',
|
||||||
|
'username' => 'cached-user',
|
||||||
|
'password' => 'cached-password',
|
||||||
|
'account_numbers' => ['V01PAK' => '63144073550101'],
|
||||||
|
];
|
||||||
|
|
||||||
|
$reflection = new ReflectionClass(SettingController::class);
|
||||||
|
$property = $reflection->getProperty('cachedDhlConfig');
|
||||||
|
$property->setAccessible(true);
|
||||||
|
$property->setValue(null, $cached);
|
||||||
|
|
||||||
|
$controller = new SettingController;
|
||||||
|
|
||||||
|
expect($controller->getDhlConfig())->toBe($cached);
|
||||||
|
|
||||||
|
$secondCall = $controller->getDhlConfig();
|
||||||
|
|
||||||
|
expect($secondCall)->toBe($cached);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('flushes the DHL configuration cache on demand', function () {
|
||||||
|
$reflection = new ReflectionClass(SettingController::class);
|
||||||
|
$property = $reflection->getProperty('cachedDhlConfig');
|
||||||
|
$property->setAccessible(true);
|
||||||
|
$property->setValue(null, ['api_key' => 'cached-api-key']);
|
||||||
|
|
||||||
|
expect($property->getValue())->toBe(['api_key' => 'cached-api-key']);
|
||||||
|
|
||||||
|
SettingController::flushDhlConfigCache();
|
||||||
|
|
||||||
|
expect($property->getValue())->toBeNull();
|
||||||
|
});
|
||||||
47
tests/Unit/Dhl/DhlModalAuthorizationTest.php
Normal file
47
tests/Unit/Dhl/DhlModalAuthorizationTest.php
Normal file
|
|
@ -0,0 +1,47 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
use App\Http\Controllers\ModalController;
|
||||||
|
use App\User;
|
||||||
|
use Symfony\Component\HttpKernel\Exception\HttpException;
|
||||||
|
|
||||||
|
function invokeDhlModalAuth(?User $user): void
|
||||||
|
{
|
||||||
|
if ($user !== null) {
|
||||||
|
Auth::shouldReceive('user')->andReturn($user);
|
||||||
|
} else {
|
||||||
|
Auth::shouldReceive('user')->andReturnNull();
|
||||||
|
}
|
||||||
|
|
||||||
|
$controller = new ModalController;
|
||||||
|
$method = (new ReflectionClass(ModalController::class))->getMethod('authorizeDhlShipmentModal');
|
||||||
|
$method->setAccessible(true);
|
||||||
|
$method->invoke($controller);
|
||||||
|
}
|
||||||
|
|
||||||
|
afterEach(function () {
|
||||||
|
Mockery::close();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('rejects guests for the DHL shipment modal', function () {
|
||||||
|
invokeDhlModalAuth(null);
|
||||||
|
})->throws(HttpException::class, 'DHL shipment modal is only available for admin users.');
|
||||||
|
|
||||||
|
it('rejects VIP users (admin == 1) for the DHL shipment modal', function () {
|
||||||
|
$vip = (new User)->forceFill(['admin' => 1]);
|
||||||
|
|
||||||
|
invokeDhlModalAuth($vip);
|
||||||
|
})->throws(HttpException::class, 'DHL shipment modal is only available for admin users.');
|
||||||
|
|
||||||
|
it('rejects regular consultants for the DHL shipment modal', function () {
|
||||||
|
$consultant = (new User)->forceFill(['admin' => 0]);
|
||||||
|
|
||||||
|
invokeDhlModalAuth($consultant);
|
||||||
|
})->throws(HttpException::class, 'DHL shipment modal is only available for admin users.');
|
||||||
|
|
||||||
|
it('allows real admin users (admin >= 2) for the DHL shipment modal', function () {
|
||||||
|
$admin = (new User)->forceFill(['admin' => 2]);
|
||||||
|
|
||||||
|
invokeDhlModalAuth($admin);
|
||||||
|
|
||||||
|
expect(true)->toBeTrue();
|
||||||
|
});
|
||||||
|
|
@ -1,6 +1,9 @@
|
||||||
<?php
|
<?php
|
||||||
|
|
||||||
|
use App\Models\Setting;
|
||||||
use App\Services\DhlProductResolver;
|
use App\Services\DhlProductResolver;
|
||||||
|
use Illuminate\Database\Schema\Blueprint;
|
||||||
|
use Illuminate\Support\Facades\Schema;
|
||||||
|
|
||||||
beforeEach(function () {
|
beforeEach(function () {
|
||||||
config([
|
config([
|
||||||
|
|
@ -57,10 +60,74 @@ it('uses configured international destination countries', function () {
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('uses saved DHL international countries even with env config priority', function () {
|
||||||
|
$createdSettingsTable = ensureDhlSettingsTableForResolverTest();
|
||||||
|
|
||||||
|
try {
|
||||||
|
config([
|
||||||
|
'dhl.config_source' => 'env',
|
||||||
|
'dhl.international_countries' => ['AT'],
|
||||||
|
]);
|
||||||
|
|
||||||
|
Setting::whereSlug('dhl_international_countries')->delete();
|
||||||
|
Setting::setContentBySlug('dhl_international_countries', ['FR'], 'object');
|
||||||
|
|
||||||
|
expect((new DhlProductResolver)->getSupportedInternationalCountries())->toBe(['FR']);
|
||||||
|
} finally {
|
||||||
|
Setting::whereSlug('dhl_international_countries')->delete();
|
||||||
|
|
||||||
|
if ($createdSettingsTable) {
|
||||||
|
Schema::drop('settings');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it('keeps an intentionally empty saved DHL international country list', function () {
|
||||||
|
$createdSettingsTable = ensureDhlSettingsTableForResolverTest();
|
||||||
|
|
||||||
|
try {
|
||||||
|
config([
|
||||||
|
'dhl.config_source' => 'env',
|
||||||
|
'dhl.international_countries' => ['AT'],
|
||||||
|
]);
|
||||||
|
|
||||||
|
Setting::whereSlug('dhl_international_countries')->delete();
|
||||||
|
Setting::setContentBySlug('dhl_international_countries', [], 'object');
|
||||||
|
|
||||||
|
expect((new DhlProductResolver)->getSupportedInternationalCountries())->toBe([]);
|
||||||
|
} finally {
|
||||||
|
Setting::whereSlug('dhl_international_countries')->delete();
|
||||||
|
|
||||||
|
if ($createdSettingsTable) {
|
||||||
|
Schema::drop('settings');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
it('normalizes configurable international countries', function () {
|
it('normalizes configurable international countries', function () {
|
||||||
expect(DhlProductResolver::normalizeCountryCodeList([' at ', 'DE', 'XX', 'FR', 'AT']))->toBe(['AT', 'FR']);
|
expect(DhlProductResolver::normalizeCountryCodeList([' at ', 'DE', 'XX', 'FR', 'AT']))->toBe(['AT', 'FR']);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
function ensureDhlSettingsTableForResolverTest(): bool
|
||||||
|
{
|
||||||
|
if (Schema::hasTable('settings')) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
Schema::create('settings', function (Blueprint $table): void {
|
||||||
|
$table->increments('id');
|
||||||
|
$table->string('slug')->index();
|
||||||
|
$table->string('type')->nullable();
|
||||||
|
$table->json('object')->nullable();
|
||||||
|
$table->text('full_text')->nullable();
|
||||||
|
$table->text('text')->nullable();
|
||||||
|
$table->integer('int')->nullable();
|
||||||
|
$table->timestamps();
|
||||||
|
});
|
||||||
|
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
it('describes DHL product scope for preflight checks', function () {
|
it('describes DHL product scope for preflight checks', function () {
|
||||||
$resolver = new DhlProductResolver;
|
$resolver = new DhlProductResolver;
|
||||||
|
|
||||||
|
|
|
||||||
15
tests/Unit/Dhl/DhlRouteRegistrationTest.php
Normal file
15
tests/Unit/Dhl/DhlRouteRegistrationTest.php
Normal file
|
|
@ -0,0 +1,15 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
it('does not register the public tracking route inside the CRM admin group', function () {
|
||||||
|
$crmRoutes = file_get_contents(base_path('routes/domains/crm.php'));
|
||||||
|
|
||||||
|
expect($crmRoutes)
|
||||||
|
->not->toMatch('/Route::(get|post|match|any)\([^)]*\)->name\([\'"]public\.tracking[\'"]\)/');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('still exposes the public tracking route on the main domain', function () {
|
||||||
|
$mainRoutes = file_get_contents(base_path('routes/domains/main.php'));
|
||||||
|
|
||||||
|
expect($mainRoutes)
|
||||||
|
->toMatch('/Route::get\([\'"]\/tracking[\'"][^)]*\)->name\([\'"]public\.tracking[\'"]\)/');
|
||||||
|
});
|
||||||
116
tests/Unit/Dhl/DhlSanitizeLoggingTest.php
Normal file
116
tests/Unit/Dhl/DhlSanitizeLoggingTest.php
Normal file
|
|
@ -0,0 +1,116 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
use App\Services\DhlShipmentService;
|
||||||
|
|
||||||
|
it('redacts DHL credentials from configuration logs', function () {
|
||||||
|
$config = [
|
||||||
|
'base_url' => 'https://api-eu.dhl.com',
|
||||||
|
'api_key' => 'super-secret-api-key',
|
||||||
|
'username' => 'mivita-user',
|
||||||
|
'password' => 'super-secret-password',
|
||||||
|
'api_secret' => 'super-secret-api-secret',
|
||||||
|
'billing_number' => '63144073550101',
|
||||||
|
'use_queue' => false,
|
||||||
|
'default_product' => 'V01PAK',
|
||||||
|
'print_only_if_codeable' => true,
|
||||||
|
'international_countries' => ['AT', 'ES'],
|
||||||
|
'account_numbers' => [
|
||||||
|
'V01PAK' => '63144073550101',
|
||||||
|
'V62KP' => '63144073556201',
|
||||||
|
'V53PAK' => '63144073555301',
|
||||||
|
'V07PAK' => null,
|
||||||
|
],
|
||||||
|
];
|
||||||
|
|
||||||
|
$sanitized = DhlShipmentService::sanitizeDhlConfigForLog($config);
|
||||||
|
$serialized = json_encode($sanitized);
|
||||||
|
|
||||||
|
expect($sanitized)
|
||||||
|
->toHaveKey('has_api_key', true)
|
||||||
|
->toHaveKey('has_username', true)
|
||||||
|
->toHaveKey('has_password', true)
|
||||||
|
->toHaveKey('has_api_secret', true)
|
||||||
|
->toHaveKey('base_url', 'https://api-eu.dhl.com')
|
||||||
|
->toHaveKey('international_countries', ['AT', 'ES'])
|
||||||
|
->and($sanitized['account_numbers_configured'])->toEqualCanonicalizing(['V01PAK', 'V62KP', 'V53PAK']);
|
||||||
|
|
||||||
|
expect($serialized)
|
||||||
|
->not->toContain('super-secret-api-key')
|
||||||
|
->not->toContain('super-secret-password')
|
||||||
|
->not->toContain('super-secret-api-secret')
|
||||||
|
->not->toContain('mivita-user')
|
||||||
|
->not->toContain('63144073550101');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('marks missing DHL credentials as not configured', function () {
|
||||||
|
$sanitized = DhlShipmentService::sanitizeDhlConfigForLog([
|
||||||
|
'base_url' => null,
|
||||||
|
'api_key' => '',
|
||||||
|
'username' => null,
|
||||||
|
'password' => '',
|
||||||
|
'account_numbers' => [
|
||||||
|
'V01PAK' => '',
|
||||||
|
'V62KP' => null,
|
||||||
|
],
|
||||||
|
]);
|
||||||
|
|
||||||
|
expect($sanitized)
|
||||||
|
->toHaveKey('has_api_key', false)
|
||||||
|
->toHaveKey('has_username', false)
|
||||||
|
->toHaveKey('has_password', false)
|
||||||
|
->toHaveKey('account_numbers_configured', []);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('redacts personally identifiable information from order data logs', function () {
|
||||||
|
$orderData = [
|
||||||
|
'order_id' => 4711,
|
||||||
|
'product_code' => 'V01PAK',
|
||||||
|
'weight_kg' => 1.25,
|
||||||
|
'label_format' => 'PDF',
|
||||||
|
'print_format' => 'A4',
|
||||||
|
'print_only_if_codeable' => true,
|
||||||
|
'reference' => 'Order-4711',
|
||||||
|
'shipper' => [
|
||||||
|
'name' => 'mivita care gmbh',
|
||||||
|
'street' => 'Leinfeld',
|
||||||
|
'houseNumber' => '2',
|
||||||
|
'postalCode' => '87755',
|
||||||
|
'city' => 'Kirchhaslach',
|
||||||
|
'country' => 'DE',
|
||||||
|
'email' => 'versand@mivita.care',
|
||||||
|
'phone' => '+49 123 456789',
|
||||||
|
],
|
||||||
|
'consignee' => [
|
||||||
|
'name' => 'Max Mustermann',
|
||||||
|
'street' => 'Hauptstrasse',
|
||||||
|
'houseNumber' => '5',
|
||||||
|
'postalCode' => '10115',
|
||||||
|
'city' => 'Berlin',
|
||||||
|
'country' => 'DE',
|
||||||
|
'email' => 'max@example.com',
|
||||||
|
'phone' => '+4930123456',
|
||||||
|
'postNumber' => '1234567890',
|
||||||
|
],
|
||||||
|
];
|
||||||
|
|
||||||
|
$sanitized = DhlShipmentService::sanitizeOrderDataForLog($orderData);
|
||||||
|
$serialized = json_encode($sanitized);
|
||||||
|
|
||||||
|
expect($sanitized)
|
||||||
|
->toHaveKey('order_id', 4711)
|
||||||
|
->toHaveKey('product_code', 'V01PAK')
|
||||||
|
->toHaveKey('weight_kg', 1.25)
|
||||||
|
->toHaveKey('consignee_country', 'DE')
|
||||||
|
->toHaveKey('consignee_postal_prefix', '10')
|
||||||
|
->toHaveKey('consignee_has_post_number', true)
|
||||||
|
->toHaveKey('has_reference', true);
|
||||||
|
|
||||||
|
expect($serialized)
|
||||||
|
->not->toContain('Max Mustermann')
|
||||||
|
->not->toContain('Hauptstrasse')
|
||||||
|
->not->toContain('max@example.com')
|
||||||
|
->not->toContain('+4930123456')
|
||||||
|
->not->toContain('1234567890')
|
||||||
|
->not->toContain('10115')
|
||||||
|
->not->toContain('Berlin');
|
||||||
|
});
|
||||||
108
tests/Unit/Dhl/DhlTrackingAuthErrorTest.php
Normal file
108
tests/Unit/Dhl/DhlTrackingAuthErrorTest.php
Normal file
|
|
@ -0,0 +1,108 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
use App\Http\Controllers\SettingController;
|
||||||
|
use App\Services\DhlTrackingService;
|
||||||
|
use Illuminate\Support\Facades\Http;
|
||||||
|
|
||||||
|
beforeEach(function () {
|
||||||
|
// Pre-populate the process-wide DHL config cache (introduced in Phase 10)
|
||||||
|
// so the SettingController::getDhlConfig() call inside DhlTrackingService
|
||||||
|
// does not hit the database during the unit test.
|
||||||
|
$reflection = new ReflectionClass(SettingController::class);
|
||||||
|
$property = $reflection->getProperty('cachedDhlConfig');
|
||||||
|
$property->setAccessible(true);
|
||||||
|
$property->setValue(null, [
|
||||||
|
'api_key' => 'cached-test-api-key-1234',
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(function () {
|
||||||
|
SettingController::flushDhlConfigCache();
|
||||||
|
DhlTrackingService::clearQuotaPause();
|
||||||
|
Mockery::close();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns an explicit auth_error and a meaningful message on HTTP 401', function () {
|
||||||
|
Http::fake([
|
||||||
|
'api-eu.dhl.com/track/shipments*' => Http::response(['title' => 'Unauthorized'], 401),
|
||||||
|
]);
|
||||||
|
|
||||||
|
$service = new DhlTrackingService;
|
||||||
|
$result = $service->trackShipment('00340434292135100148');
|
||||||
|
|
||||||
|
expect($result)
|
||||||
|
->toHaveKey('success', false)
|
||||||
|
->toHaveKey('auth_error', true)
|
||||||
|
->toHaveKey('http_status', 401)
|
||||||
|
->toHaveKey('api_used', 'unified');
|
||||||
|
|
||||||
|
expect($result['message'])
|
||||||
|
->toContain('Authentifizierung fehlgeschlagen')
|
||||||
|
->toContain('HTTP 401')
|
||||||
|
->toContain('Shipment Tracking - Unified');
|
||||||
|
|
||||||
|
// No fallback request to a phantom Parcel DE tracking endpoint must occur.
|
||||||
|
Http::assertSentCount(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('marks HTTP 403 as auth error too', function () {
|
||||||
|
Http::fake([
|
||||||
|
'api-eu.dhl.com/track/shipments*' => Http::response(['title' => 'Forbidden'], 403),
|
||||||
|
]);
|
||||||
|
|
||||||
|
$service = new DhlTrackingService;
|
||||||
|
$result = $service->trackShipment('00340434292135100148');
|
||||||
|
|
||||||
|
expect($result['auth_error'])->toBeTrue()
|
||||||
|
->and($result['http_status'])->toBe(403)
|
||||||
|
->and($result['message'])->toContain('HTTP 403');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('distinguishes a "not found" response from a 401 auth error', function () {
|
||||||
|
Http::fake([
|
||||||
|
'api-eu.dhl.com/track/shipments*' => Http::response(['shipments' => []], 200),
|
||||||
|
]);
|
||||||
|
|
||||||
|
$service = new DhlTrackingService;
|
||||||
|
$result = $service->trackShipment('99999999999999999999');
|
||||||
|
|
||||||
|
expect($result)
|
||||||
|
->toHaveKey('success', false)
|
||||||
|
->toHaveKey('not_found', true)
|
||||||
|
->and($result['message'])->toBe('Sendung nicht gefunden oder noch nicht im DHL-System erfasst.');
|
||||||
|
|
||||||
|
expect($result)->not->toHaveKey('auth_error');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('does not fall back to the non-existent parcel-de tracking endpoint anymore', function () {
|
||||||
|
Http::fake([
|
||||||
|
'api-eu.dhl.com/track/shipments*' => Http::response(['title' => 'Unauthorized'], 401),
|
||||||
|
'api-eu.dhl.com/parcel/de/tracking*' => Http::response('should never be called', 500),
|
||||||
|
]);
|
||||||
|
|
||||||
|
$service = new DhlTrackingService;
|
||||||
|
$service->trackShipment('00340434292135100148');
|
||||||
|
|
||||||
|
Http::assertSentCount(1);
|
||||||
|
Http::assertNotSent(function ($request) {
|
||||||
|
return str_contains($request->url(), '/parcel/de/tracking');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('redacts the api key in the auth-error log payload', function () {
|
||||||
|
Http::fake([
|
||||||
|
'api-eu.dhl.com/track/shipments*' => Http::response([], 401),
|
||||||
|
]);
|
||||||
|
|
||||||
|
\Illuminate\Support\Facades\Log::spy();
|
||||||
|
|
||||||
|
$service = new DhlTrackingService;
|
||||||
|
$service->trackShipment('00340434292135100148');
|
||||||
|
|
||||||
|
\Illuminate\Support\Facades\Log::shouldHaveReceived('error')
|
||||||
|
->withArgs(function (string $message, array $context) {
|
||||||
|
return str_contains($message, 'authentication failed')
|
||||||
|
&& ($context['api_key_suffix'] ?? null) === '***1234'
|
||||||
|
&& ! str_contains(json_encode($context), 'cached-test-api-key');
|
||||||
|
});
|
||||||
|
});
|
||||||
132
tests/Unit/Dhl/DhlTrackingBatchThrottleTest.php
Normal file
132
tests/Unit/Dhl/DhlTrackingBatchThrottleTest.php
Normal file
|
|
@ -0,0 +1,132 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
use Acme\Dhl\Models\DhlShipment;
|
||||||
|
use App\Http\Controllers\SettingController;
|
||||||
|
use App\Services\DhlTrackingService;
|
||||||
|
use Illuminate\Support\Collection;
|
||||||
|
use Illuminate\Support\Facades\Http;
|
||||||
|
|
||||||
|
beforeEach(function () {
|
||||||
|
$reflection = new ReflectionClass(SettingController::class);
|
||||||
|
$property = $reflection->getProperty('cachedDhlConfig');
|
||||||
|
$property->setAccessible(true);
|
||||||
|
$property->setValue(null, [
|
||||||
|
'api_key' => 'cached-test-api-key-7777',
|
||||||
|
'use_queue' => false,
|
||||||
|
]);
|
||||||
|
|
||||||
|
DhlTrackingService::clearQuotaPause();
|
||||||
|
DhlTrackingService::setCallIntervalSeconds(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(function () {
|
||||||
|
SettingController::flushDhlConfigCache();
|
||||||
|
DhlTrackingService::clearQuotaPause();
|
||||||
|
DhlTrackingService::setCallIntervalSeconds(5);
|
||||||
|
Mockery::close();
|
||||||
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return DhlShipment&\Mockery\MockInterface
|
||||||
|
*/
|
||||||
|
function makeTrackableShipment(int $id, string $trackingNumber)
|
||||||
|
{
|
||||||
|
/** @var DhlShipment&\Mockery\MockInterface $shipment */
|
||||||
|
$shipment = Mockery::mock(DhlShipment::class)->makePartial();
|
||||||
|
$shipment->id = $id;
|
||||||
|
$shipment->dhl_shipment_no = $trackingNumber;
|
||||||
|
$shipment->status = 'in_transit';
|
||||||
|
|
||||||
|
return $shipment;
|
||||||
|
}
|
||||||
|
|
||||||
|
it('fires exactly one DHL request per shipment - no pseudo-batch comma list anymore', function () {
|
||||||
|
Http::fake([
|
||||||
|
'api-eu.dhl.com/track/shipments*' => Http::response([
|
||||||
|
'shipments' => [
|
||||||
|
[
|
||||||
|
'id' => 'A1',
|
||||||
|
'status' => [
|
||||||
|
'statusCode' => 'transit',
|
||||||
|
'status' => 'Sendung in Zustellung',
|
||||||
|
'timestamp' => '2026-05-27T12:00:00+02:00',
|
||||||
|
],
|
||||||
|
'events' => [],
|
||||||
|
],
|
||||||
|
],
|
||||||
|
], 200),
|
||||||
|
]);
|
||||||
|
|
||||||
|
$shipments = new Collection([
|
||||||
|
makeTrackableShipment(1, 'A1'),
|
||||||
|
makeTrackableShipment(2, 'B2'),
|
||||||
|
makeTrackableShipment(3, 'C3'),
|
||||||
|
]);
|
||||||
|
|
||||||
|
foreach ($shipments as $shipment) {
|
||||||
|
$shipment->shouldReceive('update')->andReturnTrue();
|
||||||
|
}
|
||||||
|
|
||||||
|
$service = new DhlTrackingService;
|
||||||
|
$stats = $service->updateTrackingBatch($shipments);
|
||||||
|
|
||||||
|
expect($stats['updated'])->toBe(3);
|
||||||
|
expect($stats['failed'])->toBe(0);
|
||||||
|
|
||||||
|
// 3 shipments -> exactly 3 HTTP calls, each with a single trackingNumber.
|
||||||
|
Http::assertSentCount(3);
|
||||||
|
Http::assertSent(function ($request) {
|
||||||
|
$value = $request->data()['trackingNumber'] ?? null;
|
||||||
|
|
||||||
|
return $value !== null && ! str_contains((string) $value, ',');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('sleeps between calls when the throttle is enabled', function () {
|
||||||
|
DhlTrackingService::setCallIntervalSeconds(1);
|
||||||
|
|
||||||
|
Http::fake([
|
||||||
|
'api-eu.dhl.com/track/shipments*' => Http::response([
|
||||||
|
'shipments' => [
|
||||||
|
[
|
||||||
|
'id' => 'A1',
|
||||||
|
'status' => ['statusCode' => 'transit', 'status' => 'In Zustellung', 'timestamp' => '2026-05-27T12:00:00+02:00'],
|
||||||
|
'events' => [],
|
||||||
|
],
|
||||||
|
],
|
||||||
|
], 200),
|
||||||
|
]);
|
||||||
|
|
||||||
|
$shipments = new Collection([
|
||||||
|
makeTrackableShipment(1, 'A1'),
|
||||||
|
makeTrackableShipment(2, 'A1'),
|
||||||
|
makeTrackableShipment(3, 'A1'),
|
||||||
|
]);
|
||||||
|
foreach ($shipments as $shipment) {
|
||||||
|
$shipment->shouldReceive('update')->andReturnTrue();
|
||||||
|
}
|
||||||
|
|
||||||
|
$service = new DhlTrackingService;
|
||||||
|
|
||||||
|
$start = microtime(true);
|
||||||
|
$stats = $service->updateTrackingBatch($shipments);
|
||||||
|
$elapsed = microtime(true) - $start;
|
||||||
|
|
||||||
|
expect($stats['updated'])->toBe(3);
|
||||||
|
// 3 calls with a 1s gap between calls -> >=2s total wall-clock time.
|
||||||
|
expect($elapsed)->toBeGreaterThanOrEqual(2.0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('respects setCallIntervalSeconds(0) for the test suite', function () {
|
||||||
|
expect(DhlTrackingService::getCallIntervalSeconds())->toBe(0);
|
||||||
|
|
||||||
|
DhlTrackingService::setCallIntervalSeconds(5);
|
||||||
|
expect(DhlTrackingService::getCallIntervalSeconds())->toBe(5);
|
||||||
|
|
||||||
|
DhlTrackingService::setCallIntervalSeconds(-99);
|
||||||
|
expect(DhlTrackingService::getCallIntervalSeconds())->toBe(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('does not have a trackMultipleShipments method anymore (removed in Phase 13)', function () {
|
||||||
|
expect(method_exists(DhlTrackingService::class, 'trackMultipleShipments'))->toBeFalse();
|
||||||
|
});
|
||||||
220
tests/Unit/Dhl/DhlTrackingRateLimitTest.php
Normal file
220
tests/Unit/Dhl/DhlTrackingRateLimitTest.php
Normal file
|
|
@ -0,0 +1,220 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
use Acme\Dhl\Models\DhlShipment;
|
||||||
|
use App\Http\Controllers\SettingController;
|
||||||
|
use App\Services\DhlTrackingService;
|
||||||
|
use Illuminate\Support\Collection;
|
||||||
|
use Illuminate\Support\Facades\Http;
|
||||||
|
|
||||||
|
beforeEach(function () {
|
||||||
|
$reflection = new ReflectionClass(SettingController::class);
|
||||||
|
$property = $reflection->getProperty('cachedDhlConfig');
|
||||||
|
$property->setAccessible(true);
|
||||||
|
$property->setValue(null, [
|
||||||
|
'api_key' => 'cached-test-api-key-9999',
|
||||||
|
'use_queue' => false,
|
||||||
|
]);
|
||||||
|
|
||||||
|
// The 429 path activates a process-wide quota pause via the cache,
|
||||||
|
// which would otherwise leak between tests and short-circuit later
|
||||||
|
// assertions that expect a real HTTP request.
|
||||||
|
DhlTrackingService::clearQuotaPause();
|
||||||
|
|
||||||
|
// Batch tracking sleeps 5 seconds between calls in production; tests
|
||||||
|
// must not actually sleep, otherwise the suite would take minutes.
|
||||||
|
DhlTrackingService::setCallIntervalSeconds(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(function () {
|
||||||
|
SettingController::flushDhlConfigCache();
|
||||||
|
DhlTrackingService::clearQuotaPause();
|
||||||
|
Mockery::close();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns an explicit rate_limited flag with a German operator-facing message on HTTP 429', function () {
|
||||||
|
Http::fake([
|
||||||
|
'api-eu.dhl.com/track/shipments*' => Http::response(
|
||||||
|
['status' => 429, 'title' => 'Too Many Requests'],
|
||||||
|
429,
|
||||||
|
['Retry-After' => '120']
|
||||||
|
),
|
||||||
|
]);
|
||||||
|
|
||||||
|
$service = new DhlTrackingService;
|
||||||
|
$result = $service->trackShipment('00340434292135100148');
|
||||||
|
|
||||||
|
expect($result)
|
||||||
|
->toHaveKey('success', false)
|
||||||
|
->toHaveKey('rate_limited', true)
|
||||||
|
->toHaveKey('http_status', 429)
|
||||||
|
->toHaveKey('retry_after', 120)
|
||||||
|
->toHaveKey('api_used', 'unified');
|
||||||
|
|
||||||
|
// The message must reflect the actual DHL standard limits documented at
|
||||||
|
// https://developer.dhl.com/api-reference/shipment-tracking#rate-limits
|
||||||
|
// and must NOT accuse the operator of using a sandbox key - that was an
|
||||||
|
// incorrect Phase 12 assumption.
|
||||||
|
expect($result['message'])
|
||||||
|
->toContain('Tageslimit erreicht')
|
||||||
|
->toContain('HTTP 429')
|
||||||
|
->toContain('250 Aufrufe pro Tag')
|
||||||
|
->toContain('Quota-Erhoehung')
|
||||||
|
->toContain('2 Minute')
|
||||||
|
->not->toContain('Sandbox-Demo-Key');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('aborts the batch immediately on HTTP 429 and does not update last_tracked_at', function () {
|
||||||
|
Http::fake([
|
||||||
|
'api-eu.dhl.com/track/shipments*' => Http::response(['title' => 'Too Many Requests'], 429),
|
||||||
|
]);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @var Collection<int, DhlShipment&\Mockery\MockInterface>
|
||||||
|
*/
|
||||||
|
$shipments = collect(range(1, 12))->map(function (int $i) {
|
||||||
|
$shipment = Mockery::mock(DhlShipment::class)->makePartial();
|
||||||
|
$shipment->id = $i;
|
||||||
|
$shipment->dhl_shipment_no = '003404342921351001'.str_pad((string) $i, 2, '0', STR_PAD_LEFT);
|
||||||
|
$shipment->status = 'in_transit';
|
||||||
|
$shipment->shouldNotReceive('update');
|
||||||
|
|
||||||
|
return $shipment;
|
||||||
|
});
|
||||||
|
|
||||||
|
$service = new DhlTrackingService;
|
||||||
|
$stats = $service->updateTrackingBatch($shipments);
|
||||||
|
|
||||||
|
// Phase 13: one HTTP call per shipment, abort on first 429. So only
|
||||||
|
// the first shipment is marked failed - the other 11 are skipped
|
||||||
|
// entirely without burning more quota.
|
||||||
|
expect($stats['updated'])->toBe(0);
|
||||||
|
expect($stats['failed'])->toBe(1);
|
||||||
|
expect($stats['results'])->toHaveCount(1);
|
||||||
|
expect($stats['results'][0])
|
||||||
|
->toHaveKey('rate_limited', true)
|
||||||
|
->toHaveKey('success', false);
|
||||||
|
|
||||||
|
Http::assertSentCount(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('parses an HTTP-date Retry-After header into a positive integer', function () {
|
||||||
|
$retryDate = gmdate('D, d M Y H:i:s', time() + 300).' GMT';
|
||||||
|
|
||||||
|
Http::fake([
|
||||||
|
'api-eu.dhl.com/track/shipments*' => Http::response([], 429, ['Retry-After' => $retryDate]),
|
||||||
|
]);
|
||||||
|
|
||||||
|
$service = new DhlTrackingService;
|
||||||
|
$result = $service->trackShipment('00340434292135100148');
|
||||||
|
|
||||||
|
expect($result['retry_after'])
|
||||||
|
->toBeInt()
|
||||||
|
->toBeGreaterThan(250)
|
||||||
|
->toBeLessThanOrEqual(310);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('activates a quota pause from the Retry-After header and short-circuits subsequent calls', function () {
|
||||||
|
Http::fake([
|
||||||
|
'api-eu.dhl.com/track/shipments*' => Http::response([], 429, ['Retry-After' => '600']),
|
||||||
|
]);
|
||||||
|
|
||||||
|
$service = new DhlTrackingService;
|
||||||
|
$first = $service->trackShipment('00340434292135100100');
|
||||||
|
|
||||||
|
expect($first['rate_limited'])->toBeTrue();
|
||||||
|
expect(DhlTrackingService::isQuotaPaused())->toBeTrue();
|
||||||
|
expect(DhlTrackingService::getQuotaPausedUntil())
|
||||||
|
->not->toBeNull()
|
||||||
|
->and(DhlTrackingService::getQuotaPausedUntil()->isFuture())->toBeTrue();
|
||||||
|
|
||||||
|
// Second call - must NOT hit DHL at all, must reuse the cached pause.
|
||||||
|
$second = $service->trackShipment('00340434292135100101');
|
||||||
|
|
||||||
|
expect($second)
|
||||||
|
->toHaveKey('rate_limited', true)
|
||||||
|
->toHaveKey('http_status', 429)
|
||||||
|
->toHaveKey('paused_until');
|
||||||
|
expect($second['message'])->toContain('Lokale Quota-Pause aktiv bis');
|
||||||
|
|
||||||
|
Http::assertSentCount(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('uses the default pause window when DHL does not send a Retry-After header', function () {
|
||||||
|
Http::fake([
|
||||||
|
'api-eu.dhl.com/track/shipments*' => Http::response(['title' => 'Too Many Requests'], 429),
|
||||||
|
]);
|
||||||
|
|
||||||
|
$service = new DhlTrackingService;
|
||||||
|
$service->trackShipment('00340434292135100100');
|
||||||
|
|
||||||
|
$until = DhlTrackingService::getQuotaPausedUntil();
|
||||||
|
|
||||||
|
expect($until)->not->toBeNull();
|
||||||
|
// Default is one hour - allow a small jitter for clock drift inside the test run.
|
||||||
|
$secondsAhead = $until->diffInSeconds(now(), false) * -1;
|
||||||
|
expect($secondsAhead)
|
||||||
|
->toBeGreaterThan(3500)
|
||||||
|
->toBeLessThanOrEqual(3700);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('skips the entire updateTrackingBatch when a quota pause is already cached', function () {
|
||||||
|
DhlTrackingService::pauseQuota(900);
|
||||||
|
|
||||||
|
Http::fake([
|
||||||
|
'api-eu.dhl.com/*' => Http::response(['title' => 'Should never be called'], 500),
|
||||||
|
]);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @var Collection<int, DhlShipment&\Mockery\MockInterface>
|
||||||
|
*/
|
||||||
|
$shipments = collect(range(1, 25))->map(function (int $i) {
|
||||||
|
$shipment = Mockery::mock(DhlShipment::class)->makePartial();
|
||||||
|
$shipment->id = $i;
|
||||||
|
$shipment->dhl_shipment_no = '003404342921351002'.str_pad((string) $i, 2, '0', STR_PAD_LEFT);
|
||||||
|
$shipment->status = 'in_transit';
|
||||||
|
$shipment->shouldNotReceive('update');
|
||||||
|
|
||||||
|
return $shipment;
|
||||||
|
});
|
||||||
|
|
||||||
|
$service = new DhlTrackingService;
|
||||||
|
$stats = $service->updateTrackingBatch($shipments);
|
||||||
|
|
||||||
|
expect($stats['updated'])->toBe(0);
|
||||||
|
expect($stats['failed'])->toBe(25);
|
||||||
|
expect($stats['results'])->toHaveCount(25);
|
||||||
|
foreach ($stats['results'] as $row) {
|
||||||
|
expect($row['rate_limited'])->toBeTrue();
|
||||||
|
expect($row['paused_until'])->toBeString();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Crucially: NO HTTP call must have been sent.
|
||||||
|
Http::assertNothingSent();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('clears the quota pause via clearQuotaPause', function () {
|
||||||
|
DhlTrackingService::pauseQuota(600);
|
||||||
|
expect(DhlTrackingService::isQuotaPaused())->toBeTrue();
|
||||||
|
|
||||||
|
DhlTrackingService::clearQuotaPause();
|
||||||
|
expect(DhlTrackingService::isQuotaPaused())->toBeFalse();
|
||||||
|
expect(DhlTrackingService::getQuotaPausedUntil())->toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('redacts the api key in the rate-limit log payload', function () {
|
||||||
|
Http::fake([
|
||||||
|
'api-eu.dhl.com/track/shipments*' => Http::response([], 429),
|
||||||
|
]);
|
||||||
|
|
||||||
|
\Illuminate\Support\Facades\Log::spy();
|
||||||
|
|
||||||
|
$service = new DhlTrackingService;
|
||||||
|
$service->trackShipment('00340434292135100148');
|
||||||
|
|
||||||
|
\Illuminate\Support\Facades\Log::shouldHaveReceived('error')
|
||||||
|
->withArgs(function (string $message, array $context) {
|
||||||
|
return str_contains($message, 'rate-limited')
|
||||||
|
&& ($context['api_key_suffix'] ?? null) === '***9999'
|
||||||
|
&& ! str_contains(json_encode($context), 'cached-test-api-key');
|
||||||
|
});
|
||||||
|
});
|
||||||
119
tests/Unit/Dhl/DhlTrackingStaleProtectionTest.php
Normal file
119
tests/Unit/Dhl/DhlTrackingStaleProtectionTest.php
Normal file
|
|
@ -0,0 +1,119 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
use Acme\Dhl\Models\DhlShipment;
|
||||||
|
use App\Http\Controllers\SettingController;
|
||||||
|
use App\Services\DhlTrackingService;
|
||||||
|
use Illuminate\Support\Collection;
|
||||||
|
use Illuminate\Support\Facades\Http;
|
||||||
|
|
||||||
|
beforeEach(function () {
|
||||||
|
$reflection = new ReflectionClass(SettingController::class);
|
||||||
|
$property = $reflection->getProperty('cachedDhlConfig');
|
||||||
|
$property->setAccessible(true);
|
||||||
|
$property->setValue(null, [
|
||||||
|
'api_key' => 'cached-test-api-key-1234',
|
||||||
|
'use_queue' => false,
|
||||||
|
]);
|
||||||
|
|
||||||
|
DhlTrackingService::clearQuotaPause();
|
||||||
|
DhlTrackingService::setCallIntervalSeconds(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(function () {
|
||||||
|
SettingController::flushDhlConfigCache();
|
||||||
|
DhlTrackingService::clearQuotaPause();
|
||||||
|
DhlTrackingService::setCallIntervalSeconds(5);
|
||||||
|
Mockery::close();
|
||||||
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return DhlShipment&\Mockery\MockInterface
|
||||||
|
*/
|
||||||
|
function makeFakeShipment(int $id, string $trackingNumber, ?Carbon\Carbon $lastTrackedAt = null)
|
||||||
|
{
|
||||||
|
/** @var DhlShipment&\Mockery\MockInterface $shipment */
|
||||||
|
$shipment = Mockery::mock(DhlShipment::class)->makePartial();
|
||||||
|
$shipment->id = $id;
|
||||||
|
$shipment->dhl_shipment_no = $trackingNumber;
|
||||||
|
$shipment->last_tracked_at = $lastTrackedAt;
|
||||||
|
|
||||||
|
return $shipment;
|
||||||
|
}
|
||||||
|
|
||||||
|
it('does not update last_tracked_at when the Unified API returns 401', function () {
|
||||||
|
Http::fake([
|
||||||
|
'api-eu.dhl.com/track/shipments*' => Http::response([], 401),
|
||||||
|
]);
|
||||||
|
|
||||||
|
$shipment = makeFakeShipment(123, '00340434292135100148', now()->subDays(2));
|
||||||
|
|
||||||
|
// The sync path must never call ->update() on the shipment when an auth
|
||||||
|
// error occurs. Asserting "never called" is the strongest contract we
|
||||||
|
// can express here without a database.
|
||||||
|
$shipment->shouldNotReceive('update');
|
||||||
|
|
||||||
|
$service = new DhlTrackingService;
|
||||||
|
$result = $service->updateTracking($shipment, ['auto_retrack' => false]);
|
||||||
|
|
||||||
|
expect($result)
|
||||||
|
->toHaveKey('success', false)
|
||||||
|
->toHaveKey('auth_error', true)
|
||||||
|
->toHaveKey('http_status', 401);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('does update last_tracked_at when DHL says the shipment is not found', function () {
|
||||||
|
Http::fake([
|
||||||
|
'api-eu.dhl.com/track/shipments*' => Http::response(['shipments' => []], 200),
|
||||||
|
]);
|
||||||
|
|
||||||
|
$shipment = makeFakeShipment(123, '00340434292135100148');
|
||||||
|
|
||||||
|
$shipment->shouldReceive('update')
|
||||||
|
->once()
|
||||||
|
->withArgs(function (array $payload) {
|
||||||
|
return array_keys($payload) === ['last_tracked_at']
|
||||||
|
&& ! array_key_exists('tracking_status', $payload)
|
||||||
|
&& ! array_key_exists('status', $payload);
|
||||||
|
})
|
||||||
|
->andReturnTrue();
|
||||||
|
|
||||||
|
$service = new DhlTrackingService;
|
||||||
|
$result = $service->updateTracking($shipment, ['auto_retrack' => false]);
|
||||||
|
|
||||||
|
expect($result)
|
||||||
|
->toHaveKey('success', false)
|
||||||
|
->toHaveKey('auth_error', false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('stops the batch tracker on the first auth error to avoid burning the API quota', function () {
|
||||||
|
Http::fake([
|
||||||
|
'api-eu.dhl.com/track/shipments*' => Http::response([], 401),
|
||||||
|
]);
|
||||||
|
|
||||||
|
$shipments = new Collection([
|
||||||
|
makeFakeShipment(1, 'A1'),
|
||||||
|
makeFakeShipment(2, 'B2'),
|
||||||
|
makeFakeShipment(3, 'C3'),
|
||||||
|
]);
|
||||||
|
|
||||||
|
foreach ($shipments as $shipment) {
|
||||||
|
$shipment->shouldNotReceive('update');
|
||||||
|
}
|
||||||
|
|
||||||
|
$service = new DhlTrackingService;
|
||||||
|
$stats = $service->updateTrackingBatch($shipments);
|
||||||
|
|
||||||
|
// Since Phase 13 the batch tracker fires one HTTP call per shipment and
|
||||||
|
// aborts on the *first* auth error. So exactly one shipment is marked
|
||||||
|
// failed (the one we tried), the other two are skipped entirely.
|
||||||
|
expect($stats)
|
||||||
|
->toHaveKey('updated', 0)
|
||||||
|
->toHaveKey('failed', 1);
|
||||||
|
|
||||||
|
expect($stats['results'])->toHaveCount(1);
|
||||||
|
expect($stats['results'][0])
|
||||||
|
->toHaveKey('success', false)
|
||||||
|
->toHaveKey('auth_error', true);
|
||||||
|
|
||||||
|
Http::assertSentCount(1);
|
||||||
|
});
|
||||||
48
tests/Unit/Dhl/ReturnsServiceCountryCodeTest.php
Normal file
48
tests/Unit/Dhl/ReturnsServiceCountryCodeTest.php
Normal file
|
|
@ -0,0 +1,48 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
use Acme\Dhl\Services\ReturnsService;
|
||||||
|
use Acme\Dhl\Support\DhlClient;
|
||||||
|
|
||||||
|
function invokeReturnsServiceConvert(string $method, $argument)
|
||||||
|
{
|
||||||
|
$service = new ReturnsService(new DhlClient('https://example.test', null, null, null));
|
||||||
|
|
||||||
|
$reflection = (new ReflectionClass(ReturnsService::class))->getMethod($method);
|
||||||
|
$reflection->setAccessible(true);
|
||||||
|
|
||||||
|
return $reflection->invoke($service, $argument);
|
||||||
|
}
|
||||||
|
|
||||||
|
it('converts known ISO-2 country codes to DHL ISO-3 via the resolver', function (string $input, string $expected) {
|
||||||
|
expect(invokeReturnsServiceConvert('convertCountryCode', $input))->toBe($expected);
|
||||||
|
})->with([
|
||||||
|
'germany' => ['DE', 'DEU'],
|
||||||
|
'austria' => ['AT', 'AUT'],
|
||||||
|
'switzerland' => ['CH', 'CHE'],
|
||||||
|
'spain' => ['ES', 'ESP'],
|
||||||
|
'germany lowercase' => ['de', 'DEU'],
|
||||||
|
'germany already iso-3' => ['DEU', 'DEU'],
|
||||||
|
]);
|
||||||
|
|
||||||
|
it('throws on unsupported country codes instead of silently using DEU', function () {
|
||||||
|
invokeReturnsServiceConvert('convertCountryCode', 'XX');
|
||||||
|
})->throws(InvalidArgumentException::class);
|
||||||
|
|
||||||
|
it('normalizes addresses back to ISO-2 via the resolver', function () {
|
||||||
|
$converted = invokeReturnsServiceConvert('convertAddressFor2LetterCountry', [
|
||||||
|
'name' => 'Test',
|
||||||
|
'country' => 'AUT',
|
||||||
|
]);
|
||||||
|
|
||||||
|
expect($converted['country'])->toBe('AT');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('throws when normalizing an address with an unsupported country', function () {
|
||||||
|
invokeReturnsServiceConvert('convertAddressFor2LetterCountry', ['country' => 'ZZ']);
|
||||||
|
})->throws(InvalidArgumentException::class);
|
||||||
|
|
||||||
|
it('keeps the address unchanged when the country key is missing', function () {
|
||||||
|
$converted = invokeReturnsServiceConvert('convertAddressFor2LetterCountry', ['name' => 'Test']);
|
||||||
|
|
||||||
|
expect($converted)->toBe(['name' => 'Test']);
|
||||||
|
});
|
||||||
50
tests/Unit/Dhl/ShippingServiceParseAddressTest.php
Normal file
50
tests/Unit/Dhl/ShippingServiceParseAddressTest.php
Normal file
|
|
@ -0,0 +1,50 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
use Acme\Dhl\Services\ShippingService;
|
||||||
|
use Acme\Dhl\Support\DhlClient;
|
||||||
|
|
||||||
|
function invokeParseAddressFields(array $address): array
|
||||||
|
{
|
||||||
|
$service = new ShippingService(new DhlClient('https://example.test', null, null, null));
|
||||||
|
|
||||||
|
$reflection = (new ReflectionClass(ShippingService::class))->getMethod('parseAddressFields');
|
||||||
|
$reflection->setAccessible(true);
|
||||||
|
|
||||||
|
return $reflection->invoke($service, $address);
|
||||||
|
}
|
||||||
|
|
||||||
|
it('keeps an explicit house number unchanged', function () {
|
||||||
|
$address = invokeParseAddressFields([
|
||||||
|
'street' => 'Musterstrasse',
|
||||||
|
'houseNumber' => '42a',
|
||||||
|
]);
|
||||||
|
|
||||||
|
expect($address['street'])->toBe('Musterstrasse')
|
||||||
|
->and($address['houseNumber'])->toBe('42a');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('extracts the house number from a combined street field', function () {
|
||||||
|
$address = invokeParseAddressFields([
|
||||||
|
'street' => 'Musterstrasse 42a',
|
||||||
|
'houseNumber' => '',
|
||||||
|
]);
|
||||||
|
|
||||||
|
expect($address['street'])->toBe('Musterstrasse')
|
||||||
|
->and($address['houseNumber'])->toBe('42a');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('throws when the street contains no parseable house number instead of defaulting to 1', function () {
|
||||||
|
invokeParseAddressFields([
|
||||||
|
'street' => 'Postfach',
|
||||||
|
'houseNumber' => '',
|
||||||
|
]);
|
||||||
|
})->throws(InvalidArgumentException::class, 'Hausnummer fehlt');
|
||||||
|
|
||||||
|
it('does not throw when neither street nor house number are provided', function () {
|
||||||
|
$address = invokeParseAddressFields([
|
||||||
|
'street' => '',
|
||||||
|
'houseNumber' => '',
|
||||||
|
]);
|
||||||
|
|
||||||
|
expect($address)->toBe(['street' => '', 'houseNumber' => '']);
|
||||||
|
});
|
||||||
Loading…
Add table
Add a link
Reference in a new issue