From 245c2815413a95d4ef19ca9135fe16c23ff90aa9 Mon Sep 17 00:00:00 2001 From: Kevin Adametz Date: Fri, 8 May 2026 15:34:57 +0200 Subject: [PATCH 1/4] Steuerberater Modul tax --- app/Cron/UserMakeOrder.php | 29 ++ app/Http/Controllers/Admin/AboController.php | 11 + app/Http/Controllers/User/AboController.php | 15 +- app/Repositories/AboRepository.php | 53 ++- app/Repositories/CheckoutRepository.php | 1 + app/Services/AboRetryPaymentService.php | 238 ++++++++++++++ app/Services/DatevExportService.php | 94 ++++-- .../steuerberater-abgleich-status.csv | 17 + .../steuerberater-abgleich-status.html | 310 ++++++++++++++++++ .../steuerberater-abgleich-status.md | 58 ++++ dev/steuerberater/steuerberater.csv | 67 ++++ resources/lang/de/abo.php | 13 + resources/lang/en/abo.php | 13 + resources/lang/es/abo.php | 13 + resources/views/admin/abo/_detail.blade.php | 209 ++++++------ .../views/admin/abo/_executions.blade.php | 28 ++ resources/views/admin/abo/detail.blade.php | 57 +++- .../admin/abo/modal_abo_update.blade.php | 28 +- routes/domains/crm.php | 1 + tests/Feature/AboRepositoryDateChangeTest.php | 108 +++++- tests/Feature/AdminAboRetryPaymentTest.php | 207 ++++++++++++ .../Unit/Services/DatevExportServiceTest.php | 58 ++++ 22 files changed, 1489 insertions(+), 139 deletions(-) create mode 100644 app/Services/AboRetryPaymentService.php create mode 100644 dev/steuerberater/steuerberater-abgleich-status.csv create mode 100644 dev/steuerberater/steuerberater-abgleich-status.html create mode 100644 dev/steuerberater/steuerberater-abgleich-status.md create mode 100644 dev/steuerberater/steuerberater.csv create mode 100644 tests/Feature/AdminAboRetryPaymentTest.php diff --git a/app/Cron/UserMakeOrder.php b/app/Cron/UserMakeOrder.php index 819a9f5..6574d52 100644 --- a/app/Cron/UserMakeOrder.php +++ b/app/Cron/UserMakeOrder.php @@ -3,6 +3,7 @@ namespace App\Cron; use App\Http\Controllers\Pay\PayoneController; +use App\Models\PaymentTransaction; use App\Models\ShoppingOrder; use App\Models\ShoppingOrderItem; use App\Models\UserAbo; @@ -74,6 +75,7 @@ class UserMakeOrder $this->pay->setAboPayment($this->userAbo, $amount, 'EUR'); $this->pay->setPersonalData(); $response = $this->pay->onlyPaymentResponse(); + $this->recordPaymentTransaction($response); \Log::info('Response: '.json_encode($response)); // $response = $this->pay->ResponseData(true); @@ -86,6 +88,33 @@ class UserMakeOrder } } + private function recordPaymentTransaction(mixed $response): void + { + $shoppingPayment = $this->getShoppingPayment(); + if (! $shoppingPayment) { + return; + } + + $responseData = is_object($response) ? (array) $response : $response; + if (! is_array($responseData) || ! isset($responseData['status'])) { + return; + } + + PaymentTransaction::create([ + 'shopping_payment_id' => $shoppingPayment->id, + 'request' => 'authorization', + 'txid' => $responseData['txid'] ?? 0, + 'userid' => $responseData['userid'] ?? $this->userAbo->payone_userid ?? 0, + 'status' => $responseData['status'], + 'txaction' => $responseData['txaction'] ?? null, + 'transmitted_data' => $responseData, + 'errorcode' => $responseData['errorcode'] ?? null, + 'errormessage' => $responseData['errormessage'] ?? null, + 'customermessage' => $responseData['customermessage'] ?? null, + 'mode' => $shoppingPayment->mode, + ]); + } + public function getShoppingPayment() { Log::info('Rufe Zahlungsinformationen ab für UserAbo ID: '.$this->userAbo->id); diff --git a/app/Http/Controllers/Admin/AboController.php b/app/Http/Controllers/Admin/AboController.php index 08dead6..a8714ba 100644 --- a/app/Http/Controllers/Admin/AboController.php +++ b/app/Http/Controllers/Admin/AboController.php @@ -7,6 +7,7 @@ use App\Models\UserAbo; use App\Repositories\AboRepository; use App\Services\AboItemHistoryService; use App\Services\AboOrderCart; +use App\Services\AboRetryPaymentService; use App\Services\Shop; use Request; @@ -102,6 +103,16 @@ class AboController extends Controller return redirect(route('admin_abos_detail', [$id])); } + public function retryPayment($id, AboRetryPaymentService $retryPaymentService) + { + $user_abo = UserAbo::findOrFail($id); + $result = $retryPaymentService->retry($user_abo); + + \Session()->flash($result['success'] ? 'alert-success' : 'alert-error', $result['message']); + + return redirect(route('admin_abos_detail', [$id])); + } + public function datatable() { diff --git a/app/Http/Controllers/User/AboController.php b/app/Http/Controllers/User/AboController.php index 4e45edd..2ef531b 100644 --- a/app/Http/Controllers/User/AboController.php +++ b/app/Http/Controllers/User/AboController.php @@ -107,7 +107,8 @@ class AboController extends Controller $data = Request::all(); $user_abo = UserAbo::findOrFail($id); $this->checkPermissions($view, $user_abo); - $isAddOnlyMode = AboHelper::isAddOnlyMode($user_abo, $view); + $editView = \Auth::user()?->isAdmin() ? 'admin' : $view; + $isAddOnlyMode = AboHelper::isAddOnlyMode($user_abo, $editView); if (isset($data['action'])) { if ($data['action'] === 'abo_update_settings') { @@ -127,7 +128,7 @@ class AboController extends Controller $qtyBefore = $UserAboItem->qty; $UserAboItem->qty = $UserAboItem->qty + 1; $UserAboItem->save(); - AboItemHistoryService::logProductAdded($user_abo, $UserAboItem, $qtyBefore, $view); + AboItemHistoryService::logProductAdded($user_abo, $UserAboItem, $qtyBefore, $editView); } else { $newItem = UserAboItem::create([ 'user_abo_id' => $user_abo->id, @@ -136,7 +137,7 @@ class AboController extends Controller 'qty' => 1, 'status' => 1, ]); - AboItemHistoryService::logProductAdded($user_abo, $newItem, 0, $view); + AboItemHistoryService::logProductAdded($user_abo, $newItem, 0, $editView); } } } @@ -156,7 +157,7 @@ class AboController extends Controller } $UserAboItem->qty = $qty; $UserAboItem->save(); - AboItemHistoryService::logQtyChanged($user_abo, $UserAboItem, $qtyBefore, $qty, $view); + AboItemHistoryService::logQtyChanged($user_abo, $UserAboItem, $qtyBefore, $qty, $editView); } } } @@ -181,7 +182,7 @@ class AboController extends Controller $message = __('abo.need_basis_product'); } if (! $message) { - AboItemHistoryService::logProductRemoved($user_abo, $userAboItem, $view); + AboItemHistoryService::logProductRemoved($user_abo, $userAboItem, $editView); $userAboItem->delete(); $user_abo->refresh(); // Abo neu laden um die aktualisierten Items zu erhalten } @@ -193,7 +194,7 @@ class AboController extends Controller $UserAboItem->product_id = $data['comp_product_id']; $UserAboItem->save(); $UserAboItem->load('product'); - AboItemHistoryService::logCompProductChanged($user_abo, $UserAboItem, $oldProduct, $UserAboItem->product, $view); + AboItemHistoryService::logCompProductChanged($user_abo, $UserAboItem, $oldProduct, $UserAboItem->product, $editView); } else { $newItem = UserAboItem::create([ 'user_abo_id' => $user_abo->id, @@ -202,7 +203,7 @@ class AboController extends Controller 'qty' => 1, 'status' => 1, ]); - AboItemHistoryService::logProductAdded($user_abo, $newItem, 0, $view); + AboItemHistoryService::logProductAdded($user_abo, $newItem, 0, $editView); } } diff --git a/app/Repositories/AboRepository.php b/app/Repositories/AboRepository.php index f716869..7a03d09 100644 --- a/app/Repositories/AboRepository.php +++ b/app/Repositories/AboRepository.php @@ -4,6 +4,7 @@ namespace App\Repositories; use App\Models\UserAbo; use App\Services\AboHelper; +use Carbon\Carbon; class AboRepository extends BaseRepository { @@ -27,8 +28,10 @@ class AboRepository extends BaseRepository if ($data['action'] === 'abo_update_settings') { if ($this->validate($data)) { $this->updateStatus($data); - $this->model->abo_interval = $data['abo_interval']; - $nextDate = $this->calculateNewNextDate($data['abo_interval']); + $this->model->abo_interval = (int) $data['abo_interval']; + $nextDate = $this->isAdminUpdate($data) + ? $this->calculateAdminNextDate($data) + : $this->calculateNewNextDate((int) $data['abo_interval']); $this->model->next_date = $nextDate; $this->model->save(); @@ -49,10 +52,12 @@ class AboRepository extends BaseRepository private function updateStatus($data) { + $isAdminUpdate = $this->isAdminUpdate($data); + // Handle cancellation if (isset($data['abo_cancel']) && $data['abo_cancel'] == 'true') { // Sperre: 3 Tage vor Ausführung kann nicht mehr pausiert/gekündigt werden - if ($this->model->next_date) { + if (! $isAdminUpdate && $this->model->next_date) { $daysUntil = (int) now()->diffInDays(\Carbon\Carbon::parse($this->model->next_date), false); if ($daysUntil >= 0 && $daysUntil < self::LOCK_DAYS_PAUSE_CANCEL) { \Session()->flash('alert-error', __('abo.error_cancel_locked', ['days' => $daysUntil])); @@ -72,7 +77,7 @@ class AboRepository extends BaseRepository $active = (isset($data['abo_is_active']) && $data['abo_is_active']) ? true : false; // Sperre: 3 Tage vor Ausführung kann nicht mehr pausiert werden - if ($this->model->active && ! $active && $this->model->next_date) { + if (! $isAdminUpdate && $this->model->active && ! $active && $this->model->next_date) { $daysUntil = (int) now()->diffInDays(\Carbon\Carbon::parse($this->model->next_date), false); if ($daysUntil >= 0 && $daysUntil < self::LOCK_DAYS_PAUSE_CANCEL) { \Session()->flash('alert-error', __('abo.error_pause_locked', ['days' => $daysUntil])); @@ -122,12 +127,28 @@ class AboRepository extends BaseRepository return false; } } - if (! in_array($data['abo_interval'], \App\Models\UserAbo::$aboDeliveryDays)) { + if (! isset($data['abo_interval']) || ! in_array((int) $data['abo_interval'], \App\Models\UserAbo::$aboDeliveryDays, true)) { \Session()->flash('alert-error', __('abo.error_abo_interval')); return false; } + if ($this->isAdminUpdate($data)) { + if (! isset($data['abo_next_month']) || ! in_array($data['abo_next_month'], $this->getAdminExecutionMonths(), true)) { + \Session()->flash('alert-error', __('abo.error_next_date')); + + return false; + } + + if ($this->calculateAdminNextDate($data)->startOfDay()->lt(now()->startOfDay())) { + \Session()->flash('alert-error', __('abo.error_next_date')); + + return false; + } + + return true; + } + // Sperre: 10 Tage vor nächster Ausführung keine Änderungen mehr (Pakete werden vorgepackt) if ($this->model->next_date) { $daysUntilExecution = (int) now()->diffInDays(\Carbon\Carbon::parse($this->model->next_date), false); @@ -170,4 +191,26 @@ class AboRepository extends BaseRepository return AboHelper::setNextDate($referenceDate, $aboInterval); } + + private function calculateAdminNextDate(array $data): Carbon + { + return Carbon::createFromFormat('Y-m-d', $data['abo_next_month'].'-01') + ->startOfMonth() + ->addDays(((int) $data['abo_interval']) - 1); + } + + /** + * @return array + */ + private function getAdminExecutionMonths(): array + { + return collect(range(0, 3)) + ->map(fn (int $offset): string => now()->copy()->startOfMonth()->addMonths($offset)->format('Y-m')) + ->all(); + } + + private function isAdminUpdate(array $data): bool + { + return ($data['view'] ?? null) === 'admin'; + } } diff --git a/app/Repositories/CheckoutRepository.php b/app/Repositories/CheckoutRepository.php index fa16c0e..9823d56 100644 --- a/app/Repositories/CheckoutRepository.php +++ b/app/Repositories/CheckoutRepository.php @@ -74,6 +74,7 @@ class CheckoutRepository extends BaseRepository 'subtotal_ws' => $ShoppingCollectOrder->price_total_net, 'tax' => $ShoppingCollectOrder->tax_total, 'tax_split' => $ShoppingCollectOrder->tax_split, + 'net_split' => $ShoppingCollectOrder->net_split, 'total_shipping' => Yard::instance($this->instance)->totalWithShipping(2, '.', ''), 'points' => round($ShoppingCollectOrder->points, 2), 'weight' => 0, diff --git a/app/Services/AboRetryPaymentService.php b/app/Services/AboRetryPaymentService.php new file mode 100644 index 0000000..1fbeb7e --- /dev/null +++ b/app/Services/AboRetryPaymentService.php @@ -0,0 +1,238 @@ +status !== 3) { + return [ + 'success' => false, + 'message' => __('abo.retry_only_hold'), + ]; + } + + if (! $userAbo->active) { + return [ + 'success' => false, + 'message' => __('abo.retry_only_active'), + ]; + } + + if ($this->alreadyPaidToday($userAbo)) { + return [ + 'success' => false, + 'message' => __('abo.retry_already_paid_today'), + ]; + } + + \Log::channel('abo_order')->info('AboRetryPaymentService: Starte erneuten Zahlungsversuch', [ + 'abo_id' => $userAbo->id, + 'email' => $userAbo->email, + 'payone_userid' => $userAbo->payone_userid, + ]); + + AboHelper::ensureUserAboItemsFromLatestOrder($userAbo); + + $shoppingOrder = null; + $paymentAttemptRecorded = false; + $userOrder = new UserMakeOrder($userAbo); + + try { + if (! $userOrder->createShoppingUser()) { + return [ + 'success' => false, + 'message' => __('abo.retry_error_shopping_user'), + ]; + } + + $shoppingOrder = $userOrder->makeShoppingOrder(); + if (! $shoppingOrder) { + return [ + 'success' => false, + 'message' => __('abo.retry_error_order'), + ]; + } + + $response = $this->normalizePaymentResponse($userOrder->makePayment()); + + if (($response['status'] ?? null) === 'APPROVED') { + $this->markAboSuccess($userAbo, $shoppingOrder); + $paymentAttemptRecorded = true; + + return [ + 'success' => true, + 'message' => __('abo.retry_success', ['order' => $shoppingOrder->id]), + 'order_id' => $shoppingOrder->id, + ]; + } + + $this->logPaymentError($userAbo, $shoppingOrder, $response); + $this->markAboError($userAbo, $shoppingOrder); + $paymentAttemptRecorded = true; + $this->sendPaymentErrorMail($userOrder, $shoppingOrder, $response); + + return [ + 'success' => false, + 'message' => __('abo.retry_failed', [ + 'error' => $this->formatPaymentError($response), + 'order' => $shoppingOrder->id, + ]), + 'order_id' => $shoppingOrder->id, + ]; + } catch (\Throwable $e) { + \Log::channel('abo_order')->error('AboRetryPaymentService: Exception beim erneuten Zahlungsversuch', [ + 'abo_id' => $userAbo->id, + 'order_id' => $shoppingOrder?->id, + 'error' => $e->getMessage(), + 'trace' => $e->getTraceAsString(), + ]); + + if ($shoppingOrder && ! $paymentAttemptRecorded) { + $this->markAboError($userAbo, $shoppingOrder); + } + + return [ + 'success' => false, + 'message' => __('abo.retry_exception', ['error' => $e->getMessage()]), + 'order_id' => $shoppingOrder?->id, + ]; + } + } + + private function alreadyPaidToday(UserAbo $userAbo): bool + { + return UserAboOrder::where('user_abo_id', $userAbo->id) + ->whereDate('created_at', now()->toDateString()) + ->where('paid', true) + ->exists(); + } + + /** + * @return array + */ + private function normalizePaymentResponse(mixed $response): array + { + if (is_object($response)) { + return (array) $response; + } + + return is_array($response) ? $response : []; + } + + private function markAboSuccess(UserAbo $userAbo, mixed $shoppingOrder): void + { + DB::transaction(function () use ($userAbo, $shoppingOrder): void { + $userAbo->update([ + 'status' => 2, + 'active' => true, + 'next_date' => AboHelper::setNextDate(now(), $userAbo->abo_interval), + 'last_date' => now(), + ]); + + UserAboOrder::create([ + 'user_abo_id' => $userAbo->id, + 'shopping_order_id' => $shoppingOrder->id, + 'status' => 1, + 'paid' => true, + ]); + }); + + try { + IncentiveTracker::trackAboActivated($shoppingOrder); + } catch (\Throwable $e) { + \Log::channel('abo_order')->error('AboRetryPaymentService: Incentive-Tracking nach erfolgreichem Retry fehlgeschlagen', [ + 'abo_id' => $userAbo->id, + 'order_id' => $shoppingOrder->id, + 'error' => $e->getMessage(), + ]); + } + } + + private function markAboError(UserAbo $userAbo, mixed $shoppingOrder): void + { + DB::transaction(function () use ($userAbo, $shoppingOrder): void { + $userAbo->update([ + 'status' => 3, + 'last_date' => now(), + ]); + + UserAboOrder::create([ + 'user_abo_id' => $userAbo->id, + 'shopping_order_id' => $shoppingOrder->id, + 'status' => 3, + 'paid' => false, + ]); + }); + } + + /** + * @param array $response + */ + private function logPaymentError(UserAbo $userAbo, mixed $shoppingOrder, array $response): void + { + \Log::channel('abo_order')->error('AboRetryPaymentService: Zahlungsfehler beim erneuten Versuch', [ + 'abo_id' => $userAbo->id, + 'order_id' => $shoppingOrder->id, + 'response' => $response, + ]); + + MyLog::writeLog( + 'userabo', + 'error', + 'Error:AboRetryPaymentService::retry / makePayment Error', + $response + ); + } + + /** + * @param array $response + */ + private function sendPaymentErrorMail(UserMakeOrder $userOrder, mixed $shoppingOrder, array $response): void + { + $shoppingPayment = $userOrder->getShoppingPayment(); + if (! $shoppingPayment) { + return; + } + + try { + Payment::paymentStatusSendMail($shoppingOrder, $shoppingPayment, [ + 'mode' => $shoppingPayment->mode, + 'txaction' => 'error', + 'send_link' => false, + 'payment_error' => $response, + ]); + } catch (\Throwable $e) { + \Log::channel('abo_order')->error('AboRetryPaymentService: Fehlermail nach Zahlungsfehler konnte nicht gesendet werden', [ + 'order_id' => $shoppingOrder->id, + 'payment_id' => $shoppingPayment->id, + 'error' => $e->getMessage(), + ]); + } + } + + /** + * @param array $response + */ + private function formatPaymentError(array $response): string + { + $errorCode = $response['errorcode'] ?? null; + $errorMessage = $response['errormessage'] ?? $response['customermessage'] ?? ($response['status'] ?? __('payment.unknown')); + + if ($errorCode) { + return '['.$errorCode.'] '.$errorMessage; + } + + return (string) $errorMessage; + } +} diff --git a/app/Services/DatevExportService.php b/app/Services/DatevExportService.php index f8e9590..c6db121 100644 --- a/app/Services/DatevExportService.php +++ b/app/Services/DatevExportService.php @@ -221,7 +221,7 @@ class DatevExportService $csvContent = $this->buildCsv($export); $filename = $this->generateFilename($month, $year); $storagePath = $export->getStoragePath(); - $fullPath = $storagePath . '/' . $filename; + $fullPath = $storagePath.'/'.$filename; Storage::disk(config('datev.storage_disk'))->makeDirectory($storagePath); Storage::disk(config('datev.storage_disk'))->put($fullPath, $csvContent); @@ -254,6 +254,7 @@ class DatevExportService 'shopping_order.country.country', 'shopping_order.shopping_user', 'shopping_order.auth_user.account', + 'shopping_order.shopping_collect_order', ]) ->where('month', $month) ->where('year', $year) @@ -289,13 +290,15 @@ class DatevExportService // Tax-Split vorhanden? -> Mehrere Zeilen pro Steuersatz if ($order->tax_split && is_array($order->tax_split) && count($order->tax_split) > 0) { + $netSplit = $this->resolveNetSplit($order); + foreach ($order->tax_split as $taxRate => $taxAmount) { $taxRate = intval($taxRate); - $taxAmountFloat = $this->parseNumber($taxAmount); + $taxAmountFloat = $this->parseNumber($taxAmount, 'ek_tax'); $netAmount = 0; - if ($order->net_split && isset($order->net_split[$taxRate])) { - $netAmount = $this->parseNumber($order->net_split[$taxRate]); + if ($netSplit && isset($netSplit[$taxRate])) { + $netAmount = $this->parseNumber($netSplit[$taxRate], 'ek_net'); } $grossAmount = round($netAmount + $taxAmountFloat, 2); @@ -389,7 +392,7 @@ class DatevExportService // Steuerstatus des Beraters ermitteln $taxStatus = $this->determineCommissionTaxStatus($account); - $buSchluessel = config('datev.commission_tax_keys.' . $taxStatus, 9); + $buSchluessel = config('datev.commission_tax_keys.'.$taxStatus, 9); // USt-ID für Reverse Charge $euUstid = null; @@ -487,6 +490,7 @@ class DatevExportService 'shopping_order.country.country', 'shopping_order.shopping_user', 'shopping_order.auth_user.account', + 'shopping_order.shopping_collect_order', ]) ->where('month', $month) ->where('year', $year) @@ -500,7 +504,7 @@ class DatevExportService } $gegenkonto = $this->determineCounterAccountForOrder($order); - $buchungstext = 'STORNO ' . $this->buildBuchungstext($order); + $buchungstext = 'STORNO '.$this->buildBuchungstext($order); $buchungstext = mb_substr($buchungstext, 0, 60); $belegdatum = $this->parseBelegdatum($invoice); @@ -512,13 +516,15 @@ class DatevExportService $hasValidUstid = ! empty($euUstid); if ($order->tax_split && is_array($order->tax_split) && count($order->tax_split) > 0) { + $netSplit = $this->resolveNetSplit($order); + foreach ($order->tax_split as $taxRate => $taxAmount) { $taxRate = intval($taxRate); - $taxAmountFloat = $this->parseNumber($taxAmount); + $taxAmountFloat = $this->parseNumber($taxAmount, 'ek_tax'); $netAmount = 0; - if ($order->net_split && isset($order->net_split[$taxRate])) { - $netAmount = $this->parseNumber($order->net_split[$taxRate]); + if ($netSplit && isset($netSplit[$taxRate])) { + $netAmount = $this->parseNumber($netSplit[$taxRate], 'ek_net'); } $grossAmount = round($netAmount + $taxAmountFloat, 2); @@ -598,18 +604,18 @@ class DatevExportService } // Zeile 1: Header - $output .= $this->buildHeaderLine($export) . $lineEnding; + $output .= $this->buildHeaderLine($export).$lineEnding; // Zeile 2: Spaltenüberschriften - $output .= implode($delimiter, self::COLUMN_HEADERS) . $lineEnding; + $output .= implode($delimiter, self::COLUMN_HEADERS).$lineEnding; // Zeile 3+: Datenzeilen $lines = $export->lines()->orderBy('line_number')->get(); foreach ($lines as $line) { if ($line->row_csv) { - $output .= $line->row_csv . $lineEnding; + $output .= $line->row_csv.$lineEnding; } else { - $output .= $this->renderCsvRow($line->toArray()) . $lineEnding; + $output .= $this->renderCsvRow($line->toArray()).$lineEnding; } } @@ -722,7 +728,7 @@ class DatevExportService 'user_id' => $line['user_id'] ?? null, 'belegfeld1' => $line['belegfeld1'] ?? null, ]; - $lineRef = ($meta['source_type'] ?? '?') . ' #' . ($meta['source_id'] ?? '?'); + $lineRef = ($meta['source_type'] ?? '?').' #'.($meta['source_id'] ?? '?'); if (empty($line['belegdatum'])) { $errors[] = $this->buildValidationEntry("{$lineRef}: Belegdatum fehlt.", $meta); @@ -790,7 +796,7 @@ class DatevExportService // Gruppierte Zusammenfassung nach Konto + BU $grouped = $allLines->groupBy(function ($line) { - return $line['konto'] . '-' . $line['bu_schluessel'] . '-' . $line['soll_haben']; + return $line['konto'].'-'.$line['bu_schluessel'].'-'.$line['soll_haben']; })->map(function ($group, $key) { $first = $group->first(); @@ -869,7 +875,7 @@ class DatevExportService // Zusammengesetzter Key aus clearingtype + wallettype $key = $payment->clearingtype; if ($payment->wallettype) { - $key .= '_' . $payment->wallettype; + $key .= '_'.$payment->wallettype; } $map = config('datev.counteraccount_map', []); @@ -984,7 +990,7 @@ class DatevExportService { if ($order->shopping_user) { $name = trim( - ($order->shopping_user->billing_lastname ?? '') . ' ' . + ($order->shopping_user->billing_lastname ?? '').' '. ($order->shopping_user->billing_firstname ?? '') ); @@ -1005,7 +1011,7 @@ class DatevExportService $parts = []; if ($account) { - $name = trim(($account->last_name ?? '') . ' ' . ($account->first_name ?? '')); + $name = trim(($account->last_name ?? '').' '.($account->first_name ?? '')); if (! empty($name)) { $parts[] = $name; } @@ -1013,7 +1019,7 @@ class DatevExportService $userId = $user?->id ?? $creditUserId; if ($userId) { - $parts[] = 'Nr.' . $userId; + $parts[] = 'Nr.'.$userId; } $text = implode('; ', $parts); @@ -1038,20 +1044,60 @@ class DatevExportService return Carbon::create($invoice->year, $invoice->month, 1)->format('Y-m-d'); } + /** + * Ermittelt den Netto-Split. Historische Sammelrechnungen haben ihn teils nur + * an der ShoppingCollectOrder, nicht an der erzeugten ShoppingOrder. + */ + private function resolveNetSplit($order): ?array + { + if ($order->net_split && is_array($order->net_split)) { + return $order->net_split; + } + + $collectOrderNetSplit = $order->shopping_collect_order?->net_split; + + if ($collectOrderNetSplit && is_array($collectOrderNetSplit)) { + return $collectOrderNetSplit; + } + + return null; + } + /** * Parst einen formatierten Zahlenwert (z.B. "5.00" oder "5,00") zu float. * Behandelt sowohl einfache Werte als auch tax_split Arrays (homeparty). */ - private function parseNumber($value): float + private function parseNumber($value, ?string $preferredArrayKey = null): float { if (is_array($value)) { - // Homeparty tax_split Format: ['vk_tax' => '5.00', 'ek_tax' => '2.00'] - return floatval(str_replace(',', '.', $value['vk_tax'] ?? 0)); + $arrayKeys = $this->getSplitArrayKeys($preferredArrayKey); + + foreach ($arrayKeys as $arrayKey) { + if (array_key_exists($arrayKey, $value)) { + return $this->parseNumber($value[$arrayKey]); + } + } + + return 0.0; } return floatval(str_replace(',', '.', $value)); } + /** + * @return array + */ + private function getSplitArrayKeys(?string $preferredArrayKey): array + { + return match ($preferredArrayKey) { + 'ek_tax' => ['ek_tax', 'vk_tax', 'ek_net', 'vk_net'], + 'ek_net' => ['ek_net', 'vk_net', 'ek_tax', 'vk_tax'], + 'vk_tax' => ['vk_tax', 'ek_tax', 'vk_net', 'ek_net'], + 'vk_net' => ['vk_net', 'ek_net', 'vk_tax', 'ek_tax'], + default => ['vk_tax', 'vk_net', 'ek_tax', 'ek_net'], + }; + } + /** * Escaped ein CSV-Feld (Semikolon, Anführungszeichen, Newlines). */ @@ -1061,7 +1107,7 @@ class DatevExportService $value = str_replace('"', '""', $value); if (str_contains($value, ';') || str_contains($value, '"') || str_contains($value, "\n")) { - return '"' . $value . '"'; + return '"'.$value.'"'; } return $value; @@ -1074,6 +1120,6 @@ class DatevExportService { $monthPad = str_pad($month, 2, '0', STR_PAD_LEFT); - return "EXTF_Buchungsstapel_{$year}_{$monthPad}_" . date('YmdHis') . '.csv'; + return "EXTF_Buchungsstapel_{$year}_{$monthPad}_".date('YmdHis').'.csv'; } } diff --git a/dev/steuerberater/steuerberater-abgleich-status.csv b/dev/steuerberater/steuerberater-abgleich-status.csv new file mode 100644 index 0000000..f734ec0 --- /dev/null +++ b/dev/steuerberater/steuerberater-abgleich-status.csv @@ -0,0 +1,17 @@ +Bereich;Thema;Referenz;Anzahl;Befund;Einordnung;Status +Umsetzung;Sammelrechnungen/API;August 2025;14;"Export enthielt nur Steueranteil statt Netto+Steuer, weil net_split auf ShoppingOrder fehlte";"Technische Ursache identifiziert und behoben: net_split wird gespeichert, historischer Fallback auf ShoppingCollectOrder.net_split";Umgesetzt +Umsetzung;Homeparty;August 2025;3;"Export las falsche Split-Werte (vk_*) und wich stark vom Rechnungstotal ab";"Technische Ursache identifiziert und behoben: Homeparty liest ek_tax/ek_net";Umgesetzt +Abgleichstatus;Rueckmeldezeilen;steuerberater.csv;66;"Gesamtzahl Rueckmeldezeilen";"Basis fuer Abgleich DATEV vs. Steuerberaterliste";Info +Abgleichstatus;Fehlend im August-Export;steuerberater.csv vs. August-Export;30;"Belegnummer nicht in August-Export enthalten";"Davon 27 in DB als September 2025, 3 unter Rechnungsnummer nicht in DB gefunden";Teilweise geklaert +Abgleichstatus;Export = Betrag Test + 19%;steuerberater.csv vs. August-Export;24;"Muster: Betrag Test + 19% USt";"Hinweis auf Netto/Brutto-Missverstaendnis plus vorherige technische Split-Fehler";Geklaert +Abgleichstatus;#NV in Test aber Export vorhanden;steuerberater.csv vs. August-Export;5;"Rueckmeldung und Export widersprechen sich";"DB-basiert vorhanden, Bewertung nach Periode/Status statt #NV";Teilweise geklaert +Kategorie;Testdaten fehlerhaft: Betrag;steuerberater.csv;44;"Mix aus echten Technikfehlern und Perioden-/Datenbasisabweichungen";"Sammelrechnung/Homeparty behoben, Rest ueber Periode und Belegfluss klaeren";In Arbeit +Kategorie;Testdaten unvollstaendig;steuerberater.csv;10;"6 im August fehlend, 4 trotz #NV vorhanden";"Mehrere Faelle sind September oder abweichender Datenstand";In Arbeit +Kategorie;Testdaten fehlerhaft: Logik;steuerberater.csv;8;"3 August-Faelle 8400, 5 nicht im August";"Kein pauschaler Fehler: 8400 ohne verifizierte USt-ID plausibel, 8125 bei verifizierter USt-ID";Teilweise geklaert +Kategorie;Testdaten fehlerhaft: Storno;steuerberater.csv;3;"Vom Steuerberater als Storno markiert";"Fachlich fuer August kein DATEV-Storno, da damals keine Stornorechnungserstellung im System";Fachlich geklaert +Fachlich;USt-ID Verifikation pro Bestellung;Folgeticket;1;"Derzeit Validierung beim Eintragen, nicht bei jeder Bestellung";"Soll umgesetzt werden: pro Bestellung pruefen, bei ungueltig Hinweis + USt-Berechnung";Offen +Fachlich;Doppelzahlungen/Payone;Folgeticket;1;"DATEV-Export belegt keine echte Doppelabbuchung";"Pruefung ueber payment_transactions (txid/reference) + PAYONE Portal/API erforderlich";Offen +Logikfall;8125 korrekt;202537251;1;"ES-USt-ID vorhanden, reverse_charge=1, Export auf 8125/BU 1";"Entspricht Rechnungs-/Systemlogik zum Zeitpunkt";Geklaert +Logikfall;8400 plausibel;202536848|202537290|202537832;3;"AT-Faelle ohne verifizierte USt-ID, taxable_sales=2";"8400 aus Datenlage plausibel; fuer 8125 fehlt USt-ID-Grundlage";Geklaert +Periodenbefund;September statt August;"202538154|202538182|202538270|202538271|202538332|202538398|202538429|202538431|202538445|202538446|202538661|202538728|202538744|202538759|202538774|202538809|202538926|202539010|202539011|202539031|202539062|202539094|202539162|202539168|202539302|202539399|202539419";27;"In DB Periode 09/2025";"Kein August-Exportfehler";Geklaert +DB-Befund;Nicht unter Rechnungsnummer gefunden;202506145|202506147|202538333;3;"Nicht in DB auffindbar (unter genannter full_number)";"Separat klaeren: Datenstand, anderes Format oder extern erzeugte Rechnung";Offen diff --git a/dev/steuerberater/steuerberater-abgleich-status.html b/dev/steuerberater/steuerberater-abgleich-status.html new file mode 100644 index 0000000..5760e16 --- /dev/null +++ b/dev/steuerberater/steuerberater-abgleich-status.html @@ -0,0 +1,310 @@ + + + + + + Steuerberater-Abgleich DATEV - Status + + + +
+
+

Steuerberater-Abgleich DATEV (Stand nach Korrekturen)

+
+ Basis: steuerberater.csv vs. August-Export DATEV plus DB-Abgleich + (user_invoices, shopping_orders, datev_export_lines). +
+
+
+
66
+
Rueckmeldezeilen Steuerberater
+
+
+
30
+
Nicht im August-Export enthalten
+
+
+
27
+
Davon DB-Periode September 2025
+
+
+
3
+
Unter Rechnungsnummer nicht in DB
+
+
+
+ Auffaelliges Muster + 24 Rueckmeldefaelle entsprechen dem Schema Betrag Test + 19%. Neben Netto/Brutto-Missverstaendnis gab es + echte technische Split-Fehler bei Sammelrechnungen und Homeparty. +
+
+ Nach Umsetzung + Sammelrechnungen nutzen jetzt Netto+Steuer korrekt, Homeparty liest ek_tax/ek_net. + Kritische Beispielbelege laufen auf die Rechnungstotals. +
+
+ +
+

Umsetzung und offene Themen

+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
ThemaIst-Zustand / UrsacheAenderung / EntscheidungStatus
Sammelrechnungen/APIExport enthielt nur Steueranteil, weil net_split auf ShoppingOrder fehlte.net_split wird gespeichert; historischer Fallback auf ShoppingCollectOrder.net_split.Umgesetzt
HomepartyExport las vk_*; Rechnungstotal basiert auf ek_*.DATEV-Service nutzt fuer Homeparty nun ek_tax/ek_net.Umgesetzt
USt-ID pro BestellungValidierung erfolgt aktuell beim Eintragen, nicht bei jeder Bestellung.Folgeticket: pro Bestellung pruefen; bei ungueltig Warnung + USt-Berechnung.Offen
Storno in AugustVom Steuerberater als Storno markiert, aber keine Stornorechnungslogik aktiv gewesen.Fuer August kein DATEV-Storno ableiten, solange keine echte Stornorechnung existiert.Fachlich geklaert
Doppelzahlung / PayoneDATEV-Export belegt keine echte Doppelabbuchung.Abgleich ueber payment_transactions plus PAYONE Portal/API erforderlich.Offen
+
+ +
+

Logik-Hinweise 8125 vs. 8400

+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
RechnungExportkontoSystembefundEinordnung
202536848 / 202537290 / 2025378328400AT-Faelle ohne verifizierte USt-ID, taxable_sales=28400 ist aus Systemdaten plausibel; fuer 8125 fehlt USt-ID-Grundlage.
2025372518125 / BU 1ES-USt-ID vorhanden, reverse_charge=1Export folgt Rechnungsstand und ist fachlich konsistent.
202538445 / 202538446 / 202538774 / 202539011 / 202539399Nicht in August-DateiDB-Periode September 2025Kein August-Exportfehler; separat gegen September-Export pruefen.
+
+ +
+

Fehlende Belege im August-Export

+ + + + + + + + + + + + + + + + + + + + +
RechnungDB-BefundEinordnung
202506145 / 202506147 / 202538333Nicht in DB gefundenSeparat klaeren: Datenstand, Rechnungsformat oder extern erzeugte Rechnung.
+ 202538154, 202538182, 202538270, 202538271, 202538332, 202538398, 202538429, 202538431, 202538445, + 202538446, 202538661, 202538728, 202538744, 202538759, 202538774, 202538809, 202538926, 202539010, + 202539011, 202539031, 202539062, 202539094, 202539162, 202539168, 202539302, 202539399, 202539419 + DB-Periode September 2025Kein August-Exportfehler.
+
+
+ + diff --git a/dev/steuerberater/steuerberater-abgleich-status.md b/dev/steuerberater/steuerberater-abgleich-status.md new file mode 100644 index 0000000..f7011e9 --- /dev/null +++ b/dev/steuerberater/steuerberater-abgleich-status.md @@ -0,0 +1,58 @@ +# Steuerberater-Abgleich DATEV (Stand nach Korrekturen) + +## Kontext +- Basisdateien: + - `dev/steuerberater/steuerberater.csv` (Rueckmeldung Steuerberater) + - `storage/app/datev/2025/08/EXTF_Buchungsstapel_2025_08_20260312100928.csv` (Systemexport August) +- Zusaetzlich geprueft: + - DB-Daten aus `user_invoices`, `shopping_orders`, `datev_export_lines` + - Sonderfaelle Homeparty und Sammelrechnungen/API + +## Was wurde technisch gefixt +1. Sammelrechnungen/API: + - Problem: In vielen Faellen wurde nur der Steueranteil exportiert. + - Ursache: `net_split` fehlte auf `ShoppingOrder` (historisch), DATEV-Bildung hatte dadurch keinen Nettoanteil. + - Fix: `net_split` wird bei Sammelrechnungen gespeichert; DATEV nutzt historisch `shopping_collect_order.net_split` als Fallback. + +2. Homeparty: + - Problem: Exportbetrag wich deutlich vom Rechnungstotal ab. + - Ursache: Homeparty-Splits wurden aus `vk_*` gelesen, Rechnungssicht basiert aber auf `ek_*`. + - Fix: DATEV nutzt fuer diese Arrays `ek_tax` und `ek_net`. + +## Ergebnis nach Korrektur (Smoke-Test) +- Beispielbelege jetzt korrekt zum Rechnungstotal: + - `202536737` -> `634,88` + - `202536738` -> `806,14` + - `202537289` -> `228,86` + - `202537883` -> `426,63` + - `202537907` -> `609,87` + +## Einordnung der offenen Punkte +1. Logik 8125 vs. 8400: + - Kein pauschaler Exportfehler. + - Ohne verifizierte USt-ID bleibt `8400` plausibel. + - Mit verifizierter USt-ID/Reverse-Charge folgt Export der Rechnung und bucht auf `8125`. + +2. Storno-Hinweise: + - Fuer August fachlich kein DATEV-Storno ableitbar. + - Grund: Zu diesem Zeitpunkt waren im System noch keine echten Stornorechnungen aktiv; teils nur Liefer-/Bestellstorno oder Rechnungsvermerk. + +3. Fehlende Rechnungen: + - Viele Rueckmeldebelege liegen in der DB in `09/2025` und fehlen deshalb korrekt im August-Export. + - Drei Rechnungsnummern wurden unter der angegebenen `full_number` nicht gefunden: + - `202506145` + - `202506147` + - `202538333` + +4. USt-ID Folgeticket (offen): + - Aktuell: Validierung beim Eintragen. + - Soll: Bei jeder Bestellung neu pruefen; wenn ungueltig, Hinweis + USt-Berechnung. + +5. Doppelzahlungen/Payone (offen): + - DATEV-Export allein reicht nicht als Nachweis. + - Erforderlich: Abgleich `payment_transactions` (`txid`, `reference`) plus PAYONE Portal/API. + +## CSV fuer Steuerberater +- Strukturierte Fassung liegt in: + - `dev/steuerberater/steuerberater-abgleich-status.csv` + diff --git a/dev/steuerberater/steuerberater.csv b/dev/steuerberater/steuerberater.csv new file mode 100644 index 0000000..b116f0a --- /dev/null +++ b/dev/steuerberater/steuerberater.csv @@ -0,0 +1,67 @@ +Rechnung;Betrag Test;Betrag gebucht;Delta_1;Delta;Fehler;Kategorie;Bemerkung +202506145;#NV;265,8;-265,80;Fehler;Test;Testdaten unvollständig;Testdaten unvollständig, Rechnung liegt uns vor und wurde korrekt verbucht +202506147;#NV;115,5;-115,50;Fehler;Test;Testdaten unvollständig;Testdaten unvollständig, Rechnung liegt uns vor und wurde korrekt verbucht +202536737;85,1;533,51;-448,41;-448,41;Test;Testdaten fehlerhaft: Betrag;Testdaten fehlerhaft, Rechnung liegt uns vor und wurde korrekt verbucht +202536738;108,09;677,43;-569,34;-569,34;Test;Testdaten fehlerhaft: Betrag;gebuchte Rechnung stimmt mit Beleg überein (abgesehen von netto 0,08 EUR) +202536817;230,28;1434,71;-1.204,43;-1.204,43;Test;Testdaten fehlerhaft: Betrag;gebuchte Rechnung stimmt mit Beleg überein (abgesehen von netto 0,01 EUR) +202536821;#NV;444,54;-444,54;Fehler;Test;Testdaten unvollständig;Testdaten unvollständig, Rechnung liegt uns vor und wurde korrekt verbucht +202536841;#NV;93,43;-93,43;Fehler;Test;Testdaten unvollständig;Testdaten unvollständig, Rechnung liegt uns vor und wurde korrekt verbucht +202536843;104,13;652,71;-548,58;-548,58;Test;Testdaten fehlerhaft: Betrag;gebuchte Rechnung stimmt mit Beleg überein (abgesehen von netto 0,10 EUR) +202536848;233,55;#NV;233,55;Fehler;Test;Testdaten fehlerhaft: Logik;Rechnung korrekterweise auf 8125 gebucht nicht 8400 +202536925;52,69;330,27;-277,58;-277,58;Test;Testdaten fehlerhaft: Betrag;gebuchte Rechnung stimmt mit Beleg überein (abgesehen von netto 0,05 EUR) +202536968;167,67;1044,69;-877,02;-877,02;Test;Testdaten fehlerhaft: Betrag;Testdaten fehlerhaft, Rechnung liegt uns vor und wurde korrekt verbucht +202536978;95,29;0;95,29;95,29;;Testdaten fehlerhaft: Storno;Rg. Wurde storniert wegen Doppeltbestellung +202537144;146,97;0;146,97;146,97;;Testdaten fehlerhaft: Storno;Rg. Wurde storniert wegen Falschbestellung +202537208;122,52;101,68;20,84;20,84;Test;Testdaten fehlerhaft: Betrag;Testdaten fehlerhaft, Rechnung liegt uns vor und wurde korrekt verbucht +202537251;#NV;42,3;-42,30;Fehler;;Fachliche Klärung;von 8125 auf 8400 gebucht, obwohl spanische USt-IdNr. Vorhanden, diese ist wohl ungültig lt. Alex Dachs +202537289;39,34;163,03;-123,69;-123,69;Test;Testdaten fehlerhaft: Betrag;gebuchte Rechnung stimmt mit Beleg überein (abgesehen von netto 0,02 EUR) +202537290;226,44;#NV;226,44;Fehler;Test;Testdaten fehlerhaft: Logik;Rechnung korrekterweise auf 8125 gebucht nicht 8400 +202537317;136,16;853,15;-716,99;-716,99;Test;Testdaten fehlerhaft: Betrag;gebuchte Rechnung stimmt mit Beleg überein (abgesehen von netto 0,07 EUR) +202537450;72,57;454,54;-381,97;-381,97;Test;Testdaten fehlerhaft: Betrag;Testdaten fehlerhaft, Rechnung liegt uns vor und wurde korrekt verbucht +202537460;#NV;221,09;-221,09;Fehler;Test;Testdaten unvollständig;Testdaten unvollständig, Rechnung liegt uns vor und wurde korrekt verbucht +202537513;101,68;22,27;79,41;79,41;;Testdaten fehlerhaft: Storno;Rg. Wurde teilweise storniert +202537532;172,55;1075,04;-902,49;-902,49;Test;Testdaten fehlerhaft: Betrag;Testdaten fehlerhaft, Rechnung liegt uns vor und wurde korrekt verbucht +202537543;#NV;791,8;-791,80;Fehler;Test;Testdaten unvollständig;Testdaten unvollständig, Rechnung liegt uns vor und wurde korrekt verbucht +202537564;59,61;373,5;-313,89;-313,89;Test;Testdaten fehlerhaft: Betrag;gebuchte Rechnung stimmt mit Beleg überein (abgesehen von netto 0,02 EUR) +202537567;18,32;86,72;-68,40;-68,40;Test;Testdaten fehlerhaft: Betrag;Testdaten fehlerhaft, Rechnung liegt uns vor und wurde korrekt verbucht +202537572;177,06;90,34;86,72;86,72;Test;Testdaten fehlerhaft: Betrag;Testdaten fehlerhaft, Rechnung liegt uns vor und wurde korrekt verbucht +202537607;19,85;124,39;-104,54;-104,54;Test;Testdaten fehlerhaft: Betrag;gebuchte Rechnung stimmt mit Beleg überein (abgesehen von netto 0,02 EUR) +202537698;76,73;497,54;-420,81;-420,81;Test;Testdaten fehlerhaft: Betrag;Testdaten fehlerhaft, Rechnung liegt uns vor und wurde korrekt verbucht (abgesehen von 3,22 EUR) +202537741;86,08;539,2;-453,12;-453,12;Test;Testdaten fehlerhaft: Betrag;gebuchte Rechnung stimmt mit Beleg überein (abgesehen von netto 0,01 EUR) +202537808;86,22;86,13;0,09;0,09;Test;Testdaten fehlerhaft: Betrag;gebuchte Rechnung stimmt mit Beleg überein (abgesehen von netto 0,09 EUR) +202537832;129,35;#NV;129,35;Fehler;Test;Testdaten fehlerhaft: Logik;Rechnung korrekterweise auf 8125 gebucht nicht 8400 +202537840;86,43;541,73;-455,30;-455,30;Test;Testdaten fehlerhaft: Betrag;Testdaten fehlerhaft, Rechnung liegt uns vor und wurde korrekt verbucht +202537883;40,39;162,69;-122,30;-122,30;Test;Testdaten fehlerhaft: Betrag;gebuchte Rechnung stimmt mit Beleg überein (abgesehen von netto 0,02 EUR) +202537907;108,4;465,32;-356,92;-356,92;Test;Testdaten fehlerhaft: Betrag;gebuchte Rechnung stimmt mit Beleg überein (abgesehen von netto 0,06 EUR) +202537926;57,63;361,08;-303,45;-303,45;Test;Testdaten fehlerhaft: Betrag;gebuchte Rechnung stimmt mit Beleg überein (abgesehen von netto 0,03 EUR) +202537952;18,17;113,88;-95,71;-95,71;Test;Testdaten fehlerhaft: Betrag;gebuchte Rechnung stimmt mit Beleg überein (abgesehen von netto 0,02 EUR) +202538011;259,12;239,65;19,47;19,47;Test;Testdaten fehlerhaft: Betrag;gebuchte Rechnung stimmt mit Beleg überein (abgesehen von netto 0,01 EUR) +202538039;60,44;378,76;-318,32;-318,32;Test;Testdaten fehlerhaft: Betrag;gebuchte Rechnung stimmt mit Beleg überein (abgesehen von netto 0,05 EUR) +202538154;72,85;456,49;-383,64;-383,64;Test;Testdaten fehlerhaft: Betrag;gebuchte Rechnung stimmt mit Beleg überein (abgesehen von netto 0,04 EUR) +202538182;231,04;134,54;96,50;96,50;Test;Testdaten fehlerhaft: Betrag;gebuchte Rechnung stimmt mit Beleg überein (abgesehen von netto 0,01 EUR) +202538270;53,14;333,08;-279,94;-279,94;Test;Testdaten fehlerhaft: Betrag;gebuchte Rechnung stimmt mit Beleg überein (abgesehen von netto 0,01 EUR) +202538271;45,7;286,39;-240,69;-240,69;Test;Testdaten fehlerhaft: Betrag;gebuchte Rechnung stimmt mit Beleg überein (abgesehen von netto 0,03 EUR) +202538332;40,84;4,12;36,72;36,72;Test;Testdaten fehlerhaft: Betrag;Testdaten fehlerhaft, Rechnung liegt uns vor und wurde korrekt verbucht +202538333;#NV;36,72;-36,72;Fehler;Test;Testdaten unvollständig;Testdaten unvollständig, Rechnung liegt uns vor und wurde korrekt verbucht (Anderes Rechnungsformat) +202538398;#NV;142,57;-142,57;Fehler;Test;Testdaten unvollständig;Testdaten unvollständig, Rechnung liegt uns vor und wurde korrekt verbucht +202538429;73,19;458,66;-385,47;-385,47;Test;Testdaten fehlerhaft: Betrag;gebuchte Rechnung stimmt mit Beleg überein (abgesehen von netto 0,05 EUR) +202538431;84,52;529,69;-445,17;-445,17;Test;Testdaten fehlerhaft: Betrag;gebuchte Rechnung stimmt mit Beleg überein (abgesehen von netto 0,06 EUR) +202538445;227,39;#NV;227,39;Fehler;Test;Testdaten fehlerhaft: Logik;Rechnung korrekterweise auf 8125 gebucht nicht 8400 +202538446;358,96;#NV;358,96;Fehler;Test;Testdaten fehlerhaft: Logik;Rechnung korrekterweise auf 8125 gebucht nicht 8400 +202538661;116,85;732,15;-615,30;-615,30;Test;Testdaten fehlerhaft: Betrag;gebuchte Rechnung stimmt mit Beleg überein (abgesehen von netto 0,06 EUR) +202538728;58,18;251,86;-193,68;-193,68;Test;Testdaten unvollständig;Testdaten unvollständig, Rechnung liegt uns vor und wurde korrekt verbucht +202538744;#NV;87,9;-87,90;Fehler;Test;Testdaten unvollständig;Testdaten unvollständig, Rechnung liegt uns vor und wurde korrekt verbucht +202538759;121,09;758,77;-637,68;-637,68;Test;Testdaten fehlerhaft: Betrag;gebuchte Rechnung stimmt mit Beleg überein (abgesehen von netto 0,07 EUR) +202538774;87,9;#NV;87,90;Fehler;Test;Testdaten fehlerhaft: Logik;Rechnung korrekterweise auf 8125 gebucht nicht 8400 (Beleg nicht angehängt) +202538809;72,13;452,04;-379,91;-379,91;Test;Testdaten fehlerhaft: Betrag;gebuchte Rechnung stimmt mit Beleg überein (abgesehen von netto 0,05 EUR) +202538926;102,96;645,21;-542,25;-542,25;Test;Testdaten fehlerhaft: Betrag;gebuchte Rechnung stimmt mit Beleg überein (abgesehen von netto 0,07 EUR) +202539010;27,63;118,16;-90,53;-90,53;Test;Testdaten fehlerhaft: Betrag;Testdaten fehlerhaft, Rechnung liegt uns vor und wurde korrekt verbucht +202539011;239,65;#NV;239,65;Fehler;Test;Testdaten fehlerhaft: Logik;Rechnung korrekterweise auf 8125 gebucht nicht 8400 (Beleg nicht angehängt) +202539031;141,52;886,85;-745,33;-745,33;Test;Testdaten fehlerhaft: Betrag;gebuchte Rechnung stimmt mit Beleg überein (abgesehen von netto 0,09 EUR) +202539062;46,05;197,6;-151,55;-151,55;Test;Testdaten fehlerhaft: Betrag;gebuchte Rechnung stimmt mit Beleg überein (abgesehen von netto 0,02 EUR) +202539094;94,31;591,02;-496,71;-496,71;Test;Testdaten fehlerhaft: Betrag;gebuchte Rechnung stimmt mit Beleg überein (abgesehen von netto 0,06 EUR) +202539162;37,13;160,82;-123,69;-123,69;Test;Testdaten fehlerhaft: Betrag;Testdaten fehlerhaft, Rechnung liegt uns vor und wurde korrekt verbucht +202539168;37,95;237,75;-199,80;-199,80;Test;Testdaten fehlerhaft: Betrag;gebuchte Rechnung stimmt mit Beleg überein (abgesehen von netto 0,01 EUR) +202539302;84,31;528,24;-443,93;-443,93;Test;Testdaten fehlerhaft: Betrag;gebuchte Rechnung stimmt mit Beleg überein (abgesehen von netto 0,03 EUR) +202539399;248;#NV;248,00;Fehler;Test;Testdaten fehlerhaft: Logik;Rechnung korrekterweise auf 8125 gebucht nicht 8400 +202539419;61,01;382,37;-321,36;-321,36;Test;Testdaten fehlerhaft: Betrag;gebuchte Rechnung stimmt mit Beleg überein (abgesehen von netto 0,05 EUR) diff --git a/resources/lang/de/abo.php b/resources/lang/de/abo.php index 19628f1..8ee9e48 100644 --- a/resources/lang/de/abo.php +++ b/resources/lang/de/abo.php @@ -32,6 +32,7 @@ return [ 'abo_copy_active' => 'Wenn das Abonnement nicht aktiv ist, erfolgt keine automatische Ausführung.', 'abo_copy_next_date' => 'Der nächste Ausführungstermin kann frühesten auf den Folgetag festgelegt werden.', 'abo_copy_abo_interval' => 'Die Anpassung des Abonnement-Liefertags wirkt sich auf den kommenden Ausführungstermin aus, wenn das Abonnement aktiv ist.', + 'admin_abo_copy_next_date' => 'Admins können den nächsten Ausführungstermin direkt über Monat und Liefertag festlegen.', 'error_abo_interval' => 'Das Abo Interval nicht korrekt', 'error_abo_interval_in_the_past' => 'Das Abo wurde diesen Monat noch nicht ausgeführt. Eine Änderung auf einen vergangenen Tag würde den aktuellen Monat überspringen.', 'warning_next_date_soon' => 'Hinweis: Die nächste Abo-Ausführung ist bereits in :days Tagen (:date).', @@ -110,6 +111,18 @@ return [ 'abo_error_basis_product' => 'Fehler: Bitte wählen Sie mindestens ein Basis-Produkt aus.', 'cancel_abo' => 'Abo kündigen', 'confirm_cancel' => 'Möchten Sie das Abo wirklich kündigen?', + 'retry_payment' => 'Zahlung erneut ausführen', + 'retry_payment_confirm_title' => 'Bitte bewusst bestätigen', + 'retry_payment_confirm_copy' => 'Der Zahlungsversuch wird sofort gestartet und kann eine PayPal- oder Kreditkartenbuchung auslösen. Bitte nur ausführen, wenn die Ursache geprüft wurde.', + 'retry_payment_confirm_button' => 'Zahlung jetzt erneut versuchen', + 'retry_only_hold' => 'Der erneute Zahlungsversuch ist nur für angehaltene Abos möglich.', + 'retry_only_active' => 'Der erneute Zahlungsversuch ist nur für aktive Abos möglich.', + 'retry_already_paid_today' => 'Für dieses Abo wurde heute bereits eine erfolgreiche Zahlung gespeichert.', + 'retry_error_shopping_user' => 'Der Shopping-User konnte für den erneuten Zahlungsversuch nicht erstellt werden.', + 'retry_error_order' => 'Die Bestellung konnte für den erneuten Zahlungsversuch nicht erstellt werden.', + 'retry_success' => 'Der erneute Zahlungsversuch war erfolgreich. Bestellung #:order wurde angelegt.', + 'retry_failed' => 'Der erneute Zahlungsversuch ist fehlgeschlagen. Bestellung #:order wurde angelegt. Fehler: :error', + 'retry_exception' => 'Der erneute Zahlungsversuch konnte nicht abgeschlossen werden: :error', 'team_subscriptions' => 'Team Abos', 'team_customer_abos' => 'Team Kunden-Abos', 'chart_monthly_abos' => 'Abos pro Monat', diff --git a/resources/lang/en/abo.php b/resources/lang/en/abo.php index 228b196..69e9067 100644 --- a/resources/lang/en/abo.php +++ b/resources/lang/en/abo.php @@ -32,6 +32,7 @@ return [ 'abo_copy_active' => 'If the subscription is not active, it will not be executed automatically', 'abo_copy_next_date' => 'The next execution date can be set to the following day at the earliest', 'abo_copy_abo_interval' => 'Adjusting the subscription delivery day affects the upcoming execution date when the subscription is active.', + 'admin_abo_copy_next_date' => 'Admins can set the next execution date directly using month and delivery day.', 'error_abo_interval' => 'The subscription interval is not correct', 'error_next_date' => 'The date for the next execution is not correct', 'checkout_mail_abo_hl' => 'Your subscription / regular delivery', @@ -110,6 +111,18 @@ return [ 'error_pause_locked' => 'The subscription can no longer be paused. The next execution is in :days days. Pausing must be done at least 3 days in advance.', 'cancel_abo' => 'Cancel subscription', 'confirm_cancel' => 'Do you really want to cancel the subscription?', + 'retry_payment' => 'Retry payment', + 'retry_payment_confirm_title' => 'Please confirm deliberately', + 'retry_payment_confirm_copy' => 'The payment attempt will start immediately and can trigger a PayPal or credit card charge. Please run this only after checking the cause.', + 'retry_payment_confirm_button' => 'Retry payment now', + 'retry_only_hold' => 'The payment retry is only available for held subscriptions.', + 'retry_only_active' => 'The payment retry is only available for active subscriptions.', + 'retry_already_paid_today' => 'A successful payment has already been recorded for this subscription today.', + 'retry_error_shopping_user' => 'The shopping user could not be created for the payment retry.', + 'retry_error_order' => 'The order could not be created for the payment retry.', + 'retry_success' => 'The payment retry was successful. Order #:order was created.', + 'retry_failed' => 'The payment retry failed. Order #:order was created. Error: :error', + 'retry_exception' => 'The payment retry could not be completed: :error', 'back' => 'back', 'team_subscriptions' => 'Team subscriptions', 'team_customer_abos' => 'Team Customer Subscriptions', diff --git a/resources/lang/es/abo.php b/resources/lang/es/abo.php index f2f9759..c886ed5 100644 --- a/resources/lang/es/abo.php +++ b/resources/lang/es/abo.php @@ -32,6 +32,7 @@ return [ 'abo_copy_active' => 'Si la suscripción no está activa, no se ejecutará automáticamente', 'abo_copy_next_date' => 'La siguiente fecha de ejecución puede establecerse como muy pronto al día siguiente', 'abo_copy_abo_interval' => 'El ajuste del día de entrega de la suscripción afecta a la próxima fecha de ejecución cuando la suscripción está activa.', + 'admin_abo_copy_next_date' => 'Los administradores pueden establecer la próxima fecha de ejecución directamente mediante mes y día de entrega.', 'error_abo_interval' => 'El intervalo de suscripción no es correcto', 'error_next_date' => 'La fecha de la siguiente ejecución no es correcta', 'checkout_mail_abo_hl' => 'Su suscripción / entrega regular', @@ -110,6 +111,18 @@ return [ 'error_pause_locked' => 'La suscripción ya no puede pausarse. La próxima ejecución es en :days días. La pausa debe realizarse al menos 3 días antes.', 'cancel_abo' => 'Cancelar suscripción', 'confirm_cancel' => '¿Realmente desea cancelar la suscripción?', + 'retry_payment' => 'Reintentar pago', + 'retry_payment_confirm_title' => 'Confirme conscientemente', + 'retry_payment_confirm_copy' => 'El intento de pago comenzará inmediatamente y puede activar un cargo de PayPal o tarjeta de crédito. Ejecútelo solo después de comprobar la causa.', + 'retry_payment_confirm_button' => 'Reintentar pago ahora', + 'retry_only_hold' => 'El reintento de pago solo está disponible para suscripciones en pausa.', + 'retry_only_active' => 'El reintento de pago solo está disponible para suscripciones activas.', + 'retry_already_paid_today' => 'Ya se ha registrado hoy un pago correcto para esta suscripción.', + 'retry_error_shopping_user' => 'No se pudo crear el usuario de compra para el reintento de pago.', + 'retry_error_order' => 'No se pudo crear el pedido para el reintento de pago.', + 'retry_success' => 'El reintento de pago se realizó correctamente. Se creó el pedido #:order.', + 'retry_failed' => 'El reintento de pago falló. Se creó el pedido #:order. Error: :error', + 'retry_exception' => 'No se pudo completar el reintento de pago: :error', 'back' => 'atrás', 'team_subscriptions' => 'Suscripciones de equipo', 'team_customer_abos' => 'Suscripciones de clientes del equipo', diff --git a/resources/views/admin/abo/_detail.blade.php b/resources/views/admin/abo/_detail.blade.php index cdec861..c4a74b4 100644 --- a/resources/views/admin/abo/_detail.blade.php +++ b/resources/views/admin/abo/_detail.blade.php @@ -1,111 +1,120 @@ - - -
-
-
-
{{ __('tables.start_date') }}
- {{ $user_abo->start_date }} -
-
-
{{ __('tables.next_date') }}
- {{ $user_abo->next_date }} -
-
-
{{ __('tables.abo_delivery_day') }}
- {{ \App\Services\HTMLHelper::getAboStrLang($user_abo->abo_interval) }} -
-
-
{{ __('tables.last_date') }}
- {{ $user_abo->last_date }} -
- -
+ +
+
+
+
{{ __('tables.start_date') }}
+ {{ $user_abo->start_date }}
-
-
-
-
-
{{ __('tables.status') }} / {{ __('tables.active') }}
- {!! $user_abo->getStatusFormated() !!}  {!! get_active_badge($user_abo->active) !!} -
-
-
{{ __('tables.abo_delivery') }}
- {{ $user_abo->getCountPaidOrders() }} / {{ \App\Models\Setting::getContentBySlug('abo-min-duration') }} - -
- -
-
{{ __('tables.payment') }}
- {{ $user_abo->getPaymentType() }} -
-
-
{{ __('tables.amount') }}
- {{ $user_abo->getFormattedAmount() }} € -
-
+
+
{{ __('tables.next_date') }}
+ {{ $user_abo->next_date }} +
+
+
{{ __('tables.abo_delivery_day') }}
+ {{ \App\Services\HTMLHelper::getAboStrLang($user_abo->abo_interval) }} +
+
+
{{ __('tables.last_date') }}
+ {{ $user_abo->last_date }}
-
- @if($isAdmin) -
-
-
- -
- -
- @if(isset($user_abo->shopping_user) && $user_abo->shopping_user->member_id > 0) -
{{ __('tables.adviser') }}
- {!! ''.$user_abo->shopping_user->member->getFullName().'' !!} - @endif -
+
+
+
+
+
+
+
{{ __('tables.status') }} / {{ __('tables.active') }}
+ {!! $user_abo->getStatusFormated() !!}  {!! get_active_badge($user_abo->active) !!} + @if ($user_abo->status === 3 && $user_abo->active) + + @endif +
+
+
{{ __('tables.abo_delivery') }}
+ {{ $user_abo->getCountPaidOrders() }} / {{ \App\Models\Setting::getContentBySlug('abo-min-duration') }} -
-
{{ __('tables.is_for') }}
- {!! $user_abo->getIsForFormated() !!} - @if($user_abo->is_for === 'me') - +
- @endif +
+
{{ __('tables.payment') }}
+ {{ $user_abo->getPaymentType() }} +
+
+
{{ __('tables.amount') }}
+ {{ $user_abo->getFormattedAmount() }} € +
+
+
+ +
+@if ($isAdmin) +
+
+
+ - @if($user_abo->is_for === 'ot') - - @endif -
- -
- @else -
-
-
- @if(App\Services\AboHelper::canEditAbo($user_abo, $view)) - - @endif -
-
- {!! $user_abo->getIsForFormated() !!} - @if($user_abo->is_for === 'me') - - - @endif - - @if($user_abo->is_for === 'ot') - - @endif -
-
+
+ @if (isset($user_abo->shopping_user) && $user_abo->shopping_user->member_id > 0) +
{{ __('tables.adviser') }}
+ {!! '' . + $user_abo->shopping_user->member->getFullName() . + '' !!} + @endif
- @endif +
+
{{ __('tables.is_for') }}
+ {!! $user_abo->getIsForFormated() !!} + @if ($user_abo->is_for === 'me') + + @endif - \ No newline at end of file + @if ($user_abo->is_for === 'ot') + + @endif +
+ +
+
+@else +
+
+
+ @if (App\Services\AboHelper::canEditAbo($user_abo, $view)) + + @endif +
+ +
+ {!! $user_abo->getIsForFormated() !!} + @if ($user_abo->is_for === 'me') + + @endif + + @if ($user_abo->is_for === 'ot') + + @endif +
+
+
+@endif diff --git a/resources/views/admin/abo/_executions.blade.php b/resources/views/admin/abo/_executions.blade.php index 975874b..0f2807a 100644 --- a/resources/views/admin/abo/_executions.blade.php +++ b/resources/views/admin/abo/_executions.blade.php @@ -41,9 +41,18 @@ {{__('tables.rf_no')}} + @php + $executionColspan = (!isset($only_show_products) || !$only_show_products) ? 13 : 12; + @endphp @if($user_abo->user_abo_orders) @foreach($user_abo->user_abo_orders()->orderBy('id', 'desc')->get() as $user_abo_order) @if($user_abo_order->shopping_order) + @php + $paymentErrorTransaction = $user_abo_order->shopping_order->getLastShoppingPaymentTransaction(); + $hasPaymentError = ! $user_abo_order->paid + && $paymentErrorTransaction + && ($paymentErrorTransaction->errorcode || $paymentErrorTransaction->errormessage || $paymentErrorTransaction->customermessage); + @endphp @if($isAdmin) @@ -113,6 +122,25 @@ {{ $user_abo_order->shopping_order->getLastShoppingPayment('reference') }} + @if($hasPaymentError) + + +
+ {{ __('payment.payment_error') }}: + @if($paymentErrorTransaction->errorcode) + {{ $paymentErrorTransaction->errorcode }} + @endif + {{ $paymentErrorTransaction->errormessage ?: $paymentErrorTransaction->customermessage }} + @if($paymentErrorTransaction->error_description) + ({{ $paymentErrorTransaction->error_description }}) + @endif +
+ {{ $paymentErrorTransaction->created_at->format('d.m.Y H:i') }} +
+
+ + + @endif @endif @endforeach @endif diff --git a/resources/views/admin/abo/detail.blade.php b/resources/views/admin/abo/detail.blade.php index 35776f5..e08ef66 100644 --- a/resources/views/admin/abo/detail.blade.php +++ b/resources/views/admin/abo/detail.blade.php @@ -25,6 +25,15 @@
@endif + @if (Session::has('alert-success')) +
+
+
    +
  • {{ Session::get('alert-success') }}
  • +
+
+
+ @endif
@include('admin.abo._detail')
@@ -39,10 +48,11 @@ 'action' => route('user_abos_update', [$view, $user_abo->id]), 'class' => 'form-horizontal', 'id' => 'cart-order-form', + 'data-add-only-mode' => '0', ]) !!}
- @include('admin.abo._order_abo') + @include('admin.abo._order_abo', ['add_only_mode' => false])
@if ($comp_products && Yard::instance('shopping')->getNumComp() > 0) @@ -109,6 +119,51 @@
+ + @if ($user_abo->status === 3 && $user_abo->active) + + @endif @endsection @section('scripts') diff --git a/resources/views/admin/abo/modal_abo_update.blade.php b/resources/views/admin/abo/modal_abo_update.blade.php index be400e2..e3dce06 100644 --- a/resources/views/admin/abo/modal_abo_update.blade.php +++ b/resources/views/admin/abo/modal_abo_update.blade.php @@ -23,8 +23,34 @@ {!! HTMLHelper::getAboDeliveryOptions($user_abo->abo_interval) !!} + @if($data['view'] === 'admin') + @php + $selectedNextMonth = $user_abo->getRawOriginal('next_date') + ? \Carbon\Carbon::parse($user_abo->getRawOriginal('next_date'))->format('Y-m') + : now()->format('Y-m'); + @endphp +
+ + +
+ @endif
- {{ __('abo.abo_copy_abo_interval') }} + + @if($data['view'] === 'admin') + {{ __('abo.admin_abo_copy_next_date') }} + @else + {{ __('abo.abo_copy_abo_interval') }} + @endif +
diff --git a/routes/domains/crm.php b/routes/domains/crm.php index e434c8a..4e495bf 100644 --- a/routes/domains/crm.php +++ b/routes/domains/crm.php @@ -345,6 +345,7 @@ Route::domain(config('app.pre_url_crm').config('app.domain').config('app.tld_car Route::get('/admin/abos/detail/{id}', 'Admin\AboController@detail')->name('admin_abos_detail'); Route::post('/admin/abos/update/{id}', 'Admin\AboController@update')->name('admin_abos_update'); Route::post('/admin/abos/rollback/{id}', 'Admin\AboController@rollback')->name('admin_abos_rollback'); + Route::post('/admin/abos/retry-payment/{id}', 'Admin\AboController@retryPayment')->name('admin_abos_retry_payment'); Route::get('/admin/abos/datatable', 'Admin\AboController@datatable')->name('admin_abos_datatable'); // incentives diff --git a/tests/Feature/AboRepositoryDateChangeTest.php b/tests/Feature/AboRepositoryDateChangeTest.php index a44885f..6c890da 100644 --- a/tests/Feature/AboRepositoryDateChangeTest.php +++ b/tests/Feature/AboRepositoryDateChangeTest.php @@ -1,15 +1,23 @@ repository = new AboRepository; }); +afterEach(function () { + Carbon::setTestNow(); +}); + /** * Szenario: Heute = 15. März, next_date = 5. April (März-Ausführung bereits erfolgt). * Änderung auf Tag 20 → neues Datum soll der 20. April sein, NICHT der 20. März. @@ -86,6 +94,54 @@ it('verwendet heute als Referenz wenn kein next_date gesetzt ist', function () { Carbon::setTestNow(); }); +it('erlaubt Admins den nächsten Ausführungstermin ohne User-Sperrfrist direkt zu setzen', function () { + Carbon::setTestNow('2026-03-19 12:00:00'); + + $abo = createRepositoryTestAbo([ + 'abo_interval' => 5, + 'next_date' => '2026-03-20', + ]); + + $this->repository->setModel($abo); + + $result = $this->repository->update([ + 'action' => 'abo_update_settings', + 'id' => $abo->id, + 'view' => 'admin', + 'abo_interval' => 20, + 'abo_next_month' => '2026-03', + 'abo_is_active' => 'true', + ]); + + expect($result)->toBeTrue(); + expect($abo->refresh()->abo_interval)->toBe(20); + expect($abo->getRawOriginal('next_date'))->toBe('2026-03-20'); +}); + +it('behält die User-Sperrfrist für normale Abo-Änderungen bei', function () { + Carbon::setTestNow('2026-03-19 12:00:00'); + + $abo = createRepositoryTestAbo([ + 'abo_interval' => 5, + 'next_date' => '2026-03-20', + ]); + + $this->actingAs($abo->user); + $this->repository->setModel($abo); + + $result = $this->repository->update([ + 'action' => 'abo_update_settings', + 'id' => $abo->id, + 'view' => 'me', + 'abo_interval' => 20, + 'abo_is_active' => 'true', + ]); + + expect($result)->toBeFalse(); + expect($abo->refresh()->abo_interval)->toBe(5); + expect($abo->getRawOriginal('next_date'))->toBe('2026-03-20'); +}); + /** * Hilfsfunktion zum Aufruf privater Methoden. * @@ -98,3 +154,53 @@ function invadePrivateMethod(object $object, string $methodName, array $args = [ return $reflection->invoke($object, ...$args); } + +/** + * @param array $overrides + */ +function createRepositoryTestAbo(array $overrides = []): UserAbo +{ + $country = Country::create([ + 'code' => 'DE', + 'phone' => '49', + 'en' => 'Germany', + 'de' => 'Deutschland', + 'es' => 'Alemania', + 'fr' => 'Allemagne', + 'it' => 'Germania', + 'ru' => 'Deutschland', + ]); + + $user = User::forceCreate([ + 'email' => 'abo-repository-'.uniqid('', true).'@example.com', + 'password' => bcrypt('secret'), + 'lang' => 'de', + 'admin' => 0, + ]); + + $shoppingUser = ShoppingUser::create([ + 'auth_user_id' => $user->id, + 'member_id' => $user->id, + 'billing_country_id' => $country->id, + 'shipping_country_id' => $country->id, + 'billing_email' => $user->email, + 'is_for' => 'me', + 'is_from' => 'user_order', + ]); + + return UserAbo::create(array_merge([ + 'user_id' => $user->id, + 'member_id' => $user->id, + 'shopping_user_id' => $shoppingUser->id, + 'is_for' => 'me', + 'email' => $user->email, + 'payone_userid' => random_int(100000, 999999), + 'clearingtype' => 'cc', + 'active' => true, + 'status' => 2, + 'abo_interval' => 5, + 'start_date' => '2026-01-05', + 'last_date' => '2026-02-05', + 'next_date' => '2026-03-05', + ], $overrides)); +} diff --git a/tests/Feature/AdminAboRetryPaymentTest.php b/tests/Feature/AdminAboRetryPaymentTest.php new file mode 100644 index 0000000..3a85787 --- /dev/null +++ b/tests/Feature/AdminAboRetryPaymentTest.php @@ -0,0 +1,207 @@ + $fixture['userAbo'], + 'isAdmin' => false, + 'only_show_products' => true, + ])->render(); + + expect($html) + ->toContain('Zahlung fehlgeschlagen') + ->toContain('923') + ->toContain('Keine Deckung'); +}); + +it('blockiert erneute Zahlungsversuche für nicht angehaltene Abos vor dem Payment-Aufruf', function () { + $service = new AboRetryPaymentService; + $userAbo = new UserAbo([ + 'status' => 2, + 'active' => true, + ]); + + $result = $service->retry($userAbo); + + expect($result['success'])->toBeFalse(); + expect($result['message'])->toBe(__('abo.retry_only_hold')); +}); + +it('speichert PAYONE-Fehler aus dem automatischen Abo-Lauf als PaymentTransaction', function () { + $fixture = createAdminAboRetryFixture(); + $shoppingPayment = $fixture['userAboOrder']->shopping_order->shopping_payments()->firstOrFail(); + $userMakeOrder = new UserMakeOrder($fixture['userAbo']); + + $payProperty = new ReflectionProperty($userMakeOrder, 'pay'); + $payProperty->setAccessible(true); + $payProperty->setValue($userMakeOrder, new class($shoppingPayment) + { + public function __construct(private ShoppingPayment $shoppingPayment) {} + + public function getShoppingPayment(): ShoppingPayment + { + return $this->shoppingPayment; + } + }); + + $recordMethod = new ReflectionMethod($userMakeOrder, 'recordPaymentTransaction'); + $recordMethod->setAccessible(true); + $recordMethod->invoke($userMakeOrder, [ + 'status' => 'ERROR', + 'errorcode' => 130, + 'errormessage' => 'Limit überschritten', + 'customermessage' => 'Die Zahlung wurde abgelehnt.', + ]); + + $paymentTransaction = $shoppingPayment->payment_transactions()->latest('id')->firstOrFail(); + + expect($paymentTransaction->status)->toBe('ERROR'); + expect($paymentTransaction->errorcode)->toBe(130); + expect($paymentTransaction->errormessage)->toBe('Limit überschritten'); +}); + +it('setzt die Mindestlaufzeit-Sperre fuer Admin-Bearbeitung ausser Kraft', function () { + $fixture = createAdminAboRetryFixture(); + + expect(AboHelper::isAddOnlyMode($fixture['userAbo'], 'admin'))->toBeFalse(); +}); + +/** + * @return array{userAbo: UserAbo, userAboOrder: UserAboOrder} + */ +function createAdminAboRetryFixture(): array +{ + $country = Country::create([ + 'code' => 'DE', + 'phone' => '49', + 'en' => 'Germany', + 'de' => 'Deutschland', + 'es' => 'Alemania', + 'fr' => 'Allemagne', + 'it' => 'Germania', + 'ru' => 'Deutschland', + ]); + + $shipping = Shipping::create([ + 'name' => 'Standard', + 'active' => true, + ]); + + $shippingCountry = ShippingCountry::create([ + 'shipping_id' => $shipping->id, + 'country_id' => $country->id, + ]); + + $user = User::forceCreate([ + 'email' => 'admin-abo-retry-'.uniqid('', true).'@example.com', + 'password' => bcrypt('secret'), + 'lang' => 'de', + ]); + + $userShop = UserShop::create([ + 'user_id' => $user->id, + 'name' => 'TS'.substr(uniqid('', true), 0, 8), + 'slug' => 'ts-'.uniqid(), + 'active' => true, + ]); + + $shoppingUser = ShoppingUser::create([ + 'auth_user_id' => $user->id, + 'member_id' => $user->id, + 'billing_country_id' => $country->id, + 'shipping_country_id' => $country->id, + 'billing_email' => $user->email, + 'shipping_email' => $user->email, + 'shipping_firstname' => 'Max', + 'shipping_lastname' => 'Muster', + 'is_for' => 'me', + 'is_from' => 'user_order', + ]); + + $userAbo = UserAbo::create([ + 'user_id' => $user->id, + 'member_id' => $user->id, + 'shopping_user_id' => $shoppingUser->id, + 'is_for' => 'me', + 'email' => $user->email, + 'payone_userid' => 123456, + 'clearingtype' => 'wlt', + 'wallettype' => 'PPE', + 'active' => true, + 'status' => 3, + 'abo_interval' => 5, + 'next_date' => now()->toDateString(), + ]); + + $shoppingOrder = ShoppingOrder::create([ + 'shopping_user_id' => $shoppingUser->id, + 'auth_user_id' => $user->id, + 'member_id' => $user->id, + 'country_id' => $shippingCountry->id, + 'user_shop_id' => $userShop->id, + 'payment_for' => 3, + 'total' => 100, + 'subtotal' => 90, + 'total_shipping' => 100, + 'paid' => false, + 'is_abo' => true, + 'txaction' => 'failed', + 'mode' => 'test', + ]); + + $shoppingPayment = ShoppingPayment::create([ + 'shopping_order_id' => $shoppingOrder->id, + 'clearingtype' => 'wlt', + 'wallettype' => 'PPE', + 'reference' => 'RF123456', + 'amount' => 10000, + 'currency' => 'EUR', + 'mode' => 'test', + 'is_abo' => true, + 'abo_interval' => 5, + ]); + + PaymentTransaction::create([ + 'shopping_payment_id' => $shoppingPayment->id, + 'request' => 'debit', + 'txid' => 1, + 'userid' => 123456, + 'status' => 'ERROR', + 'txaction' => 'failed', + 'errorcode' => 923, + 'errormessage' => 'Keine Deckung', + 'mode' => 'test', + ]); + + $userAboOrder = UserAboOrder::create([ + 'user_abo_id' => $userAbo->id, + 'shopping_order_id' => $shoppingOrder->id, + 'status' => 3, + 'paid' => false, + ]); + + return [ + 'userAbo' => $userAbo, + 'userAboOrder' => $userAboOrder, + ]; +} diff --git a/tests/Unit/Services/DatevExportServiceTest.php b/tests/Unit/Services/DatevExportServiceTest.php index ca1f557..6eaae9d 100644 --- a/tests/Unit/Services/DatevExportServiceTest.php +++ b/tests/Unit/Services/DatevExportServiceTest.php @@ -458,6 +458,64 @@ class DatevExportServiceTest extends TestCase $this->assertEquals(15.50, $result); } + /** @test */ + public function it_parses_homeparty_ek_tax_split_format_with_preferred_key() + { + $method = new \ReflectionMethod(DatevExportService::class, 'parseNumber'); + $method->setAccessible(true); + + $result = $method->invoke($this->service, ['vk_tax' => '15.50', 'ek_tax' => '7.50'], 'ek_tax'); + + $this->assertEquals(7.50, $result); + } + + /** @test */ + public function it_parses_homeparty_ek_net_split_format_with_preferred_key() + { + $method = new \ReflectionMethod(DatevExportService::class, 'parseNumber'); + $method->setAccessible(true); + + $result = $method->invoke($this->service, ['vk_net' => '123.45', 'ek_net' => '67.89'], 'ek_net'); + + $this->assertEquals(67.89, $result); + } + + /** @test */ + public function it_resolves_net_split_from_collective_order_when_order_has_none() + { + $method = new \ReflectionMethod(DatevExportService::class, 'resolveNetSplit'); + $method->setAccessible(true); + + $collectOrder = new \stdClass; + $collectOrder->net_split = ['19' => '533.61']; + + $order = new \stdClass; + $order->net_split = null; + $order->shopping_collect_order = $collectOrder; + + $result = $method->invoke($this->service, $order); + + $this->assertEquals(['19' => '533.61'], $result); + } + + /** @test */ + public function it_prefers_order_net_split_over_collective_order_net_split() + { + $method = new \ReflectionMethod(DatevExportService::class, 'resolveNetSplit'); + $method->setAccessible(true); + + $collectOrder = new \stdClass; + $collectOrder->net_split = ['19' => '533.61']; + + $order = new \stdClass; + $order->net_split = ['19' => '677.51']; + $order->shopping_collect_order = $collectOrder; + + $result = $method->invoke($this->service, $order); + + $this->assertEquals(['19' => '677.51'], $result); + } + /* |-------------------------------------------------------------------------- | Model Tests From 70240d2b6acb1d30d7900d5371190b83d8d28d9c Mon Sep 17 00:00:00 2001 From: Kevin Adametz Date: Wed, 13 May 2026 17:33:52 +0200 Subject: [PATCH 2/4] 13-05-2026 implementation FR --- .devcontainer/devcontainer.json | 3 +- .env | 5 + .../BackfillFrenchDatabaseTranslations.php | 551 ++++++++++++++++++ config/localization.php | 2 +- config/services.php | 7 + ...9_add_french_locale_to_trans_languages.php | 32 + .../ENTWICKLUNGSKONZEPT-BACKOFFICE.md | 417 +++++++++++++ .../ENTWICKLUNGSKONZEPT-DHL-MODUL.md | 190 ++++++ .../dhl_test.txt | 0 .../legacy}/AKTUALISIERUNG-PAKET-ANSATZ.md | 0 .../legacy}/DHL_CURL_781_EXTREME_FIX.md | 0 .../legacy}/DHL_LEGACY_CURL_CONFIG.md | 0 .../legacy}/DHL_LIVE_SERVER_FIX.md | 0 .../legacy}/DHL_LIVE_SERVER_SOLUTION.md | 0 .../legacy}/DHL_SSL_FIX_README.md | 0 .../legacy}/OPTIMIERUNGEN.md | 0 .../legacy}/PAKET-INSTALLATION.md | 0 .../legacy}/PLAN-OPTIMIERT.md | 0 .../legacy}/README.md | 0 .../legacy}/SCHRITT-3-COMPLETED.md | 0 .../parcel-de-shipping-v2_2.yaml | 0 ...BusinessUpdateCalculatedFields-Examples.sh | 0 .../BusinessUpdateCalculatedFields.md | 0 resources/lang/fr.json | 170 ++++++ resources/lang/fr/abo.php | 135 +++++ resources/lang/fr/abo_history.php | 41 ++ resources/lang/fr/account.php | 48 ++ resources/lang/fr/actions.php | 24 + resources/lang/fr/auth.php | 8 + resources/lang/fr/backend.php | 34 ++ resources/lang/fr/cal.php | 59 ++ resources/lang/fr/customer.php | 38 ++ resources/lang/fr/dataprotect.php | 192 ++++++ resources/lang/fr/dhl.php | 90 +++ resources/lang/fr/email.php | 130 +++++ resources/lang/fr/gtc.php | 71 +++ resources/lang/fr/home.php | 61 ++ resources/lang/fr/homeparty.php | 74 +++ resources/lang/fr/incentive.php | 159 +++++ resources/lang/fr/marketingplan.php | 63 ++ resources/lang/fr/membership.php | 60 ++ resources/lang/fr/msg.php | 40 ++ resources/lang/fr/navigation.php | 91 +++ resources/lang/fr/order.php | 125 ++++ resources/lang/fr/pagination.php | 6 + resources/lang/fr/passwords.php | 9 + resources/lang/fr/payment.php | 192 ++++++ resources/lang/fr/pdf.php | 65 +++ resources/lang/fr/portal.php | 39 ++ resources/lang/fr/register.php | 70 +++ resources/lang/fr/reminder.php | 62 ++ resources/lang/fr/shop.php | 57 ++ resources/lang/fr/tables.php | 95 +++ resources/lang/fr/team.php | 134 +++++ resources/lang/fr/validation.php | 182 ++++++ resources/lang/fr/webcontent.php | 170 ++++++ resources/lang/fr/weborder.php | 45 ++ resources/lang/fr/website.php | 101 ++++ ...BackfillFrenchDatabaseTranslationsTest.php | 278 +++++++++ tests/Feature/FrenchLocalizationTest.php | 48 ++ tests/Unit/Services/LocaleGuardTest.php | 1 + 61 files changed, 4472 insertions(+), 2 deletions(-) create mode 100644 app/Console/Commands/BackfillFrenchDatabaseTranslations.php create mode 100644 database/migrations/2026_05_12_113939_add_french_locale_to_trans_languages.php create mode 100644 dev/2026-05-13-backoffice/ENTWICKLUNGSKONZEPT-BACKOFFICE.md create mode 100644 dev/2026-05-13-dhl-modul/ENTWICKLUNGSKONZEPT-DHL-MODUL.md rename dev/{dhl-modul => 2026-05-13-dhl-modul}/dhl_test.txt (100%) rename dev/{dhl-modul => 2026-05-13-dhl-modul/legacy}/AKTUALISIERUNG-PAKET-ANSATZ.md (100%) rename dev/{dhl-modul => 2026-05-13-dhl-modul/legacy}/DHL_CURL_781_EXTREME_FIX.md (100%) rename dev/{dhl-modul => 2026-05-13-dhl-modul/legacy}/DHL_LEGACY_CURL_CONFIG.md (100%) rename dev/{dhl-modul => 2026-05-13-dhl-modul/legacy}/DHL_LIVE_SERVER_FIX.md (100%) rename dev/{dhl-modul => 2026-05-13-dhl-modul/legacy}/DHL_LIVE_SERVER_SOLUTION.md (100%) rename dev/{dhl-modul => 2026-05-13-dhl-modul/legacy}/DHL_SSL_FIX_README.md (100%) rename dev/{dhl-modul => 2026-05-13-dhl-modul/legacy}/OPTIMIERUNGEN.md (100%) rename dev/{dhl-modul => 2026-05-13-dhl-modul/legacy}/PAKET-INSTALLATION.md (100%) rename dev/{dhl-modul => 2026-05-13-dhl-modul/legacy}/PLAN-OPTIMIERT.md (100%) rename dev/{dhl-modul => 2026-05-13-dhl-modul/legacy}/README.md (100%) rename dev/{dhl-modul => 2026-05-13-dhl-modul/legacy}/SCHRITT-3-COMPLETED.md (100%) rename dev/{dhl-modul => 2026-05-13-dhl-modul}/parcel-de-shipping-v2_2.yaml (100%) rename {docs => dev}/BusinessUpdateCalculatedFields-Examples.sh (100%) rename {docs => dev}/BusinessUpdateCalculatedFields.md (100%) create mode 100644 resources/lang/fr.json create mode 100644 resources/lang/fr/abo.php create mode 100644 resources/lang/fr/abo_history.php create mode 100644 resources/lang/fr/account.php create mode 100644 resources/lang/fr/actions.php create mode 100644 resources/lang/fr/auth.php create mode 100644 resources/lang/fr/backend.php create mode 100644 resources/lang/fr/cal.php create mode 100644 resources/lang/fr/customer.php create mode 100644 resources/lang/fr/dataprotect.php create mode 100644 resources/lang/fr/dhl.php create mode 100644 resources/lang/fr/email.php create mode 100644 resources/lang/fr/gtc.php create mode 100644 resources/lang/fr/home.php create mode 100644 resources/lang/fr/homeparty.php create mode 100644 resources/lang/fr/incentive.php create mode 100644 resources/lang/fr/marketingplan.php create mode 100644 resources/lang/fr/membership.php create mode 100644 resources/lang/fr/msg.php create mode 100644 resources/lang/fr/navigation.php create mode 100644 resources/lang/fr/order.php create mode 100644 resources/lang/fr/pagination.php create mode 100644 resources/lang/fr/passwords.php create mode 100644 resources/lang/fr/payment.php create mode 100644 resources/lang/fr/pdf.php create mode 100644 resources/lang/fr/portal.php create mode 100644 resources/lang/fr/register.php create mode 100644 resources/lang/fr/reminder.php create mode 100644 resources/lang/fr/shop.php create mode 100644 resources/lang/fr/tables.php create mode 100644 resources/lang/fr/team.php create mode 100644 resources/lang/fr/validation.php create mode 100644 resources/lang/fr/webcontent.php create mode 100644 resources/lang/fr/weborder.php create mode 100644 resources/lang/fr/website.php create mode 100644 tests/Feature/BackfillFrenchDatabaseTranslationsTest.php create mode 100644 tests/Feature/FrenchLocalizationTest.php diff --git a/.devcontainer/devcontainer.json b/.devcontainer/devcontainer.json index a912347..a47958e 100644 --- a/.devcontainer/devcontainer.json +++ b/.devcontainer/devcontainer.json @@ -30,7 +30,8 @@ "LARAVEL_SAIL": "1" }, "mounts": [ - "source=${localWorkspaceFolder},target=/var/www/html,type=bind,consistency=cached" + "source=${localWorkspaceFolder},target=/var/www/html,type=bind,consistency=cached", + "source=/Users/pandora/Library/Mobile Documents/iCloud~md~obsidian/Documents/DEV-Vault/mivita,target=/var/www/html/docs,type=bind", ], // WICHTIG: Nur noch der Vite-Port muss weitergeleitet werden, den Rest macht das Mutterschiff. "forwardPorts": [ diff --git a/.env b/.env index 4e1ce7d..9fbc49f 100644 --- a/.env +++ b/.env @@ -177,3 +177,8 @@ DHL_SENDER_PHONE="+49 123 456789" DHL_API_TYPE=developer DHL_API_SECRET=OyoeePEbYmY1EuOG +# OpenAI API Key +OPENAI_API_KEY=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 +OPENAI_API_URL=https://api.openai.com/v1/chat/completions +OPENAI_MODEL=gpt-5.4-mini +OPENAI_TIMEOUT=60 diff --git a/app/Console/Commands/BackfillFrenchDatabaseTranslations.php b/app/Console/Commands/BackfillFrenchDatabaseTranslations.php new file mode 100644 index 0000000..548f247 --- /dev/null +++ b/app/Console/Commands/BackfillFrenchDatabaseTranslations.php @@ -0,0 +1,551 @@ +option('driver'); + $sourceLanguage = Str::lower((string) $this->option('source')); + $targetLanguage = Str::lower((string) $this->option('target')); + $dryRun = (bool) $this->option('dry-run'); + $overwrite = (bool) $this->option('overwrite'); + $limit = $this->option('limit') !== null ? (int) $this->option('limit') : null; + + if (! in_array($driver, ['openai', 'copy-source'], true)) { + $this->error('Unsupported driver. Use openai or copy-source.'); + + return self::FAILURE; + } + + if ($driver === 'openai' && blank(config('services.openai.api_key'))) { + $this->error('OPENAI_API_KEY is missing. Set it on the live server or use --driver=copy-source for a dry run.'); + + return self::FAILURE; + } + + if ((bool) $this->option('test-api')) { + return $this->runApiTest($driver, $sourceLanguage, $targetLanguage); + } + + $models = $this->selectedModels(); + + if ($models === []) { + $this->error('No valid models selected.'); + + return self::FAILURE; + } + + $this->ensureTargetLanguage($targetLanguage, $dryRun); + + $summary = [ + 'created' => 0, + 'updated' => 0, + 'skipped' => 0, + 'empty' => 0, + ]; + + foreach ($models as $modelName => $spec) { + $this->info("Processing {$modelName}..."); + + try { + $modelSummary = $this->backfillModel($modelName, $spec, $driver, $sourceLanguage, $targetLanguage, $overwrite, $dryRun, $limit); + } catch (RequestException $exception) { + $this->reportOpenAiException($exception); + + return self::FAILURE; + } + + foreach ($summary as $key => $value) { + $summary[$key] = $value + $modelSummary[$key]; + } + } + + $this->newLine(); + $this->info("Created: {$summary['created']}"); + $this->info("Updated: {$summary['updated']}"); + $this->info("Skipped existing: {$summary['skipped']}"); + $this->info("Skipped empty source: {$summary['empty']}"); + + return self::SUCCESS; + } + + private function runApiTest(string $driver, string $sourceLanguage, string $targetLanguage): int + { + if ($driver !== 'openai') { + $this->error('The API test requires --driver=openai.'); + + return self::FAILURE; + } + + $this->info('OpenAI translation API test'); + $this->line('Model: '.config('services.openai.model')); + $this->line("Language: {$sourceLanguage} -> {$targetLanguage}"); + $this->newLine(); + + foreach ($this->apiTestSamples() as $index => $sourceValue) { + try { + $translatedValue = $this->translateWithOpenAI($sourceValue, $sourceLanguage, $targetLanguage); + } catch (RequestException $exception) { + $this->reportOpenAiException($exception); + + return self::FAILURE; + } + + $this->line('['.($index + 1).'] DE: '.$sourceValue); + $this->line('['.($index + 1).'] FR: '.$translatedValue); + $this->newLine(); + } + + $this->info('API test completed.'); + + return self::SUCCESS; + } + + /** + * @return array + */ + private function apiTestSamples(): array + { + return [ + 'Aloe Vera Gel für die tägliche Pflege der Haut.', + 'Der Berater kann seinem Kunden ein passendes Abo empfehlen.', + 'MIVITA Produktbeschreibung mit :amount ml Inhalt und PayPal Zahlung.', + ]; + } + + private function reportOpenAiException(RequestException $exception): void + { + $response = $exception->response; + $status = $response->status(); + $errorCode = (string) $response->json('error.code'); + $errorType = (string) $response->json('error.type'); + $message = (string) ($response->json('error.message') ?: $exception->getMessage()); + + $this->error("OpenAI API request failed with HTTP {$status}."); + + if ($errorCode !== '') { + $this->line("Code: {$errorCode}"); + } + + if ($errorType !== '') { + $this->line("Type: {$errorType}"); + } + + $this->line("Message: {$message}"); + + if ($status === 429 || $errorCode === 'insufficient_quota') { + $this->warn('Bitte prüfe im OpenAI Dashboard das Billing, das Projekt-Budget, Usage-Limits und ob der OPENAI_API_KEY zum richtigen Projekt gehört.'); + } + } + + /** + * @param array $spec + * @return array{created: int, updated: int, skipped: int, empty: int} + */ + private function backfillModel( + string $modelName, + array $spec, + string $driver, + string $sourceLanguage, + string $targetLanguage, + bool $overwrite, + bool $dryRun, + ?int $limit + ): array { + $summary = [ + 'created' => 0, + 'updated' => 0, + 'skipped' => 0, + 'empty' => 0, + ]; + + $query = DB::table($spec['source_table']) + ->select(array_merge(['id'], $spec['fields'])) + ->orderBy('id'); + + if (Schema::hasColumn($spec['source_table'], 'deleted_at')) { + $query->whereNull('deleted_at'); + } + + if ($limit !== null && $limit > 0) { + $query->limit($limit); + } + + $totalRows = $this->countRows($spec, $limit); + $totalFields = count($spec['fields']); + $currentRow = 0; + + $this->line("Status {$modelName}: {$totalRows} Datensätze, {$totalFields} Felder."); + + foreach ($query->cursor() as $row) { + $currentRow++; + $this->line("Datensatz {$currentRow}/{$totalRows}: {$modelName}#{$row->id}"); + + foreach ($spec['fields'] as $field) { + $statusTarget = "{$modelName}#{$row->id}.{$field}"; + $sourceValue = trim((string) ($row->{$field} ?? '')); + + if ($sourceValue === '') { + $summary['empty']++; + $this->line(" - {$statusTarget}: Quelle leer, übersprungen."); + + continue; + } + + $existingValue = $this->existingTranslationValue($spec, (int) $row->id, $field, $targetLanguage); + + if (! $overwrite && filled($existingValue)) { + $summary['skipped']++; + $this->line(" - {$statusTarget}: vorhandene Übersetzung, übersprungen."); + + continue; + } + + $this->line(" - {$statusTarget}: ".$this->translationStatusText($field, $spec, $driver).'...'); + + $translatedValue = $this->translateValue($sourceValue, $field, $spec, $driver, $sourceLanguage, $targetLanguage); + + if ($dryRun) { + $action = $existingValue === null ? 'create' : 'update'; + $this->line(" - {$statusTarget}: [dry-run] würde {$action}."); + } else { + $this->storeTranslationValue($spec, (int) $row->id, $field, $targetLanguage, $translatedValue); + $action = $existingValue === null ? 'erstellt' : 'aktualisiert'; + $this->line(" - {$statusTarget}: gespeichert ({$action})."); + } + + if ($existingValue === null) { + $summary['created']++; + } else { + $summary['updated']++; + } + } + } + + return $summary; + } + + /** + * @param array $spec + */ + private function existingTranslationValue(array $spec, int $sourceId, string $field, string $targetLanguage): ?string + { + if (($spec['storage'] ?? 'table') === 'json') { + $translations = $this->jsonTranslations($spec, $sourceId, $field); + $value = $translations[$targetLanguage] ?? null; + + return $value === null ? null : trim((string) $value); + } + + return DB::table($spec['translation_table']) + ->where([ + 'language' => $targetLanguage, + $spec['foreign_key'] => $sourceId, + 'key' => $field, + ]) + ->value('value'); + } + + /** + * @param array $spec + */ + private function storeTranslationValue(array $spec, int $sourceId, string $field, string $targetLanguage, string $translatedValue): void + { + if (($spec['storage'] ?? 'table') === 'json') { + $translationColumn = 'trans_'.$field; + $translations = $this->jsonTranslations($spec, $sourceId, $field); + $translations[$targetLanguage] = $translatedValue; + + $data = [ + $translationColumn => json_encode($translations, JSON_UNESCAPED_UNICODE), + ]; + + if (Schema::hasColumn($spec['source_table'], 'updated_at')) { + $data['updated_at'] = now(); + } + + DB::table($spec['source_table']) + ->where('id', $sourceId) + ->update($data); + + return; + } + + DB::table($spec['translation_table'])->updateOrInsert( + [ + 'language' => $targetLanguage, + $spec['foreign_key'] => $sourceId, + 'key' => $field, + ], + [ + 'value' => $translatedValue, + 'created_at' => now(), + 'updated_at' => now(), + ] + ); + } + + /** + * @param array $spec + * @return array + */ + private function jsonTranslations(array $spec, int $sourceId, string $field): array + { + $translationColumn = 'trans_'.$field; + $value = DB::table($spec['source_table']) + ->where('id', $sourceId) + ->value($translationColumn); + + if (is_array($value)) { + return $value; + } + + $decodedValue = json_decode((string) $value, true); + + return is_array($decodedValue) ? $decodedValue : []; + } + + /** + * @param array $spec + */ + private function countRows(array $spec, ?int $limit): int + { + $query = DB::table($spec['source_table']); + + if (Schema::hasColumn($spec['source_table'], 'deleted_at')) { + $query->whereNull('deleted_at'); + } + + $count = $query->count(); + + if ($limit !== null && $limit > 0) { + return min($count, $limit); + } + + return $count; + } + + /** + * @param array $spec + */ + private function translationStatusText(string $field, array $spec, string $driver): string + { + if ($driver === 'copy-source' || in_array($field, $spec['copy_fields'] ?? [], true)) { + return 'übernehme Quelle'; + } + + return 'übersetze via OpenAI'; + } + + /** + * @param array $spec + */ + private function translateValue(string $sourceValue, string $field, array $spec, string $driver, string $sourceLanguage, string $targetLanguage): string + { + if ($driver === 'copy-source' || in_array($field, $spec['copy_fields'] ?? [], true)) { + return $sourceValue; + } + + return $this->translateWithOpenAI($sourceValue, $sourceLanguage, $targetLanguage); + } + + private function translateWithOpenAI(string $sourceValue, string $sourceLanguage, string $targetLanguage): string + { + [$preparedValue, $protectedValues] = $this->protectTerms($sourceValue); + + $payload = [ + 'model' => config('services.openai.model'), + 'temperature' => 0.1, + 'messages' => [ + [ + 'role' => 'system', + 'content' => implode(' ', [ + 'You translate ecommerce and MLM CRM content for mivita.care.', + 'Translate from German to French unless another source/target language is requested.', + 'Return only the translated text, without quotes, notes, markdown, explanations, or alternative variants.', + 'Preserve HTML tags, URLs, numbers, units, placeholders, and tokens like __MIVITA_TRANSLATION_TOKEN_0__ exactly.', + 'Keep brand names and protected product terms unchanged.', + 'Use consistent terminology: Berater = conseiller, Kunde = client, Abo = abonnement.', + ]), + ], + [ + 'role' => 'user', + 'content' => "Source language: {$sourceLanguage}\nTarget language: {$targetLanguage}\nText:\n{$preparedValue}", + ], + ], + ]; + + $response = Http::withToken((string) config('services.openai.api_key')) + ->acceptJson() + ->timeout((int) config('services.openai.timeout', 60)) + ->retry(2, 1000, function ($exception): bool { + if ($exception instanceof RequestException && $exception->response->status() === 429) { + return false; + } + + return true; + }) + ->post((string) config('services.openai.url'), $payload) + ->throw() + ->json('choices.0.message.content'); + + return $this->restoreTerms(trim((string) $response), $protectedValues); + } + + /** + * @return array{0: string, 1: array} + */ + private function protectTerms(string $value): array + { + $protectedValues = []; + $patterns = [ + '/(:[A-Za-z_][A-Za-z0-9_-]*)/u', + '/(\{\{\s*[^}]+\s*\}\})/u', + '/\b(MIVITA|PAYONE|PayPal|DHL|INCI|CBD|GRÜNE SEELE|Aloe Vera)\b/u', + ]; + + foreach ($patterns as $pattern) { + $value = preg_replace_callback($pattern, function (array $matches) use (&$protectedValues): string { + $token = '__MIVITA_TRANSLATION_TOKEN_'.count($protectedValues).'__'; + $protectedValues[$token] = $matches[1]; + + return $token; + }, $value); + } + + return [$value, $protectedValues]; + } + + /** + * @param array $protectedValues + */ + private function restoreTerms(string $value, array $protectedValues): string + { + return str_replace(array_keys($protectedValues), array_values($protectedValues), $value); + } + + private function ensureTargetLanguage(string $targetLanguage, bool $dryRun): void + { + if ($dryRun) { + $this->line("[dry-run] ensure trans_languages.{$targetLanguage}"); + + return; + } + + DB::table('trans_languages')->updateOrInsert( + ['language' => $targetLanguage], + [ + 'name' => $targetLanguage === 'fr' ? 'Französisch' : Str::upper($targetLanguage), + 'created_at' => now(), + 'updated_at' => now(), + ] + ); + } + + /** + * @return array> + */ + private function selectedModels(): array + { + $availableModels = $this->translationModels(); + $selectedModels = $this->option('models'); + + if (blank($selectedModels)) { + return $availableModels; + } + + return collect(explode(',', (string) $selectedModels)) + ->map(fn (string $model): string => trim($model)) + ->filter() + ->mapWithKeys(fn (string $model): array => [$model => $availableModels[$model] ?? null]) + ->filter() + ->all(); + } + + /** + * @return array> + */ + private function translationModels(): array + { + return [ + 'products' => [ + 'source_table' => 'products', + 'translation_table' => 'trans_products', + 'foreign_key' => 'product_id', + 'fields' => ['name', 'copy', 'description', 'usage', 'ingredients'], + ], + 'ingredients' => [ + 'source_table' => 'ingredients', + 'translation_table' => 'trans_ingredients', + 'foreign_key' => 'ingredient_id', + 'fields' => ['name', 'inci', 'effect'], + 'copy_fields' => ['inci'], + ], + 'categories' => [ + 'source_table' => 'categories', + 'translation_table' => 'trans_categories', + 'foreign_key' => 'categorie_id', + 'fields' => ['name', 'headline'], + ], + 'shippings' => [ + 'source_table' => 'shippings', + 'translation_table' => 'trans_shippings', + 'foreign_key' => 'shipping_id', + 'fields' => ['name'], + ], + 'user_levels' => [ + 'source_table' => 'user_levels', + 'translation_table' => 'trans_user_levels', + 'foreign_key' => 'user_level_id', + 'fields' => ['name'], + ], + 'dashboard_news' => [ + 'storage' => 'json', + 'source_table' => 'dashboard_news', + 'fields' => ['title', 'teaser', 'content'], + ], + ]; + } +} diff --git a/config/localization.php b/config/localization.php index ee1f25a..1de5f2b 100644 --- a/config/localization.php +++ b/config/localization.php @@ -52,7 +52,7 @@ return [ // 'ewo' => ['name' => 'Ewondo', 'script' => 'Latn', 'native' => 'ewondo', 'regional' => ''], // 'ee' => ['name' => 'Ewe', 'script' => 'Latn', 'native' => 'eʋegbe', 'regional' => ''], // 'fil' => ['name' => 'Filipino', 'script' => 'Latn', 'native' => 'Filipino', 'regional' => 'fil_PH'], - // 'fr' => ['name' => 'French', 'script' => 'Latn', 'native' => 'français', 'regional' => 'fr_FR'], + 'fr' => ['name' => 'French', 'script' => 'Latn', 'native' => 'français', 'regional' => 'fr_FR'], // 'fr-CA' => ['name' => 'Canadian French', 'script' => 'Latn', 'native' => 'français canadien', 'regional' => 'fr_CA'], // 'fy' => ['name' => 'Western Frisian', 'script' => 'Latn', 'native' => 'frysk', 'regional' => 'fy_DE'], // 'fur' => ['name' => 'Friulian', 'script' => 'Latn', 'native' => 'furlan', 'regional' => 'fur_IT'], diff --git a/config/services.php b/config/services.php index aa1f7f8..d5219f2 100755 --- a/config/services.php +++ b/config/services.php @@ -35,4 +35,11 @@ return [ 'secret' => env('STRIPE_SECRET'), ], + 'openai' => [ + 'api_key' => env('OPENAI_API_KEY'), + 'url' => env('OPENAI_API_URL', 'https://api.openai.com/v1/chat/completions'), + 'model' => env('OPENAI_MODEL', 'gpt-5.4-mini'), + 'timeout' => env('OPENAI_TIMEOUT', 60), + ], + ]; diff --git a/database/migrations/2026_05_12_113939_add_french_locale_to_trans_languages.php b/database/migrations/2026_05_12_113939_add_french_locale_to_trans_languages.php new file mode 100644 index 0000000..cf576d9 --- /dev/null +++ b/database/migrations/2026_05_12_113939_add_french_locale_to_trans_languages.php @@ -0,0 +1,32 @@ +updateOrInsert( + ['language' => 'fr'], + [ + 'name' => 'Französisch', + 'created_at' => now(), + 'updated_at' => now(), + ] + ); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + DB::table('trans_languages') + ->where('language', 'fr') + ->delete(); + } +}; diff --git a/dev/2026-05-13-backoffice/ENTWICKLUNGSKONZEPT-BACKOFFICE.md b/dev/2026-05-13-backoffice/ENTWICKLUNGSKONZEPT-BACKOFFICE.md new file mode 100644 index 0000000..503f52f --- /dev/null +++ b/dev/2026-05-13-backoffice/ENTWICKLUNGSKONZEPT-BACKOFFICE.md @@ -0,0 +1,417 @@ +# Entwicklungskonzept: Backoffice Dashboard & Interaktivität + +Datum: 13.05.2026 + +Quelle: `docs/salescenter/Todos Backoffice.md` + +## Zielbild + +Das Partner-Backoffice soll von einer statischen Monats-Kachelansicht zu einer klickbaren, linienbasierten Statistik ausgebaut werden. Führungskräfte sollen von der Gesamtübersicht über Linien und Generationen bis zu einzelnen Kunden, Partnern und Abos navigieren können. + +Kernziel ist eine einheitliche Datenbasis für: + +- Dashboard-Kennzahlen pro Linie 1 bis 8 inklusive Summenzeile +- Detailansichten je Linie, Firstline und Kennzahl +- Kundenabos, Teamabos, Kundenabos im Team, Umsatz und Punkte +- neue Spezial-Kennzahl "1000 Punkte Shop" +- saubere Begrifflichkeit und rechtssichere Sichtbarkeit in Incentive-Ranglisten + +## Bestandsaufnahme im System + +### Dashboard + +Aktueller Einstieg: + +- Route: `GET /home` +- Controller: `App\Http\Controllers\HomeController@show` +- View: `resources/views/home.blade.php` +- Statistik-Partial: `resources/views/dashboard/_statistics.blade.php` + +Die aktuelle Statistik wird direkt im Blade-Partial berechnet. Sie nutzt unter anderem: + +- `App\Models\UserBusiness` für gespeicherte Monatswerte und Payline-Punkte +- `App\Models\UserSalesVolume` für Kunden-/Shop-Punkte +- `App\Models\UserAbo` für eigene Abos, Kundenabos und Teamabos +- rekursive Sponsor-Abfragen über `users.m_sponsor` + +Bewertung: + +- Die vorhandene Ansicht ist ein guter Einstieg, aber fachlich zu grob. +- Es gibt noch keine Linien 1 bis 8, keine Summenzeile und keine Drill-down-Routen. +- Die Kennzahl "Kundenabos" vermischt aktuell eigene Abos und Kundenabos. +- "Team-Abos" zählt aktuell Beraterabos im Team, aber nicht die separat geforderten Kundenabos im Team. + +### Team- und Abo-Bereich + +Relevante Routen und Controller: + +- `User\TeamController@structure` für Strukturansicht +- `User\TeamController@show` und `datatableOptimized` für Teamliste +- `User\TeamController@showAbos` für Team-Beraterabos +- `User\TeamController@showTeamCustomerAbos` für Kundenabos im Team +- `User\TeamController@detailAbo` für Abo-Details + +Relevante Daten: + +- `UserAbo::is_for = 'me'` bedeutet Berater-/Eigenabo +- `UserAbo::is_for = 'ot'` bedeutet Kundenabo +- `UserAbo::user_id` ist der Bestell-/Abo-User +- `UserAbo::member_id` ist der zugeordnete Berater +- `UserAbo::next_date` liefert die nächste Abo-Ausführung +- `UserAbo::getTotalPoints()` berechnet Punkte aus Abo-Items und Produkten +- `AboHelper::getTeamUserIds()` liefert Downline-User-IDs +- `AboHelper::getMonthlyAboCounts()` kennt bereits die Scopes `team_abos` und `team_cust_abos` + +Bewertung: + +- Viele Datenquellen für die geforderten Listen existieren bereits. +- Die Logik ist aber auf mehrere Controller, Blades und Helper verteilt. +- Für das neue Dashboard sollte die Logik zentralisiert werden, statt weitere Berechnungen in Blade-Dateien zu ergänzen. + +### Business- und Punkteberechnung + +Relevante Bausteine: + +- `App\Services\BusinessPlan\TreeCalcBotOptimized` +- `App\Models\UserBusiness` +- `App\Models\UserSalesVolume` +- `App\Services\BusinessPlan\SalesPointsVolume` + +Bewertung: + +- Für Monats- und Teamwerte sollte bevorzugt auf gespeicherte Businessdaten zurückgegriffen werden. +- Live-Berechnung über TreeCalcBot ist als Fallback sinnvoll, darf aber nicht bei jedem Seitenaufruf ungefiltert große Strukturen neu berechnen. +- Die neue Drill-down-Logik benötigt klare Regeln, ob sie Live-Daten oder gespeicherte Monats-Snapshots zeigt. + +### Incentives + +Relevante Dateien: + +- `App\Http\Controllers\User\IncentiveController` +- `resources/views/user/incentive/show.blade.php` +- `App\Models\IncentiveParticipant` +- `App\Services\Incentive\IncentiveTracker` + +Status: + +- Teilnahme-Opt-in existiert über `accepted_terms_at`. +- Ranking wird aktuell mit `paginate(100)` angezeigt. +- Nicht zustimmende Teilnehmer werden für normale User anonymisiert. +- VIP-Ansicht sieht zusätzliche Hinweise zur Zustimmung. + +Lücke zum Briefing: + +- Es gibt noch keine separate Zustimmung "Name/Fotos/Land in Rangliste sichtbar". +- Foto und Land sind in der aktuellen Rangliste nicht als sichtbare Standardspalten umgesetzt. +- Rechtliche Freigabe muss fachlich vor der technischen Umsetzung geklärt werden. + +### Checkout-Herkunft + +Relevante Dateien: + +- `App\Http\Controllers\Web\CheckoutController` +- `App\Repositories\CheckoutRepository` +- `App\Models\ShoppingUser` + +Status: + +- `CheckoutController::validateCheckoutData()` validiert Basisdaten, aber keine Pflichtfrage "Von wem hast du von Mivita erfahren?" +- `ShoppingUser` nutzt `is_from` für den technischen Ursprung wie `shopping`, `homeparty` oder `collection`. +- Ein fachliches Herkunfts-/Empfehlungsfeld existiert nicht als sauber getrennte Datenquelle. + +### Storno und Punkterückführung + +Relevante Dateien: + +- `App\Repositories\InvoiceRepository` +- `App\Services\BusinessPlan\SalesPointsVolume` +- `App\Services\Incentive\IncentiveTracker` + +Status: + +- Beim Erstellen einer Stornorechnung ruft `InvoiceRepository::createCancellation()` die Punktekorrektur auf. +- `SalesPointsVolume::cancelSalesPointsVolume()` erstellt einen negativen `UserSalesVolume`-Eintrag mit Status `6`. +- Danach wird der Monat neu berechnet und `IncentiveTracker::trackStorno()` informiert. + +Risiken: + +- Wenn zur Originalrechnung kein `UserSalesVolume` existiert, wird nur geloggt und keine Punktekorrektur erstellt. +- Der negative Storno-Eintrag wird aktuell dem aktuellen Monat zugeordnet, nicht zwingend dem ursprünglichen Umsatzmonat. +- Fachlich muss entschieden werden, ob Stornos im Stornomonat oder im Ursprungsmonat wirken sollen. + +## Fachliche Prüfung der To-dos + +### 1. Überarbeitetes Dashboard & KPI-Übersicht + +Umsetzung sinnvoll, aber nicht als Erweiterung des bestehenden Blade-Partials. Benötigt wird ein dedizierter Service, der die Kennzahlen pro Linie liefert. + +Vorgeschlagene Tabelle Stufe 1: + +- Linie +- Anzahl Berater +- Umsatz gesamt +- Eigen-/Beraterabos im Team +- eigene Kundenabos +- Kundenabos im Team +- Neupartner +- 1000 Punkte Shop +- Summe + +Wichtig: + +- "Teamkundenabos" sollte als Begriff für Kundenabos der Downline verwendet werden. +- "Teamabos" sollte nur für Berater-/Eigenabos im Team verwendet werden. +- Eigene Abos dürfen nicht in Kundenabos eingerechnet werden. + +### 2. Interaktivität & Deep Dive + +Umsetzung über eigene Backoffice-Statistik-Routen statt über große Modals im Dashboard. + +Vorgeschlagene Stufen: + +- Stufe 1: Linienübersicht `/user/backoffice/statistics` +- Stufe 2: Linien-/Firstline-Detail `/user/backoffice/statistics/line/{line}` +- Stufe 3: Kennzahlenliste `/user/backoffice/statistics/details?line=...&metric=...&user=...` + +Jede Zahl erhält einen Link auf eine gefilterte Detailansicht. Die Detailansicht sollte die Query-Parameter sichtbar halten, damit Support und Fachbereich Ergebnisse reproduzieren können. + +### 3. Spezial-Kennzahl "1000 Punkte Shop" + +Definition muss vor Umsetzung final geklärt werden. + +Vorschlag für Version 1: + +- Zeitraum: gewählter Monat/Jahr des Dashboards +- Personenkreis: Downline des eingeloggten Users +- Schwelle: `UserSalesVolume` Shop-/KP-Summe pro Partner >= 1000 Punkte +- Sortierung: Volumen absteigend +- Detailspalten: Name, Account, Linie/Generation, Shop-Punkte, Gesamt-KP, Umsatz netto + +Offene Fachfrage: + +- Meint "Kundenumsatz" nur Shop-Umsatz (`month_shop_points`) oder alle Kundenpunkte inklusive Abo-/Beraterkontext? + +### 4. Bestellformular: "Von wem hast du von Mivita erfahren?" + +Technisch sollte ein neues fachliches Feld eingeführt werden, nicht `is_from` zweckentfremdet werden. + +Vorschlag: + +- Migration für `shopping_users.referral_source_name` oder ähnliches Feld +- optional zusätzlich auf `shopping_orders`, wenn die Information revisionssicher pro Bestellung eingefroren werden soll +- Pflichtvalidierung in `CheckoutController::validateCheckoutData()` +- Anzeige im Checkout-Formular und optional im Admin-/Bestelldetail +- später exportierbar für Marketingauswertung + +Zu klären: + +- Freitext oder Auswahl plus Freitext? +- Pflicht nur im Webshop/Bestelllink oder auch bei Salescenter-, Homeparty- und Collection-Flows? +- Soll eine konkrete Beraterzuordnung daraus entstehen oder nur Tracking? + +### 5. Stornoprozess + +Die Grundlogik existiert. Vor einer fachlichen Freigabe braucht es Tests und eine Periodenentscheidung. + +Prüfpunkte: + +- Storno einer Beraterbestellung erzeugt negative Punkte beim richtigen Berater. +- Storno einer Kunden-/Shopbestellung erzeugt negative Punkte im richtigen Umsatztyp. +- Storno einer Abo-Bestellung wirkt korrekt auf Incentive-Logs. +- Storno ohne vorhandenen `UserSalesVolume` ist sichtbar und lösbar, nicht nur ein Log-Eintrag. +- Businessdaten nach Storno werden für betroffenen Monat/Jahr aktualisiert oder als neu zu berechnen markiert. + +### 6. Rechtliches & Sichtbarkeit in Incentives + +Die bestehende Teilnahmezustimmung sollte nicht automatisch als Freigabe für öffentliche Namens-/Fotoanzeige interpretiert werden. + +Vorschlag: + +- eigenes Feld auf `incentive_participants`, z. B. `ranking_visibility_accepted_at` +- Button/Checkbox in der Incentive-Seite: "Ich stimme zu, dass mein Name, Foto und Land in der Rangliste sichtbar sind" +- Anzeige von Name, Foto und Land nur, wenn die Zustimmung vorliegt oder ein Admin/VIP die Ansicht nutzt +- nach juristischer Klärung kann die Regel angepasst werden + +Technische Ergänzungen: + +- Ranking um Foto und Land erweitern +- Pagination/Limit bewusst definieren: alle Teilnehmer anzeigen, aber paginiert +- Übersetzungen in `resources/lang/*/incentive.php` ergänzen + +### 7. Multimedia-Bereich / Event-Archiv + +Es gibt bereits Dashboard-News und ein News-Archiv. Für Events sind zwei Varianten möglich: + +Variante A: `DashboardNews` um Typ "event" erweitern + +- weniger Aufwand +- bestehendes Admin-Modul kann wiederverwendet werden +- geeignet, wenn Events im gleichen Format wie News funktionieren + +Variante B: eigenes Event-Modul + +- sauberere Trennung +- eigene Felder für Galerie, Eventdatum, Call-/Foto-Typ +- sinnvoll, wenn Uploads, mehrere Bilder pro Event oder Kategorien benötigt werden + +Empfehlung: Variante A als MVP, falls keine komplexe Galerieverwaltung benötigt wird. + +## Technisches Zielkonzept + +### Neue zentrale Services + +Empfohlen: + +- `App\Services\Backoffice\BackofficeDashboardService` +- `App\Services\Backoffice\BackofficeDrilldownService` + +Aufgaben `BackofficeDashboardService`: + +- Zeitraum normalisieren +- Downline pro Linie aufbauen +- Summen pro Linie berechnen +- Daten für Stufe 1 und Stufe 2 liefern +- Caching-/Snapshot-Strategie kapseln + +Aufgaben `BackofficeDrilldownService`: + +- Kennzahlenfilter aus Request validieren +- Personen-/Abo-/Umsatzlisten erzeugen +- Berechtigungen gegen Downline prüfen +- einheitliche Summary für Listen liefern + +### Controller und Views + +Empfohlen: + +- `App\Http\Controllers\User\BackofficeStatisticsController` +- Views unter `resources/views/user/backoffice/statistics/` + +Geplante Actions: + +- `index()` für Stufe 1 +- `line(int $line)` für Stufe 2 +- `details()` für Stufe 3 +- optional `export()` für CSV/Excel später + +Die vorhandenen Team-Views bleiben bestehen. Das neue Dashboard verweist für Detailseiten aber auf eigene, schlankere Statistik-Views. + +### Datenmodell und Definitionen + +Einheitliche Metriken: + +- `consultants`: aktive Berater in Linie +- `own_abos`: eigene Beraterabos des eingeloggten Users +- `team_partner_abos`: Beraterabos im Team (`is_for = 'me'`) +- `direct_customer_abos`: Kundenabos des betrachteten Beraters (`member_id = user_id`, `is_for = 'ot'`) +- `team_customer_abos`: Kundenabos der Downline-Berater (`member_id in teamUserIds`, `is_for = 'ot'`) +- `new_partners`: neue aktive Partner im Zeitraum +- `turnover_points`: Punkte aus `UserSalesVolume`/`UserBusiness` +- `turnover_net`: Netto-Umsatz aus `UserSalesVolume`/`UserBusiness` +- `shop_1000`: Partner mit Kunden-/Shop-Punkten >= 1000 + +### Berechtigungen + +Jede Detailansicht muss sicherstellen: + +- Der eingeloggte User sieht nur eigene Downline-Daten. +- Ein direkt angefragter `user_id` muss per Sponsor-Hierarchie im Team liegen. +- Kundenabos werden nur in dem Umfang angezeigt, der fachlich für Berater vorgesehen ist. +- Datenschutzrelevante Kundendaten sollten auf das notwendige Minimum reduziert werden. + +## Umsetzungsphasen + +### Phase 1: Begriffe, Datenbasis und MVP Dashboard + +- Fachliche Definitionen finalisieren +- `BackofficeDashboardService` erstellen +- Stufe-1-Linienübersicht mit Linien 1 bis 8 und Summenzeile bauen +- bestehende Dashboard-Kachel durch Link auf neue Statistikseite ergänzen oder neue Seite im Menü aufnehmen +- Kennzahlen noch ohne vollständigen Deep Dive, aber bereits sauber berechnet + +### Phase 2: Drill-down Stufe 2 und Stufe 3 + +- Linien-Detailansicht pro Firstline bauen +- Detailansichten je Kennzahl bauen +- Abo-Listen mit Name, Punktewert, nächster Ausführung und Anzahl Lieferungen anzeigen +- Links aus jeder Kennzahl setzen +- leere Zustände und Summenzeilen ergänzen + +### Phase 3: 1000 Punkte Shop + +- fachliche Definition final bestätigen +- Query und Summary implementieren +- Widget in Übersicht ergänzen +- Detailansicht mit Sortierung nach Volumen absteigend bauen + +### Phase 4: Herkunftsabfrage im Checkout + +- Migration und Model-Fillable ergänzen +- Checkout-Formular erweitern +- Validierung und Speicherung ergänzen +- Admin-/Bestelldetail oder Export um Feld erweitern + +### Phase 5: Storno-Qualitätssicherung + +- Tests für vorhandene Storno-Punktepfade ergänzen +- Fachentscheidung zur Periodenlogik dokumentieren +- Fehlerfall ohne Original-`UserSalesVolume` sichtbar machen +- ggf. Businessdaten-Neuberechnung nach Storno anstoßen + +### Phase 6: Incentive-Sichtbarkeit und Event-Archiv + +- rechtliche Entscheidung einarbeiten +- separates Ranking-Sichtbarkeits-Opt-in ergänzen +- Foto/Land im Ranking anzeigen +- Event-Archiv als `DashboardNews`-Typ oder eigenes Modul umsetzen + +## Teststrategie + +Feature-Tests: + +- Dashboard zeigt nur Daten der eigenen Downline. +- Linien 1 bis 8 werden korrekt gruppiert. +- Summenzeile entspricht Summe der Linien. +- Klick auf Teamabos zeigt nur `is_for = 'me'` in der Downline. +- Klick auf Kundenabos im Team zeigt nur `is_for = 'ot'` mit `member_id` in der Downline. +- 1000-Punkte-Shop listet nur Partner über Schwelle und sortiert absteigend. +- Checkout verlangt die Herkunftsabfrage und speichert sie. +- Storno erzeugt negativen `UserSalesVolume`-Eintrag und aktualisiert Monatswerte. +- Incentive-Ranking zeigt Name/Foto/Land nur nach passender Zustimmung. + +Unit-Tests: + +- Service aggregiert Linien korrekt. +- Service verhindert Zugriff auf fremde Team-User. +- Metrikdefinitionen liefern stabile Counts bei aktiven, gekündigten und zukünftigen Abos. + +Regressionsprüfung: + +- bestehende Teamseiten `user.team.*` +- bestehende Abo-Seiten +- bestehendes Incentive-Ranking +- Checkout für Webshop, Bestelllink und Salescenter-Flows + +## Offene Fachfragen + +1. Soll die neue Statistik die aktuelle Monatslogik nutzen oder standardmäßig den letzten abgeschlossenen Monat zeigen? +2. Sollen Stornos im Stornomonat oder im ursprünglichen Umsatzmonat gegengerechnet werden? +3. Wie genau wird "1000 Punkte Shop" definiert: nur Shop-Punkte, alle Kundenpunkte oder Kundenabos plus Einzelbestellungen? +4. Welche Kundendaten dürfen Berater in Deep-Dive-Listen sehen? +5. Ist die Herkunftsabfrage Freitext, Auswahlfeld oder Kombination? +6. Gilt die Herkunftsabfrage für alle Checkout-Flows oder nur für externe Kundenbestellungen? +7. Darf eine Incentive-Teilnahme bereits Name/Foto/Land freigeben oder braucht es ein separates Opt-in? +8. Soll das Event-Archiv nur Bilder und Texte enthalten oder eine echte Galerie mit Mehrfachuploads? + +## Empfehlung + +Die Backoffice-Statistik sollte als eigenes kleines Modul im User-Bereich umgesetzt werden, nicht als weitere Logik in `dashboard/_statistics.blade.php`. Die vorhandenen Datenquellen sind ausreichend für ein MVP, aber sie müssen zentral aggregiert, fachlich sauber benannt und über berechtigte Drill-down-Routen zugänglich gemacht werden. + +Priorität für die erste Umsetzung: + +1. Daten- und Begriffsdefinitionen finalisieren +2. zentrale Services und Stufe-1-Linienübersicht +3. Drill-down für Abos und Neupartner +4. 1000-Punkte-Shop +5. Checkout-Herkunft und Storno-Tests +6. Incentive-Sichtbarkeit und Event-Archiv diff --git a/dev/2026-05-13-dhl-modul/ENTWICKLUNGSKONZEPT-DHL-MODUL.md b/dev/2026-05-13-dhl-modul/ENTWICKLUNGSKONZEPT-DHL-MODUL.md new file mode 100644 index 0000000..ffdbf87 --- /dev/null +++ b/dev/2026-05-13-dhl-modul/ENTWICKLUNGSKONZEPT-DHL-MODUL.md @@ -0,0 +1,190 @@ +# Entwicklungskonzept DHL Modul + +Stand: 13.05.2026 + +## Ziel + +Diese Datei ist die aktuelle Arbeitsgrundlage fuer die Weiterentwicklung des DHL Moduls. Die bisherigen Markdown-Dateien in diesem Ordner dokumentieren abgeschlossene oder ueberholte Zwischenstaende, insbesondere den frueheren SDK-Ansatz, Paketinstallation, SSL/cURL-Fixes und einzelne abgeschlossene Entwicklungsschritte. + +Aktuelle Anforderungen kommen aus `docs/dhl/Anpassung DHL Modul.md`: + +- Internationaler Versand ausserhalb Deutschlands, insbesondere Oesterreich und Spanien. +- Freies Feld fuer Sendungsreferenz oder interne Hinweise wie "Nachlieferung". +- Adressvalidierung vor Labelerstellung, damit fehlerhafte Labels und kostenpflichtige Stornos vermieden werden. +- Storno im DHL Cockpit pruefen und stabilisieren. +- Gewicht von Kompensationsprodukten in das DHL-Paketgewicht einrechnen. +- Tracking-Mails auf Rhythmus, Ausloeser und Mehrfachversand pruefen. +- Tracking-Codes in Admin, User-Portal und User-N-Portal sichtbar machen. +- Tracking-Mail-Versand nur bei Statusaenderung. +- DHL-Umstellung von `V62WP` Warenpost auf `V62KP` DHL Kleinpaket bis spaetestens 31.05.2026. + +## Aktueller technischer Stand + +Das produktive Modul basiert auf dem Paket `packages/acme-laravel-dhl` und verwendet die DHL REST API ueber `Acme\Dhl\Support\DhlClient`. Die zentrale Tabelle ist `dhl_package_shipments`. + +Wichtige produktive Einstiegspunkte: + +- `app/Http/Controllers/DhlShipmentController.php` +- `app/Services/DhlShipmentService.php` +- `app/Services/DhlModalService.php` +- `app/Services/DhlDataHelper.php` +- `app/Services/DhlTrackingService.php` +- `packages/acme-laravel-dhl/src/Services/ShippingService.php` +- `packages/acme-laravel-dhl/src/Services/ReturnsService.php` +- `packages/acme-laravel-dhl/src/Models/DhlShipment.php` +- `config/dhl.php` + +Historische Dokumente erwaehnen teilweise alte Strukturen wie `App\Models\DhlShipment`, `dhl_shipments` oder einen konsolidierten `DhlApiService`. Diese Angaben sind nicht mehr massgeblich, ausser sie werden explizit in aktuellem Code noch referenziert. Aktuell gibt es genau dort noch Altlasten, die bereinigt werden muessen. + +## Offensichtliche Befunde + +### 1. Produktkuerzel `V62WP` ist veraltet + +`V62WP` ist weiterhin in Konfiguration, Admin-Settings, Validierung, Produktauswahl und Sprachdateien vorhanden. DHL verlangt die Umstellung auf `V62KP`. + +Betroffene Stellen: + +- `config/dhl.php` +- `app/Http/Controllers/SettingController.php` +- `app/Services/DhlModalService.php` +- `packages/acme-laravel-dhl/src/Services/ShippingService.php` +- `resources/lang/*/dhl.php` + +Risiko: Kleinpaket/Warenpost-Labels koennen nach der DHL-Frist abgelehnt werden. + +### 2. Async Tracking verwendet veraltetes Model + +`app/Jobs/TrackShipmentJob.php` importiert `App\Models\DhlShipment`, dieses Model existiert im aktuellen System nicht mehr. Die produktive DHL-Integration verwendet `Acme\Dhl\Models\DhlShipment`. + +Risiko: Asynchrones Tracking bricht zur Laufzeit, sobald Queue-Tracking genutzt wird. + +### 3. Statuswerte fuer Storno sind inkonsistent + +Im Paket wird bei erfolgreichem Storno `canceled` gesetzt. Andere Stellen, Uebersetzungen und historische Dokumente verwenden `cancelled`. + +Risiko: Filter, Badges, Terminal-Status, Uebersetzungen und Storno-Buttons verhalten sich uneinheitlich. + +### 4. Storno ist technisch vorhanden, aber nicht robust genug + +Storno laeuft ueber `DELETE /parcel/de/shipping/v2/orders/{shipmentNumber}`. Der Code prueft `canCancel()`, speichert aber Fehler nur begrenzt fachlich verwertbar. Produktspezifische Einschraenkungen wie Warenpost/Kleinpaket sind nicht sauber modelliert. + +Risiko: Anwender sehen generische Fehler und koennen nicht erkennen, ob ein Storno produktbedingt, statusbedingt, API-bedingt oder wegen Sandbox/Production-Mismatch scheitert. + +### 5. Internationaler Versand ist nur teilweise vorbereitet + +`V53PAK` ist als internationales Produkt vorhanden, und einige Laender werden in 3-stellige ISO-Codes konvertiert. Dennoch gibt es keinen zentralen Produktentscheid je Zielland, keine harte Validierung nicht unterstuetzter Laender und einen gefaehrlichen Fallback auf `DEU`. + +Risiko: Sendungen nach Oesterreich, Spanien oder weitere Laender koennen falsche Produktcodes, falsche Abrechnungsnummern oder falsche Laendercodes erhalten. + +### 6. Adressvalidierung ist nur formal + +Aktuell prueft das Modul Pflichtfelder, Hausnummern und einfache PLZ-Regeln. Eine echte Pruefung, ob Strasse, PLZ und Ort postalisch existieren, findet nicht statt. + +Empfohlene Loesung: DHL/Post & DHL `DATAFACTORY AUTOCOMPLETE 2.0` fuer DE/AT/CH pruefen und integrieren. Alternativen fuer breiteren Laenderumfang: Loqate, Google Address Validation oder HERE. Wichtig ist eine harte Sperre bei nicht versandfaehigen Adressen vor Labelerstellung. + +### 7. Referenzfeld ist API-seitig vorhanden, aber nicht im Cockpit nutzbar + +`ShippingService` kann `reference` nach `refNo` mappen. `DhlDataHelper` setzt aktuell automatisch `Order-{id}`. + +Risiko: Admins koennen Hinweise wie "Nachlieferung" nicht strukturiert am Label/API-Auftrag hinterlegen. + +### 8. Gewicht von Kompensationsprodukten fehlt + +Kompensationsprodukte werden im Warenkorb mit Gewicht `0` abgelegt, damit die Kompensationslogik nicht beeinflusst wird. Das DHL-Gewicht kommt aus `ShoppingOrder->weight` und enthaelt dieses Produktgewicht dadurch nicht. + +Risiko: DHL-Label wird mit zu geringem Paketgewicht erstellt. + +### 9. Tracking-Mail-Logik ist grundsaetzlich brauchbar, muss aber abgesichert werden + +Der Scheduler ruft stuendlich `dhl:update-tracking --days=30 --send-emails` auf. Statusabhaengige Intervalle verhindern zu viele API-Calls. Automatische Mails werden nur gesendet, wenn eine Sendung neu auf `in_transit` wechselt und noch keine Mail markiert wurde. + +Risiko: Statusspruenge direkt auf `out_for_delivery` oder besondere DHL-Statuscodes koennen ohne Mail bleiben. Mehrere Sendungen einer Bestellung werden teils zusammengefasst, aber die fachliche Regel muss final bestaetigt werden. + +## Entwicklungskonzept + +### Phase 1: Pflichtkorrekturen vor DHL-Frist + +1. `V62WP` vollstaendig auf `V62KP` migrieren. +2. Neue Konfigurationskeys fuer `DHL_ACCOUNT_NUMBER_V62KP`, Admin-Setting `dhl_account_v62kp`, Dimensionen und Uebersetzungen einfuehren. +3. `ShippingService` Validierung um `V62KP` erweitern und `V62WP` entfernen oder nur noch als Legacy-Mapping fuer Altdaten anzeigen. +4. Bestehende Sendungen mit `V62WP` historisch lesbar lassen, aber neue Labelerstellung blockieren. +5. Tests fuer Produktcode-Auswahl, Validierung und Payload-Erstellung schreiben. + +### Phase 2: Stabilisierung von Tracking und Storno + +1. `TrackShipmentJob` auf aktuelles Model und aktuellen `DhlTrackingService` umstellen. +2. Statuswerte vereinheitlichen. Empfehlung: intern `canceled` verwenden, Uebersetzung auf Deutsch "Storniert". +3. `TERMINAL_STATUSES`, Badges, Filter und Sprachdateien entsprechend angleichen. +4. Storno-Fehler strukturiert speichern: HTTP-Status, DHL-Fehlercode, DHL-Detailtext, Zeitpunkt. +5. Admin-Feedback verbessern: nicht stornierbar wegen Status, Produkt, API-Antwort oder nicht auffindbarer DHL-Sendung. +6. Tests fuer erfolgreiche Stornierung, bereits stornierte Sendung, nicht stornierbare Sendung und API-Fehler. + +### Phase 3: Internationalisierung Versand + +1. Zentralen Service fuer Produkt-/Billing-Entscheidung einfuehren, z. B. `DhlProductResolver`. +2. Zielland, Produktcode, Abrechnungsnummer und erlaubte Services dort validieren. +3. Regeln initial: + - `DE`: `V01PAK` oder `V62KP` + - `AT`, `ES` und weitere aktivierte Laender: `V53PAK` +4. Fallback auf `DEU` entfernen. Unbekannte Laender muessen mit klarer Fehlermeldung abbrechen. +5. Cockpit-Formular: Produkt anhand Zielland vorschlagen, aber Admin-Korrektur erlauben. + +### Phase 4: Adressvalidierung vor Labelerstellung + +1. Neuen serverseitigen Validierungsendpunkt fuer DHL-Adressen schaffen. +2. Basisvalidierung behalten: Pflichtfelder, Land, PLZ-Format, Hausnummer, Packstation/Postnummer. +3. DHL DATAFACTORY AUTOCOMPLETE 2.0 fuer DE/AT/CH evaluieren. +4. Falls DHL fuer alle benoetigten Laender nicht ausreicht, externen Provider evaluieren. +5. UI-Status einfuehren: + - gueltig: Labelerstellung erlaubt + - Warnung: Admin kann bewusst fortfahren + - Fehler: Labelerstellung gesperrt +6. Validierungsergebnis optional am Shipment/Order protokollieren. + +### Phase 5: Referenzfeld und Admin-UX + +1. Neues Formularfeld `reference` oder `shipment_reference` im DHL Cockpit. +2. Wert an `DhlDataHelper::prepareOrderData()` uebergeben. +3. `ShippingService` nutzt vorhandenes Mapping nach `refNo`. +4. Referenz im Shipment speichern, damit spaeter nachvollziehbar ist, warum eine Sendung erstellt wurde. +5. Laengenlimit der DHL API beachten, aktuell maximal 35 Zeichen. + +### Phase 6: DHL-Gewicht korrekt berechnen + +1. Separaten Gewichtsdienst fuer DHL einfuehren, z. B. `DhlShipmentWeightCalculator`. +2. Basis: `ShoppingOrder->weight`. +3. Fuer `shopping_order_items` mit `comp > 0` das Produktgewicht aus `Product->weight` nachladen und addieren. +4. Nur DHL-Gewicht anpassen, nicht die bestehende Warenkorb-/Versandkostenlogik. +5. Rundung und DHL-Grenzwerte je Produkt testen. + +### Phase 7: Tracking-Codes und Mails fachlich finalisieren + +1. Bestehende Admin-Anzeige pruefen und bei Bedarf vereinheitlichen. +2. Tracking-Code-Anzeige in User-Portal und User-N-Portal ergaenzen. +3. Mail-Regel final definieren: + - automatisch nur einmal pro Sendung + - nur bei relevanter Statusaenderung + - mehrere Sendungen einer Bestellung sinnvoll zusammenfassen +4. Statusspruenge wie `created` direkt nach `out_for_delivery` abdecken. +5. Command `dhl:update-tracking` Tests fuer Mailausloeser und Nicht-Ausloeser ergaenzen. + +## Empfohlene Reihenfolge + +1. `V62WP` -> `V62KP`, Statuswerte und `TrackShipmentJob` korrigieren. +2. Storno stabilisieren und bessere Fehlermeldungen im Cockpit anzeigen. +3. Internationalen Produktresolver einbauen. +4. Referenzfeld und Gewichtskorrektur umsetzen. +5. Adressvalidierung integrieren. +6. Tracking-Anzeigen und Mailregeln final testen. + +## Teststrategie + +- Feature-Tests fuer Controller-Endpunkte: Label erstellen, Storno, Tracking-Mail, Tracking-Update. +- Unit-Tests fuer Produktresolver, Gewichtskalkulation und Adressvalidierung. +- HTTP-Fakes fuer DHL API Responses inklusive Fehlerfaelle. +- Regression-Test fuer `V62KP` Payload. +- Command-Test fuer `dhl:update-tracking --send-emails`. + +## 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. diff --git a/dev/dhl-modul/dhl_test.txt b/dev/2026-05-13-dhl-modul/dhl_test.txt similarity index 100% rename from dev/dhl-modul/dhl_test.txt rename to dev/2026-05-13-dhl-modul/dhl_test.txt diff --git a/dev/dhl-modul/AKTUALISIERUNG-PAKET-ANSATZ.md b/dev/2026-05-13-dhl-modul/legacy/AKTUALISIERUNG-PAKET-ANSATZ.md similarity index 100% rename from dev/dhl-modul/AKTUALISIERUNG-PAKET-ANSATZ.md rename to dev/2026-05-13-dhl-modul/legacy/AKTUALISIERUNG-PAKET-ANSATZ.md diff --git a/dev/dhl-modul/DHL_CURL_781_EXTREME_FIX.md b/dev/2026-05-13-dhl-modul/legacy/DHL_CURL_781_EXTREME_FIX.md similarity index 100% rename from dev/dhl-modul/DHL_CURL_781_EXTREME_FIX.md rename to dev/2026-05-13-dhl-modul/legacy/DHL_CURL_781_EXTREME_FIX.md diff --git a/dev/dhl-modul/DHL_LEGACY_CURL_CONFIG.md b/dev/2026-05-13-dhl-modul/legacy/DHL_LEGACY_CURL_CONFIG.md similarity index 100% rename from dev/dhl-modul/DHL_LEGACY_CURL_CONFIG.md rename to dev/2026-05-13-dhl-modul/legacy/DHL_LEGACY_CURL_CONFIG.md diff --git a/dev/dhl-modul/DHL_LIVE_SERVER_FIX.md b/dev/2026-05-13-dhl-modul/legacy/DHL_LIVE_SERVER_FIX.md similarity index 100% rename from dev/dhl-modul/DHL_LIVE_SERVER_FIX.md rename to dev/2026-05-13-dhl-modul/legacy/DHL_LIVE_SERVER_FIX.md diff --git a/dev/dhl-modul/DHL_LIVE_SERVER_SOLUTION.md b/dev/2026-05-13-dhl-modul/legacy/DHL_LIVE_SERVER_SOLUTION.md similarity index 100% rename from dev/dhl-modul/DHL_LIVE_SERVER_SOLUTION.md rename to dev/2026-05-13-dhl-modul/legacy/DHL_LIVE_SERVER_SOLUTION.md diff --git a/dev/dhl-modul/DHL_SSL_FIX_README.md b/dev/2026-05-13-dhl-modul/legacy/DHL_SSL_FIX_README.md similarity index 100% rename from dev/dhl-modul/DHL_SSL_FIX_README.md rename to dev/2026-05-13-dhl-modul/legacy/DHL_SSL_FIX_README.md diff --git a/dev/dhl-modul/OPTIMIERUNGEN.md b/dev/2026-05-13-dhl-modul/legacy/OPTIMIERUNGEN.md similarity index 100% rename from dev/dhl-modul/OPTIMIERUNGEN.md rename to dev/2026-05-13-dhl-modul/legacy/OPTIMIERUNGEN.md diff --git a/dev/dhl-modul/PAKET-INSTALLATION.md b/dev/2026-05-13-dhl-modul/legacy/PAKET-INSTALLATION.md similarity index 100% rename from dev/dhl-modul/PAKET-INSTALLATION.md rename to dev/2026-05-13-dhl-modul/legacy/PAKET-INSTALLATION.md diff --git a/dev/dhl-modul/PLAN-OPTIMIERT.md b/dev/2026-05-13-dhl-modul/legacy/PLAN-OPTIMIERT.md similarity index 100% rename from dev/dhl-modul/PLAN-OPTIMIERT.md rename to dev/2026-05-13-dhl-modul/legacy/PLAN-OPTIMIERT.md diff --git a/dev/dhl-modul/README.md b/dev/2026-05-13-dhl-modul/legacy/README.md similarity index 100% rename from dev/dhl-modul/README.md rename to dev/2026-05-13-dhl-modul/legacy/README.md diff --git a/dev/dhl-modul/SCHRITT-3-COMPLETED.md b/dev/2026-05-13-dhl-modul/legacy/SCHRITT-3-COMPLETED.md similarity index 100% rename from dev/dhl-modul/SCHRITT-3-COMPLETED.md rename to dev/2026-05-13-dhl-modul/legacy/SCHRITT-3-COMPLETED.md diff --git a/dev/dhl-modul/parcel-de-shipping-v2_2.yaml b/dev/2026-05-13-dhl-modul/parcel-de-shipping-v2_2.yaml similarity index 100% rename from dev/dhl-modul/parcel-de-shipping-v2_2.yaml rename to dev/2026-05-13-dhl-modul/parcel-de-shipping-v2_2.yaml diff --git a/docs/BusinessUpdateCalculatedFields-Examples.sh b/dev/BusinessUpdateCalculatedFields-Examples.sh similarity index 100% rename from docs/BusinessUpdateCalculatedFields-Examples.sh rename to dev/BusinessUpdateCalculatedFields-Examples.sh diff --git a/docs/BusinessUpdateCalculatedFields.md b/dev/BusinessUpdateCalculatedFields.md similarity index 100% rename from docs/BusinessUpdateCalculatedFields.md rename to dev/BusinessUpdateCalculatedFields.md diff --git a/resources/lang/fr.json b/resources/lang/fr.json new file mode 100644 index 0000000..7b53731 --- /dev/null +++ b/resources/lang/fr.json @@ -0,0 +1,170 @@ +{ + "MR": "Monsieur", + "MS": "Madame", + "DIV": "Divers", + "please select": "Veuillez sélectionner", + "please specify": "Veuillez préciser.", + "further countries": "autres pays", + "none": "Aucun", + "no": "Non", + "yes": "OUI", + "Company data": "Données de l'entreprise", + "Company name": "Nom de l'entreprise", + "Company": "Entreprise", + "Street": "Rue", + "Addition": "Complément", + "House number": "Numéro", + "Address": "Adresse", + "no.": "N°", + "City": "Ville", + "Postcode": "Code postal", + "Country": "Pays", + "Delivery country": "Pays de livraison", + "Delivery address": "Adresse de livraison", + "Delivery addresses": "Adresses de livraison", + "Phone": "Téléphone", + "optional": "optionnel", + "Phone code": "Indicatif téléphonique", + "Country code": "Code pays", + "E-Mail": "E-mail", + "Homepage": "Page d'accueil", + "Industry": "Secteur", + "Industries": "Secteurs", + "Main Industry": "Secteur principal", + "Personal Data": "Données personnelles", + "Data": "Données", + "Function": "Fonction", + "Salutation": "Civilité", + "Title": "Titre", + "First name": "Prénom", + "Last name": "Nom", + "Mobile Phone": "Téléphone mobile", + "Name": "Nom", + "Date of birth": "Date de naissance", + "Comments": "Remarques", + "Flat Building optional": "Appartement / bâtiment (optionnel)", + "Shipping to the same address": "Livraison à la même adresse", + "Consent & Privacy": "Consentement et confidentialité", + "Product": "Produit", + "Products": "Produits", + "New Password": "Nouveau mot de passe", + "Old Password": "Ancien mot de passe", + "Create Password": "Créer un mot de passe", + "Change password": "Modifier le mot de passe", + "Confirm new Password": "Répéter le nouveau mot de passe", + "Confirm Password": "Confirmer le mot de passe", + "Confirm E-Mail": "Répéter l'e-mail", + "E-Mail Address": "Adresse e-mail", + "Confirm E-Mail Address": "Répéter l'adresse e-mail", + "Forgot your Password?": "Mot de passe oublié ?", + "Login": "Connexion", + "Logout": "Déconnexion", + "Password": "Mot de passe", + "Register": "S'inscrire", + "Remember Me": "Rester connecté", + "Reset Password": "Réinitialiser le mot de passe", + "Send Password Reset Link": "Envoyer le lien de réinitialisation du mot de passe", + "save": "enregistrer", + "save and next": "enregistrer et continuer", + "save changes": "enregistrer les modifications", + "sended": "envoyer", + "abort": "annuler", + "add": "ajouter", + "This field is required.": "Ce champ est obligatoire.", + "Please enter a valid email address.": "Veuillez saisir une adresse e-mail valide.", + "This E-mail is already in use.": "Cette adresse e-mail est déjà utilisée.", + "Please enter the same value again.": "Les adresses e-mail ne correspondent pas.", + "a valid e-mail address": "Veuillez saisir une adresse e-mail valide.", + "Already have an account?": "Vous avez déjà un compte ?", + "Login to your account": "Connectez-vous à votre compte", + "now register data": "enregistrer maintenant les données", + "Required fields": "Champs obligatoires", + "Industry sectors": "Secteurs", + "Interests": "Centres d'intérêt", + "Leads": "Contacts", + "Your Data": "Vos données", + "Edit your data": "Modifier vos données", + "saved": "Enregistré", + "The changes have been saved.": "Les modifications ont été enregistrées.", + "error": "Erreur", + "Here you can adjust your data.": "Vous pouvez adapter vos données ici.", + "Overview": "Aperçu", + "Data, Login & Security": "Données, connexion et sécurité", + "Sign in with your e-mail:": "Connexion avec votre e-mail :", + "delete": "supprimer", + "confirm_delete": "Supprimer vraiment ?", + "imprint": "Mentions légales", + "data protections": "Protection des données", + "Thank you for your registration!": "Merci pour votre inscription !", + "We have sent you an e-mail with a link to activate your data.": "Nous vous avons envoyé un e-mail avec un lien pour activer votre compte.", + "Please check your emails and confirm the link.": "Veuillez consulter vos e-mails et confirmer le lien.", + "back to the homepage": "retour à la page d'accueil", + "You have successfully verified your account!": "Votre compte a été vérifié avec succès !", + "Now check your data and release the data.": "Vérifiez maintenant vos données et validez-les.", + "Check and release data": "Vérifier et valider les données", + "Check data": "Vérifier les données", + "Page not available": "Page non disponible", + "Data released": "Données validées", + "Data released now": "Valider les données maintenant", + "E-Mail verified": "E-mail vérifié", + "E-Mail not verified": "E-mail non vérifié", + "Privacy policy approved": "Politique de confidentialité acceptée", + "Consent for further information": "Consentement pour recevoir d'autres informations", + "at": "le", + "If you have checked your data, share your data here!": "Si vous avez vérifié vos données, validez-les ici !", + "Contacts all": "tous les contacts", + "Contacts verify": "contacts vérifiés", + "Contacts active": "contacts activés", + "This website uses cookies": "Ce site utilise des cookies afin de vous garantir le meilleur service possible. En visitant cette page, vous acceptez l'utilisation de cookies.", + "OK": "OK", + "Contacts": "Contacts", + "activ": "actif", + "active": "activé", + "inactive": "désactivé", + "verified": "vérifié", + "'E-Mail": "'E-mail", + "create new Contact": "Créer un nouveau contact", + "Create/Edit Contact": "Créer/modifier un contact", + "Pos": "Pos", + "Description": "Désignation", + "Translate": "Traduction", + "Status": "Statut", + "back": "retour", + "back_to_overview": "retour à l'aperçu", + "create/edit": "créer/modifier", + "close": "fermer", + "take over": "reprendre", + "Number to move the position if necessary": "Nombre pour déplacer la position si nécessaire", + "Create new interest": "Créer un nouveau centre d'intérêt", + "Really delete entry?": "Supprimer vraiment cette entrée ?", + "Create a new industry": "Créer un nouveau secteur", + "Your e-mail has been changed.": "Votre e-mail a été modifié.", + "We sent you an activation code. Check your email!": "Nous vous avons envoyé un code d'activation. Vérifiez vos e-mails !", + "An activation code was sent to the account by e-mail!": "Un code d'activation a été envoyé au contact par e-mail !", + "New E-Mail Address": "Nouvelle adresse e-mail", + "Confirm new E-Mail": "Répéter la nouvelle adresse e-mail", + "business": "professionnel", + "private": "privé", + "business or private": "professionnel ou privé", + "use": "Utilisation", + "Contact": "Contact", + "waiting for activation since": "en attente d'activation depuis", + "edit": "modifier", + "your mivita.care team": "Votre équipe mivita.care", + "create new password": "créer un nouveau mot de passe", + "Now assign a password.": "Définissez maintenant votre mot de passe pour accéder à votre compte.", + "to your data": "vers vos données", + "Your registration has already been completed.": "Votre inscription est déjà terminée.", + "The link to register is no longer active.": "Le lien d'inscription n'est plus actif ou l'inscription est déjà terminée.", + "go to login": "aller à la connexion", + "Please confirm your data first.": "Veuillez d'abord vérifier et confirmer vos données.", + "yes, data checked and share": "Oui, données vérifiées et valider maintenant", + "Copy link": "Copier le lien", + "ml": "ml", + "g": "g", + "liter": "litre", + "kg": "kg", + "search_for": "Rechercher ....", + "show_all_filters": "Afficher tous les filtres", + "": "" +} diff --git a/resources/lang/fr/abo.php b/resources/lang/fr/abo.php new file mode 100644 index 0000000..15c5c81 --- /dev/null +++ b/resources/lang/fr/abo.php @@ -0,0 +1,135 @@ + 'Abonnement', + 'payment_for_abo' => 'Mode de paiement pour l’abonnement', + 'abo_delivery' => 'Abonnement - livraison régulière', + 'abo_are_for_me_and_shipped' => 'L’abonnement est pour moi et sera envoyé à mon adresse', + 'abo_are_for_customer_and_shipped' => 'L’abonnement est pour un client et sera envoyé au client', + 'abo_delivery_to_me' => 'Livraison d’abonnement à moi-même', + 'abo_delivery_to_the_customer' => 'Livraison d’abonnement au client', + 'every_week' => 'chaque semaine', + 'every_weeks' => 'toutes les :num semaines', + 'of_month' => 'du mois', + 'delivery_intervall' => 'Adapter le jour de livraison', + 'abo_order_info_check' => 'À la conclusion de l’abonnement, une livraison régulière est mise en place. Elle est automatiquement expédiée et facturée au jour de livraison choisi.', + 'abo_order_info_check_2' => 'La première livraison et facturation a lieu le jour de la création de l’abonnement. Ensuite, l’expédition se fait automatiquement au jour de livraison choisi du mois suivant.', + 'abo_order_info_check_3' => 'PayPal et carte de crédit sont disponibles comme modes de paiement. L’abonnement a une durée minimale de :abo-min-duration mois. Ensuite, il peut être mis en pause, modifié ou résilié à tout moment.', + 'abo_order_info_checkbox' => 'Oui, j’ai compris les conditions de l’abonnement !', + 'abo_order_info_checkbox_required' => 'Veuillez confirmer les conditions de l’abonnement pour continuer.', + 'abo_infos' => 'Infos abonnement', + 'abo_delivery_infos' => 'Infos livraison abonnement', + 'abo_start_date' => 'Début de l’abonnement', + 'abo_delivery_intervall' => 'Jour de livraison de l’abonnement', + 'abo_first_execution_date' => 'Première exécution', + 'abo_next_execution_date' => 'Prochaine exécution', + 'delivery_day' => 'Adapter le jour de livraison', + 'abo_settings' => 'Paramètres abonnement', + 'add_new_abo' => 'Créer un nouvel abonnement', + 'abo_edit' => 'Modifier l’abonnement', + 'abo_details' => 'Détails de l’abonnement', + 'abo_is_active' => 'L’abonnement est actif', + 'abo_copy_active' => 'Si l’abonnement n’est pas actif, aucune exécution automatique n’a lieu.', + 'abo_copy_next_date' => 'Le prochain jour d’exécution peut être fixé au plus tôt au lendemain.', + 'abo_copy_abo_interval' => 'L’adaptation du jour de livraison de l’abonnement impacte la prochaine date d’exécution si l’abonnement est actif.', + 'admin_abo_copy_next_date' => 'Les admins peuvent définir directement la prochaine date d’exécution via le mois et le jour de livraison.', + 'error_abo_interval' => 'L’intervalle d’abonnement est incorrect', + 'error_abo_interval_in_the_past' => 'L’abonnement n’a pas encore été exécuté ce mois-ci. Un changement vers un jour passé ferait sauter le mois en cours.', + 'warning_next_date_soon' => 'Remarque : la prochaine exécution de l’abonnement est déjà dans :days jours (:date).', + 'warning_next_date_soon_select' => 'Remarque : la prochaine exécution de l’abonnement est déjà dans :placeholder_days jours (:placeholder_date).', + 'warning_next_date_info' => 'La prochaine exécution de l’abonnement est dans :days jours, le :date.', + 'info_next_execution_select' => 'Prochaine exécution : dans :placeholder_days jours, le :placeholder_date.', + 'error_change_locked' => 'Les modifications ne sont plus possibles. La prochaine exécution est dans :days jours. Les modifications doivent être effectuées au moins 10 jours avant.', + 'error_abo_interval_too_soon' => 'Le jour de livraison choisi n’est qu’à :days jours. Veuillez choisir un jour de livraison situé au moins 10 jours dans le futur.', + 'error_cancel_locked' => 'Une résiliation n’est plus possible. La prochaine exécution est dans :days jours. Les résiliations doivent être effectuées au moins 3 jours avant.', + 'error_pause_locked' => 'L’abonnement ne peut plus être mis en pause. La prochaine exécution est dans :days jours. La pause doit être effectuée au moins 3 jours avant.', + 'error_next_date' => 'La date de la prochaine exécution est incorrecte', + 'checkout_mail_abo_hl' => 'Votre abonnement / livraison régulière.', + 'checkout_mail_abo_start' => 'Votre abonnement a été créé avec succès avec les paramètres suivants :', + 'checkout_mail_abo_info' => 'Vous trouverez les paramètres de votre abonnement dans votre compte sous "Mes abonnements" et pourrez également les y modifier.', + 'abo_new' => 'nouveau', + 'abo_okay' => 'ok', + 'abo_hold' => 'en pause', + 'abo_cancel' => 'annulé', + 'abo_finish' => 'terminé', + 'abo_inactive' => 'inactif', + 'abo_grace' => 'geste commercial', + 'abo_info' => 'Informations abonnement', + 'info_min_duration_reached' => 'Votre abonnement pourra être modifié, complété, mis en pause ou résilié au plus tôt à partir du :date.', + 'info_min_duration_orders_left' => 'L’abonnement ne pourra être modifié, complété, mis en pause ou résilié qu’après encore :count exécutions.', + 'pros_hl' => 'Les avantages d’un abonnement', + 'pros_list' => '
  • Conclusion d’abonnement pour conseillers et clients : Chaque conseiller ou client peut conclure un abonnement exécuté à un jour fixe du mois afin de garantir une livraison régulière et planifiable.
  • +
  • Livraison mensuelle : Une nouvelle livraison est envoyée directement à votre porte une fois par mois.
  • +
  • Adaptable avec flexibilité : L’abonnement peut être adapté individuellement, p. ex. en termes de produits, quantités ou dates de livraison.
  • +
  • Large choix de produits : Différents produits peuvent être inclus dans l’abonnement.
  • +
  • Durée : L’abonnement a une durée minimale de :abo-min-duration mois, puis il peut être mis en pause ou résilié.
  • +
  • Avantage prix : Les produits d’abonnement bénéficient souvent de remises ou offres spéciales.
  • +
  • Démarrer maintenant : Choisissez vos produits, adaptez l’abonnement à vos besoins, payez la première commande et activez ainsi votre abonnement pour les prochaines livraisons.
  • ', + 'abo_pros' => 'Avantages abonnement', + 'abo_order_hl' => 'Composition de l’abonnement', + 'abo_order_info_2' => 'Vous pouvez adapter les produits de votre abonnement à tout moment ; lors de la prochaine exécution, vos produits composés vous seront envoyés.', + 'abo_order_info_block' => 'Vous pouvez adapter la composition de votre abonnement après la durée minimale de :abo-min-duration mois.', + 'abo_order_info_block_team' => 'La composition de l’abonnement pour un membre de l’équipe ne peut être adaptée que par lui-même.', + 'abo_order_info_block_customer' => 'Vous pouvez à tout moment ajouter de nouveaux produits à l’abonnement de votre client. La suppression de produits n’est possible qu’après la durée minimale de :abo-min-duration mois.', + 'abo_order_info_add_only' => 'Vous pouvez à tout moment ajouter de nouveaux produits à votre abonnement. La suppression de produits n’est possible qu’après la durée minimale de :abo-min-duration mois.', + 'error_add_only_no_remove' => 'La suppression de produits n’est pas possible pendant la durée minimale.', + 'confirm_add_title' => 'Confirmer l’ajout du produit', + 'confirm_add_title_normal' => 'Ajouter le produit à l’abonnement', + 'confirm_add_warning' => 'Pendant la durée minimale, les produits ajoutés ne peuvent pas être retirés. Veuillez vérifier soigneusement votre sélection.', + 'confirm_add_warning_normal' => 'Voulez-vous vraiment ajouter ce produit à votre abonnement ?', + 'confirm_add_cancel' => 'Annuler', + 'confirm_add_ok' => 'Oui, ajouter', + 'add_product' => 'Ajouter le produit', + 'product_prices_career_level_info' => 'Les prix produits sont affichés et calculés selon votre niveau de carrière :user_level_name moins :user_level_margin % de marge.', + 'product_prices_career_level_cpay_info' => 'Les prix produits sont affichés comme prix de vente client ; après finalisation du paiement client, vous recevez votre commission selon votre niveau de carrière :user_level_name, commission :user_level_margin %.', + 'error_email_has_abo' => 'Un abonnement existe déjà pour l’adresse e-mail :email.', + 'abo_assigned' => 'Abonnement actif', + 'base' => 'Base', + 'upgrade' => 'Upgrade', + 'abo_type_info' => 'Remarque : chaque abonnement se compose au minimum d’un produit de base :base !
    Les produits upgrade :upgrade sont optionnels et peuvent être ajoutés à volonté.
    L’abonnement a une durée minimale de :abo-min-duration mois, puis il peut être mis en pause ou résilié.', + 'abo_type_info_base' => 'L’abonnement nécessite au moins un produit de base :base !', + 'need_basis_product' => 'Vous devez avoir au moins un produit de base dans votre abonnement. Veuillez d’abord ajouter un nouveau produit de base puis supprimer l’ancien !', + 'abo_item_not_found' => 'Position d’abonnement introuvable', + 'product_not_found' => 'Produit introuvable', + 'create_abo' => 'Créer un abonnement', + 'info' => 'Info', + 'data' => 'Données', + 'check' => 'Vérifier', + 'choose' => 'Sélectionner', + 'order' => 'commander', + 'basis_product' => 'Produit de base', + 'upgrade_products' => 'Produits upgrade', + 'base_product' => 'Produit de base', + 'upgrade_product' => 'Produit upgrade', + 'my_address' => 'Mon adresse', + 'my_address_check' => 'Vérifier mon adresse', + 'my_address_check_info' => 'Veuillez vérifier votre adresse afin de garantir que la marchandise sera envoyée à la bonne adresse.', + 'edit' => 'modifier', + 'confirm_and_next' => 'confirmer et continuer', + 'understood_and_next' => 'compris et continuer', + 'change_my_data_empty' => 'Vous n’avez pas encore enregistré d’adresse de facturation et de livraison. Sans celles-ci, vous ne pouvez pas créer d’abonnement ; veuillez les créer.', + 'abo_error_basis_product' => 'Erreur : veuillez sélectionner au moins un produit de base.', + 'cancel_abo' => 'Résilier l’abonnement', + 'confirm_cancel' => 'Voulez-vous vraiment résilier l’abonnement ?', + 'retry_payment' => 'Relancer le paiement', + 'retry_payment_confirm_title' => 'Veuillez confirmer consciemment', + 'retry_payment_confirm_copy' => 'La tentative de paiement démarre immédiatement et peut déclencher un débit PayPal ou carte de crédit. À exécuter uniquement si la cause a été vérifiée.', + 'retry_payment_confirm_button' => 'Réessayer le paiement maintenant', + 'retry_only_hold' => 'La nouvelle tentative de paiement n’est possible que pour les abonnements en pause.', + 'retry_only_active' => 'La nouvelle tentative de paiement n’est possible que pour les abonnements actifs.', + 'retry_already_paid_today' => 'Un paiement réussi a déjà été enregistré aujourd’hui pour cet abonnement.', + 'retry_error_shopping_user' => 'Le Shopping-User n’a pas pu être créé pour la nouvelle tentative de paiement.', + 'retry_error_order' => 'La commande n’a pas pu être créée pour la nouvelle tentative de paiement.', + 'retry_success' => 'La nouvelle tentative de paiement a réussi. Commande #:order créée.', + 'retry_failed' => 'La nouvelle tentative de paiement a échoué. Commande #:order créée. Erreur : :error', + 'retry_exception' => 'La nouvelle tentative de paiement n’a pas pu être finalisée : :error', + 'team_subscriptions' => 'Abonnements équipe', + 'team_customer_abos' => 'Abonnements clients de l’équipe', + 'chart_monthly_abos' => 'Abonnements par mois', + 'chart_active_abos' => 'Abonnements actifs', + 'chart_abos_label' => 'Abonnements', + 'abo_count' => 'Nombre d’abonnements', + 'customer_privacy_info' => 'Pour des raisons de protection des données, aucune donnée personnelle client n’est affichée.', + 'every_month_on' => 'mensuellement le :day.', + 'back' => 'retour', +]; diff --git a/resources/lang/fr/abo_history.php b/resources/lang/fr/abo_history.php new file mode 100644 index 0000000..f4ac1c2 --- /dev/null +++ b/resources/lang/fr/abo_history.php @@ -0,0 +1,41 @@ + 'Composition initiale', + 'change_history' => 'Historique des modifications', + 'no_initial_data' => 'Aucune donnée initiale disponible (abonnement créé avant l’activation de l’historique)', + 'no_changes' => 'Aucune modification disponible', + 'col_date' => 'Date', + 'col_action' => 'Action', + 'col_product' => 'Article', + 'col_details' => 'Détails', + 'col_changed_by' => 'Modifié par', + 'col_channel' => 'Zone', + 'action_initial' => 'Origine', + 'action_added' => 'Ajouté', + 'action_removed' => 'Supprimé', + 'action_qty_changed' => 'Quantité modifiée', + 'action_comp_changed' => 'Comp remplacé', + 'action_comp_added' => 'Comp ajouté', + 'action_comp_removed' => 'Comp supprimé', + 'action_rollback' => 'Réinitialisé', + 'desc_initial' => 'Quantité : :qty', + 'desc_added' => 'Quantité : :qty', + 'desc_removed' => 'Article supprimé', + 'desc_qty_changed' => 'Quantité modifiée de :from à :to', + 'desc_comp_changed' => 'Remplacé : :old', + 'desc_comp_added' => 'Ajouté par le système', + 'desc_comp_removed' => 'Supprimé par le système', + 'desc_rollback' => 'Réinitialisé à l’origine', + 'channel_admin' => 'Admin', + 'channel_user_me' => 'Conseiller', + 'channel_user_ot' => 'Conseiller (client)', + 'channel_portal' => 'Portail client', + 'channel_system' => 'Système', + 'price_net' => 'net', + 'price_gross' => 'brut', + 'rollback_btn' => 'Réinitialiser à l’origine', + 'rollback_confirm' => 'Voulez-vous vraiment réinitialiser l’abonnement à son état initial ? Tous les produits actuels seront remplacés par la composition initiale.', + 'rollback_success' => 'L’abonnement a été réinitialisé avec succès à son état initial.', + 'rollback_no_data' => 'Rollback impossible : aucune donnée initiale disponible.', +]; diff --git a/resources/lang/fr/account.php b/resources/lang/fr/account.php new file mode 100644 index 0000000..0c9225f --- /dev/null +++ b/resources/lang/fr/account.php @@ -0,0 +1,48 @@ + '', + 'BIC' => 'BIC', + 'IBAN' => 'IBAN', + 'VAT_ID_number' => 'Numéro de TVA intracommunautaire', + 'VAT_copy_1' => 'Choisissez petite entreprise si votre activité ne génère pas plus de 22 000 EUR de chiffre d’affaires par an.', + 'VAT_liability' => 'Assujettissement à la TVA', + 'account_holder' => 'Titulaire du compte', + 'bank_data' => 'Coordonnées bancaires', + 'delivery_address' => 'Adresse de livraison', + 'firstname_lastname' => 'Prénom Nom', + 'invoice_address' => 'Adresse de facturation', + 'my_credit' => 'Mon crédit', + 'vat_data' => 'Données fiscales', + 'info_vat_numbers' => 'Remarque : veuillez indiquer le numéro fiscal et/ou le numéro de TVA !*', + 'new_vat_validate' => 'Valider le nouveau numéro de TVA et activer la procédure d’autoliquidation', + 'btn_vat_validate' => 'Valider le numéro de TVA', + 'phone_need_error' => 'Erreur : veuillez indiquer un téléphone et/ou un mobile !*', + 'phone_need_note' => 'Remarque : veuillez indiquer un téléphone et/ou un mobile !*', + 'required_for_commission_payments' => 'Nécessaire pour le versement des commissions', + 'reverse_charge_action_1' => 'Supprimer la procédure d’autoliquidation et le numéro de TVA', + 'reverse_charge_action_2' => 'Valider le numéro de TVA et activer la procédure d’autoliquidation', + 'reverse_charge_copy_1' => 'Autoliquidation de la taxe. Dans ce cas particulier, le destinataire de la prestation (conseiller) et non le prestataire (mivita) doit acquitter la TVA.', + 'reverse_charge_note_1' => 'Le numéro de TVA est nécessaire et validé lors de l’activation.', + 'reverse_charge_procedure' => 'Procédure d’autoliquidation', + 'tax_number' => 'Numéro fiscal', + 'taxable_sales_1' => 'assujetti à la TVA (je dépose une déclaration de TVA mensuelle / trimestrielle / annuelle auprès du fisc)', + 'taxable_sales_2' => 'non assujetti à la TVA (petite entreprise au sens du § 19', + 'validator_creditcard' => 'Veuillez saisir un numéro de carte de crédit valide', + 'validator_date' => 'Veuillez saisir une date valide.', + 'validator_digits' => 'Veuillez saisir uniquement des chiffres.', + 'validator_email' => 'Veuillez saisir une adresse e-mail valide.', + 'validator_equalTo' => 'Veuillez saisir à nouveau la même valeur.', + 'validator_max' => 'Veuillez saisir une valeur inférieure ou égale à {0}.', + 'validator_maxlength' => 'Veuillez saisir au maximum {0} caractères.', + 'validator_min' => 'Veuillez saisir une valeur supérieure ou égale à {0}.', + 'validator_minlength' => 'Veuillez saisir au moins {0} caractères.', + 'validator_number' => 'Veuillez saisir un nombre.', + 'validator_range' => 'Veuillez saisir une valeur entre {0} et {1}.', + 'validator_rangelength' => 'Veuillez saisir entre {0} et {1} caractères.', + 'validator_required' => 'Ce champ est obligatoire.', + 'validator_url' => 'Veuillez saisir une URL valide.', + 'language_settings' => 'Paramètres de langue', + 'preferred_language' => 'Langue préférée', + 'language_hint' => 'Cette langue est utilisée pour vos factures, relevés de commissions et bons de livraison.', +]; diff --git a/resources/lang/fr/actions.php b/resources/lang/fr/actions.php new file mode 100644 index 0000000..ee91354 --- /dev/null +++ b/resources/lang/fr/actions.php @@ -0,0 +1,24 @@ + '', + 'cancel' => 'annuler', + 'confirm' => 'confirmer', + 'file_is_too_big' => 'Le fichier est trop volumineux
    max. $0 Mo', + 'image_too_small ' => 'Image trop petite
    min. $0 pixels', + 'invalid_file' => 'Fichier invalide
    uniquement : $0', + 'really_delete_picture' => 'Supprimer vraiment l’image ?', + 'rotate' => 'pivoter', + 'save_image' => 'Enregistrer l’image', + 'search_file_or_drag_drop' => 'Rechercher un fichier ou glisser-déposer', + 'upload_photo' => 'Téléchargement de photo', + 'dictDefaultMessage' => 'Déposez les fichiers ici pour les téléverser', + 'dictFallbackMessage' => 'Votre navigateur ne prend pas en charge le téléversement par glisser-déposer.', + 'dictFallbackText' => 'Veuillez utiliser le formulaire de secours ci-dessous pour téléverser vos fichiers comme auparavant.', + 'dictFileTooBig' => 'Le fichier est trop volumineux ({{filesize}}MiB). Taille max. : {{maxFilesize}}MiB.', + 'dictInvalidFileType' => 'Vous ne pouvez pas téléverser des fichiers de ce type.', + 'dictResponseError' => 'Le serveur a répondu avec le code {{statusCode}}.', + 'dictCancelUpload' => 'Annuler le téléversement', + 'dictRemoveFile' => 'Supprimer le fichier', + 'dictMaxFilesExceeded' => 'Vous ne pouvez plus téléverser de fichiers.', +]; diff --git a/resources/lang/fr/auth.php b/resources/lang/fr/auth.php new file mode 100644 index 0000000..371b084 --- /dev/null +++ b/resources/lang/fr/auth.php @@ -0,0 +1,8 @@ + 'Cette combinaison d’identifiants est introuvable dans notre base de données.', + 'not_found' => 'Cette adresse e-mail n’est pas enregistrée.', + 'failed_customer' => 'Cette adresse e-mail est introuvable dans notre base de données.', + 'throttle' => 'Trop de tentatives de connexion. Veuillez réessayer dans :seconds secondes.', +]; diff --git a/resources/lang/fr/backend.php b/resources/lang/fr/backend.php new file mode 100644 index 0000000..c20ceb1 --- /dev/null +++ b/resources/lang/fr/backend.php @@ -0,0 +1,34 @@ + 'Actualités du tableau de bord', + 'add_news' => 'Ajouter une actualité', + 'edit_news' => 'Modifier l’actualité', + 'title' => 'Titre', + 'teaser' => 'Teaser', + 'content' => 'Contenu', + 'status' => 'Statut', + 'active' => 'Actif', + 'inactive' => 'Inactif', + 'created_at' => 'Créé le', + 'actions' => 'Actions', + 'delete' => 'Supprimer', + 'confirm_delete' => 'Supprimer vraiment ?', + 'no_news_yet' => 'Aucune actualité disponible', + 'cancel' => 'Annuler', + 'general_settings' => 'Paramètres généraux', + 'news_active' => 'L’actualité est active', + 'news_active_hint' => 'Une seule actualité active est affichée dans le tableau de bord', + 'news_active_single' => 'Remarque : une seule actualité peut être active à la fois. Lors de l’activation, toutes les autres sont automatiquement désactivées.', + 'german' => 'Allemand', + 'default_language' => 'Langue par défaut', + 'teaser_hint' => 'Texte court directement visible (max. 2-3 phrases)', + 'content_hint' => 'Contenu plus long affiché après "Lire la suite". Les balises HTML sont autorisées (p. ex. , ', + 'business_environment_hl' => 'Environnement professionnel', + 'business_environment_list' => '
      +
    • Cosmétique / cosmétique naturelle
    • +
    • Coiffure
    • +
    • Nutrition
    • +
    • Soin des ongles et des pieds
    • +
    • Naturopathes & médecine alternative
    • +
    • Médecine (peau, physiothérapie, général, etc.)
    • +
    • Autour des animaux
    • +
    • Yoga & méditation
    • +
    • Gastronomie
    • +
    • Sport & fitness
    • +
    • etc.
    • +
    ', + 'social_environment_hl' => 'Dans les réseaux sociaux', + 'social_environment_list' => '
      +
    • Facebook
    • +
    • Youtube
    • +
    • Instagram
    • +
    • Snapchat
    • +
    • Famille
    • +
    • entre autres ...
    • +
    ', + 'direct_sales_advantages_hl' => 'La vente directe et ses avantages', + 'direct_sales_advantages_copy' => '

    La vente directe s’est depuis longtemps imposée dans le monde comme une forme d’activité à part entière et est représentée dans de nombreux secteurs. La vente directe, aussi appelée network marketing, décrit la vente personnelle de biens ou services à des consommateurs de l’environnement direct : famille, amis, collègues, etc. Le contact personnel et la fonction de conseil sont décisifs - un service client par excellence ! C’est là la grande force de la vente directe : le service au client, que les vastes espaces anonymes d’Internet ne peuvent plus offrir de la même manière, nous le créons avec personnalité et transparence.

    ', + 'direct_sales_exactly_hl' => 'Vente directe - comment cela fonctionne-t-il exactement ?', + 'direct_sales_exactly_copy' => '

    Imaginez que vous êtes fan des produits d’une entreprise. Vous vous dites : « Super, il faut que j’en parle à ma meilleure amie, à mon ami, à ma famille et au monde entier. » Et ils achètent le produit sur votre recommandation. Cela arrive tous les jours.

    +

    La différence est que vous ne vous contentez pas de recommander : vous êtes vous-même vendeur, construisez votre clientèle et réalisez ainsi une marge de 30-40 %. De nouveaux clients gagnés - parfait !

    +

    Imaginez maintenant que votre ami ou amie devienne aussi partenaire commercial, construise sa propre clientèle et que vous gagniez aussi sur son activité. Et cela continue jusqu’à 3 niveaux + bonus !

    ', + 'advantages_MIVITA_hl' => 'Avantages chez MIVITA :', + 'advantages_MIVITA_subl' => 'Il y a beaucoup de bonnes raisons de devenir indépendant avec mivita. À la question de savoir pourquoi elles ont choisi la vente directe chez mivita, de nombreuses conseillères et de nombreux conseillers répondent :', + 'advantages_MIVITA_list' => '
      +
    • 20-40 % de marge de vente
    • +
    • Bonus et actions attractifs
    • +
    • Gains jusqu’à 3 niveaux
    • +
    • Aucun chiffre d’affaires minimum
    • +
    • Organisation libre du temps
    • +
    • Travail en équipe
    • +
    • Supports publicitaires & boutique en ligne sur demande
    • +
    • Expédition et gestion des retours par MIVITA
    • +
    • Formations à Majorque
    • +
    ', + 'advantages_MIVITA_botl' => '... construisez un avenir autodéterminé dans lequel vous êtes votre propre chef.', + 'advantages_direct_sales_hl' => 'Avantages de la vente directe', + 'advantages_direct_sales_subl' => 'La vente directe offre les avantages suivants', + 'advantages_direct_sales_list' => '
      +
    • Gagner un revenu supplémentaire ou améliorer la qualité de vie de la famille
    • +
    • Être son propre chef avec un faible risque et de faibles investissements
    • +
    • Travailler de façon flexible avec une organisation libre du temps
    • +
    • Ne pas avoir d’obligation d’achat
    • +
    • Pouvoir démarrer sans limite d’âge, sans connaissances préalables ni qualifications particulières
    • +
    • Faible risque et faibles coûts fixes
    • +
    • Grâce à la haute qualité des produits
    • +
    ', + 'compatibility_with_family_copy1' => 'Qui ne connaît pas cela ? L’argent manque partout et le chef fait déjà la grimace quand l’enfant est malade. Beaucoup de familles dépendent d’un deuxième emploi ou d’un temps partiel de la mère ou du père. Et cela ne s’accorde souvent pas avec les attentes de l’employeur. Mais que se passerait-il si vous pouviez construire vous-même facilement votre revenu mensuel complémentaire tout en ayant plus de temps pour votre enfant ?', + 'compatibility_with_family_copy2' => 'Surtout dans la vente directe, avec ses possibilités d’organisation libre du temps et de revenu passif, vous obtenez à moyen terme une liberté qu’un emploi fixe ne peut pas offrir. En tant que conseillère MIVITA indépendante, vous décidez vous-même quand et où vous travaillez. Et cela ne concerne pas seulement les jeunes parents. Pour les personnes qui doivent s’occuper de proches ou qui souhaitent tout simplement plus de temps et de soulagement financier, la vente directe est également une solution idéale :', + 'compatibility_with_family_list' => '
      +
    • Travailler de chez soi ou en déplacement
    • +
    • Pas de loyer de bureau
    • +
    • Pas d’investissements importants dans le développement
    • +
    • Aucune infrastructure coûteuse nécessaire
    • +
    ', + 'women_direct_sales_hl' => 'Les femmes dans la vente directe', + 'women_direct_sales_copy' => 'Selon l’Association fédérale allemande de la vente directe (BDD), 82 % des cadres étaient des femmes. C’est le résultat d’une enquête menée auprès des entreprises membres du BDD. La part des femmes est donc nettement plus élevée que dans l’économie globale. Et ce n’est pas un hasard. Traditionnellement, de nombreuses femmes travaillent dans la vente directe et y trouvent leur vocation en raison de conditions familiales ou d’un manque d’égalité des chances sur le marché, afin d’exprimer leur énergie, leurs idées et leur engagement et de devenir des femmes d’affaires prospères.', + '' => '', +]; diff --git a/resources/lang/fr/weborder.php b/resources/lang/fr/weborder.php new file mode 100644 index 0000000..3e64f19 --- /dev/null +++ b/resources/lang/fr/weborder.php @@ -0,0 +1,45 @@ + 'Détails du produit', + 'description' => 'Description', + 'application' => 'Utilisation', + 'ingredients' => 'Ingrédients', + 'to_shopping_cart' => 'vers le panier ', + 'shopping_cart' => 'Panier', + 'excl_VAT_plus_shipping_costs' => 'hors TVA, plus frais de livraison', + 'incl_VAT_plus_shipping_costs' => 'TVA incluse, plus frais de livraison', + 'free_shipping_costs_from' => 'Livraison offerte à partir de', + 'only_missing_free_shipping_costs_from' => 'Il ne manque plus que :value € pour votre livraison gratuite.', + 'add_shopping_cart' => 'Ajouter au panier', + 'details' => 'Détails', + 'to_product' => 'vers le produit', + 'price_net' => 'Prix nets', + 'incl' => 'incl.', + 'VAT' => 'TVA', + 'plus_shipping_cost' => 'plus frais de livraison', + 'delivery_time_1_3' => 'Délai de livraison : 1-3 jours ouvrés', + 'payment_methods' => 'Modes de paiement', + 'payment_method_paypal' => 'Paiement avec PayPal', + 'payment_method_paypal_copy' => 'Après vérification réussie, vous serez redirigé vers la page de paiement sécurisée de PayPal afin de poursuivre le paiement. Veuillez ne pas fermer le navigateur après le paiement réussi avant d’être redirigé vers la boutique.', + 'payment_method_direct' => 'Virement immédiat', + 'payment_method_direct_copy' => 'Après vérification réussie, vous serez redirigé vers la page de paiement sécurisée de PAYONE pour les virements SOFORT afin de poursuivre le paiement. Veuillez ne pas fermer le navigateur après le paiement réussi avant d’être redirigé vers la boutique.', + 'payment_method_sepa' => 'Prélèvement SEPA', + 'payment_method_sepa_copy' => 'Votre compte sera débité pour cette commande unique après l’envoi de la commande. Le prélèvement SEPA est traité par notre prestataire de paiement PAYONE.', + 'payment_method_creditcard' => 'Carte de crédit', + 'payment_method_creditcard_copy' => 'Le montant sera débité de votre carte de crédit dès l’envoi de la commande. Le paiement par carte de crédit est traité par notre prestataire de paiement PAYONE.', + 'payment_method_prepayment' => 'Paiement anticipé', + 'payment_method_prepayment_copy' => 'Après l’envoi de la commande, vous recevrez un e-mail avec les coordonnées bancaires pour effectuer le paiement. Après réception du montant, votre marchandise sera expédiée.', + 'shipping_methods_costs' => 'Modes et frais de livraison', + 'free_shipping_at' => 'Livraison offerte à partir de :value €', + 'only_missing_free_shipping_at' => 'Il ne manque plus que :value € pour votre livraison gratuite.', + 'free_shipping' => 'livraison gratuite', + 'not_free_shipping' => 'livraison non gratuite', + 'shopping_cart_sum' => 'Total du panier', + 'shipping_cost' => 'Frais de livraison', + 'sum_net' => 'Somme nette', + 'checkout_ssl_server' => 'Vous serez redirigé vers notre serveur de checkout, la connexion est chiffrée SSL.', + 'to_checkout' => 'vers la caisse', + 'all_price_plus_VAT_info' => 'Tous les prix incluent la TVA légale :link, sauf indication contraire', + '' => '', +]; diff --git a/resources/lang/fr/website.php b/resources/lang/fr/website.php new file mode 100644 index 0000000..3c01287 --- /dev/null +++ b/resources/lang/fr/website.php @@ -0,0 +1,101 @@ + 'Accueil', + 'aloe_vera' => 'Aloe Vera', + 'productworld' => 'Univers produits', + 'career_opportunities' => 'Opportunités de carrière', + 'contact' => 'Contact', + 'shop' => 'Boutique', + 'partner' => 'Partenaire', + 'register' => 'Inscription', + 'contents' => 'Contenus', + 'all_rights_reserved' => 'All Rights Reserved, mivita.care', + 'payment_methods' => 'Modes de paiement', + 'impress' => 'Mentions légales', + 'cancellation_policy' => 'Politique de rétractation', + 'privacy_policy' => 'Politique de confidentialité', + 'gtc' => 'CGV', + 'shipping_costs' => 'Frais de livraison', + 'sales_partner_or_questions' => 'Vous souhaitez devenir partenaire commercial ou vous avez des questions sur nos produits ?', + 'contact_now' => 'Nous contacter maintenant', + 'contact_number' => '+49 (0) 8333 94 61 767', + 'contact_number_link' => '+4983339461767', + 'to_contact' => 'vers le contact', + 'email' => 'E-mail', + 'address' => 'Adresse', + 'phone' => 'Téléphone', + 'contact_address' => 'mivita care gmbh
    Leinfeld 2
    87755 Kirchhaslach
    ', + 'phone_number' => 'Téléphone : +49 (0) 8333 94 61 767', + 'email_address' => 'info@mivita.care', + 'business_hours' => 'Heures d’ouverture', + 'business_hours_details' => 'Lun. - Ven. : 9h-12h et 13h-16h', + 'language' => 'Langue', + 'you_are_now_in_shop' => 'Vous êtes dans la boutique :', + 'you_are_now_in_shop_notice' => 'Remarque importante : les commandes dans le pays sélectionné ne peuvent être expédiées qu’à l’intérieur de ce pays. Si vous changez de pays, vous serez déconnecté de votre session actuelle et votre panier sera vidé.', + 'change_country' => 'Changer de pays', + 'start_copy_mivita' => 'MIVITA est une entreprise basée en Allemagne qui distribue des produits innovants, respectueux de l’environnement et de haute qualité. Depuis son entrée sur le marché, MIVITA s’engage pour des valeurs mettant au premier plan la satisfaction des clients ainsi que la responsabilité envers l’environnement.', + 'more' => 'Plus', + 'read' => 'lire', + 'NOW' => 'MAINTENANT', + 'slider_subl' => 'Aloe Vera bio & cosmétique naturelle', + 'slider_hl' => 'Compléments alimentaires', + 'start_why_hl' => 'Pourquoi MIVITA ?', + 'start_why_copy' => '

    Nos clients font à juste titre confiance aux excellentes propriétés des produits MIVITA. Chacun fait ainsi un choix intelligent pour la santé et l’environnement.

    +

    Nous accordons une grande importance non seulement à la qualité de nos produits, mais aussi à un conseil complet et compétent par des conseillères et conseillers aimables et qualifiés.

    +

    Nous développons et distribuons exclusivement des produits de haute qualité. Utilisés et entretenus correctement, ils sont durables et très économiques. Des facteurs qui garantissent la satisfaction de nos clients.

    ', + 'start_aloe_hl' => 'Aloe Vera', + 'start_aloe_copy' => 'Avec les plus hautes exigences, nous collaborons avec la ferme Aloe Vera à Majorque, qui garantit une qualité de classe mondiale.', + 'start_products_hl' => 'Produits', + 'start_products_copy' => 'Vous trouverez ici un aperçu de toute la gamme premium de MIVITA. Du jus d’aloe de haute qualité à la cosmétique bienfaisante.', + 'start_salespartner_hl' => 'Partenaire commercial', + 'start_salespartner_copy' => 'Devenez vous aussi partenaire commercial de MIVITA et construisez votre propre activité avec des produits de qualité. Si vous le souhaitez, vous pouvez commencer tout de suite ...', + 'start_mivitapartner_copy' => '

    MIVITA est le partenaire exclusif de la ferme Aloe Vera de Mallorca pour la vente directe en Allemagne. + Deux entreprises se sont réunies ici, pour lesquelles qualité et durabilité sont des priorités absolues. C’est ainsi seulement que des clients satisfaits à long terme deviennent de véritables fans de nos produits.

    +
    +

    « Pour le bien de l’homme, de l’animal et de la nature ... la durabilité et les produits qui font vraiment du bien sont importants pour nous. »

    +

    Alois Ried - propriétaire MIVITA

    +
    ', + 'youtube_accepted_copy' => 'En chargeant la vidéo YouTube, vous acceptez notre politique de confidentialité / XII. Intégration de services et contenus de tiers ', + 'to_privacy_policy' => 'vers la politique de confidentialité', + 'accept_youtube_load' => 'accepter et charger la vidéo YouTube', + 'welcome' => 'Bienvenue', + 'welcome_copy' => 'Je me réjouis de votre visite dans ma boutique en ligne MIVITA. Je suis votre conseil personnel autour des produits et de leur utilisation.', + 'my_accessibility' => 'Mes disponibilités', + 'for_you_on_spot' => 'Sur place pour vous', + 'for_you_on_spot_copy' => 'Nous étions pour vous sur place à la ferme Aloe Vera à Majorque, car la transparence est importante pour nous. Vous savez ainsi exactement d’où viennent vos produits et comment ils sont fabriqués. Une vraie qualité.', + 'thank_you_for_your_request' => 'Merci pour votre demande', + 'we_will_get_back_to_you' => 'Nous vous répondrons rapidement.', + 'best_regards' => 'Cordialement', + 'your_mivita_team' => 'Votre équipe mivita.care', + 'contact_hl' => 'Écrivez-nous !', + 'contact_subl' => 'Notre activité vous intéresse ou vous avez des questions sur les produits ? Nous nous réjouissons de recevoir votre message et vous répondrons dès que possible.', + 'your_message_to_us' => 'Votre message', + 'your_enquiry_relate_partnership' => 'Votre demande concerne-t-elle un partenariat commercial souhaité ?*', + 'yes' => 'OUI', + 'no' => 'NON', + 'through_whom_label' => 'Par qui avez-vous entendu parler de nous (partenaire commercial, client, réseaux sociaux, etc.) ? Merci d’indiquer impérativement un nom - c’est important pour notre attribution interne :*', + 'through_whom_placeholder' => 'Prénom et nom, site web, profil Facebook, Instagram, YouTube ou similaire', + 'send_message' => 'Envoyer le message', + 'register_now' => 's’inscrire maintenant', + 'business_owner' => 'Gérant', + 'registry_court' => 'Tribunal d’enregistrement', + 'register_number' => 'Numéro d’enregistrement', + 'VATID' => 'N° TVA', + 'business_owner_value' => 'Alois Ried', + 'registry_court_value' => 'Memmingen', + 'register_number_value' => 'HRB 21591', + 'VATID_value' => 'DE 453867883', + 'support_mivita' => 'Support mivita', + 'note_on_use' => 'Note d’utilisation', + 'online_dispute_resolution' => 'Règlement en ligne des litiges', + 'note_on_use_copy' => 'Toute utilisation, reproduction, transmission, publication ou exploitation commerciale non autorisée des contenus présents sur ce site sans l’autorisation de l’auteur est interdite et fera l’objet de poursuites pénales et civiles !', + 'online_dispute_resolution_copy' => 'conformément à l’art. 14 al. 1 du règlement ODR : la Commission européenne met à disposition une plateforme de règlement en ligne des litiges (RLL), que vous trouverez ici', + 'show_all_products' => 'Afficher tous les produits', + 'to_login' => 'vers la connexion', + 'to_customer_portal' => 'vers l’espace client', + 'to_sales_center' => 'vers le Salescenter', + 'shipping_error_billing' => 'Remarque : le pays de livraison :shipping_country de votre panier ne correspond pas au pays de livraison :billing_country de votre adresse de facturation.', + 'shipping_error_delivery' => 'Remarque : le pays de livraison :shipping_country de votre panier ne correspond pas au pays de livraison :billing_country de votre adresse de livraison.', + '' => '', +]; diff --git a/tests/Feature/BackfillFrenchDatabaseTranslationsTest.php b/tests/Feature/BackfillFrenchDatabaseTranslationsTest.php new file mode 100644 index 0000000..c802096 --- /dev/null +++ b/tests/Feature/BackfillFrenchDatabaseTranslationsTest.php @@ -0,0 +1,278 @@ +id(); + $table->string('language'); + $table->string('name')->nullable(); + $table->timestamps(); + }); + + Schema::create('products', function (Blueprint $table) { + $table->id(); + $table->string('name'); + $table->text('copy')->nullable(); + $table->text('description')->nullable(); + $table->text('usage')->nullable(); + $table->text('ingredients')->nullable(); + }); + + Schema::create('trans_products', function (Blueprint $table) { + $table->id(); + $table->string('language'); + $table->unsignedBigInteger('product_id'); + $table->string('key')->nullable(); + $table->text('value')->nullable(); + $table->timestamps(); + }); + + Schema::create('ingredients', function (Blueprint $table) { + $table->id(); + $table->string('name'); + $table->text('inci')->nullable(); + $table->text('effect')->nullable(); + }); + + Schema::create('trans_ingredients', function (Blueprint $table) { + $table->id(); + $table->string('language'); + $table->unsignedBigInteger('ingredient_id'); + $table->string('key')->nullable(); + $table->text('value')->nullable(); + $table->timestamps(); + }); + + Schema::create('dashboard_news', function (Blueprint $table) { + $table->id(); + $table->string('title')->nullable(); + $table->text('teaser')->nullable(); + $table->text('content')->nullable(); + $table->json('trans_title')->nullable(); + $table->json('trans_teaser')->nullable(); + $table->json('trans_content')->nullable(); + $table->boolean('active')->default(true); + $table->timestamps(); + }); +}); + +it('backfills missing french product translations without overwriting existing values', function () { + DB::table('products')->insert([ + 'id' => 1, + 'name' => 'Aloe Vera Gel', + 'copy' => 'Deutsche Kurzbeschreibung', + 'description' => 'Deutsche Beschreibung', + 'usage' => null, + 'ingredients' => 'Aloe Vera', + ]); + + DB::table('trans_products')->insert([ + 'language' => 'fr', + 'product_id' => 1, + 'key' => 'name', + 'value' => 'Nom existant', + 'created_at' => now(), + 'updated_at' => now(), + ]); + + $this->artisan('translation:backfill-french-db', [ + '--driver' => 'copy-source', + '--models' => 'products', + ]) + ->expectsOutput('Processing products...') + ->expectsOutput('Status products: 1 Datensätze, 5 Felder.') + ->expectsOutput('Datensatz 1/1: products#1') + ->expectsOutput(' - products#1.name: vorhandene Übersetzung, übersprungen.') + ->expectsOutput(' - products#1.copy: übernehme Quelle...') + ->expectsOutput(' - products#1.copy: gespeichert (erstellt).') + ->expectsOutput(' - products#1.usage: Quelle leer, übersprungen.') + ->assertSuccessful(); + + expect(DB::table('trans_languages')->where('language', 'fr')->value('name'))->toBe('Französisch') + ->and(DB::table('trans_products')->where('language', 'fr')->where('product_id', 1)->where('key', 'name')->value('value'))->toBe('Nom existant') + ->and(DB::table('trans_products')->where('language', 'fr')->where('product_id', 1)->where('key', 'copy')->value('value'))->toBe('Deutsche Kurzbeschreibung') + ->and(DB::table('trans_products')->where('language', 'fr')->where('product_id', 1)->where('key', 'description')->value('value'))->toBe('Deutsche Beschreibung') + ->and(DB::table('trans_products')->where('language', 'fr')->where('product_id', 1)->where('key', 'usage')->exists())->toBeFalse(); +}); + +it('uses openai for translatable fields and copies inci values unchanged', function () { + config()->set('services.openai.api_key', 'test-key'); + config()->set('services.openai.model', 'gpt-5.4-mini'); + + Http::fake(function (Request $request) { + $text = $request['messages'][1]['content']; + + return Http::response([ + 'choices' => [ + [ + 'message' => [ + 'content' => str_contains($text, 'Pflege') + ? 'FR __MIVITA_TRANSLATION_TOKEN_1__ __MIVITA_TRANSLATION_TOKEN_0__ Pflege' + : 'FR Beruhigende Wirkung', + ], + ], + ], + ]); + }); + + DB::table('ingredients')->insert([ + 'id' => 1, + 'name' => 'MIVITA :amount Pflege', + 'inci' => 'Aloe Barbadensis Leaf Juice', + 'effect' => 'Beruhigende Wirkung', + ]); + + $this->artisan('translation:backfill-french-db', [ + '--driver' => 'openai', + '--models' => 'ingredients', + ])->assertSuccessful(); + + expect(DB::table('trans_ingredients')->where('language', 'fr')->where('ingredient_id', 1)->where('key', 'name')->value('value'))->toBe('FR MIVITA :amount Pflege') + ->and(DB::table('trans_ingredients')->where('language', 'fr')->where('ingredient_id', 1)->where('key', 'inci')->value('value'))->toBe('Aloe Barbadensis Leaf Juice') + ->and(DB::table('trans_ingredients')->where('language', 'fr')->where('ingredient_id', 1)->where('key', 'effect')->value('value'))->toBe('FR Beruhigende Wirkung'); + + Http::assertSentCount(2); + Http::assertSent(fn (Request $request): bool => $request->hasHeader('Authorization', 'Bearer test-key') + && $request->url() === 'https://api.openai.com/v1/chat/completions' + && $request['model'] === 'gpt-5.4-mini'); +}); + +it('backfills dashboard news json translations', function () { + DB::table('dashboard_news')->insert([ + 'id' => 1, + 'title' => 'Neue Aktion', + 'teaser' => 'Kurzer Hinweis für das Dashboard', + 'content' => '

    Ausführlicher Inhalt für Berater.

    ', + 'trans_title' => json_encode(['en' => 'Existing English title']), + 'trans_teaser' => json_encode(['fr' => 'Teaser existant']), + 'trans_content' => null, + 'active' => true, + 'created_at' => now(), + 'updated_at' => now(), + ]); + + $this->artisan('translation:backfill-french-db', [ + '--driver' => 'copy-source', + '--models' => 'dashboard_news', + ]) + ->expectsOutput('Processing dashboard_news...') + ->expectsOutput('Status dashboard_news: 1 Datensätze, 3 Felder.') + ->expectsOutput('Datensatz 1/1: dashboard_news#1') + ->expectsOutput(' - dashboard_news#1.title: übernehme Quelle...') + ->expectsOutput(' - dashboard_news#1.title: gespeichert (erstellt).') + ->expectsOutput(' - dashboard_news#1.teaser: vorhandene Übersetzung, übersprungen.') + ->expectsOutput(' - dashboard_news#1.content: übernehme Quelle...') + ->expectsOutput(' - dashboard_news#1.content: gespeichert (erstellt).') + ->assertSuccessful(); + + $news = DB::table('dashboard_news')->where('id', 1)->first(); + + expect(json_decode($news->trans_title, true))->toMatchArray([ + 'en' => 'Existing English title', + 'fr' => 'Neue Aktion', + ]) + ->and(json_decode($news->trans_teaser, true))->toMatchArray([ + 'fr' => 'Teaser existant', + ]) + ->and(json_decode($news->trans_content, true))->toMatchArray([ + 'fr' => '

    Ausführlicher Inhalt für Berater.

    ', + ]); +}); + +it('updates dashboard news json translations when overwrite is enabled', function () { + DB::table('dashboard_news')->insert([ + 'id' => 1, + 'title' => 'Aktualisierte Aktion', + 'teaser' => null, + 'content' => null, + 'trans_title' => json_encode(['fr' => 'Ancien titre']), + 'trans_teaser' => null, + 'trans_content' => null, + 'active' => true, + 'created_at' => now(), + 'updated_at' => now(), + ]); + + $this->artisan('translation:backfill-french-db', [ + '--driver' => 'copy-source', + '--models' => 'dashboard_news', + '--overwrite' => true, + ]) + ->expectsOutput(' - dashboard_news#1.title: gespeichert (aktualisiert).') + ->assertSuccessful(); + + $translations = json_decode(DB::table('dashboard_news')->where('id', 1)->value('trans_title'), true); + + expect($translations['fr'])->toBe('Aktualisierte Aktion'); +}); + +it('prints sample translations for the api test mode', function () { + config()->set('services.openai.api_key', 'test-key'); + config()->set('services.openai.model', 'gpt-5.4-mini'); + + Http::fakeSequence() + ->push(['choices' => [['message' => ['content' => 'Gel d’Aloe Vera pour les soins quotidiens de la peau.']]]]) + ->push(['choices' => [['message' => ['content' => 'Le conseiller peut recommander un abonnement adapté à son client.']]]]) + ->push(['choices' => [['message' => ['content' => 'Description du produit MIVITA avec :amount ml de contenu et paiement PayPal.']]]]); + + $this->artisan('translation:backfill-french-db', [ + '--test-api' => true, + ]) + ->expectsOutput('OpenAI translation API test') + ->expectsOutput('Model: gpt-5.4-mini') + ->expectsOutput('Language: de -> fr') + ->expectsOutput('[1] DE: Aloe Vera Gel für die tägliche Pflege der Haut.') + ->expectsOutput('[1] FR: Gel d’Aloe Vera pour les soins quotidiens de la peau.') + ->expectsOutput('[2] DE: Der Berater kann seinem Kunden ein passendes Abo empfehlen.') + ->expectsOutput('[2] FR: Le conseiller peut recommander un abonnement adapté à son client.') + ->expectsOutput('[3] DE: MIVITA Produktbeschreibung mit :amount ml Inhalt und PayPal Zahlung.') + ->expectsOutput('[3] FR: Description du produit MIVITA avec :amount ml de contenu et paiement PayPal.') + ->expectsOutput('API test completed.') + ->assertSuccessful(); + + Http::assertSentCount(3); +}); + +it('prints a helpful message when openai quota is exceeded', function () { + config()->set('services.openai.api_key', 'test-key'); + config()->set('services.openai.model', 'gpt-5.4-mini'); + + Http::fake([ + '*' => Http::response([ + 'error' => [ + 'message' => 'You exceeded your current quota, please check your plan and billing details.', + 'type' => 'insufficient_quota', + 'code' => 'insufficient_quota', + ], + ], 429), + ]); + + $this->artisan('translation:backfill-french-db', [ + '--test-api' => true, + ]) + ->expectsOutput('OpenAI translation API test') + ->expectsOutput('Model: gpt-5.4-mini') + ->expectsOutput('Language: de -> fr') + ->expectsOutput('OpenAI API request failed with HTTP 429.') + ->expectsOutput('Code: insufficient_quota') + ->expectsOutput('Type: insufficient_quota') + ->expectsOutput('Message: You exceeded your current quota, please check your plan and billing details.') + ->expectsOutput('Bitte prüfe im OpenAI Dashboard das Billing, das Projekt-Budget, Usage-Limits und ob der OPENAI_API_KEY zum richtigen Projekt gehört.') + ->assertFailed(); + + Http::assertSentCount(1); +}); diff --git a/tests/Feature/FrenchLocalizationTest.php b/tests/Feature/FrenchLocalizationTest.php new file mode 100644 index 0000000..438d0cc --- /dev/null +++ b/tests/Feature/FrenchLocalizationTest.php @@ -0,0 +1,48 @@ +toHaveKey('fr') + ->and(LocaleGuard::normalize('FR')) + ->toBe('fr'); +}); + +it('keeps french translation files in sync with the german source keys', function () { + foreach (glob(resource_path('lang/de/*.php')) as $germanFile) { + $fileName = basename($germanFile); + $frenchFile = resource_path('lang/fr/'.$fileName); + + expect($frenchFile)->toBeFile(); + expect(frenchLocalizationFlattenTranslationKeys(require $frenchFile)) + ->toEqualCanonicalizing(frenchLocalizationFlattenTranslationKeys(require $germanFile)); + } +}); + +/** + * @return array + */ +function frenchLocalizationFlattenTranslationKeys(array $translations, string $prefix = ''): array +{ + $keys = []; + + foreach ($translations as $key => $value) { + $translationKey = $prefix.(string) $key; + + if (is_array($value)) { + $keys = [ + ...$keys, + ...frenchLocalizationFlattenTranslationKeys($value, $translationKey.'.'), + ]; + + continue; + } + + $keys[] = $translationKey; + } + + return $keys; +} diff --git a/tests/Unit/Services/LocaleGuardTest.php b/tests/Unit/Services/LocaleGuardTest.php index 358d7da..39d1932 100644 --- a/tests/Unit/Services/LocaleGuardTest.php +++ b/tests/Unit/Services/LocaleGuardTest.php @@ -8,6 +8,7 @@ uses(TestCase::class); it('normalizes supported locales', function () { expect(LocaleGuard::normalize('DE'))->toBe('de'); expect(LocaleGuard::normalize('en'))->toBe('en'); + expect(LocaleGuard::normalize('FR'))->toBe('fr'); }); it('returns null for unsupported or invalid locale strings', function () { From 53bdba33cd55128d47a48c0665c651b9125e71e6 Mon Sep 17 00:00:00 2001 From: Kevin Adametz Date: Mon, 18 May 2026 17:23:28 +0200 Subject: [PATCH 3/4] User Statistik --- .../BackofficeStoreStatisticsSnapshots.php | 127 ++++++ app/Console/Kernel.php | 3 + .../User/BackofficeStatisticsController.php | 279 ++++++++++++ .../Controllers/Web/CheckoutController.php | 6 + app/Models/BackofficeStatisticsSnapshot.php | 29 ++ app/Models/ShoppingOrder.php | 25 ++ app/Repositories/CheckoutRepository.php | 6 + .../Backoffice/BackofficeDashboardService.php | 365 ++++++++++++++++ .../Backoffice/BackofficeDrilldownService.php | 331 ++++++++++++++ ..._backoffice_statistics_snapshots_table.php | 40 ++ ..._order_source_to_shopping_orders_table.php | 29 ++ .../ENTWICKLUNGSKONZEPT-BACKOFFICE.md | 80 +++- resources/lang/de/navigation.php | 1 + resources/lang/en/navigation.php | 1 + resources/lang/es/navigation.php | 1 + resources/lang/fr/navigation.php | 1 + resources/views/admin/sales/_detail.blade.php | 9 + .../layouts/includes/layout-sidenav.blade.php | 11 + .../backoffice/statistics/details.blade.php | 279 ++++++++++++ .../backoffice/statistics/index.blade.php | 237 ++++++++++ .../views/web/templates/checkout.blade.php | 25 ++ routes/domains/crm.php | 6 + .../BackofficeStatisticsAccessTest.php | 342 +++++++++++++++ .../BackofficeDashboardServiceTest.php | 409 ++++++++++++++++++ 24 files changed, 2633 insertions(+), 9 deletions(-) create mode 100644 app/Console/Commands/BackofficeStoreStatisticsSnapshots.php create mode 100644 app/Http/Controllers/User/BackofficeStatisticsController.php create mode 100644 app/Models/BackofficeStatisticsSnapshot.php create mode 100644 app/Services/Backoffice/BackofficeDashboardService.php create mode 100644 app/Services/Backoffice/BackofficeDrilldownService.php create mode 100644 database/migrations/2026_05_18_115047_create_backoffice_statistics_snapshots_table.php create mode 100644 database/migrations/2026_05_18_134807_add_customer_order_source_to_shopping_orders_table.php create mode 100644 resources/views/user/backoffice/statistics/details.blade.php create mode 100644 resources/views/user/backoffice/statistics/index.blade.php create mode 100644 tests/Feature/BackofficeStatisticsAccessTest.php create mode 100644 tests/Unit/Services/BackofficeDashboardServiceTest.php diff --git a/app/Console/Commands/BackofficeStoreStatisticsSnapshots.php b/app/Console/Commands/BackofficeStoreStatisticsSnapshots.php new file mode 100644 index 0000000..4110d18 --- /dev/null +++ b/app/Console/Commands/BackofficeStoreStatisticsSnapshots.php @@ -0,0 +1,127 @@ +monthsToStore(); + $force = (bool) $this->option('force'); + + if ($months === []) { + $this->info('Keine abgeschlossenen Monate zum Speichern.'); + + return self::SUCCESS; + } + + $userQuery = User::query() + ->where('admin', '>=', 1) + ->where('admin', '<', 4) + ->whereNull('deleted_at'); + + if ($userId = $this->option('user')) { + $userQuery->where('id', $userId); + } + + $users = $userQuery->get(); + $this->info('Berechne Backoffice-Snapshots fuer '.$users->count().' User und '.count($months).' Monate...'); + + $bar = $this->output->createProgressBar($users->count()); + $bar->start(); + + $stored = 0; + $skipped = 0; + + foreach ($users as $user) { + foreach ($months as [$year, $month]) { + $exists = BackofficeStatisticsSnapshot::query() + ->where('user_id', $user->id) + ->where('year', $year) + ->where('month', $month) + ->exists(); + + if ($exists && ! $force) { + $skipped++; + + continue; + } + + $dashboardService->storeSnapshot($user, $month, $year); + $stored++; + } + + $bar->advance(); + gc_collect_cycles(); + } + + $bar->finish(); + $this->newLine(); + $this->info("Fertig. Gespeichert: {$stored}, uebersprungen: {$skipped}"); + + return self::SUCCESS; + } + + /** + * @return array + */ + private function monthsToStore(): array + { + $monthOption = $this->option('month'); + $yearOption = $this->option('year'); + + if ($monthOption && $yearOption) { + $month = max(1, min(12, (int) $monthOption)); + $year = (int) $yearOption; + + if (! $this->isClosedMonth($month, $year)) { + return []; + } + + return [[$year, $month]]; + } + + $months = []; + $cursor = Carbon::create(2026, 1, 1)->startOfMonth(); + $lastClosedMonth = now()->startOfMonth()->subMonth(); + + while ($cursor->lte($lastClosedMonth)) { + $months[] = [(int) $cursor->year, (int) $cursor->month]; + $cursor->addMonth(); + } + + return $months; + } + + private function isClosedMonth(int $month, int $year): bool + { + return Carbon::create($year, $month, 1)->endOfMonth()->lt(now()->startOfMonth()); + } +} diff --git a/app/Console/Kernel.php b/app/Console/Kernel.php index 113f653..6da640b 100644 --- a/app/Console/Kernel.php +++ b/app/Console/Kernel.php @@ -55,6 +55,9 @@ class Kernel extends ConsoleKernel // Abo-Chart-Snapshots: vergangene Monate einfrieren (nach allen Abo-Jobs) $schedule->command('abo:store-chart-snapshots')->dailyAt('04:30'); + // Backoffice-Statistik-Snapshots: abgeschlossene Monate fuer VIP-Statistiken einfrieren + $schedule->command('backoffice:store-statistics-snapshots')->dailyAt('04:45'); + // Incentive: Punkteberechnung täglich nach business:store-optimized $schedule->command('incentive:calculate')->dailyAt('05:00'); diff --git a/app/Http/Controllers/User/BackofficeStatisticsController.php b/app/Http/Controllers/User/BackofficeStatisticsController.php new file mode 100644 index 0000000..9524441 --- /dev/null +++ b/app/Http/Controllers/User/BackofficeStatisticsController.php @@ -0,0 +1,279 @@ +middleware('active.account'); + } + + public function index(Request $request): View + { + if (! $request->user()?->isVIP()) { + abort(404); + } + + [$selectedMonth, $selectedYear] = $this->selectedPeriod($request); + $startTime = microtime(true); + $statistics = $this->dashboardService->overview($request->user(), $selectedMonth, $selectedYear); + $performance = [ + 'duration_ms' => round((microtime(true) - $startTime) * 1000, 2), + 'source_label' => $statistics['_meta']['source_label'] ?? 'Live', + 'calculated_at' => $statistics['_meta']['calculated_at'] ?? null, + ]; + + return view('user.backoffice.statistics.index', [ + 'selectedMonth' => $selectedMonth, + 'selectedYear' => $selectedYear, + 'filterMonths' => HTMLHelper::getTransMonths(), + 'filterYears' => HTMLHelper::getYearRange(2022), + 'statistics' => $statistics, + 'performance' => $performance, + ]); + } + + public function details(Request $request): View + { + if (! $request->user()?->isVIP()) { + abort(404); + } + + [$selectedMonth, $selectedYear] = $this->selectedPeriod($request); + $line = (int) $request->get('line', 1); + $metric = (string) $request->get('metric', 'consultants'); + + return view('user.backoffice.statistics.details', [ + 'selectedMonth' => $selectedMonth, + 'selectedYear' => $selectedYear, + 'details' => $this->drilldownService->details($request->user(), $line, $metric, $selectedMonth, $selectedYear), + ]); + } + + public function export(Request $request): StreamedResponse + { + if (! $request->user()?->isVIP()) { + abort(404); + } + + [$selectedMonth, $selectedYear] = $this->selectedPeriod($request); + $line = (int) $request->get('line', 1); + $metric = (string) $request->get('metric', 'consultants'); + $details = $this->drilldownService->details($request->user(), $line, $metric, $selectedMonth, $selectedYear); + $filename = sprintf( + 'backoffice-statistik-%s-linie-%s-%02d-%d.csv', + $metric, + $line === 0 ? 'alle' : $line, + $selectedMonth, + $selectedYear + ); + + return response()->streamDownload(function () use ($details): void { + $output = fopen('php://output', 'w'); + + fwrite($output, "\xEF\xBB\xBF"); + fputcsv($output, $this->csvHeaders($details['metric']), ';'); + + foreach ($details['rows'] as $row) { + fputcsv($output, $this->csvRow($details['metric'], $row), ';'); + } + + fputcsv($output, []); + fputcsv($output, $this->csvSummaryRow($details), ';'); + + fclose($output); + }, $filename, [ + 'Content-Type' => 'text/csv; charset=UTF-8', + ]); + } + + public function overviewExport(Request $request): StreamedResponse + { + if (! $request->user()?->isVIP()) { + abort(404); + } + + [$selectedMonth, $selectedYear] = $this->selectedPeriod($request); + $statistics = $this->dashboardService->overview($request->user(), $selectedMonth, $selectedYear); + $filename = sprintf('backoffice-statistik-uebersicht-%02d-%d.csv', $selectedMonth, $selectedYear); + + return response()->streamDownload(function () use ($statistics): void { + $output = fopen('php://output', 'w'); + + fwrite($output, "\xEF\xBB\xBF"); + fputcsv($output, $this->overviewCsvHeaders(), ';'); + + foreach ($statistics['lines'] as $line) { + fputcsv($output, $this->overviewCsvRow($line), ';'); + } + + fputcsv($output, []); + fputcsv($output, $this->overviewCsvRow($statistics['totals']), ';'); + + fclose($output); + }, $filename, [ + 'Content-Type' => 'text/csv; charset=UTF-8', + ]); + } + + /** + * @return array{0: int, 1: int} + */ + private function selectedPeriod(Request $request): array + { + $selectedMonth = max(1, min(12, (int) $request->get('month', session(self::SESSION_MONTH_KEY, now()->month)))); + $selectedYear = (int) $request->get('year', session(self::SESSION_YEAR_KEY, now()->year)); + + session([ + self::SESSION_MONTH_KEY => $selectedMonth, + self::SESSION_YEAR_KEY => $selectedYear, + ]); + + return [$selectedMonth, $selectedYear]; + } + + /** + * @return string[] + */ + private function overviewCsvHeaders(): array + { + return [ + 'Linie', + 'Berater', + 'Neupartner', + 'Teamabos', + 'Neue Teamabos', + 'Teamkundenabos', + 'Neue Teamkundenabos', + 'Eigenpunkte', + 'Externe Punkte', + 'Kundenabo-Punkte', + 'Einzelbestellungs-Punkte', + 'Sonstige Kundenpunkte', + 'Gesamtpunkte', + '1000 Punkte Shop', + 'Umsatz Netto', + ]; + } + + /** + * @param array $row + * @return array + */ + private function overviewCsvRow(array $row): array + { + return [ + $row['label'] ?? '', + $row['consultants'] ?? 0, + $row['new_partners'] ?? 0, + $row['team_partner_abos'] ?? 0, + $row['team_partner_abos_new'] ?? 0, + $row['team_customer_abos'] ?? 0, + $row['team_customer_abos_new'] ?? 0, + $row['own_points'] ?? 0, + $row['external_points'] ?? 0, + $row['customer_abo_points'] ?? 0, + $row['customer_single_order_points'] ?? 0, + $row['customer_other_points'] ?? 0, + $row['total_points'] ?? 0, + $row['shop_1000'] ?? 0, + $row['turnover_net'] ?? 0, + ]; + } + + /** + * @return string[] + */ + private function csvHeaders(string $metric): array + { + if (in_array($metric, ['team_partner_abos', 'team_customer_abos'], true)) { + return ['Name', 'E-Mail', 'Karriere-Level', 'Berater', 'Abo-Punkte', 'Status', 'Status-Grund', 'Besteht seit', 'Naechste Ausfuehrung', 'Lieferungen']; + } + + if (in_array($metric, ['own_points', 'external_points', 'customer_abo_points', 'customer_single_order_points', 'customer_other_points', 'total_points', 'shop_1000'], true)) { + return ['Name', 'E-Mail', 'Karriere-Level', 'Eigenpunkte', 'Externe Punkte', 'Kundenabo-Punkte', 'Einzelbestellungs-Punkte', 'Sonstige Kundenpunkte', 'Gesamtpunkte']; + } + + return ['Name', 'E-Mail', 'Karriere-Level', 'Aktiv seit', 'Account gueltig bis', 'Account Status']; + } + + /** + * @param array $row + * @return array + */ + private function csvRow(string $metric, array $row): array + { + if (in_array($metric, ['team_partner_abos', 'team_customer_abos'], true)) { + return [ + $row['name'] ?? '', + $row['email'] ?? '', + $row['career_level'] ?? '', + $row['consultant_name'] ?? $row['name'] ?? '', + $row['points'] ?? 0, + $row['status_label'] ?? '', + $row['status_reason'] ?? '', + $row['start_date'] ?? '', + $row['next_date'] ?? '', + $row['deliveries'] ?? 0, + ]; + } + + if (in_array($metric, ['own_points', 'external_points', 'customer_abo_points', 'customer_single_order_points', 'customer_other_points', 'total_points', 'shop_1000'], true)) { + return [ + $row['name'] ?? '', + $row['email'] ?? '', + $row['career_level'] ?? '', + $row['own_points'] ?? 0, + $row['external_points'] ?? 0, + $row['customer_abo_points'] ?? 0, + $row['customer_single_order_points'] ?? 0, + $row['customer_other_points'] ?? 0, + $row['total_points'] ?? 0, + ]; + } + + return [ + $row['name'] ?? '', + $row['email'] ?? '', + $row['career_level'] ?? '', + $row['active_date'] ?? '', + $row['payment_account'] ?? '', + $row['account_status'] ?? '', + ]; + } + + /** + * @param array $details + * @return array + */ + private function csvSummaryRow(array $details): array + { + $metric = $details['metric']; + $summary = $details['summary']; + + if (in_array($metric, ['team_partner_abos', 'team_customer_abos'], true)) { + return ['Summe', $summary['count'].' Eintraege', '', '', $summary['points'], '', '', '', '', $summary['deliveries']]; + } + + if (in_array($metric, ['own_points', 'external_points', 'customer_abo_points', 'customer_single_order_points', 'customer_other_points', 'total_points', 'shop_1000'], true)) { + return ['Summe', $summary['count'].' Eintraege', '', $summary['own_points'], $summary['external_points'], $summary['customer_abo_points'], $summary['customer_single_order_points'], $summary['customer_other_points'], $summary['total_points']]; + } + + return ['Summe', $summary['count'].' Eintraege', '', '', '', '']; + } +} diff --git a/app/Http/Controllers/Web/CheckoutController.php b/app/Http/Controllers/Web/CheckoutController.php index adf9424..e9383ca 100644 --- a/app/Http/Controllers/Web/CheckoutController.php +++ b/app/Http/Controllers/Web/CheckoutController.php @@ -87,6 +87,7 @@ class CheckoutController extends Controller 'is_for' => $is_for, 'is_abo' => $is_abo, 'abo_interval' => $abo_interval, + 'customer_order_source_options' => ShoppingOrder::customerOrderSourceOptions(), 'shopping_data' => $shopping_data, 'user_shop' => Util::getUserShop(), 'shopping_user' => $shopping_user, @@ -225,6 +226,11 @@ class CheckoutController extends Controller 'accepted_data_checkbox' => 'accepted', ]; + if (Request::get('is_from') === 'shopping') { + $rules['customer_order_source'] = 'required|in:'.implode(',', array_keys(ShoppingOrder::customerOrderSourceOptions())); + $rules['customer_order_source_comment'] = 'nullable|string|max:500'; + } + if (Request::get('same_as_billing')) { $rules = array_merge($rules, [ 'shipping_firstname' => 'required', diff --git a/app/Models/BackofficeStatisticsSnapshot.php b/app/Models/BackofficeStatisticsSnapshot.php new file mode 100644 index 0000000..d4df187 --- /dev/null +++ b/app/Models/BackofficeStatisticsSnapshot.php @@ -0,0 +1,29 @@ + 'int', + 'year' => 'int', + 'month' => 'int', + 'payload' => 'array', + 'calculated_at' => 'datetime', + ]; + } +} diff --git a/app/Models/ShoppingOrder.php b/app/Models/ShoppingOrder.php index 5b6d89b..fd60876 100644 --- a/app/Models/ShoppingOrder.php +++ b/app/Models/ShoppingOrder.php @@ -167,6 +167,8 @@ class ShoppingOrder extends Model 'api_notice', 'api_status', 'mode', + 'customer_order_source', + 'customer_order_source_comment', 'shipped', 'tracking', ]; @@ -180,6 +182,29 @@ class ShoppingOrder extends Model 'points' => 'float', ]; + public const CUSTOMER_ORDER_SOURCE_OPTIONS = [ + 'recommendation' => 'Empfehlung', + 'social_media' => 'Social Media', + 'search_engine' => 'Google / Suchmaschine', + 'event' => 'Event / Messe', + 'consultant_link' => 'Berater-Link', + 'returning_customer' => 'Wiederbesteller', + 'other' => 'Sonstiges', + ]; + + /** + * @return array + */ + public static function customerOrderSourceOptions(): array + { + return self::CUSTOMER_ORDER_SOURCE_OPTIONS; + } + + public function getCustomerOrderSourceLabel(): string + { + return self::CUSTOMER_ORDER_SOURCE_OPTIONS[$this->customer_order_source] ?? ''; + } + public static $shippedTypes = [ 0 => 'open', 1 => 'in_process', diff --git a/app/Repositories/CheckoutRepository.php b/app/Repositories/CheckoutRepository.php index 9823d56..b47699e 100644 --- a/app/Repositories/CheckoutRepository.php +++ b/app/Repositories/CheckoutRepository.php @@ -28,6 +28,7 @@ class CheckoutRepository extends BaseRepository public function makeShoppingOrder($shopping_user, $data) { + $requestData = $data; $user_shop = Util::getUserShop(); if ($shopping_user->is_from === 'homeparty') { @@ -103,6 +104,11 @@ class CheckoutRepository extends BaseRepository 'txaction' => 'prev', 'mode' => Util::getUserShoppingMode(), ]; + + if ($shopping_user->is_from === 'shopping') { + $data['customer_order_source'] = $requestData['customer_order_source'] ?? null; + $data['customer_order_source_comment'] = $requestData['customer_order_source_comment'] ?? null; + } } $shopping_order = false; diff --git a/app/Services/Backoffice/BackofficeDashboardService.php b/app/Services/Backoffice/BackofficeDashboardService.php new file mode 100644 index 0000000..2f2019f --- /dev/null +++ b/app/Services/Backoffice/BackofficeDashboardService.php @@ -0,0 +1,365 @@ + + */ + public function metricLabels(): array + { + return [ + 'consultants' => 'Berater', + 'new_partners' => 'Neupartner', + 'team_partner_abos' => 'Teamabos', + 'team_customer_abos' => 'Teamkundenabos', + 'own_points' => 'Eigenpunkte', + 'external_points' => 'Externe Kundenpunkte', + 'customer_abo_points' => 'Kundenabo-Punkte', + 'customer_single_order_points' => 'Einzelbestellungs-Punkte', + 'customer_other_points' => 'Sonstige Kundenpunkte', + 'total_points' => 'Gesamtpunkte', + 'shop_1000' => '1000 Punkte Shop', + ]; + } + + /** + * @return array{month: int, year: int, metric_labels: array, lines: array>, totals: array} + */ + public function overview(User $user, int $month, int $year): array + { + if ($this->isClosedMonth($month, $year)) { + $snapshot = BackofficeStatisticsSnapshot::query() + ->where('user_id', $user->id) + ->where('month', $month) + ->where('year', $year) + ->first(); + + if ($snapshot) { + return $this->withMeta($snapshot->payload, 'snapshot', $snapshot->calculated_at?->format('d.m.Y H:i')); + } + } + + return $this->withMeta($this->buildOverview($user, $month, $year), 'live'); + } + + public function storeSnapshot(User $user, int $month, int $year): BackofficeStatisticsSnapshot + { + $payload = $this->buildOverview($user, $month, $year); + + return BackofficeStatisticsSnapshot::query()->updateOrCreate( + [ + 'user_id' => $user->id, + 'year' => $year, + 'month' => $month, + ], + [ + 'payload' => $payload, + 'calculated_at' => now(), + ] + ); + } + + /** + * @return array{month: int, year: int, metric_labels: array, lines: array>, totals: array} + */ + private function buildOverview(User $user, int $month, int $year): array + { + $lineBuckets = $this->lineBuckets($user->id); + $lines = []; + $totals = $this->emptyLine(0, []); + + foreach ($lineBuckets as $line => $users) { + $row = $this->buildLineRow($line, $users, $month, $year); + $lines[$line] = $row; + $totals = $this->addToTotals($totals, $row); + } + + $totals['label'] = 'Summe'; + + return [ + 'month' => $month, + 'year' => $year, + 'metric_labels' => $this->metricLabels(), + 'lines' => $lines, + 'totals' => $totals, + ]; + } + + /** + * @param array $overview + * @return array + */ + private function withMeta(array $overview, string $source, ?string $calculatedAt = null): array + { + $overview['_meta'] = [ + 'source' => $source, + 'source_label' => $source === 'snapshot' ? 'Snapshot' : 'Live', + 'calculated_at' => $calculatedAt, + ]; + + return $overview; + } + + private function isClosedMonth(int $month, int $year): bool + { + return Carbon::create($year, $month, 1)->endOfMonth()->lt(now()->startOfMonth()); + } + + /** + * @return array> + */ + public function lineBuckets(int $rootUserId): array + { + $lineBuckets = []; + $currentSponsorIds = [$rootUserId]; + $visitedUserIds = [$rootUserId]; + + for ($line = 1; $line <= self::MAX_DEPTH_SAFETY_LIMIT && $currentSponsorIds !== []; $line++) { + $users = User::query() + ->with('account') + ->whereIn('m_sponsor', $currentSponsorIds) + ->whereNotIn('id', $visitedUserIds) + ->whereColumn('id', '!=', 'm_sponsor') + ->whereNull('deleted_at') + ->get(); + + if ($users->isEmpty()) { + break; + } + + $lineBuckets[$line] = $users; + $currentSponsorIds = $users->pluck('id')->all(); + $visitedUserIds = array_merge($visitedUserIds, $currentSponsorIds); + } + + return $lineBuckets; + } + + /** + * @return int[] + */ + public function lineUserIds(int $rootUserId, int $line): array + { + if ($line === 0) { + return $this->teamUserIds($rootUserId); + } + + if ($line < 1) { + return []; + } + + return ($this->lineBuckets($rootUserId)[$line] ?? collect())->pluck('id')->map(fn ($id) => (int) $id)->all(); + } + + /** + * @return int[] + */ + public function teamUserIds(int $rootUserId): array + { + return collect($this->lineBuckets($rootUserId)) + ->flatMap(fn (Collection $users) => $users->pluck('id')) + ->map(fn ($id) => (int) $id) + ->values() + ->all(); + } + + /** + * @param \Illuminate\Support\Collection $users + * @return array + */ + private function buildLineRow(int $line, Collection $users, int $month, int $year): array + { + $userIds = $users->pluck('id')->map(fn ($id) => (int) $id)->all(); + + if ($userIds === []) { + return $this->emptyLine($line, $userIds); + } + + $salesSummary = $this->salesSummary($userIds, $month, $year); + + return [ + 'line' => $line, + 'label' => 'Linie '.$line, + 'user_ids' => $userIds, + 'consultants' => $this->activeConsultants($users), + 'new_partners' => $this->newPartners($users, $month, $year), + 'team_partner_abos' => $this->activeAboQuery()->whereIn('user_id', $userIds)->where('is_for', 'me')->count(), + 'team_partner_abos_new' => $this->newAboCount($userIds, 'user_id', 'me', $month, $year), + 'team_customer_abos' => $this->activeAboQuery()->whereIn('member_id', $userIds)->where('is_for', 'ot')->count(), + 'team_customer_abos_new' => $this->newAboCount($userIds, 'member_id', 'ot', $month, $year), + 'own_points' => $salesSummary['own_points'], + 'external_points' => $salesSummary['external_points'], + 'customer_abo_points' => $salesSummary['customer_abo_points'], + 'customer_single_order_points' => $salesSummary['customer_single_order_points'], + 'customer_other_points' => $salesSummary['customer_other_points'], + 'total_points' => $salesSummary['total_points'], + 'turnover_net' => $salesSummary['turnover_net'], + 'shop_1000' => $this->shop1000Count($userIds, $month, $year), + ]; + } + + /** + * @return array + */ + private function emptyLine(int $line, array $userIds): array + { + return [ + 'line' => $line, + 'label' => $line > 0 ? 'Linie '.$line : 'Summe', + 'user_ids' => $userIds, + 'consultants' => 0, + 'new_partners' => 0, + 'team_partner_abos' => 0, + 'team_partner_abos_new' => 0, + 'team_customer_abos' => 0, + 'team_customer_abos_new' => 0, + 'own_points' => 0.0, + 'external_points' => 0.0, + 'customer_abo_points' => 0.0, + 'customer_single_order_points' => 0.0, + 'customer_other_points' => 0.0, + 'total_points' => 0.0, + 'turnover_net' => 0.0, + 'shop_1000' => 0, + ]; + } + + /** + * @param array $totals + * @param array $row + * @return array + */ + private function addToTotals(array $totals, array $row): array + { + foreach (array_keys($this->metricLabels()) as $metric) { + $totals[$metric] += $row[$metric]; + } + + $totals['turnover_net'] += $row['turnover_net']; + $totals['team_partner_abos_new'] += $row['team_partner_abos_new']; + $totals['team_customer_abos_new'] += $row['team_customer_abos_new']; + $totals['user_ids'] = array_merge($totals['user_ids'], $row['user_ids']); + + return $totals; + } + + /** + * @param \Illuminate\Support\Collection $users + */ + private function activeConsultants(Collection $users): int + { + return $users + ->filter(fn (User $user) => $user->m_level !== null && $user->payment_account !== null) + ->count(); + } + + /** + * @param \Illuminate\Support\Collection $users + */ + private function newPartners(Collection $users, int $month, int $year): int + { + $startDate = Carbon::create($year, $month, 1)->startOfMonth(); + $endDate = Carbon::create($year, $month, 1)->endOfMonth(); + + return $users + ->filter(function (User $user) use ($startDate, $endDate): bool { + if ($user->m_level === null || $user->active_date === null || ! $this->hasActivePaymentAccount($user)) { + return false; + } + + $activeDate = Carbon::parse($user->active_date); + + return $activeDate->betweenIncluded($startDate, $endDate); + }) + ->count(); + } + + private function hasActivePaymentAccount(User $user): bool + { + return $user->payment_account !== null && Carbon::parse($user->payment_account)->isFuture(); + } + + private function activeAboQuery(): Builder + { + return UserAbo::query() + ->where('active', true) + ->whereNotIn('status', [4, 5, 6]); + } + + /** + * @param int[] $userIds + */ + private function newAboCount(array $userIds, string $userColumn, string $isFor, int $month, int $year): int + { + $startDate = Carbon::create($year, $month, 1)->startOfMonth(); + $endDate = Carbon::create($year, $month, 1)->endOfMonth(); + + return $this->activeAboQuery() + ->whereIn($userColumn, $userIds) + ->where('is_for', $isFor) + ->whereBetween('start_date', [$startDate, $endDate]) + ->count(); + } + + /** + * @param int[] $userIds + * @return array{own_points: float, external_points: float, customer_abo_points: float, customer_single_order_points: float, customer_other_points: float, total_points: float, turnover_net: float} + */ + private function salesSummary(array $userIds, int $month, int $year): array + { + $summary = UserSalesVolume::query() + ->leftJoin('shopping_orders', 'shopping_orders.id', '=', 'user_sales_volumes.shopping_order_id') + ->whereIn('user_id', $userIds) + ->where('month', $month) + ->where('year', $year) + ->selectRaw('COALESCE(SUM(month_KP_points), 0) as own_points') + ->selectRaw('COALESCE(SUM(month_shop_points), 0) as external_points') + ->selectRaw('COALESCE(SUM(CASE WHEN shopping_orders.is_abo = 1 THEN month_shop_points ELSE 0 END), 0) as customer_abo_points') + ->selectRaw('COALESCE(SUM(CASE WHEN shopping_orders.id IS NOT NULL AND (shopping_orders.is_abo = 0 OR shopping_orders.is_abo IS NULL) THEN month_shop_points ELSE 0 END), 0) as customer_single_order_points') + ->selectRaw('COALESCE(SUM(CASE WHEN shopping_orders.id IS NULL THEN month_shop_points ELSE 0 END), 0) as customer_other_points') + ->selectRaw('COALESCE(SUM(month_total_net), 0) + COALESCE(SUM(month_shop_total_net), 0) as turnover_net') + ->first(); + + $ownPoints = (float) ($summary->own_points ?? 0); + $externalPoints = (float) ($summary->external_points ?? 0); + + return [ + 'own_points' => $ownPoints, + 'external_points' => $externalPoints, + 'customer_abo_points' => (float) ($summary->customer_abo_points ?? 0), + 'customer_single_order_points' => (float) ($summary->customer_single_order_points ?? 0), + 'customer_other_points' => (float) ($summary->customer_other_points ?? 0), + 'total_points' => $ownPoints + $externalPoints, + 'turnover_net' => (float) ($summary->turnover_net ?? 0), + ]; + } + + /** + * @param int[] $userIds + */ + private function shop1000Count(array $userIds, int $month, int $year): int + { + return UserSalesVolume::query() + ->whereIn('user_id', $userIds) + ->where('month', $month) + ->where('year', $year) + ->select('user_id') + ->selectRaw('COALESCE(SUM(month_KP_points), 0) + COALESCE(SUM(month_shop_points), 0) as total_points') + ->groupBy('user_id') + ->havingRaw('COALESCE(SUM(month_KP_points), 0) + COALESCE(SUM(month_shop_points), 0) >= 1000') + ->get() + ->count(); + } +} diff --git a/app/Services/Backoffice/BackofficeDrilldownService.php b/app/Services/Backoffice/BackofficeDrilldownService.php new file mode 100644 index 0000000..8f8da4f --- /dev/null +++ b/app/Services/Backoffice/BackofficeDrilldownService.php @@ -0,0 +1,331 @@ +>, summary: array} + */ + public function details(User $viewer, int $line, string $metric, int $month, int $year): array + { + $metricLabels = $this->dashboardService->metricLabels(); + + if (! array_key_exists($metric, $metricLabels)) { + abort(404); + } + + $userIds = $this->dashboardService->lineUserIds($viewer->id, $line); + $rows = match ($metric) { + 'consultants' => $this->consultantRows($userIds), + 'new_partners' => $this->newPartnerRows($userIds, $month, $year), + 'team_partner_abos' => $this->partnerAboRows($userIds, $month, $year), + 'team_customer_abos' => $this->customerAboRows($userIds, $month, $year), + 'own_points', 'external_points', 'customer_abo_points', 'customer_single_order_points', 'customer_other_points', 'total_points', 'shop_1000' => $this->pointsRows($userIds, $month, $year, $metric), + default => [], + }; + + return [ + 'metric' => $metric, + 'metric_label' => $metricLabels[$metric], + 'line' => $line, + 'line_label' => $line > 0 ? 'Linie '.$line : 'Alle Linien', + 'month' => $month, + 'year' => $year, + 'rows' => $rows, + 'summary' => $this->summary($rows), + ]; + } + + /** + * @param array> $rows + * @return array{count: int, points: float, own_points: float, external_points: float, customer_abo_points: float, customer_single_order_points: float, customer_other_points: float, total_points: float, deliveries: int} + */ + private function summary(array $rows): array + { + return [ + 'count' => count($rows), + 'points' => (float) collect($rows)->sum('points'), + 'own_points' => (float) collect($rows)->sum('own_points'), + 'external_points' => (float) collect($rows)->sum('external_points'), + 'customer_abo_points' => (float) collect($rows)->sum('customer_abo_points'), + 'customer_single_order_points' => (float) collect($rows)->sum('customer_single_order_points'), + 'customer_other_points' => (float) collect($rows)->sum('customer_other_points'), + 'total_points' => (float) collect($rows)->sum('total_points'), + 'deliveries' => (int) collect($rows)->sum('deliveries'), + ]; + } + + /** + * @param int[] $userIds + * @return array> + */ + private function consultantRows(array $userIds): array + { + return User::query() + ->with(['account', 'user_level']) + ->whereIn('id', $userIds) + ->whereNotNull('m_level') + ->whereNotNull('payment_account') + ->orderBy('id') + ->get() + ->map(fn (User $user) => [ + 'type' => 'user', + 'user_id' => $user->id, + 'name' => $this->userName($user), + 'email' => $user->email, + 'career_level' => $this->careerLevel($user), + 'is_account_active' => Carbon::parse($user->payment_account)->isFuture(), + 'account_status' => Carbon::parse($user->payment_account)->isFuture() ? 'Aktiv' : 'Abgelaufen', + 'active_date' => $this->formatDate($user->active_date), + 'payment_account' => $this->formatDate($user->payment_account), + ]) + ->values() + ->all(); + } + + /** + * @param int[] $userIds + * @return array> + */ + private function newPartnerRows(array $userIds, int $month, int $year): array + { + $startDate = Carbon::create($year, $month, 1)->startOfMonth(); + $endDate = Carbon::create($year, $month, 1)->endOfMonth(); + + return User::query() + ->with(['account', 'user_level']) + ->whereIn('id', $userIds) + ->whereNotNull('m_level') + ->whereNotNull('payment_account') + ->whereBetween('active_date', [$startDate, $endDate]) + ->orderBy('active_date') + ->get() + ->filter(fn (User $user) => Carbon::parse($user->payment_account)->isFuture()) + ->map(fn (User $user) => [ + 'type' => 'user', + 'user_id' => $user->id, + 'name' => $this->userName($user), + 'email' => $user->email, + 'career_level' => $this->careerLevel($user), + 'active_date' => $this->formatDate($user->active_date), + 'payment_account' => $this->formatDate($user->payment_account), + ]) + ->values() + ->all(); + } + + /** + * @param int[] $userIds + * @return array> + */ + private function partnerAboRows(array $userIds, int $month, int $year): array + { + return $this->activeAboQuery() + ->with(['user.account', 'user.user_level', 'user_abo_items.product', 'user_abo_orders.shopping_order.shopping_payments.payment_transactions']) + ->whereIn('user_id', $userIds) + ->where('is_for', 'me') + ->orderBy('next_date') + ->get() + ->map(fn (UserAbo $abo) => [ + 'type' => 'abo', + 'abo_id' => $abo->id, + 'user_id' => $abo->user_id, + 'name' => $abo->user ? $this->userName($abo->user) : '#'.$abo->user_id, + 'email' => $abo->user?->email, + 'career_level' => $abo->user ? $this->careerLevel($abo->user) : '-', + 'points' => $abo->getTotalPoints(), + 'start_date' => $this->formatDate($abo->getRawOriginal('start_date')), + 'is_new_this_month' => $this->isAboNewInMonth($abo, $month, $year), + 'next_date' => $this->formatDate($abo->next_date), + 'deliveries' => $abo->getCountOrders(), + 'status' => $abo->status, + 'status_label' => $abo->getStatusType(), + 'status_badge' => $abo->getStatusFormated(), + 'status_reason' => $this->aboStatusReason($abo), + ]) + ->values() + ->all(); + } + + /** + * @param int[] $userIds + * @return array> + */ + private function customerAboRows(array $userIds, int $month, int $year): array + { + return $this->activeAboQuery() + ->with(['member.account', 'member.user_level', 'user.account', 'user_abo_items.product', 'user_abo_orders.shopping_order.shopping_payments.payment_transactions']) + ->whereIn('member_id', $userIds) + ->where('is_for', 'ot') + ->orderBy('member_id') + ->orderBy('next_date') + ->get() + ->map(fn (UserAbo $abo) => [ + 'type' => 'customer_abo', + 'abo_id' => $abo->id, + 'user_id' => $abo->user_id, + 'member_id' => $abo->member_id, + 'name' => $abo->user ? $this->userName($abo->user) : ($abo->email ?: '#'.$abo->user_id), + 'email' => $abo->email ?: $abo->user?->email, + 'consultant_name' => $abo->member ? $this->userName($abo->member) : '#'.$abo->member_id, + 'career_level' => $abo->member ? $this->careerLevel($abo->member) : '-', + 'points' => $abo->getTotalPoints(), + 'start_date' => $this->formatDate($abo->getRawOriginal('start_date')), + 'is_new_this_month' => $this->isAboNewInMonth($abo, $month, $year), + 'next_date' => $this->formatDate($abo->next_date), + 'deliveries' => $abo->getCountOrders(), + 'status' => $abo->status, + 'status_label' => $abo->getStatusType(), + 'status_badge' => $abo->getStatusFormated(), + 'status_reason' => $this->aboStatusReason($abo), + ]) + ->values() + ->all(); + } + + /** + * @param int[] $userIds + * @return array> + */ + private function pointsRows(array $userIds, int $month, int $year, string $metric): array + { + $rows = UserSalesVolume::query() + ->leftJoin('shopping_orders', 'shopping_orders.id', '=', 'user_sales_volumes.shopping_order_id') + ->whereIn('user_id', $userIds) + ->where('month', $month) + ->where('year', $year) + ->select('user_sales_volumes.user_id') + ->selectRaw('COALESCE(SUM(month_KP_points), 0) as own_points') + ->selectRaw('COALESCE(SUM(month_shop_points), 0) as external_points') + ->selectRaw('COALESCE(SUM(CASE WHEN shopping_orders.is_abo = 1 THEN month_shop_points ELSE 0 END), 0) as customer_abo_points') + ->selectRaw('COALESCE(SUM(CASE WHEN shopping_orders.id IS NOT NULL AND (shopping_orders.is_abo = 0 OR shopping_orders.is_abo IS NULL) THEN month_shop_points ELSE 0 END), 0) as customer_single_order_points') + ->selectRaw('COALESCE(SUM(CASE WHEN shopping_orders.id IS NULL THEN month_shop_points ELSE 0 END), 0) as customer_other_points') + ->selectRaw('COALESCE(SUM(month_KP_points), 0) + COALESCE(SUM(month_shop_points), 0) as total_points') + ->groupBy('user_sales_volumes.user_id'); + + if ($metric === 'own_points') { + $rows->havingRaw('COALESCE(SUM(month_KP_points), 0) > 0'); + } + + if ($metric === 'external_points') { + $rows->havingRaw('COALESCE(SUM(month_shop_points), 0) > 0'); + } + + if ($metric === 'customer_abo_points') { + $rows->havingRaw('COALESCE(SUM(CASE WHEN shopping_orders.is_abo = 1 THEN month_shop_points ELSE 0 END), 0) > 0'); + } + + if ($metric === 'customer_single_order_points') { + $rows->havingRaw('COALESCE(SUM(CASE WHEN shopping_orders.id IS NOT NULL AND (shopping_orders.is_abo = 0 OR shopping_orders.is_abo IS NULL) THEN month_shop_points ELSE 0 END), 0) > 0'); + } + + if ($metric === 'customer_other_points') { + $rows->havingRaw('COALESCE(SUM(CASE WHEN shopping_orders.id IS NULL THEN month_shop_points ELSE 0 END), 0) > 0'); + } + + if ($metric === 'total_points') { + $rows->havingRaw('COALESCE(SUM(month_KP_points), 0) + COALESCE(SUM(month_shop_points), 0) > 0'); + } + + if ($metric === 'shop_1000') { + $rows->havingRaw('COALESCE(SUM(month_KP_points), 0) + COALESCE(SUM(month_shop_points), 0) >= 1000'); + } + + $salesRows = $rows->orderByDesc('total_points')->get(); + $users = User::query()->with(['account', 'user_level'])->whereIn('id', $salesRows->pluck('user_id'))->get()->keyBy('id'); + + return $salesRows + ->map(fn (UserSalesVolume $row) => [ + 'type' => 'points', + 'user_id' => $row->user_id, + 'name' => $users->has($row->user_id) ? $this->userName($users->get($row->user_id)) : '#'.$row->user_id, + 'email' => $users->get($row->user_id)?->email, + 'career_level' => $users->has($row->user_id) ? $this->careerLevel($users->get($row->user_id)) : '-', + 'own_points' => (float) $row->own_points, + 'external_points' => (float) $row->external_points, + 'customer_abo_points' => (float) $row->customer_abo_points, + 'customer_single_order_points' => (float) $row->customer_single_order_points, + 'customer_other_points' => (float) $row->customer_other_points, + 'total_points' => (float) $row->total_points, + ]) + ->values() + ->all(); + } + + private function activeAboQuery(): Builder + { + return UserAbo::query() + ->where('active', true) + ->whereNotIn('status', [4, 5, 6]); + } + + private function userName(User $user): string + { + $name = trim(($user->account?->first_name ?? '').' '.($user->account?->last_name ?? '')); + + return $name !== '' ? $name : ($user->email ?: '#'.$user->id); + } + + private function careerLevel(User $user): string + { + return $user->user_level?->name ?: ($user->m_level ? 'Level '.$user->m_level : '-'); + } + + private function aboStatusReason(UserAbo $abo): ?string + { + if ((int) $abo->status === 2) { + return null; + } + + $transaction = $abo->user_abo_orders + ->sortByDesc('created_at') + ->pluck('shopping_order') + ->filter() + ->map(fn ($order) => $order->getLastShoppingPaymentTransaction()) + ->filter() + ->first(); + + if (! $transaction) { + return null; + } + + $message = $transaction->errormessage ?: $transaction->customermessage; + + if (! $message) { + return null; + } + + return $transaction->errorcode ? '['.$transaction->errorcode.'] '.$message : $message; + } + + private function isAboNewInMonth(UserAbo $abo, int $month, int $year): bool + { + $startDate = $abo->getRawOriginal('start_date'); + + if (! $startDate) { + return false; + } + + $date = Carbon::parse($startDate); + + return (int) $date->month === $month && (int) $date->year === $year; + } + + private function formatDate(mixed $date): ?string + { + if ($date === null || $date === '') { + return null; + } + + return Carbon::parse($date)->format('d.m.Y'); + } +} diff --git a/database/migrations/2026_05_18_115047_create_backoffice_statistics_snapshots_table.php b/database/migrations/2026_05_18_115047_create_backoffice_statistics_snapshots_table.php new file mode 100644 index 0000000..b59aa88 --- /dev/null +++ b/database/migrations/2026_05_18_115047_create_backoffice_statistics_snapshots_table.php @@ -0,0 +1,40 @@ +id(); + $table->unsignedInteger('user_id'); + $table->unsignedSmallInteger('year'); + $table->unsignedTinyInteger('month'); + $table->json('payload'); + $table->timestamp('calculated_at')->nullable(); + $table->timestamps(); + + $table->unique(['user_id', 'year', 'month'], 'backoffice_statistics_snapshot_unique'); + $table->index(['year', 'month']); + + $table->foreign('user_id') + ->references('id') + ->on('users') + ->onDelete('cascade'); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::dropIfExists('backoffice_statistics_snapshots'); + } +}; diff --git a/database/migrations/2026_05_18_134807_add_customer_order_source_to_shopping_orders_table.php b/database/migrations/2026_05_18_134807_add_customer_order_source_to_shopping_orders_table.php new file mode 100644 index 0000000..22b3ea8 --- /dev/null +++ b/database/migrations/2026_05_18_134807_add_customer_order_source_to_shopping_orders_table.php @@ -0,0 +1,29 @@ +string('customer_order_source')->nullable()->after('mode'); + $table->text('customer_order_source_comment')->nullable()->after('customer_order_source'); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::table('shopping_orders', function (Blueprint $table) { + $table->dropColumn(['customer_order_source', 'customer_order_source_comment']); + }); + } +}; diff --git a/dev/2026-05-13-backoffice/ENTWICKLUNGSKONZEPT-BACKOFFICE.md b/dev/2026-05-13-backoffice/ENTWICKLUNGSKONZEPT-BACKOFFICE.md index 503f52f..d1b94a8 100644 --- a/dev/2026-05-13-backoffice/ENTWICKLUNGSKONZEPT-BACKOFFICE.md +++ b/dev/2026-05-13-backoffice/ENTWICKLUNGSKONZEPT-BACKOFFICE.md @@ -10,7 +10,7 @@ Das Partner-Backoffice soll von einer statischen Monats-Kachelansicht zu einer k Kernziel ist eine einheitliche Datenbasis für: -- Dashboard-Kennzahlen pro Linie 1 bis 8 inklusive Summenzeile +- Dashboard-Kennzahlen pro vorhandener Linie inklusive Summenzeile - Detailansichten je Linie, Firstline und Kennzahl - Kundenabos, Teamabos, Kundenabos im Team, Umsatz und Punkte - neue Spezial-Kennzahl "1000 Punkte Shop" @@ -37,7 +37,7 @@ Die aktuelle Statistik wird direkt im Blade-Partial berechnet. Sie nutzt unter a Bewertung: - Die vorhandene Ansicht ist ein guter Einstieg, aber fachlich zu grob. -- Es gibt noch keine Linien 1 bis 8, keine Summenzeile und keine Drill-down-Routen. +- Es gibt noch keine dynamische Linienübersicht, keine Summenzeile und keine Drill-down-Routen. - Die Kennzahl "Kundenabos" vermischt aktuell eigene Abos und Kundenabos. - "Team-Abos" zählt aktuell Beraterabos im Team, aber nicht die separat geforderten Kundenabos im Team. @@ -296,6 +296,48 @@ Geplante Actions: Die vorhandenen Team-Views bleiben bestehen. Das neue Dashboard verweist für Detailseiten aber auf eigene, schlankere Statistik-Views. +### Erster MVP: VIP-Menüpunkt Statistik + +Der erste technische Schritt ist ein eigener Navigationspunkt `Statistik` im User-Backoffice. Dieser Punkt ist bewusst nicht unter `Mein Team` einsortiert, sondern als eigener Einstieg sichtbar, damit die neue Auswertung fachlich als eigenes Modul wahrgenommen wird. + +Bereits angelegte Basis: + +- Route: `GET /user/backoffice/statistics` +- Detailroute: `GET /user/backoffice/statistics/details?line=...&metric=...&month=...&year=...` +- Name: `user_backoffice_statistics` +- Controller: `App\Http\Controllers\User\BackofficeStatisticsController@index` +- View: `resources/views/user/backoffice/statistics/index.blade.php` +- Services: `App\Services\Backoffice\BackofficeDashboardService` und `App\Services\Backoffice\BackofficeDrilldownService` +- Navigation: eigener Sidenav-Punkt `Statistik` mit `VIP`-Badge +- Zugriff: nur `Auth::user()->isVIP()`, normale aktive User erhalten `404` +- MVP-Übersicht: alle tatsächlich vorhandenen Linien mit Beratern inklusive abgelaufener, aber nicht gelöschter Accounts, Neupartnern, Teamabos, Teamkundenabos, Eigenpunkten, externen Punkten, Kundenabo-Punkten, Einzelbestellungs-Punkten, sonstigen Kundenpunkten, Gesamtpunkten und `1000 Punkte Shop` +- MVP-Details: klickbare Listen für Berater, Neupartner, Teamabos, Teamkundenabos und Punktekennzahlen +- Summenzeile: ebenfalls klickbar über `line=0` und damit Detailansicht über alle vorhandenen Linien +- Berater-Detail: abgelaufene, aber nicht gelöschte Accounts werden mit roter Tabellenzeile und Status `Abgelaufen` angezeigt +- Abo-Detail: zeigt `Besteht seit`; neue Abos im gewählten Monat werden grün markiert und in der Übersicht als Klammerzahl hinter den Abo-Counts gezeigt +- Zeitraum: gewählter Monat/Jahr bleibt über Query-Parameter und Session erhalten, auch beim Rücksprung aus Detailansichten +- Detailtabellen: clientseitige Suche, klickbare Sortierung der Spalten und Summenzeile am Tabellenende +- Detailtabellen: CSV-Export über `GET /user/backoffice/statistics/export?line=...&metric=...&month=...&year=...` +- Übersicht: Kennzahlen werden als gut anklickbare Badges dargestellt; Nullwerte bleiben sichtbar, aber nicht klickbar +- Punktetrennung: externe Kundenpunkte werden zusätzlich nach Kundenabo-Punkten, Einzelbestellungs-Punkten und sonstigen Kundenpunkten getrennt +- Snapshots: abgeschlossene Monate können in `backoffice_statistics_snapshots` gespeichert werden; vorhandene Snapshots werden bevorzugt vor Live-Berechnung geladen +- Command: `backoffice:store-statistics-snapshots` speichert Snapshots für VIP-User, optional mit `--user`, `--month`, `--year` und `--force` +- 1000-Punkte-Shop: Detailansicht zeigt die getrennten Punkte nach Eigen-, Abo-, Einzelbestellungs- und sonstigen Kundenpunkten; die vorherige Qualifikations-Einteilung wurde zugunsten des aktuellen Karriere-Levels entfernt +- Karriere-Level: Detailansichten zeigen den aktuellen Karriere-Level des jeweiligen Beraters +- Datenschutz: Detailansichten zeigen einen sichtbaren Hinweis, dass personenbezogene Detaildaten 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, inklusive Summenzeile, neuer Abo-Zählungen und Punktetrennung +- Tests: CSV-Inhalte, Detail-CSV mit Karriere-Level, Zeitraum-Erhalt, neue Abo-Markierung und Abo-Statusgrund aus Zahlungsfehlern sind gezielt abgesichert +- Performance-Hinweis: Die Übersicht zeigt Datenquelle (`Live` oder `Snapshot`) und Laufzeit der Berechnung, um große VIP-Teams leichter prüfen zu können +- Checkout-Herkunft: Kundenbestellungen im Shop speichern eine vordefinierte Herkunft plus optionalen Freitext und zeigen diese im Bestelldetail an + +Weitere Umsetzung nach dem MVP: + +1. Snapshot-Command nach Migration auf Test-/Produktivdaten einmalig für abgeschlossene Monate laufen lassen. +2. Umsatzarten weiter fachlich verfeinern, falls neben `shopping_orders.is_abo` zusätzliche Herkunftsarten zuverlässig gespeichert werden. +3. Optional Excel-Export ergänzen, falls CSV für den Fachbereich nicht reicht. +4. Spätere rechtliche Einschränkungen für Kundendaten nach finaler Klärung einarbeiten. +5. `1000 Punkte Shop` nach fachlicher Abnahme ggf. um weitere Herkunftsarten erweitern. + ### Datenmodell und Definitionen Einheitliche Metriken: @@ -325,7 +367,7 @@ Jede Detailansicht muss sicherstellen: - Fachliche Definitionen finalisieren - `BackofficeDashboardService` erstellen -- Stufe-1-Linienübersicht mit Linien 1 bis 8 und Summenzeile bauen +- Stufe-1-Linienübersicht mit allen tatsächlich vorhandenen Linien und Summenzeile bauen - bestehende Dashboard-Kachel durch Link auf neue Statistikseite ergänzen oder neue Seite im Menü aufnehmen - Kennzahlen noch ohne vollständigen Deep Dive, aber bereits sauber berechnet @@ -346,10 +388,10 @@ Jede Detailansicht muss sicherstellen: ### Phase 4: Herkunftsabfrage im Checkout -- Migration und Model-Fillable ergänzen -- Checkout-Formular erweitern -- Validierung und Speicherung ergänzen -- Admin-/Bestelldetail oder Export um Feld erweitern +- umgesetzt: Migration `shopping_orders.customer_order_source` und `customer_order_source_comment` +- umgesetzt: Checkout-Formular für Shop-Kundenbestellungen (`is_from = shopping`) mit Auswahlfeld plus optionalem Freitext +- umgesetzt: Validierung und Speicherung in `CheckoutController` / `CheckoutRepository` +- umgesetzt: Anzeige im Bestelldetail für `payment_for = 6` ### Phase 5: Storno-Qualitätssicherung @@ -370,7 +412,7 @@ Jede Detailansicht muss sicherstellen: Feature-Tests: - Dashboard zeigt nur Daten der eigenen Downline. -- Linien 1 bis 8 werden korrekt gruppiert. +- alle tatsächlich vorhandenen Linien werden korrekt gruppiert. - Summenzeile entspricht Summe der Linien. - Klick auf Teamabos zeigt nur `is_for = 'me'` in der Downline. - Klick auf Kundenabos im Team zeigt nur `is_for = 'ot'` mit `member_id` in der Downline. @@ -395,13 +437,21 @@ Regressionsprüfung: ## Offene Fachfragen 1. Soll die neue Statistik die aktuelle Monatslogik nutzen oder standardmäßig den letzten abgeschlossenen Monat zeigen? +//beides, Wichtig ist immer der aktuelle Monat, das hier um Qualität zahlen gibt und natürlich auch die letzten abgeschlossenen Monat. Hier können wir natürlich auch in Datenbanken entsprechend die Kennzahlen speichern und nicht immer hohe Quere an die Datenbank zu senden. 2. Sollen Stornos im Stornomonat oder im ursprünglichen Umsatzmonat gegengerechnet werden? +//ist noch zu prüfen Wird umgesetzt, sobald hier eine deutliche Klärung stattgefunden hat 3. Wie genau wird "1000 Punkte Shop" definiert: nur Shop-Punkte, alle Kundenpunkte oder Kundenabos plus Einzelbestellungen? +Ich würde hier erst mal alle Punkte zusammenziehen also Kunden funkte Kunden Abos Einzelbestellungen etc. alles was in die einzelnen Punkte des Kunden geht. Zusätzlich würde ich's einmal trennen nach Eigenpunkten und externen Punkten. D.h. grundsätzlich würde ich hier auch eine Trennung vornehmen der einzelnen Shop Punkte, Kunden, Abos, Einzelbestellung etc. 4. Welche Kundendaten dürfen Berater in Deep-Dive-Listen sehen? +In der Entwicklung zeigen wir erst mal die gesamten Inhalte an. Mit einem Hinweis wird gerade rechtlich geklärt. 5. Ist die Herkunftsabfrage Freitext, Auswahlfeld oder Kombination? +Kombination ein Auswahlfeld von vordefinierten Sachen alternativ Freitext 6. Gilt die Herkunftsabfrage für alle Checkout-Flows oder nur für externe Kundenbestellungen? +Nur für Kundenbestellung in den Shops 7. Darf eine Incentive-Teilnahme bereits Name/Foto/Land freigeben oder braucht es ein separates Opt-in? -8. Soll das Event-Archiv nur Bilder und Texte enthalten oder eine echte Galerie mit Mehrfachuploads? +Auch hier befindet sich noch beim Rechtsanwalt in Klärung. Hier würde ich erst mal einbauen Und mit Hinweisen versehen, die dann gegebenenfalls später rausgenommen werden müssen +8. Soll das Event-Archiv nur Bilder und Texte enthalten oder eine echte Galerie mit Mehrfachuploads? +Echte Galerie mit mehrfach Upload ## Empfehlung @@ -415,3 +465,15 @@ Priorität für die erste Umsetzung: 4. 1000-Punkte-Shop 5. Checkout-Herkunft und Storno-Tests 6. Incentive-Sichtbarkeit und Event-Archiv + + + +Snapshots/Caching: Abgeschlossene Monate speichern, damit große Teams nicht jedes Mal live berechnet werden. +php artisan migrate +php artisan backoffice:store-statistics-snapshots +php artisan list backoffice +1000-Punkte-Shop verfeinert: zählt weiter Berater ab 1000 Gesamtpunkten und zeigt in der Detailansicht den aktuellen Karriere-Level statt einer fachlich erklärungsbedürftigen Qualifikations-Einteilung. +Datenschutz-Hinweis umgesetzt: Detailansichten weisen sichtbar darauf hin, dass personenbezogene Daten rechtlich noch final geklärt werden. +Übersichts-Export umgesetzt: CSV-Export steht auch direkt in der Linienübersicht zur Verfügung. +Tests ausgebaut: CSV-Inhalte und Zeitraum-Erhalt sind näher am Controller-/Export-Flow abgesichert; Abo-Statusgrund und neue Abo-Markierung sind im Service-Test abgedeckt. +Performance prüfen: Bei echten VIP-Accounts mit großem Team messen, ob die Live-Queries schnell genug sind. \ No newline at end of file diff --git a/resources/lang/de/navigation.php b/resources/lang/de/navigation.php index d82edf3..bb1b357 100644 --- a/resources/lang/de/navigation.php +++ b/resources/lang/de/navigation.php @@ -47,6 +47,7 @@ return [ 'settings' => 'Einstellungen', 'shipping_costs' => 'Versandkosten', 'start_site' => 'Startseite Shop', + 'statistics' => 'Statistik', 'structure' => 'Struktur', 'system_settings' => 'System-E.', 'translate' => 'Übersetzungen', diff --git a/resources/lang/en/navigation.php b/resources/lang/en/navigation.php index ad14da3..f1c6837 100644 --- a/resources/lang/en/navigation.php +++ b/resources/lang/en/navigation.php @@ -56,6 +56,7 @@ return [ 'settings' => 'settings', 'shipping_costs' => 'shipping', 'start_site' => 'home page', + 'statistics' => 'Statistics', 'structure' => 'structure', 'system_settings' => 'system E.', 'tags' => 'Tags', diff --git a/resources/lang/es/navigation.php b/resources/lang/es/navigation.php index 1e9b361..1c0c1a8 100644 --- a/resources/lang/es/navigation.php +++ b/resources/lang/es/navigation.php @@ -56,6 +56,7 @@ return [ 'settings' => 'ajustes', 'shipping_costs' => 'envío', 'start_site' => 'pagina de inicio', + 'statistics' => 'Estadísticas', 'structure' => 'estructura', 'system_settings' => 'sistema E.', 'tags' => 'Etiquetas', diff --git a/resources/lang/fr/navigation.php b/resources/lang/fr/navigation.php index b26f810..e1d96ae 100644 --- a/resources/lang/fr/navigation.php +++ b/resources/lang/fr/navigation.php @@ -47,6 +47,7 @@ return [ 'settings' => 'Paramètres', 'shipping_costs' => 'Frais de livraison', 'start_site' => 'Page d’accueil boutique', + 'statistics' => 'Statistiques', 'structure' => 'Structure', 'system_settings' => 'Param. système', 'translate' => 'Traductions', diff --git a/resources/views/admin/sales/_detail.blade.php b/resources/views/admin/sales/_detail.blade.php index 3ea69e8..4ee2346 100644 --- a/resources/views/admin/sales/_detail.blade.php +++ b/resources/views/admin/sales/_detail.blade.php @@ -185,6 +185,15 @@
    {{ __('order.points_total') }}
    {{ $shopping_order->getFormattedPoints() }} + @if ((int) $shopping_order->payment_for === 6 && $shopping_order->customer_order_source) +
    +
    Herkunft
    + {{ $shopping_order->getCustomerOrderSourceLabel() }} + @if ($shopping_order->customer_order_source_comment) +
    {{ $shopping_order->customer_order_source_comment }}
    + @endif +
    + @endif
    diff --git a/resources/views/layouts/includes/layout-sidenav.blade.php b/resources/views/layouts/includes/layout-sidenav.blade.php index cbd5f2c..925fb94 100755 --- a/resources/views/layouts/includes/layout-sidenav.blade.php +++ b/resources/views/layouts/includes/layout-sidenav.blade.php @@ -55,6 +55,17 @@
    {{ __('navigation.news_archive') }}
    + @if (Auth::user()->isVIP()) +
  • + +
    {{ __('navigation.statistics') }}
    +
    +
    VIP
    +
    +
    +
  • + @endif
  • diff --git a/resources/views/user/backoffice/statistics/details.blade.php b/resources/views/user/backoffice/statistics/details.blade.php new file mode 100644 index 0000000..95dddcf --- /dev/null +++ b/resources/views/user/backoffice/statistics/details.blade.php @@ -0,0 +1,279 @@ +@extends('layouts.layout-2') + +@section('content') +

    +
    + {{ __('navigation.statistics') }} / {{ $details['metric_label'] }} + VIP +
    +
    +

    + +
    +
    +
    +
    +
    Linie
    + {{ $details['line_label'] }} +
    +
    +
    Zeitraum
    + {{ $details['month'] }}/{{ $details['year'] }} +
    +
    +
    Kennzahl
    + {{ $details['metric_label'] }} +
    +
    +
    Treffer
    + {{ number_format($details['summary']['count'], 0, ',', '.') }} +
    +
    +
    +
    + +
    +
    +
    + Datenschutz-Hinweis: + Die Anzeige personenbezogener Detaildaten befindet sich noch in rechtlicher Klärung und ist aktuell nur für berechtigte VIP-Auswertungen vorgesehen. +
    + @if ($details['rows'] === []) +
    +

    {{ __('tables.no_data_available') }}

    +
    + @else +
    +
    +
    + + + +
    + +
    + + Suche nach Name, E-Mail, Status, Punkten oder Datum. Die Summenzeile bleibt unverändert. + +
    +
    + + + + + + + @if (in_array($details['metric'], ['team_partner_abos', 'team_customer_abos'], true)) + + + + + + + @elseif (in_array($details['metric'], ['own_points', 'external_points', 'customer_abo_points', 'customer_single_order_points', 'customer_other_points', 'total_points', 'shop_1000'], true)) + + + + + + + @else + + + @endif + + + + @foreach ($details['rows'] as $row) + + + + + @if (in_array($details['metric'], ['team_partner_abos', 'team_customer_abos'], true)) + + + + + + + @elseif (in_array($details['metric'], ['own_points', 'external_points', 'customer_abo_points', 'customer_single_order_points', 'customer_other_points', 'total_points', 'shop_1000'], true)) + + + + + + + @else + + + @endif + + @endforeach + + + + + + + @if (in_array($details['metric'], ['team_partner_abos', 'team_customer_abos'], true)) + + + + + + + @elseif (in_array($details['metric'], ['own_points', 'external_points', 'customer_abo_points', 'customer_single_order_points', 'customer_other_points', 'total_points', 'shop_1000'], true)) + + + + + + + @else + + + @endif + + +
    Name E-Mail Karriere-Level Berater Abo-Punkte Status Besteht seit Nächste Ausführung Lieferungen Eigenpunkte Externe Punkte Kundenabo-Punkte Einzelbestellungs-Punkte Sonstige Kundenpunkte Gesamtpunkte Aktiv seit Account gültig bis
    Summe{{ number_format($details['summary']['count'], 0, ',', '.') }} Einträge{{ \App\Services\Util::formatNumber($details['summary']['points']) }}{{ number_format($details['summary']['deliveries'], 0, ',', '.') }}{{ \App\Services\Util::formatNumber($details['summary']['own_points']) }}{{ \App\Services\Util::formatNumber($details['summary']['external_points']) }}{{ \App\Services\Util::formatNumber($details['summary']['customer_abo_points']) }}{{ \App\Services\Util::formatNumber($details['summary']['customer_single_order_points']) }}{{ \App\Services\Util::formatNumber($details['summary']['customer_other_points']) }}{{ \App\Services\Util::formatNumber($details['summary']['total_points']) }}
    +
    + @endif +
    +
    + + @if ($details['rows'] !== []) + + + + @endif +@endsection diff --git a/resources/views/user/backoffice/statistics/index.blade.php b/resources/views/user/backoffice/statistics/index.blade.php new file mode 100644 index 0000000..f2213c3 --- /dev/null +++ b/resources/views/user/backoffice/statistics/index.blade.php @@ -0,0 +1,237 @@ +@extends('layouts.layout-2') + +@section('content') +

    +
    + {{ __('navigation.statistics') }} + VIP +
    +

    + +
    +
    +
    +
    +
    {{ __('navigation.statistics') }} / Backoffice MVP
    +

    + Linienbasierte Übersicht für alle vorhandenen Team-Linien. Klickbare Zahlen führen direkt in die + passenden Namen- und Detaillisten. +

    + @if (! empty($performance)) +
    + Datenquelle: + {{ $performance['source_label'] }} + Berechnet in {{ number_format($performance['duration_ms'], 2, ',', '.') }} ms + @if (! empty($performance['calculated_at'])) + Snapshot vom {{ $performance['calculated_at'] }} + @endif +
    + @endif +
    +
    +
    + + CSV + + + + +
    +
    +
    +
    +
    + + @php + $clickableMetrics = [ + 'consultants', + 'new_partners', + 'team_partner_abos', + 'team_customer_abos', + 'own_points', + 'external_points', + 'customer_abo_points', + 'customer_single_order_points', + 'customer_other_points', + 'total_points', + 'shop_1000', + ]; + + $formatValue = function ($value, string $metric): string { + if ( + in_array( + $metric, + [ + 'own_points', + 'external_points', + 'customer_abo_points', + 'customer_single_order_points', + 'customer_other_points', + 'total_points', + ], + true, + ) + ) { + return \App\Services\Util::formatNumber($value); + } + + return number_format((float) $value, 0, ',', '.'); + }; + + $newAboMetric = function (array $row, string $metric): ?string { + return match ($metric) { + 'team_partner_abos' => 'team_partner_abos_new', + 'team_customer_abos' => 'team_customer_abos_new', + default => null, + }; + }; + @endphp + +
    +
    +
    + + + + + + + + + + + + + + + + + + + @foreach ($statistics['lines'] as $line) + + + @foreach ($clickableMetrics as $metric) + + @endforeach + + @endforeach + + + + + @foreach ($clickableMetrics as $metric) + + @endforeach + + +
    LinieBeraterNeupartnerTeamabosTeamkundenabosEigenpunkteExterne PunkteKundenabo-PunkteEinzelbestellungs-PunkteSonstige KundenpunkteGesamtpunkte1000 Punkte Shop
    {{ $line['label'] }} + @if ($line[$metric] > 0) + + {{ $formatValue($line[$metric], $metric) }} + + @php $newMetric = $newAboMetric($line, $metric); @endphp + @if ($newMetric && $line[$newMetric] > 0) + +{{ number_format($line[$newMetric], 0, ',', '.') }} + @endif + @else + 0 + @endif +
    {{ $statistics['totals']['label'] }} + @if ($statistics['totals'][$metric] > 0) + + {{ $formatValue($statistics['totals'][$metric], $metric) }} + + @php $newMetric = $newAboMetric($statistics['totals'], $metric); @endphp + @if ($newMetric && $statistics['totals'][$newMetric] > 0) + +{{ number_format($statistics['totals'][$newMetric], 0, ',', '.') }} + @endif + @else + 0 + @endif +
    +
    +
    +
    + +
    +
    +
    +
    +
    Begriffe
    +

    + Teamabos sind Berater-/Eigenabos im Team. Teamkundenabos sind Kundenabos, die einem Berater + aus der jeweiligen Linie zugeordnet sind. +

    +

    + Externe Punkte kommen aktuell aus `month_shop_points`, Eigenpunkte aus `month_KP_points`. +

    +
    +
    +
    +
    +
    +
    +
    Nächster Ausbau
    +

    + Die Detailansichten liefern bereits Namen und Basisdaten. Im nächsten Schritt werden Abo-Punkte + und Umsatzarten noch feiner nach Abo, Einzelbestellung und Shop getrennt. +

    +
    +
    +
    +
    + + +@endsection diff --git a/resources/views/web/templates/checkout.blade.php b/resources/views/web/templates/checkout.blade.php index a33426b..fc7e0bb 100644 --- a/resources/views/web/templates/checkout.blade.php +++ b/resources/views/web/templates/checkout.blade.php @@ -550,6 +550,31 @@ {{ __('customer.language_hint') }} +
    +
    +
    + + + @if ($errors->has('customer_order_source')) + + @endif +
    +
    +
    +
    + + {!! Form::textarea('customer_order_source_comment', old('customer_order_source_comment'), ['class' => 'form-control '.($errors->has('customer_order_source_comment') ? 'error' : ''), 'id'=>'customer_order_source_comment', 'rows' => 3, 'maxlength' => 500]) !!} + @if ($errors->has('customer_order_source_comment')) + + @endif +
    +
    +
    @else
    diff --git a/routes/domains/crm.php b/routes/domains/crm.php index 4e495bf..efd48cd 100644 --- a/routes/domains/crm.php +++ b/routes/domains/crm.php @@ -125,6 +125,12 @@ Route::domain(config('app.pre_url_crm').config('app.domain').config('app.tld_car Route::post('/user/shop/api/orders/checkout', 'User\ShopApiController@checkout')->name('user_shop_api_orders_checkout'); Route::get('/user/shop/api/orders/datatable', 'User\ShopApiController@ordersDatatable')->name('user_shop_api_orders_datatable'); + // user backoffice statistics + Route::get('/user/backoffice/statistics', 'User\BackofficeStatisticsController@index')->name('user_backoffice_statistics'); + Route::get('/user/backoffice/statistics/details', 'User\BackofficeStatisticsController@details')->name('user_backoffice_statistics_details'); + Route::get('/user/backoffice/statistics/export', 'User\BackofficeStatisticsController@export')->name('user_backoffice_statistics_export'); + Route::get('/user/backoffice/statistics/export-overview', 'User\BackofficeStatisticsController@overviewExport')->name('user_backoffice_statistics_overview_export'); + // user team Route::get('/user/team/add/member', 'User\TeamController@addMember')->name('user_team_add_member'); diff --git a/tests/Feature/BackofficeStatisticsAccessTest.php b/tests/Feature/BackofficeStatisticsAccessTest.php new file mode 100644 index 0000000..d76bd0c --- /dev/null +++ b/tests/Feature/BackofficeStatisticsAccessTest.php @@ -0,0 +1,342 @@ +forceFill([ + 'admin' => $admin, + 'lang' => 'de', + 'active' => 1, + 'blocked' => 0, + 'wizard' => 100, + ]); +} + +function makeBackofficeStatisticsRequest(User $user): Request +{ + $request = Request::create('/user/backoffice/statistics', 'GET'); + $request->setUserResolver(fn () => $user); + + return $request; +} + +function makeBackofficeStatisticsController(?BackofficeDashboardService $dashboardService = null, ?BackofficeDrilldownService $drilldownService = null): BackofficeStatisticsController +{ + $dashboardService ??= Mockery::mock(BackofficeDashboardService::class); + $drilldownService ??= Mockery::mock(BackofficeDrilldownService::class); + + return new BackofficeStatisticsController($dashboardService, $drilldownService); +} + +function backofficeStatisticsStreamedContent(StreamedResponse $response): string +{ + ob_start(); + $response->sendContent(); + + return (string) ob_get_clean(); +} + +it('zeigt die Backoffice-Statistik fuer VIP-User', function () { + $vip = makeBackofficeStatisticsUser(1); + $dashboardService = Mockery::mock(BackofficeDashboardService::class); + $dashboardService + ->shouldReceive('overview') + ->once() + ->andReturn([ + 'month' => now()->month, + 'year' => now()->year, + 'metric_labels' => [], + 'lines' => [], + 'totals' => [], + '_meta' => [ + 'source_label' => 'Live', + 'calculated_at' => null, + ], + ]); + + $controller = makeBackofficeStatisticsController($dashboardService); + + $response = $controller->index(makeBackofficeStatisticsRequest($vip)); + + expect($response->getName())->toBe('user.backoffice.statistics.index'); + expect($response->getData())->toHaveKeys(['selectedMonth', 'selectedYear', 'statistics', 'performance']); + expect($response->getData()['performance']['source_label'])->toBe('Live'); +}); + +it('behaelt den zuletzt gewaehlten Statistik-Zeitraum in der Session', function () { + $vip = makeBackofficeStatisticsUser(1); + $dashboardService = Mockery::mock(BackofficeDashboardService::class); + $dashboardService + ->shouldReceive('overview') + ->twice() + ->andReturn([ + 'month' => 4, + 'year' => 2026, + 'metric_labels' => [], + 'lines' => [], + 'totals' => [], + '_meta' => [ + 'source_label' => 'Snapshot', + 'calculated_at' => '17.05.2026 04:45', + ], + ]); + + $controller = makeBackofficeStatisticsController($dashboardService); + $firstRequest = makeBackofficeStatisticsRequest($vip); + $firstRequest->query->set('month', 4); + $firstRequest->query->set('year', 2026); + + $controller->index($firstRequest); + $response = $controller->index(makeBackofficeStatisticsRequest($vip)); + + expect($response->getData()['selectedMonth'])->toBe(4); + expect($response->getData()['selectedYear'])->toBe(2026); + expect($response->getData()['performance']['source_label'])->toBe('Snapshot'); +}); + +it('blockiert die Backoffice-Statistik fuer normale aktive User', function () { + $user = makeBackofficeStatisticsUser(0); + $controller = makeBackofficeStatisticsController(); + + expect(fn () => $controller->index(makeBackofficeStatisticsRequest($user))) + ->toThrow(NotFoundHttpException::class); +}); + +it('erstellt einen CSV-Export fuer die Statistik-Uebersicht', function () { + $vip = makeBackofficeStatisticsUser(1); + $dashboardService = Mockery::mock(BackofficeDashboardService::class); + $dashboardService + ->shouldReceive('overview') + ->once() + ->andReturn([ + 'month' => 5, + 'year' => 2026, + 'metric_labels' => [], + 'lines' => [ + [ + 'label' => 'Linie 1', + 'consultants' => 2, + 'new_partners' => 1, + 'team_partner_abos' => 1, + 'team_partner_abos_new' => 1, + 'team_customer_abos' => 1, + 'team_customer_abos_new' => 1, + 'own_points' => 400, + 'external_points' => 700, + 'customer_abo_points' => 700, + 'customer_single_order_points' => 0, + 'customer_other_points' => 0, + 'total_points' => 1100, + 'shop_1000' => 1, + 'turnover_net' => 300, + ], + ], + 'totals' => [ + 'label' => 'Summe', + 'consultants' => 2, + 'new_partners' => 1, + 'team_partner_abos' => 1, + 'team_partner_abos_new' => 1, + 'team_customer_abos' => 1, + 'team_customer_abos_new' => 1, + 'own_points' => 400, + 'external_points' => 700, + 'customer_abo_points' => 700, + 'customer_single_order_points' => 0, + 'customer_other_points' => 0, + 'total_points' => 1100, + 'shop_1000' => 1, + 'turnover_net' => 300, + ], + ]); + + $controller = makeBackofficeStatisticsController($dashboardService); + $request = makeBackofficeStatisticsRequest($vip); + $request->query->set('month', 5); + $request->query->set('year', 2026); + + $response = $controller->overviewExport($request); + $content = backofficeStatisticsStreamedContent($response); + + expect($response)->toBeInstanceOf(StreamedResponse::class); + expect($response->headers->get('content-disposition'))->toContain('backoffice-statistik-uebersicht-05-2026.csv'); + expect($content)->toContain('Linie;Berater;Neupartner;Teamabos'); + expect($content)->toContain('Neue Teamabos'); + expect($content)->toContain('"Linie 1";2;1;1;1;1;1;400;700;700;0;0;1100;1;300'); + expect($content)->toContain('Summe;2;1;1;1;1;1;400;700;700;0;0;1100;1;300'); +}); + +it('erstellt einen CSV-Export fuer Detaildaten mit Karriere-Level und Summenzeile', function () { + $vip = makeBackofficeStatisticsUser(1); + $drilldownService = Mockery::mock(BackofficeDrilldownService::class); + $drilldownService + ->shouldReceive('details') + ->once() + ->with($vip, 0, 'shop_1000', 5, 2026) + ->andReturn([ + 'metric' => 'shop_1000', + 'metric_label' => '1000 Punkte Shop', + 'line' => 0, + 'line_label' => 'Alle Linien', + 'month' => 5, + 'year' => 2026, + 'rows' => [ + [ + 'name' => 'Max Mustermann', + 'email' => 'max@example.test', + 'career_level' => 'Partner', + 'own_points' => 400, + 'external_points' => 700, + 'customer_abo_points' => 700, + 'customer_single_order_points' => 0, + 'customer_other_points' => 0, + 'total_points' => 1100, + ], + ], + 'summary' => [ + 'count' => 1, + 'points' => 0, + 'own_points' => 400, + 'external_points' => 700, + 'customer_abo_points' => 700, + 'customer_single_order_points' => 0, + 'customer_other_points' => 0, + 'total_points' => 1100, + 'deliveries' => 0, + ], + ]); + + $controller = makeBackofficeStatisticsController(null, $drilldownService); + $request = makeBackofficeStatisticsRequest($vip); + $request->query->set('line', 0); + $request->query->set('metric', 'shop_1000'); + $request->query->set('month', 5); + $request->query->set('year', 2026); + + $response = $controller->export($request); + $content = backofficeStatisticsStreamedContent($response); + + expect($response)->toBeInstanceOf(StreamedResponse::class); + expect($response->headers->get('content-disposition'))->toContain('backoffice-statistik-shop_1000-linie-alle-05-2026.csv'); + expect($content)->toContain('Name;E-Mail;Karriere-Level;Eigenpunkte;"Externe Punkte"'); + expect($content)->toContain('"Max Mustermann";max@example.test;Partner;400;700;700;0;0;1100'); + expect($content)->toContain('Summe;"1 Eintraege";;400;700;700;0;0;1100'); +}); + +it('nutzt den gespeicherten Zeitraum fuer Detailansicht und Export', function () { + $vip = makeBackofficeStatisticsUser(1); + $dashboardService = Mockery::mock(BackofficeDashboardService::class); + $drilldownService = Mockery::mock(BackofficeDrilldownService::class); + + $dashboardService + ->shouldReceive('overview') + ->once() + ->andReturn([ + 'month' => 4, + 'year' => 2026, + 'metric_labels' => [], + 'lines' => [], + 'totals' => [], + ]); + + $drilldownService + ->shouldReceive('details') + ->once() + ->with($vip, 1, 'consultants', 4, 2026) + ->andReturn([ + 'metric' => 'consultants', + 'metric_label' => 'Berater', + 'line' => 1, + 'line_label' => 'Linie 1', + 'month' => 4, + 'year' => 2026, + 'rows' => [], + 'summary' => ['count' => 0], + ]); + + $drilldownService + ->shouldReceive('details') + ->once() + ->with($vip, 1, 'consultants', 4, 2026) + ->andReturn([ + 'metric' => 'consultants', + 'metric_label' => 'Berater', + 'line' => 1, + 'line_label' => 'Linie 1', + 'month' => 4, + 'year' => 2026, + 'rows' => [], + 'summary' => ['count' => 0], + ]); + + $controller = makeBackofficeStatisticsController($dashboardService, $drilldownService); + $indexRequest = makeBackofficeStatisticsRequest($vip); + $indexRequest->query->set('month', 4); + $indexRequest->query->set('year', 2026); + $controller->index($indexRequest); + + $detailsRequest = makeBackofficeStatisticsRequest($vip); + $detailsRequest->query->set('line', 1); + $detailsRequest->query->set('metric', 'consultants'); + $detailsResponse = $controller->details($detailsRequest); + + $exportRequest = makeBackofficeStatisticsRequest($vip); + $exportRequest->query->set('line', 1); + $exportRequest->query->set('metric', 'consultants'); + $exportResponse = $controller->export($exportRequest); + + expect($detailsResponse->getData()['selectedMonth'])->toBe(4); + expect($detailsResponse->getData()['selectedYear'])->toBe(2026); + expect($exportResponse->headers->get('content-disposition'))->toContain('backoffice-statistik-consultants-linie-1-04-2026.csv'); +}); + +it('rendert eine Suche in der Detailtabelle', function () { + $html = file_get_contents(resource_path('views/user/backoffice/statistics/details.blade.php')); + + expect($html)->toContain('backoffice-statistics-detail-search'); + expect($html)->toContain('backoffice-statistics-detail-table'); + expect($html)->toContain('data-sortable="true"'); + expect($html)->toContain('getSortValue'); + expect($html)->toContain('user_backoffice_statistics_export'); + expect($html)->toContain('Datenschutz-Hinweis'); + expect($html)->toContain('Karriere-Level'); + expect($html)->not->toContain('Qualifikation'); + + $indexHtml = file_get_contents(resource_path('views/user/backoffice/statistics/index.blade.php')); + + expect($indexHtml)->toContain('user_backoffice_statistics_overview_export'); + expect($indexHtml)->toContain('Datenquelle:'); + expect($indexHtml)->toContain('Berechnet in'); +}); + +it('erfasst die Herkunft bei Kundenbestellungen im Shop-Checkout', function () { + $checkoutView = file_get_contents(resource_path('views/web/templates/checkout.blade.php')); + $checkoutController = file_get_contents(app_path('Http/Controllers/Web/CheckoutController.php')); + $checkoutRepository = file_get_contents(app_path('Repositories/CheckoutRepository.php')); + $orderDetail = file_get_contents(resource_path('views/admin/sales/_detail.blade.php')); + + expect($checkoutView)->toContain('customer_order_source'); + expect($checkoutView)->toContain('Wie bist du auf uns aufmerksam geworden?'); + expect($checkoutController)->toContain("Request::get('is_from') === 'shopping'"); + expect($checkoutController)->toContain('customer_order_source'); + expect($checkoutRepository)->toContain('$shopping_user->is_from === \'shopping\''); + expect($checkoutRepository)->toContain('customer_order_source_comment'); + expect($orderDetail)->toContain('getCustomerOrderSourceLabel'); + + $shoppingOrder = (new ShoppingOrder)->forceFill([ + 'customer_order_source' => 'social_media', + ]); + + expect($shoppingOrder->getCustomerOrderSourceLabel())->toBe('Social Media'); +}); diff --git a/tests/Unit/Services/BackofficeDashboardServiceTest.php b/tests/Unit/Services/BackofficeDashboardServiceTest.php new file mode 100644 index 0000000..186381e --- /dev/null +++ b/tests/Unit/Services/BackofficeDashboardServiceTest.php @@ -0,0 +1,409 @@ +increments('id'); + $table->string('first_name')->nullable(); + $table->string('last_name')->nullable(); + }); + + Schema::create('user_levels', function ($table) { + $table->increments('id'); + $table->string('name'); + $table->unsignedInteger('pos')->nullable(); + $table->boolean('active')->default(true); + $table->timestamps(); + }); + + Schema::create('users', function ($table) { + $table->increments('id'); + $table->string('email')->unique(); + $table->string('password'); + $table->unsignedInteger('account_id')->nullable(); + $table->unsignedInteger('m_level')->nullable(); + $table->unsignedInteger('m_sponsor')->nullable(); + $table->boolean('active')->default(false); + $table->timestamp('active_date')->nullable(); + $table->unsignedTinyInteger('admin')->default(0); + $table->unsignedTinyInteger('wizard')->default(0); + $table->unsignedTinyInteger('blocked')->default(0); + $table->char('lang', 2)->default('de'); + $table->timestamp('payment_account')->nullable(); + $table->timestamps(); + $table->softDeletes(); + }); + + Schema::create('user_abos', function ($table) { + $table->increments('id'); + $table->unsignedInteger('user_id')->nullable(); + $table->unsignedInteger('member_id')->nullable(); + $table->char('is_for', 2)->nullable(); + $table->boolean('active')->default(true); + $table->unsignedTinyInteger('status')->default(2); + $table->date('start_date')->nullable(); + $table->date('next_date')->nullable(); + $table->timestamps(); + $table->softDeletes(); + }); + + Schema::create('user_abo_items', function ($table) { + $table->increments('id'); + $table->unsignedInteger('user_abo_id'); + $table->unsignedInteger('product_id')->nullable(); + $table->unsignedTinyInteger('comp')->nullable(); + $table->unsignedInteger('qty')->default(1); + $table->timestamps(); + }); + + Schema::create('user_abo_orders', function ($table) { + $table->increments('id'); + $table->unsignedInteger('user_abo_id'); + $table->unsignedInteger('shopping_order_id'); + $table->unsignedTinyInteger('status')->default(2); + $table->boolean('paid')->default(true); + $table->timestamps(); + }); + + Schema::create('shopping_orders', function ($table) { + $table->increments('id'); + $table->boolean('is_abo')->default(false); + $table->timestamps(); + $table->softDeletes(); + }); + + Schema::create('shopping_payments', function ($table) { + $table->increments('id'); + $table->unsignedInteger('shopping_order_id'); + $table->string('clearingtype')->nullable(); + $table->string('reference')->nullable(); + $table->integer('amount')->nullable(); + $table->string('currency')->nullable(); + $table->timestamps(); + }); + + Schema::create('payment_transactions', function ($table) { + $table->increments('id'); + $table->unsignedInteger('shopping_payment_id'); + $table->string('request')->nullable(); + $table->unsignedInteger('errorcode')->nullable(); + $table->string('errormessage')->nullable(); + $table->string('customermessage')->nullable(); + $table->timestamps(); + }); + + Schema::create('user_sales_volumes', function ($table) { + $table->increments('id'); + $table->unsignedInteger('user_id'); + $table->unsignedInteger('shopping_order_id')->nullable(); + $table->unsignedTinyInteger('month')->nullable(); + $table->unsignedSmallInteger('year')->nullable(); + $table->decimal('month_KP_points', 13, 2)->nullable(); + $table->decimal('month_shop_points', 13, 2)->nullable(); + $table->decimal('month_total_net', 13, 2)->nullable(); + $table->decimal('month_shop_total_net', 13, 2)->nullable(); + $table->timestamps(); + }); + + Schema::create('backoffice_statistics_snapshots', function ($table) { + $table->id(); + $table->unsignedInteger('user_id'); + $table->unsignedSmallInteger('year'); + $table->unsignedTinyInteger('month'); + $table->json('payload'); + $table->timestamp('calculated_at')->nullable(); + $table->timestamps(); + }); +}); + +it('aggregiert Linien, Abos und Punkte fuer die Backoffice-Statistik', function () { + UserLevel::forceCreate([ + 'id' => 1, + 'name' => 'Partner', + 'pos' => 1, + 'active' => true, + ]); + + $root = User::forceCreate([ + 'email' => 'root@test.test', + 'password' => 'secret', + 'lang' => 'de', + 'admin' => 1, + ]); + + $lineOne = User::forceCreate([ + 'email' => 'line-one@test.test', + 'password' => 'secret', + 'lang' => 'de', + 'm_sponsor' => $root->id, + 'm_level' => 1, + 'active_date' => '2026-05-03 00:00:00', + 'payment_account' => '2030-01-01 00:00:00', + ]); + + $lineTwo = User::forceCreate([ + 'email' => 'line-two@test.test', + 'password' => 'secret', + 'lang' => 'de', + 'm_sponsor' => $lineOne->id, + 'm_level' => 1, + 'active_date' => '2026-04-03 00:00:00', + 'payment_account' => '2030-01-01 00:00:00', + ]); + + User::forceCreate([ + 'email' => 'expired-line-one@test.test', + 'password' => 'secret', + 'lang' => 'de', + 'm_sponsor' => $root->id, + 'm_level' => 1, + 'active_date' => '2026-01-03 00:00:00', + 'payment_account' => '2020-01-01 00:00:00', + ]); + + $sponsor = $lineTwo; + + foreach (range(3, 9) as $lineNumber) { + $sponsor = User::forceCreate([ + 'email' => 'line-'.$lineNumber.'@test.test', + 'password' => 'secret', + 'lang' => 'de', + 'm_sponsor' => $sponsor->id, + 'm_level' => 1, + 'active_date' => '2026-04-03 00:00:00', + 'payment_account' => '2030-01-01 00:00:00', + ]); + } + + $activePartnerAbo = UserAbo::forceCreate([ + 'user_id' => $lineOne->id, + 'member_id' => $lineOne->id, + 'is_for' => 'me', + 'active' => true, + 'status' => 2, + 'start_date' => '2026-05-04', + 'next_date' => '2026-06-01', + ]); + + UserAbo::forceCreate([ + 'user_id' => $lineTwo->id, + 'member_id' => $lineOne->id, + 'is_for' => 'ot', + 'active' => true, + 'status' => 2, + 'start_date' => '2026-05-05', + 'next_date' => '2026-06-01', + ]); + + $pausedPartnerAbo = UserAbo::forceCreate([ + 'user_id' => $lineOne->id, + 'member_id' => $lineOne->id, + 'is_for' => 'me', + 'active' => true, + 'status' => 3, + 'start_date' => '2026-04-15', + 'next_date' => '2026-06-02', + ]); + + $aboOrderId = DB::table('shopping_orders')->insertGetId([ + 'is_abo' => true, + 'created_at' => now(), + 'updated_at' => now(), + ]); + + $singleOrderId = DB::table('shopping_orders')->insertGetId([ + 'is_abo' => false, + 'created_at' => now(), + 'updated_at' => now(), + ]); + + $failedAboOrderId = DB::table('shopping_orders')->insertGetId([ + 'is_abo' => true, + 'created_at' => now(), + 'updated_at' => now(), + ]); + + $failedAboPaymentId = DB::table('shopping_payments')->insertGetId([ + 'shopping_order_id' => $failedAboOrderId, + 'clearingtype' => 'cc', + 'reference' => 'abo-failed', + 'amount' => 9900, + 'currency' => 'EUR', + 'created_at' => now(), + 'updated_at' => now(), + ]); + + DB::table('payment_transactions')->insert([ + 'shopping_payment_id' => $failedAboPaymentId, + 'request' => 'authorization', + 'errorcode' => 902, + 'errormessage' => 'Bank hat abgelehnt', + 'customermessage' => 'Bitte Zahlungsmittel prüfen', + 'created_at' => now(), + 'updated_at' => now(), + ]); + + DB::table('user_abo_orders')->insert([ + 'user_abo_id' => $pausedPartnerAbo->id, + 'shopping_order_id' => $failedAboOrderId, + 'status' => 3, + 'paid' => false, + 'created_at' => now(), + 'updated_at' => now(), + ]); + + UserSalesVolume::forceCreate([ + 'user_id' => $lineOne->id, + 'shopping_order_id' => $aboOrderId, + 'month' => 5, + 'year' => 2026, + 'month_KP_points' => 400, + 'month_shop_points' => 700, + 'month_total_net' => 100, + 'month_shop_total_net' => 200, + ]); + + UserSalesVolume::forceCreate([ + 'user_id' => $lineTwo->id, + 'shopping_order_id' => $singleOrderId, + 'month' => 5, + 'year' => 2026, + 'month_KP_points' => 200, + 'month_shop_points' => 300, + 'month_total_net' => 50, + 'month_shop_total_net' => 60, + ]); + + $overview = (new BackofficeDashboardService)->overview($root, 5, 2026); + + expect($overview['lines'][1]['consultants'])->toBe(2); + expect($overview['lines'][1]['new_partners'])->toBe(1); + expect($overview['lines'][1]['team_partner_abos'])->toBe(2); + expect($overview['lines'][1]['team_partner_abos_new'])->toBe(1); + expect($overview['lines'][1]['team_customer_abos'])->toBe(1); + expect($overview['lines'][1]['team_customer_abos_new'])->toBe(1); + expect($overview['lines'][1]['own_points'])->toBe(400.0); + expect($overview['lines'][1]['external_points'])->toBe(700.0); + expect($overview['lines'][1]['customer_abo_points'])->toBe(700.0); + expect($overview['lines'][1]['customer_single_order_points'])->toBe(0.0); + expect($overview['lines'][1]['customer_other_points'])->toBe(0.0); + expect($overview['lines'][1]['total_points'])->toBe(1100.0); + expect($overview['lines'][1]['shop_1000'])->toBe(1); + expect($overview['lines'][2]['consultants'])->toBe(1); + expect($overview['lines'])->toHaveKey(9); + expect($overview['lines'])->not->toHaveKey(10); + expect($overview['lines'][9]['consultants'])->toBe(1); + expect($overview['totals']['total_points'])->toBe(1600.0); + + $details = (new BackofficeDrilldownService(new BackofficeDashboardService))->details($root, 0, 'total_points', 5, 2026); + + expect($details['line_label'])->toBe('Alle Linien'); + expect($details['rows'])->toHaveCount(2); + expect($details['summary']['own_points'])->toBe(600.0); + expect($details['summary']['external_points'])->toBe(1000.0); + expect($details['summary']['customer_abo_points'])->toBe(700.0); + expect($details['summary']['customer_single_order_points'])->toBe(300.0); + expect($details['summary']['customer_other_points'])->toBe(0.0); + expect($details['summary']['total_points'])->toBe(1600.0); + + $shop1000Details = (new BackofficeDrilldownService(new BackofficeDashboardService))->details($root, 0, 'shop_1000', 5, 2026); + + expect($shop1000Details['rows'])->toHaveCount(1); + expect($shop1000Details['rows'][0]['career_level'])->toBe('Partner'); + expect($shop1000Details['rows'][0]['own_points'])->toBe(400.0); + expect($shop1000Details['rows'][0]['customer_abo_points'])->toBe(700.0); + + $consultantDetails = (new BackofficeDrilldownService(new BackofficeDashboardService))->details($root, 1, 'consultants', 5, 2026); + + expect($consultantDetails['rows'])->toHaveCount(2); + expect($consultantDetails['summary']['count'])->toBe(2); + expect(collect($consultantDetails['rows'])->pluck('is_account_active')->all())->toBe([true, false]); + expect(collect($consultantDetails['rows'])->pluck('career_level')->all())->toBe(['Partner', 'Partner']); + + $aboDetails = (new BackofficeDrilldownService(new BackofficeDashboardService))->details($root, 1, 'team_partner_abos', 5, 2026); + + expect($aboDetails['rows'][0]['start_date'])->toBe('04.05.2026'); + expect($aboDetails['rows'][0]['is_new_this_month'])->toBeTrue(); + expect($aboDetails['rows'])->toHaveCount(2); + expect(collect($aboDetails['rows'])->firstWhere('abo_id', $activePartnerAbo->id)['status_reason'])->toBeNull(); + expect(collect($aboDetails['rows'])->firstWhere('abo_id', $pausedPartnerAbo->id)['status_reason'])->toBe('[902] Bank hat abgelehnt'); + expect(collect($aboDetails['rows'])->firstWhere('abo_id', $pausedPartnerAbo->id)['is_new_this_month'])->toBeFalse(); +}); + +it('verwendet gespeicherte Snapshots fuer abgeschlossene Monate', function () { + $root = User::forceCreate([ + 'email' => 'snapshot-root@test.test', + 'password' => 'secret', + 'lang' => 'de', + 'admin' => 1, + ]); + + $payload = [ + 'month' => 4, + 'year' => 2026, + 'metric_labels' => [], + 'lines' => [ + 1 => [ + 'line' => 1, + 'label' => 'Linie 1', + 'user_ids' => [], + 'consultants' => 99, + 'new_partners' => 0, + 'team_partner_abos' => 0, + 'team_partner_abos_new' => 0, + 'team_customer_abos' => 0, + 'team_customer_abos_new' => 0, + 'own_points' => 0, + 'external_points' => 0, + 'customer_abo_points' => 0, + 'customer_single_order_points' => 0, + 'customer_other_points' => 0, + 'total_points' => 0, + 'turnover_net' => 0, + 'shop_1000' => 0, + ], + ], + 'totals' => [ + 'label' => 'Summe', + 'consultants' => 99, + ], + ]; + + BackofficeStatisticsSnapshot::create([ + 'user_id' => $root->id, + 'year' => 2026, + 'month' => 4, + 'payload' => $payload, + 'calculated_at' => now(), + ]); + + $overview = (new BackofficeDashboardService)->overview($root, 4, 2026); + + expect($overview['lines'][1]['consultants'])->toBe(99); + expect($overview['_meta']['source'])->toBe('snapshot'); + expect($overview['_meta']['source_label'])->toBe('Snapshot'); +}); From 036595be949c8e32277ddeaacf6ea3fb2cdcd4b9 Mon Sep 17 00:00:00 2001 From: Kevin Date: Wed, 27 May 2026 13:40:38 +0000 Subject: [PATCH 4/4] 27-05-2026 Update DHL Modul v2.0 --- app/Console/Commands/DhlUpdateTracking.php | 13 +- .../Controllers/DhlShipmentController.php | 108 ++- app/Http/Controllers/ModalController.php | 2 + app/Http/Controllers/SettingController.php | 34 +- app/Jobs/TrackShipmentJob.php | 64 +- app/Services/DhlAddressValidator.php | 258 ++++++ app/Services/DhlDataHelper.php | 31 +- app/Services/DhlModalService.php | 146 ++- app/Services/DhlProductResolver.php | 199 +++++ app/Services/DhlShipmentService.php | 173 ++-- app/Services/DhlShipmentWeightCalculator.php | 89 ++ app/Services/DhlTrackingService.php | 24 +- config/dhl.php | 6 +- ...ference_to_dhl_package_shipments_table.php | 32 + .../ENTWICKLUNGSKONZEPT-DHL-MODUL.md | 837 +++++++++++++++++- docs/Kurzfristige Anpassungen.md | 19 + docs/dhl/Anpassung DHL Modul.md | 49 + docs/salescenter/Todos Backoffice.md | 157 ++++ .../DhlAddressValidationException.php | 8 + .../src/Models/DhlShipment.php | 104 ++- .../src/Services/ShippingService.php | 209 ++++- ...x-Bio-Siegel-EG-Öko-VO-Deutschland.svg.png | Bin 0 -> 91721 bytes resources/lang/de/dhl.php | 4 +- resources/lang/en/dhl.php | 4 +- resources/lang/es/dhl.php | 4 +- resources/lang/fr/dhl.php | 4 +- resources/views/admin/dhl/cockpit.blade.php | 2 +- .../admin/dhl/modal_create_shipment.blade.php | 220 ++++- .../dhl/modal_in_order_shipment.blade.php | 38 +- resources/views/admin/dhl/show.blade.php | 78 +- .../views/admin/settings/index.blade.php | 58 +- resources/views/public/tracking.blade.php | 2 + routes/domains/crm.php | 1 + tests/Pest.php | 1 + tests/Unit/Dhl/DhlAddressValidatorTest.php | 174 ++++ tests/Unit/Dhl/DhlDataHelperReferenceTest.php | 100 +++ tests/Unit/Dhl/DhlProductResolverTest.php | 83 ++ tests/Unit/Dhl/DhlShipmentStatusTest.php | 96 ++ .../Dhl/DhlShipmentWeightCalculatorTest.php | 60 ++ .../Dhl/ShippingServiceProductCodeTest.php | 130 +++ tests/Unit/Dhl/TrackShipmentJobTest.php | 35 + 41 files changed, 3346 insertions(+), 310 deletions(-) create mode 100644 app/Services/DhlAddressValidator.php create mode 100644 app/Services/DhlProductResolver.php create mode 100644 app/Services/DhlShipmentWeightCalculator.php create mode 100644 database/migrations/2026_05_27_120253_add_reference_to_dhl_package_shipments_table.php create mode 100644 docs/Kurzfristige Anpassungen.md create mode 100644 docs/dhl/Anpassung DHL Modul.md create mode 100644 docs/salescenter/Todos Backoffice.md create mode 100644 packages/acme-laravel-dhl/src/Exceptions/DhlAddressValidationException.php create mode 100644 public/pdf/MIVITA_Vorlage-Rechnungen Ordner/Links/1200px-Bio-Siegel-EG-Öko-VO-Deutschland.svg.png create mode 100644 tests/Unit/Dhl/DhlAddressValidatorTest.php create mode 100644 tests/Unit/Dhl/DhlDataHelperReferenceTest.php create mode 100644 tests/Unit/Dhl/DhlProductResolverTest.php create mode 100644 tests/Unit/Dhl/DhlShipmentStatusTest.php create mode 100644 tests/Unit/Dhl/DhlShipmentWeightCalculatorTest.php create mode 100644 tests/Unit/Dhl/ShippingServiceProductCodeTest.php create mode 100644 tests/Unit/Dhl/TrackShipmentJobTest.php diff --git a/app/Console/Commands/DhlUpdateTracking.php b/app/Console/Commands/DhlUpdateTracking.php index 0d1621f..a531087 100644 --- a/app/Console/Commands/DhlUpdateTracking.php +++ b/app/Console/Commands/DhlUpdateTracking.php @@ -244,14 +244,7 @@ class DhlUpdateTracking extends Command */ private function shouldSendEmail(DhlShipment $shipment, string $oldStatus): bool { - // E-Mail nur senden wenn: - // 1. Status ist jetzt "in_transit" - // 2. Vorheriger Status war NICHT "in_transit" (also Status hat sich geändert) - // 3. Noch keine E-Mail gesendet wurde - return $shipment->status === 'in_transit' - && $oldStatus !== 'in_transit' - && ! $shipment->wasTrackingEmailSent() - && $shipment->canSendTrackingEmail(); + return $shipment->shouldTriggerTrackingEmail($oldStatus); } /** @@ -282,7 +275,7 @@ class DhlUpdateTracking extends Command // Sammle alle Sendungen für diese Bestellung, die noch keine E-Mail erhalten haben $allShipments = DhlShipment::where('order_id', $order->id) - ->where('status', 'in_transit') + ->whereIn('status', DhlShipment::TRACKING_EMAIL_TRIGGER_STATUSES) ->whereNotNull('dhl_shipment_no') ->whereNull('tracking_email_sent_at') ->get(); @@ -297,7 +290,7 @@ class DhlUpdateTracking extends Command // Markiere alle Sendungen als versendet foreach ($allShipments as $s) { - $s->markTrackingEmailSent('auto'); + $s->markTrackingEmailSent('auto', $recipientEmail, $allShipments); } Log::info('[DHL Cron] Tracking email sent automatically', [ diff --git a/app/Http/Controllers/DhlShipmentController.php b/app/Http/Controllers/DhlShipmentController.php index f97c49c..8af1bc2 100644 --- a/app/Http/Controllers/DhlShipmentController.php +++ b/app/Http/Controllers/DhlShipmentController.php @@ -7,9 +7,13 @@ use App\Jobs\CancelShipmentJob; // Old DHL model replaced with new package model use App\Jobs\CreateReturnLabelJob; use App\Mail\MailDhlTracking; +use App\Models\Country; use App\Models\ShoppingOrder; +use App\Services\DhlAddressValidator; use App\Services\DhlModalService; +use App\Services\DhlProductResolver; use App\Services\DhlShipmentService; +use App\Services\DhlShipmentWeightCalculator; use App\Services\DhlTrackingService; use Exception; use Illuminate\Http\JsonResponse; @@ -122,7 +126,12 @@ class DhlShipmentController extends Controller $query->where('type', $request->get('type')); } if ($request->filled('status')) { - $query->where('status', $request->get('status')); + $status = DhlShipment::normalizeStatus($request->get('status')); + if ($status === 'canceled') { + $query->whereIn('status', ['canceled', 'cancelled']); + } else { + $query->where('status', $status); + } } if ($request->filled('date_from')) { $query->whereDate('created_at', '>=', $request->get('date_from')); @@ -181,10 +190,11 @@ class DhlShipmentController extends Controller 'created' => ['class' => 'success', 'text' => 'Erstellt'], 'shipped' => ['class' => 'primary', 'text' => 'Versendet'], 'delivered' => ['class' => 'info', 'text' => 'Zugestellt'], - 'cancelled' => ['class' => 'secondary', 'text' => 'Storniert'], + 'canceled' => ['class' => 'secondary', 'text' => 'Storniert'], 'failed' => ['class' => 'danger', 'text' => 'Fehler'], ]; - $statusInfo = $statusMap[$shipment->status] ?? ['class' => 'light', 'text' => e($shipment->status)]; + $status = DhlShipment::normalizeStatus($shipment->status); + $statusInfo = $statusMap[$status] ?? ['class' => 'light', 'text' => e($shipment->status)]; return ''.$statusInfo['text'].''; }) @@ -277,6 +287,7 @@ class DhlShipmentController extends Controller 'order_id' => 'required|exists:shopping_orders,id', 'weight' => 'required|numeric|min:0.1|max:31.5', 'product_code' => 'sometimes|string', + 'reference' => 'nullable|string|max:35', 'priority' => 'sometimes|string|in:normal,high', 'auto_track' => 'sometimes|boolean', // Shipping address validation @@ -294,6 +305,10 @@ class DhlShipmentController extends Controller ]); $order = ShoppingOrder::findOrFail($request->order_id); + $shipmentWeight = max( + (float) $request->weight, + (new DhlShipmentWeightCalculator)->calculate($order) + ); // Check if shipment already exists /* $existingShipment = DhlShipment::where('shopping_order_id', $order->id) @@ -314,6 +329,7 @@ class DhlShipmentController extends Controller // Prepare options for shipment creation $options = [ 'product_code' => $request->get('product_code', 'V01PAK'), + 'reference' => $request->get('reference'), 'priority' => $request->get('priority', 'normal'), 'auto_track' => $request->get('auto_track', true), 'shipping_address' => $shippingAddress, @@ -323,15 +339,20 @@ class DhlShipmentController extends Controller // Use DhlShipmentService (handles queue/sync automatically based on config) $dhlShipmentService = new DhlShipmentService; - $result = $dhlShipmentService->createShipment($order, (float) $request->weight, $options); + $result = $dhlShipmentService->createShipment($order, $shipmentWeight, $options); Log::info('[DHL Controller] Shipment creation processed', [ 'order_id' => $order->id, - 'weight' => $request->weight, + 'weight' => $shipmentWeight, + 'requested_weight' => $request->weight, 'queued' => $result['queued'] ?? false, 'success' => $result['success'] ?? false, ]); + if (! ($result['success'] ?? false)) { + return response()->json($result, ($result['type'] ?? null) === 'dhl_address_validation' ? 422 : 500); + } + return response()->json($result); } catch (Exception $e) { Log::error('[DHL Controller] Failed to dispatch shipment creation', [ @@ -346,6 +367,81 @@ class DhlShipmentController extends Controller } } + public function validateAddress(Request $request, DhlAddressValidator $validator): JsonResponse + { + $country = $request->filled('shipping_country_id') + ? Country::find($request->get('shipping_country_id')) + : null; + $resolver = new DhlProductResolver; + + $result = $validator->validate(array_merge($request->all(), [ + 'shipping_country_code' => $country?->code, + ])); + $errors = $result['errors']; + $warnings = $result['warnings']; + $product = [ + 'code' => $request->get('product_code'), + 'scope' => null, + 'scope_label' => 'Nicht geprüft', + 'country_code' => $country?->code, + 'country_label' => $country?->getLocated(), + ]; + + if ($country) { + try { + $resolvedProductCode = $resolver->resolveProductCode( + $country->code, + $request->get('product_code'), + config('dhl.default_product', 'V01PAK') + ); + $product = [ + 'code' => $resolvedProductCode, + 'scope' => $resolver->getProductScope($resolvedProductCode), + 'scope_label' => $resolver->getProductScopeLabel($resolvedProductCode), + 'country_code' => $country->code, + 'country_label' => $country->getLocated(), + ]; + } catch (\InvalidArgumentException $e) { + $errors[] = $e->getMessage(); + } + } + + $status = 'valid'; + if ($errors !== []) { + $status = 'error'; + } elseif ($warnings !== []) { + $status = 'warning'; + } + + return response()->json([ + 'success' => $errors === [], + 'status' => $status, + 'can_create_label' => $errors === [], + 'errors' => array_values(array_unique($errors)), + 'warnings' => array_values(array_unique($warnings)), + 'message' => $this->addressValidationMessage($status), + 'preflight' => [ + 'product' => $product, + 'address' => [ + 'status' => $result['status'], + 'normalized' => $result['normalized'], + 'validation_available' => $result['validation_available'], + 'validation_level' => $result['validation_level'], + 'validation_message' => $result['validation_message'], + ], + ], + ], $errors === [] ? 200 : 422); + } + + private function addressValidationMessage(string $status): string + { + return match ($status) { + 'valid' => 'Adresse ist formal versandfähig.', + 'warning' => 'Adresse ist formal versandfähig, sollte aber vor der Labelerstellung geprüft werden.', + default => 'Adresse ist nicht versandfähig. Bitte korrigieren Sie die markierten Felder.', + }; + } + /** * Display the specified shipment */ @@ -719,7 +815,7 @@ class DhlShipmentController extends Controller // Mark all included shipments as sent foreach ($allShipments as $s) { - $s->markTrackingEmailSent('manual'); + $s->markTrackingEmailSent('manual', $recipientEmail, $allShipments); } Log::info('[DHL Controller] Tracking email sent', [ diff --git a/app/Http/Controllers/ModalController.php b/app/Http/Controllers/ModalController.php index cf624ca..561690b 100644 --- a/app/Http/Controllers/ModalController.php +++ b/app/Http/Controllers/ModalController.php @@ -241,6 +241,8 @@ class ModalController extends Controller 'V01PAK' => 'DHL Paket (National)', 'V53WPAK' => 'DHL Paket International', ], + 'productSuggestions' => (new \App\Services\DhlProductResolver)->getProductSuggestionsByCountry(), + 'selectedProductCode' => 'V01PAK', 'errors' => ['Fehler beim Laden der Daten: '.$e->getMessage()], 'warnings' => [], ]; diff --git a/app/Http/Controllers/SettingController.php b/app/Http/Controllers/SettingController.php index 87b522d..a51a64f 100644 --- a/app/Http/Controllers/SettingController.php +++ b/app/Http/Controllers/SettingController.php @@ -3,6 +3,8 @@ namespace App\Http\Controllers; use App\Models\Setting; +use App\Services\DhlProductResolver; +use Illuminate\Support\Facades\Session; use Request; class SettingController extends Controller @@ -36,9 +38,9 @@ class SettingController extends Controller // DHL-spezifische Behandlung if ($data['action'] === 'save_dhl') { $this->updateDhlConfigCache(); - \Session()->flash('alert-save-dhl', 'DHL Konfiguration erfolgreich gespeichert!'); + Session::flash('alert-save-dhl', 'DHL Konfiguration erfolgreich gespeichert!'); } else { - \Session()->flash('alert-save', '1'); + Session::flash('alert-save', '1'); } } @@ -72,10 +74,12 @@ class SettingController extends Controller 'test_mode' => config('dhl.legacy.test_mode', true), // Product Settings - 'default_product' => $this->getConfigValue('dhl_product', config('dhl.default_product'), $useEnvPriority), + 'default_product' => $this->normalizeDhlProductCode($this->getConfigValue('dhl_product', config('dhl.default_product'), $useEnvPriority)), + 'international_countries' => $this->getDhlInternationalCountries($useEnvPriority), 'label_format' => $this->getConfigValue('dhl_label_format', config('dhl.label_format'), $useEnvPriority), 'print_format' => $this->getConfigValue('dhl_print_format', config('dhl.print_format'), $useEnvPriority), 'retoure_print_format' => $this->getConfigValue('dhl_retoure_print_format', config('dhl.retoure_print_format'), $useEnvPriority), + 'print_only_if_codeable' => (bool) $this->getConfigValue('dhl_print_only_if_codeable', config('dhl.print_only_if_codeable'), $useEnvPriority), 'use_queue' => $this->getConfigValue('dhl_use_queue', config('dhl.use_queue'), $useEnvPriority), // Sender Address @@ -94,7 +98,7 @@ class SettingController extends Controller // Account Numbers 'account_numbers' => [ 'V01PAK' => $this->getConfigValue('dhl_account_v01pak', config('dhl.account_numbers.V01PAK'), $useEnvPriority), - 'V62WP' => $this->getConfigValue('dhl_account_v62wp', config('dhl.account_numbers.V62WP'), $useEnvPriority), + 'V62KP' => $this->getConfigValue('dhl_account_v62kp', $this->getConfigValue('dhl_account_v62wp', config('dhl.account_numbers.V62KP'), $useEnvPriority), $useEnvPriority), 'V53PAK' => $this->getConfigValue('dhl_account_v53pak', config('dhl.account_numbers.V53PAK'), $useEnvPriority), 'V07PAK' => $this->getConfigValue('dhl_account_v07pak', config('dhl.account_numbers.V07PAK'), $useEnvPriority), 'default' => config('dhl.account_numbers.default'), @@ -103,7 +107,7 @@ class SettingController extends Controller // Dimensions 'dimensions' => [ 'V01PAK' => config('dhl.dimensions.V01PAK'), - 'V62WP' => config('dhl.dimensions.V62WP'), + 'V62KP' => config('dhl.dimensions.V62KP'), 'V53PAK' => config('dhl.dimensions.V53PAK'), 'V07PAK' => config('dhl.dimensions.V07PAK'), 'default' => config('dhl.dimensions.default'), @@ -136,6 +140,26 @@ class SettingController extends Controller } } + private function normalizeDhlProductCode(?string $productCode): string + { + return $productCode === 'V62WP' ? 'V62KP' : ($productCode ?: 'V01PAK'); + } + + /** + * @return string[] + */ + private function getDhlInternationalCountries(bool $useEnvPriority): array + { + $configCountries = config('dhl.international_countries', DhlProductResolver::DEFAULT_INTERNATIONAL_COUNTRIES); + $countries = $configCountries; + + if (! $useEnvPriority) { + $countries = Setting::getContentBySlug('dhl_international_countries') ?: $configCountries; + } + + return DhlProductResolver::normalizeCountryCodeList(is_array($countries) ? $countries : []); + } + /** * Update DHL configuration cache after saving settings */ diff --git a/app/Jobs/TrackShipmentJob.php b/app/Jobs/TrackShipmentJob.php index 44c56b7..6b4ecee 100644 --- a/app/Jobs/TrackShipmentJob.php +++ b/app/Jobs/TrackShipmentJob.php @@ -2,8 +2,9 @@ namespace App\Jobs; -use App\Models\DhlShipment; -use App\Services\DhlApiService; +use Acme\Dhl\Models\DhlShipment; +use App\Services\DhlTrackingService; +use DateTime; use Exception; use Illuminate\Bus\Queueable as BusQueueable; use Illuminate\Contracts\Queue\ShouldQueue; @@ -14,7 +15,7 @@ use Illuminate\Support\Facades\Log; /** * Job to track DHL shipments asynchronously - * + * * This job handles the tracking of DHL shipments in the background, * updating tracking status and details automatically. */ @@ -37,7 +38,7 @@ class TrackShipmentJob implements ShouldQueue * * @var int */ - public $tries = 2; // Lower tries for tracking as it's less critical + public $tries = 2; /** * The maximum number of seconds the job can run before timing out. @@ -49,14 +50,13 @@ class TrackShipmentJob implements ShouldQueue /** * Create a new job instance. * - * @param DhlShipment $dhlShipment - * @param array $options + * @param array $options */ public function __construct(DhlShipment $dhlShipment, array $options = []) { $this->dhlShipment = $dhlShipment; $this->options = $options; - + // Set queue name - tracking is usually lower priority $this->onQueue('dhl-tracking'); } @@ -64,35 +64,34 @@ class TrackShipmentJob implements ShouldQueue /** * Execute the job. */ - public function handle(): void + public function handle(DhlTrackingService $trackingService): void { try { Log::info('[DHL Queue] Starting shipment tracking job', [ 'shipment_id' => $this->dhlShipment->id, - 'tracking_number' => $this->dhlShipment->tracking_number, + 'dhl_shipment_no' => $this->dhlShipment->dhl_shipment_no, 'attempt' => $this->attempts(), ]); - $dhlService = new DhlApiService(); - - // Get tracking details - $trackingDetails = $dhlService->getTrackingDetails($this->dhlShipment); + $result = $trackingService->updateTrackingNow($this->dhlShipment, $this->options); Log::info('[DHL Queue] Shipment tracking updated successfully', [ 'shipment_id' => $this->dhlShipment->id, - 'tracking_status' => $trackingDetails['status'] ?? 'unknown', - 'events_count' => isset($trackingDetails['events']) ? count($trackingDetails['events']) : 0, + 'success' => $result['success'] ?? false, + 'tracking_status' => $result['tracking_status'] ?? 'unknown', + 'tracking_completed' => $result['tracking_completed'] ?? false, ]); // Schedule next tracking update if shipment is still in transit - if (isset($this->options['auto_retrack']) && $this->options['auto_retrack']) { - $status = $trackingDetails['status'] ?? ''; + if (($this->options['auto_retrack'] ?? false) && ! ($result['tracking_completed'] ?? false)) { + $this->dhlShipment->refresh(); + $status = $this->dhlShipment->status ?? ''; if ($this->shouldContinueTracking($status)) { // Schedule next tracking in 2-6 hours based on current status $nextTrackingDelay = $this->getNextTrackingDelay($status); TrackShipmentJob::dispatch($this->dhlShipment, $this->options) ->delay(now()->addMinutes($nextTrackingDelay)); - + Log::info('[DHL Queue] Next tracking job scheduled', [ 'shipment_id' => $this->dhlShipment->id, 'delay_minutes' => $nextTrackingDelay, @@ -103,7 +102,7 @@ class TrackShipmentJob implements ShouldQueue } catch (Exception $e) { Log::warning('[DHL Queue] Shipment tracking failed', [ 'shipment_id' => $this->dhlShipment->id, - 'tracking_number' => $this->dhlShipment->tracking_number, + 'dhl_shipment_no' => $this->dhlShipment->dhl_shipment_no, 'error' => $e->getMessage(), 'attempt' => $this->attempts(), 'max_tries' => $this->tries, @@ -115,7 +114,7 @@ class TrackShipmentJob implements ShouldQueue 'shipment_id' => $this->dhlShipment->id, 'error' => $e->getMessage(), ]); - + // Don't re-throw for final attempt - just log and continue return; } @@ -126,14 +125,12 @@ class TrackShipmentJob implements ShouldQueue /** * Handle a job failure. - * - * @param Exception $exception */ public function failed(Exception $exception): void { Log::warning('[DHL Queue] TrackShipmentJob permanently failed', [ 'shipment_id' => $this->dhlShipment->id, - 'tracking_number' => $this->dhlShipment->tracking_number, + 'dhl_shipment_no' => $this->dhlShipment->dhl_shipment_no, 'error' => $exception->getMessage(), ]); @@ -142,29 +139,14 @@ class TrackShipmentJob implements ShouldQueue /** * Determine if we should continue tracking this shipment - * - * @param string $status - * @return bool */ private function shouldContinueTracking(string $status): bool { - $finalStates = [ - 'delivered', - 'delivered_to_recipient', - 'delivered_to_pickup_location', - 'returned_to_sender', - 'cancelled', - 'lost', - ]; - - return !in_array(strtolower($status), $finalStates); + return ! in_array(strtolower($status), DhlShipment::TERMINAL_STATUSES, true); } /** * Get delay for next tracking update based on current status - * - * @param string $status - * @return int Minutes until next tracking */ private function getNextTrackingDelay(string $status): int { @@ -184,10 +166,8 @@ class TrackShipmentJob implements ShouldQueue /** * Determine the time at which the job should timeout. - * - * @return \DateTime */ - public function retryUntil() + public function retryUntil(): DateTime { return now()->addMinutes(30); // Short timeout for tracking } diff --git a/app/Services/DhlAddressValidator.php b/app/Services/DhlAddressValidator.php new file mode 100644 index 0000000..00a5160 --- /dev/null +++ b/app/Services/DhlAddressValidator.php @@ -0,0 +1,258 @@ + ['pattern' => '/^\d{5}$/', 'message' => 'Deutsche Postleitzahl muss 5 Ziffern haben.'], + 'AT' => ['pattern' => '/^\d{4}$/', 'message' => 'Oesterreichische Postleitzahl muss 4 Ziffern haben.'], + 'CH' => ['pattern' => '/^\d{4}$/', 'message' => 'Schweizer Postleitzahl muss 4 Ziffern haben.'], + 'ES' => ['pattern' => '/^\d{5}$/', 'message' => 'Spanische Postleitzahl muss 5 Ziffern haben.'], + ]; + + /** + * @return array{status: string, can_create_label: bool, errors: array, warnings: array, normalized: array, validation_available: bool, validation_level: string, validation_message: string} + */ + public function validate(array $address): array + { + $normalized = $this->normalize($address); + $errors = []; + $warnings = []; + $validationAvailable = $this->hasDachValidation($normalized['country_code']); + $validationLevel = $validationAvailable ? 'formal_dach' : 'basic'; + $validationMessage = $validationAvailable + ? 'Formale DACH-Pruefung aktiv: Pflichtfelder, PLZ-Format, Plausibilitaet und Packstation-Regeln werden geprueft. Eine echte Adressdatenbank-/DHL-Leitcodepruefung ist nicht angebunden.' + : 'Fuer dieses Zielland ist aktuell nur eine Basis-Adresspruefung verfuegbar.'; + + foreach ($this->requiredFields($normalized) as $field => $label) { + if ($normalized[$field] === '') { + $errors[] = "{$label} ist erforderlich."; + } + } + + if ($normalized['firstname'] === '' && $normalized['lastname'] === '' && $normalized['company'] === '') { + $errors[] = 'Entweder Name oder Firmenname muss angegeben werden.'; + } + + $this->validateCountryAndPostalCode($normalized, $errors, $warnings); + $this->validateAddressPlausibility($normalized, $errors); + $this->validatePackstation($normalized, $errors, $warnings); + $this->validateWarnings($normalized, $warnings); + + $status = 'valid'; + if ($errors !== []) { + $status = 'error'; + } elseif ($warnings !== []) { + $status = 'warning'; + } + + return [ + 'status' => $status, + 'can_create_label' => $errors === [], + 'errors' => array_values(array_unique($errors)), + 'warnings' => array_values(array_unique($warnings)), + 'normalized' => $normalized, + 'validation_available' => $validationAvailable, + 'validation_level' => $validationLevel, + 'validation_message' => $validationMessage, + ]; + } + + /** + * @return array + */ + private function normalize(array $address): array + { + $countryCode = $address['country_code'] + ?? $address['shipping_country_code'] + ?? data_get($address, 'country.code') + ?? ''; + + return [ + 'firstname' => trim((string) ($address['firstname'] ?? $address['shipping_firstname'] ?? '')), + 'lastname' => trim((string) ($address['lastname'] ?? $address['shipping_lastname'] ?? '')), + 'company' => trim((string) ($address['company'] ?? $address['shipping_company'] ?? '')), + 'street' => trim((string) ($address['address'] ?? $address['street'] ?? $address['shipping_address'] ?? '')), + 'house_number' => trim((string) ($address['houseNumber'] ?? $address['house_number'] ?? $address['shipping_houseNumber'] ?? '')), + 'postal_code' => trim((string) ($address['zipcode'] ?? $address['postalCode'] ?? $address['postal_code'] ?? $address['shipping_zipcode'] ?? '')), + 'city' => trim((string) ($address['city'] ?? $address['shipping_city'] ?? '')), + 'country_code' => strtoupper(trim((string) $countryCode)), + 'email' => trim((string) ($address['email'] ?? $address['shipping_email'] ?? '')), + 'phone' => trim((string) ($address['phone'] ?? $address['shipping_phone'] ?? '')), + 'postnumber' => trim((string) ($address['postnumber'] ?? $address['postNumber'] ?? $address['shipping_postnumber'] ?? '')), + ]; + } + + /** + * @return array + */ + private function requiredFields(array $address): array + { + $fields = [ + 'street' => 'Straße', + 'postal_code' => 'Postleitzahl', + 'city' => 'Ort', + 'country_code' => 'Land', + ]; + + if (! $this->isPackstation($address)) { + $fields['house_number'] = 'Hausnummer'; + } + + return $fields; + } + + private function validateCountryAndPostalCode(array $address, array &$errors, array &$warnings): void + { + if ($address['country_code'] === '') { + return; + } + + $resolver = new DhlProductResolver; + + try { + if ($resolver->getAllowedProductCodesForCountry($address['country_code']) === []) { + $errors[] = "DHL-Versand in das Zielland {$address['country_code']} ist aktuell nicht freigegeben."; + } + } catch (\InvalidArgumentException $e) { + $errors[] = $e->getMessage(); + + return; + } + + if (isset(self::COUNTRY_SPECIFIC_POSTAL_PATTERNS[$address['country_code']]) && $address['postal_code'] !== '') { + $rule = self::COUNTRY_SPECIFIC_POSTAL_PATTERNS[$address['country_code']]; + if (! preg_match($rule['pattern'], $address['postal_code'])) { + $errors[] = $rule['message']; + } + + return; + } + + if ($address['postal_code'] !== '') { + $warnings[] = 'Fuer dieses Zielland ist aktuell nur eine Basis-Adresspruefung verfuegbar. Bitte Adresse manuell pruefen.'; + } + } + + private function hasDachValidation(string $countryCode): bool + { + return in_array($countryCode, self::DACH_COUNTRIES, true); + } + + private function validateAddressPlausibility(array $address, array &$errors): void + { + $isDachAddress = $this->hasDachValidation($address['country_code']); + + if ($address['street'] !== '' && mb_strlen($address['street']) < 3) { + $errors[] = 'Straße ist zu kurz.'; + } + + if ($address['street'] !== '' && ! preg_match('/[a-zäöüß]/iu', $address['street'])) { + $errors[] = 'Straße muss Buchstaben enthalten.'; + } + + if ($address['city'] !== '' && mb_strlen($address['city']) < 2) { + $errors[] = 'Ort ist zu kurz.'; + } + + if ($address['city'] !== '' && ! preg_match('/[a-zäöüß]/iu', $address['city'])) { + $errors[] = 'Ort muss Buchstaben enthalten.'; + } + + if ($address['postal_code'] !== '' && ! preg_match('/^[A-Z0-9][A-Z0-9 -]{2,9}$/i', $address['postal_code'])) { + $errors[] = 'Postleitzahl enthaelt ungueltige Zeichen.'; + } + + if ($isDachAddress && ! $this->isPackstation($address) && ! preg_match('/\d/', $address['house_number'])) { + $errors[] = 'Hausnummer muss fuer DACH-Adressen eine Ziffer enthalten.'; + } + + if ($this->containsPlaceholderValue($address['street'])) { + $errors[] = 'Straße wirkt wie eine Test- oder Platzhalteradresse.'; + } + + if ($this->containsPlaceholderValue($address['city'])) { + $errors[] = 'Ort wirkt wie eine Test- oder Platzhalterangabe.'; + } + } + + private function validatePackstation(array $address, array &$errors, array &$warnings): void + { + if ($this->isPackstation($address) && $address['postnumber'] === '') { + $errors[] = 'DHL Postnummer ist fuer Packstation/Paketbox erforderlich.'; + } + + if ($address['postnumber'] === '') { + return; + } + + if ($address['country_code'] !== 'DE') { + $errors[] = 'Packstation/Paketbox ist aktuell nur fuer Deutschland erlaubt.'; + } + + if (! preg_match('/^[0-9]{6,10}$/', $address['postnumber'])) { + $errors[] = 'DHL Postnummer muss 6-10 Ziffern enthalten.'; + } + + if (! $this->isPackstation($address)) { + $warnings[] = 'DHL Postnummer ist gesetzt. Bitte pruefen, ob Straße/Nr. eine Packstation oder Paketbox enthaelt.'; + } + + $lockerNumber = $this->extractLockerNumber($address); + if ($this->isPackstation($address) && $lockerNumber === null) { + $errors[] = 'Packstation-/Paketbox-Nummer fehlt.'; + } + + if ($lockerNumber !== null && ((int) $lockerNumber < 100 || (int) $lockerNumber > 999)) { + $errors[] = 'Packstation-/Paketbox-Nummer muss 3-stellig sein (100-999).'; + } + } + + private function validateWarnings(array $address, array &$warnings): void + { + if ($address['phone'] === '') { + $warnings[] = 'Telefonnummer fehlt. DHL kann Empfaenger bei Zustellproblemen eventuell nicht kontaktieren.'; + } + + if ($address['email'] === '') { + $warnings[] = 'E-Mail-Adresse fehlt. Tracking-Mails koennen nicht automatisch an diese Adresse gesendet werden.'; + } + + if (! $this->hasDachValidation($address['country_code']) && ! $this->isPackstation($address) && ! preg_match('/\d/', $address['house_number'])) { + $warnings[] = 'Hausnummer enthaelt keine Ziffer. Bitte Adresse manuell pruefen.'; + } + } + + private function containsPlaceholderValue(string $value): bool + { + $value = mb_strtolower(trim($value)); + + if ($value === '') { + return false; + } + + return (bool) preg_match('/(test|fake|falsch|dummy|asdf|qwertz|qwerty|xxx|kein|ungueltig|invalid)/u', $value) + || preg_match('/^(.)\1{2,}$/u', $value); + } + + private function isPackstation(array $address): bool + { + return (bool) preg_match('/\b(packstation|paketbox)\b/i', $address['street'].' '.$address['house_number']); + } + + private function extractLockerNumber(array $address): ?string + { + if (preg_match('/(?:packstation|paketbox)\s*(\d+)/i', $address['street'], $matches)) { + return $matches[1]; + } + + if ($this->isPackstation($address) && preg_match('/\d+/', $address['house_number'], $matches)) { + return $matches[0]; + } + + return null; + } +} diff --git a/app/Services/DhlDataHelper.php b/app/Services/DhlDataHelper.php index 319de1b..a1862f2 100644 --- a/app/Services/DhlDataHelper.php +++ b/app/Services/DhlDataHelper.php @@ -35,15 +35,27 @@ class DhlDataHelper $settingController = new SettingController; $dhlConfig = $settingController->getDhlConfig(); } - $dimensions = isset($dhlConfig['dimensions'][$options['product_code']]) ? $dhlConfig['dimensions'][$options['product_code']] : $dhlConfig['dimensions']['default']; + $resolver = new DhlProductResolver; + $destinationCountryCode = $shippingAddress['country']?->code; + if (! $destinationCountryCode) { + throw new \Exception('shipping_address.country is required'); + } + + $resolvedDhlProduct = $resolver->resolveForShipment( + $destinationCountryCode, + $options['product_code'] ?? null, + $dhlConfig['default_product'] ?? 'V01PAK' + ); + $dimensions = $dhlConfig['dimensions'][$resolvedDhlProduct['product_code']] ?? $dhlConfig['dimensions']['default']; return [ 'order_id' => $order->id, 'weight_kg' => $weight, - 'product_code' => $options['product_code'] ?? 'V01PAK', + 'product_code' => $resolvedDhlProduct['product_code'], 'label_format' => $options['label_format'] ?? $dhlConfig['label_format'] ?? 'PDF', 'print_format' => $options['print_format'] ?? $dhlConfig['print_format'] ?? null, 'retoure_print_format' => $options['retoure_print_format'] ?? $dhlConfig['retoure_print_format'] ?? null, + 'print_only_if_codeable' => (bool) ($options['print_only_if_codeable'] ?? $dhlConfig['print_only_if_codeable'] ?? config('dhl.print_only_if_codeable', true)), // Shipper data (sender) - from admin settings 'shipper' => [ @@ -66,7 +78,7 @@ class DhlDataHelper 'houseNumber' => $shippingAddress['houseNumber'] ?? '', 'postalCode' => $shippingAddress['zipcode'] ?? '', 'city' => $shippingAddress['city'] ?? '', - 'country' => $shippingAddress['country']?->code ?? 'DE', + 'country' => $resolvedDhlProduct['country_code'], 'email' => $shippingAddress['email'] ?? '', 'phone' => $shippingAddress['phone'] ?? '', // DHL Postnummer für Packstation/Paketbox @@ -82,7 +94,18 @@ class DhlDataHelper 'services' => $options['services'] ?? [], // Custom reference for tracking - 'reference' => 'Order-'.$order->id, + 'reference' => self::normalizeReference($options['reference'] ?? $options['shipment_reference'] ?? null, $order), ]; } + + private static function normalizeReference(?string $reference, ShoppingOrder $order): string + { + $reference = trim((string) $reference); + + if ($reference === '') { + return 'Order-'.$order->id; + } + + return mb_substr(preg_replace('/\s+/', ' ', $reference), 0, 35); + } } diff --git a/app/Services/DhlModalService.php b/app/Services/DhlModalService.php index b4541de..50233f0 100644 --- a/app/Services/DhlModalService.php +++ b/app/Services/DhlModalService.php @@ -45,6 +45,8 @@ class DhlModalService 'shippingAddress' => null, 'availableCountries' => $this->getAvailableCountries(), 'productCodes' => $this->getAvailableProductCodes(), + 'productSuggestions' => (new DhlProductResolver)->getProductSuggestionsByCountry(), + 'selectedProductCode' => null, 'errors' => [], 'warnings' => [], 'existingShipments' => [], @@ -89,6 +91,7 @@ class DhlModalService // Process and validate shipping address $result['shippingAddress'] = $this->processShippingAddress($order); + $result['selectedProductCode'] = $this->getSuggestedProductCode($result['shippingAddress']); // Validate address completeness $addressValidation = $this->validateAddress($result['shippingAddress']); @@ -111,7 +114,7 @@ class DhlModalService 'error' => $e->getMessage(), ]); - $result['errors'][] = 'Fehler beim Laden der Bestelldaten: ' . $e->getMessage(); + $result['errors'][] = 'Fehler beim Laden der Bestelldaten: '.$e->getMessage(); } return $result; @@ -125,7 +128,7 @@ class DhlModalService private function loadOrder($id): ?ShoppingOrder { return ShoppingOrder::with([ - 'shopping_order_items', + 'shopping_order_items.product', 'shopping_user', 'dhlShipments', // Include DHL shipments ])->find($id); @@ -170,38 +173,7 @@ class DhlModalService */ private function calculateOrderWeight(ShoppingOrder $order): float { - return $order->weight / 1000; // from grams to kg - /* - // Default fallback weight - $defaultWeight = 1.0; - - if (!$order->shopping_order_items || $order->shopping_order_items->isEmpty()) { - return $defaultWeight; - } - - // If order has a weight field (in grams), convert to kg - if ($order->weight && $order->weight > 0) { - return round($order->weight / 100, 1); // Convert grams to kg - } - - // Calculate from items if available - $totalWeight = 0; - foreach ($order->shopping_order_items as $item) { - if ($item->weight && $item->weight > 0) { - $totalWeight += ($item->weight * $item->quantity); - } - } - - if ($totalWeight > 0) { - return round($totalWeight / 100, 1); // Convert grams to kg - } - - // Estimate based on item count if no weight data - $itemCount = $order->shopping_order_items->sum('quantity'); - $estimatedWeight = max($itemCount * 0.5, $defaultWeight); // Estimate 0.5kg per item - - return round($estimatedWeight, 1); - */ + return (new DhlShipmentWeightCalculator)->calculate($order); } /** @@ -287,50 +259,12 @@ class DhlModalService */ private function validateAddress(array $address): array { - $errors = []; - $warnings = []; - - // Required fields - $requiredFields = [ - 'firstname' => 'Vorname', - 'lastname' => 'Nachname', - 'address' => 'Straße', - 'zipcode' => 'Postleitzahl', - 'city' => 'Stadt', - ]; - - foreach ($requiredFields as $field => $label) { - if (empty(trim($address[$field]))) { - $errors[] = "{$label} ist erforderlich."; - } - } - - // Name validation - if (empty(trim($address['firstname'])) && empty(trim($address['lastname'])) && empty(trim($address['company']))) { - $errors[] = 'Entweder Name oder Firmenname muss angegeben werden.'; - } - - // Street number validation - if (! empty($address['address']) && empty($address['houseNumber'])) { - $warnings[] = 'Hausnummer konnte nicht automatisch erkannt werden. Bitte prüfen Sie die Adressangaben.'; - } - - // Postal code format validation for Germany - if (! empty($address['zipcode']) && $address['country'] && $address['country']->code === 'DE') { - if (! preg_match('/^\d{5}$/', $address['zipcode'])) { - $warnings[] = 'Deutsche Postleitzahl sollte 5 Ziffern haben.'; - } - } - - // Country validation - if (! $address['country']) { - $errors[] = 'Land konnte nicht ermittelt werden.'; - } + $result = (new DhlAddressValidator)->validate($address); return [ - 'valid' => empty($errors), - 'errors' => $errors, - 'warnings' => $warnings, + 'valid' => $result['can_create_label'], + 'errors' => $result['errors'], + 'warnings' => $result['warnings'], ]; } @@ -386,8 +320,8 @@ class DhlModalService $productCodes['V53PAK'] = 'DHL Paket International'; } - if (! empty($accountNumbers['V62WP'])) { - $productCodes['V62WP'] = 'DHL Warenpost National'; + if (! empty($accountNumbers['V62KP'])) { + $productCodes['V62KP'] = 'DHL Kleinpaket'; } if (! empty($accountNumbers['V07PAK'])) { @@ -399,13 +333,27 @@ class DhlModalService $productCodes = [ 'V01PAK' => 'DHL Paket National', 'V53PAK' => 'DHL Paket International', - 'V62WP' => 'DHL Warenpost National', + 'V62KP' => 'DHL Kleinpaket', ]; } return $productCodes; } + private function getSuggestedProductCode(array $shippingAddress): string + { + $countryCode = $shippingAddress['country']?->code; + if (! $countryCode) { + return 'V01PAK'; + } + + try { + return (new DhlProductResolver)->resolveProductCode($countryCode, null, 'V01PAK'); + } catch (\InvalidArgumentException) { + return 'V01PAK'; + } + } + /** * Validate shipment parameters before API call * @@ -431,23 +379,35 @@ class DhlModalService $errors[] = 'Ungültiger Produktcode ausgewählt.'; } - // Address validation - $requiredAddressFields = [ - 'shipping_firstname' => 'Vorname', - 'shipping_lastname' => 'Nachname', - 'shipping_address' => 'Straße', - 'shipping_houseNumber' => 'Hausnummer', - 'shipping_zipcode' => 'Postleitzahl', - 'shipping_city' => 'Stadt', - 'shipping_country_id' => 'Land', - ]; - - foreach ($requiredAddressFields as $field => $label) { - if (empty(trim($shipmentData[$field] ?? ''))) { - $errors[] = "{$label} ist erforderlich."; + if (! empty($shipmentData['shipping_country_id']) && $productCode) { + $country = Country::find($shipmentData['shipping_country_id']); + if ($country) { + try { + (new DhlProductResolver)->resolveProductCode($country->code, $productCode); + } catch (\InvalidArgumentException $e) { + $errors[] = $e->getMessage(); + } } } + if ($productCode) { + try { + (new DhlShipmentWeightCalculator)->assertWithinProductLimit($weight, $productCode); + } catch (\InvalidArgumentException $e) { + $errors[] = $e->getMessage(); + } + } + + $country = null; + if (! empty($shipmentData['shipping_country_id'])) { + $country = Country::find($shipmentData['shipping_country_id']); + } + $addressValidation = (new DhlAddressValidator)->validate(array_merge($shipmentData, [ + 'shipping_country_code' => $country?->code, + ])); + $errors = array_merge($errors, $addressValidation['errors']); + $warnings = array_merge($warnings, $addressValidation['warnings']); + return [ 'valid' => empty($errors), 'errors' => $errors, diff --git a/app/Services/DhlProductResolver.php b/app/Services/DhlProductResolver.php new file mode 100644 index 0000000..fea8af6 --- /dev/null +++ b/app/Services/DhlProductResolver.php @@ -0,0 +1,199 @@ + 'DEU', + 'AT' => 'AUT', + 'ES' => 'ESP', + 'CH' => 'CHE', + 'US' => 'USA', + 'GB' => 'GBR', + 'FR' => 'FRA', + 'IT' => 'ITA', + 'NL' => 'NLD', + 'BE' => 'BEL', + 'PL' => 'POL', + 'CZ' => 'CZE', + 'DK' => 'DNK', + 'SE' => 'SWE', + 'NO' => 'NOR', + ]; + + /** + * @return array{country_code: string, dhl_country_code: string, product_code: string} + */ + public function resolveForShipment(string $destinationCountryCode, ?string $requestedProductCode = null, ?string $defaultProductCode = null): array + { + $countryCode = $this->normalizeCountryCode($destinationCountryCode); + $productCode = $this->resolveProductCode($countryCode, $requestedProductCode, $defaultProductCode); + + return [ + 'country_code' => $countryCode, + 'dhl_country_code' => $this->toDhlCountryCode($countryCode), + 'product_code' => $productCode, + ]; + } + + public function resolveProductCode(string $destinationCountryCode, ?string $requestedProductCode = null, ?string $defaultProductCode = null): string + { + $countryCode = $this->normalizeCountryCode($destinationCountryCode); + $hasRequestedProduct = $requestedProductCode !== null && trim($requestedProductCode) !== ''; + $productCode = $this->normalizeProductCode($requestedProductCode ?: $defaultProductCode); + + if ($countryCode === self::DOMESTIC_COUNTRY) { + $productCode = $productCode ?: 'V01PAK'; + + if (! in_array($productCode, self::DOMESTIC_PRODUCT_CODES, true)) { + throw new InvalidArgumentException("Produkt {$productCode} ist fuer DHL-Versand nach Deutschland nicht erlaubt."); + } + + return $productCode; + } + + if (! in_array($countryCode, $this->getSupportedInternationalCountries(), true)) { + throw new InvalidArgumentException("DHL-Versand in das Zielland {$countryCode} ist aktuell nicht freigegeben."); + } + + if (! $productCode || (! $hasRequestedProduct && in_array($productCode, self::DOMESTIC_PRODUCT_CODES, true))) { + return self::INTERNATIONAL_PRODUCT_CODE; + } + + if ($productCode !== self::INTERNATIONAL_PRODUCT_CODE) { + throw new InvalidArgumentException("Produkt {$productCode} ist fuer DHL-Versand in das Zielland {$countryCode} nicht erlaubt."); + } + + return $productCode; + } + + /** + * @return array + */ + public function getProductSuggestionsByCountry(): array + { + return array_fill_keys($this->getSupportedInternationalCountries(), self::INTERNATIONAL_PRODUCT_CODE) + + [self::DOMESTIC_COUNTRY => 'V01PAK']; + } + + /** + * @return string[] + */ + public function getAllowedProductCodesForCountry(string $destinationCountryCode): array + { + $countryCode = $this->normalizeCountryCode($destinationCountryCode); + + if ($countryCode === self::DOMESTIC_COUNTRY) { + return self::DOMESTIC_PRODUCT_CODES; + } + + if (in_array($countryCode, $this->getSupportedInternationalCountries(), true)) { + return [self::INTERNATIONAL_PRODUCT_CODE]; + } + + return []; + } + + public function toDhlCountryCode(string $countryCode): string + { + $countryCode = $this->normalizeCountryCode($countryCode); + + return self::DHL_COUNTRY_CODES[$countryCode]; + } + + public function assertBillingNumber(string $productCode, ?string $billingNumber): string + { + if ($billingNumber === null || trim($billingNumber) === '') { + throw new InvalidArgumentException("Keine DHL-Abrechnungsnummer fuer Produkt {$productCode} konfiguriert."); + } + + return $billingNumber; + } + + public function getProductScope(string $productCode): string + { + $productCode = $this->normalizeProductCode($productCode); + + return $productCode === self::INTERNATIONAL_PRODUCT_CODE ? 'international' : 'national'; + } + + public function getProductScopeLabel(string $productCode): string + { + return $this->getProductScope($productCode) === 'international' + ? 'DHL Paket International' + : 'DHL Paket National'; + } + + public function normalizeCountryCode(string $countryCode): string + { + $countryCode = strtoupper(trim($countryCode)); + + if ($countryCode === '') { + throw new InvalidArgumentException('DHL-Zielland fehlt.'); + } + + if (strlen($countryCode) === 3) { + $countryCode = array_search($countryCode, self::DHL_COUNTRY_CODES, true) ?: $countryCode; + } + + if (! array_key_exists($countryCode, self::DHL_COUNTRY_CODES)) { + throw new InvalidArgumentException("DHL-Laendercode {$countryCode} wird nicht unterstuetzt."); + } + + return $countryCode; + } + + public function normalizeProductCode(?string $productCode): ?string + { + if ($productCode === null || trim($productCode) === '') { + return null; + } + + return strtoupper(trim($productCode)); + } + + /** + * @return string[] + */ + public function getSupportedInternationalCountries(): array + { + $useEnvPriority = config('dhl.config_source') === 'env'; + $configCountries = config('dhl.international_countries', self::DEFAULT_INTERNATIONAL_COUNTRIES); + $countries = $configCountries; + + if (! $useEnvPriority) { + $countries = Setting::getContentBySlug('dhl_international_countries') ?: $configCountries; + } + + return self::normalizeCountryCodeList(is_array($countries) ? $countries : []); + } + + /** + * @return string[] + */ + public static function normalizeCountryCodeList(array $countryCodes): array + { + $countryCodes = array_map( + static fn ($countryCode): string => strtoupper(trim((string) $countryCode)), + $countryCodes + ); + + return array_values(array_filter(array_unique($countryCodes), static function (string $countryCode): bool { + return $countryCode !== '' + && $countryCode !== self::DOMESTIC_COUNTRY + && array_key_exists($countryCode, self::DHL_COUNTRY_CODES); + })); + } +} diff --git a/app/Services/DhlShipmentService.php b/app/Services/DhlShipmentService.php index ccc426c..c0bb012 100644 --- a/app/Services/DhlShipmentService.php +++ b/app/Services/DhlShipmentService.php @@ -2,18 +2,18 @@ namespace App\Services; +use Acme\Dhl\Exceptions\DhlAddressValidationException; use Acme\Dhl\Models\DhlShipment; -use App\Models\ShoppingOrder; use App\Http\Controllers\SettingController; -use App\Jobs\CreateShipmentJob; use App\Jobs\CancelShipmentJob; -use App\Services\DhlDataHelper; -use Illuminate\Support\Facades\Log; +use App\Jobs\CreateShipmentJob; +use App\Models\ShoppingOrder; use Exception; +use Illuminate\Support\Facades\Log; /** * DHL Shipment Service - * + * * Handles both synchronous and asynchronous shipment creation * based on configuration settings */ @@ -21,20 +21,23 @@ class DhlShipmentService { /** * Create a DHL shipment (sync or async based on config) - * - * @param ShoppingOrder $order - * @param float $weight - * @param array $options - * @return array */ public function createShipment(ShoppingOrder $order, float $weight = 1.0, array $options = []): array { + $weight = max($weight, (new DhlShipmentWeightCalculator)->calculate($order)); + // Get DHL configuration - $settingController = new SettingController(); + $settingController = new SettingController; $dhlConfig = $settingController->getDhlConfig(); \Log::info('dhlConfig', $dhlConfig); // Check if queue should be used $useQueue = $dhlConfig['use_queue'] ?? false; + if ($useQueue && $this->requiresSynchronousAddressValidation($options, $dhlConfig)) { + Log::info('[DHL Service] Queue disabled for DHL mustEncode address validation', [ + 'order_id' => $order->id, + ]); + $useQueue = false; + } if ($useQueue) { return $this->createShipmentAsync($order, $weight, $options, $dhlConfig); @@ -43,14 +46,20 @@ class DhlShipmentService } } + private function requiresSynchronousAddressValidation(array $options, array $dhlConfig): bool + { + if (! (bool) ($options['print_only_if_codeable'] ?? $dhlConfig['print_only_if_codeable'] ?? config('dhl.print_only_if_codeable', true))) { + return false; + } + + $country = $options['shipping_address']['country'] ?? null; + $countryCode = is_object($country) ? ($country->code ?? null) : ($country['code'] ?? null); + + return strtoupper((string) $countryCode) === DhlProductResolver::DOMESTIC_COUNTRY; + } + /** * Create shipment asynchronously using queue - * - * @param ShoppingOrder $order - * @param float $weight - * @param array $options - * @param array $dhlConfig - * @return array */ private function createShipmentAsync(ShoppingOrder $order, float $weight, array $options, array $dhlConfig): array { @@ -60,14 +69,14 @@ class DhlShipmentService Log::info('[DHL Service] Shipment creation dispatched to queue', [ 'order_id' => $order->id, - 'weight' => $weight + 'weight' => $weight, ]); return [ 'success' => true, 'message' => 'Sendung wird erstellt. Sie erhalten eine Benachrichtigung, sobald das Versandlabel verfügbar ist.', 'queued' => true, - 'order_id' => $order->id + 'order_id' => $order->id, ]; } catch (Exception $e) { Log::error('[DHL Service] Failed to dispatch shipment creation', [ @@ -77,27 +86,21 @@ class DhlShipmentService return [ 'success' => false, - 'message' => 'Fehler beim Einreihen der Sendungserstellung: ' . $e->getMessage(), - 'queued' => false + 'message' => 'Fehler beim Einreihen der Sendungserstellung: '.$e->getMessage(), + 'queued' => false, ]; } } /** * Create shipment synchronously - * - * @param ShoppingOrder $order - * @param float $weight - * @param array $options - * @param array $dhlConfig - * @return array */ private function createShipmentSync(ShoppingOrder $order, float $weight, array $options, array $dhlConfig): array { try { Log::info('[DHL Service] Creating shipment synchronously', [ 'order_id' => $order->id, - 'weight' => $weight + 'weight' => $weight, ]); // Create DHL client directly with correct base URL @@ -132,32 +135,42 @@ class DhlShipmentService 'label_path' => $result['labelPath'] ?? null, 'label_url' => $result['labelUrl'] ?? null, ]; - } catch (Exception $e) { - Log::error('[DHL Service] Shipment creation failed (sync)', [ + } catch (DhlAddressValidationException $e) { + Log::warning('[DHL Service] Shipment address validation failed (sync)', [ 'order_id' => $order->id, - 'error' => $e->getMessage() + 'error' => $e->getMessage(), ]); return [ 'success' => false, - 'message' => 'Fehler beim Erstellen des Versandlabels: ' . $e->getMessage(), + 'type' => 'dhl_address_validation', + 'message' => $e->getMessage(), + 'errors' => [$e->getMessage()], 'queued' => false, - 'order_id' => $order->id + 'order_id' => $order->id, + ]; + } catch (Exception $e) { + Log::error('[DHL Service] Shipment creation failed (sync)', [ + 'order_id' => $order->id, + 'error' => $e->getMessage(), + ]); + + return [ + 'success' => false, + 'message' => 'Fehler beim Erstellen des Versandlabels: '.$e->getMessage(), + 'queued' => false, + 'order_id' => $order->id, ]; } } /** * Cancel a DHL shipment (sync or async based on config) - * - * @param DhlShipment $shipment - * @param array $options - * @return array */ public function cancelShipment(DhlShipment $shipment, array $options = []): array { // Get DHL configuration - $settingController = new SettingController(); + $settingController = new SettingController; $dhlConfig = $settingController->getDhlConfig(); // Check if queue should be used @@ -172,11 +185,6 @@ class DhlShipmentService /** * Cancel shipment asynchronously using queue - * - * @param DhlShipment $shipment - * @param array $options - * @param array $dhlConfig - * @return array */ private function cancelShipmentAsync(DhlShipment $shipment, array $options, array $dhlConfig): array { @@ -186,14 +194,14 @@ class DhlShipmentService Log::info('[DHL Service] Shipment cancellation dispatched to queue', [ 'shipment_id' => $shipment->id, - 'dhl_shipment_no' => $shipment->dhl_shipment_no + 'dhl_shipment_no' => $shipment->dhl_shipment_no, ]); return [ 'success' => true, 'message' => 'Sendung wird storniert...', 'queued' => true, - 'shipment_id' => $shipment->id + 'shipment_id' => $shipment->id, ]; } catch (Exception $e) { Log::error('[DHL Service] Failed to dispatch shipment cancellation', [ @@ -203,40 +211,39 @@ class DhlShipmentService return [ 'success' => false, - 'message' => 'Fehler beim Einreihen der Stornierung: ' . $e->getMessage(), - 'queued' => false + 'message' => 'Fehler beim Einreihen der Stornierung: '.$e->getMessage(), + 'queued' => false, ]; } } /** * Cancel shipment synchronously - * - * @param DhlShipment $shipment - * @param array $options - * @param array $dhlConfig - * @return array */ private function cancelShipmentSync(DhlShipment $shipment, array $options, array $dhlConfig): array { try { // Validate shipment has DHL number if (empty($shipment->dhl_shipment_no)) { + $this->recordCancellationFailure($shipment, 'missing_shipment_number', 'Sendung hat keine DHL-Sendungsnummer und kann nicht storniert werden.'); + return [ 'success' => false, 'message' => 'Sendung hat keine DHL-Sendungsnummer und kann nicht storniert werden.', 'queued' => false, - 'shipment_id' => $shipment->id + 'shipment_id' => $shipment->id, ]; } // Validate shipment can be cancelled if (! $shipment->canCancel()) { + $this->recordCancellationFailure($shipment, 'status_not_cancelable', 'Sendung kann im aktuellen Status "'.$shipment->status.'" nicht storniert werden.'); + return [ 'success' => false, - 'message' => 'Sendung kann im aktuellen Status "' . $shipment->status . '" nicht storniert werden. Nur Status "created" oder "pending" sind stornierbar.', + 'message' => 'Sendung kann im aktuellen Status "'.$shipment->getStatusTranslation().'" nicht storniert werden. Nur Status "Erstellt" oder "Wartend" sind stornierbar.', 'queued' => false, - 'shipment_id' => $shipment->id + 'shipment_id' => $shipment->id, ]; } @@ -244,7 +251,7 @@ class DhlShipmentService 'shipment_id' => $shipment->id, 'dhl_shipment_no' => $shipment->dhl_shipment_no, 'status' => $shipment->status, - 'base_url' => $dhlConfig['base_url'] + 'base_url' => $dhlConfig['base_url'], ]); // Create DHL client @@ -263,37 +270,41 @@ class DhlShipmentService if ($success) { Log::info('[DHL Service] Shipment cancelled successfully (sync)', [ 'shipment_id' => $shipment->id, - 'dhl_shipment_no' => $shipment->dhl_shipment_no + 'dhl_shipment_no' => $shipment->dhl_shipment_no, ]); return [ 'success' => true, 'message' => 'Sendung wurde erfolgreich storniert!', 'queued' => false, - 'shipment_id' => $shipment->id + 'shipment_id' => $shipment->id, ]; } else { throw new Exception('Cancellation returned false'); } } catch (\InvalidArgumentException $e) { + $this->recordCancellationFailure($shipment, 'validation_failed', $e->getMessage(), $e); + Log::warning('[DHL Service] Shipment cancellation validation failed', [ 'shipment_id' => $shipment->id, - 'error' => $e->getMessage() + 'error' => $e->getMessage(), ]); return [ 'success' => false, 'message' => $e->getMessage(), 'queued' => false, - 'shipment_id' => $shipment->id + 'shipment_id' => $shipment->id, ]; } catch (Exception $e) { + $this->recordCancellationFailure($shipment, 'api_failed', $e->getMessage(), $e); + Log::error('[DHL Service] Shipment cancellation failed (sync)', [ 'shipment_id' => $shipment->id, 'dhl_shipment_no' => $shipment->dhl_shipment_no, 'status' => $shipment->status, 'error' => $e->getMessage(), - 'error_trace' => $e->getTraceAsString() + 'error_trace' => $e->getTraceAsString(), ]); // Check if it's an API authentication/resource error @@ -304,16 +315,50 @@ class DhlShipmentService 'message' => 'Die Sendung konnte bei DHL nicht gefunden werden. Mögliche Ursachen: Sendung wurde bereits storniert, ist zu alt, oder wurde in einem anderen Modus (Sandbox/Production) erstellt.', 'queued' => false, 'shipment_id' => $shipment->id, - 'technical_error' => $errorMessage + 'technical_error' => $errorMessage, ]; } return [ 'success' => false, - 'message' => 'Fehler beim Stornieren der Sendung: ' . $errorMessage, + 'message' => 'Fehler beim Stornieren der Sendung: '.$errorMessage, 'queued' => false, - 'shipment_id' => $shipment->id + 'shipment_id' => $shipment->id, ]; } } + + private function recordCancellationFailure(DhlShipment $shipment, string $reason, string $detail, ?Exception $exception = null): void + { + $apiResponseData = $shipment->api_response_data ?? []; + $apiResponseData['cancellation_error'] = [ + 'status' => 'failed', + 'reason' => $reason, + 'http_status' => $exception ? $this->extractHttpStatus($exception->getMessage()) : null, + 'dhl_code' => $exception ? $this->extractDhlErrorCode($exception->getMessage()) : null, + 'detail' => $detail, + 'exception_class' => $exception ? $exception::class : null, + 'occurred_at' => now()->toISOString(), + ]; + + $shipment->update(['api_response_data' => $apiResponseData]); + } + + private function extractHttpStatus(string $message): ?int + { + if (preg_match('/\b([45][0-9]{2})\b/', $message, $matches)) { + return (int) $matches[1]; + } + + return null; + } + + private function extractDhlErrorCode(string $message): ?string + { + if (preg_match('/\b([A-Z]{2}-[A-Za-z0-9_-]+)\b/', $message, $matches)) { + return $matches[1]; + } + + return null; + } } diff --git a/app/Services/DhlShipmentWeightCalculator.php b/app/Services/DhlShipmentWeightCalculator.php new file mode 100644 index 0000000..159b821 --- /dev/null +++ b/app/Services/DhlShipmentWeightCalculator.php @@ -0,0 +1,89 @@ + 31.5, + 'V53PAK' => 31.5, + 'V62KP' => 1.0, + ]; + + public function calculate(ShoppingOrder $order): float + { + $this->loadOrderItems($order); + + $baseWeightGrams = max((int) ($order->weight ?? 0), 0); + $compensationWeightGrams = $this->calculateCompensationWeightGrams($order); + $totalWeightGrams = $baseWeightGrams + $compensationWeightGrams; + + if ($totalWeightGrams <= 0) { + return self::DEFAULT_WEIGHT_KG; + } + + return $this->roundWeightKg($totalWeightGrams / 1000); + } + + public function getMaxWeightKgForProduct(?string $productCode): float + { + $productCode = strtoupper(trim((string) $productCode)); + + return self::PRODUCT_MAX_WEIGHTS_KG[$productCode] ?? self::DEFAULT_MAX_WEIGHT_KG; + } + + public function assertWithinProductLimit(float $weightKg, ?string $productCode): void + { + $maxWeightKg = $this->getMaxWeightKgForProduct($productCode); + + if ($weightKg > $maxWeightKg) { + throw new InvalidArgumentException(sprintf( + 'Gewicht %.3f kg ueberschreitet das DHL-Maximalgewicht fuer %s (%.1f kg).', + $weightKg, + $productCode ?: 'das gewaehlte Produkt', + $maxWeightKg + )); + } + } + + private function calculateCompensationWeightGrams(ShoppingOrder $order): int + { + $items = $order->shopping_order_items ?? collect(); + $weightGrams = 0; + + foreach ($items as $item) { + if ((int) ($item->comp ?? 0) <= 0) { + continue; + } + + $productWeight = (int) ($item->product?->weight ?? 0); + if ($productWeight <= 0) { + continue; + } + + $quantity = max((int) ($item->qty ?? 1), 1); + $weightGrams += $productWeight * $quantity; + } + + return $weightGrams; + } + + private function roundWeightKg(float $weightKg): float + { + return round(max($weightKg, 0.1), 3); + } + + private function loadOrderItems(ShoppingOrder $order): void + { + if ($order->exists && ! $order->relationLoaded('shopping_order_items')) { + $order->loadMissing('shopping_order_items.product'); + } + } +} diff --git a/app/Services/DhlTrackingService.php b/app/Services/DhlTrackingService.php index 61a7e2b..ea58e1b 100644 --- a/app/Services/DhlTrackingService.php +++ b/app/Services/DhlTrackingService.php @@ -307,6 +307,16 @@ class DhlTrackingService } } + /** + * Update tracking immediately, bypassing queue dispatch. + */ + public function updateTrackingNow(DhlShipment $shipment, array $options = []): array + { + $settingController = new SettingController; + + return $this->updateTrackingSync($shipment, $options, $settingController->getDhlConfig()); + } + /** * Update tracking asynchronously using queue */ @@ -366,7 +376,7 @@ class DhlTrackingService $result = $this->trackShipment($shipment->dhl_shipment_no); if ($result['success']) { - $internalStatus = $this->mapDhlStatusToInternal($result['status']); + $internalStatus = self::mapDhlStatusToInternal($result['status']); // Update shipment with tracking data $updateData = [ @@ -481,7 +491,7 @@ class DhlTrackingService // Remove from map so we can detect missing ones later unset($shipmentMap[$trackingNo]); - $internalStatus = $this->mapDhlStatusToInternal($trackingResult['status']); + $internalStatus = self::mapDhlStatusToInternal($trackingResult['status']); $updateData = [ 'status' => $internalStatus, @@ -593,19 +603,25 @@ class DhlTrackingService /** * Map DHL status codes to internal status */ - private function mapDhlStatusToInternal(string $dhlStatus): string + public static function mapDhlStatusToInternal(string $dhlStatus): string { $statusMap = [ 'pre-transit' => 'created', + 'pre_transit' => 'created', + 'pretransit' => 'created', 'transit' => 'in_transit', + 'in-transit' => 'in_transit', + 'in_transit' => 'in_transit', 'out-for-delivery' => 'out_for_delivery', + 'out_for_delivery' => 'out_for_delivery', 'delivered' => 'delivered', 'failure' => 'failed', + 'failed' => 'failed', 'returned' => 'returned', 'exception' => 'exception', ]; - return $statusMap[$dhlStatus] ?? 'unknown'; + return $statusMap[strtolower($dhlStatus)] ?? 'unknown'; } /** diff --git a/config/dhl.php b/config/dhl.php index 5474fc7..d031fad 100644 --- a/config/dhl.php +++ b/config/dhl.php @@ -49,6 +49,8 @@ return [ 'print_format' => env('DHL_PRINT_FORMAT', 'A4'), 'retoure_print_format' => env('DHL_RETOURE_PRINT_FORMAT', 'A4'), 'profile' => env('DHL_PROFILE', 'STANDARD_GRUPPENPROFIL'), + 'print_only_if_codeable' => env('DHL_PRINT_ONLY_IF_CODEABLE', true), + 'international_countries' => array_values(array_filter(array_map('trim', explode(',', env('DHL_INTERNATIONAL_COUNTRIES', 'AT,ES'))))), /* |-------------------------------------------------------------------------- @@ -93,7 +95,7 @@ return [ 'account_numbers' => [ 'default' => env('DHL_ACCOUNT_NUMBER_DEFAULT', '63144073550101'), 'V01PAK' => env('DHL_ACCOUNT_NUMBER_V01PAK', '63144073550101'), // DHL Paket National - 'V62WP' => env('DHL_ACCOUNT_NUMBER_V62WP', '63144073556201'), // Warenpost National + 'V62KP' => env('DHL_ACCOUNT_NUMBER_V62KP', '63144073556201'), // DHL Kleinpaket 'V53PAK' => env('DHL_ACCOUNT_NUMBER_V53PAK', '63144073555301'), // DHL Paket International 'V07PAK' => env('DHL_ACCOUNT_NUMBER_V07PAK', '63144073550701'), // DHL Retoure Online ], @@ -101,7 +103,7 @@ return [ 'dimensions' => [ 'default' => ['length' => 120, 'width' => 60, 'height' => 60], 'V01PAK' => ['length' => 120, 'width' => 60, 'height' => 60], // DHL Paket National - 'V62WP' => ['length' => 35, 'width' => 25, 'height' => 8], // Warenpost National + 'V62KP' => ['length' => 35, 'width' => 25, 'height' => 8], // DHL Kleinpaket 'V53PAK' => ['length' => 120, 'width' => 60, 'height' => 60], // DHL Paket International 'V07PAK' => ['length' => 120, 'width' => 60, 'height' => 60], // DHL Retoure Online ], diff --git a/database/migrations/2026_05_27_120253_add_reference_to_dhl_package_shipments_table.php b/database/migrations/2026_05_27_120253_add_reference_to_dhl_package_shipments_table.php new file mode 100644 index 0000000..3727149 --- /dev/null +++ b/database/migrations/2026_05_27_120253_add_reference_to_dhl_package_shipments_table.php @@ -0,0 +1,32 @@ +string('reference', 35)->nullable()->after('routing_code'); + }); + } + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + if (Schema::hasColumn('dhl_package_shipments', 'reference')) { + Schema::table('dhl_package_shipments', function (Blueprint $table) { + $table->dropColumn('reference'); + }); + } + } +}; diff --git a/dev/2026-05-13-dhl-modul/ENTWICKLUNGSKONZEPT-DHL-MODUL.md b/dev/2026-05-13-dhl-modul/ENTWICKLUNGSKONZEPT-DHL-MODUL.md index ffdbf87..beb5209 100644 --- a/dev/2026-05-13-dhl-modul/ENTWICKLUNGSKONZEPT-DHL-MODUL.md +++ b/dev/2026-05-13-dhl-modul/ENTWICKLUNGSKONZEPT-DHL-MODUL.md @@ -1,6 +1,6 @@ # Entwicklungskonzept DHL Modul -Stand: 13.05.2026 +Stand: 27.05.2026 ## Ziel @@ -168,6 +168,78 @@ Risiko: Statusspruenge direkt auf `out_for_delivery` oder besondere DHL-Statusco 4. Statusspruenge wie `created` direkt nach `out_for_delivery` abdecken. 5. Command `dhl:update-tracking` Tests fuer Mailausloeser und Nicht-Ausloeser ergaenzen. +### Phase 8: DHL-seitige Adressvalidierung ueber mustEncode / printOnlyIfCodable + +Status: umgesetzt fuer deutsche Empfaengeradressen. + +Ziel: + +- Die bestehende formale Vorabpruefung bleibt erhalten. +- Bei der finalen DHL-Labelerstellung soll DHL selbst pruefen, ob die Adresse leitcodierbar bzw. versandfaehig ist. +- Dafuer soll der DHL-Query-Parameter `mustEncode=true` genutzt werden. +- `printOnlyIfCodable` ist laut DHL-Spezifikation der Legacy-Name dieser Funktion. +- Wenn DHL die Adresse ablehnt, wird kein Etikett erstellt. +- Die DHL-Fehlermeldung wird in das bestehende Modal zurueckgespiegelt, damit der Admin die Lieferadresse direkt korrigieren kann. +- Laut DHL-Beschreibung ist diese Pruefung nur fuer deutsche Empfaengeradressen relevant. + +Geplanter Ablauf: + +1. Admin oeffnet das DHL-Erstellungsmodal. +2. Die bestehende Vorabpruefung prueft weiterhin: + - Pflichtfelder + - PLZ-Format + - Produkt-/Zielland-Kombination + - Packstation-/Paketbox-Regeln + - einfache Plausibilitaet +3. Beim Klick auf `Sendung jetzt erstellen` wird die Sendung an DHL uebergeben. +4. Der DHL-Request enthaelt `mustEncode=true`. +5. DHL erstellt das Label nur, wenn die Adresse codeable/leitcodierbar ist. +6. Wenn DHL die Adresse ablehnt: + - es wird kein Shipment als erfolgreich erstellt markiert + - die DHL-Fehlermeldung wird normalisiert + - das Modal bleibt geoeffnet + - die Fehlermeldung erscheint unten in der Vorabpruefung + - der Admin kann die Adresse korrigieren und erneut pruefen/erstellen + +Geplante technische Integrationspunkte: + +- `packages/acme-laravel-dhl/src/Services/ShippingService.php` + - Create-Shipment-Request um den Query-Parameter `mustEncode=true` erweitern. + - Legacy-Begriff `printOnlyIfCodable` nur als Dokumentations-/Kompatibilitaetshinweis fuehren, nicht als neuen internen Optionsnamen verwenden. + - DHL-Fehlerantworten fuer nicht codeable Adressen strukturiert auswerten. +- `app/Services/DhlDataHelper.php` + - Option aus Konfiguration/Settings fuer die Request-Erstellung vorbereiten. +- `app/Services/DhlShipmentService.php` + - DHL-Adressfehler als fachliche Validierungsfehler behandeln, nicht als generischen Systemfehler. +- `app/Http/Controllers/DhlShipmentController.php` + - Fehlerantwort im JSON so zurueckgeben, dass das Modal sie anzeigen kann. +- `resources/views/admin/dhl/modal_create_shipment.blade.php` + - DHL-Fehler bei Labelerstellung in der bestehenden Vorabpruefungsbox anzeigen. + - Keine separate Browser-Alert-Meldung. +- `config/dhl.php` und/oder DHL-Settings + - Einstellung z. B. `print_only_if_codeable` vorbereiten. + - Standard fachlich klaeren: fuer Produktion bevorzugt aktiv, fuer Tests/Sandbox ggf. konfigurierbar. + +Fachliche Entscheidung: + +- Die formale Pruefung bleibt vor DHL bestehen, damit offensichtliche Fehler frueh im Modal sichtbar sind. +- `mustEncode=true` ist die finale DHL-seitige Absicherung direkt bei der Labelerstellung. +- Eine abgelehnte DHL-Adresse blockiert die Labelerstellung. +- Provider-Ausfall oder API-Fehler muss von einer fachlichen DHL-Adressablehnung unterscheidbar sein. +- Die Fehlermeldung soll fuer Admins handlungsorientiert sein, z. B. `DHL kann diese Adresse nicht leitcodieren. Bitte Straße, Hausnummer, PLZ und Ort pruefen.` +- Fuer nicht-deutsche Empfaengeradressen darf `mustEncode` nicht als vollwertige externe Adressvalidierung dargestellt werden, solange DHL diese Einschraenkung in der API-Dokumentation nennt. + +Offene Klaerung nach Implementierung: + +- Verhalten in Sandbox und Produktion. +- Welche DHL-Status-/Fehlercodes fuer nicht codeable Adressen zurueckkommen. +- Ob `mustEncode` fuer alle genutzten Produkte gilt: + - `V01PAK` + - `V62KP` + - `V53PAK` +- Ob internationale Sendungen ignoriert werden, Warnungen liefern oder anders behandelt werden. +- Ob bestehende manuelle Admin-Uebersteuerung fachlich erlaubt sein soll oder DHL-Ablehnung immer hart blockiert. + ## Empfohlene Reihenfolge 1. `V62WP` -> `V62KP`, Statuswerte und `TrackShipmentJob` korrigieren. @@ -176,6 +248,7 @@ Risiko: Statusspruenge direkt auf `out_for_delivery` oder besondere DHL-Statusco 4. Referenzfeld und Gewichtskorrektur umsetzen. 5. Adressvalidierung integrieren. 6. Tracking-Anzeigen und Mailregeln final testen. +7. DHL-seitige Adressvalidierung ueber `mustEncode=true` integrieren, sobald API-Verhalten und Fehlerrueckgaben geklaert sind. ## Teststrategie @@ -185,6 +258,768 @@ Risiko: Statusspruenge direkt auf `out_for_delivery` oder besondere DHL-Statusco - Regression-Test fuer `V62KP` Payload. - Command-Test fuer `dhl:update-tracking --send-emails`. +## Entwicklungsprotokoll + +Dieser Abschnitt dokumentiert die tatsaechlich umgesetzten Entwicklungsschritte. Jede Phase wird hier nach Abschluss mit Ziel, betroffenen Dateien, fachlicher Entscheidung und Verifikation ergaenzt. + +### 27.05.2026 - Phase 1: Pflichtkorrekturen vor DHL-Frist + +Status: abgeschlossen. + +Ziel: + +- Neue DHL-Labels duerfen nicht mehr mit `V62WP` Warenpost erstellt werden. +- `V62KP` DHL Kleinpaket wird als neues Produkt fuer nationale Kleinpaket-Sendungen eingefuehrt. +- Historische Sendungen mit `V62WP` bleiben im System lesbar. + +Umsetzung: + +- `config/dhl.php` + - `DHL_ACCOUNT_NUMBER_V62KP` als neuer Konfigurationskey eingefuehrt. + - `V62KP` in `account_numbers` und `dimensions` aufgenommen. + - `V62WP` aus der produktiven Konfiguration fuer neue Label entfernt. +- `app/Http/Controllers/SettingController.php` + - DHL-Konfiguration liefert nun `account_numbers.V62KP` und `dimensions.V62KP`. + - Altes Datenbank-Setting `dhl_account_v62wp` wird nur noch als Fallback genutzt, falls `dhl_account_v62kp` noch nicht gepflegt ist. + - Altes Default-Produkt `V62WP` wird beim Lesen auf `V62KP` normalisiert. +- `resources/views/admin/settings/index.blade.php` + - Admin-Auswahl fuer Standard-Produkt von `V62WP - Warenpost National` auf `V62KP - DHL Kleinpaket` umgestellt. + - Admin-Feld `dhl_account_v62kp` eingefuehrt. + - Bestehender Wert aus `dhl_account_v62wp` wird im Formular als Fallback angezeigt, damit Bestandskonfigurationen nicht leer starten. +- `app/Services/DhlModalService.php` + - Produktauswahl im DHL-Cockpit bietet `V62KP - DHL Kleinpaket` statt `V62WP - DHL Warenpost National` an. + - Fallback-Produktsatz ebenfalls auf `V62KP` umgestellt. +- `packages/acme-laravel-dhl/src/Services/ShippingService.php` + - Validierung fuer neue Label erlaubt `V62KP`. + - `V62WP` wird fuer neue Label abgelehnt. + - Billing-Nummer-Aufloesung nutzt automatisch den neuen Setting-Key `dhl_account_v62kp`. +- `resources/lang/de/dhl.php`, `resources/lang/en/dhl.php`, `resources/lang/es/dhl.php`, `resources/lang/fr/dhl.php` + - `V62KP` als DHL Kleinpaket ergaenzt. + - `V62WP` als Legacy-Anzeige belassen, damit historische Sendungen weiterhin verstaendlich dargestellt werden. +- `tests/Pest.php` + - TestCase-Bootstrapping fuer `tests/Unit/Dhl` registriert. +- `tests/Unit/Dhl/ShippingServiceProductCodeTest.php` + - Neuer Regression-Test fuer Phase 1 ergaenzt. + +Fachliche Entscheidung: + +- `V62WP` wird nicht hart aus allen Anzeigen entfernt, weil bestehende DHL-Sendungen mit diesem Produktcode historisch nachvollziehbar bleiben muessen. +- Neue Labelerstellung blockiert `V62WP` bewusst ueber die `ShippingService`-Validierung. +- Eine Datenmigration fuer bestehende Settings wurde noch nicht angelegt. Stattdessen wird `dhl_account_v62wp` temporaer als Fallback verwendet. Eine spaetere Migration kann den Wert dauerhaft nach `dhl_account_v62kp` ueberfuehren. + +Verifikation: + +- `./vendor/bin/pint --dirty --format agent` +- `php artisan test --compact tests/Unit/Dhl/ShippingServiceProductCodeTest.php` + - Ergebnis: 3 Tests bestanden, 5 Assertions. +- IDE/Linter-Pruefung der geaenderten Dateien: + - Ergebnis: keine Fehler. + +Offene Folgepunkte: + +- Phase 2: `TrackShipmentJob` auf aktuelles DHL-Model und aktuellen `DhlTrackingService` umstellen. Erledigt am 27.05.2026. +- Phase 2: Storno-Statuswerte zwischen `canceled` und `cancelled` vereinheitlichen. Erledigt am 27.05.2026. +- Optional: Migration oder Admin-Hinweis fuer das alte Setting `dhl_account_v62wp`, sobald die Fallback-Phase beendet werden soll. + +### 27.05.2026 - Phase 2: Tracking-Job und Storno-Status stabilisiert + +Status: abgeschlossen fuer Tracking-Job, Statusvereinheitlichung und strukturierte Storno-Fehlerbasis. + +Ziel: + +- Asynchrones Tracking darf nicht mehr auf das alte, nicht mehr produktive DHL-Model `App\Models\DhlShipment` zugreifen. +- Storno-Statuswerte werden intern auf `canceled` vereinheitlicht. +- Alte Daten mit `cancelled` bleiben lesbar und filterbar. +- Storno-Fehler werden fachlich verwertbarer im Shipment protokolliert. + +Umsetzung: + +- `app/Jobs/TrackShipmentJob.php` + - Import von `App\Models\DhlShipment` auf `Acme\Dhl\Models\DhlShipment` umgestellt. + - Abhaengigkeit auf den alten `DhlApiService` entfernt. + - Job nutzt nun `DhlTrackingService::updateTrackingNow()` ueber Laravel-Dependency-Injection im `handle()`-Methodenparameter. + - Queue-Tracking fuehrt dadurch synchron innerhalb des Jobs aus und dispatcht nicht erneut rekursiv in dieselbe Queue. + - Logging verwendet jetzt `dhl_shipment_no` statt des alten/inkonsistenten `tracking_number`. +- `app/Services/DhlTrackingService.php` + - `updateTrackingNow()` ergaenzt, um Tracking bewusst ohne Queue-Dispatch auszufuehren. + - Bestehender Controller-/Service-Pfad `updateTracking()` bleibt unveraendert und entscheidet weiterhin anhand der DHL-Konfiguration zwischen sync/async. +- `packages/acme-laravel-dhl/src/Models/DhlShipment.php` + - `cancelled` als Legacy-Alias fuer `canceled` eingefuehrt. + - Statusuebersetzung und Badge-Klasse normalisieren alte `cancelled`-Werte auf `canceled`. + - Terminal-Statusliste enthaelt `canceled` und `cancelled`, damit Alt-Datensaetze nicht weiter getrackt werden. +- `packages/acme-laravel-dhl/src/Services/ShippingService.php` + - Erfolgreiche Stornierung setzt weiterhin intern `status = canceled`. + - Erfolgreiche Storno-Antwort wird unter `api_response_data.cancellation` gespeichert. + - Fehlgeschlagene Stornos werden unter `api_response_data.cancellation_error` strukturiert gespeichert: + - `status` + - `http_status` + - `dhl_code` + - `detail` + - `exception_class` + - `occurred_at` +- `app/Services/DhlShipmentService.php` + - Lokale Storno-Validierungsfehler wie fehlende DHL-Sendungsnummer oder nicht stornierbarer Status werden ebenfalls in `api_response_data.cancellation_error` protokolliert. + - Admin-Feedback fuer nicht stornierbaren Status nutzt die uebersetzte Statusbezeichnung. +- `app/Http/Controllers/DhlShipmentController.php` + - Statusfilter normalisiert `cancelled` auf `canceled`. + - Bei Filter `canceled` werden neue `canceled`- und alte `cancelled`-Sendungen gemeinsam gefunden. + - DataTable-Badges verwenden intern `canceled`. +- `resources/views/admin/dhl/cockpit.blade.php` + - Statusfilter zeigt `canceled` als neuen Wert fuer "Storniert". + - Alte URL-/Request-Werte mit `cancelled` bleiben im Select kompatibel. +- `resources/views/admin/dhl/show.blade.php` + - Detailansicht behandelt `canceled` und `cancelled` gleich. + - Aktionsbereich wird fuer beide Storno-Statuswerte ausgeblendet. +- `resources/views/public/tracking.blade.php` + - Public-Tracking behandelt `canceled` und `cancelled` gleich. +- `resources/lang/de/dhl.php`, `resources/lang/en/dhl.php`, `resources/lang/es/dhl.php`, `resources/lang/fr/dhl.php` + - Neuer Statuskey `canceled` ergaenzt. + - Legacy-Key `cancelled` bleibt erhalten. +- `tests/Unit/Dhl/DhlShipmentStatusTest.php` + - Neue Tests fuer Statusnormalisierung, Uebersetzung und Badge-Klasse. +- `tests/Unit/Dhl/TrackShipmentJobTest.php` + - Neuer Test, dass der Queue-Job den aktuellen `DhlTrackingService` nutzt. + +Fachliche Entscheidung: + +- Intern gilt ab jetzt `canceled` als kanonischer DHL-Storno-Status. +- `cancelled` wird nicht migriert oder entfernt, weil es in bestehenden Daten/URLs/Views vorkommen kann und weiterhin verstaendlich angezeigt werden soll. +- Storno-Fehler werden zunaechst in `api_response_data` gespeichert, um keine neue Datenbankmigration fuer den ersten Stabilisierungsschritt zu erzwingen. Falls spaeter gezielte Admin-Auswertungen gebraucht werden, koennen dedizierte Spalten oder eine eigene Fehler-Tabelle folgen. + +Verifikation: + +- `./vendor/bin/pint --dirty --format agent` +- `php artisan test --compact tests/Unit/Dhl` + - Ergebnis: 6 Tests bestanden, 12 Assertions. +- IDE/Linter-Pruefung der geaenderten Dateien: + - Ergebnis: keine Fehler. + +Offene Folgepunkte: + +- Phase 2 Rest: Tests fuer echte Storno-API-Fehler mit HTTP-Fakes/Mocking weiter ausbauen. +- Phase 2 Rest: Admin-Detailanzeige fuer gespeicherte `api_response_data.cancellation_error` bei Bedarf sichtbar machen. +- Phase 3: Internationalen Produkt-/Billing-Resolver einfuehren. Erledigt am 27.05.2026. + +### 27.05.2026 - Phase 3: Internationaler Produkt- und Laenderresolver + +Status: abgeschlossen fuer zentrale Produkt-/Laenderentscheidung, initiale Laenderfreigabe und harten Zielland-Fallback-Stopp. + +Ziel: + +- Produktcode, Zielland und DHL-Laendercode werden zentral entschieden. +- Deutschland nutzt fuer neue Label nur erlaubte nationale Produkte `V01PAK` oder `V62KP`. +- Oesterreich und Spanien nutzen initial `V53PAK`. +- Unbekannte oder nicht freigegebene Ziellaender duerfen nicht mehr still auf `DEU` fallen. +- Das DHL-Cockpit schlaegt den Produktcode anhand des Ziellands vor, laesst aber erlaubte Admin-Korrekturen zu. + +Umsetzung: + +- `app/Services/DhlProductResolver.php` + - Neuer zentraler Resolver fuer DHL-Produkt- und Laenderentscheidungen. + - Regeln initial: + - `DE`: erlaubt `V01PAK`, `V62KP` + - `AT`: erlaubt `V53PAK` + - `ES`: erlaubt `V53PAK` + - DHL-Laendercode-Konvertierung zentralisiert, z. B. `DE -> DEU`, `AT -> AUT`, `ES -> ESP`. + - Unbekannte Laendercodes loesen eine klare Exception aus statt auf Deutschland zurueckzufallen. + - Explizit falsch gewaehlte Produkt-/Laender-Kombinationen werden abgelehnt. + - Fehlende Billing-Nummern werden ueber `assertBillingNumber()` fachlich klar abgebrochen. +- `app/Services/DhlDataHelper.php` + - Empfaengerland ist jetzt Pflicht fuer die DHL-Datenaufbereitung. + - Produktcode wird ueber `DhlProductResolver::resolveForShipment()` bestimmt. + - Der alte Empfaengerland-Fallback auf `DE` wurde entfernt. + - Dimensionen werden anhand des aufgeloesten Produktcodes gelesen. +- `packages/acme-laravel-dhl/src/Services/ShippingService.php` + - Payload-Erstellung nutzt den Resolver fuer Zielland und Produktcode. + - Billing-Nummer wird gegen den aufgeloesten Produktcode validiert. + - `convertCountryCode()` nutzt den Resolver und gibt keinen `DEU`-Fallback mehr zurueck. + - Normale Empfaengeradressen und Packstation/Paketbox-Payloads verwenden die gleiche harte Laendercode-Validierung. +- `app/Services/DhlModalService.php` + - Modal-Daten enthalten nun `productSuggestions` und `selectedProductCode`. + - Initialer Produktvorschlag wird aus dem Zielland abgeleitet. + - Servervalidierung prueft Produkt-/Zielland-Kombinationen ueber den Resolver. +- `resources/views/admin/dhl/modal_in_order_shipment.blade.php` + - Produktauswahl markiert den vorgeschlagenen Produktcode. + - Laenderoptionen tragen den ISO-Code als `data-country-code`. + - Hinweistext ergaenzt, dass der Produktcode anhand des Ziellands vorgeschlagen wird. +- `resources/views/admin/dhl/modal_create_shipment.blade.php` + - Bei Ziellandwechsel wird der passende Produktvorschlag automatisch gesetzt. +- `app/Http/Controllers/ModalController.php` + - Fallback-Daten fuer das DHL-Modal um Produktvorschlaege und Default-Produkt ergaenzt. +- `tests/Unit/Dhl/DhlProductResolverTest.php` + - Neue Tests fuer Deutschland, Oesterreich, Spanien, nicht freigegebene Laender, unbekannte Laendercodes und fehlende Billing-Nummern. +- `tests/Unit/Dhl/ShippingServiceProductCodeTest.php` + - Payload-Test fuer internationales Paket nach Oesterreich ergaenzt. + - Regression-Test ergaenzt, dass nicht freigegebene Laender nicht auf Deutschland fallen. + +Fachliche Entscheidung: + +- `DE`, `AT` und `ES` sind die initial freigegebenen DHL-Versandlaender fuer diese Phase. +- Weitere Laender werden nicht implizit erlaubt, auch wenn eine ISO-Konvertierung technisch bekannt ist. Sie muessen fachlich freigegeben und im Resolver ergaenzt werden. +- Falls im Backend kein Produktcode explizit uebergeben wird, kann der Resolver fuer `AT`/`ES` automatisch `V53PAK` vorschlagen. Wenn ein Admin explizit ein nicht erlaubtes Produkt waehlt, wird die Labelerstellung serverseitig abgelehnt. +- Der alte `DEU`-Fallback wurde bewusst entfernt, weil ein falsches Zielland zu falschen Labels und kostenpflichtigen Stornos fuehren kann. + +Verifikation: + +- `./vendor/bin/pint --dirty --format agent` +- `php artisan test --compact tests/Unit/Dhl` + - Ergebnis: 15 Tests bestanden, 32 Assertions. +- IDE/Linter-Pruefung der geaenderten Dateien: + - Ergebnis: keine Fehler. + +Offene Folgepunkte: + +- Phase 3 Erweiterung: Weitere Laender erst nach fachlicher Freigabe in `DhlProductResolver` aufnehmen. +- Phase 3 Erweiterung: Optional Admin-Setting fuer freigegebene internationale Ziellaender einfuehren. +- Phase 4: Adressvalidierung vor Labelerstellung integrieren. Erledigt als formale Basisvalidierung am 27.05.2026. + +### 27.05.2026 - Phase 4: Formale Adressvalidierung vor Labelerstellung + +Status: abgeschlossen fuer serverseitige Basisvalidierung, Warn-/Fehlerstatus und Cockpit-Vorpruefung. Externe postalische Validierung ist noch offen. + +Ziel: + +- Vor der Labelerstellung wird eine DHL-Adresse serverseitig bewertet. +- Offensichtlich nicht versandfaehige Adressen blockieren die Labelerstellung. +- Pruefbeduerftige Adressen erzeugen Warnungen und muessen im Cockpit bewusst bestaetigt werden. +- Packstation/Paketbox-Faelle werden strenger validiert. +- Die bestehende Labelerstellung bleibt auch serverseitig abgesichert, falls die UI-Pruefung umgangen wird. + +Umsetzung: + +- `app/Services/DhlAddressValidator.php` + - Neuer zentraler Validator fuer formale DHL-Adresspruefung. + - Rueckgabeformat: + - `status`: `valid`, `warning` oder `error` + - `can_create_label`: true/false + - `errors` + - `warnings` + - `normalized` + - Blockierende Pruefungen: + - Pflichtfelder fuer Strasse, PLZ, Ort und Land. + - Name/Firma muss vorhanden sein. + - Zielland muss im `DhlProductResolver` freigegeben sein. + - PLZ-Format fuer `DE`, `AT`, `ES`. + - Packstation/Paketbox nur fuer Deutschland. + - DHL Postnummer muss bei Packstation/Paketbox vorhanden und 6-10-stellig sein. + - Packstation-/Paketbox-Nummer muss vorhanden und 3-stellig im Bereich 100-999 sein. + - Warnungen: + - Telefonnummer fehlt. + - E-Mail-Adresse fehlt. + - Hausnummer enthaelt keine Ziffer. + - Postnummer ist gesetzt, aber Strasse/Nr. sieht nicht nach Packstation/Paketbox aus. +- `app/Services/DhlModalService.php` + - Bestehende Modal-Adresspruefung nutzt nun den neuen `DhlAddressValidator`. + - `validateShipmentData()` fuehrt die Adressvalidierung auch serverseitig vor der Labelerstellung aus. +- `app/Http/Controllers/DhlShipmentController.php` + - Neuer Endpoint `validateAddress()`. + - Antwortet mit Status, Fehlern, Warnungen und `can_create_label`. + - Gibt HTTP 422 zurueck, wenn die Adresse nicht label-faehig ist. +- `routes/domains/crm.php` + - Neue Route `POST /admin/dhl/validate-address` mit Name `admin.dhl.validate-address`. +- `resources/views/admin/dhl/modal_create_shipment.blade.php` + - Modal ruft vor der eigentlichen Labelerstellung den neuen Validierungsendpunkt auf. + - Bei Fehlern wird die Labelerstellung blockiert. + - Bei Warnungen muss der Admin bewusst bestaetigen, bevor das Label erstellt wird. +- `tests/Unit/Dhl/DhlAddressValidatorTest.php` + - Neue Tests fuer: + - formal gueltige Adresse + - nicht freigegebenes Zielland + - falsches PLZ-Format fuer aktiviertes Land + - Warnstatus ohne Blockade + - gueltige Packstation + - ungueltige Packstation-Postnummer + - fehlende Packstation-Pflichtdaten + +Fachliche Entscheidung: + +- Diese Phase implementiert noch keine echte postalische Existenzpruefung von Strasse/PLZ/Ort. +- Der Validator verhindert aber bereits die teuersten formalen Fehler vor Labelerstellung. +- Warnungen blockieren nicht automatisch, weil Admins im Cockpit bewusst korrigierte oder fachlich bekannte Sonderfaelle versenden koennen sollen. +- Die spaetere Provider-Integration kann hinter dem gleichen `DhlAddressValidator` ergaenzt werden. + +Verifikation: + +- `./vendor/bin/pint --dirty --format agent` +- `php artisan test --compact tests/Unit/Dhl` + - Ergebnis: 22 Tests bestanden, 50 Assertions. +- IDE/Linter-Pruefung der geaenderten Dateien: + - Ergebnis: keine Fehler. + +Offene Folgepunkte: + +- Phase 4 Erweiterung: DHL DATAFACTORY AUTOCOMPLETE 2.0 fuer `DE`/`AT`/`CH` fachlich und technisch evaluieren. +- Phase 4 Erweiterung: Falls DHL DATAFACTORY nicht fuer alle benoetigten Laender reicht, externen Provider wie Loqate, Google Address Validation oder HERE bewerten. +- Phase 4 Erweiterung: Validierungsergebnis optional dauerhaft an Shipment/Order protokollieren. +- Phase 5: Referenzfeld und Admin-UX umsetzen. Erledigt am 27.05.2026. + +### 27.05.2026 - Phase 5: Referenzfeld und Admin-UX + +Status: abgeschlossen. + +Ziel: + +- Admins koennen im DHL-Cockpit eine eigene Versandreferenz setzen. +- Die Referenz wird als DHL `refNo` an die Shipping API uebergeben. +- Die Referenz wird an der Sendung gespeichert und in der Detailansicht angezeigt. +- Das DHL-Laengenlimit von 35 Zeichen wird eingehalten. +- Ohne Admin-Eingabe bleibt der bisherige Fallback `Order-{id}` erhalten. + +Umsetzung: + +- `database/migrations/2026_05_27_120253_add_reference_to_dhl_package_shipments_table.php` + - Neue nullable Spalte `reference` mit Laenge 35 fuer `dhl_package_shipments`. + - Migration prueft `Schema::hasColumn`, damit sie robust gegen bereits vorhandene Spalten bleibt. +- `packages/acme-laravel-dhl/src/Models/DhlShipment.php` + - `reference` in `$fillable` aufgenommen. +- `resources/views/admin/dhl/modal_in_order_shipment.blade.php` + - Neues Feld `reference` in der Sendungskonfiguration. + - Default-Wert: `Order-{id}`. + - `maxlength="35"` und Hilfetext zum DHL `refNo`. +- `resources/views/admin/dhl/modal_create_shipment.blade.php` + - Clientseitige Validierung fuer maximal 35 Zeichen ergaenzt. +- `app/Http/Controllers/DhlShipmentController.php` + - Servervalidierung fuer `reference` mit `max:35`. + - Uebergibt `reference` in die Optionen fuer `DhlShipmentService`. +- `app/Services/DhlDataHelper.php` + - Uebernimmt `reference` oder alternativ `shipment_reference` aus den Optionen. + - Normalisiert Leerraum. + - Kuerzt programmgesteuert auf 35 Zeichen. + - Nutzt `Order-{id}` als Fallback, wenn keine Referenz gesetzt ist. +- `packages/acme-laravel-dhl/src/Services/ShippingService.php` + - Bestehendes Mapping nach DHL `refNo` bleibt aktiv. + - Gesendete Referenz wird beim Erstellen des `DhlShipment`-Datensatzes gespeichert. +- `resources/views/admin/dhl/show.blade.php` + - Referenz wird in den Sendungsinformationen angezeigt. +- `tests/Unit/Dhl/DhlDataHelperReferenceTest.php` + - Neue Tests fuer Admin-Referenz, Fallback `Order-{id}` und 35-Zeichen-Normalisierung. + +Fachliche Entscheidung: + +- Die Referenz bleibt bewusst ein kurzes Freitextfeld und wird nicht an Bestellnotizen oder interne Kommentare gekoppelt. +- Das Feld wird fuer DHL `refNo` und spaetere Nachvollziehbarkeit genutzt, nicht als internes Memo. +- Der Fallback `Order-{id}` bleibt erhalten, damit bestehende Prozesse ohne manuelle Referenz unveraendert funktionieren. + +Verifikation: + +- `./vendor/bin/pint --dirty --format agent` +- `php artisan test --compact tests/Unit/Dhl` + - Ergebnis: 25 Tests bestanden, 54 Assertions. +- IDE/Linter-Pruefung der geaenderten Dateien: + - Ergebnis: keine Fehler. + +Offene Folgepunkte: + +- Datenbankmigration vor Nutzung in der Zielumgebung ausfuehren. +- Phase 6: DHL-Gewicht korrekt berechnen. + +### 27.05.2026 - Abgleich DHL Geschaeftskundenportal: Aktivierte Dienste + +Status: dokumentiert als fachlicher Abgleich vor Phase 6. + +Quelle: + +- Screenshot aus dem DHL Geschaeftskundenportal vom 27.05.2026 mit aktivierten Produkten und Abrechnungsnummern. + +Aktivierte Dienste laut Portal: + +- `63144073550101` - DHL Paket GKP +- `63144073550701` - DHL Retoure Online +- `63144073555301` - DHL Paket International GKP +- `63144073555302` - DHL Retoure Int. A +- `63144073556201` - Warenpost National / DHL Kleinpaket +- `63144073556601` - Warenpost International +- `63144073550801` - DHL Retoure MAUL + +Abgleich mit aktueller Implementierung: + +- `DHL Paket GKP` + - Aktueller Produktcode im Modul: `V01PAK` + - Konfiguriert in `config/dhl.php` als `DHL_ACCOUNT_NUMBER_V01PAK`. + - Wird fuer nationale Paketsendungen genutzt. + - Status: abgedeckt. +- `DHL Paket International GKP` + - Aktueller Produktcode im Modul: `V53PAK` + - Konfiguriert in `config/dhl.php` als `DHL_ACCOUNT_NUMBER_V53PAK`. + - Wird aktuell nur fuer fachlich freigegebene Ziellaender `AT` und `ES` genutzt. + - Status: technisch abgedeckt, Laenderfreigabe bleibt bewusst begrenzt. +- `Warenpost National / DHL Kleinpaket` + - Aktueller Produktcode im Modul: `V62KP`. + - Konfiguriert in `config/dhl.php` als `DHL_ACCOUNT_NUMBER_V62KP`. + - Ersetzt das alte `V62WP`. + - Status: abgedeckt. +- `DHL Retoure Online` + - Konfiguriert in `config/dhl.php` als `DHL_ACCOUNT_NUMBER_V07PAK`. + - Status: Konto ist konfiguriert. Der bestehende `ReturnsService` nutzt in Teilen noch Fallback-Logik und muss vor produktiver Retourennutzung separat gegen das aktive Retoure-Konto geprueft werden. +- `DHL Retoure Int. A` + - Konto im Portal aktiv. + - Im Modul aktuell nicht als eigenstaendiger internationaler Retourenprozess modelliert. + - Status: offen, eigener Folgepunkt. +- `Warenpost International` + - Konto im Portal aktiv. + - Im Modul aktuell nicht freigeschaltet. Laut DHL API ist dafuer `V66WPI` relevant; dafuer sind Zoll-/CN22-Daten und eigene Gewichts-/Laenderregeln erforderlich. + - Status: bewusst nicht Teil der bisherigen Phasen. +- `DHL Retoure MAUL` + - Konto im Portal aktiv. + - Im Modul aktuell nicht modelliert. + - Status: offen, nur nach fachlichem Bedarf umsetzen. + +Technische Entscheidung: + +- Fuer Phase 6 wird nur das Gewicht fuer die bereits freigegebenen Ausgangsprodukte betrachtet: `V01PAK`, `V53PAK`, `V62KP`. +- Internationale Warenpost (`V66WPI`) wird nicht stillschweigend aktiviert, obwohl ein Konto im Portal sichtbar ist. Die Produktart benoetigt separate Regeln und ggf. Zollangaben. +- Internationale Retouren und Retoure MAUL werden nicht mit bestehenden Paket-/Retoure-Fallbacks vermischt. + +Neue Folgepunkte aus dem Portal-Abgleich: + +- Retourenlogik separat gegen aktives `DHL Retoure Online` Konto pruefen und ggf. `V07PAK`/Returns-API sauber modellieren. +- Internationales Retourenprodukt `DHL Retoure Int. A` fachlich klaeren. +- `Warenpost International`/`V66WPI` nur als eigene Phase ergaenzen, wenn Zoll-, Gewichts- und Laenderregeln geklaert sind. +- `DHL Retoure MAUL` nur bei konkretem Prozessbedarf aufnehmen. + +### 27.05.2026 - Nachtrag vor Phase 6: Internationale Paketlaender per DHL-Settings steuerbar + +Status: abgeschlossen. + +Ziel: + +- Kunden/Admins koennen selbst entscheiden, welche Ziellaender fuer `DHL Paket International` aktiv sind. +- Die Freigabe erfolgt im bestehenden Settings-Bereich ueber Checkboxen. +- Der `DhlProductResolver` nutzt die gespeicherten Laender dynamisch statt einer fest kodierten `AT`/`ES`-Liste. +- Deutschland bleibt separat geregelt und wird nicht als internationales Paketland gespeichert. + +Umsetzung: + +- `config/dhl.php` + - Neuer Fallback `international_countries` aus `DHL_INTERNATIONAL_COUNTRIES`. + - Default bleibt `AT,ES`. +- `app/Http/Controllers/SettingController.php` + - `getDhlConfig()` liefert nun `international_countries`. + - Bei `DHL_CONFIG_SOURCE=env` wird keine Datenbankabfrage fuer diese Liste ausgefuehrt. + - Bei Datenbankprioritaet wird `dhl_international_countries` aus den Settings gelesen. +- `resources/views/admin/settings/index.blade.php` + - Neuer Checkbox-Bereich `DHL Paket International Ziellaender`. + - Es werden aktive App-Laender angezeigt, deren ISO-Code im DHL-Resolver bekannt ist. + - Speicherung als Setting `dhl_international_countries` vom Typ `object`. +- `app/Services/DhlProductResolver.php` + - Feste internationale Liste durch `getSupportedInternationalCountries()` ersetzt. + - Resolver liest je nach `DHL_CONFIG_SOURCE` entweder Config/ENV oder Datenbank-Setting. + - `normalizeCountryCodeList()` normalisiert, dedupliziert und filtert unbekannte Codes sowie `DE`. + - Produktvorschlaege fuer das Modal werden dynamisch aus der aktivierten Laenderliste erzeugt. +- `app/Http/Controllers/ModalController.php` + - Fallback-Daten fuer das DHL-Modal nutzen nun ebenfalls dynamische Produktvorschlaege. +- `tests/Unit/Dhl/DhlProductResolverTest.php` + - Tests fuer konfigurierbare internationale Laender ergaenzt. + - Tests fuer Normalisierung und Filterung der Laenderliste ergaenzt. + +Fachliche Entscheidung: + +- Die Checkboxen aktivieren nur `V53PAK` fuer DHL Paket International. +- `V66WPI` Warenpost International bleibt davon unberuehrt und wird nicht versehentlich aktiviert. +- `DE` wird nicht als internationales Zielland zugelassen, weil nationale Sendungen ueber `V01PAK`/`V62KP` laufen. + +Verifikation: + +- `./vendor/bin/pint --dirty --format agent` +- `php artisan test --compact tests/Unit/Dhl` + - Ergebnis: 27 Tests bestanden, 60 Assertions. +- IDE/Linter-Pruefung der geaenderten Dateien: + - Ergebnis: keine Fehler. + +### 27.05.2026 - Phase 6: DHL-Gewicht korrekt berechnen + +Status: abgeschlossen. + +Ziel: + +- Das DHL-Labelgewicht beruecksichtigt Kompensationsprodukte. +- Die bestehende Warenkorb-, Versandkosten- und Kompensationslogik bleibt unveraendert. +- Admins koennen das Gewicht weiterhin im Modal erhoehen, aber nicht unter das berechnete DHL-Mindestgewicht druecken. +- Produktbezogene DHL-Gewichtsgrenzen werden geprueft. + +Umsetzung: + +- `app/Services/DhlShipmentWeightCalculator.php` + - Neuer zentraler Gewichtsdienst fuer DHL-Labelerstellung. + - Basisgewicht ist `ShoppingOrder->weight` in Gramm. + - Fuer `shopping_order_items` mit `comp > 0` wird das verknuepfte `Product->weight` je `qty` addiert. + - Falls kein Gewicht vorhanden ist, wird ein sicherer Fallback von `1.0 kg` genutzt. + - Rundung erfolgt auf drei Nachkommastellen. + - Produktlimits: + - `V01PAK`: 31.5 kg + - `V53PAK`: 31.5 kg + - `V62KP`: 1.0 kg +- `app/Services/DhlModalService.php` + - Laedt Bestellpositionen jetzt mit Produktrelation. + - Modal-Gewicht nutzt den neuen Kalkulator. + - Validierung prueft das Gewicht gegen das gewaehlte DHL-Produkt. +- `app/Http/Controllers/DhlShipmentController.php` + - Vor Labelerstellung wird das tatsaechliche Versandgewicht auf mindestens das berechnete DHL-Gewicht gesetzt. + - Ein manuell hoeheres Gewicht aus dem Formular bleibt erhalten. +- `app/Services/DhlShipmentService.php` + - Ermittelt serverseitig ebenfalls das Mindestgewicht, damit Queue- und Direktaufrufe abgesichert sind. +- `packages/acme-laravel-dhl/src/Services/ShippingService.php` + - Prueft produktbezogene DHL-Gewichtsgrenzen vor Payload-Erstellung. +- `resources/views/admin/dhl/modal_in_order_shipment.blade.php` + - Hinweistext am Gewichtsfeld stellt klar, dass Kompensationsprodukte eingerechnet sind. +- `tests/Unit/Dhl/DhlShipmentWeightCalculatorTest.php` + - Neue Tests fuer: + - Basisgewicht aus Bestellung + - Addition von Kompensationsprodukt-Gewichten + - Fallbackgewicht + - `V62KP`-Gewichtslimit + - erlaubtes Paketgewicht fuer `V01PAK` + +Fachliche Entscheidung: + +- Die Berechnung wirkt nur auf das DHL-Labelgewicht. +- `ShoppingOrder->weight` und die bestehende Checkout-/Versandkostenlogik werden nicht veraendert. +- Kompensationsartikel werden nur dann addiert, wenn `comp > 0` und ein Produktgewicht vorhanden ist. +- `V62KP` wird mit 1.0 kg begrenzt, damit Kleinpaket nicht versehentlich fuer schwerere Sendungen genutzt wird. + +Verifikation: + +- `./vendor/bin/pint --dirty --format agent` +- `php artisan test --compact tests/Unit/Dhl` + - Ergebnis: 32 Tests bestanden, 66 Assertions. +- IDE/Linter-Pruefung der geaenderten Dateien: + - Ergebnis: keine Fehler. + +### 27.05.2026 - Phase 7: Tracking-Codes und Tracking-Mails finalisiert + +Status: abgeschlossen fuer Admin/User-Anzeige und automatische Mail-Ausloesung. + +Ziel: + +- Tracking-Codes sind in Admin- und User-Order-Details sichtbar. +- Automatische Tracking-Mails werden nur einmal pro Sendung versendet. +- Mails werden nur bei relevanter Statusaenderung ausgeloest. +- Statusspruenge wie `created` direkt nach `out_for_delivery` werden abgedeckt. +- Mehrere passende Sendungen einer Bestellung werden weiterhin in einer Mail zusammengefasst. + +Umsetzung: + +- `packages/acme-laravel-dhl/src/Models/DhlShipment.php` + - Neue Konstante `TRACKING_EMAIL_TRIGGER_STATUSES`. + - Relevante automatische Mail-Statuswerte: + - `in_transit` + - `out_for_delivery` + - Neue Methode `shouldTriggerTrackingEmail()`. + - Bedingung: + - aktueller Status ist relevant + - Status hat sich gegenueber dem vorherigen Status geaendert + - Tracking-Mail wurde noch nicht markiert + - Sendung hat DHL-Sendungsnummer und Empfaengeradresse + - Scope `needsTrackingEmail()` nutzt die zentrale Statusliste. +- `app/Console/Commands/DhlUpdateTracking.php` + - Automatische Mailentscheidung nutzt nun `DhlShipment::shouldTriggerTrackingEmail()`. + - Beim Zusammenfassen mehrerer Sendungen werden alle unbenachrichtigten Sendungen der Bestellung mit relevantem Status aufgenommen. + - Dadurch werden direkte Spruenge nach `out_for_delivery` nicht mehr uebersehen. +- `app/Services/DhlTrackingService.php` + - DHL-Statusmapping erweitert: + - `transit`, `in-transit`, `in_transit` -> `in_transit` + - `out-for-delivery`, `out_for_delivery` -> `out_for_delivery` + - weitere Varianten fuer `pre_transit` und `failed` + - Mapping ist nun statisch nutzbar und separat testbar. +- `resources/views/admin/dhl/show.blade.php` + - Oeffentlicher Tracking-Link nutzt nun den korrekten Query-Parameter `tracking_number`. +- `resources/views/admin/sales/_detail_dhl_shipments.blade.php` + - Bereits vorhandener DHL-Block wird auch in den User-Order-Modalen mit eingebunden, Aktionen bleiben ueber `isAdmin` abgesichert. +- `tests/Unit/Dhl/DhlShipmentStatusTest.php` + - Tests fuer Tracking-Mail-Ausloesung bei `out_for_delivery`. + - Tests fuer einmalige Mail-Ausloesung. + - Tests, dass `delivered` keine neue automatische Tracking-Mail mehr ausloest. + - Tests fuer DHL-Statusmapping-Varianten. + +Fachliche Entscheidung: + +- Automatische Tracking-Mails werden bei `in_transit` oder `out_for_delivery` versendet. +- `delivered` loest keine neue automatische Tracking-Mail aus, weil die Benachrichtigung dann fachlich zu spaet waere und Mehrfachmails vermieden werden sollen. +- Manuelles erneutes Senden im Admin bleibt weiterhin moeglich. +- Mehrere Sendungen einer Bestellung werden zusammengefasst, sofern sie noch keine Tracking-Mail erhalten haben und einen relevanten Status besitzen. + +Verifikation: + +- `./vendor/bin/pint --dirty --format agent` +- `php artisan test --compact tests/Unit/Dhl` + - Ergebnis: 38 Tests bestanden, 74 Assertions. +- IDE/Linter-Pruefung der geaenderten Dateien: + - Ergebnis: keine Fehler. + +### 27.05.2026 - Nachtrag Phase 7: Tracking-E-Mail-Historie in der Detailansicht + +Status: abgeschlossen. + +Ziel: + +- In der DHL-Detailansicht soll nicht nur der letzte Tracking-Mail-Status sichtbar sein. +- Es soll nachvollziehbar sein, welche Tracking-E-Mails mit welchem Sendungsstatus versendet wurden. +- Automatische und manuelle Versendungen sollen unterscheidbar bleiben. +- Zusammengefasste Tracking-Mails sollen zeigen, welche Sendungen enthalten waren. + +Umsetzung: + +- `packages/acme-laravel-dhl/src/Models/DhlShipment.php` + - `markTrackingEmailSent()` erweitert. + - Bei jedem Versand wird ein Eintrag in `api_response_data.tracking_email_history` geschrieben. + - Gespeicherte Felder je Eintrag: + - `sent_at` + - `type` (`auto` oder `manual`) + - `recipient_email` + - `status` + - `tracking_status` + - `dhl_shipment_no` + - `included_shipment_ids` + - Neue Methode `getTrackingEmailHistory()` liefert die Historie mit neuestem Eintrag zuerst. + - Neue statische Methode `getStatusBadgeClassFor()` fuer Status-Badges in historischen Eintraegen. +- `app/Http/Controllers/DhlShipmentController.php` + - Manueller Tracking-Mail-Versand uebergibt Empfaenger und enthaltene Sendungen an `markTrackingEmailSent()`. +- `app/Console/Commands/DhlUpdateTracking.php` + - Automatischer Tracking-Mail-Versand uebergibt Empfaenger und enthaltene Sendungen an `markTrackingEmailSent()`. +- `resources/views/admin/dhl/show.blade.php` + - Bereich `Tracking-E-Mail Status` zeigt weiterhin den letzten Versand prominent an. + - Darunter wird eine Historientabelle angezeigt mit: + - Zeitpunkt + - Typ + - Status zum Versandzeitpunkt + - Empfaenger + - enthaltene Sendungs-IDs +- `tests/Unit/Dhl/DhlShipmentStatusTest.php` + - Tests fuer Tracking-Mail-Historie mit neuestem Eintrag zuerst. + - Test fuer Legacy-Sendungen ohne Historie. + +Fachliche Entscheidung: + +- Die Historie wird im bestehenden `api_response_data` JSON der Sendung gespeichert, damit keine neue Tabelle notwendig ist. +- Die bestehenden Felder `tracking_email_sent_at` und `tracking_email_type` bleiben fuer schnelle Anzeige und bestehende Logik erhalten. +- Historische Sendungen, die nur die alten Felder besitzen, bleiben kompatibel und zeigen weiterhin den letzten Versandstatus. + +Verifikation: + +- `./vendor/bin/pint --dirty --format agent` +- `php artisan test --compact tests/Unit/Dhl` + - Ergebnis: 40 Tests bestanden, 79 Assertions. +- IDE/Linter-Pruefung der geaenderten Dateien: + - Ergebnis: keine Fehler. + +### 27.05.2026 - Nachtrag: Modale Vorabpruefung vor Labelerstellung + +Status: abgeschlossen. + +Ziel: + +- Vor der finalen DHL-Labelerstellung soll im bestehenden Erstellungsmodal eine sichtbare Vorabpruefung erscheinen. +- Der Admin soll Produktcode, nationale/internationale Sendungsart, Zielland und Lieferadressstatus sehen. +- Fehler blockieren die Labelerstellung; Warnungen bleiben sichtbar und muessen bewusst bestaetigt werden. + +Umsetzung: + +- `app/Http/Controllers/DhlShipmentController.php` + - Der bestehende Endpoint `validateAddress()` liefert nun zusaetzlich strukturierte `preflight`-Daten. + - Die Produktpruefung nutzt `DhlProductResolver`. + - Fehler aus Produkt-/Zielland-Kombinationen werden gemeinsam mit Adressfehlern zurueckgegeben. +- `app/Services/DhlProductResolver.php` + - Neue Methoden `getProductScope()` und `getProductScopeLabel()`. + - Dadurch kann die UI Produktcodes fachlich als national oder international anzeigen. +- `resources/views/admin/dhl/modal_in_order_shipment.blade.php` + - Neuer Statusbereich `Vorabpruefung vor Labelerstellung`. + - Der Statusbereich sitzt am Ende des Formulars direkt vor den Aktionsbuttons. + - Initialer Buttontext: `Vorabpruefung durchfuehren`. +- `app/Services/DhlAddressValidator.php` + - Die Lieferadresse wird zusaetzlich auf plausible Feldinhalte geprueft. + - Offensichtlich ungueltige Werte bei Straße, PLZ oder Ort fuehren jetzt zu Fehlern statt nur zu einer positiven Formalpruefung. + - Fuer DE, AT und CH ist eine formale DACH-Pruefung hinterlegt. + - Diese umfasst Pflichtfelder, PLZ-Format, Plausibilitaet, Platzhalter-/Testdaten und Packstation-Regeln. + - DACH-Hausnummern ohne Ziffer werden als Fehler blockiert. + - Die UI weist nun ausdruecklich darauf hin, dass keine echte DHL-/Adressdatenbank- oder Leitcodepruefung angebunden ist. + - Fuer unterstuetzte Ziellaender ohne landesspezifische Pruefung wird ein Hinweis auf die reine Basis-Adresspruefung ausgegeben. +- `resources/views/admin/dhl/modal_create_shipment.blade.php` + - Der erste Klick fuehrt nur die Vorabpruefung aus. + - Die separate Browser-Alert-Fehlermeldung bei Vorpruefungsfehlern wurde entfernt. + - Der Validierungsstatus zeigt nun `Formale DACH-Pruefung` statt irrefuehrend `Aktiv`. + - Das Ergebnis wird direkt im Modal angezeigt: + - Produktcode + - Sendungsart + - Zielland + - normalisierte Lieferadresse + - Status der Adressvalidierung + - Fehler und Hinweise + - Erst nach erfolgreicher Vorabpruefung wechselt der Button auf `Sendung jetzt erstellen`. + - Aenderungen an Formularfeldern setzen die Freigabe automatisch zurueck. +- `tests/Unit/Dhl/DhlProductResolverTest.php` + - Neuer Test fuer nationale und internationale Produktklassifizierung. +- `tests/Unit/Dhl/DhlAddressValidatorTest.php` + - Neuer Test fuer unplausible Lieferadressfelder. + - Neue Tests fuer CH-Adressvalidierung und Basispruefungs-Hinweise. + - Neue Tests fuer Platzhalteradressen und DACH-Hausnummern ohne Ziffer. + +Fachliche Entscheidung: + +- Die bestehende Servervalidierung bleibt die Quelle der Wahrheit. +- Die Vorabpruefung ersetzt keine Validierung bei der finalen Erstellung; vor dem Erstellen wird nochmals gegen denselben Endpoint geprueft. +- Dadurch werden nachtraegliche Formularaenderungen oder veraltete Modalzustande nicht ungeprueft an DHL uebergeben. + +Verifikation: + +- `./vendor/bin/pint --dirty --format agent` +- `php artisan test --compact tests/Unit/Dhl` + - Ergebnis: 46 Tests bestanden, 109 Assertions. +- IDE/Linter-Pruefung der geaenderten Dateien: + - Ergebnis: keine Fehler. + +### 27.05.2026 - Phase 8: DHL-seitige Adressvalidierung ueber mustEncode + +Status: umgesetzt fuer deutsche Empfaengeradressen. + +Ziel: + +- DHL soll selbst die finale Adress-/Leitcodefaehigkeit pruefen. +- Grundlage ist der DHL-Query-Parameter `mustEncode=true`. +- `printOnlyIfCodable` ist laut DHL-Spezifikation der Legacy-Name. +- Wenn diese Option gesetzt ist, soll DHL das Etikett nur dann erstellen, wenn die Adresse codeable/leitcodierbar ist. +- Wird die Adresse von DHL abgelehnt, soll der Fehler im bestehenden Modal erscheinen und dort korrigierbar sein. +- Laut DHL-Dokumentation ist die Funktion nur fuer deutsche Empfaengeradressen relevant. + +Umsetzung: + +- Die aktuelle formale Vorabpruefung bleibt erhalten. +- `config/dhl.php` + - Neue Option `print_only_if_codeable`, steuerbar ueber `DHL_PRINT_ONLY_IF_CODEABLE`. + - Standard: aktiv. +- `app/Http/Controllers/SettingController.php` + - DHL-Konfiguration liefert `print_only_if_codeable`. +- `resources/views/admin/settings/index.blade.php` + - Neue Checkbox `DHL-Leitcodierung erzwingen (mustEncode)`. +- `app/Services/DhlDataHelper.php` + - Option wird in die Orderdaten fuer den DHL-Request uebernommen. +- `packages/acme-laravel-dhl/src/Services/ShippingService.php` + - Fuer deutsche Empfaengeradressen wird bei aktiver Option der Query-Parameter `mustEncode=true` an die DHL Create-Shipment-Operation uebergeben. + - DHL-Responses ohne Label/Shipment oder mit Item-Fehlerstatus werden vor dem Speichern der Sendung abgefangen. + - Nicht leitcodierbare Adressen werden als `DhlAddressValidationException` normalisiert. +- `packages/acme-laravel-dhl/src/Exceptions/DhlAddressValidationException.php` + - Neue fachliche Exception fuer DHL-Adressablehnungen. +- `app/Services/DhlShipmentService.php` + - DHL-Adressablehnungen werden als Validierungsfehler zurueckgegeben. + - Wenn Queue aktiv ist, aber `mustEncode` fuer eine deutsche Empfaengeradresse greift, wird synchron erstellt, damit der DHL-Fehler direkt im Modal sichtbar bleibt. +- `app/Http/Controllers/DhlShipmentController.php` + - Gibt DHL-Adressvalidierungsfehler mit HTTP 422 zurueck. +- `resources/views/admin/dhl/modal_create_shipment.blade.php` + - Fehler aus der finalen DHL-Erstellung werden in der bestehenden Vorabpruefungsbox angezeigt. + - Keine separate Browser-Alert-Meldung. + +Offene Punkte: + +- DHL-Sandbox-Verhalten testen. +- Fehlercodes fuer nicht codeable Adressen sammeln. +- Produktabdeckung fuer `V01PAK`, `V62KP` und `V53PAK` klaeren. +- Klaeren, wie internationale Sendungen behandelt werden, da DHL `mustEncode` als nur fuer deutsche Empfaengeradressen relevant beschreibt. + +Verifikation: + +- `./vendor/bin/pint --dirty --format agent` +- `php artisan test --compact tests/Unit/Dhl` + - Ergebnis: 49 Tests bestanden, 115 Assertions. + ## 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. diff --git a/docs/Kurzfristige Anpassungen.md b/docs/Kurzfristige Anpassungen.md new file mode 100644 index 0000000..597f099 --- /dev/null +++ b/docs/Kurzfristige Anpassungen.md @@ -0,0 +1,19 @@ + +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. + diff --git a/docs/dhl/Anpassung DHL Modul.md b/docs/dhl/Anpassung DHL Modul.md new file mode 100644 index 0000000..de74255 --- /dev/null +++ b/docs/dhl/Anpassung DHL Modul.md @@ -0,0 +1,49 @@ + + +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 \ No newline at end of file diff --git a/docs/salescenter/Todos Backoffice.md b/docs/salescenter/Todos Backoffice.md new file mode 100644 index 0000000..869a0fb --- /dev/null +++ b/docs/salescenter/Todos Backoffice.md @@ -0,0 +1,157 @@ + + + +#### 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. \ No newline at end of file diff --git a/packages/acme-laravel-dhl/src/Exceptions/DhlAddressValidationException.php b/packages/acme-laravel-dhl/src/Exceptions/DhlAddressValidationException.php new file mode 100644 index 0000000..393e78f --- /dev/null +++ b/packages/acme-laravel-dhl/src/Exceptions/DhlAddressValidationException.php @@ -0,0 +1,8 @@ + 'unknown', ]; + public const TRACKING_EMAIL_TRIGGER_STATUSES = [ + 'in_transit', + 'out_for_delivery', + ]; + + public const LEGACY_STATUS_ALIASES = [ + 'cancelled' => 'canceled', + ]; + /** * Get the tracking events for this shipment */ @@ -164,7 +174,9 @@ class DhlShipment extends Model */ public function getStatusTranslation(): string { - return __('dhl.status.'.$this->status, [], $this->status); + $status = self::normalizeStatus($this->status); + + return __('dhl.status.'.$status, [], $status); } /** @@ -172,9 +184,20 @@ class DhlShipment extends Model */ public static function getStatusTranslationFor(string $status): string { + $status = self::normalizeStatus($status); + return __('dhl.status.'.$status, [], $status); } + public static function normalizeStatus(?string $status): string + { + if ($status === null || $status === '') { + return 'unknown'; + } + + return self::LEGACY_STATUS_ALIASES[$status] ?? $status; + } + /** * Get translated type for current locale */ @@ -234,23 +257,92 @@ class DhlShipment extends Model return $this->tracking_email_sent_at !== null; } + /** + * @return array> + */ + public function getTrackingEmailHistory(): array + { + $history = data_get($this->api_response_data ?? [], 'tracking_email_history', []); + + if (! is_array($history)) { + return []; + } + + return array_values(array_reverse($history)); + } + + public function shouldTriggerTrackingEmail(?string $previousStatus): bool + { + $currentStatus = self::normalizeStatus($this->status); + $previousStatus = self::normalizeStatus($previousStatus); + + return in_array($currentStatus, self::TRACKING_EMAIL_TRIGGER_STATUSES, true) + && $currentStatus !== $previousStatus + && ! $this->wasTrackingEmailSent() + && $this->canSendTrackingEmail(); + } + /** * Mark tracking email as sent */ - public function markTrackingEmailSent(string $type = 'manual'): void + public function markTrackingEmailSent(string $type = 'manual', ?string $recipientEmail = null, ?iterable $includedShipments = null): void { + $apiResponseData = $this->api_response_data ?? []; + $history = data_get($apiResponseData, 'tracking_email_history', []); + + if (! is_array($history)) { + $history = []; + } + + $history[] = [ + 'sent_at' => now()->toISOString(), + 'type' => $type, + 'recipient_email' => $recipientEmail, + 'status' => self::normalizeStatus($this->status), + 'tracking_status' => $this->tracking_status, + 'dhl_shipment_no' => $this->dhl_shipment_no, + 'included_shipment_ids' => $this->extractIncludedShipmentIds($includedShipments), + ]; + + data_set($apiResponseData, 'tracking_email_history', $history); + $this->update([ 'tracking_email_sent_at' => now(), 'tracking_email_type' => $type, + 'api_response_data' => $apiResponseData, ]); } + /** + * @return array + */ + private function extractIncludedShipmentIds(?iterable $includedShipments): array + { + if ($includedShipments === null) { + return [$this->id]; + } + + $ids = []; + foreach ($includedShipments as $shipment) { + if ($shipment instanceof self && $shipment->id !== null) { + $ids[] = $shipment->id; + } + } + + return $ids ?: [$this->id]; + } + /** * Get status badge class for Bootstrap */ public function getStatusBadgeClass(): string { - return match ($this->status) { + return self::getStatusBadgeClassFor($this->status); + } + + public static function getStatusBadgeClassFor(?string $status): string + { + return match (self::normalizeStatus($status)) { 'created', 'pending' => 'secondary', 'in_transit' => 'info', 'out_for_delivery' => 'primary', @@ -266,13 +358,13 @@ class DhlShipment extends Model */ public function scopeActive($query) { - return $query->whereNotIn('status', ['delivered', 'canceled', 'returned', 'failed']); + return $query->whereNotIn('status', self::TERMINAL_STATUSES); } /** * Terminal statuses where tracking is considered complete */ - public const TERMINAL_STATUSES = ['delivered', 'canceled', 'returned', 'failed']; + public const TERMINAL_STATUSES = ['delivered', 'canceled', 'cancelled', 'returned', 'failed']; /** * Tracking interval per status (in hours). @@ -345,7 +437,7 @@ class DhlShipment extends Model */ public function scopeNeedsTrackingEmail($query) { - return $query->where('status', 'in_transit') + return $query->whereIn('status', self::TRACKING_EMAIL_TRIGGER_STATUSES) ->whereNull('tracking_email_sent_at'); } } diff --git a/packages/acme-laravel-dhl/src/Services/ShippingService.php b/packages/acme-laravel-dhl/src/Services/ShippingService.php index e606ff3..447f5bf 100644 --- a/packages/acme-laravel-dhl/src/Services/ShippingService.php +++ b/packages/acme-laravel-dhl/src/Services/ShippingService.php @@ -2,9 +2,13 @@ namespace Acme\Dhl\Services; +use Acme\Dhl\Exceptions\DhlAddressValidationException; +use Acme\Dhl\Exceptions\DhlValidationException; use Acme\Dhl\Jobs\CreateShipmentJob; use Acme\Dhl\Models\DhlShipment; use Acme\Dhl\Support\DhlClient; +use App\Services\DhlProductResolver; +use App\Services\DhlShipmentWeightCalculator; use Exception; use Illuminate\Support\Facades\DB; use Illuminate\Support\Facades\Log; @@ -53,6 +57,7 @@ class ShippingService $query = array_filter([ 'printFormat' => $validatedData['print_format'] ?? null, 'retourePrintFormat' => $validatedData['retoure_print_format'] ?? null, + 'mustEncode' => $this->shouldUseMustEncode($validatedData) ? 'true' : null, ]); $response = $this->client->request('post', '/parcel/de/shipping/v2/orders', $payload, $query); @@ -60,6 +65,13 @@ class ShippingService Log::info('[DHL API] Response received', [ 'response' => $response, ]); + $this->assertSuccessfulShipmentResponse($response, $this->shouldUseMustEncode($validatedData)); + } catch (DhlValidationException $e) { + if ($this->shouldUseMustEncode($validatedData) && $this->looksLikeAddressValidationError($e->getMessage())) { + throw new DhlAddressValidationException($this->normalizeDhlAddressValidationMessage($e->getMessage()), (int) $e->getCode(), $e); + } + + throw $e; } catch (Exception $e) { Log::error('[DHL API] Request failed', [ 'error' => $e->getMessage(), @@ -101,18 +113,18 @@ class ShippingService $shipment = DhlShipment::where('dhl_shipment_no', $shipmentNumber)->first(); if (! $shipment) { - throw new InvalidArgumentException('Shipment not found in database: ' . $shipmentNumber); + throw new InvalidArgumentException('Shipment not found in database: '.$shipmentNumber); } if (! $shipment->canCancel()) { - throw new InvalidArgumentException('Shipment cannot be canceled (current status: ' . $shipment->status . ')'); + throw new InvalidArgumentException('Shipment cannot be canceled (current status: '.$shipment->status.')'); } Log::info('[DHL Package] Attempting to cancel shipment', [ 'shipmentNumber' => $shipmentNumber, 'shipment_id' => $shipment->id, 'status' => $shipment->status, - 'endpoint' => "/parcel/de/shipping/v2/orders/{$shipmentNumber}" + 'endpoint' => "/parcel/de/shipping/v2/orders/{$shipmentNumber}", ]); try { @@ -120,28 +132,78 @@ class ShippingService Log::info('[DHL Package] Shipment cancellation response', [ 'shipmentNumber' => $shipmentNumber, - 'response' => $response + 'response' => $response, ]); - $shipment->update(['status' => 'canceled']); + $this->recordCancellationSuccess($shipment, $response); Log::info('[DHL Package] Canceled shipment successfully', [ 'shipmentNumber' => $shipmentNumber, - 'shipment_id' => $shipment->id + 'shipment_id' => $shipment->id, ]); return true; } catch (\Exception $e) { + $this->recordCancellationFailure($shipment, $e); + Log::error('[DHL Package] Shipment cancellation failed', [ 'shipmentNumber' => $shipmentNumber, 'shipment_id' => $shipment->id, 'error' => $e->getMessage(), - 'error_class' => get_class($e) + 'error_class' => get_class($e), ]); throw $e; } } + private function recordCancellationSuccess(DhlShipment $shipment, array $response): void + { + $apiResponseData = $shipment->api_response_data ?? []; + $apiResponseData['cancellation'] = [ + 'status' => 'success', + 'response' => $response, + 'occurred_at' => now()->toISOString(), + ]; + + $shipment->update([ + 'status' => 'canceled', + 'api_response_data' => $apiResponseData, + ]); + } + + private function recordCancellationFailure(DhlShipment $shipment, \Exception $exception): void + { + $apiResponseData = $shipment->api_response_data ?? []; + $apiResponseData['cancellation_error'] = [ + 'status' => 'failed', + 'http_status' => $this->extractHttpStatus($exception->getMessage()), + 'dhl_code' => $this->extractDhlErrorCode($exception->getMessage()), + 'detail' => $exception->getMessage(), + 'exception_class' => $exception::class, + 'occurred_at' => now()->toISOString(), + ]; + + $shipment->update(['api_response_data' => $apiResponseData]); + } + + private function extractHttpStatus(string $message): ?int + { + if (preg_match('/\b([45][0-9]{2})\b/', $message, $matches)) { + return (int) $matches[1]; + } + + return null; + } + + private function extractDhlErrorCode(string $message): ?string + { + if (preg_match('/\b([A-Z]{2}-[A-Za-z0-9_-]+)\b/', $message, $matches)) { + return $matches[1]; + } + + return null; + } + /** * Validate required order data according to DHL API v2 specification */ @@ -153,10 +215,11 @@ class ShippingService $validator = Validator::make($data, [ 'order_id' => 'nullable|integer', 'weight_kg' => 'required|numeric|min:0.1|max:31.5', // DHL weight limit - 'product_code' => 'nullable|string|in:V01PAK,V53PAK,V62WP,V07PAK', + 'product_code' => 'nullable|string|in:V01PAK,V53PAK,V62KP,V07PAK', 'label_format' => 'nullable|string|in:PDF,ZPL', 'print_format' => 'nullable|string', // Values like A4, 910-300-700 etc. 'retoure_print_format' => 'nullable|string', + 'print_only_if_codeable' => 'nullable|boolean', // Shipper validation (sender) 'shipper' => 'required|array', @@ -198,7 +261,81 @@ class ShippingService throw new InvalidArgumentException($validator->errors()->first()); } - return $validator->validated(); + $validated = $validator->validated(); + (new DhlShipmentWeightCalculator)->assertWithinProductLimit( + (float) $validated['weight_kg'], + $validated['product_code'] ?? null + ); + + return $validated; + } + + private function shouldUseMustEncode(array $orderData): bool + { + return (bool) ($orderData['print_only_if_codeable'] ?? config('dhl.print_only_if_codeable', true)) + && strtoupper((string) ($orderData['consignee']['country'] ?? '')) === DhlProductResolver::DOMESTIC_COUNTRY; + } + + private function assertSuccessfulShipmentResponse(array $response, bool $mustEncodeEnabled): void + { + $itemStatusCode = (int) (data_get($response, 'items.0.sstatus.statusCode') + ?? data_get($response, 'items.0.sstatus.status') + ?? data_get($response, 'status.statusCode') + ?? data_get($response, 'status.status') + ?? 200); + + if ($itemStatusCode < 400 && $this->extractShipmentNumber($response) !== null && $this->extractLabelData($response) !== null) { + return; + } + + $message = $this->extractResponseErrorMessage($response) ?: 'DHL hat kein Versandlabel erstellt.'; + + if ($mustEncodeEnabled || $this->looksLikeAddressValidationError($message)) { + throw new DhlAddressValidationException($this->normalizeDhlAddressValidationMessage($message)); + } + + throw new DhlValidationException($message); + } + + private function extractResponseErrorMessage(array $response): ?string + { + $message = data_get($response, 'items.0.sstatus.detail') + ?? data_get($response, 'items.0.sstatus.title') + ?? data_get($response, 'status.detail') + ?? data_get($response, 'status.title') + ?? data_get($response, 'detail') + ?? data_get($response, 'message'); + + $validationMessages = data_get($response, 'items.0.validationMessages', []); + if (is_array($validationMessages) && $validationMessages !== []) { + $messages = []; + foreach ($validationMessages as $validationMessage) { + $messages[] = $validationMessage['validationMessage'] + ?? $validationMessage['message'] + ?? $validationMessage['property'] + ?? null; + } + + $messages = array_values(array_filter($messages)); + if ($messages !== []) { + $message = implode('; ', $messages); + } + } + + return $message ? (string) $message : null; + } + + private function looksLikeAddressValidationError(string $message): bool + { + return (bool) preg_match('/address|adresse|anschrift|leitcod|routing|route|codeable|codable|encodable|mustEncode|postal|postleitzahl|street|straße|strasse|house|hausnummer|city|ort/i', $message); + } + + private function normalizeDhlAddressValidationMessage(string $message): string + { + $message = trim(preg_replace('/^DHL API validation error:\s*/i', '', $message)); + $message = $message !== '' ? $message : 'DHL kann diese Adresse nicht leitcodieren.'; + + return 'DHL kann diese Adresse nicht leitcodieren. Bitte Straße, Hausnummer, PLZ und Ort prüfen. DHL-Meldung: '.$message; } /** @@ -302,8 +439,14 @@ class ShippingService */ private function buildShipmentPayload(array $orderData): array { - $productCode = $orderData['product_code'] ?? config('dhl.default_product', 'V01PAK'); - $billingNumber = $this->getBillingNumberForProduct($productCode); + $resolver = new DhlProductResolver; + $destination = $resolver->resolveForShipment( + $orderData['consignee']['country'] ?? '', + $orderData['product_code'] ?? null, + config('dhl.default_product', 'V01PAK') + ); + $productCode = $destination['product_code']; + $billingNumber = $resolver->assertBillingNumber($productCode, $this->getBillingNumberForProduct($productCode)); $payload = [ 'profile' => config('dhl.profile', 'STANDARD_GRUPPENPROFIL'), @@ -319,7 +462,7 @@ class ShippingService 'addressHouse' => $orderData['shipper']['houseNumber'] ?? null, 'postalCode' => $orderData['shipper']['postalCode'] ?? '', 'city' => $orderData['shipper']['city'] ?? '', - 'country' => $this->convertCountryCode($orderData['shipper']['country'] ?? 'DE'), + 'country' => $this->convertCountryCode($orderData['shipper']['country'] ?? ''), 'email' => ! empty($orderData['shipper']['email']) ? $orderData['shipper']['email'] : null, 'phone' => ! empty($orderData['shipper']['phone']) ? $orderData['shipper']['phone'] : null, ], function ($value) { @@ -381,7 +524,7 @@ class ShippingService 'addressHouse' => $consignee['houseNumber'] ?? null, 'postalCode' => $consignee['postalCode'] ?? '', 'city' => $consignee['city'] ?? '', - 'country' => $this->convertCountryCode($consignee['country'] ?? 'DE'), + 'country' => $this->convertCountryCode($consignee['country'] ?? ''), 'email' => ! empty($consignee['email']) ? $consignee['email'] : null, 'phone' => ! empty($consignee['phone']) ? $consignee['phone'] : null, ], function ($value) { @@ -435,12 +578,12 @@ class ShippingService 'houseNumber' => $consignee['houseNumber'] ?? '', ]); - $errorMessage = 'PACKSTATION-FEHLER: Die Packstation-Nummer muss 3-stellig sein (100-999).' . PHP_EOL . PHP_EOL; - $errorMessage .= 'Eingegeben wurde: "' . $lockerNumber . '"' . PHP_EOL . PHP_EOL; - $errorMessage .= 'HINWEISE:' . PHP_EOL; - $errorMessage .= '• Straße/Nr.: "Packstation 145" (nicht "Packstation 12345")' . PHP_EOL; - $errorMessage .= '• Die Packstation-NUMMER ist die 3-stellige Nummer auf dem gelben DHL-Schild' . PHP_EOL; - $errorMessage .= '• Die 5-10-stellige POSTNUMMER ist etwas anderes (kommt ins separate Feld!)' . PHP_EOL; + $errorMessage = 'PACKSTATION-FEHLER: Die Packstation-Nummer muss 3-stellig sein (100-999).'.PHP_EOL.PHP_EOL; + $errorMessage .= 'Eingegeben wurde: "'.$lockerNumber.'"'.PHP_EOL.PHP_EOL; + $errorMessage .= 'HINWEISE:'.PHP_EOL; + $errorMessage .= '• Straße/Nr.: "Packstation 145" (nicht "Packstation 12345")'.PHP_EOL; + $errorMessage .= '• Die Packstation-NUMMER ist die 3-stellige Nummer auf dem gelben DHL-Schild'.PHP_EOL; + $errorMessage .= '• Die 5-10-stellige POSTNUMMER ist etwas anderes (kommt ins separate Feld!)'.PHP_EOL; $errorMessage .= '• Beispiel: Packstation 145, PLZ 12345, Postnummer 1234567890'; throw new \InvalidArgumentException($errorMessage); @@ -464,7 +607,7 @@ class ShippingService 'postNumber' => $postNumber, 'postalCode' => $consignee['postalCode'] ?? '', 'city' => $consignee['city'] ?? '', - 'country' => $this->convertCountryCode($consignee['country'] ?? 'DE'), + 'country' => $this->convertCountryCode($consignee['country'] ?? ''), ], function ($value) { return $value !== null && $value !== ''; }); @@ -534,25 +677,7 @@ class ShippingService */ private function convertCountryCode(string $countryCode): string { - $countryMap = [ - 'DE' => 'DEU', - 'AT' => 'AUT', - 'CH' => 'CHE', - 'US' => 'USA', - 'GB' => 'GBR', - 'FR' => 'FRA', - 'IT' => 'ITA', - 'ES' => 'ESP', - 'NL' => 'NLD', - 'BE' => 'BEL', - 'PL' => 'POL', - 'CZ' => 'CZE', - 'DK' => 'DNK', - 'SE' => 'SWE', - 'NO' => 'NOR', - ]; - - return $countryMap[strtoupper($countryCode)] ?? 'DEU'; + return (new DhlProductResolver)->toDhlCountryCode($countryCode); } /** @@ -576,8 +701,9 @@ class ShippingService } // Try to get from admin settings via Setting model first (database settings override config) + $settingKey = 'dhl_account_'.strtolower($productCode); + try { - $settingKey = 'dhl_account_' . strtolower($productCode); $accountNumber = \App\Models\Setting::getContentBySlug($settingKey); if ($accountNumber) { Log::info('Using DHL account number from database settings', [ @@ -692,6 +818,7 @@ class ShippingService 'order_id' => $orderData['order_id'] ?? null, 'dhl_shipment_no' => $shipmentNumber, 'routing_code' => $this->extractRoutingCode($response), + 'reference' => $payload['shipments'][0]['refNo'] ?? null, 'type' => 'outbound', 'product_code' => $payload['shipments'][0]['product'], 'billing_number' => $payload['shipments'][0]['billingNumber'], @@ -721,7 +848,7 @@ class ShippingService return null; } - $path = 'dhl/labels/' . $shipment->dhl_shipment_no . '.' . strtolower($format); + $path = 'dhl/labels/'.$shipment->dhl_shipment_no.'.'.strtolower($format); $success = false; for ($attempt = 1; $attempt <= 3; $attempt++) { try { diff --git a/public/pdf/MIVITA_Vorlage-Rechnungen Ordner/Links/1200px-Bio-Siegel-EG-Öko-VO-Deutschland.svg.png b/public/pdf/MIVITA_Vorlage-Rechnungen Ordner/Links/1200px-Bio-Siegel-EG-Öko-VO-Deutschland.svg.png new file mode 100644 index 0000000000000000000000000000000000000000..fb2247ffb07f22287fc9d6b4450dcccee0004c60 GIT binary patch literal 91721 zcmdSBhd)sf z7Jlb>^?JWP-+$rv8n@T&<+`4a=i_l6=iJZz+|N<4rn=H8vh!pp6zUXOSy3B>A}mIs z2$o5S;U`HkF;?&&;(Mw}il}4c@06OXNcahfGy3Lrl3~I#r$lJ$3GU0HP%J34qWmrQ zw@agLZ;f3NkJoo99%XSxj-flTiXl(BpB@eTR5wmZUKedF z#47*3n9ljl%{2e>Ic(RhKYYZh{oqJf6aH$kk4-$-JG}IajTkNQ>+y>8&ekpF4fi?b zq80+-N2>>Ke@oNAX8!;F*Vq|%5|rE>>=GaPbhQ+mMqyKtiZHL59mQgE(f~#FG>(yD zUKiao#t)ydddhdN%Trm@Ttl&_@T{UxLGf`!5p*HzaDyD3mm+HC81YbAQxtrtU`q@= zdOyRB1cj1QEK`-_CVfi0N`(p{b2G(LzAVDPF^^fLK%x9dPenRMx)GujgYn4ay1iAq ztyW3l$3fm5SeaR5v7gimsNnk;rR*Kk9=26F`0Q*o+x#30sqh#pib`~)9{-gxCMM#e z@=JXbD&>LN8|PAFxhGP^D2|9YV}W^+F-F+=rQwV*g3O0LnU`v} znLL%VM<7b>Eb`&KSVN;4l5&x|cql&wx0LiEcX-U;EbQUyRdk@I66|0)hyiQ&6!zx- zq>2{B@iZ<}clGl!LG^9;>|yH$3n>O(Rev-I#zU{K-mAt(q1@|!x7%gHBTItFId`y2 zC(+^VZpdQUS0X4Vo~ATN5u&!wfK6L%F?zh#sW^L6B=F5A*RZ2ZeTCoyw_{ap#3U%W zu!G#V^|%vqW0PQ0k2dT-s-D-h82(RNa*|?m&qX(KzS{wi6kt- zH5@zzdrotTNSSmCQhiF~)bJk>|{KvezM1nk+P1-q}hUyIuS(|NA>1TbfDIapvi zje+ClS{SU>sxuHb#ESe}%-x?#?(U_MXB;)WIfGS15W(TnobypEq*&xYgvfyyFm%dB z9L*Qu#t&U0(qAA4(r|*!Rh#{vYbn^hb~dFVlmdsGXh3#eZHjruIl458heEw#N#pQt zlfsAf2;fLgY3bkL-6+(FM*(q+KjBc!&y`5=rmJfybp@4qHyq#*<&Z=@UBrac&Y?z z=bv8AacT=$lp7^`>gj|w`1K$o;$Ce>$!CjTACnsrA_uX^?NT7;MPs&|&>F$XI`EN` z=k@OI5NFkJtQy3>IxdxbOb@_0MbNM_THgy?u_i_X)Whd--rC&_ zxNv#JQNuZ3%tBfTJMcqJODJ+N+BpHaUDVOE9NJ_ZE))sbmEGKQ8zf{=e**sX?=@0Z zlp99$diP?)H=K8`>Bo@B3&BmunIX_nVOonj|HHmLjA+mQep-mNi%HMGy?i%8PgRnG z^eF`ZMnEvU{nMqAqX>ts6?jX1U_9IWBHSYxJs9nQIYta!IabAPS7hg{5XyMFS_GW= zW!QO_O5zxb-yK0lf$7(tv1CKoYr2CkeiABq##zG#F2||HaC^bI?S(#%t>G#y3PqLH zpp=xWN^xYl`cnMr1;WpU$U7**sqljm5s2e8n~MJW(W37$;*exrZMT{Ampk;UMJ69) z#WH)e^Tz|8KXAyGV8f!RJC|&^(5HXVm)_n;r&au8y6fdm5_m{0s_Ep`B=N+ z^i>Zx|AJDlH+uO;!ETW99(*h`X zuTlgkl(?7ksPT`Xp@~H5z(wp56|(L#Sl14_M2gmTck{RR{ypsJ&_jmo;T3_T*AEws zWlGquCZDz0_)sQ_N`jaV^m5uE8^W8Nu^`Q>K%8aUtw z&rVSyzgFBquV|>wES1RDr(HmwTny>~;nYm6zk_ za63-ZE38wMk}UJIZ2TEgTcVD~A`IDx?LLP2&E`2+w!qrcxNiJU5N-=*#?pyKcdJ^Z z*eR|@&}}7pz`_$GC;eIhmAl!{!Jbk#`#nE?#5_^EFhT(iTsBZ9ohDXZhZxVC)B7{{ z;6r57-K7zk9$@EkhSk327)jhlxMKPQZv8Vn17BT@9eMzE{@UnA#6p7+)$2Z9Um?By zNI#9q=zw|b90Fo?w!TW?1$D!)lLn4a2soBFxIo33aZX)#SU9PYIhVa>HW5%^1N&{O zPS3mr1OE1S443R!Jm9|$aPPrs+Fm#+R}kJSYLfCUgf)m zB0UADrWt3LlTI|oXtwzNWlZl#^XFw0$~3v={h>rn?V$uL{Wl-_SpX_gl`V4WB5w3` zpYum|We0BEiu+f{`x2Ko;YNrS_ff;2Co0W5mYCaCxK+Vn~`H9X`EvY|LH014bihv!&0;%VME$ zK7Jm@xl+RB=MG;S6vVg|LxP6Tdv9K%S9pwbW94E-#@pmG*DoD%dXIxY_?Xw@7k6U$ zUVT*7p6s|{peL@c2)R9tON_6-zswUX`_>U70y$|~5G-&o#fIiWOdGn~IlpD!iSu^+ zF0a{H1vnE{tfaB#{du{oV1T95A`E=MeZq?Ge=rW%WCaR2?WZe6He1l5!{I%Oyct5G zxTu!XI?7FPP_OWy^t_a)7ZN@ACDq%mF54fy;NW=}y=CO!kl+VxF|O(97ptYd^mo;2 zh4&rf?uuLPX5ui}jNFbe`ZuymX4s`y6IOdc`cqA76{lUB4IOXf@-||9q6M5ol#PMY zTE-1sK+F#iRXiB7KCIBnqx#2 zy4{dH4D}RxE%;0zE}rruv1l%XKitEz(d=!yBe0-WW(eZ#<~o)0lZ@15N&}-AVD%cn zY!nB2r{L(dq_F+iIj2t@SPIB5ub2^gQHw}vbz8#9W&guQ`1WpOW{16}`>5bwKue7y z0y6lJ83KaxKE@hN&dIsb&@`$0yU+X_62yLWYn#vS|LCpkha+;SRN{xr_K**1$Y9;e-;xKrD(m0{S*pPRUt--ei1%DlPLf7pdY z&;&t&gJ*#zg)=k%{0%T&MJSjqu;RM*n#YLXpF1B z8=>DwlONFpLEKPW#8C%?#OlZV{vL)m$YrA*XU`Vikp8r73HIQQ_}Gfw4I?ux-~EX) zEyw1ob>o8r#i^;wY5*enh5jv#PwmA>IgVS8l1gMdH3IQ&F@+QEF)f*RtjzA~gnfb^zpAqsQ8n zrI*yeC8k!~=&XHI_xIbj^A6o1eX?MYYbn6aUjW&dMgYb49_CDh1AuEnoHe@r@qPY9 zBqbt@#lB zb>Fegz{gupSwqEA@fk}Ivw0uvSL(XjO<)`0k$33-gk)jxyQK#h{#6Nt+oIUwagP+c z^P+1_&Mz_9^r>d-ntJdLfIP%^2>oPXkHRH#kXoQDGQ0hLzr|*G3^GLJEzNWdjl&NV z*|C$bV>$Ia$HMQqjDnt){titG%(g$)PWRTir?{!~3S2H>CXrwJ1U%E^ssgE8gg}h* z2XMr(8p$|CW@ayesWZU7O5Af$TYs|>$DHgljFedfCziW=cF3_>ok^@kik+s@-cxQdVH`=iPa=-KO5ytXy$cz$*et2M&}dks+-mzWzkxDW!GM`8tocp=QWj9*~3Fp>}oYylX>lS!dUn=*C6?K;+ ztn9!g`vRIPq1M2AXt_mH(Ur#9uiaD!S8gA&<9tRzpEmu9<-%1WGQ_1@rFxzxrw{}( z0ka4u=^x37S@{QC5G!0#0uH-tT8~dQa6f`(hsVLC$JW#7w+g)L?Ny(!8(UKy9uN?H z`Tk6EWBsG~XYza}6RF=2Vf3J&Wbrv^AcrShLLE^%HA-EUzqOe}kUE`6aOsn?e@CqQ zYs7gx>=pM65=D#otp8B^E^Oh+rIOQ}-pvqefYX~@KTO;$ z6({RcmFA17xp00++LWW+o?H6^E)baNW|i9t(AjHo>3iVzlYkqvR3fUXMjV{0v}<0A zE1H%X32;~4Irre1X5m-+?$@o*T@CR08oA{49_$>)Lb1Uq}eN%h2Kc*9EJ4J{Z-Fx`)I0Lpb>_moz4> zlT8Q$7dT;wxgSC2?xtwwvCQVOp`pzB9+PDS4L z|4Ser&ldYlSKWo$8p77@v3!xeS2J8za_lZ8ocsr3f~QVCebK>>?dsTjG9f9>ffugE z4Vglg_ztK@gGJ!B9b6#J?oCV|AF!&%ppEyE_J78a6rid3JSwz%{&a)~L1)`-CZyZ| zG9_fx9SZmN4(^X*7$1EfsW$8mM}PDmg}L3}!04K!Ip+@I-u>v?ZpC>m8Ay4~s}Kl~ z38TdCSYrZ#wFMCq(7n>ch~Dl#3HGyh98Zs4^hLt#n$p{Bh`1TVe)1|=Ay=wAlVDlV z^1{9zh8Q3osmk4KB2!)hujKxGCZ=h{@^XwWj8X^OFC~xu>goa3`8%$C1~g{( z+`8gkiGYZE_&b&YiM|0(Skv~l_dQwt8jb%YIe+kfWzv6%AU9u5#mzXoayaC-?nCF5 zs5YdCt#`OZ8CM83=>lv&deP@EQTkur`|=sCzoncrbQ2OVlxo&}VNj~EIrXP^TA9uZ zAxVA>ucM$^E#vxOwTQxiSIPlW`#nsH3k5b)dtoNJkQ$Fn_@;1uV!~0=v0HgV1OhMs2;Ba1V27zxAjd0 zN;Y;bm7LZ}ed}t*)5b%70u+mr`USVz=FQ}3IK}-F(wSyr0zxGgGL&SrRK$EN#~{yH zfd%ayjPtTY`2gS=wbDAfrsbKp5Mt_;!mVuUx`oRJ3IjlHHF}T1Oc%zayQG~Q7l_GD zfE%}f)Z*D$t3n&exqYT{W}Ylj=PObLO-FwF9JmmqmE{2!Ftcxx^WOXgJWCO^sR8k` z%?OW<*K%10;n?3s?1JkN#5Rpc*5kWpnx5fq;I3r(hH!L7q+5!YzD8EefWQWjUNM}8 z=jRRaH_d}kQ(34_3fOyc0fd1RH-5tFlXSqUAvaJ=NgVZWMy3uv9@>_IvdjZA*ybDO zJO}{X5~;PncIeSxrC$F0Tq>&rr6hR4Q0z{-S^}ux!V2R!ACA~O|;-WnUIgfWH#m13X1$z zKQZbT!5V*QuLO5+1+)zB#QJ_!Uso-XH>Nm8Cm8W28eqeFX0@KlJsMG#VxvX-v$~L? z#j`d(C^D^p8hz9~q$6;1y3I&<$bmnvqFstzz(lm<8E~FI_Hbcgi(Uf0&)t*;jVcp1 z>*J*$>@VH#y38oCa2oegVsKv?i1*u}zFo`=K8v7R+(VPM^LcbW!$0u_ytcUZ*Sh2S zO3;@>7g_DwZYAJx+VZbwIX15%KI_#I9+$IDkO{CgnN&I4*VpqCR?y)v24#QZ8Em~` zIbp$DKvt_?w5D*}(Wen?#TA;6ap@38Tl~%v$){B4)TOg1j}hfVakn*BS%n}^39gPR z_Cih|Tyl-W_!o|SS)tqU<09N19Heyrlzi09i5#K+^Ddr_%Sdb-!d&h(+_SgOgT&Ba zg%$(yI4BLcL9!+7Tc=#pImPu2svewF1KjVnN1V!weS|!rxGXh}-hvlz0C&+4P0I*) zlyU$y*Cz3NX3<{sTWl;u4F;J1n_UgYYCH3Jx#)d z;2vcD6u?+;*$Z4eajG1zTEKNielw!kYuLZ&RsGhjsh)8H-+n9XPdlYAj7SJ2rj{ zpY|BEl+=A^sH=}KAHNH(fH#*tx>&&jK8d9Y{u&CMkJ#-ZnMT3oi;eJE7bBd@;jvbc z=v9RFX{habof4EqXeVmASndf%zf*@-dF<5Hs><_Qz&s%D&)g7U0L6q>l9;bPE-sOK zo(7oD{STu?MqWTlIG#s`f9Lw=&okWR5tv`(b@?**2tsRpvv~$|10Z%31e}KnZiJe1 zpadvk^!Th_dnOlYL~$(~-#%r}03LKHZ@~sACoTvzu(6wlr?}5S@~+I^vih1!6!0+R zfC%H73oEq33MK>S{cJeId8slhNKOba0nc<^E5=c8IVK*}ot}yl(p}#F)i&aU5p>(e ze#8@gc;pZ6VWv2|d#c#U08MlAvXEL8m@}|l6~upE1%{n2EA+}hVY|%iuG=M~K57(V^*86EV0i9_GFpbE37ViLHsVxZRKxx zif`(#23Vmkf$DM(^FPu(Q)!Y18XVXkqi<5+9KsIzUj2~-;K2D?k|S^^!#10*xqFE1 z_(Y_JrSQ2g1AhD|d~<6Cg7^7$sAv9>1ClM6Jw9YoNOU9j&mm{6C7T%8MKC(B;(1Nb z&T|j|^!I^)8bJ`y1uAxtyok*!t6|)HMTUQqsdL8l8OfrJzd|{S(#xy5y3ew_;6k8c zr~me3C0b?fmnVxpSZ&>ig~v(X=4f#3cFoZ4Q`ojlT-Wd)kOhk>bc%GfUTp*1fD{69 zK0wEv!qqCdQ!K==V>PT@Sb7Pfj`j7VdM!8roGC_78bJr9_Qp0tQDh6(N}^0e`Y+J} zP4C}OGyj|0Xvx6ci7|Sq{5GUb622HjFlre)1oR+<3NqXJZf3d@Q0(C!dPQ6d^l+|8;6ynO*HUqg`dQ`0H#$>{oaeU~4`fKM@*w3xUss!%mEYjUpi^y*WS#-tpkfG140H<(Z3D%6Y*>VI z1!QlvOwK$yZd>Uwp43H?tD3+&akxoaY~7&ICEpMREucn_#&i*7?Z$;0s+HfR@RixX zA1p9>9P_T6Yn=oCmDP&(nx{K+-o?JS58*SApPLu09*tD8(mn4E7JVQEy~)EaLFamVay^uNchM#v{80hPst+HAf$Ii+l0H|t=C?s zU&l1>*t019d4jKCfno3S+SZ-C`e|0Z)ERmQq{dX6Oe0K$)k2W_rk_L6m6Ccttwuz_ zQkXx-P?MXal9_8B8lWs_@Tmvh8r`M{>9;EUzB*Phj+iifFRGa`b^%FSYh3fUf&mVY z4TSQ$c+Mtz0qfc@7GVJOhJy$~m_|ej4NL)K2k$K>;i4#ATFP!L4-}7(_;)XoP)ex> zo|Ni(49+Qu9Hgx>EKIhkX;* zaGHl?+;B@~{}oTqsfQ5FkK3>jJ2q&R8z&9weS}KR{Y7r*_T?C|{7YF=<1fwQC@acq zDJ8YV>R-v@f9f^pzEPWwU_Tj%>Fw8X(ZIX`62@yD)rnpnK+aqJtxY#(1y?r~>q7vj z;B=hS#nzAWV|{K&NaKr0^-1Bk>cb{#58~5#5kopJ_dc^-0rM;NO*A;~a?3#7>-R?l zWJjD5cZ1{Z*v4O3o;pyj`%A4@EWw~EEFdFP!iyg8a#Q>Xi)TysfVx+S6x+Dw-p1h9 zsadGn*P#{_iG!Y4CR3X`Xm2dIp7jPb0bQ{poia|jUdSBSoScnD*thkFUFd@BcO(K# zlQVRh;*Gsa0pH6$7WU{c#2^I=14b13gso!m{=32@6%id9&D*Rwsa}ng zGQM`Z1!FdUY7oLR?=>aiM_KPj^yd`D^<#Qljnumb8l%e#3-fD$ctBS{A9Y7I&^X)Z zRB}1;GM>+%~KGvZy;ou>#-rdUJtw`kKR@TYe3ioeDAHyI{qnshGpD^ zCqUl-7UR{NNTjL!OKa+&XxcaZZR6pZUm zjN+SC2zGrx>fj_w+M%z58S>C{=SSr89NR7kgtj%jdK*LPTZr?*n9(+ui{#%_|0KbD(O8L zfXmY0z4gYt%GR!1)d*W1d~bMhfa|yQ)6q}gu$drqR`}zQ@2U=^#07=0oO-m@S3Kh%bPy|#G^9b6tZr&jN} zJRG~38YNJ31S!sd=O>Ty@;KGaF#Hgm3*JA54oo98yN2!- z)k;3xnR{KxqG;YM@cJ*60`T6ocuDDBOXp z2PJIIe;OgktiZXBzzl<@f&9oHz94}vnE7M-(vk_gmZ}-;RH0>h`u5^ z_avC{P5ISAsAdr}E`3eb0bT0v7ETjBnN_P1O(m&x?EB_I1ZqP7#x(?Z2y+dI++fm_K!dO2&~C`RfB+_>*mRu7oZ)9;K$fs7J3Jfn-P?cF3>r8HIyR81aV{h6n2#E}3_9gCigXU(y$eF6sA{`}oWddPEJ_=gRv@ zkam3P_NWW&JrR*vB5w$3)b?RKaWcfU>a`3P=JCOt!ENJBPkbUn* zxowYTuV(+!4~@LPWHvaMfU^62+Fl}DK2_QKGtv8lc)t+PnLpK5n&VOc&^?=A80SVv z1*D6XP(;cIX;f;to3Ap|b&?(%`^Qi>>Le0d;rDSvLbK3mLI{Qu z%E`1|VKUC}b^360z33KWN)N2LOyaRMTOjI4#5*#Xf` zqy%AYHo*r9luOMs5n-U!LqB^xceRbrLqCRBh1eEoJ5H|R7(-<5DES_c5u7weDf?bq z|JUr3>pffi$_gW@g40S${vEu!!O@ZU@GtDZ4ua{qyOtJgqsT(4brc3h`mzK2kD%Fi z2ZO5vSyWCMC|9ncRYsm2@{iv11d$ZUpg7TR-x=q?Pn|$v_I|tsp!j-sx!G$=z0C$G z1n*()5Z$@K z{(=f*CL)<-o&mD(EjWTQqM4*dRdZS_7m95{R~3ol$xnvodwf6OJn%E3d3Zp(%(`4p ztR_>|&ZifVK4D0-n%$3E$domwmT#U4*w20bJQ&a5+92!3iH4In$puBUxTckrYqMqs zNBHEp72ca<4HW&_`}6=z%qQ|a6agJg3$|Ic@-wI=ppz>>Qx&ncIxgqoib-yRC-~4k zy@4P*r!&8?)Yb$duTDP9O((Bm_bUc71UmqbG>|7$o!^@HkqrL+&a>5-@zP(}(C?`j zaDYVdmB)}G{;VV(oc6lpF6JOwA`d2r1ru;%T+7lGYh||oHwZ#%to5q@?E=&i4TgS?aDYL;F94;DLM6yFkJLNO^55Z})@|lU5JT}Bx+FG+ix~TnM;H3B~vfuSmiQBc4B?7<$769sNHR#-}_T{f_lMy9?+Ch~$G>lj%`P zdbhwD_*eNz&W5o>C_FBpo_ToB)yTJ6=`#~{%vzZi80qj&5SG9>Pc{u3g=`8 z02(?Sj8buOxKqVf>~pA@51>uw(TSZ^k;I8zwvPIi8dXpnb?49|nt0;|w@@;T^QQ%S zUwwFu5PlKd4C7a+Omng4=>0-B!s0qG`~Wszp}PP;z@T*Mdd-iuD2P<`gf;6>Zf0a! z0_8{f9jpc^{I$XOpE8&-feygGIk5f+AfWbu9vysJ0TJYL5J8>>SL&Uh$FVt_RbPde?@D5fV{Ydjm>c#P?C# zUzQd;px+9?Gw`EE^`hP}kWqmiPNy)E4LSn44ejwfQ+jBZ+iC$j{{C$XbQZR|XlerCW8!@SVc}W^dR59WFA3JV$iv$Q zY5~ssio?~DAEZ8y2?c;mM5b==k*OP+K=}v<=+kUN-MtYOck1f0H0G1^z0$mfhMPbR6L!0scH=gnCjRHgj&r zJOb-02;1+T2$rUq#Lc$~Q8-g_K$Cu&(dN0LdA$ORiKlTQDt7=hsS9x3C$>vf3-O9f z`zN683Bu?aN4LJ&_-JY5Y%hC+>1`zCaC@-x`Yv~_HR0R;N zq6P;9AYf?Unaz;Qoj!d8DVN4~@K1LCQDB&ozXhdcszNva7kChl<#_Hy zY5QRBBpti2v=$ymo^=9qPhceUA*4kcb`OsxY;L71cSSxzSi$!mD3_c>A?k^Tp)}>|3-H zrYBC+5bc3f)SE6Jh(nF~uYg#R=J(i4mY5<{DA6&C0aDyfTf&IAiE z-FcK1U*!s#qyQ;?d*VJP5uO`8#NR|`M_#o8=YBB^Aj z7c%{v7}YZWbEZNQ$~@0UbjR8jIby|!vSG0NJjln=KnNe zfoZ5_*Bpc;mPI0DRv4)RV3O?}ECSY2CE{t1^R#CxN=FuW{7dg{A%qkJ5Hbsc!--EP zBR7p#Nohri`QXhOIPRauE{MQJl2~?ji0R2Ed_`lisH>&MR z;;@7OF`xT>Svob42i#QUY#SDMrdWzaHb!m#B`$_Hu)*e?15g?QIJ~cReGM=VbznLN zzi+cqNfj~P?&-EZ%csN?rH~1b{s=&B# z$@ej8?yJEJRIlg@Upc3{DdI#h>5Ys^pi&cf z#xc6Y_~c82Ux2|D|JL{hl(C3v7}y?;Tvj^BK9wN*Ao9m_1(08t{%MP(oc-qQUz_kR z5>GJuJ_!o|2!8rk_r71C1arHQJRZ=lRSrvrgig`T>2+EVeoEo=2yZP; z7xYp}c>q{M=iT;4fybjR!_~!M;9uns!nfCqT^%=oFZV_5ynAsN(;*tkBYM8&=YNPH zq9-x5*J1aswkhO3?}m8N?>APIuLLf%euPmkfka+}yW$##VIpyrUXp#$%eVA3swzv2 zp*;N1&n}|VT*(>f$;TeaV=Enje&~d>2f)@ROV9*fC0hKV#c$3XEM5h z0O``MZE>PyboZ{;lr6_K))0c~(Y^8G`u^lcN3XxBHDcB?kY#W8lwWJy5FvVeZ6!zn zM&mZ2zl0(3iSmmnAH1{2^sW^gxb> z$9?B>v0M&I-;Vy1r#pXKR*&hb59+da#&h6*#_Ps@AD?(C_e7glJv#Sewi>7RF^_um z(R|&D7b-M+21eTdb{+;`(!54TVP|%me)C6})=;m?8Dj`|(>@68Vl7RCzKWU3i)xf-R z@9zXAZKRnP)x}?A3{5%-oF)(3a>VpJWtm%}kcVG+Vbw-T5<`U3eeel}{Ep-ky*}N5 zjTdwoUXdZA|unyAgvU^*k?DVv260i9LDTRcSG;fFJw znx*A1t-r07Cn;Q@ZMm9g!I`5Soq3RxpPdXFsZeeE4!Q))c8fqSjs!q!XZm@QBxaL~ ze8wuQC~im)emCqlg}g9WLgkpxIt0OnoCEpQ=o)`?mda%inUUBoPR4$K*T3VpU;J2V z|IF3ZE*ed?-x0r1)AOyWTi>lrXVlo(MmO>)izcYT8}E*IFRm1Qc<19C_>Elvkvj&; z4$D^6>!({Ss!Bg@iyHgP-2?~Wj=XHrt+qx_(t*jp2GabPJU@)ud=dKqrr6oPs<=g^Yvhbd0>COVcPz_XK86!PDO>`^0K|KNc5YXp86*QeMP>q zp^Q>`(b3W8uUZ7T2^T9tx<`IPY#~7(aqU&v2C{>~yeyqjaA4=B595#JA0DY|4wy~4wuGH=LbS;Y=!JxQdtT`4SAj}m+gPhCQ7;~{1Pzk zDl?W6m)sao&OGUPjcM|C!)wh4j1rvIGg$^`8=21`N$9hwV$*4@ny7+lR8|7Xm){v; zF`Q@`VxpR7hV)}k3}bU1@$r^lCLo#ApQ^awsGVhS%^PKc+I=bMpF{4OY);4N|A2&w zqCUB@sr!Sf_N5st_u2OQy*zXSxi|D*y3g4zoNPXucRB5QknHg;L4uh5m+uWqns@Am z9+4#`CVnq*6FhlVh)UX1h9WU6nZTl7*MeK_@zE6yT7UME-R9-gGk(4&i;rsY7n5(3 z9(xn_%ThYk{pR;S_8N`%tzn$sPOjUlJnnw?`f|CCSbAOcpPd1IyzYXC+k(eMjBcLi zKJD!v?F%~`*VY{wA07F8Y8aoQ@SROwEvj>~cBs4KQ^h)NV`ZJdhMAfUP3%2Kmlf^2 zJNNqfnoEHaTnI7Bo&#NZgYir6GZF*rJAbMkylRb6)<91ET15jrA(!V(5;lqJU)fc) zMu*-=&J>;`CJ=QTQB3?CBrYNGd1&Zd1jpxwp3|s_$;p#H%F>L18R2FDl$4aLN{!sz z-Rp;kNeUeb8#7)i=;_hL2pWcjGKjKVyx7L;*wfNN^33GyQ|h!EshIY7J&y#eOkBXZ zV4?x)*N^b5_X$zE16>P6g1=F1`vm!QN75HAhdt=71Xp@k6{xHJ{ymlKTc$9DYfRu~ zSGH|lIN4`uTN<=cqiW!Kds)>O-Tj*QZjgQ_t0&5SU~6MF%fnYA87z5?&YK}rVRc_n z%W_0VULNm9sm;yA&$JzIn6a_3?D<75 zB-Z#7FNTK1p!{ytjqWd6izm4X$equVsCpX>CW_=76bn2)@A91QuKgwIWp6L!1VZSD zeoZAImk9_FQdWP$330!c?Av?=*jpjV9kszr5w{j-U;K6{2n=VsOIUDYNJNRr^pfgE z-g2Oy-;Ei9f*W{Nv}c3-{J?$%2849D8;p0&0vv^M= z=*OiWk&K{du3$&PPA)x`ElDwIyCFnJ+S`Wqt=ZhMcm`sP3PpR-&3&26v>!liUlM6c=8Sd?Dbwkr9*3Ea|rLGEhq9sd}PR znpWD4UbO313t_X4e8X5-Hzq9RzHHEIjuVdI=R$X6cd3j(&Np|{*Nk~~G0u0=bDJnO zV12dWER$)DgP-nEp}?^;#d$7qo5YmYyS9zb2G2`S`f@m?brg$-rZ7(uwqkLb>x4^KBgUjFkd(amWadu}0$%p-k@Jyya?)Mjr_D@!QgD-Zgb za!lU$MB*#X!6ZuYnhB=x=ycdD zEiIiF#~3waf1mW+@xWhMS?NXRo@QhWeO*-dWO~|SAxdQ(eAq-kUv4UeWq~#mHGe($ zQ>+i|`reG#w_|UX1_C~>3#Oy{$$Dweo;ANFG2D3|u!ZXPKUPd+ePPxvdp*#oCPyTH z<|aBKMq5c}xQT#I&su?$-%kYfL#N!pC@1HvY|^=ak?xZaKY7djRLoba>oa|nCt-Jz zfLP9}-wCcguk~y3+V)q^T6#fk_jyM3&83IC52k0QHM4F7zj;GXCwM!=by;_(AVNAN z{i*UDHQA@rUt)bvU2^xkzlRs|)l@;xQ}jdkhu1L?!7qC^j_Ie*yi+bxax%LV@7( z&YkZl%*ujE4D!4EsiyF-87r?E3S&9N-xecI!=Ze3+(GiZuI=?iZPu&x_sSXph!2TR z*1r&}X}##kJFl%wdY3`;z7GASjR@xS0i zdnQOe9r}mK^t}qT!YBM)m(D|r?5~C|cHdZ~USAMuyg_kk@ zNSV3DpYrH|dkP+%!;#8G>(uXa4s|={iluGv!Z__S|AzFjODu`+j@$yRtx(z@zI@XK zDvZ}|9%~*gYa=hJAzO;?rrgAvSK-vrd9~!icbr!sriI|vTuLV z<0p~0q@?yQZao_n-sdOJ2lYllDlpvb+iu9@zx`7`fznk!4bsEg%J(GiJyKC?Z%QKT z3G$^56IVp)zo9#?&s8jvR-JuP{NktbO||mf7Hs_6#Kf)-Ti2|PA4oYbbyF`hub>ju z-8%7N2u~=G&V8g1b=iFOR^{dO*oPRx4PEyXFS*i~*sc{zuX6@>3D<%+&m*<1cXy%oE#~*|wBqV_KtW7thAr3$HVRfjej?dGsqZU7t z7_3vH#oaCwT85h_Dn!}e9a~Gu_0YXm-cprnxtipj?sZqm|H?uV07m zK4;iTpG_VxaoDD2TK9^OY%m&o&4G??`7X4#rZ1<*sz6GDdV{*00-m>c4Hy}3oACrqBPGnxMMA>fN8ViRbY7%9M85#VM%1e`jZ{qy>t=9VTgQ+H79oM`P| zA8h|2he#l!R(OnWO6C%Hq3n8l%X)eNkUK8ekMvaD~u$*<|LTZ?|ts4)_pX zOQ_XI)ZzpnvKgrAJiWV-SEO0)V7x$^cTqr~S*IqsK9oT$TPOcpQ5o|#^HoB9v8qp^ z##bJyiag+OVxQblSTEv#aJ9bj7b?%dBt(Qna57vvi`wOiUQBrSkKJAOsfwe+wMkV$ zwqmC#UvDN_dira5eLt3mLZs5@r<5m&7f!zDQ9fTi;3Kf$IoNcw8DCBi9TBD-C^TG8 zz_(rRPjwr0GJU1^GTxI4`s3B@)I6KX*@<8cDS^O*XIw5S(QvpS0?2LEzmtzA+RMj2 zTWdvN!tndvjfT!w#vgxLIUutE~06CpP24IG_o1=onYO^AG{7+{{P{aRvS!}z4NMx zTKB=++vzul%XY2zFB1~y9`i28#-6=;Fs&nWzOS5UT7J%1a%tb2@;u2KY#IS$Y2`Fkq?ETrBXSNip%Qxo_YR|LnQBID<%PEieKj2;wQ z#6k&`*C?8r6_!Huex-gsbu}UN(XwTZ$XW9<&O981hX*o&<1xp%`y#2c?Q!Pi4Hr>D z=R;$~0tHRAa}}-$Akom@>XCcrCu<w9`m4-E|oCA2q2_xJRdlf>V0)PQ}CUQ&hX5uuH56oNc^snzz} ziRh{XSsTkywJDHQGlPc2JX#i?*Sbr+?YMh7P%K#vrkYaIHo;<<5X&x z>9^*K#e>o(Sf8REE5hwJJ|!j0TFL}E24Bs=xHrX0OBMUd5^paC+M1a`8C!1kS)q#g zE}q|4i!WdHwmpbrX=Y+D_YSwt4W6#Lz}d{UaG|N^*BP_Lciy|sbK!#}8$y&tq_JxM zERB_tA0~S-HJErY>I;}u)SSO4(j%isv0vGLExhtsj)(>I+YbyUhz5X@-7lt^v|b?1 zw5!H{F&asAPu5SSmoera|N4CL)$9$1r~l#HNRAHQbA8iGUcV+6Lgr;V>mxO!uP5H4 z_N-w_WS$S2WMiU2`2lo3?Mw^^3?y+08Kt2@0;(mltlmnXp@x$C*BOKT5w;5>5Ac=B zk`%4A;_{gp^e#nk{2Z$FUOx(Cekb2R%+1UDlnUR$A-78LkK;Er&fWDT?&+v>9bo>_ z;s3Z79p3SRnHm~7TXcj<_O)2ybuvCzeTAS@&7W&znmZ-uI!S5^a(|vNKglh?`^EqL zRI%Mx8VJY1ayLQBqN8^oKhk=+<~+Jy%-?#ky>X$KNBnFZF|kMyOXZsn+5|o(9)!tt zSI>CqGtc~t#y`PY>@^^8bu^2zcK;zwNJz-U?CkTUCA)>ohERKdmwmto)gcWz+XI!1 zsv9dTr1dS}h84i|SCmPA;+Yad0w6o(0eAHlO54!f(k~4@xo;(eF*{g{NbT`T-LAui zMS|>zcUxwA?}?BS`$f71!YXHpDN4SI4Ne-}{q&d&(vtd|8v09nrG|s=r&%Zq?ff7s zFGOGbo~J~KO_Dg>(b3V`slW4NqQF+>IjA|BmaUUJgEQQhr?nt5EEPz%OyJM42p}<7 za;PPq&|qXGDIJ?jef{w)?d6}_Y`)F0l82Rc619K+JW8m!&wKGAxutq?W{2k6N0yze z(#GDmO`>vJ4g79Wm%JD8yrgoosaGO|?i@`ZPa3`HOjJRudU*m#SFTpN;FqPilCC^2 z%B7)Fh73+lp|ibigM0i0&zi4gIi&#As$pfWGLL~0`h|$!5LhV=taNmp^!xexs$Ry2 zL2Z-`aZxqB=RVBM%A#CG7_=;fn)6LZ<4bpU8z{|$hsIdvvNVW>m)35oE0DgG^Z=$H zpg8l~^m|4yX{U^8;yWut9ti_sYiUev`33ZAo?eOk5MdFK^`^#Pcl*ISWpxz=Gc#6~ z2tJ$2-89RZRCtqkeGJn))M>@FO zpCGmFyzyD*lu1^H=}W`!W&F1;GR$D*z=N(Xvt@nF!%HH3%-0z z&eXQNMM_z`5;iWj@snG1JeZ8Itx$z^hbq=e&(AN~sn^}A4FTvO&4r`gNd}~Bh#ur4Q+9zzUuTv~hn)N26Chht0yrN6LliF6yhUC8F z<8{zF7Zg^Y#am(-&2KX|ga(3|I{6jN-cA)?JD0&9j6w!-;gOb&nVeJ0sI20PuZJ54 zU(QB1OjU6ta_87q))CxggDHgXv1A2!#g^bl28B=1?m+ zBLvg`+W-v$Hbc;CfVSAa#wbGDFo>js+0&-SpMGS6WsnQgs(84cVY6Ll*UX>PdO%U? zUzeUlh6rrQ^7?zON*ja?R3OvJ0zb3c?% z$89TsG<@k*YHIY3MJLyZ*M3e88<=KfY-GM_nWvDaUis{Ij`xyB-!Jq3^<tPA##vfAIuHk`eHXXMDW4R74VSu>5nEF z>aSIxqg@Cgds@g!!v|%&Eux`-b+Js+3qx6DRbuE)zH@VP#twSTkjt{O@v&0D#sO_v z*KlreMSp%dqez*k6&H&0MyfsWfhCDU;ae5VQ@xZgfw8x)ie~#G!^ZKeiOWw!nFtj> zT!8~lq=|&z%z_H7w!!G3mc2L35=BBb?~HhVn@r%^GN$bJSPkp1S$d7s8bj^r z;P82-gB7zson;l!(cqpNB{MSZr zu^R*Wb?f7c{O+3lNJUI#j$9gjw8?LTZze*!jLOfM@y%l-CR z@HUi1A^^}dNg)POkg_D5akp3+?~Uty)BBN;u`^)HfG#d?H9x*Fx26W7JWI9;CY>nFcxbbaax+e31P#UJ3X`yJdQVHCRuH8nCC z3s?m?up9~>hKqYY72u|CBD~cYTWq!w-6RAot60Z@5aEEG5F=}P2LXuB2bmqBX8rH8 z>nJV<`ZR6j_I>OfNemM{T7Wuk^P4Gu+xs$RClTi>s2A5LAO9|2T<8@mzZr}SDGZ08 z@_g%atpSXajojL;XGCK+f6@x;k<#MXrAxBLjKP41ycUie92_<8+s|nID{jFX6K3 zc3*Cp#9g{3B(R9}m}1Xt@$X|^VY8uFyq(jmD}u=hF4K%!rQvJj$BWsq1;X#wcf90g z*B-^fB_^|4PYeZY&Rk+Se&{O!FvsbxI6~**u{Wm~W5tV+y2rli0}^0l=+JqKm;V+C z9w|dcpFe9JM-)o^DpM8|CL+UPSrsn&;9h48{P7I!StjRnx$G0uC2V2zuW0qR zr>WSC)bdr$+*Ui8^R6BzJWW@m)^23{c_O6><{h;2GB?ug{YR znKtz|mMX&>RqnoXbpF3XWz=*U#4;C8Qn9j6cV?<`0W>i*x!4 z;v7(T88rrSkf0$zVr7rZKq!e1CR@GzHhroCi}&CZ*IybH%s}#`RP)!g1HolnJQaW4 z?||obXtRroQY(@$=k+x=w*tJ`+HAl5$}kupS5 z+(~U6V?2inIQ&J`R5vyhJ6J*NGJz+bajhvh7HsAf(A5dvwlE4io|?2w`vH{G{@kUb zfB=6f&*=rPve7HzpnG1O3$;n%@I~Wk!Ic5OSs5FVGuE$sacpMI%>l!-kFl|D8K+0Y zi@Is%bE=x@lKY<9P-h9S0i^7v2L;`6)87WXpcXg16DazE+^W4ap~Wz5rGusn-C~%s zd>Mj5QR-|cTbY1`4Y7w&@Qk+KDv>`7^`kD!Z`KAKL0>S6dncXU32kbUX4*L6v<-@L zT^}ZZ)6eL;YDQ&MOqw1un;;2}70>i0GIwk1uATiY)Fq1}6dx~9rFCBx9Q#E;_FS<` zZeQ8QT?;*5XsYwZW$~?7@!3g%x*2ssyl?2^WIN})=E9HO-|x`2xw#b}4{5Z@0|PPt zbqrY9oiVwp>QO=q>w7-vRf+!+SZn^)!{Jsj*QfJ6^`PDrJ{abTk}Q)aq@Mj*{REKF zrMZ>pe0q|6C=n8SuHbhg(Ivtigj62boBFR+hUXl?+Zfxd(C8_v$d8qL=P_IRDB5aq zagL5CZnLoqei_eWnR1AYn0zQUe%5vZ(|1pzpxR-GZt9ae*e()!jDu<}pi+X7^3GX( z>}K5w7PtI{G5}Gc!?`6ZlPxp+!GQX6wfEe@29_h5{d_JL$M&(V9+0x>3*mP8c0rmh zbxqAcXiIbQ^I0W$=zssV4UdkHx3}k=Uzk_d)m1nawQ#q%@lNpfoL{sCK17)yUV zH?m>dIQJ8_h|{=XBXs1Tfx0qWn-0CM0Oa~K_zNEkTcS7UQna^br z&ZYzn?!W15=Ucy;&govV^_m#JzB+YAzT)Vo5a%!>$gcG6ZzIv~rBcU>)x)jRneNMM zGM^4iZu+~Pez>#urgmpb+HY@5<3i!@&Ti9RCTcfD^S`Y_Wr^Ys_Lh1Fv#r(kJsR+S z)@~s_#B3^kjEW*+kai>LjAd$ru0UN~-RbX|(KI~T{+|{=`pe^m-@hZ^MzX9_CVZrP zo*rwbs7$G)nmHq*S*12UQb0QbhkD~)9Dvy5J=@M%vagB zE&=CUw=%i&FyCZu)9J4@;|3*!_WrguEb3CjyuM@VM&sE1eb=14oU1}Cq-GN} zAH>`iIsMXEb|?4xEtXQo!LN1oUq>?cMah(x!>^rxbnM!i{NXcX#@E;gJ~pL!EF7J{ zTC(@u`UOt#GWhZkN#Qg26T}INbCg> zBd&aOUcgl;VEpv#&x>og`lz*$wqW;}mi@vZfXeOm$I9JqHemWQY7jEpcvw2d@HY7m z8MFfT{u?r{koV%f+tYi_?deY!7Z-!APoyHz|Mc`cD1KN?_eA%DtX1AgOon=ek%YVX z*pt%vS22u*THNhZgM|IqM4hj{)`4|XzedqzzRe$~P@sk)yMT0-5RtACCfGeosn5+4 zyp2UhB7=&5X>P%SMymrW7yuqCS{}syYb%XMr!+22q`5$;f0RE=sZU8sPahfYcM$=r z&7IY`00s-Z@PS8vntp{hEeh9vSNN&%b4!}Ol$u!jI40jkvtr}gO~)|3qN%O&rg@pO zRlJMy!!k@l+1AYK%g(;b2i%R0zp@5xw(Vy=(+K-AfPh0GRqMK?rRCV?r}TgsN9KIF zyUI{Mn5RN-|Fh;`sY!mqg39X5+m`_(1AssFO^lPKyBcERMMKejlLk@snvE+38LY!7 zZKoS+i^c#s|7Pk%xA7>&34Z)mf4jkaT>ujkQ*ga{w9Ku|i{X5a)cbfx(Pp3w1NE9a zBqUUmV=`~VPROOt9#C=h{I4aS+)+I#lAR@?dK`}H#RJ=2O!ye<>3$03ZAHe7&&^IZ zjgrKzYkEmb5zT{xg3Sv>YaCtua zfqndXp%_I|4DeeFh@Siy)7LGrDLMcqTxsS3FafB|j&Jl5XfUD9&1Cqy6r+POwi)TgZ8vq^P=xVL(t)?q&g=9>1EZMiuI3<8wWwpgJdBr-|CH}Z9~Jp zW35C3r}wS)RAUmSBC?0_8u@4UvlXJt%n!JEAAO?eO%~1XD;I;mO7wi=CIiw78^jUB zsWEC=uO_Mrwaf%*3WT{o1X#Pc7N3hi-A2MuqNw0}16tL63fY3)or*?K%|u_36+Mfz4BTu9rB|$<`orE`61-{a!`zqu3;tRQgYnp2att=}B=;PR@+J0ulEx zI}dhKF*xAX%KrIh9qDM;jlTKuD!%SbXwb}L!%q;Ob2eWFO}~r)jITL%YUAv^QhJ3F;FCI`QY&U~>>SJQ4+=2eQ@20Y!gXRLgfOE=yqF0}oP zv|_|7N4_R`{MlK7Wv7F(3SG(1QfI{Iy{aUSZ@t2$i^jF+h-vTOGg#lWQh)O8TVKn2 zN*-vaa_(;|tM|*LD$?4=Uf@{fX+qig_G+-rea05T_U#+NklzJuJkVFFs&3AN<381PQeqvnjp># zB^F=4WR97om}o3tH2(DC_df{$QHS;dgwt{;k(kVNG_9=IQLU2147c$KsRcse%u&iA zo?c$&E%WjobM4e%ta+gN%GLWUW3@xPN+JKp;umGg46RbCvjLy9bn$o@6XYU*V;I!b z)Oa6$CSslOjz`L)o>jP%Icz*yjp8oc8l*^la)gGTpjy8tpgCQRdE+;xDH{sAhynI` zl_i+aUmU`NAffOr`MyxH!2eT|xHi*64^^lNQkHo+7~pR$?W9ST zTl70rJspif{t7@Jl!V)sY`<|MB%hAQx6Cca+{61x^HntxwANaktoBf=Q4@5yB40v# zlLsK`LTCTot~%+sefik?`+lO<{4s(+=Okz&ooS7nVEECJ9`w3_+_oUi!W+1`s!Yf=x#lo1d!*t14-J-#JyCW{cF$oXEi&V!AuJ= zW}kyUIfFk5$%eBdq_Utl%w!>jW)GmXYXe1;KSwLlt>!e;+^Xl>FF&hDY{Fcl zN>vSumHLA%;l{Q1^YoArX*$uDdc3|Dq4U|W`3H%#bV?Z$)CC&WoL%jy8pjsxI_OQ- z+63sV{m4>?rW@wEWx4&5<=FEFFO&Jhfh-(Cvm5Wwz7#(*WHVdXb$_;;ZrQ{x>V7IC_bud!ho;L?t^$$6185!<>X5Bi9XV)A#_5!|8nnsSk6Mg!F z;)$qz^M1OY?w4pYV!B(+YD4*E=XudK(+#VnHYC@J3Lg-kp&aQdZ2sb|i&B^^1cUP9_+xb5MO%vNSoYMsq+&v-R_q>d~V zxpKO&z9i#L4C19_f@)%onI!HZ)N!;?<2ZhP zehvFw-NUr&OuWv`wQNc&(oTp&W@PYN7pv@tl~z=oMM)X#D(JknF71jRgiBiUHE3eb zHms~ORQFo`ibXl{Off0sEmB^nm4stvQ;%gL=}iSi%R$UU0;)VuTo@Pd-}I$0Az?3nI;i^8OhFrU@JoO2+gGP*K zZ?)OiY_8JA3!2N1xqp0(>c}dsH99>!UaEI=-tDpFHQAw8h-D1=@$`|NZFA0u-Hv*Z zuE&%83H`%~QZdgZkF4pAwPR`%W{zpzDo^N~^^b6J%R}f{KkTPbY>!H02?+{S@#L zAnaykf106@eN4ebWS}GsBMMP3&2pC>v4sXAn|SG;FZgByA0EHDF$hdQ3fIETc6RoT zn9GR>3b&pxemwkF=FDubZv0@57hdFN2edK}bc@hS;~^-hsICEPBYKcikc^3miE86Ce{Fnu=gErbEtJjX z>jKa4uEx8mnKTb~aK`o)Xp^G5#+Cn|*=aq;&%>?X;e(fY(qnyn(&a!zfQnI>sIV25 zIyt@q;wH^}?QdZ zut|gvfc6YOi+ZtZ%9w&`_=Bo(Y!_i4OdCSf|0p0iT{(P%FFI4Z9ICVLnofIn6oMQQn z*~FqLh1F~I{7K#Je$pYUo>>=!Ow3Qjglm&P?sDf6p z@$ng=OoAvgXEJ+oQttR0c0I@)bMnEhkv})rlB)-t_V}i2#i}li_Up{y;HjB(dE*z) zbYI<%3uew&l}Bx@4s*H99n3k-mOD%p2-`ne87X=%*29QMvpvSW%`oCVk^eBMOx(Oi z#i!|gmXd7vC)nX8?9hG&t3Zj7CCnls`IT-?UnLf<2ooNf75cZoA0Gf2>~CoaK+e_N zr|lFvo%hpzUWkX775GhN* zg2eKO2Nt9*m-c@wh&M7*hgfIx_;Jq?Om;*88gEE>*u17-M&$chkm+Jk2n_M_w*;;kJJ=d|QWHxykHDh`WySrouZZrlw%;sHYlxW+D^Q zb!E$%q_b7gA86u-e%~A=cRs@f7R~$5h+f4^(zng^+&3l)6KwC%*!v){ z>ljlxGu^zf`>~&^`mqS4K0RWhR50to+QgfT%x~Qfy=@&XXz{)KejLg^6Ja8VP z5RXL436*T#{}S?{%OK)Vzt+KH)z+9-Mm~z@mzlPgXsKhW}T?SMXP3?M(`n`f|cSxOpXhu4aWU+@O;DL3ai%A86=Zt4Cdq-0s*O>N2ws**E_Lnz#guxR4SJ{+Tlm#cAY+&pPvuh*=hiO#$k77r-hzXLEU<+80(Z&okL&y z`AnM4`p9c&S?kx=WR5<|hGRfn@q@TR25Q*^=1G2y+OK9$7H`w^tWJc^7NA-YQXf-= zG4Dah~(&`^w3vRAn7uI(>;CVpeivxB!XSbvT<>+I-33ju)uA7$jk?&$1 z)ri{~UXnUVZIDGR-58&#xyeQe@AnUTuM){~^4u`*C#BBl*I4Qoa3o@mp}RR;tMcu2 zBdDQ0JUun6$8zNGNM$&k-&#--IqZ^D#R2*;e+lGJjFnNyws%$pEQW=w`r-%YkrhVF z2=!m7TbU1A@Ow4;D`uNkhh}xO-D`UB{G$quj7v63gI^^BcYu;j{Po5Ptxy4FAGJsv z6~@S;4BLY+jZs$oYQwecPc9=hO0Q`mUux3?fXCfkHogPmS)J(|Ld10iNngU#AMB3z zt3)av&VHR#f85FAk{D2w%ri|V{0mJf7OW$uP%LJ-OP}Uyad_V%OT+a>apU zBx7V{wt%aNrM|}yvr;f~;3b~VL#EyCikCWZfl*nH7hhYcpQDN$pqPD3nXhimDZ5=t z9xh!p8GGJcN+efp_x|>cktJe4c+PJ4Nxj(LEydSK#yUMJvhxQhla8n@q^j-xsj;SS z#;JY7gu^Y6UDJnO{KjJW;uyykq7^oVHvTqV#CyZn9)iAnFkc6u1`WHzfD}5s8L@E` z?S03;zf)>3%1^?;f0L4hMVf)&&zbOYV{i_0`=2T~fY-@tq-iss#{<0C)zX5&su-Qq zXX-u&n6q9eaa){xWTBRQUnUmb&h+UP#IJ<-A2{dqu`Q07tM!kiq@)@0wfxXxRG9p0 z`>^2ar2)H=!iofug!q45yhchCYXm*oC4U?_ixf_$L2Pp)og4Kqbr#@zRF$D>dg+zH zyMSo&$^u~u7A`jW{MYj8FkA-6lty=d4ZmrbDC{XP2uu3gLygfABRQ{kyAE$ZyuWp` zK3nMknAr+2R|+pVs8G=0SA#60*_MaDJjnYnDNTCoZaypF?jMB7w+z3l=@KnT-;Ela z0+4qG-`&z0J-nkU4mEJyVZ7uNCrWRez>6V`_EP1;aXh7_!;ZCKf^{kHCnK`y`DxJR zDdnrCW~shx2hGF#$vV)rTBsEpF|J6d;Lj;Bn)SnyYc2@z^JgCuoEhfEm*IAs z624!kwYio!>cMHwgG6mcb=+}tmP?jTMT_|E+yXCxo^|8PQsdI2X0L`Bm)X_=JRD(5HcO!km|2#(&B; zs=HUkEJfj(R}ei{WXIYMB9(!*$CM3!gSdSMXzZ#iY=--j58(Srs=ZhTBHwHX=ea6H zM2O{n=fi*f{h+*Lm>_CwGs*JaV5f%l%X(i<9P+TI3pI<@X+Bi^{QT(k)7C~wVM1Z& zp$3Oqfjgyc5uMTCWIZb&fNq_<080RN_J|2bFc8_ww1epGa$q{S6%9v^iJ8p0)XbLP z<9pZgBd{#qvj*tO2d@m|!AZStl~IkFz^c-qFj*ftE@177rE z9%LxYq71;=vv~X(Ka`m!GA;Uc`)4@+Q`hj8jOm91ya+nT--;~3q^Mvtx_jV*)tnB9 z4)st>!Qs_0Ys}ELw!vhyb1{#$wwC!$At40}{dQd$u7k3bRnv;Qg6idD2(A5jOCG-3)sMm^f zas9Wz?1)LritphM;x=Ogz5rcO87o>Q#d|h|wPTaIjMf7ku>?0SmNtccdz_`JQKg7< z3x?(nwo$?1Mq6>;-l99j@PR@At8lA&inYYDpX7OuFqBT6ZU)0e?v7%}_m0)kDRJ@f zuU=(--gEuC8i6a&HZhI{F1y?pdusCG|BOr_yImQm`(=jU!{QQ)^LtP(wl9nz4te;ciTv0d^ZTy4J6Q7G$RY%lXimSD znkGk?@-ugL#nDa=dNX+=lDfO8S9wx9$U!0tDurt>c){VY4oX=nV*fv~_)_bA%EuaY z2T?MA%pBjlWD6=(OOv?+98c$Q3#~z&>il+vV|!{53Z@s!P%Y%Adxj!|$OYdK>-+hH zN=owhD(11qFKh^w^BL z`fE90w>L1NGGof90gDPu4y!-E2apLyK<_ITQ>xX8iUpXjSf-1H_z^Xw=xunrBbK(? z0FSx(iqFaR;T;LoRo|O1`UN!brSHm4cI6ukKYi~5_LwoU8-H3;thY7YfKwI7Z=$(e z_vx<)r%i($sp|}FWfIf_pRdRcXT_A^oh1$6G@U>0PwwPW0D~Gd z^Zx0~bMl4S||i$cW* z5ZsqOv~;SXU~8P{A65(B-S&1Uf2V7J`c+z*YO48_v-9(vF$-t_lM>&C5UiD`1u4-K zYTZjUp^aVFmqaP#x_Tzy>Dz5{rU&V;;NakdM@NH-;f3)*XjUk?Q2;`Hz$jG$wK=cD za+h=!zZzPxCD5GpI}k!}ud;S_twhoqd{~PB+jD=MempF(u)+dR7sl0(%JviE~xU zE}`v@zGzS8h{C7Xu3z~x7D4lEmS8MY_`AYqWm#7j5LLM`FXhbdU_-@&*3!6ad@fiX z3`WNRJX%^>kLSb5$L8~Q6JEd8Tpo*BogNX|&!sMeldyb~WA1mR44aT2x5PI#uP+iq zx@#bV>2o-acNvjuZbzC@x-*BB`yJ9gtJqjl zwu*FiacL_mZ30s*=H*=zkrh^hWbmnmgV@%rroLCdXe7%bUv-$aC-4)@LsH&)77W1lHrrM54C#9| zhv&L6iIeWTFH~Y&p1k(PQUgXNqZn?!Kn6V=Z#l01%3t0q*Ui_WZPoG3vs{oK2j;e_ zp3cM|10IgaeRy=I=vTK0Q^L2$vi2}@9;)+OgajHyOfVK`n8nhn65+a*@L7gyITC0{ z%XqhZ5WS$;B}?}sw$?mEhYpx*@Ly*wL+4+tP>xF0Kwe{auj||>< zxyvNKn}XXYmv*?mt1Jv){&oCjj$v}UHa#=_mt{)*FZ4uC8s{b&9uURIuHezeqf6OeRqR23R+uGi{ckk{4 zSCQ7%Rws~O&CQLf=Llr=X-R_Y!Fz9`7L2#6A?& zhd4;|sLDQIW1YO{mOnw2oS0M#wNj8*Ccm$cV8wZ zh7?CVc>MS=ykSA8q=X(IlK~NlS)+=f^s%0u)Yt%U^y;Q=_x zbIR&jWW+p~=9Y`lP#ktCQ6`+0DtL5O{(lPUb=P!Uh+Jvpf?nCaioy33&QGZ-)> z_^;OwWy8yJi2M3--^)&_pYl3coU<_gS*#|Yqkrq^1Fs13e$LPpd zB?>REfD|#a@2Ul_&_d5;ls#iZuH}8>+-zNAW8>|kBM_m{(*FL<$)nW?T?8NtR2x1c zbq0%2b1xKJZqZ^x-z z>J7=y=RjSmtN_iuhWYT!U(xr&?K(=FU7Dj*l{tkjwEYPh&;Ej41tXtIHGhT_7 ziGcpr60Qji2%vD^4i|-li=%b?&e3!?vs6uQT(ZgOil(n9Uw#^lQ130oO|v^jKj{Hz zUBvdf3mWOcH-&WbVF5#z5}(Bwa~m79eBjCD0uDIvbMHK*qo5ZA9<=Kt*dg-xc1^CaIO)+GSK4EE0 zd_Y&QAVUZlw1*n+wK236g{=0_U~wzq&k>j+QDKHPiUM~F3!xO6hX$L7=$*{Jn-un5 zgu(a&omC=*tDublhykzk&dxc z;{TfCV%wA6hnPj!Or|>1qKH*>#jh;?O&36X%z|O4g5Rxp);Bta=YHuKw=A3NzRfti zCu3^eb8VIAhIn~<_!x9+EQEec4h6=IUbl0K!-<$oF%X!Ik1~Be7(;OcYUUql4&Ofz zo8EwvoX60;j?eNFQ~lQsrtEQlzE>^~AOEYkwqNxAwch&NmRa;3z^CX`pc$0{T1FXPqk`S;%bh>`N; zrMJ)1_9oNUyh`1Vp7naWzj|4>eEY~Os?cnH@tem*E;UQAS9(VUy@cx=UdlHWkeRY{ z2VgO$hZL)?kGF7VW@Zi(Jg{hCX%T#T{8S%h^r>K}?9;9JYx%9?S@W_-=EX?Mf{;z& z_g0sRLysk_iIuo;xZjjMEHJ1lTkCoDmt;`o0L$ z!_{HoDHHM}0BgKLFEFqC-s=g;#h24p!^^X?{_yh4D$&%}o}L`lGJ2+%X&M@-0HoX% zMgGke<~N5BFkj#_2fiQ2EW%(i9hqc9q!zGNIn{AqG0=PiE6!7;li17;L244}aCiYS zY>Qawm@sA^7&#zEdiWMqFyETG=W9g}^Fi~pmv>9?2ClDm)d|1J=Le~#dPuxZmNCgV zSdB-GPv&8WS3PlQ<`w^lVRElFtYW&CnkNX4#7}ZOMXG#vl{|$|p(El+DS|h=aM2z& zMFDRz@Sd#5J*ac%MqOgnHr5F3|Sr_jCIOzSInvENIS&uM!y);>})P#h0AJ(7ma|C4oqKFqIin@#1o^ zShYVK#ereqAkV%VU>P(;)y6(vtiA`_ckkX+&lfbm{;PfL{Yv(QsHU!VfSgH6e&0Y! zyZN0|q<8%%at|+04UUyA`OgvCeninJm#8Bp;J^}f5=?&;hQOza$SaShA!P+1XcB8G z3;Q2sto$|RR;yFI+aKJ%_|LU(F0C%Tpf>TPjg&e=^N_>hJtXTYCb21TysJc){fa;O zXLaWe)4toA&kQy5@Z2p$sVx2~@;j->n`FE#R1olvC*k5_6)BhwL{7>p{vkTTcy3tA zB#%V|vHask4UM^P=dZhT^sb@gS+{OMxgn%$@nNE>VVUf&L-=!xuII~i4oPZ<~ z-{Zom%g#fO1nNfvaKG@Xlc|l`#4qFoW|_r%V%Gb|g4gFG z;cz$tX5OTQ@#d416FV~5(I`xPI(uSt#nZEHn858D+XI^171^WWti3U>6GFH^FAe42 zYX4w2l!YTg{TXe`Tx9)DXRZ&N%?iC6bwkSPzcs6g>2A=>Je>)Bo!BkG2vKXk)UJuV5^)}Aq(N9*pyT{l+s)I*tqYeYG3WP<>1BwUsP2zO0)o6MXBnj#9LnB}cU%u{lT|1BxvFiF$>`Q)<8a=)TF z*^F}g#c1hU3Fkt=RAk$dG9i!CKD`B>!xE!@$K3B@wo*1T{PAA}|cd0F#! zaVwYKFavOrFFH}vEjy;n8kWT6%4J2|EM3AXyN;ser?%9DP@~<$n-l_X76^5e@NjbC z3pAWv*8N}<3Az;ZWkHGSxnF*0%Sp=aytz_TsUG*_r8QpBwtAabLqo%H)l=fdEHMa)aIEAAs(W02s4AYoRaW<641^oyoU}NC9d~x($)tAHb>1vURA-aQ_Q%(Er7|fD=i?ihAR3?%_o6r|X7+?rjX1r46+;+HRjJ!KibKME>~Y-f~X z^>mi7h*B7`>Mer<{^)q(%496W6F2te^DAWQl4?nptgW#)fJg4`Qgya$?D1aRHPxEB zdDEvfd<0-dZ;9j8w>Es;cI3U^?9zg(O0wi?@4i%dT5zQ1y1|FYSghMH>2p+^PXvVR z9Swt!(l_e{T8k5>#ci`H2l7az1rAeq+ztHz!6m{=1(ivCsh@Tw_^HxBBvUxKf0@m( zKd=22=P?$h!Ig{^KKdx3^RKi9 zN2bR&pIx;;gFuYuyVr8cNlh?j140MIB=#;Jt#b1$U(v4?Dr>oW<~H|yG0`wnwfz0F zhoO`NdqRAND0~|8U1@z~@dxYkw+?#8)Y>~*e)nGCj#t%#7 z`W;E16W=5*9L@N3b~5{h*KaitZak{)=&>ClZ}JxBH~MrpwbS)#>99V>s~e=0SNF)$ z4Y55kTE?m=sJ2Ope>#w0Hz)Vt_%ETdv_{A|JA5xs5T8FJOtm*-P%1W`;9k!Tnaoxt6+phYXQICblJCxpc~QPVc0CKd8UE5D$Urt)pv*|QYG z-062%9q%LYUiat*PO=VM7mESokWMp-cJTzzr)EC9t9zNKy9|pL5sz@qi5ZafwI%s+ z-3PRm5oO5eK`AH1zy3Sgjn*Xp*39mHV-{E?ZAnu$iy)L7ZkxW=w|pSB%#Q-|?FL z3Lqr0{JFBo^AYg_Qaz*a1gEHqU+=n2qT1XAKl>>Kx58f1FuLgcQ2E zOm$&+J~XOjZIh)nppIStFJ~vIFq|%s_IPgJAz~6EGpMCm4ZC55=+v{!Z1^IW{#wsK zXsgX4%C;cUPvEZCO{n?4e9DPc*SAW_bRy#x$QEX3BQDEHU_?yHf&X247;qfy`BJly z8DElN&D=DI0DVhVF8P-)JIy2%(zr})9B+)86uQvV+t&TQe(zK$4Bo9sOn9vc39GAL zv}eP%M-?AGDk=@9Qr(f*)#00^{R3mb-R=t)@#hLt$f4J=LTq8!Nd?u2`bd6%riLyC zY!L0HNTJ(JMoo-^TjbR7@NP59r0T)GLQSEk3iZCucs*(F)=Jdwk`cLkzKccdz zIuNIEaIy~YF&N2(!jYKw*Hw!pzqtp+4i$p%J%7G15v+#j!OmDrxKS!XzZOpgKXt9O zlJ?Q*Q69LJ&@)YC`=4dxSf>Y_yN>XPpjmN82^;0yieT4N9E&r<)z|EjLG)|6+gLuG$5WQtdve(- zYQB9VO6ZUjMt+V*S!2z=9~0bDQ_F{ieqJ2kBJA)q(Qwf#**1Tk%_+dMY_dh?v~Yb> z_t#@x_sr*ZeM0MMGY{b8N_CAit?@%?O))&K_{Sxm#8<|0KVOrreMye*N{FUTgzERt zON?m7YMFIAN;6c=)&#GYkku9Rf9$@>Ci&|))_P`` zWo4`1k-1#b&6wL{{tDnZU%9@u<|SN*xEfwEak<=A5b27GOxoDh-sXRMPZx# zlCw~g{;iC@fxEFU2e(~_(X+5ZQ(fKNZC`d4${n|i0YGw!v9k8aQPi|vQGmd=RUsT+ z+}3PZpImWt9L1SMt>W`>NkE<;!68$GSO7`#IDRj| zaME~moYbeS13k%}v`I-tF`E_zt(h-~wMLGllX6h@qp?T5;Wy3!Q*-)_`4>4F z99t@Y3fxst=nq79hh`{PMn8R@{QqkV242Kr@uF+Gx?#pgk3Aa6=Vl7sQfuFg0&QF=9G7E?3IiZAVqXYZ4C6(3Zqj5 zSMJA;G?jV%GVt6X>XL90Q)c#@y>pO2dP>2osv$eyd*3d&_@ez%i;9dT52&Ihilh#< zWR0v3RC|@V$kPQ$DJx)_LH8ZzM_`!=yU=ki8qt#p*1f8=mHn{+SeR8_cEzahcX!ae zfbyqjzb@rHco#f5X$ap&RzkwGLU$Vz(e$U7i^-m*?e~y3X3-~apFhvTKZv~pJ)Rwa zeC&OT>|hsOXuD8QrhUL@_LOQBLEM9fwhN95lKCUT8FX@RKcARey9^H}Cpn;}4G*zM zZBFPkbzJ z<$^bzP$XK0u`dng>J}Q`~?h)|*GHUQ355ZZTVk@DPYPU7$^_EzmNvN*1cBeV?(Itk|Tt-u1R9Dl@y z{Yrp84&8))U{a!2s#K=0tE&}eWnaC08`0CVcTYv>4qS?+7SCTl)fh8}P?p-$&MA+D z>e217g;<#C4yi+WWIXv=)^yRB-o$JceUUOZb+1bzwJX+jG*}>gzN&6cW5{`Tx0JJp z6J7qHt4x(Mgaw@>yc5n-tITyu$Z|_7Oh57ln4~2m!ciR@S@EanM|BTSrCnjovT8Pe z1~Q*0+bt3t&|wS=i2$6MJ@yPr`V@_@{h6aXMsWU-%^{W6L$(dwW zJzLlxNyq$uR9$yCm;cxQpzN8lGWt+tla(!svPV|3GeTs`i0mRGJK0$wE7`J%GK!FF zGPC!5&aJ-Bb3MQ7`u_2|E}!oEe!tIro!2?%uwM^9KZYP7;qXAi-eNcFGtJuU3d807 zJ_aniSpfI)qIiBu?^vS3)#Y?T&LJnnfie^FDOA+)dF5~gJL@EM{gqU!&YuKDX8wmi z&+jOugqaz7Jx}pII(zbD${G50h^28C#IC%(oIU<>zyQVy;V+QwcYoLR#>XSNlGDq= zdS&cpPQ~;z4ENE;E9f4RysD!ZMnx?i1!r%uajy7a$qJEpY55@^kwzLPNib3PD1x+} zsR-|#w#?e=?hm;^H}2ME#qgTz!x$GT0eAko!{63OzRPF+)gLJt#x8qRJz#w*>=q&7 z$J#v7K(6n5va@*s)mLaZ)+(^S-PS9aLmnR(c47~VU~Oq>=^S#xMG)yza8ic+qDJiB zTmZ6`tHr8%J4n2VaT;$vyv3_^nFGwRe~R^0cmFWNPtGeI74e8&w;*=;b4*4exsQ7a zKz%EC@dCeQe;o!ODWU;!Y5Kh6(Eo|zi=CA9bbBNd;I~XLzwq|;7^$SRi;(BsxB<|3 zpdh0=HmxF9L@n*RvEgOU8PqKXzZtR4^h=t3Bhx1VJ0pQI$OJp{v9B;fGx`PyMPx6Z z5$~G8MYxoT%F2nl`R~PxK@7YH&v4vSv^b9(Rxf>gGz5^u`Ny^2wNWYc?{X!>=pp*0 z5w<@()6v_5;ow6~2JYH3} zW<4#er!kqvemhft>SPBgyq-XCzHMYFr7-j^YLfqId(Y3=#ftNnK3n zkXxYDe{xd0v-<%juPiyOyrFl=bL1ox%1H}-xuzM60<_|dEY5eGHm?GDoGy_v`r+cs zn&I>WJE-<6*(e_o4z&R9z@~-F$CmQZBIE!@%sx8cq5lFd2jILR`jdko5OI|)&htOd zrg(2%@$`)LY`jkn;ri;vG0=V$gMQJ~_6S>AmK~Qys;SWodh zd_mC&*@mjbar(h<2j>^Io@;y87S3b=ZYMjo3Z{id&??9Nr<}Jk-pEp_aO>r1n@zY-q4TRZ_jPum zrj~&0e_R06C6U)-!9SY0aY)iDDqwbsZg28PHKckiLqiF6JY_N?GO`WbnqTEar&PLR zDmft1i@AjAhI(f7dnB|UML9j`D-elId6PG7M`aOm5h^Q4Ktz~x%Z4L)xBNY~UU9;= zZ|WQ;zX6@iV$$~wY8X;K8m=&hoGu?WI-svUnd})wv6FM5PJb=EXu3 zQCPBURXs(z!B32-Y ziFi13J3~M8j9?l)i&htko0>NV$s$IL80@%--!eO^6SWDDGPCapQ?KyVFwiCQ$K7H( z9MI>L!?!fLHq~ao{0nLjSy*(KI1OLZ%*(y@cXw9}s%Z8flEA!{wc)}~=|!oa7}_Q# zZgY>iL9_rx2efD5MJGY=LZ1g%k3Q=9n+w4U-&)`Usfow$ED#si$m_m%GpPVJj^z>$ zo4KH(H04P=Z|nKrvn%2Ad=?PKHirEfhA&OxOXQd%6RFbjwri4=e@c;?z!XHn#KHok z5nYUxPw#~5b{p!Ri0q{sR=g${@&2?bu52IyDWO6k1l{&t~nKl_Hg=~QS8 zx6nqyC4D1r|AER=EE4=DHSb#>b~}` zxnTdBo{eI6+Z`ZCa&*k`YarWE1sNJNgu&RnwvBG^&>a9KJim%oGe_~HL0DvUv{P=? z-|<4Awdg=jqB6sEZ*On+NJzkS#OliB%V)M0`fXLaY}z>b-rcH7Fp_MB z8|Vfjt>&s78XjFs*Ai9wQjTK@(n7%su-M(j=t)L_Z5mc^?(Fu^^?+7FHAkt6z&G}k` zrHBSFb$p8u_lw^09U&lB2n=sUG;XjEyjVO8mb!$@CkO#%Bp&jv%a;O)ML8HuxAfd^ zX64rS20|+S2$6hIos zdc6Y9E2DJGQQ{|Sax3k|0t|K*LfI-uyjpuAoF=9P>&~GMnyR*Pa&l6;jLKF=et*5n z$_hy71(;?4@l>>kU%%?W_{3?h-_!igKj3N}&zBWbZd9R$-2D*!<1^fuv{+J~aUNzy z18N?EpF2u|UM5Zk*!C3Gd*7L7tADfYCcJRjhws^uNd=><##$bc24BgRhnm?8Ll}tO zRT5Bgtv&)COE}4o`%SVh$EV*;3u9*{_*bsqq0QO3-1XPF6G8Ge!kM`wOJ^_OA_P&` z4~$i2Fi+cAc2ZVd(44G9`1}nfQaD8FtE7^RPG0KrFsudGK0}d^Eq(Yo@Z!o6!#m-e0ZKOlP}*xXw(dxv46HgmTOf$`xg1p_a45cI{jQ z;4A;arZHQ07|@^i0q$B*Yz&R;EsYlA>Y&olxa)M@*>^-iGEO+fT-)d z$yQ-*Ri|!Tv2~mB0-+WbyLK}`d|^;-Js|#rsL|@<%~sSc$|{E150u{buk}s+>b}kD zm&WNY;&Tq6P0A4<&LP= z1Et4Bxf-*|S|_h%S6fQV`tb5!E7M9o!;)lx*ffcvxcIT7BgEc9xsIzmW@8NRZ|fMn z4Om`Ajb?@IidF-IdZb2wY}iVd2l@l0%Q@w{`ZkUf(FwF{dM=;MI@yy zod5y&KrmPMdEj@Ozg0Hi)tzoUCOZ(v6+t$=K&U_27Sd1lel=|RPxhyV7HJu;9w9^d zhm_iWKU+`)_1-9G@3%ykPhS&c1|YlVX0$fJhs~8ch#4wtEKk)RgPb#moT|91!5*qH zmfKsRE|YDz)>Davb+S*NN3m?CKoy=#jeWs>n(J$$rr6hr%_bN zVY&H>U>5ly7Kq8`4j>!4iMts1o$J5_@8aO-peHa3rS=P9Pob|(25@8HjBLA22~ssv zGR%-GGq`1pvtL=dQD%y(*r`s+AzOXzZa@Capu06E)9=Y^qN%#D$x6M2R z@S>{&^MZJEG$k067koZXpt!(NWR42HV%u{wfJ3e(J!H7{%|e>tt`4G&)vRX^ zal43SW^!G9<459sq`G_b9@bLxMe-dcdh7f7^?My-mKj0ZH;>yka>Kz5zNpo z9EI2wD~4dif_<-HVg@Ol|5Pn+B92wJ@(??~Zbw2g2+uTX>weTpmY##!9C#lp3g?t8 zQC=$3FBH3*jW1)?PGkf7da7T3Ro2}=fwCqsp{p&wEPST=XNI`L&cNPY03sj0ePY2&flPOIo0iv-u2`yz89VSHBa6G z>c4OXPW55JY&Vz_UqB(TyWevUisP#paKdANW*^{8KB`wl3b;=nHDP^8w?x}esRbNs zo}D3#mFxPNM*MnUQ62(8jW};Pfaj6S`nLQ~@MORv2{3jd7^?eSp9L|zCT3}nzbeQz zT)jvGxL=Zl*K3g*ly`q=H@q*jI2yJ&el1r2t~NiPA82mJy^C+AQqS$--BfluOVoop$`jmKl1|*^|_eu1-Xts2LEL@{X|{@0@=1NS#X~B zmhT)!*C9b?t8K?~a+Vx&r-svN?U1Uzg=F)&e7w2ATqa3bH*yKRs`D^%3zFmQWj(u% zI~9pMHrMBsEzOyL>T`F!RYHZua1*aOO2kjvjy3a-3-R4FDx*hOt5^+-Kkr6sbC?{t zBUN(K^>fp4<_Hr`rvO3^fA~CFIy#6liJXb(F`{^e51oOPIT_e8bm*+9rll1)3q1)c z?c~A^yF7LK>k8nqt|fk@5`i|kccPKbq(R)v@Ey&oIS*xP$MZ3zN(v17eq(XKQY{`j zse}T<;U8e!@}?!f{KZ0fPi@?b=yL}<%OFo@>5Zs^pt?)vhaFXW92}X^ZlHIz+WXhb z3u}=21Zu$Z+ZYZg0q4!taLR*uL#rDbpx88Qc~@Ir_&n2hPf;a8qyS2u`zRl6?t4FU_zsGd(4KfAx4h5`Gr^ z{+Vi3ff~nqLCH*Qr1^xx$Nl8;Nt`X3DdzqhS7tN3Vot;bK-qbpNzkx)VC@jpv z*Q#!LJrxjV_HR#jGcLi1C1oV3_q$tY$NrAnX)Li0X`5?(R{$;o26@A+oMm8uA%5me zN}eK(16P7Yuw;UW{Dw>p=xoq_9tI-kpN7&x9pi8c0lJgM5^C2zGYhV{d5v^L`b z%b~nUI-8>haDEeuiwa5gHkba|gdD!(Ye-^oW`v!Mt#OH(FCsA!X3M|rXV;#{po7kMHjK_~!+A!M z>0?6ZCgGQOL84o?ZuPNNX%AF$VG~h9;=%8bxr_NR>nQ@4y+=2ZjZ-Km-Iy^(-z0YS zhrq<2Gyr7|<(#jGXlMNPRzmKy)MAC(Gu*YYPgRxlNZl^0K>3EuxRFD8*Dga-Adwu5 z0LbBsUTruCDOz$AutSw*f>TgTzK%gXPu<5VUt#(s>9Iq}rxo!pB3dc#3@tMO@J+Mx z(;-idtt8YmiH7}24b|jOF4yWYQRrrOREHr^<~m>L#aEIO$OTt#Y#gWX+AUYZN0U>G z`oQra!Gq&tTIW_xD2AH!&Gq(CcerhjshFOQr>@e}(FtaaVqOcnq-F9kp7-~An8y|` z3&YaiElqBxk!g(E*k^X7s)lyd&bTx{)Tz0QqbddEEKSNP%IM$g(yp!X`odAMfkS-e zsWqWH^?i5!aKVSWWjOQ+wj7q$s6*|y~WqBp2CXhqJUz?3wU9lmb-3I zLwxa@yzT{C++S8t+ViW06qXNz!ha37>q!Ss^^tj(S+L5t2F7(mF}4 zIfk(QraykkX5{GZCI71!sfQ!c=Ee;UqWS6X2V?{ zhC#tV4QfxCo{qQC@dsViZjZN*u5v-UNiqAU<8=aAB8F%FQ7rHR-)rSa_#gd%#PH#G zU(DF5-fu{fl$viT3oJ}-OQTA2nclZ@!Co|a>^`?_JHMQSpe%in`UWbQ2fc;7Nd#uWMLjdYTm-9z>NAtudx-a3wF%TZuFc`gsp0UyofTF=Q%z@thwj2~ z3xrc(1Fv%W1~iBvMc6;jj&R1a#XaC3GWr`VL%E0@n|>6tLe8W+b) zgqzV!L>=0nr=^x1Q3=PkoG5YuG9)p1d3pG4AIvxXU^N2m`VYs0`0^f07}mxRF&nq$ z#OuoH|9X0^ezA{iz+^=dihQdVo&A4Q1ro~n_=ZflrHPsRgKx4bw{@`$?9ON(Y8})wi!jGyZY#)u( zJS4~7V(@vS_YnrKblM0`6}O$-0t*@Of1&Q|`1LbY0;yf8*EDdA%e&)-x4G7vo(X$%KE&?RG0lYwR^GeuPmaaRe+d!sEhh%TgwO@r%$HTp{OP79(1U~h zgV`&1(qT(NY7+_K7MiCNDev>7%wgoE4zI|1iff4ibMwtcK-C%kd$9k&GXhm{>ga>kk$PXUr@t zV0hzkz=X_Hk;RVf0&O09J9j;@Q$wQG-58MOKH&~Wd*>D`WRyY!|Ko)YyWOorSn!}k z__CrcCuDd7HF#vFQ_Wzcxkrhb5n>~L8IQ?Q>i$U=QbFYA@?~Kd!`jd{75nbyJC@|b z=St1L)OXQ|su~z5CuQ*?L!g%PQ!Xy(Y&jE$WhhrX)L5yegr--B16SL6GZ0pUB8?=* zCt9)FNsixvgtz*6vSkidBvE`x4EOciQhqs@!K#s6-`x%M5MA8>jJ=mYDr8xBD$qVb|&NIgW!R$NQBI0U~Xx3;lUP z9$AWjuF~utf02JX3xgnZlJ3OwFuDh-QI<1c^TYwF>ck820h!Mo*tHpCnQPtt7`WJ> zge`Tfw{Iih1<6MPR8&uAgJf%bS#(Uy#AHCob&qXAq(k~mwEy(m2bY7d_S$~-~i92h>r+J`C)P;M@`LY6)==IPfIL=11=(S6|E3jfJMQ4jk3@a>x>RV6t> zMHb&7cD89}jOpo~gUxVBj2*@0_2y40lO#9KM*|UVMO{<>mjoe5SReKI%Rbba2W@|n z!OuzuL zl4fRJ(LAX_WoE{B*Uik%9tpU?vu_zZm2hx$h=2_#Ff4oefdv!Olo;0o^U2AUkO=jH zm4|E#{fUsAkdexZN$j#fZyv(VX9^;km3x8rMp>yv)UGzbb?oWIM?XK_NU8JctyVGg z7s6%v!v@Rn>S{fTA9ZTk>@ABLd{NG~F1Mwk$?n>r+dlbAE_4qcuM%}PmO@ZbwiJBy z)6}pWt`Wi_*eJ6W620zqcT_Pa|7xKQAS};;O`5G-T(xy4q_@UEs6x~Ys3Td?hlie< zOFv*#-MmhxRsrd~Pk^wLmIRI#sC8d~uAz6Y`5(QoCufGeBu_puUtpav%q}r_GH(WM z4t5H3S@yrXaIfsgvBw=XH8Nrbkx;}lHu&Kb?PRC86~-hzhP}fZ+;z~Iz#kFqr8Cj; zM`PT?KD#UR@!5K&J~Dx{JPfUvY*Z++Ln=KJ!l6~DC3h%Quv1of`ww62N&}(i-4<$+ z3xk;(G^$&D8-66>{z|Y=T3kFyBmm>Kyu7r!`ngyytv$;~sDFW#BBiE&9^8K0KOm@~ zyPKr0u5NX0E1<<;s{9g|-1h4!&lD)RCl2=rFDHuxXk;s+udq_%;o)6n|K9SQE0AyF zc<9hL*cahlsdQ;BKo4_;6rCD*)kSbg?{Bj*V5k0WD%V7!C2qz3 zfaSzq^kY&aH0BkZfTo-VnLt3E+4B~cf|w=ffL1dBof#I%-o!g=uPm!UBLciUc1*@b zYqgISVQdmiL4NG$8ua<{_~erBp)cm$=r|^BZW0LNpk7yEFHo(^OJw@EI~qV1MJ(W6 zWC@`v15MIj>6)NY&IPFPD`fiJQ(O7gR-*RygBq?-i zx8a?NIXTR}m%SKYHy!)HSfk?{f`{VMnDn*$xq!;TLp$vR-j72v?VB_7`RWT4oCyK9 zhVsO^f#j|-`vXZ;sIVT-4pu{nKPEwm;sXg4l-Mh04L!+@F#zxhr5^pFTiVMg7z=LL z=+R_QM?o0|$`!b=Hasv}s)G^TjIj{>vu6fHfbu5=ezwd=TA3teRh}hq+FdeU-<#!W#Y@Ka<)G>3%lv%`eJP%>Pg0V90+G=Po@C`B!q2<@KqEJA!_7pC496{yR)9?eb$+f zzbnHT_R_aDe~tnwI^SjBK-H}M&_hOAvwrD77^}=2st)!Z5T#mD4#KvbjtuGGPNbH- z_%~g~0(>%WYTECqu`86pCTBxeA`nJ8@|4dYzcQ13HJM%Qz^^0IEQi)XCGo>KCdMk8;mYPQr^3P}YAz@r1CWA0LeGIZ zQC$L%i zAKXVb8!rSWW;XxLegz==z4fH*G}CU)pFGB)_kr2^h`FNJs)Ff805yGyNRxTOKw(O= zvASCC!NB2*Oz^<}F6FSTM%z9p4L5y*ENWt5{6^A^2@W5NT&DaR&%pv|qjGj4mc7-U zq#gWhCP8S4Ur5u_Gk6{PEFqwL1}SC^dHd1g^R`~dsR2@rzU*Ir5zIC=5e=Lb5uo+? zx8t)xA-6H0L*NgJ+?fw?FUsA@>`-GxzEE zY-xa9sJfDm-gOwkk=b?0LNJlnZUXZRHa23N-z1b;q`8hk$f3yO(N&=7U|&s&$&_J1 zv-M+!Q#!a1-v3s@X`Ud6(g9j|*?S1m!zG2|kSH)d5VGu0m@W>gN906E1KoN;HMOK(o}vYc8et znp-E6DjkilzMwJ3$PuDQD47$VcOIx!i( zcQd0AiZ&CVP0)eGd)?$35Uo@#RBvOiLN93`DX zsafz*E8#Bz7dS!FDr7zz38i;v5j60e&q}MEU;JB+fyJ&oBEO$z950p1;ugn3(G8vf z9=;f>J`SwM6aq}NzHfu1;g9QVI#hDu*OA1nwG=h7$xoMc32DWUIR*O2Uhf0QnV<>< zo62OKWDe=1d_W3!f3UD=ob5d@P-b>|sxCg7X2d?UP=nIsa!M(HeD?}Y8?ev$NW?ZT z!Ey}+P*oE_-5!xt;Cpe!e>Jb+bLAo_rmB7>8Kj+5?F3;e&2YqKaY@K9j+`PGiuG5d zi~%Cj?D8aVF4U;x^yQv=Z?*XXD66recV5lgGhUQ~k+;Jg*sEGca!*Q0H)T&&?I9_>9#MRjbx00rHBuA;2`d5(f(^2W=06cVV9K&(hpN}UW3T`}SC~iu zZ}m9%dE_3$l0;yt%Eet)FS=8Ty-w0U^pMl zRIf{85^kl6%;d@L`wtHnpR>48wG-yv@Wb8(UeIenSg6ow^T6wyTsybsN;01|4hi8k zKuatFNWqBOH1L3>YrcXC2j)HJw>)a-^8~+cx^dBq%UK@ngZGqGo=vW=BNU)4C$Zw`&53(W zi^&r%(x^qqcbaz8o@(!#cgxZ{Z%L55`LgWq?>}D3{YeVIE+AKe7LR||{vpWEMH~{x z^$h0l0bADfL zDrocp)I)=+l+D%EmE-=V1iiZ(@hN?{Gz}4r+XhHK&PR+R2pPo*>cRSbG_L^X=Z)2# z8rJjbPCPH0>`1_Jb$0>Mv&gGX&Z5*9#NbV+Ewn{XZ80#wRU`>y^7|wA5eFr9IGAxL zo5G5|#)gk{Roi`ng)X7>fzv9$RbMu-Z;NP$K=p3&dFlJ9VMPKmh*2Cuma|!MHfq3A zps(u`+e8CsIythpu{!cRCuC7`n?R;Rz@ug znuj@+H!^3En@2adKsTu@f3^IP#M<={@%2SR*j9I@$PdD{Q=8jI2B|qC$L7{9KYR z;ogR-un+8bkXi2IT|(tqr5zBWGW$61y@N*dXa8_`gkci@8#>g4jK#DH^g`ONN_d&x zH8;LteS*LR)3dWdEedht3A4$5NW?;HD%6*Liy&YB6McDjr607}|BNff%N1?|j!f5h z$S@@ynpj;VTN!-j?iq_NX&f5>95^{Sus z^a%EHm;pTSihupcRGV@CPXWtsFrj2Y>x{X%2o$2bhrbk7MPzPMD#kMwZ?K0!re zJlOIl92ZSki~DmG36to3Z~!*S+LYdjR*BSeNI)W_{H$9c6gtK37m~e>g)6G7HJ~YZ z9IEenHNzSfMQp%fahsbE)+v2Tcj}m$2)l-csKH@VEehI7K{lJ?7*BUSDlruTGmMF; zPgUVi?nYCtiM-&$CCJ)Qci-ziSBcM_ejQ`9a~qf< z84xAo8@cz>+sRB}{5VM@N`-D{g;UX7(m-YvkG~Nfth1(Eu5ct&WImosIMS%DKL#QU z4M>vLV!^KMqF`vXa}~rHx#jX>>4mVYo^Kle-x@DH7&ItZz6R>AZ$8gVlt&$ z)L$mtb6cvG#(i2!KyP$(3pWyXToZz3XS^_(2#+%`O5o^3dIyMaFqS}&Qd=9Dy6|!3 z=X2^(^TvLqT8)yW-)Af3E)6ExrF?L8u3emj4{dr^X6lyQF61bobV9_{Pj|__ zTdd>IFD(dO_D+ZT0q|1-Wq-*)!KmR~?Fb=?TVwYk`etObgP_k#P|p{DB>yn>lpvJ`haoh4KT(-qPP~E-cF96BqJ6LI+H!`|zuC z;C^L8X`UB|x{e3L8A>1V&`T99@IrZ8>;W!pdkL!CnB7=?p&#de^YC+}JeMtrleH0~ z-z_L}b-BTd63?8SNigR~q=u?H#s89qw36@L{@2XlHIB-VVERf`N*);*NhM-KB|GNM z4sa_diSpW%pVeb0r{;LkmF7p~V_{p1KPLQkM%#dEnp+xA=kWM(eXtr6@7Cx6>FA|5 zB0=+~9uUc6nikHSk!|^M4N3+B{-pASLhp#^jr0RZ8I|PsFxgy9So5GZwryt9SXpx! z?%`F0PC^k1kU^t9JC{&iMi4TS_MS8<36nFBC#cWzBsn^+Hgk>wJ1hP|!dLTVqKkoP z_V$CtUN{uBJM_f#*QJtLmV7`Km#G|Cn8*02k1-C@aPsQ@c9S4v@Pb1R>Md32YcO2- zv`zl~pGT-E6n`3VZ$}Ej2<4#&4zQ})-T^c+D)8V8ganHAU`OYMKQX|%g>YnboXzhB z;nFb3DI07i8T#*D_LnLgt?kKk<)#((r0SF~41^J(rv%%VLppbz_>n9D04g6Oz##sf zru11MM*kN*r7-N$?m+TC5Qxc?7w>Q2s&KfmCuVgwjb@hDE<*Qjw(O!c^W+pA`Zl1+9)aO)^ z(z{G(;D)45Ft8pEjF|R*)E5QCF+FYCc=NivXosYFIA>)_ivaxt!V7k|o*+__xM&Az zSe(jvkA}vcB<%h#zw{DV7m>%t*4g&+ZTMr03^|A-OhwEE6Un|8p^>F}%1NRb8rfZ* zUc%qsgezQX6n}Ev>M{%%p^~)6Y*nHPKRVc+0Cc_~_Ujm0(y$<aLe)Rmuqk9?7iGq(^wYQZ?%(L(9Z~3e4psrUFqtEG2oE>f3jj_;es6a3FK|s! z`jh5@^#US^&?W!&2z80G!21q~y=yR6$7n1;Z&llqGj@jBbifGCdp$Kog7nez>+i9Njc@iH?r`<^V( zfa3Ld*Q1a(3K`Tn<_pl(SJRdF*E1b^EKWAl?0r)I^Mh**ZEZMDkB?VL zR^@z7PKsT}*3|BLoUiXzVnT~c;dA<{p1GkquQOkZdsXY*ZMpLoV_CE~f;Snp&XCOq zU%Ik68uHD*KJS9l$|{>3l~>q#)c)kl{bswR&{l_IrJz) zL&Mzzoce)oN&|zcTC6utFY*d=F30GzX8>G2_p{*Q`CAQ@rL#rWv&u_QodHbq8G7uJ zX!^2d!MzJGy5>eu7~NWXMtI|Jo1V+%60&m1Hv<`5&$^pRUny-j9WnajM$$FXJQla6 zzWSmB*XRAtx?*ST^LaVhOYD;RF6X1HCkF7k`Q2q)2-$*wiB%(E_F{?Zfr(2a@iP61 z4fXQUQmA|k8wP6W58e3kGBE__a2NF>8RI9_m6b8!ndO0 z`c2uJ<52Mm4h@!S?)N=mpt}(8<_+x-S!aP&|GOMIs^HYrOMQ6n{OapPaP!IG|5eq{ zCFQs77X=K`)!uUjqelWPm1#Zrw(50~IUAcQU zto0Gx@AMlwwy6TGvC^KT;Nak#=vQ_l(m>CdoSKS^i14Xc9EgmM53X3WzpY#s@S%?% zS-#52NyN&^`r6#y!GUc~=CGQf_xpDO%{A<$~bSWTd=VVh5GgD zm|DmF(yfm-pZV@(XI&E83thU(JgZkpVPIW3ND(q@mzr83eg|aF)X`gAaZViNhq@a_ zcawctSz=CW_N+cd6+`&gGr@k3qSM2;jaBaY>Pq?xVd_sBaZT(fDpl`vPU1q85nGV< zOp{Ba!#U}sApm%Yk=>)iQyOQAzru-_#gfd zP38GqVW9_YJeBg>TyxrXp-WWPs8FF7q^ZnsinVBsG=E)3RhGzDvbLAv-P?7IrIR7o zV|>~(qxAuV85F7Zy~RxFbzT9vWMo|YAm~I-C||ckb$XoidBUn7*^{UmCQ<*gDO4xF z#2HdnQUBOqNj1F%T?8ejmXkL2Ze0i$0o82BFz~CQiel zHA)C3H4*nwDsi;TP3`gW4TGGiGwjCc_X^rrH>I0l!EaD)L^3aZ5P;#&Unvkf$72;j7bp=HkdkUR+LA%{n; z5TMfFZa#ngOfcdb*dCUK_gBKnA}f>R2MdNLw^+Bs@weEJ0{$MgIR2%>;B$^%(r z*JugK1*dmMp#>D>-dz4Y&IJ)1@TWI%$*_!GFVWsxq!W`b4_JnG`26q0MLse+B};@1 z>syT%-}8IW7J;U%&n}UfyMUwfbDySO7>;`S_uTX48#0Jd{=A!ntMb2}GdkZaUg5b| zu|)NHPTM;lo~d$P`T?ARHazn-ZX}5j2kMp!1!q~_s2)7kTKcbzzIih%#gq~7hsX%_HPnB7BJuMP+JfMtnCsx4sI!}Q1hzHf=RgRrd`OZ%yW z#PpPpQh|$TN3E7Dk-CF8KB>XJ1d_?w-)oUId0ZXztLmyETVy*HJx+Ah|7`@DJDRf zgYFT4K&7;x!a$%ieWiDdL2J@edyYW6|Eg_E*X8Aw%tR4R+d?46LNPXg}nH z0@>#N7-2dvd@>L%podNf#VZ7SL8LP<3^T z8a7dX&IwfK;V5>*RZ#T>H~t@XX&%$+Z%h82PRAu)cI@hOtNJlF&yO?8j&2L1QDoJO3G;#Pbf1&f1!_ zb%Jc1ZTT|=(ma9fBV-m-q)BV^c}o(-i&)y846=Lsu90AAy=A#>S#=Tpv)1jHSIk>3 zU0%>eY;D?)sGP}jm*C*-@X))=s^^G{E`!Tx!2US0bm1`lDZxk~N9;cX$n~W^-tZ6~Ei?PyMWIl8t@rgQ%Y|}(D#P9W zCO|8HCN^HYUSEQe4pso!1bGue1&D#)z3UWB7cj|$fG2FCWa3@in zuRICI-@!HEZ(90mVdx_&uJzBx4b@!K9UwbbrVdx&l(TjDU1eVc$Um!e@cAKhFcwD$ z+Q0n_&mGX1t$fi}|L*Or!e6BUOZ6G*8EGI8xFm3AGaP&##YAzfqF-!4wNupG#P%IMMi*mKyH)6yF0Cf`9_GYjfbPxML7 znb1-JH^+X}DFw&x6}Kq7?%x=_Uzr5l1NL zBpd#Iky9Qxopf6mDI!}K_3sA1T%G9fQXJj%FSh z&QmdV585uBdPY8-i-LyPEr06f?jGoZ+1i!CEtYk`WebJhT=5?y9@mDGY5u2NCFUR< zt%i4S5SKP)l*nP*jm@Lxf~yWz&dlS2)N8mmEAk$4fssW-;PKDr|GV@^+nI}w#auSv z@?TrdZCqN;+m1jy6s5F(C-IGi&4Kg+z8C3=*sC$%5nd?P`V6r!-*|TH!t#gw@Em=f z09YPI#uBu5l4wu>0mXTd(ezsigw%q4htGn8ovf^ypcro}{WWFoO zASH`@36s3*e=B*P7g?SgzltUzi7Lb^(b)dS7GN*uz$Ha^ad1o0@>0Mm+R7 zMktn3h1fio_+nU(S(-=KvwxE3UMgXO2BQID^QOF%TjZ<+gdc=?0i^qSYH55RK^W*} z3Dmw`qC2HHB)!2T{~ea{{R;at7E#-XBuX@Jr@g9$OZ^>7Yy=MF?lt0~{I*4uEx6n( zwG>rYMt{{Rl6d;!hLhf}(fWfM5E}-=>3UxE4}L}GD-UWPLn?h4(@hUN`4FBcVU8q* zbdU!@v)R8m?lh*#!F0KG6xf^(zDu#o;QILU{ZnHl=95q&qdE`vR-XdvI#}&kOmP~( zc7(trtywgy-2Qy$)jyH#n9y>;1Y6Gq1{Lm%v@XUPbleu-r&u~zZ0JUg6PN@>XIL%qGw|0adfm~ zaqS7uvKKpbe(fa}_YFieiyTk%pAUav#x)}L9zvj}Tdjj8_8!-K1|9^ZmVlQLQ92uO z3DKDQb9Ew4T>so!{>?gG63|bGqMo}Oe0@|_aiovF`3BBd5H1Ie_Fv9!am61AIL05D z;$!^pAy-`F3V%GZKY&q^NY-)L=PaDh5U6EsEao8lzw;@3Q;8Mu9~VIK-}7#V=?&ox zQtXu?8v54UTaUw52$PBlLZZSC2JjDL`jf(bjuB@s?t9@?Oydi4=zmA^pFmMOlG$Y( zp(uCF-Kc^4pnc*Ioupgd|Af8&XEP`Umh_h>KQl94ian|Cg)(U#c$o ztiaX0u#cegVFOe-G5x9@hErtHE%tJP5fc3|4$&8%AHCFhf#b;#^iMDE#JzcgjQxLL zFEt{tAn4TM zRC>iJDtZw;>}u4#JaYZX`ei@}Ff)2}=ImkMyk&366%m((;N)8Oj1p_JsQV|MkYP$h zivtrq4KmkH1`OLfJJ~m>L`6k`Swk^B{|hf%{P;R8ZO2L8n_kOihD1T*{*IXI?$cKt z9FFzXmtf?<$3H(OKW8UP=lv+?^rB;Av_3Ufgt~~alFMY9J7*cy@@K=Jl!M_1#?eTW zSrhE)Cao8R2xneuNj4u+6mY_!kB-kSJQ0Wk1JM3J!tKQCaI+~qAk2o7?lmXttq$( z=`bxUW*ziTK84XrgeLISH3CA^aro=~NR{zuNd;ff>Flrb`s6_*mitJGdxWB@uu3cujQCsj82vtN_7Q{m(#OZ1HM}!r>Li7z z@%rPVxv^~S#-_%OHx{jwhUp4HVLICz-DdO#7*bYNY!WFFbhTBkaypil2`S#?`Ck+j zy*8A@M0e*Mhze2lqYZWUyG5bDZ+~ZAV`1U~idMvp^ypx-r6q*2(^(0w!bo+!Si~s| zbP;#Eie%?t=z;ygdE&xNKP(P9GU*3c@s~t{V*N?o(vQyo$iM`rz%)Y^L9r){m{uTu2mM(vt@Uuo~^;u;WGp|BUg^h>7l0k&w&;vO_Q zQXNif=u}Q>UzOimynbJQq~q|-Y7tM(bMNtA+xKWQQ=ABiuY(eI>|c_VJ3SYXoPG=aWx^TD|=<*;{c zcE4^n_6dz~@Yew7>}vVZx;rF{~<5QqKKTF>IRB8wV?Lvt0VC zhY^&kzX-TfEeZ`>Z5{7M#l{9kHU*-Ym@*WpL4Z|aw_ice-@q!R*2bMBR~S^dbzi|c4{^}MBD9mp+tlCC&#g|P&iRrt;z&DyDL zu8@^HGiYG%!R1q2A`|hjxLH4i8Dox6Dlto!AQiaOTN5Fx(o^MTa%jePJOFt1Pi_4E z(gF)mmk%8m1s|{`nNki=(>2<3B<*Vo$Esc-3?|c`-67d_;Q|bA=3#Bzg9fA(#sisQT{I zE)r@R%}0)fLn!>f+jgIJxMB-- z&w>ToO`5o%S~O(<_N~}y@0!zMul| zN{}q_3W0(|Kos7Bx1?URL_@~-QtGfX(XC4Cq)!YVAvv2L&_+3l+uL63i9+Hziu@en z{NYxWH$KKqL6iqn9v&W^*SthLJw5MsGl<{c$K3RRX)I6M33iR=RVvEwM5#-XprEk- zr2iST^-@4-PjzxbLqmgiqm%mGEC9c28{D01JxwHWA}amwG&>Iker-4QpyLl0@`_5? z_Xnhq2kfr3ftS+|_nr>kU*G`CprQIpLK_Tk-a5sUjdqN+TF_cPZU62uPQ7gK*&z7ext!zVxNLyW`u}ncrI9tTp2w zGQ96O?>Rf3=h=IY(L#uzstt)FU`IGO!+x=vR?pi_@*x&{;%8UU+d-#r zeivi^iSm_Tjlls~JNyT!s00^r`qFEEv8jiH;|TfFEJTM!_rBLwfykwz@J#l#iks*B zRxX%89@Hfrp(plhYtQWpzlvCL{68u=I=bwRj*dCX8M0}UU>tvlua3RL&x@dtH25vF zY5Ae2Tw|42tlraypv37`tN-+No!mPojCnnz2}22Mwu=&cLlL`{y$3 zk%Mh+P~G`MoXxnI6f?Oiv4$`uB#6JR@z&YJjkQE^plEh#%J+_1US6`i;$t2eI3~Bh z{i8H#TniAI8-@YzbwF^TaHV7#LL73zFF){NEItuF=zC5MIY%+6$=`=t*SSu+Cg&%Z zKqc?JDq?gFVdba$8Qw3k!%?wZMi>K@#u zT6?q;?bnTstvjQ;*v{6@?~elmrv|Uq(e&}L06u*mz@(Gy<^dy{!#m91Q@|AOJQ+=X zqOL$>^FaCIDjpSDznVIKd4#<1xMs3rTf!Jv7mLVWYpMEj&gv@YFbGQGbFjp>p6lr` za_XaeH)q=?H-5-JQc#eAcn}(CMaIMsWUQyOdll&@Gs#i|IDLk|=i#}qjv%DMV43i&b4+taQ6Xe;z9U>hFYPM;h8la!wp82Een z$xkgvBX&s{&(RVl99sRlk0>_|xV8zI(hdinwI*Pd6`c-ghxE7vBDy=S`*lZ+8ySaA z2_o+D%>I^GMGjs}f(cgkvGivr*&p7R*w>J))rHHwgp5W55lSgJ)k-Qk)2K~k5la3o z=hjb6gdC#(S6hbPkl#wV;?oW+>0^kNMS47+z}B$j+rnw96*9VB#v8(@Ga%BR@zBTr zP1oo1d8)Z*oXE&PQHYyOh4_%191bOp%*^8TJ$(xD^3TOqCMI+$xx51d?-+aiCV&-@ zz2WWzV#bR`aw(GG{qGQbj;q&wh0<>Ql%!gEnj>F_+6KRxP&m zDm90bFB$|dksEsg5a3vce@ziKZky-|sfOG!_0$L_a1ZHSE~`mSY8o&lp4;)r`KaE6 zk3eGc%gq{AS6A0OMkV>~zE`Z$(lO?f(Ki6D>oYsGIzK`qdA)`7;S69ZVV5MY<2&O3 zcxoyX87_d$rraIdD5S(8c3QRNuiJ11HhHsI^@pT-aT|A_wFG}ny&HuH$p|X9UWA!~ zBCa?3W4RC`QWu;CSiUaUod|r*>mp}czk(sqCNMz$3)x8h1;hVhdO2skEJq4lk0Rxm zew2(4dbNzNu4cQ6^7B)dmX^lrrv$ddT&BkF^>6Dov2jb4WBo!r2M}uYWjq{(WK76> zAJTk7z)#PKX`-;zpmKaia`Pe4arBj&5U6VO+`$E^9D_wk3qzUcd#~)P^|-i-&6((X zNJu$1dcVV(KCa42y1@TJmKe=t(gM`LR4o#2gNLvjK3#q%XM_-ly9T z37b|6K1lFpgwKM@+#rGbdl5>D^y*&Beag>ge^F_wR@7JHviT~G(dzZ<0MNANB@dyU z^s9NvDU@5`s~UpR3HMbM9w_zL7jwaCZk+ja2R7*k9t~bY03sdOmw%Fh6biWe5tr+* zWCCx=mc3@_<>~1O?)tF!0Z^EMLE)U6n=4*pc$moq&r@A~CJG9*;Z=>8zfAhYC_B3? z>D|;cTxjJ_o^GDP61igSEkiE(8UGUpEJKGutfPE|2mIJnK>^8A98j`V$h4TQD9 zs3%=m6R$+Ed>?0C^K%uI63ah>wkFIh>FK%(`xlxgDse~%ql4VshR^<`CBikICGzV` zgqmj&Vq(Hfo?l;|z9N0RH5oDev-9)Y z=figGVtKRmM-z6}M<>F4fg3SW4b`L+mIIjZznK2_U8aUA^kG74la05GQv%U7ZAYR) z5CNxtl|F_RML70%&QeiPG4I^M+qZA4oW~{O^vo7hye8%%H+GxC>G_{*?-eE>*P0fK zStU}KB*`h_zP;-obh{yPFK?Fc|&*3oR zIMtkz^n>jB!yRbh)fCOH!M*(ldFNvW6qLN_sVZHl#)&_oWA$Yn9S6jvo;;lz-Itbj zl5dy)I+H(&gVrkl2YJlU;E#EMdzl_Xgq)ZOBaS_cZDJW8Hrl(43gNIDVz;SIIkkvl z?Q$CQ@qt-TyM+53IRP2H>iE#n((>|pCmU1a*RKz^kC@O_lI@mQ&|s@Ic- zcsDjTZNQ=y9Bpfuk8BZAWu`hdc6L&bRla!#v*k|~9Y6^2Bsx=TbLP{=!Q!WrR<^uZ zR#p}s+kWf4{lm%5W=5Gm@%-bH&(Fb#yGKu-e%GHp`8MC3{UI*$JmO79$BcLo8_OLP zj2}M79T;{QkbcVyZz~oMoqwJ4pQ;#FWl)o7Qwoy- zKzq?mE|nUu3(AP!6z3#m*Z5c~%ps=Wm47iX@MmKVUFpJxxh@To zRyB1Cm+IRhLLv0wAhTlroL+nJ0TO-r0yFo6>>GhNmgWE%nV~1WoBZQ+BPA{}C@`NQ zFe2nRgB}U}JtEM(`DVvzq}iF=HSn@qP^~>4sgW5`)O;~Nk2-F@N1+@?kE%b)jH6YprKnoLYgu??C zu-Rk+mIwHgY$xSLeck12P@iErT%|nobXVZ*d$q-0qt@NgwCA&JpqAvoYyxD~nQ5l+ zJDxB5ci(lEt^|o9_k6%t0r>g}As^%(S#WaW%q{P-M2Dr{iooy3V>s2iZ|}jUu6KYV z=WFdp?eA^jVC~k(zwlj-WV1(}>O<%$`Rm9Pnz>8r=~<>thFuZ#udDNj(_>#4;;`Q>)Vc8d1;=|IhEW3?qZa823Y=o4P&w5>lL6(Y zMKNypwMPv)+Kcr+&sZ0ePvk9KGp=^Nz#QF_k5B;@6FhPO>m*nFABorUnN;so7nCA+ z??B7ZH|-X6$^F|nb+j+sut;(i(uSIU;M1yqveQ4H@3@Zlv)B?F}pF~ARI_MTwL=WHTy=<`(p zvi&B}|6b##4A9E;Acv?S?X}Ji0Fc@Q)?tq*22;}uaM+!EBx3jjgCx5ok?3Eq37l4l zosea@CDdmLd?FGtw6kg-rIq}S`}xKhPsto3Uvfc2EiP=pkpXvYFaHb9w=4jR3v0H* zFGiwbQaE_ft>c;Qh*z>%%YT9ct)xF1=spV@x8gBccr!sD zjhUtE;j`+(4)Ib-!mL$$!IrjkY7IFu>rvU+Ai7dy`?DPG%D@G<$nG4rbRw8Uq8X9~;v)E&OLH6*92kyvABdj2V=#9YV;Ej$z=tRL$ z0d1=Ni?UCjF8}%OYtYFRod&h^0Os{6(fh&a!K4A(5_nx?pC36A(xSy|wA18u(0t=J z{KDP1EH{t;h?Tdu*d-Veia>rPMM-wxB&pk;1;1lR4*xzSy33BY7j zB2o>7IqmhY{ma%4R7n(h76XR*I^gG=h1-wieMHkSHsPtNA-zFluEN<5ci}_c5kW12 z3WMq2Z}BMp)0Q=T=rJymx6kLJ0+ku=Vdl)=wFa0UNeDHtL|u~f#oto}hcl1r5VQY} z#A#Wgx&@R@C188_I*Vh>L+q9jcx&~Lt8>A#Xv7K0fz(sChsr!^A8cp-e-`Ab9f##r z7L|4!jSCxg<>{9n6FVAyE|IhI zl!Tg}Gh^{^@+E5A5$XY`mmCnj|S399V$a4&)WXMQ8Rv7SY8;TJp$DAAb)7orQCC9UdA~ z|Lyoy2&~5;SH@kP2dTir!;{1x6Gh&JH;g?Qafp(EN@zSf!Us6!3MWGG4EvY*HFh1? zzr1(+6ZzjpD7WWV1A|-nkf;>c@-8^ZkL)A^aq2VBM&NLm3kh5z1j=z9`vbE%8f-Zo zr$#SdUJ!G9{U~tZrC>qdYbHYQCO7)&*I6N3!QpJtOY9j=Se(s4K1D>qDLsZ5;kOB~ zw=pO*RG!U=<1`Y93WM$cZZmIO{}y{cQ~5*O;VIk{6=hcg%RVelBPk7*@t_%wDjaae zzx+cyZ8KQc801K%Q8MCrkGu0EE)izgIQAj0dSK*`Dy&xL%g&dNC zoUY#>Jsjk3|0x&>hJ*IVAqc~7@III{{3;%&jH~XhAClyar9g!4TW7 z760%8PH=Sp-;K{1F5BcQ1w0!3LDgvA&5$7tI682TE5YRRvNL}-M99OPy7cc)2NECh zO|XHDs!3H^1D^5sUFvgU&C;(L@n~APsULuydtqZ^Emdsbu_6jPXxEomN=eyb7Bon0hAo$A&n52H&g&$(K{jchSGrehT` zqYYo%Ae>ESfi^yn2wF4v)YOP9Eu9<>C_oceUS3{eu}a(NpVszMDR!X54OU4tILW_~oH~%B4OoeB_Z;*P!2Or)s`?Zb((?md-CTZLW%PRHKA0J&!OuX~>k(6|nwb%-3q1|C{yGHhZ{1`N^hmlWEc~|3%r(6d&1C&O8_*XK(G2Gjd z`RGqIj_6h^b9y-Hnt?mf35XGhQ_N#IHR?a8?K8nh9h|QA0$@fOI!7vyMTjJPVWPEU ze8`%jUY7CbFN~+0FeD`=DtgV=(aAYGGZWX2C$P%(oQ=&oFkX^Ro)AOzqk)wbu#8H3 z2M6h|3gdIs@&%6YkNAHXaRM+W0fBK^ow*w7FTCNF%}T&T$a*$ZBYjw4MWG_iRvRqAz(WxE!1#z;(e*c9L4!hP#taeoH_-3Sc*Eil*& z=Th6<9=4jU(MbLSO#TILatI_?uHle?{`1GjRUlw9c-`}4fG3Aej+Cj%PD#Q?5#}#L z#lqO|K)J~kpiF_G6tL{~MvgdB;zk`_eXb{^rHE=4#r2|>8JIjSUKKQdru$B6hLWB>6twIZyThF&%o0kZa`l;*r+)b> zdOd)Xx`KR@h~&jV)_D^Y@`P?82`?@fA-H9U($Lu4mOOB2{95@o2wPBeJt09Fe90e? z2V1#RwR7RGdhg*W#nr1Jv;h$fd`cuuX+DGsJ2>fqW}PWDa7qe0@mcocM=o2-0~ho? z?q{@Uw?APO5TJ=-Q~dTxnTwg9ALDyIw?NolMgJW&{2pLk-`f63VqQqewO<*OiCP3? zKmn&vZrrZc%+{)q>*$z$2$swul6<0SiCh}I8sJ1aXSt=oxjVd06Nzih8ugOfZPN{Q zr!3MCk&@(|3&JBJBNH4jvmC7?QQBJ0A6QOTEmKUwK#!CXr*_6DeJ(y$O85loVtv&Y z2sNdpGfVl6ZmZb-gA3C@-1e`qc_s!iW&vV41Zhc2nfuvmD&P~VgsRej=;dvdAaCb}ap(~f4a?n0N80LffsmPGAo#vOL`+rGIUvdJ z1oNKv7DwK}>-dJ|BW<$mdY$Ih@7}Xp`A2fMrjB=7?Vg#Zm!Donnf$)vw*&w|pvhf= z5z{1eHfm<{q8D@BmQE0`P(5_Vm5q!aol6vRBWMn%|Izk=9EuiV*`x0J>eYv8uqnxU z*1J9~AV1@AxZ4<#p(oksb|p{z=FOV~qh5!1V@|fBO!d|6?e8^(F@~jR9G!vbOi?r0 z6F^E%mXb!i#KwQK@(*Vc@&6tAG-`!WYirp@SHY2lg+)x;;cv{AU)|I2R|Rfp>4wJ< z&G3Fs06tAR5pyqlaM}3>N&{M7tZOVc=|i|;iI17xl4PAOSe?`O^4~0 znM|#A7CE(!6iOS}K_PO{-Ily5oGBun-;Cz)1(<`X;sFobzs@_=v(UOV1S;JOvGEoC zWhQpH#GQX`0J+7blo$wEGiFmQPxWg-wOvP+#gLJHwrtoH=h?d5j%+K5P)2^@^Xq@H@0>>5+OH4fZV5XseHLzjMq-U8$A?cH%~F65U(u~mZFgAd8o1sEl~t~e$gL*bs+uob<^m0k*3>e{ zZuB(@@7OpyAP?iQ0s5ZD)X?-UZ`SKzTrN@ArA$XF_Xs!BN9g|j+~lEhGh}O#-NMx4 z;VPSq4jZ7Khx-cRT8pHlKg-?s=fLiy3lt8H0I2>Nx;VCyKD39>u4RIV`EO(iSC#Ba z-Vo>ogpYsxP=V68@$K8iUk#q=Q)2*TEg0Pr0ciT|KZ}?gWqd2eB-1YK%Af4}y>~gZ z3hEmpp~#JfL(fLF3ul|Af1W8n<7eGZZ3;z}o`1!3E-%0Tj*4=g# zM7(iUk~;dbG|Qqvsa3OzJ}hdnEb4sOoC+}EwC6HBX0n<#SwqZeX^v-Us0KM)tT6q& z$I=%l0irs0_>le8<$)*cE8>r&RSQ7wwG{jUXKICeJqKZT*e)=0AQ92#G=!SQk-p^j z5lz1!>8CkKQf}VV1G@ssQ1Oj0Kc{t;Zrsc|^^=4Ah5KAqRvafg+E7jQ>3*~eG#Pj~ zO}t%N+2F^D73Uwmlp%dird0aH}?iJq>RO4 zJF_ejh|%H{k;#SU{|5ifW@5L&aamAH9>I=8UV$%WbRG&FI+pd5A=Oz>@=od)jWB$e zb_ia}-fE>_KxRaE5A+DnGGs~k*mzTtn3(|FDc&zgx@9a_8s&FI2E9D5OR%dQ#wN37*Kx z##r)tuONn@xa8Ziqn(;qZX<`#+9aF zJya=y%wu$dGUo<1!nh@2B~nk~U$J-D?T6poGg^$fODACD+gfB|Y121JPVGSQ_HQNB zyP6t8h=G)A94&-|U(9eVLfeDyP^Wf*LBetC7Ra{Pd;V1NecwWoUAaggEhocz^X8Sf zxVRkE+)L1FNf~KbG60l|ot+JI)E<6>dRJSkBMKw>rd^Xhex#DtsSAcbj6c6_z&0{~ zj*Tc!8`(k~^5R!ZO9O^o<3nQN8A8HCUW;J_0(!{@hK0!(NuIGBsXY&)t$OlwYjhIy zKB)kC`|aDe59CY{P<`m^=GHhgMEG_4y_(3u7AKgdST0>$i#%|`(7T8PI&cGzF1e^sAOE28e$f@yT%5*03?aczGb!X7=ZI(RZZMeNk@8zp;sl zT|k3J;mhXX0?D4S%`R(%7%>+YSCnr47mHZ;70=7pTy`~p@7_$m2Iq6cum*XCYEVE1PsyeiNxCxFhj zjioQ;3|qJ~OSS4;9hUBwpnLi*)VW_~*EkBs!m#Y0(d;-)M5H*oXSk^zP=Ydjd}7QWiow2f z(V{HW0$<&WuZG$;e6@4bSmZ0r8n;_a|Pk`IF6!T@RQ-K*gk(;J@j0tTyq|4(=|H zMK}o}AB-KDWK(STkk)9hdd=&H9H_>|7H6|jHeX?k*)Oq%LEM(uUD(~J@Y+k`E&Mo$ zwvrG}6d*B7`{Ctm!A)m9v1gMgml@84Nq~+54p6+*H?K>s@+ChR%Y+Fs0|iGe1DGh=%~BP2tAsco7-RMz+P#A4Gv)RB+~yAua@;kcw%zGdSz)q z5~PV7hr8Vd_j2mh!GO!VTbe$3lC~v?)S z-DaG7i7%s6#1%wZ@pI4IO74!xa6cr91gX2S&6Y;J6pGcig^-)(!8%$@yA*sS#piAa zB@8cU&RH4(Wh+C z%($9C(gXKREE2TNw>S22jK%LbXUDZM6_Vl;cA)lC+_!j=FN@MWe!PK{rIk#TY9`H_ z4Mv?SxhLtk^h|6724ty-=_^yYZ%<#m=y9@tDL~^Cm&tTy$QthSl|MO7{27f2Fa@#~ zL98AwD&lPI=dZymBH59z?PR|H(k`em|CD#?>e|rH=(&M95k-0~ZLV-T3a?}aE{%zh5pW$bA2^V@Y6Ln*w|hkeJb7Cj-{SP%~;^b=rDdS+lLOtHv;~qL#Y9T_fwXCs%E{6G!@5RMj zV}xo^+E1TeOm>@jdsJ`3>g`3Qy*hJqo<{p_;rWCR_m|_Cklvy^c?bKFx`~}_Nuq5_ zVwMZv@MyHoF@M)X$eYY|1ba77a@c393?i*k7Ov7WpB0YYAVKnrxMi)e4L$gp(Ad!M zAWFX&+WW{Pi)^M0y?c5aToq+gxRkN9ahe?joO>$)O!7Jo>uo)`pZLrKLS159oV^ zK}F2Rg>@N+kJHmLppIdjl#NiX+b;OxIa)>WQZ`J|)YP^uC4=4{(@J+oL8|<)=P7xwz^jzL?*~jO-&i> z^y$9@5oM%#&CtvFu2(MK~8V!gWHShlAewoy^(i6U+^ zjR6<$_NBOiL&7EOhr7#ij+F=P$<=PFUmS1RFmYmg>}+Zd8nm);HM zFCl1D%~w}&=xa|wvmzg4aiawB^B_xcU-&RZ! z&P2t};yg5Hl%Ketlx517g3nQH#}sPQLN55%dIG`t`TO-mAsc#NwjWALj<+M+PQSZml^Z0gjTXxs2ys49=0X|J3^x9fiUppBd z9$ra#xs<)CYNSZ*)JtIJx)X0k?#osRl>j@GbFe&K{?&kpZf>GV{X z{V4h-V!w8+7jE)Hr5H0^87M8I zoOR|Cmq>^@>Kw_}*6+F~cKdd|83Wz^GZ(FR6(A(LWqMAf`*4NN?$UjhU^)X#@#uqc zv)(*iZ4Ym?aB7LnQtPj03iP#|KUr8=MHIhomo1<96XJ2AKig*5M7vPuYH4ZdJnOZZ z2F-)>d~!gJG*Ra?qJC`Wh-=!Hyw9IiyY6}$eW}~d(NXqrhs1I^^hA|LxO}cfAJOV;N-P z4GRv7!C1Az9K4BfpMVmp)e$cS+!H|fZheYRminT?w58?u1NE$fge{0;K+mp*i58V4 z3U#)*u6IRDwxSQG00$SYQb8{YjSh+xJkPQB6>!?L==6M;Me1wNTq#1;)W8I=WDqA) zI=i{B^7B`;to{LIIRzt4mTS>^-R?wpd)DO0O}~5rtC89uEk1NpS?0NrhRMl^5`&Ri zBeZ`y6@TL4n?XV+f3F?V9l9g?hdg^;0j1eWN#C-WymY4Sl&r?OhtTTL8BusRWGeRr ze}T^5hi!L6p!u$-gfUUF%Z?Vbny{PY2~Hma4KmJ~TF-nrC>$R3EI*4+K+1t3_0#t} zafw74iXXMGe%cu)%qcAUCJ2_0$!m@IWVq}c!g_J$t~*%4yyE9Q%cZ?<4l7Jh9F|Tc zc9KBd;aG+cL_N-qeA#kBUz*1nA*S`wR^yhmsKw6e&-g-K8Ec&Ss*W;+4~UC2Dx=wY zTi)DbsSh0LUm%FBMP<}Nc|fkKjs>-XOrbuKluulPNxV*Q`B){|!%N5_e&Go<6Mbs=!L3R=Z3n3pr9Z$xV(r_AZ$y%A5vk~OXY@5AK6>Qme1-V`yA%_@oILK zfBYUOUjt?M+(%E|p^^cHf3Xm(8`Ao?MxjvcrS@acB?OFtl2q z;d-()->sR!JA6=_UJs=RPA7{ggc6(gpoLIigv2pbyXYYYYIo^fb~0n=34Zdetzkg2 z-A@Y-!kx*)j=0r}^fHBO&k%dQ%JSI5I}uz?Av5^tmanIblpE1&onKf8PKo7e{z4}4 zO!skrfr}l7ZaqExe;*bWzc)2a%yqI(|GX)s>_v#Jw;1g1rbHLL@XKATJuZfFfVJ1S zwB~ymHT%lP>{C!)+&U4~u-( zv#L6`IPr@>kQPrrwc18|8UMnY1SqJ63Z?uT`Cq|1Qv;s_vSVs?`%(k=6E$p2fvY2| z?-1kTqXc>0^vUS5#Y)IO(Q!dj+DO%Q=r1Z2!qu97osErlU{*ELRP9gEB2((g1q;dA zw}hIwLoH&ZptihsS?txtWRI2VOZt;I6*&97rKP1cOo0|SpgZgQpx0TO{#~rfl#r4V zaqbRMB(59s{G6gZXf*O;yfNU(u`qxndB~>%raeRzrhS|{)ez-Mx9ohIUIpPx(_Rpz zd~GG9X5w)txeFiiKTnwUso2kNE%%Xy#Be;ssGR6P_BK&-V?inz0GV{iWinlp3|VD! zNFxyjBZ<{Fc1ojXc6uPs8Ou;QX&>l$%aKGnm_@|vBd z7jtEr%8qJOZiGt~cdKets5$JP;VB2(OUhsC`QZCOc5wfs1(hN^d%e5O<#pRIdU0^^ zpq?*|PmtSSruA@Sm7@{!!fR{0tk~Wy44S&EAIWnGS5z!!X!id zNH_QAwB5@-;GaTmWK#woQ?U&uHE=9#o@}x#oWacfu88I*@#dHx%Qh4w9q0jl)#j2x zQ3{*Wvs5hSfQUr1vLq;1T>2H0M~r0~Eb9=f*{fQHA+=8veZynztq;Al^Y0syAruou z-|lIeCuv%p+PW(iwY_ym2Z!^n`m$xDBmy>z>lUTw5*EsOxoUE!6ve>NC>PrB<#5$* zLEKfl3SrO@t#PGjxPy|C(uXih;`3pD85eph=lbw-NEY~pWvgFdOtFiuF2PCpC-2~MF&*K-O`8np>+4V#t$kBwQcV4b+5TROIeKWI)ov3$IGnL${?r0|mnVw`DUG zh`vnhxT|hopxmcE^pMPYz{B8elaQNYMj!Q_F)ScI+%P!$atd?ycV{9g413qU_da`u z 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 R*2`!yx|*kmFEy-h{2#gBIJy7; literal 0 HcmV?d00001 diff --git a/resources/lang/de/dhl.php b/resources/lang/de/dhl.php index 61b4b90..0992f9b 100644 --- a/resources/lang/de/dhl.php +++ b/resources/lang/de/dhl.php @@ -12,6 +12,7 @@ return [ 'returned' => 'Retourniert', 'failed' => 'Fehler', 'unknown' => 'Unbekannt', + 'canceled' => 'Storniert', 'cancelled' => 'Storniert', 'shipped' => 'Versendet', ], @@ -26,7 +27,8 @@ return [ 'V53WPAK' => 'DHL Paket International', 'V54EPAK' => 'DHL Paket International', 'V55PAK' => 'DHL Paket International', - 'V62WP' => 'DHL Paket International', + 'V62KP' => 'DHL Kleinpaket', + 'V62WP' => 'DHL Warenpost (Legacy)', 'V66WPI' => 'DHL Paket International', ], diff --git a/resources/lang/en/dhl.php b/resources/lang/en/dhl.php index 1bd0ec2..f432cd3 100644 --- a/resources/lang/en/dhl.php +++ b/resources/lang/en/dhl.php @@ -12,6 +12,7 @@ return [ 'returned' => 'Returned', 'failed' => 'Failed', 'unknown' => 'Unknown', + 'canceled' => 'Canceled', 'cancelled' => 'Cancelled', 'shipped' => 'Shipped', ], @@ -26,7 +27,8 @@ return [ 'V53WPAK' => 'DHL Package International', 'V54EPAK' => 'DHL Package International', 'V55PAK' => 'DHL Package International', - 'V62WP' => 'DHL Package International', + 'V62KP' => 'DHL Kleinpaket', + 'V62WP' => 'DHL Warenpost (Legacy)', 'V66WPI' => 'DHL Package International', ], diff --git a/resources/lang/es/dhl.php b/resources/lang/es/dhl.php index dfe8f88..84d65e7 100644 --- a/resources/lang/es/dhl.php +++ b/resources/lang/es/dhl.php @@ -12,6 +12,7 @@ return [ 'returned' => 'Devuelto', 'failed' => 'Fallido', 'unknown' => 'Desconocido', + 'canceled' => 'Cancelado', 'cancelled' => 'Cancelado', 'shipped' => 'Enviado', ], @@ -26,7 +27,8 @@ return [ 'V53WPAK' => 'DHL Paquete Internacional', 'V54EPAK' => 'DHL Paquete Internacional', 'V55PAK' => 'DHL Paquete Internacional', - 'V62WP' => 'DHL Paquete Internacional', + 'V62KP' => 'DHL Kleinpaket', + 'V62WP' => 'DHL Warenpost (Legacy)', 'V66WPI' => 'DHL Paquete Internacional', ], diff --git a/resources/lang/fr/dhl.php b/resources/lang/fr/dhl.php index 18460e3..61664be 100644 --- a/resources/lang/fr/dhl.php +++ b/resources/lang/fr/dhl.php @@ -12,6 +12,7 @@ return [ 'returned' => 'Retourné', 'failed' => 'Erreur', 'unknown' => 'Inconnu', + 'canceled' => 'Annulé', 'cancelled' => 'Annulé', 'shipped' => 'Expédié', ], @@ -26,7 +27,8 @@ return [ 'V53WPAK' => 'DHL Paket International', 'V54EPAK' => 'DHL Paket International', 'V55PAK' => 'DHL Paket International', - 'V62WP' => 'DHL Paket International', + 'V62KP' => 'DHL Kleinpaket', + 'V62WP' => 'DHL Warenpost (Legacy)', 'V66WPI' => 'DHL Paket International', ], diff --git a/resources/views/admin/dhl/cockpit.blade.php b/resources/views/admin/dhl/cockpit.blade.php index 6bbc466..5b38c72 100644 --- a/resources/views/admin/dhl/cockpit.blade.php +++ b/resources/views/admin/dhl/cockpit.blade.php @@ -102,7 +102,7 @@ - +
    diff --git a/resources/views/admin/dhl/modal_create_shipment.blade.php b/resources/views/admin/dhl/modal_create_shipment.blade.php index 2287a0c..b5ad016 100644 --- a/resources/views/admin/dhl/modal_create_shipment.blade.php +++ b/resources/views/admin/dhl/modal_create_shipment.blade.php @@ -109,9 +109,148 @@ $(document).ready(function() { return; } + if ($('#dhl-preflight-confirmed').val() !== '1') { + validateDhlAddress(formData, submitBtn, function() { + $('#dhl-preflight-confirmed').val('1'); + submitBtn.prop('disabled', false).html(' Sendung jetzt erstellen'); + }); + + return; + } + + validateDhlAddress(formData, submitBtn, function() { + submitDhlShipment(form, formData, submitBtn); + }); + }); + + function validateDhlAddress(formData, submitBtn, onSuccess) { + submitBtn.prop('disabled', true).html(' Prüfe Sendung...'); + + $.ajax({ + url: '{{ route('admin.dhl.validate-address') }}', + method: 'POST', + data: formData, + success: function(response) { + renderDhlPreflightStatus(response); + + onSuccess(); + }, + error: function(xhr) { + var response = xhr.responseJSON || {}; + + renderDhlPreflightStatus(response); + resetDhlPreflight(true); + } + }); + } + + function renderDhlPreflightStatus(response) { + var status = response.status || 'error'; + var product = (response.preflight && response.preflight.product) ? response.preflight.product : {}; + var address = (response.preflight && response.preflight.address) ? response.preflight.address : {}; + var normalized = address.normalized || {}; + var errors = response.errors || []; + var warnings = response.warnings || []; + var statusClass = status === 'valid' ? 'success' : (status === 'warning' ? 'warning' : 'danger'); + var statusIcon = status === 'valid' ? 'check-circle' : (status === 'warning' ? 'exclamation-triangle' : 'times-circle'); + var productScope = product.scope_label || 'Nicht geprüft'; + var productCode = product.code || $('#modal-product-code').val() || '-'; + var countryLabel = product.country_label || $('#shipping_country option:selected').text().trim() || '-'; + var validationBadgeClass = address.validation_available ? 'info' : 'warning'; + var validationBadgeText = address.validation_available ? 'Formale DACH-Prüfung' : 'Basisprüfung'; + var validationMessage = address.validation_message || 'Adressvalidierung wurde nicht eindeutig bestimmt.'; + + var listHtml = ''; + if (errors.length) { + listHtml += '
    Fehler:
      '; + errors.forEach(function(error) { + listHtml += '
    • ' + escapeHtml(error) + '
    • '; + }); + listHtml += '
    '; + } + if (warnings.length) { + listHtml += '
    Hinweise:
      '; + warnings.forEach(function(warning) { + listHtml += '
    • ' + escapeHtml(warning) + '
    • '; + }); + listHtml += '
    '; + } + + if (!errors.length && !warnings.length) { + listHtml = '
    Alle Vorabprüfungen sind erfolgreich.
    '; + } + + $('#dhl-preflight-status') + .removeClass('border-secondary border-success border-warning border-danger') + .addClass('border-' + statusClass) + .html(` +
    + + Vorabprüfung: ${escapeHtml(response.message || 'Prüfung abgeschlossen')} +
    +
    +
    +
    + Produktcode + ${escapeHtml(productCode)} +
    +
    + Sendungsart + ${escapeHtml(productScope)} +
    +
    + Zielland + ${escapeHtml(countryLabel)} +
    +
    +
    + Lieferadresse + ${escapeHtml([normalized.street, normalized.house_number].filter(Boolean).join(' '))}, + ${escapeHtml([normalized.postal_code, normalized.city].filter(Boolean).join(' '))} +
    +
    + Adressvalidierung + ${escapeHtml(validationBadgeText)} + ${escapeHtml(validationMessage)} +
    + ${listHtml} + ${response.can_create_label ? 'Prüfung bestätigt. Klicken Sie erneut auf „Sendung jetzt erstellen“, um das Label zu erzeugen.' : ''} +
    + `); + } + + function resetDhlPreflight(keepStatus) { + $('#dhl-preflight-confirmed').val('0'); + $('#create-shipment-btn').prop('disabled', false).html(' Vorabprüfung durchführen'); + + if (keepStatus) { + return; + } + + $('#dhl-preflight-status') + .removeClass('border-success border-warning border-danger') + .addClass('border-secondary') + .html(` +
    + + Vorabprüfung vor Labelerstellung +
    +
    +

    + Formular geändert. Bitte führen Sie die Vorabprüfung erneut aus, bevor das Label erstellt wird. +

    +
    + `); + } + + function escapeHtml(value) { + return $('
    ').text(value || '').html(); + } + + function submitDhlShipment(form, formData, submitBtn) { // Disable submit button submitBtn.prop('disabled', true).html(' Wird erstellt...'); - + $.ajax({ url: form.attr('action'), method: 'POST', @@ -119,14 +258,14 @@ $(document).ready(function() { success: function(response) { if (response.success) { console.log(response); - + // Show success message if (typeof showAlert === 'function') { showAlert('success', response.message); } else { alert(response.message); } - + // Switch to info mode instead of closing modal setTimeout(function() { // Show loading indicator @@ -140,7 +279,7 @@ $(document).ready(function() {
    `; $('#modals-load-content .modal-dialog').html(loadingHtml); - + // Reload modal in info mode to show the created shipment $.post('{{ route('modal_load') }}', { action: 'create-dhl-shipment', @@ -148,7 +287,7 @@ $(document).ready(function() { _token: '{{ csrf_token() }}' }).done(function(response) { $('#modals-load-content .modal-dialog').html(response.html); - + // Show success message in the new modal content setTimeout(function() { if (typeof showAlert === 'function') { @@ -160,30 +299,60 @@ $(document).ready(function() { }); }, 1000); // Wait 1 seconds to show success message } else { - alert(response.message || 'Fehler beim Erstellen der Sendung.'); - submitBtn.prop('disabled', false).html(' Sendung erstellen'); + renderDhlCreationErrorStatus(response); + resetDhlPreflight(true); } }, error: function(xhr) { - var errorMessage = 'Fehler beim Erstellen der Sendung.'; + var response = xhr.responseJSON || { + message: 'Fehler beim Erstellen der Sendung.' + }; if (xhr.responseJSON && xhr.responseJSON.message) { - errorMessage = xhr.responseJSON.message; + response.message = xhr.responseJSON.message; } else if (xhr.responseText) { try { var errorData = JSON.parse(xhr.responseText); if (errorData.errors) { - errorMessage = Object.values(errorData.errors).flat().join(', '); + response.errors = Object.values(errorData.errors).flat(); } } catch (e) { // Ignore JSON parse errors } } - - alert(errorMessage); - submitBtn.prop('disabled', false).html(' Sendung erstellen'); + + renderDhlCreationErrorStatus(response); + resetDhlPreflight(true); } }); - }); + } + + function renderDhlCreationErrorStatus(response) { + var errors = response.errors || [response.message || 'DHL hat die Labelerstellung abgelehnt.']; + + renderDhlPreflightStatus({ + status: 'error', + can_create_label: false, + message: 'DHL hat die Adresse bei der Labelerstellung abgelehnt.', + errors: errors, + warnings: [], + preflight: { + product: { + code: $('#modal-product-code').val(), + scope_label: $('#modal-product-code option:selected').text().trim() + }, + address: { + validation_available: true, + validation_message: 'DHL-Leitcodierung über mustEncode wurde bei der Labelerstellung geprüft.', + normalized: { + street: $('#shipping_address').val(), + house_number: $('#shipping_houseNumber').val(), + postal_code: $('#shipping_zipcode').val(), + city: $('#shipping_city').val() + } + } + } + }); + } // Enhanced form validation function validateForm() { @@ -216,6 +385,12 @@ $(document).ready(function() { isValid = false; } }); + + var reference = $('#modal-reference').val(); + if (reference && reference.length > 35) { + $('#modal-reference').addClass('is-invalid').after('
    Referenz darf maximal 35 Zeichen lang sein.
    '); + isValid = false; + } return isValid; } @@ -237,6 +412,23 @@ $(document).ready(function() { } } }); + + var dhlProductSuggestions = @json($productSuggestions ?? []); + + $('#shipping_country').on('change', function() { + var countryCode = $(this).find(':selected').data('country-code'); + var suggestedProduct = dhlProductSuggestions[countryCode]; + + if (suggestedProduct && $('#modal-product-code option[value="' + suggestedProduct + '"]').length) { + $('#modal-product-code').val(suggestedProduct); + } + }); + + $('#modal-create-shipment-form').on('input change', 'input, select', function() { + if ($(this).attr('id') !== 'dhl-preflight-confirmed') { + resetDhlPreflight(); + } + }); // Real-time required field validation $('input[required], select[required]').on('blur', function() { diff --git a/resources/views/admin/dhl/modal_in_order_shipment.blade.php b/resources/views/admin/dhl/modal_in_order_shipment.blade.php index 3141982..57b2e59 100644 --- a/resources/views/admin/dhl/modal_in_order_shipment.blade.php +++ b/resources/views/admin/dhl/modal_in_order_shipment.blade.php @@ -1,6 +1,7 @@ diff --git a/resources/views/admin/dhl/show.blade.php b/resources/views/admin/dhl/show.blade.php index f6365fc..b7cb744 100644 --- a/resources/views/admin/dhl/show.blade.php +++ b/resources/views/admin/dhl/show.blade.php @@ -54,9 +54,10 @@ {{ __('dhl.status.delivered') }} @break + @case('canceled') @case('cancelled')
    - {{ __('dhl.status.cancelled') }} + {{ __('dhl.status.canceled') }}
    @break @case('failed') @@ -110,7 +111,7 @@ @if(false) {{ $shipment->dhl_shipment_no }}
    - Verfolgen @@ -147,7 +148,7 @@ -@if($shipment->status != 'cancelled') +@if(!in_array($shipment->status, ['canceled', 'cancelled']))
    @@ -219,6 +220,16 @@ @endif + + Referenz: + + @if($shipment->reference) + {{ $shipment->reference }} + @else + Nicht gesetzt + @endif + + {{-- Routing-Code: @@ -511,14 +522,71 @@
    @if($shipment->wasTrackingEmailSent()) -
    +
    - E-Mail gesendet
    + Zuletzt gesendet
    Am {{ $shipment->tracking_email_sent_at->format('d.m.Y \u\m H:i') }} Uhr ({{ $shipment->tracking_email_type === 'auto' ? 'automatisch' : 'manuell' }})
    + + @php($trackingEmailHistory = $shipment->getTrackingEmailHistory()) + @if(!empty($trackingEmailHistory)) +
    + + + + + + + + + + + + @foreach($trackingEmailHistory as $entry) + + + + + + + + @endforeach + +
    ZeitpunktTypStatusEmpfängerSendungen
    + @if(!empty($entry['sent_at'])) + {{ \Carbon\Carbon::parse($entry['sent_at'])->format('d.m.Y H:i') }} + @else + - + @endif + + + {{ ($entry['type'] ?? '') === 'auto' ? 'Automatisch' : 'Manuell' }} + + + + {{ \Acme\Dhl\Models\DhlShipment::getStatusTranslationFor($entry['status'] ?? 'unknown') }} + + @if(!empty($entry['tracking_status'])) +
    {{ $entry['tracking_status'] }} + @endif +
    + @if(!empty($entry['recipient_email'])) + {{ $entry['recipient_email'] }} + @else + - + @endif + + @if(!empty($entry['included_shipment_ids'])) + #{{ implode(', #', $entry['included_shipment_ids']) }} + @else + - + @endif +
    +
    + @endif @else
    diff --git a/resources/views/admin/settings/index.blade.php b/resources/views/admin/settings/index.blade.php index 5606f63..9ddae0d 100755 --- a/resources/views/admin/settings/index.blade.php +++ b/resources/views/admin/settings/index.blade.php @@ -170,11 +170,15 @@
    + @php + $selectedDhlProduct = \App\Models\Setting::getContentBySlug('dhl_product') ?: 'V01PAK'; + $selectedDhlProduct = $selectedDhlProduct === 'V62WP' ? 'V62KP' : $selectedDhlProduct; + @endphp {{ Form::select('settings[dhl_product][val]', [ 'V01PAK' => 'V01PAK - DHL Paket National', 'V53PAK' => 'V53PAK - DHL Paket International', - 'V62WP' => 'V62WP - Warenpost National' - ], \App\Models\Setting::getContentBySlug('dhl_product') ?: 'V01PAK', array('class'=>'form-control custom-select')) }} + 'V62KP' => 'V62KP - DHL Kleinpaket' + ], $selectedDhlProduct, array('class'=>'form-control custom-select')) }} {{ Form::hidden('settings[dhl_product][type]', 'text') }}
    @@ -196,6 +200,50 @@ Deaktiviert: Versandlabel werden sofort erstellt (synchron)
    +
    + + {{ Form::hidden('settings[dhl_print_only_if_codeable][type]', 'bool') }} + + Aktiviert für deutsche Empfängeradressen mustEncode=true. DHL erstellt dann nur ein Label, wenn die Adresse leitcodierbar ist. + +
    +
    + + @php + $dhlInternationalCountries = (new \App\Services\DhlProductResolver())->getSupportedInternationalCountries(); + $dhlCountryCodes = array_keys(\App\Services\DhlProductResolver::DHL_COUNTRY_CODES); + $dhlInternationalCountryOptions = \App\Models\Country::query() + ->where('active', 1) + ->whereIn('code', $dhlCountryCodes) + ->where('code', '!=', \App\Services\DhlProductResolver::DOMESTIC_COUNTRY) + ->orderBy('de') + ->get(); + @endphp +
    + @foreach ($dhlInternationalCountryOptions as $countryOption) +
    + +
    + @endforeach +
    + {{ Form::hidden('settings[dhl_international_countries][type]', 'object') }} + + Aktivierte Länder verwenden im DHL-Modul automatisch V53PAK. Deutschland bleibt separat über V01PAK/V62KP geregelt. + +
    - - {{ Form::text('settings[dhl_account_v62wp][val]', \App\Models\Setting::getContentBySlug('dhl_account_v62wp'), array('class'=>'form-control')) }} - {{ Form::hidden('settings[dhl_account_v62wp][type]', 'text') }} + + {{ Form::text('settings[dhl_account_v62kp][val]', \App\Models\Setting::getContentBySlug('dhl_account_v62kp') ?: \App\Models\Setting::getContentBySlug('dhl_account_v62wp'), array('class'=>'form-control')) }} + {{ Form::hidden('settings[dhl_account_v62kp][type]', 'text') }}
    diff --git a/resources/views/public/tracking.blade.php b/resources/views/public/tracking.blade.php index 08658ec..4aa66fa 100644 --- a/resources/views/public/tracking.blade.php +++ b/resources/views/public/tracking.blade.php @@ -308,6 +308,7 @@ $(document).ready(function() { badgeClass = 'badge-info'; text = 'Zugestellt'; break; + case 'canceled': case 'cancelled': badgeClass = 'badge-secondary'; text = 'Storniert'; @@ -345,6 +346,7 @@ $(document).ready(function() { iconClass = 'fas fa-home'; color = 'text-info'; break; + case 'canceled': case 'cancelled': iconClass = 'fas fa-ban'; color = 'text-secondary'; diff --git a/routes/domains/crm.php b/routes/domains/crm.php index efd48cd..ec4307d 100644 --- a/routes/domains/crm.php +++ b/routes/domains/crm.php @@ -291,6 +291,7 @@ Route::domain(config('app.pre_url_crm').config('app.domain').config('app.tld_car Route::post('/datatable', 'DhlShipmentController@datatable')->name('admin.dhl.datatable'); Route::get('/shipment/{shipment}', 'DhlShipmentController@show')->name('admin.dhl.show'); Route::post('/shipment', 'DhlShipmentController@store')->name('admin.dhl.store'); + Route::post('/validate-address', 'DhlShipmentController@validateAddress')->name('admin.dhl.validate-address'); Route::delete('/shipment/{shipment}/cancel', 'DhlShipmentController@cancel')->name('admin.dhl.cancel'); Route::post('/shipment/{shipment}/return-label', 'DhlShipmentController@createReturnLabel')->name('admin.dhl.create-return'); Route::post('/shipment/{shipment}/update-tracking', 'DhlShipmentController@updateTracking')->name('admin.dhl.update-tracking'); diff --git a/tests/Pest.php b/tests/Pest.php index efc2cb5..f7b4296 100644 --- a/tests/Pest.php +++ b/tests/Pest.php @@ -3,4 +3,5 @@ uses(Tests\TestCase::class)->in('Feature/Incentive'); uses(Tests\TestCase::class)->in('Feature/Sys'); uses(Tests\TestCase::class)->in('Unit/Incentive'); +uses(Tests\TestCase::class)->in('Unit/Dhl'); uses(Tests\TestCase::class)->in('Feature/PaymentDashboard'); diff --git a/tests/Unit/Dhl/DhlAddressValidatorTest.php b/tests/Unit/Dhl/DhlAddressValidatorTest.php new file mode 100644 index 0000000..1bd49c2 --- /dev/null +++ b/tests/Unit/Dhl/DhlAddressValidatorTest.php @@ -0,0 +1,174 @@ + 'Max', + 'lastname' => 'Mustermann', + 'street' => 'Hauptstrasse', + 'house_number' => '5', + 'postal_code' => '10115', + 'city' => 'Berlin', + 'country_code' => 'DE', + 'email' => 'max@example.com', + 'phone' => '+4930123456', + ], $overrides); +} + +beforeEach(function () { + config([ + 'dhl.config_source' => 'env', + 'dhl.international_countries' => ['AT', 'ES'], + ]); +}); + +it('marks a complete supported address as valid', function () { + $result = (new DhlAddressValidator)->validate(validDhlAddress()); + + expect($result['status'])->toBe('valid') + ->and($result['can_create_label'])->toBeTrue() + ->and($result['errors'])->toBeEmpty() + ->and($result['warnings'])->toBeEmpty() + ->and($result['validation_available'])->toBeTrue() + ->and($result['validation_level'])->toBe('formal_dach') + ->and($result['validation_message'])->toBe('Formale DACH-Pruefung aktiv: Pflichtfelder, PLZ-Format, Plausibilitaet und Packstation-Regeln werden geprueft. Eine echte Adressdatenbank-/DHL-Leitcodepruefung ist nicht angebunden.'); +}); + +it('blocks unsupported destination countries', function () { + $result = (new DhlAddressValidator)->validate(validDhlAddress([ + 'country_code' => 'FR', + 'postal_code' => '75001', + ])); + + expect($result['status'])->toBe('error') + ->and($result['can_create_label'])->toBeFalse() + ->and($result['errors'])->toContain('DHL-Versand in das Zielland FR ist aktuell nicht freigegeben.'); +}); + +it('blocks invalid postal code formats for enabled countries', function () { + $result = (new DhlAddressValidator)->validate(validDhlAddress([ + 'country_code' => 'AT', + 'postal_code' => '10115', + ])); + + expect($result['status'])->toBe('error') + ->and($result['errors'])->toContain('Oesterreichische Postleitzahl muss 4 Ziffern haben.'); +}); + +it('validates swiss postal codes when switzerland is enabled', function () { + config([ + 'dhl.config_source' => 'env', + 'dhl.international_countries' => ['AT', 'CH'], + ]); + + $result = (new DhlAddressValidator)->validate(validDhlAddress([ + 'country_code' => 'CH', + 'postal_code' => '8000', + ])); + + expect($result['validation_available'])->toBeTrue() + ->and($result['validation_level'])->toBe('formal_dach') + ->and($result['errors'])->not->toContain('Schweizer Postleitzahl muss 4 Ziffern haben.'); +}); + +it('marks supported countries without country specific validation as basic checks', function () { + config([ + 'dhl.config_source' => 'env', + 'dhl.international_countries' => ['FR'], + ]); + + $result = (new DhlAddressValidator)->validate(validDhlAddress([ + 'country_code' => 'FR', + 'postal_code' => '75001', + ])); + + expect($result['status'])->toBe('warning') + ->and($result['can_create_label'])->toBeTrue() + ->and($result['validation_available'])->toBeFalse() + ->and($result['validation_level'])->toBe('basic') + ->and($result['validation_message'])->toBe('Fuer dieses Zielland ist aktuell nur eine Basis-Adresspruefung verfuegbar.') + ->and($result['warnings'])->toContain('Fuer dieses Zielland ist aktuell nur eine Basis-Adresspruefung verfuegbar. Bitte Adresse manuell pruefen.'); +}); + +it('blocks implausible delivery address fields', function () { + $result = (new DhlAddressValidator)->validate(validDhlAddress([ + 'street' => '12', + 'postal_code' => '12@@@', + 'city' => '1', + ])); + + expect($result['status'])->toBe('error') + ->and($result['can_create_label'])->toBeFalse() + ->and($result['errors'])->toContain('Straße ist zu kurz.') + ->and($result['errors'])->toContain('Straße muss Buchstaben enthalten.') + ->and($result['errors'])->toContain('Ort ist zu kurz.') + ->and($result['errors'])->toContain('Ort muss Buchstaben enthalten.') + ->and($result['errors'])->toContain('Postleitzahl enthaelt ungueltige Zeichen.'); +}); + +it('blocks placeholder delivery addresses', function () { + $result = (new DhlAddressValidator)->validate(validDhlAddress([ + 'street' => 'Teststrasse', + 'city' => 'Fakeort', + ])); + + expect($result['status'])->toBe('error') + ->and($result['can_create_label'])->toBeFalse() + ->and($result['errors'])->toContain('Straße wirkt wie eine Test- oder Platzhalteradresse.') + ->and($result['errors'])->toContain('Ort wirkt wie eine Test- oder Platzhalterangabe.'); +}); + +it('blocks DACH addresses with house numbers without digits', function () { + $result = (new DhlAddressValidator)->validate(validDhlAddress([ + 'house_number' => 'links', + ])); + + expect($result['status'])->toBe('error') + ->and($result['can_create_label'])->toBeFalse() + ->and($result['errors'])->toContain('Hausnummer muss fuer DACH-Adressen eine Ziffer enthalten.'); +}); + +it('allows warning-only addresses but marks them for review', function () { + $result = (new DhlAddressValidator)->validate(validDhlAddress([ + 'phone' => '', + ])); + + expect($result['status'])->toBe('warning') + ->and($result['can_create_label'])->toBeTrue() + ->and($result['warnings'])->toContain('Telefonnummer fehlt. DHL kann Empfaenger bei Zustellproblemen eventuell nicht kontaktieren.'); +}); + +it('validates German packstation addresses', function () { + $result = (new DhlAddressValidator)->validate(validDhlAddress([ + 'street' => 'Packstation', + 'house_number' => '145', + 'postnumber' => '12345678', + ])); + + expect($result['status'])->toBe('valid') + ->and($result['can_create_label'])->toBeTrue(); +}); + +it('blocks invalid packstation postnumbers', function () { + $result = (new DhlAddressValidator)->validate(validDhlAddress([ + 'street' => 'Packstation', + 'house_number' => '145', + 'postnumber' => 'abc', + ])); + + expect($result['status'])->toBe('error') + ->and($result['errors'])->toContain('DHL Postnummer muss 6-10 Ziffern enthalten.'); +}); + +it('requires postnumber and locker number for packstation addresses', function () { + $result = (new DhlAddressValidator)->validate(validDhlAddress([ + 'street' => 'Packstation', + 'house_number' => '', + 'postnumber' => '', + ])); + + expect($result['status'])->toBe('error') + ->and($result['errors'])->toContain('DHL Postnummer ist fuer Packstation/Paketbox erforderlich.'); +}); diff --git a/tests/Unit/Dhl/DhlDataHelperReferenceTest.php b/tests/Unit/Dhl/DhlDataHelperReferenceTest.php new file mode 100644 index 0000000..dae72a7 --- /dev/null +++ b/tests/Unit/Dhl/DhlDataHelperReferenceTest.php @@ -0,0 +1,100 @@ +id = 98765; + + return $order; +} + +function dhlReferenceOptions(array $overrides = []): array +{ + return array_merge([ + 'product_code' => 'V01PAK', + 'shipping_address' => [ + 'firstname' => 'Max', + 'lastname' => 'Mustermann', + 'company' => '', + 'address' => 'Hauptstrasse', + 'houseNumber' => '5', + 'zipcode' => '10115', + 'city' => 'Berlin', + 'country' => (object) ['code' => 'DE'], + 'email' => 'max@example.com', + 'phone' => '+4930123456', + 'postnumber' => null, + ], + ], $overrides); +} + +function dhlReferenceConfig(): array +{ + return [ + 'default_product' => 'V01PAK', + 'label_format' => 'PDF', + 'dimensions' => [ + 'V01PAK' => ['length' => 30, 'width' => 20, 'height' => 10], + 'default' => ['length' => 30, 'width' => 20, 'height' => 10], + ], + 'sender' => [ + 'company' => 'mivita care gmbh', + 'name' => '', + 'street' => 'Leinfeld', + 'house_number' => '2', + 'postalCode' => '87755', + 'city' => 'Kirchhaslach', + 'country' => 'DE', + 'email' => 'versand@example.com', + 'phone' => '+4987654321', + ], + ]; +} + +it('uses the admin shipment reference for DHL order data', function () { + $orderData = DhlDataHelper::prepareOrderData( + dhlReferenceOrder(), + 1.2, + dhlReferenceOptions(['reference' => 'Nachlieferung Mai']), + dhlReferenceConfig() + ); + + expect($orderData['reference'])->toBe('Nachlieferung Mai'); +}); + +it('falls back to the order reference when no admin reference is given', function () { + $orderData = DhlDataHelper::prepareOrderData( + dhlReferenceOrder(), + 1.2, + dhlReferenceOptions(['reference' => '']), + dhlReferenceConfig() + ); + + expect($orderData['reference'])->toBe('Order-98765'); +}); + +it('normalizes the DHL reference to the API length limit', function () { + $orderData = DhlDataHelper::prepareOrderData( + dhlReferenceOrder(), + 1.2, + dhlReferenceOptions(['reference' => 'Sehr lange interne Referenz fuer DHL Label Nachlieferung']), + dhlReferenceConfig() + ); + + expect(strlen($orderData['reference']))->toBe(35) + ->and($orderData['reference'])->toBe('Sehr lange interne Referenz fuer DH'); +}); + +it('passes the DHL mustEncode option into order data', function () { + $orderData = DhlDataHelper::prepareOrderData( + dhlReferenceOrder(), + 1.2, + dhlReferenceOptions(['print_only_if_codeable' => false]), + dhlReferenceConfig() + ); + + expect($orderData['print_only_if_codeable'])->toBeFalse(); +}); diff --git a/tests/Unit/Dhl/DhlProductResolverTest.php b/tests/Unit/Dhl/DhlProductResolverTest.php new file mode 100644 index 0000000..d65525f --- /dev/null +++ b/tests/Unit/Dhl/DhlProductResolverTest.php @@ -0,0 +1,83 @@ + 'env', + 'dhl.international_countries' => ['AT', 'ES'], + ]); +}); + +it('resolves domestic DHL products for Germany', function () { + $resolver = new DhlProductResolver; + + expect($resolver->resolveForShipment('DE', 'V62KP'))->toMatchArray([ + 'country_code' => 'DE', + 'dhl_country_code' => 'DEU', + 'product_code' => 'V62KP', + ]); +}); + +it('suggests international parcel for Austria and Spain', function (string $countryCode) { + $resolver = new DhlProductResolver; + + expect($resolver->resolveProductCode($countryCode, null, 'V01PAK'))->toBe('V53PAK'); +})->with([ + 'austria' => 'AT', + 'spain' => 'ES', +]); + +it('rejects unsupported destination countries', function () { + (new DhlProductResolver)->resolveProductCode('FR', null, 'V01PAK'); +})->throws(InvalidArgumentException::class, 'DHL-Versand in das Zielland FR ist aktuell nicht freigegeben.'); + +it('uses configured international destination countries', function () { + $previousConfigSource = config('dhl.config_source'); + $previousCountries = config('dhl.international_countries'); + + config([ + 'dhl.config_source' => 'env', + 'dhl.international_countries' => ['AT', 'FR'], + ]); + + try { + $resolver = new DhlProductResolver; + + expect($resolver->resolveProductCode('FR', null, 'V01PAK'))->toBe('V53PAK') + ->and($resolver->getProductSuggestionsByCountry())->toMatchArray([ + 'DE' => 'V01PAK', + 'FR' => 'V53PAK', + ]); + } finally { + config([ + 'dhl.config_source' => $previousConfigSource, + 'dhl.international_countries' => $previousCountries, + ]); + } +}); + +it('normalizes configurable international countries', function () { + expect(DhlProductResolver::normalizeCountryCodeList([' at ', 'DE', 'XX', 'FR', 'AT']))->toBe(['AT', 'FR']); +}); + +it('describes DHL product scope for preflight checks', function () { + $resolver = new DhlProductResolver; + + expect($resolver->getProductScope('V01PAK'))->toBe('national') + ->and($resolver->getProductScopeLabel('V01PAK'))->toBe('DHL Paket National') + ->and($resolver->getProductScope('V53PAK'))->toBe('international') + ->and($resolver->getProductScopeLabel('V53PAK'))->toBe('DHL Paket International'); +}); + +it('rejects domestic products for international shipments when explicitly selected', function () { + (new DhlProductResolver)->resolveProductCode('AT', 'V01PAK'); +})->throws(InvalidArgumentException::class, 'Produkt V01PAK ist fuer DHL-Versand in das Zielland AT nicht erlaubt.'); + +it('does not fallback unknown countries to Germany', function () { + (new DhlProductResolver)->toDhlCountryCode('XX'); +})->throws(InvalidArgumentException::class, 'DHL-Laendercode XX wird nicht unterstuetzt.'); + +it('requires a billing number for the resolved product', function () { + (new DhlProductResolver)->assertBillingNumber('V53PAK', null); +})->throws(InvalidArgumentException::class, 'Keine DHL-Abrechnungsnummer fuer Produkt V53PAK konfiguriert.'); diff --git a/tests/Unit/Dhl/DhlShipmentStatusTest.php b/tests/Unit/Dhl/DhlShipmentStatusTest.php new file mode 100644 index 0000000..e2be2c5 --- /dev/null +++ b/tests/Unit/Dhl/DhlShipmentStatusTest.php @@ -0,0 +1,96 @@ +toBe('canceled') + ->and(DhlShipment::normalizeStatus('canceled'))->toBe('canceled'); +}); + +it('translates canceled and legacy cancelled shipments consistently', function () { + app()->setLocale('de'); + + $canceledShipment = new DhlShipment(['status' => 'canceled']); + $legacyShipment = new DhlShipment(['status' => 'cancelled']); + + expect($canceledShipment->getStatusTranslation())->toBe('Storniert') + ->and($legacyShipment->getStatusTranslation())->toBe('Storniert') + ->and($canceledShipment->getStatusBadgeClass())->toBe('warning') + ->and($legacyShipment->getStatusBadgeClass())->toBe('warning'); +}); + +it('returns tracking email history with latest entries first', function () { + $shipment = new DhlShipment([ + 'api_response_data' => [ + 'tracking_email_history' => [ + [ + 'sent_at' => '2026-05-27T08:00:00+00:00', + 'type' => 'auto', + 'status' => 'in_transit', + 'recipient_email' => 'first@example.test', + ], + [ + 'sent_at' => '2026-05-27T09:00:00+00:00', + 'type' => 'manual', + 'status' => 'out_for_delivery', + 'recipient_email' => 'second@example.test', + ], + ], + ], + ]); + + $history = $shipment->getTrackingEmailHistory(); + + expect($history)->toHaveCount(2) + ->and($history[0]['recipient_email'])->toBe('second@example.test') + ->and($history[0]['status'])->toBe('out_for_delivery') + ->and(DhlShipment::getStatusBadgeClassFor($history[0]['status']))->toBe('primary'); +}); + +it('returns empty tracking email history for legacy shipments without history', function () { + $shipment = new DhlShipment(['api_response_data' => ['items' => []]]); + + expect($shipment->getTrackingEmailHistory())->toBe([]); +}); + +it('triggers tracking emails for relevant status changes only once', function () { + $order = new ShoppingOrder; + + $shipment = new DhlShipment([ + 'status' => 'out_for_delivery', + 'dhl_shipment_no' => '00340435065133', + 'email' => 'customer@example.test', + ]); + $shipment->setRelation('shoppingOrder', $order); + + expect($shipment->shouldTriggerTrackingEmail('created'))->toBeTrue() + ->and($shipment->shouldTriggerTrackingEmail('out_for_delivery'))->toBeFalse(); + + $shipment->tracking_email_sent_at = now(); + + expect($shipment->shouldTriggerTrackingEmail('created'))->toBeFalse(); +}); + +it('does not trigger tracking emails for terminal delivered status', function () { + $order = new ShoppingOrder; + + $shipment = new DhlShipment([ + 'status' => 'delivered', + 'dhl_shipment_no' => '00340435065133', + 'email' => 'customer@example.test', + ]); + $shipment->setRelation('shoppingOrder', $order); + + expect($shipment->shouldTriggerTrackingEmail('in_transit'))->toBeFalse(); +}); + +it('maps DHL status variants to internal tracking statuses', function (string $dhlStatus, string $internalStatus) { + expect(DhlTrackingService::mapDhlStatusToInternal($dhlStatus))->toBe($internalStatus); +})->with([ + 'transit' => ['transit', 'in_transit'], + 'in-transit' => ['in-transit', 'in_transit'], + 'out-for-delivery' => ['out-for-delivery', 'out_for_delivery'], + 'out_for_delivery' => ['out_for_delivery', 'out_for_delivery'], +]); diff --git a/tests/Unit/Dhl/DhlShipmentWeightCalculatorTest.php b/tests/Unit/Dhl/DhlShipmentWeightCalculatorTest.php new file mode 100644 index 0000000..5107edd --- /dev/null +++ b/tests/Unit/Dhl/DhlShipmentWeightCalculatorTest.php @@ -0,0 +1,60 @@ +weight = $baseWeightGrams; + $order->setRelation('shopping_order_items', new Collection($items)); + + return $order; +} + +function dhlWeightItem(int $comp, int $qty, int $productWeightGrams): ShoppingOrderItem +{ + $product = new Product; + $product->weight = $productWeightGrams; + + $item = new ShoppingOrderItem; + $item->comp = $comp; + $item->qty = $qty; + $item->setRelation('product', $product); + + return $item; +} + +it('uses the shopping order weight as DHL base weight', function () { + $weight = (new DhlShipmentWeightCalculator)->calculate(dhlWeightOrder(1250)); + + expect($weight)->toBe(1.25); +}); + +it('adds compensation product weights to the DHL weight', function () { + $weight = (new DhlShipmentWeightCalculator)->calculate(dhlWeightOrder(500, [ + dhlWeightItem(comp: 1, qty: 2, productWeightGrams: 250), + dhlWeightItem(comp: 0, qty: 10, productWeightGrams: 1000), + ])); + + expect($weight)->toBe(1.0); +}); + +it('falls back to a safe default DHL weight when no weight exists', function () { + $weight = (new DhlShipmentWeightCalculator)->calculate(dhlWeightOrder(0)); + + expect($weight)->toBe(1.0); +}); + +it('validates product specific DHL weight limits', function () { + (new DhlShipmentWeightCalculator)->assertWithinProductLimit(1.001, 'V62KP'); +})->throws(InvalidArgumentException::class, 'Gewicht 1.001 kg ueberschreitet das DHL-Maximalgewicht fuer V62KP (1.0 kg).'); + +it('allows regular parcel products up to the DHL parcel limit', function () { + (new DhlShipmentWeightCalculator)->assertWithinProductLimit(31.5, 'V01PAK'); + + expect(true)->toBeTrue(); +}); diff --git a/tests/Unit/Dhl/ShippingServiceProductCodeTest.php b/tests/Unit/Dhl/ShippingServiceProductCodeTest.php new file mode 100644 index 0000000..c10e790 --- /dev/null +++ b/tests/Unit/Dhl/ShippingServiceProductCodeTest.php @@ -0,0 +1,130 @@ + 123456, + 'weight_kg' => 0.5, + 'product_code' => $productCode, + 'label_format' => 'PDF', + 'shipper' => [ + 'name' => 'mivita care gmbh', + 'street' => 'Leinfeld', + 'houseNumber' => '2', + 'postalCode' => '87755', + 'city' => 'Kirchhaslach', + 'country' => 'DE', + ], + 'consignee' => [ + 'name' => 'Max Mustermann', + 'street' => 'Hauptstrasse', + 'houseNumber' => '5', + 'postalCode' => '10115', + 'city' => 'Berlin', + 'country' => $countryCode, + ], + 'reference' => 'ORDER-123456', + ]; +} + +it('accepts DHL Kleinpaket as a product code', function () { + $service = makeDhlPhaseOneShippingService(); + $method = new ReflectionMethod($service, 'validateOrderData'); + $method->setAccessible(true); + + $validated = $method->invoke($service, validDhlPhaseOneOrderData('V62KP')); + + expect($validated['product_code'])->toBe('V62KP'); +}); + +it('builds an international DHL parcel payload for Austria', function () { + config([ + 'dhl.account_numbers.V53PAK' => '63144073555301', + 'dhl.legacy.sandbox' => false, + 'dhl.legacy.test_mode' => false, + ]); + + $service = makeDhlPhaseOneShippingService(); + $method = new ReflectionMethod($service, 'buildShipmentPayload'); + $method->setAccessible(true); + + $payload = $method->invoke($service, validDhlPhaseOneOrderData('V53PAK', 'AT')); + + expect($payload['shipments'][0]['product'])->toBe('V53PAK') + ->and($payload['shipments'][0]['billingNumber'])->toBe('63144073555301') + ->and($payload['shipments'][0]['consignee']['country'])->toBe('AUT'); +}); + +it('rejects unsupported countries instead of falling back to Germany', function () { + $service = makeDhlPhaseOneShippingService(); + $method = new ReflectionMethod($service, 'buildShipmentPayload'); + $method->setAccessible(true); + + $method->invoke($service, validDhlPhaseOneOrderData('V53PAK', 'FR')); +})->throws(InvalidArgumentException::class); + +it('rejects legacy Warenpost for new labels', function () { + $service = makeDhlPhaseOneShippingService(); + $method = new ReflectionMethod($service, 'validateOrderData'); + $method->setAccessible(true); + + $method->invoke($service, validDhlPhaseOneOrderData('V62WP')); +})->throws(InvalidArgumentException::class); + +it('builds a DHL Kleinpaket payload', function () { + config([ + 'dhl.account_numbers.V62KP' => '63144073556201', + 'dhl.legacy.sandbox' => false, + 'dhl.legacy.test_mode' => false, + ]); + + $service = makeDhlPhaseOneShippingService(); + $method = new ReflectionMethod($service, 'buildShipmentPayload'); + $method->setAccessible(true); + + $payload = $method->invoke($service, validDhlPhaseOneOrderData('V62KP')); + + expect($payload['shipments'][0]['product'])->toBe('V62KP') + ->and($payload['shipments'][0]['billingNumber'])->toBe('63144073556201') + ->and($payload['shipments'][0]['refNo'])->toBe('ORDER-123456'); +}); + +it('uses mustEncode only for German consignee addresses', function () { + $service = makeDhlPhaseOneShippingService(); + $method = new ReflectionMethod($service, 'shouldUseMustEncode'); + $method->setAccessible(true); + + expect($method->invoke($service, validDhlPhaseOneOrderData('V01PAK', 'DE') + ['print_only_if_codeable' => true]))->toBeTrue() + ->and($method->invoke($service, validDhlPhaseOneOrderData('V53PAK', 'AT') + ['print_only_if_codeable' => true]))->toBeFalse() + ->and($method->invoke($service, validDhlPhaseOneOrderData('V01PAK', 'DE') + ['print_only_if_codeable' => false]))->toBeFalse(); +}); + +it('turns non-codeable DHL responses into address validation errors', function () { + $service = makeDhlPhaseOneShippingService(); + $method = new ReflectionMethod($service, 'assertSuccessfulShipmentResponse'); + $method->setAccessible(true); + + $method->invoke($service, [ + 'status' => [ + 'statusCode' => 200, + ], + 'items' => [[ + 'sstatus' => [ + 'statusCode' => 400, + 'detail' => 'Consignee address is not encodable.', + ], + 'validationMessages' => [[ + 'validationMessage' => 'Address is not encodable.', + ]], + ]], + ], true); +})->throws(DhlAddressValidationException::class, 'DHL kann diese Adresse nicht leitcodieren.'); diff --git a/tests/Unit/Dhl/TrackShipmentJobTest.php b/tests/Unit/Dhl/TrackShipmentJobTest.php new file mode 100644 index 0000000..a029554 --- /dev/null +++ b/tests/Unit/Dhl/TrackShipmentJobTest.php @@ -0,0 +1,35 @@ + 123, + 'dhl_shipment_no' => '00340434161094000001', + 'status' => 'created', + ]); + + $options = ['auto_retrack' => false]; + $job = new TrackShipmentJob($shipment, $options); + + /** @var DhlTrackingService&MockInterface $trackingService */ + $trackingService = Mockery::mock(DhlTrackingService::class); + $trackingService + ->shouldReceive('updateTrackingNow') + ->once() + ->with($shipment, $options) + ->andReturn([ + 'success' => true, + 'tracking_status' => 'transit', + 'tracking_completed' => false, + ]); + + $job->handle($trackingService); +});