Compare commits

...

4 commits

Author SHA1 Message Date
036595be94 27-05-2026 Update DHL Modul v2.0 2026-05-27 13:40:38 +00:00
53bdba33cd User Statistik 2026-05-18 17:23:28 +02:00
70240d2b6a 13-05-2026 implementation FR 2026-05-13 17:33:52 +02:00
245c281541 Steuerberater Modul tax 2026-05-08 15:34:57 +02:00
141 changed files with 11929 additions and 449 deletions

View file

@ -30,7 +30,8 @@
"LARAVEL_SAIL": "1" "LARAVEL_SAIL": "1"
}, },
"mounts": [ "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. // WICHTIG: Nur noch der Vite-Port muss weitergeleitet werden, den Rest macht das Mutterschiff.
"forwardPorts": [ "forwardPorts": [

5
.env
View file

@ -177,3 +177,8 @@ DHL_SENDER_PHONE="+49 123 456789"
DHL_API_TYPE=developer DHL_API_TYPE=developer
DHL_API_SECRET=OyoeePEbYmY1EuOG 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

View file

@ -0,0 +1,551 @@
<?php
namespace App\Console\Commands;
use Illuminate\Console\Command;
use Illuminate\Http\Client\RequestException;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Http;
use Illuminate\Support\Facades\Schema;
use Illuminate\Support\Str;
/*
php artisan translation:backfill-french-db --dry-run
php artisan translation:backfill-french-db
php artisan translation:backfill-french-db --models=products,ingredients --limit=10 --dry-run
php artisan translation:backfill-french-db --overwrite
*/
class BackfillFrenchDatabaseTranslations extends Command
{
/**
* The name and signature of the console command.
*
* @var string
*/
protected $signature = 'translation:backfill-french-db
{--driver=openai : Translation driver: openai or copy-source}
{--models= : Comma-separated models: products,ingredients,categories,shippings,user_levels,dashboard_news}
{--source=de : Source language}
{--target=fr : Target language}
{--limit= : Limit source rows per model}
{--overwrite : Replace existing non-empty target translations}
{--test-api : Translate sample texts and print them without touching the database}
{--dry-run : Show planned writes without changing the database}';
/**
* The console command description.
*
* @var string
*/
protected $description = 'Backfills French database translations for the admin translation module';
/**
* Execute the console command.
*/
public function handle(): int
{
$driver = (string) $this->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<int, string>
*/
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<string, mixed> $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<string, mixed> $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<string, mixed> $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<string, mixed> $spec
* @return array<string, string>
*/
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<string, mixed> $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<string, mixed> $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<string, mixed> $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<string, string>}
*/
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<string, string> $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<string, array<string, mixed>>
*/
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<string, array<string, mixed>>
*/
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'],
],
];
}
}

View file

@ -0,0 +1,127 @@
<?php
namespace App\Console\Commands;
use App\Models\BackofficeStatisticsSnapshot;
use App\Services\Backoffice\BackofficeDashboardService;
use App\User;
use Carbon\Carbon;
use Illuminate\Console\Command;
class BackofficeStoreStatisticsSnapshots extends Command
{
/**
* The name and signature of the console command.
*
* @var string
*/
protected $signature = 'backoffice:store-statistics-snapshots
{--user= : Nur einen bestimmten User berechnen (user_id)}
{--month= : Nur einen bestimmten Monat berechnen}
{--year= : Nur ein bestimmtes Jahr berechnen}
{--force : Bereits vorhandene Snapshots überschreiben}';
/**
* The console command description.
*
* @var string
*/
protected $description = 'Speichert Backoffice-Statistik-Snapshots fuer abgeschlossene Monate';
/**
* Execute the console command.
*/
public function handle(BackofficeDashboardService $dashboardService): int
{
$months = $this->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<array{int, int}>
*/
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());
}
}

View file

@ -244,14 +244,7 @@ class DhlUpdateTracking extends Command
*/ */
private function shouldSendEmail(DhlShipment $shipment, string $oldStatus): bool private function shouldSendEmail(DhlShipment $shipment, string $oldStatus): bool
{ {
// E-Mail nur senden wenn: return $shipment->shouldTriggerTrackingEmail($oldStatus);
// 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();
} }
/** /**
@ -282,7 +275,7 @@ class DhlUpdateTracking extends Command
// Sammle alle Sendungen für diese Bestellung, die noch keine E-Mail erhalten haben // Sammle alle Sendungen für diese Bestellung, die noch keine E-Mail erhalten haben
$allShipments = DhlShipment::where('order_id', $order->id) $allShipments = DhlShipment::where('order_id', $order->id)
->where('status', 'in_transit') ->whereIn('status', DhlShipment::TRACKING_EMAIL_TRIGGER_STATUSES)
->whereNotNull('dhl_shipment_no') ->whereNotNull('dhl_shipment_no')
->whereNull('tracking_email_sent_at') ->whereNull('tracking_email_sent_at')
->get(); ->get();
@ -297,7 +290,7 @@ class DhlUpdateTracking extends Command
// Markiere alle Sendungen als versendet // Markiere alle Sendungen als versendet
foreach ($allShipments as $s) { foreach ($allShipments as $s) {
$s->markTrackingEmailSent('auto'); $s->markTrackingEmailSent('auto', $recipientEmail, $allShipments);
} }
Log::info('[DHL Cron] Tracking email sent automatically', [ Log::info('[DHL Cron] Tracking email sent automatically', [

View file

@ -55,6 +55,9 @@ class Kernel extends ConsoleKernel
// Abo-Chart-Snapshots: vergangene Monate einfrieren (nach allen Abo-Jobs) // Abo-Chart-Snapshots: vergangene Monate einfrieren (nach allen Abo-Jobs)
$schedule->command('abo:store-chart-snapshots')->dailyAt('04:30'); $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 // Incentive: Punkteberechnung täglich nach business:store-optimized
$schedule->command('incentive:calculate')->dailyAt('05:00'); $schedule->command('incentive:calculate')->dailyAt('05:00');

View file

@ -3,6 +3,7 @@
namespace App\Cron; namespace App\Cron;
use App\Http\Controllers\Pay\PayoneController; use App\Http\Controllers\Pay\PayoneController;
use App\Models\PaymentTransaction;
use App\Models\ShoppingOrder; use App\Models\ShoppingOrder;
use App\Models\ShoppingOrderItem; use App\Models\ShoppingOrderItem;
use App\Models\UserAbo; use App\Models\UserAbo;
@ -74,6 +75,7 @@ class UserMakeOrder
$this->pay->setAboPayment($this->userAbo, $amount, 'EUR'); $this->pay->setAboPayment($this->userAbo, $amount, 'EUR');
$this->pay->setPersonalData(); $this->pay->setPersonalData();
$response = $this->pay->onlyPaymentResponse(); $response = $this->pay->onlyPaymentResponse();
$this->recordPaymentTransaction($response);
\Log::info('Response: '.json_encode($response)); \Log::info('Response: '.json_encode($response));
// $response = $this->pay->ResponseData(true); // $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() public function getShoppingPayment()
{ {
Log::info('Rufe Zahlungsinformationen ab für UserAbo ID: '.$this->userAbo->id); Log::info('Rufe Zahlungsinformationen ab für UserAbo ID: '.$this->userAbo->id);

View file

@ -7,6 +7,7 @@ use App\Models\UserAbo;
use App\Repositories\AboRepository; use App\Repositories\AboRepository;
use App\Services\AboItemHistoryService; use App\Services\AboItemHistoryService;
use App\Services\AboOrderCart; use App\Services\AboOrderCart;
use App\Services\AboRetryPaymentService;
use App\Services\Shop; use App\Services\Shop;
use Request; use Request;
@ -102,6 +103,16 @@ class AboController extends Controller
return redirect(route('admin_abos_detail', [$id])); 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() public function datatable()
{ {

View file

@ -7,9 +7,13 @@ use App\Jobs\CancelShipmentJob;
// Old DHL model replaced with new package model // Old DHL model replaced with new package model
use App\Jobs\CreateReturnLabelJob; use App\Jobs\CreateReturnLabelJob;
use App\Mail\MailDhlTracking; use App\Mail\MailDhlTracking;
use App\Models\Country;
use App\Models\ShoppingOrder; use App\Models\ShoppingOrder;
use App\Services\DhlAddressValidator;
use App\Services\DhlModalService; use App\Services\DhlModalService;
use App\Services\DhlProductResolver;
use App\Services\DhlShipmentService; use App\Services\DhlShipmentService;
use App\Services\DhlShipmentWeightCalculator;
use App\Services\DhlTrackingService; use App\Services\DhlTrackingService;
use Exception; use Exception;
use Illuminate\Http\JsonResponse; use Illuminate\Http\JsonResponse;
@ -122,7 +126,12 @@ class DhlShipmentController extends Controller
$query->where('type', $request->get('type')); $query->where('type', $request->get('type'));
} }
if ($request->filled('status')) { 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')) { if ($request->filled('date_from')) {
$query->whereDate('created_at', '>=', $request->get('date_from')); $query->whereDate('created_at', '>=', $request->get('date_from'));
@ -181,10 +190,11 @@ class DhlShipmentController extends Controller
'created' => ['class' => 'success', 'text' => 'Erstellt'], 'created' => ['class' => 'success', 'text' => 'Erstellt'],
'shipped' => ['class' => 'primary', 'text' => 'Versendet'], 'shipped' => ['class' => 'primary', 'text' => 'Versendet'],
'delivered' => ['class' => 'info', 'text' => 'Zugestellt'], 'delivered' => ['class' => 'info', 'text' => 'Zugestellt'],
'cancelled' => ['class' => 'secondary', 'text' => 'Storniert'], 'canceled' => ['class' => 'secondary', 'text' => 'Storniert'],
'failed' => ['class' => 'danger', 'text' => 'Fehler'], '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 '<span class="badge badge-'.$statusInfo['class'].'">'.$statusInfo['text'].'</span>'; return '<span class="badge badge-'.$statusInfo['class'].'">'.$statusInfo['text'].'</span>';
}) })
@ -277,6 +287,7 @@ class DhlShipmentController extends Controller
'order_id' => 'required|exists:shopping_orders,id', 'order_id' => 'required|exists:shopping_orders,id',
'weight' => 'required|numeric|min:0.1|max:31.5', 'weight' => 'required|numeric|min:0.1|max:31.5',
'product_code' => 'sometimes|string', 'product_code' => 'sometimes|string',
'reference' => 'nullable|string|max:35',
'priority' => 'sometimes|string|in:normal,high', 'priority' => 'sometimes|string|in:normal,high',
'auto_track' => 'sometimes|boolean', 'auto_track' => 'sometimes|boolean',
// Shipping address validation // Shipping address validation
@ -294,6 +305,10 @@ class DhlShipmentController extends Controller
]); ]);
$order = ShoppingOrder::findOrFail($request->order_id); $order = ShoppingOrder::findOrFail($request->order_id);
$shipmentWeight = max(
(float) $request->weight,
(new DhlShipmentWeightCalculator)->calculate($order)
);
// Check if shipment already exists // Check if shipment already exists
/* $existingShipment = DhlShipment::where('shopping_order_id', $order->id) /* $existingShipment = DhlShipment::where('shopping_order_id', $order->id)
@ -314,6 +329,7 @@ class DhlShipmentController extends Controller
// Prepare options for shipment creation // Prepare options for shipment creation
$options = [ $options = [
'product_code' => $request->get('product_code', 'V01PAK'), 'product_code' => $request->get('product_code', 'V01PAK'),
'reference' => $request->get('reference'),
'priority' => $request->get('priority', 'normal'), 'priority' => $request->get('priority', 'normal'),
'auto_track' => $request->get('auto_track', true), 'auto_track' => $request->get('auto_track', true),
'shipping_address' => $shippingAddress, 'shipping_address' => $shippingAddress,
@ -323,15 +339,20 @@ class DhlShipmentController extends Controller
// Use DhlShipmentService (handles queue/sync automatically based on config) // Use DhlShipmentService (handles queue/sync automatically based on config)
$dhlShipmentService = new DhlShipmentService; $dhlShipmentService = new DhlShipmentService;
$result = $dhlShipmentService->createShipment($order, (float) $request->weight, $options); $result = $dhlShipmentService->createShipment($order, $shipmentWeight, $options);
Log::info('[DHL Controller] Shipment creation processed', [ Log::info('[DHL Controller] Shipment creation processed', [
'order_id' => $order->id, 'order_id' => $order->id,
'weight' => $request->weight, 'weight' => $shipmentWeight,
'requested_weight' => $request->weight,
'queued' => $result['queued'] ?? false, 'queued' => $result['queued'] ?? false,
'success' => $result['success'] ?? 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); return response()->json($result);
} catch (Exception $e) { } catch (Exception $e) {
Log::error('[DHL Controller] Failed to dispatch shipment creation', [ 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 * Display the specified shipment
*/ */
@ -719,7 +815,7 @@ class DhlShipmentController extends Controller
// Mark all included shipments as sent // Mark all included shipments as sent
foreach ($allShipments as $s) { foreach ($allShipments as $s) {
$s->markTrackingEmailSent('manual'); $s->markTrackingEmailSent('manual', $recipientEmail, $allShipments);
} }
Log::info('[DHL Controller] Tracking email sent', [ Log::info('[DHL Controller] Tracking email sent', [

View file

@ -241,6 +241,8 @@ class ModalController extends Controller
'V01PAK' => 'DHL Paket (National)', 'V01PAK' => 'DHL Paket (National)',
'V53WPAK' => 'DHL Paket International', 'V53WPAK' => 'DHL Paket International',
], ],
'productSuggestions' => (new \App\Services\DhlProductResolver)->getProductSuggestionsByCountry(),
'selectedProductCode' => 'V01PAK',
'errors' => ['Fehler beim Laden der Daten: '.$e->getMessage()], 'errors' => ['Fehler beim Laden der Daten: '.$e->getMessage()],
'warnings' => [], 'warnings' => [],
]; ];

View file

@ -3,6 +3,8 @@
namespace App\Http\Controllers; namespace App\Http\Controllers;
use App\Models\Setting; use App\Models\Setting;
use App\Services\DhlProductResolver;
use Illuminate\Support\Facades\Session;
use Request; use Request;
class SettingController extends Controller class SettingController extends Controller
@ -36,9 +38,9 @@ class SettingController extends Controller
// DHL-spezifische Behandlung // DHL-spezifische Behandlung
if ($data['action'] === 'save_dhl') { if ($data['action'] === 'save_dhl') {
$this->updateDhlConfigCache(); $this->updateDhlConfigCache();
\Session()->flash('alert-save-dhl', 'DHL Konfiguration erfolgreich gespeichert!'); Session::flash('alert-save-dhl', 'DHL Konfiguration erfolgreich gespeichert!');
} else { } 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), 'test_mode' => config('dhl.legacy.test_mode', true),
// Product Settings // 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), 'label_format' => $this->getConfigValue('dhl_label_format', config('dhl.label_format'), $useEnvPriority),
'print_format' => $this->getConfigValue('dhl_print_format', config('dhl.print_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), '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), 'use_queue' => $this->getConfigValue('dhl_use_queue', config('dhl.use_queue'), $useEnvPriority),
// Sender Address // Sender Address
@ -94,7 +98,7 @@ class SettingController extends Controller
// Account Numbers // Account Numbers
'account_numbers' => [ 'account_numbers' => [
'V01PAK' => $this->getConfigValue('dhl_account_v01pak', config('dhl.account_numbers.V01PAK'), $useEnvPriority), '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), 'V53PAK' => $this->getConfigValue('dhl_account_v53pak', config('dhl.account_numbers.V53PAK'), $useEnvPriority),
'V07PAK' => $this->getConfigValue('dhl_account_v07pak', config('dhl.account_numbers.V07PAK'), $useEnvPriority), 'V07PAK' => $this->getConfigValue('dhl_account_v07pak', config('dhl.account_numbers.V07PAK'), $useEnvPriority),
'default' => config('dhl.account_numbers.default'), 'default' => config('dhl.account_numbers.default'),
@ -103,7 +107,7 @@ class SettingController extends Controller
// Dimensions // Dimensions
'dimensions' => [ 'dimensions' => [
'V01PAK' => config('dhl.dimensions.V01PAK'), 'V01PAK' => config('dhl.dimensions.V01PAK'),
'V62WP' => config('dhl.dimensions.V62WP'), 'V62KP' => config('dhl.dimensions.V62KP'),
'V53PAK' => config('dhl.dimensions.V53PAK'), 'V53PAK' => config('dhl.dimensions.V53PAK'),
'V07PAK' => config('dhl.dimensions.V07PAK'), 'V07PAK' => config('dhl.dimensions.V07PAK'),
'default' => config('dhl.dimensions.default'), '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 * Update DHL configuration cache after saving settings
*/ */

View file

@ -107,7 +107,8 @@ class AboController extends Controller
$data = Request::all(); $data = Request::all();
$user_abo = UserAbo::findOrFail($id); $user_abo = UserAbo::findOrFail($id);
$this->checkPermissions($view, $user_abo); $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 (isset($data['action'])) {
if ($data['action'] === 'abo_update_settings') { if ($data['action'] === 'abo_update_settings') {
@ -127,7 +128,7 @@ class AboController extends Controller
$qtyBefore = $UserAboItem->qty; $qtyBefore = $UserAboItem->qty;
$UserAboItem->qty = $UserAboItem->qty + 1; $UserAboItem->qty = $UserAboItem->qty + 1;
$UserAboItem->save(); $UserAboItem->save();
AboItemHistoryService::logProductAdded($user_abo, $UserAboItem, $qtyBefore, $view); AboItemHistoryService::logProductAdded($user_abo, $UserAboItem, $qtyBefore, $editView);
} else { } else {
$newItem = UserAboItem::create([ $newItem = UserAboItem::create([
'user_abo_id' => $user_abo->id, 'user_abo_id' => $user_abo->id,
@ -136,7 +137,7 @@ class AboController extends Controller
'qty' => 1, 'qty' => 1,
'status' => 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->qty = $qty;
$UserAboItem->save(); $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'); $message = __('abo.need_basis_product');
} }
if (! $message) { if (! $message) {
AboItemHistoryService::logProductRemoved($user_abo, $userAboItem, $view); AboItemHistoryService::logProductRemoved($user_abo, $userAboItem, $editView);
$userAboItem->delete(); $userAboItem->delete();
$user_abo->refresh(); // Abo neu laden um die aktualisierten Items zu erhalten $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->product_id = $data['comp_product_id'];
$UserAboItem->save(); $UserAboItem->save();
$UserAboItem->load('product'); $UserAboItem->load('product');
AboItemHistoryService::logCompProductChanged($user_abo, $UserAboItem, $oldProduct, $UserAboItem->product, $view); AboItemHistoryService::logCompProductChanged($user_abo, $UserAboItem, $oldProduct, $UserAboItem->product, $editView);
} else { } else {
$newItem = UserAboItem::create([ $newItem = UserAboItem::create([
'user_abo_id' => $user_abo->id, 'user_abo_id' => $user_abo->id,
@ -202,7 +203,7 @@ class AboController extends Controller
'qty' => 1, 'qty' => 1,
'status' => 1, 'status' => 1,
]); ]);
AboItemHistoryService::logProductAdded($user_abo, $newItem, 0, $view); AboItemHistoryService::logProductAdded($user_abo, $newItem, 0, $editView);
} }
} }

View file

@ -0,0 +1,279 @@
<?php
namespace App\Http\Controllers\User;
use App\Http\Controllers\Controller;
use App\Services\Backoffice\BackofficeDashboardService;
use App\Services\Backoffice\BackofficeDrilldownService;
use App\Services\HTMLHelper;
use Illuminate\Http\Request;
use Illuminate\View\View;
use Symfony\Component\HttpFoundation\StreamedResponse;
class BackofficeStatisticsController extends Controller
{
private const SESSION_MONTH_KEY = 'backoffice_statistics_month';
private const SESSION_YEAR_KEY = 'backoffice_statistics_year';
public function __construct(
private BackofficeDashboardService $dashboardService,
private BackofficeDrilldownService $drilldownService
) {
$this->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<string, mixed> $row
* @return array<int, mixed>
*/
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<string, mixed> $row
* @return array<int, mixed>
*/
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<string, mixed> $details
* @return array<int, mixed>
*/
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', '', '', '', ''];
}
}

View file

@ -87,6 +87,7 @@ class CheckoutController extends Controller
'is_for' => $is_for, 'is_for' => $is_for,
'is_abo' => $is_abo, 'is_abo' => $is_abo,
'abo_interval' => $abo_interval, 'abo_interval' => $abo_interval,
'customer_order_source_options' => ShoppingOrder::customerOrderSourceOptions(),
'shopping_data' => $shopping_data, 'shopping_data' => $shopping_data,
'user_shop' => Util::getUserShop(), 'user_shop' => Util::getUserShop(),
'shopping_user' => $shopping_user, 'shopping_user' => $shopping_user,
@ -225,6 +226,11 @@ class CheckoutController extends Controller
'accepted_data_checkbox' => 'accepted', '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')) { if (Request::get('same_as_billing')) {
$rules = array_merge($rules, [ $rules = array_merge($rules, [
'shipping_firstname' => 'required', 'shipping_firstname' => 'required',

View file

@ -2,8 +2,9 @@
namespace App\Jobs; namespace App\Jobs;
use App\Models\DhlShipment; use Acme\Dhl\Models\DhlShipment;
use App\Services\DhlApiService; use App\Services\DhlTrackingService;
use DateTime;
use Exception; use Exception;
use Illuminate\Bus\Queueable as BusQueueable; use Illuminate\Bus\Queueable as BusQueueable;
use Illuminate\Contracts\Queue\ShouldQueue; use Illuminate\Contracts\Queue\ShouldQueue;
@ -37,7 +38,7 @@ class TrackShipmentJob implements ShouldQueue
* *
* @var int * @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. * The maximum number of seconds the job can run before timing out.
@ -49,8 +50,7 @@ class TrackShipmentJob implements ShouldQueue
/** /**
* Create a new job instance. * Create a new job instance.
* *
* @param DhlShipment $dhlShipment * @param array<string, mixed> $options
* @param array $options
*/ */
public function __construct(DhlShipment $dhlShipment, array $options = []) public function __construct(DhlShipment $dhlShipment, array $options = [])
{ {
@ -64,29 +64,28 @@ class TrackShipmentJob implements ShouldQueue
/** /**
* Execute the job. * Execute the job.
*/ */
public function handle(): void public function handle(DhlTrackingService $trackingService): void
{ {
try { try {
Log::info('[DHL Queue] Starting shipment tracking job', [ Log::info('[DHL Queue] Starting shipment tracking job', [
'shipment_id' => $this->dhlShipment->id, 'shipment_id' => $this->dhlShipment->id,
'tracking_number' => $this->dhlShipment->tracking_number, 'dhl_shipment_no' => $this->dhlShipment->dhl_shipment_no,
'attempt' => $this->attempts(), 'attempt' => $this->attempts(),
]); ]);
$dhlService = new DhlApiService(); $result = $trackingService->updateTrackingNow($this->dhlShipment, $this->options);
// Get tracking details
$trackingDetails = $dhlService->getTrackingDetails($this->dhlShipment);
Log::info('[DHL Queue] Shipment tracking updated successfully', [ Log::info('[DHL Queue] Shipment tracking updated successfully', [
'shipment_id' => $this->dhlShipment->id, 'shipment_id' => $this->dhlShipment->id,
'tracking_status' => $trackingDetails['status'] ?? 'unknown', 'success' => $result['success'] ?? false,
'events_count' => isset($trackingDetails['events']) ? count($trackingDetails['events']) : 0, 'tracking_status' => $result['tracking_status'] ?? 'unknown',
'tracking_completed' => $result['tracking_completed'] ?? false,
]); ]);
// Schedule next tracking update if shipment is still in transit // Schedule next tracking update if shipment is still in transit
if (isset($this->options['auto_retrack']) && $this->options['auto_retrack']) { if (($this->options['auto_retrack'] ?? false) && ! ($result['tracking_completed'] ?? false)) {
$status = $trackingDetails['status'] ?? ''; $this->dhlShipment->refresh();
$status = $this->dhlShipment->status ?? '';
if ($this->shouldContinueTracking($status)) { if ($this->shouldContinueTracking($status)) {
// Schedule next tracking in 2-6 hours based on current status // Schedule next tracking in 2-6 hours based on current status
$nextTrackingDelay = $this->getNextTrackingDelay($status); $nextTrackingDelay = $this->getNextTrackingDelay($status);
@ -103,7 +102,7 @@ class TrackShipmentJob implements ShouldQueue
} catch (Exception $e) { } catch (Exception $e) {
Log::warning('[DHL Queue] Shipment tracking failed', [ Log::warning('[DHL Queue] Shipment tracking failed', [
'shipment_id' => $this->dhlShipment->id, 'shipment_id' => $this->dhlShipment->id,
'tracking_number' => $this->dhlShipment->tracking_number, 'dhl_shipment_no' => $this->dhlShipment->dhl_shipment_no,
'error' => $e->getMessage(), 'error' => $e->getMessage(),
'attempt' => $this->attempts(), 'attempt' => $this->attempts(),
'max_tries' => $this->tries, 'max_tries' => $this->tries,
@ -126,14 +125,12 @@ class TrackShipmentJob implements ShouldQueue
/** /**
* Handle a job failure. * Handle a job failure.
*
* @param Exception $exception
*/ */
public function failed(Exception $exception): void public function failed(Exception $exception): void
{ {
Log::warning('[DHL Queue] TrackShipmentJob permanently failed', [ Log::warning('[DHL Queue] TrackShipmentJob permanently failed', [
'shipment_id' => $this->dhlShipment->id, 'shipment_id' => $this->dhlShipment->id,
'tracking_number' => $this->dhlShipment->tracking_number, 'dhl_shipment_no' => $this->dhlShipment->dhl_shipment_no,
'error' => $exception->getMessage(), 'error' => $exception->getMessage(),
]); ]);
@ -142,29 +139,14 @@ class TrackShipmentJob implements ShouldQueue
/** /**
* Determine if we should continue tracking this shipment * Determine if we should continue tracking this shipment
*
* @param string $status
* @return bool
*/ */
private function shouldContinueTracking(string $status): bool private function shouldContinueTracking(string $status): bool
{ {
$finalStates = [ return ! in_array(strtolower($status), DhlShipment::TERMINAL_STATUSES, true);
'delivered',
'delivered_to_recipient',
'delivered_to_pickup_location',
'returned_to_sender',
'cancelled',
'lost',
];
return !in_array(strtolower($status), $finalStates);
} }
/** /**
* Get delay for next tracking update based on current status * 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 private function getNextTrackingDelay(string $status): int
{ {
@ -184,10 +166,8 @@ class TrackShipmentJob implements ShouldQueue
/** /**
* Determine the time at which the job should timeout. * 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 return now()->addMinutes(30); // Short timeout for tracking
} }

View file

@ -0,0 +1,29 @@
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Model;
class BackofficeStatisticsSnapshot extends Model
{
protected $table = 'backoffice_statistics_snapshots';
protected $fillable = [
'user_id',
'year',
'month',
'payload',
'calculated_at',
];
protected function casts(): array
{
return [
'user_id' => 'int',
'year' => 'int',
'month' => 'int',
'payload' => 'array',
'calculated_at' => 'datetime',
];
}
}

View file

@ -167,6 +167,8 @@ class ShoppingOrder extends Model
'api_notice', 'api_notice',
'api_status', 'api_status',
'mode', 'mode',
'customer_order_source',
'customer_order_source_comment',
'shipped', 'shipped',
'tracking', 'tracking',
]; ];
@ -180,6 +182,29 @@ class ShoppingOrder extends Model
'points' => 'float', '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<string, string>
*/
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 = [ public static $shippedTypes = [
0 => 'open', 0 => 'open',
1 => 'in_process', 1 => 'in_process',

View file

@ -4,6 +4,7 @@ namespace App\Repositories;
use App\Models\UserAbo; use App\Models\UserAbo;
use App\Services\AboHelper; use App\Services\AboHelper;
use Carbon\Carbon;
class AboRepository extends BaseRepository class AboRepository extends BaseRepository
{ {
@ -27,8 +28,10 @@ class AboRepository extends BaseRepository
if ($data['action'] === 'abo_update_settings') { if ($data['action'] === 'abo_update_settings') {
if ($this->validate($data)) { if ($this->validate($data)) {
$this->updateStatus($data); $this->updateStatus($data);
$this->model->abo_interval = $data['abo_interval']; $this->model->abo_interval = (int) $data['abo_interval'];
$nextDate = $this->calculateNewNextDate($data['abo_interval']); $nextDate = $this->isAdminUpdate($data)
? $this->calculateAdminNextDate($data)
: $this->calculateNewNextDate((int) $data['abo_interval']);
$this->model->next_date = $nextDate; $this->model->next_date = $nextDate;
$this->model->save(); $this->model->save();
@ -49,10 +52,12 @@ class AboRepository extends BaseRepository
private function updateStatus($data) private function updateStatus($data)
{ {
$isAdminUpdate = $this->isAdminUpdate($data);
// Handle cancellation // Handle cancellation
if (isset($data['abo_cancel']) && $data['abo_cancel'] == 'true') { if (isset($data['abo_cancel']) && $data['abo_cancel'] == 'true') {
// Sperre: 3 Tage vor Ausführung kann nicht mehr pausiert/gekündigt werden // 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); $daysUntil = (int) now()->diffInDays(\Carbon\Carbon::parse($this->model->next_date), false);
if ($daysUntil >= 0 && $daysUntil < self::LOCK_DAYS_PAUSE_CANCEL) { if ($daysUntil >= 0 && $daysUntil < self::LOCK_DAYS_PAUSE_CANCEL) {
\Session()->flash('alert-error', __('abo.error_cancel_locked', ['days' => $daysUntil])); \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; $active = (isset($data['abo_is_active']) && $data['abo_is_active']) ? true : false;
// Sperre: 3 Tage vor Ausführung kann nicht mehr pausiert werden // 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); $daysUntil = (int) now()->diffInDays(\Carbon\Carbon::parse($this->model->next_date), false);
if ($daysUntil >= 0 && $daysUntil < self::LOCK_DAYS_PAUSE_CANCEL) { if ($daysUntil >= 0 && $daysUntil < self::LOCK_DAYS_PAUSE_CANCEL) {
\Session()->flash('alert-error', __('abo.error_pause_locked', ['days' => $daysUntil])); \Session()->flash('alert-error', __('abo.error_pause_locked', ['days' => $daysUntil]));
@ -122,12 +127,28 @@ class AboRepository extends BaseRepository
return false; 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')); \Session()->flash('alert-error', __('abo.error_abo_interval'));
return false; 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) // Sperre: 10 Tage vor nächster Ausführung keine Änderungen mehr (Pakete werden vorgepackt)
if ($this->model->next_date) { if ($this->model->next_date) {
$daysUntilExecution = (int) now()->diffInDays(\Carbon\Carbon::parse($this->model->next_date), false); $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); 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<int, string>
*/
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';
}
} }

View file

@ -28,6 +28,7 @@ class CheckoutRepository extends BaseRepository
public function makeShoppingOrder($shopping_user, $data) public function makeShoppingOrder($shopping_user, $data)
{ {
$requestData = $data;
$user_shop = Util::getUserShop(); $user_shop = Util::getUserShop();
if ($shopping_user->is_from === 'homeparty') { if ($shopping_user->is_from === 'homeparty') {
@ -74,6 +75,7 @@ class CheckoutRepository extends BaseRepository
'subtotal_ws' => $ShoppingCollectOrder->price_total_net, 'subtotal_ws' => $ShoppingCollectOrder->price_total_net,
'tax' => $ShoppingCollectOrder->tax_total, 'tax' => $ShoppingCollectOrder->tax_total,
'tax_split' => $ShoppingCollectOrder->tax_split, 'tax_split' => $ShoppingCollectOrder->tax_split,
'net_split' => $ShoppingCollectOrder->net_split,
'total_shipping' => Yard::instance($this->instance)->totalWithShipping(2, '.', ''), 'total_shipping' => Yard::instance($this->instance)->totalWithShipping(2, '.', ''),
'points' => round($ShoppingCollectOrder->points, 2), 'points' => round($ShoppingCollectOrder->points, 2),
'weight' => 0, 'weight' => 0,
@ -102,6 +104,11 @@ class CheckoutRepository extends BaseRepository
'txaction' => 'prev', 'txaction' => 'prev',
'mode' => Util::getUserShoppingMode(), '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; $shopping_order = false;

View file

@ -0,0 +1,238 @@
<?php
namespace App\Services;
use App\Cron\UserMakeOrder;
use App\Models\UserAbo;
use App\Models\UserAboOrder;
use App\Services\Incentive\IncentiveTracker;
use Illuminate\Support\Facades\DB;
class AboRetryPaymentService
{
/**
* @return array{success: bool, message: string, order_id?: int}
*/
public function retry(UserAbo $userAbo): array
{
if ($userAbo->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<string, mixed>
*/
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<string, mixed> $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<string, mixed> $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<string, mixed> $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;
}
}

View file

@ -0,0 +1,365 @@
<?php
namespace App\Services\Backoffice;
use App\Models\BackofficeStatisticsSnapshot;
use App\Models\UserAbo;
use App\Models\UserSalesVolume;
use App\User;
use Carbon\Carbon;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Support\Collection;
class BackofficeDashboardService
{
private const MAX_DEPTH_SAFETY_LIMIT = 100;
/**
* @return array<string, string>
*/
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<string, string>, lines: array<int, array<string, mixed>>, totals: array<string, mixed>}
*/
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<string, string>, lines: array<int, array<string, mixed>>, totals: array<string, mixed>}
*/
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<string, mixed> $overview
* @return array<string, mixed>
*/
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<int, \Illuminate\Support\Collection<int, \App\User>>
*/
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<int, \App\User> $users
* @return array<string, mixed>
*/
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<string, mixed>
*/
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<string, mixed> $totals
* @param array<string, mixed> $row
* @return array<string, mixed>
*/
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<int, \App\User> $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<int, \App\User> $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();
}
}

View file

@ -0,0 +1,331 @@
<?php
namespace App\Services\Backoffice;
use App\Models\UserAbo;
use App\Models\UserSalesVolume;
use App\User;
use Carbon\Carbon;
use Illuminate\Database\Eloquent\Builder;
class BackofficeDrilldownService
{
public function __construct(private BackofficeDashboardService $dashboardService) {}
/**
* @return array{metric: string, metric_label: string, line: int, line_label: string, month: int, year: int, rows: array<int, array<string, mixed>>, summary: array<string, mixed>}
*/
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<int, array<string, mixed>> $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<int, array<string, mixed>>
*/
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<int, array<string, mixed>>
*/
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<int, array<string, mixed>>
*/
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<int, array<string, mixed>>
*/
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<int, array<string, mixed>>
*/
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');
}
}

View file

@ -254,6 +254,7 @@ class DatevExportService
'shopping_order.country.country', 'shopping_order.country.country',
'shopping_order.shopping_user', 'shopping_order.shopping_user',
'shopping_order.auth_user.account', 'shopping_order.auth_user.account',
'shopping_order.shopping_collect_order',
]) ])
->where('month', $month) ->where('month', $month)
->where('year', $year) ->where('year', $year)
@ -289,13 +290,15 @@ class DatevExportService
// Tax-Split vorhanden? -> Mehrere Zeilen pro Steuersatz // Tax-Split vorhanden? -> Mehrere Zeilen pro Steuersatz
if ($order->tax_split && is_array($order->tax_split) && count($order->tax_split) > 0) { 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) { foreach ($order->tax_split as $taxRate => $taxAmount) {
$taxRate = intval($taxRate); $taxRate = intval($taxRate);
$taxAmountFloat = $this->parseNumber($taxAmount); $taxAmountFloat = $this->parseNumber($taxAmount, 'ek_tax');
$netAmount = 0; $netAmount = 0;
if ($order->net_split && isset($order->net_split[$taxRate])) { if ($netSplit && isset($netSplit[$taxRate])) {
$netAmount = $this->parseNumber($order->net_split[$taxRate]); $netAmount = $this->parseNumber($netSplit[$taxRate], 'ek_net');
} }
$grossAmount = round($netAmount + $taxAmountFloat, 2); $grossAmount = round($netAmount + $taxAmountFloat, 2);
@ -487,6 +490,7 @@ class DatevExportService
'shopping_order.country.country', 'shopping_order.country.country',
'shopping_order.shopping_user', 'shopping_order.shopping_user',
'shopping_order.auth_user.account', 'shopping_order.auth_user.account',
'shopping_order.shopping_collect_order',
]) ])
->where('month', $month) ->where('month', $month)
->where('year', $year) ->where('year', $year)
@ -512,13 +516,15 @@ class DatevExportService
$hasValidUstid = ! empty($euUstid); $hasValidUstid = ! empty($euUstid);
if ($order->tax_split && is_array($order->tax_split) && count($order->tax_split) > 0) { 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) { foreach ($order->tax_split as $taxRate => $taxAmount) {
$taxRate = intval($taxRate); $taxRate = intval($taxRate);
$taxAmountFloat = $this->parseNumber($taxAmount); $taxAmountFloat = $this->parseNumber($taxAmount, 'ek_tax');
$netAmount = 0; $netAmount = 0;
if ($order->net_split && isset($order->net_split[$taxRate])) { if ($netSplit && isset($netSplit[$taxRate])) {
$netAmount = $this->parseNumber($order->net_split[$taxRate]); $netAmount = $this->parseNumber($netSplit[$taxRate], 'ek_net');
} }
$grossAmount = round($netAmount + $taxAmountFloat, 2); $grossAmount = round($netAmount + $taxAmountFloat, 2);
@ -1038,20 +1044,60 @@ class DatevExportService
return Carbon::create($invoice->year, $invoice->month, 1)->format('Y-m-d'); 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. * Parst einen formatierten Zahlenwert (z.B. "5.00" oder "5,00") zu float.
* Behandelt sowohl einfache Werte als auch tax_split Arrays (homeparty). * 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)) { if (is_array($value)) {
// Homeparty tax_split Format: ['vk_tax' => '5.00', 'ek_tax' => '2.00'] $arrayKeys = $this->getSplitArrayKeys($preferredArrayKey);
return floatval(str_replace(',', '.', $value['vk_tax'] ?? 0));
foreach ($arrayKeys as $arrayKey) {
if (array_key_exists($arrayKey, $value)) {
return $this->parseNumber($value[$arrayKey]);
}
}
return 0.0;
} }
return floatval(str_replace(',', '.', $value)); return floatval(str_replace(',', '.', $value));
} }
/**
* @return array<int, string>
*/
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). * Escaped ein CSV-Feld (Semikolon, Anführungszeichen, Newlines).
*/ */

View file

@ -0,0 +1,258 @@
<?php
namespace App\Services;
class DhlAddressValidator
{
private const DACH_COUNTRIES = ['DE', 'AT', 'CH'];
private const COUNTRY_SPECIFIC_POSTAL_PATTERNS = [
'DE' => ['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<int, string>, warnings: array<int, string>, normalized: array<string, mixed>, 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<string, string>
*/
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<string, string>
*/
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;
}
}

View file

@ -35,15 +35,27 @@ class DhlDataHelper
$settingController = new SettingController; $settingController = new SettingController;
$dhlConfig = $settingController->getDhlConfig(); $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 [ return [
'order_id' => $order->id, 'order_id' => $order->id,
'weight_kg' => $weight, 'weight_kg' => $weight,
'product_code' => $options['product_code'] ?? 'V01PAK', 'product_code' => $resolvedDhlProduct['product_code'],
'label_format' => $options['label_format'] ?? $dhlConfig['label_format'] ?? 'PDF', 'label_format' => $options['label_format'] ?? $dhlConfig['label_format'] ?? 'PDF',
'print_format' => $options['print_format'] ?? $dhlConfig['print_format'] ?? null, 'print_format' => $options['print_format'] ?? $dhlConfig['print_format'] ?? null,
'retoure_print_format' => $options['retoure_print_format'] ?? $dhlConfig['retoure_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 data (sender) - from admin settings
'shipper' => [ 'shipper' => [
@ -66,7 +78,7 @@ class DhlDataHelper
'houseNumber' => $shippingAddress['houseNumber'] ?? '', 'houseNumber' => $shippingAddress['houseNumber'] ?? '',
'postalCode' => $shippingAddress['zipcode'] ?? '', 'postalCode' => $shippingAddress['zipcode'] ?? '',
'city' => $shippingAddress['city'] ?? '', 'city' => $shippingAddress['city'] ?? '',
'country' => $shippingAddress['country']?->code ?? 'DE', 'country' => $resolvedDhlProduct['country_code'],
'email' => $shippingAddress['email'] ?? '', 'email' => $shippingAddress['email'] ?? '',
'phone' => $shippingAddress['phone'] ?? '', 'phone' => $shippingAddress['phone'] ?? '',
// DHL Postnummer für Packstation/Paketbox // DHL Postnummer für Packstation/Paketbox
@ -82,7 +94,18 @@ class DhlDataHelper
'services' => $options['services'] ?? [], 'services' => $options['services'] ?? [],
// Custom reference for tracking // 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);
}
} }

View file

@ -45,6 +45,8 @@ class DhlModalService
'shippingAddress' => null, 'shippingAddress' => null,
'availableCountries' => $this->getAvailableCountries(), 'availableCountries' => $this->getAvailableCountries(),
'productCodes' => $this->getAvailableProductCodes(), 'productCodes' => $this->getAvailableProductCodes(),
'productSuggestions' => (new DhlProductResolver)->getProductSuggestionsByCountry(),
'selectedProductCode' => null,
'errors' => [], 'errors' => [],
'warnings' => [], 'warnings' => [],
'existingShipments' => [], 'existingShipments' => [],
@ -89,6 +91,7 @@ class DhlModalService
// Process and validate shipping address // Process and validate shipping address
$result['shippingAddress'] = $this->processShippingAddress($order); $result['shippingAddress'] = $this->processShippingAddress($order);
$result['selectedProductCode'] = $this->getSuggestedProductCode($result['shippingAddress']);
// Validate address completeness // Validate address completeness
$addressValidation = $this->validateAddress($result['shippingAddress']); $addressValidation = $this->validateAddress($result['shippingAddress']);
@ -125,7 +128,7 @@ class DhlModalService
private function loadOrder($id): ?ShoppingOrder private function loadOrder($id): ?ShoppingOrder
{ {
return ShoppingOrder::with([ return ShoppingOrder::with([
'shopping_order_items', 'shopping_order_items.product',
'shopping_user', 'shopping_user',
'dhlShipments', // Include DHL shipments 'dhlShipments', // Include DHL shipments
])->find($id); ])->find($id);
@ -170,38 +173,7 @@ class DhlModalService
*/ */
private function calculateOrderWeight(ShoppingOrder $order): float private function calculateOrderWeight(ShoppingOrder $order): float
{ {
return $order->weight / 1000; // from grams to kg return (new DhlShipmentWeightCalculator)->calculate($order);
/*
// 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);
*/
} }
/** /**
@ -287,50 +259,12 @@ class DhlModalService
*/ */
private function validateAddress(array $address): array private function validateAddress(array $address): array
{ {
$errors = []; $result = (new DhlAddressValidator)->validate($address);
$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.';
}
return [ return [
'valid' => empty($errors), 'valid' => $result['can_create_label'],
'errors' => $errors, 'errors' => $result['errors'],
'warnings' => $warnings, 'warnings' => $result['warnings'],
]; ];
} }
@ -386,8 +320,8 @@ class DhlModalService
$productCodes['V53PAK'] = 'DHL Paket International'; $productCodes['V53PAK'] = 'DHL Paket International';
} }
if (! empty($accountNumbers['V62WP'])) { if (! empty($accountNumbers['V62KP'])) {
$productCodes['V62WP'] = 'DHL Warenpost National'; $productCodes['V62KP'] = 'DHL Kleinpaket';
} }
if (! empty($accountNumbers['V07PAK'])) { if (! empty($accountNumbers['V07PAK'])) {
@ -399,13 +333,27 @@ class DhlModalService
$productCodes = [ $productCodes = [
'V01PAK' => 'DHL Paket National', 'V01PAK' => 'DHL Paket National',
'V53PAK' => 'DHL Paket International', 'V53PAK' => 'DHL Paket International',
'V62WP' => 'DHL Warenpost National', 'V62KP' => 'DHL Kleinpaket',
]; ];
} }
return $productCodes; 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 * Validate shipment parameters before API call
* *
@ -431,23 +379,35 @@ class DhlModalService
$errors[] = 'Ungültiger Produktcode ausgewählt.'; $errors[] = 'Ungültiger Produktcode ausgewählt.';
} }
// Address validation if (! empty($shipmentData['shipping_country_id']) && $productCode) {
$requiredAddressFields = [ $country = Country::find($shipmentData['shipping_country_id']);
'shipping_firstname' => 'Vorname', if ($country) {
'shipping_lastname' => 'Nachname', try {
'shipping_address' => 'Straße', (new DhlProductResolver)->resolveProductCode($country->code, $productCode);
'shipping_houseNumber' => 'Hausnummer', } catch (\InvalidArgumentException $e) {
'shipping_zipcode' => 'Postleitzahl', $errors[] = $e->getMessage();
'shipping_city' => 'Stadt', }
'shipping_country_id' => 'Land', }
]; }
foreach ($requiredAddressFields as $field => $label) { if ($productCode) {
if (empty(trim($shipmentData[$field] ?? ''))) { try {
$errors[] = "{$label} ist erforderlich."; (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 [ return [
'valid' => empty($errors), 'valid' => empty($errors),
'errors' => $errors, 'errors' => $errors,

View file

@ -0,0 +1,199 @@
<?php
namespace App\Services;
use App\Models\Setting;
use InvalidArgumentException;
class DhlProductResolver
{
public const DOMESTIC_COUNTRY = 'DE';
public const DOMESTIC_PRODUCT_CODES = ['V01PAK', 'V62KP'];
public const INTERNATIONAL_PRODUCT_CODE = 'V53PAK';
public const DEFAULT_INTERNATIONAL_COUNTRIES = ['AT', 'ES'];
public const DHL_COUNTRY_CODES = [
'DE' => '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<string, string>
*/
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);
}));
}
}

View file

@ -2,14 +2,14 @@
namespace App\Services; namespace App\Services;
use Acme\Dhl\Exceptions\DhlAddressValidationException;
use Acme\Dhl\Models\DhlShipment; use Acme\Dhl\Models\DhlShipment;
use App\Models\ShoppingOrder;
use App\Http\Controllers\SettingController; use App\Http\Controllers\SettingController;
use App\Jobs\CreateShipmentJob;
use App\Jobs\CancelShipmentJob; use App\Jobs\CancelShipmentJob;
use App\Services\DhlDataHelper; use App\Jobs\CreateShipmentJob;
use Illuminate\Support\Facades\Log; use App\Models\ShoppingOrder;
use Exception; use Exception;
use Illuminate\Support\Facades\Log;
/** /**
* DHL Shipment Service * DHL Shipment Service
@ -21,20 +21,23 @@ class DhlShipmentService
{ {
/** /**
* Create a DHL shipment (sync or async based on config) * 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 public function createShipment(ShoppingOrder $order, float $weight = 1.0, array $options = []): array
{ {
$weight = max($weight, (new DhlShipmentWeightCalculator)->calculate($order));
// Get DHL configuration // Get DHL configuration
$settingController = new SettingController(); $settingController = new SettingController;
$dhlConfig = $settingController->getDhlConfig(); $dhlConfig = $settingController->getDhlConfig();
\Log::info('dhlConfig', $dhlConfig); \Log::info('dhlConfig', $dhlConfig);
// Check if queue should be used // Check if queue should be used
$useQueue = $dhlConfig['use_queue'] ?? false; $useQueue = $dhlConfig['use_queue'] ?? false;
if ($useQueue && $this->requiresSynchronousAddressValidation($options, $dhlConfig)) {
Log::info('[DHL Service] Queue disabled for DHL mustEncode address validation', [
'order_id' => $order->id,
]);
$useQueue = false;
}
if ($useQueue) { if ($useQueue) {
return $this->createShipmentAsync($order, $weight, $options, $dhlConfig); 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 * 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 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', [ Log::info('[DHL Service] Shipment creation dispatched to queue', [
'order_id' => $order->id, 'order_id' => $order->id,
'weight' => $weight 'weight' => $weight,
]); ]);
return [ return [
'success' => true, 'success' => true,
'message' => 'Sendung wird erstellt. Sie erhalten eine Benachrichtigung, sobald das Versandlabel verfügbar ist.', 'message' => 'Sendung wird erstellt. Sie erhalten eine Benachrichtigung, sobald das Versandlabel verfügbar ist.',
'queued' => true, 'queued' => true,
'order_id' => $order->id 'order_id' => $order->id,
]; ];
} catch (Exception $e) { } catch (Exception $e) {
Log::error('[DHL Service] Failed to dispatch shipment creation', [ Log::error('[DHL Service] Failed to dispatch shipment creation', [
@ -78,26 +87,20 @@ class DhlShipmentService
return [ return [
'success' => false, 'success' => false,
'message' => 'Fehler beim Einreihen der Sendungserstellung: '.$e->getMessage(), 'message' => 'Fehler beim Einreihen der Sendungserstellung: '.$e->getMessage(),
'queued' => false 'queued' => false,
]; ];
} }
} }
/** /**
* Create shipment synchronously * 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 private function createShipmentSync(ShoppingOrder $order, float $weight, array $options, array $dhlConfig): array
{ {
try { try {
Log::info('[DHL Service] Creating shipment synchronously', [ Log::info('[DHL Service] Creating shipment synchronously', [
'order_id' => $order->id, 'order_id' => $order->id,
'weight' => $weight 'weight' => $weight,
]); ]);
// Create DHL client directly with correct base URL // Create DHL client directly with correct base URL
@ -132,32 +135,42 @@ class DhlShipmentService
'label_path' => $result['labelPath'] ?? null, 'label_path' => $result['labelPath'] ?? null,
'label_url' => $result['labelUrl'] ?? null, 'label_url' => $result['labelUrl'] ?? null,
]; ];
} catch (DhlAddressValidationException $e) {
Log::warning('[DHL Service] Shipment address validation failed (sync)', [
'order_id' => $order->id,
'error' => $e->getMessage(),
]);
return [
'success' => false,
'type' => 'dhl_address_validation',
'message' => $e->getMessage(),
'errors' => [$e->getMessage()],
'queued' => false,
'order_id' => $order->id,
];
} catch (Exception $e) { } catch (Exception $e) {
Log::error('[DHL Service] Shipment creation failed (sync)', [ Log::error('[DHL Service] Shipment creation failed (sync)', [
'order_id' => $order->id, 'order_id' => $order->id,
'error' => $e->getMessage() 'error' => $e->getMessage(),
]); ]);
return [ return [
'success' => false, 'success' => false,
'message' => 'Fehler beim Erstellen des Versandlabels: '.$e->getMessage(), 'message' => 'Fehler beim Erstellen des Versandlabels: '.$e->getMessage(),
'queued' => false, 'queued' => false,
'order_id' => $order->id 'order_id' => $order->id,
]; ];
} }
} }
/** /**
* Cancel a DHL shipment (sync or async based on config) * 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 public function cancelShipment(DhlShipment $shipment, array $options = []): array
{ {
// Get DHL configuration // Get DHL configuration
$settingController = new SettingController(); $settingController = new SettingController;
$dhlConfig = $settingController->getDhlConfig(); $dhlConfig = $settingController->getDhlConfig();
// Check if queue should be used // Check if queue should be used
@ -172,11 +185,6 @@ class DhlShipmentService
/** /**
* Cancel shipment asynchronously using queue * 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 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', [ Log::info('[DHL Service] Shipment cancellation dispatched to queue', [
'shipment_id' => $shipment->id, 'shipment_id' => $shipment->id,
'dhl_shipment_no' => $shipment->dhl_shipment_no 'dhl_shipment_no' => $shipment->dhl_shipment_no,
]); ]);
return [ return [
'success' => true, 'success' => true,
'message' => 'Sendung wird storniert...', 'message' => 'Sendung wird storniert...',
'queued' => true, 'queued' => true,
'shipment_id' => $shipment->id 'shipment_id' => $shipment->id,
]; ];
} catch (Exception $e) { } catch (Exception $e) {
Log::error('[DHL Service] Failed to dispatch shipment cancellation', [ Log::error('[DHL Service] Failed to dispatch shipment cancellation', [
@ -204,39 +212,38 @@ class DhlShipmentService
return [ return [
'success' => false, 'success' => false,
'message' => 'Fehler beim Einreihen der Stornierung: '.$e->getMessage(), 'message' => 'Fehler beim Einreihen der Stornierung: '.$e->getMessage(),
'queued' => false 'queued' => false,
]; ];
} }
} }
/** /**
* Cancel shipment synchronously * Cancel shipment synchronously
*
* @param DhlShipment $shipment
* @param array $options
* @param array $dhlConfig
* @return array
*/ */
private function cancelShipmentSync(DhlShipment $shipment, array $options, array $dhlConfig): array private function cancelShipmentSync(DhlShipment $shipment, array $options, array $dhlConfig): array
{ {
try { try {
// Validate shipment has DHL number // Validate shipment has DHL number
if (empty($shipment->dhl_shipment_no)) { if (empty($shipment->dhl_shipment_no)) {
$this->recordCancellationFailure($shipment, 'missing_shipment_number', 'Sendung hat keine DHL-Sendungsnummer und kann nicht storniert werden.');
return [ return [
'success' => false, 'success' => false,
'message' => 'Sendung hat keine DHL-Sendungsnummer und kann nicht storniert werden.', 'message' => 'Sendung hat keine DHL-Sendungsnummer und kann nicht storniert werden.',
'queued' => false, 'queued' => false,
'shipment_id' => $shipment->id 'shipment_id' => $shipment->id,
]; ];
} }
// Validate shipment can be cancelled // Validate shipment can be cancelled
if (! $shipment->canCancel()) { if (! $shipment->canCancel()) {
$this->recordCancellationFailure($shipment, 'status_not_cancelable', 'Sendung kann im aktuellen Status "'.$shipment->status.'" nicht storniert werden.');
return [ return [
'success' => false, '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, 'queued' => false,
'shipment_id' => $shipment->id 'shipment_id' => $shipment->id,
]; ];
} }
@ -244,7 +251,7 @@ class DhlShipmentService
'shipment_id' => $shipment->id, 'shipment_id' => $shipment->id,
'dhl_shipment_no' => $shipment->dhl_shipment_no, 'dhl_shipment_no' => $shipment->dhl_shipment_no,
'status' => $shipment->status, 'status' => $shipment->status,
'base_url' => $dhlConfig['base_url'] 'base_url' => $dhlConfig['base_url'],
]); ]);
// Create DHL client // Create DHL client
@ -263,37 +270,41 @@ class DhlShipmentService
if ($success) { if ($success) {
Log::info('[DHL Service] Shipment cancelled successfully (sync)', [ Log::info('[DHL Service] Shipment cancelled successfully (sync)', [
'shipment_id' => $shipment->id, 'shipment_id' => $shipment->id,
'dhl_shipment_no' => $shipment->dhl_shipment_no 'dhl_shipment_no' => $shipment->dhl_shipment_no,
]); ]);
return [ return [
'success' => true, 'success' => true,
'message' => 'Sendung wurde erfolgreich storniert!', 'message' => 'Sendung wurde erfolgreich storniert!',
'queued' => false, 'queued' => false,
'shipment_id' => $shipment->id 'shipment_id' => $shipment->id,
]; ];
} else { } else {
throw new Exception('Cancellation returned false'); throw new Exception('Cancellation returned false');
} }
} catch (\InvalidArgumentException $e) { } catch (\InvalidArgumentException $e) {
$this->recordCancellationFailure($shipment, 'validation_failed', $e->getMessage(), $e);
Log::warning('[DHL Service] Shipment cancellation validation failed', [ Log::warning('[DHL Service] Shipment cancellation validation failed', [
'shipment_id' => $shipment->id, 'shipment_id' => $shipment->id,
'error' => $e->getMessage() 'error' => $e->getMessage(),
]); ]);
return [ return [
'success' => false, 'success' => false,
'message' => $e->getMessage(), 'message' => $e->getMessage(),
'queued' => false, 'queued' => false,
'shipment_id' => $shipment->id 'shipment_id' => $shipment->id,
]; ];
} catch (Exception $e) { } catch (Exception $e) {
$this->recordCancellationFailure($shipment, 'api_failed', $e->getMessage(), $e);
Log::error('[DHL Service] Shipment cancellation failed (sync)', [ Log::error('[DHL Service] Shipment cancellation failed (sync)', [
'shipment_id' => $shipment->id, 'shipment_id' => $shipment->id,
'dhl_shipment_no' => $shipment->dhl_shipment_no, 'dhl_shipment_no' => $shipment->dhl_shipment_no,
'status' => $shipment->status, 'status' => $shipment->status,
'error' => $e->getMessage(), 'error' => $e->getMessage(),
'error_trace' => $e->getTraceAsString() 'error_trace' => $e->getTraceAsString(),
]); ]);
// Check if it's an API authentication/resource error // Check if it's an API authentication/resource error
@ -304,7 +315,7 @@ 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.', '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, 'queued' => false,
'shipment_id' => $shipment->id, 'shipment_id' => $shipment->id,
'technical_error' => $errorMessage 'technical_error' => $errorMessage,
]; ];
} }
@ -312,8 +323,42 @@ class DhlShipmentService
'success' => false, 'success' => false,
'message' => 'Fehler beim Stornieren der Sendung: '.$errorMessage, 'message' => 'Fehler beim Stornieren der Sendung: '.$errorMessage,
'queued' => false, '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;
}
} }

View file

@ -0,0 +1,89 @@
<?php
namespace App\Services;
use App\Models\ShoppingOrder;
use InvalidArgumentException;
class DhlShipmentWeightCalculator
{
public const DEFAULT_WEIGHT_KG = 1.0;
public const DEFAULT_MAX_WEIGHT_KG = 31.5;
public const PRODUCT_MAX_WEIGHTS_KG = [
'V01PAK' => 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');
}
}
}

View file

@ -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 * Update tracking asynchronously using queue
*/ */
@ -366,7 +376,7 @@ class DhlTrackingService
$result = $this->trackShipment($shipment->dhl_shipment_no); $result = $this->trackShipment($shipment->dhl_shipment_no);
if ($result['success']) { if ($result['success']) {
$internalStatus = $this->mapDhlStatusToInternal($result['status']); $internalStatus = self::mapDhlStatusToInternal($result['status']);
// Update shipment with tracking data // Update shipment with tracking data
$updateData = [ $updateData = [
@ -481,7 +491,7 @@ class DhlTrackingService
// Remove from map so we can detect missing ones later // Remove from map so we can detect missing ones later
unset($shipmentMap[$trackingNo]); unset($shipmentMap[$trackingNo]);
$internalStatus = $this->mapDhlStatusToInternal($trackingResult['status']); $internalStatus = self::mapDhlStatusToInternal($trackingResult['status']);
$updateData = [ $updateData = [
'status' => $internalStatus, 'status' => $internalStatus,
@ -593,19 +603,25 @@ class DhlTrackingService
/** /**
* Map DHL status codes to internal status * Map DHL status codes to internal status
*/ */
private function mapDhlStatusToInternal(string $dhlStatus): string public static function mapDhlStatusToInternal(string $dhlStatus): string
{ {
$statusMap = [ $statusMap = [
'pre-transit' => 'created', 'pre-transit' => 'created',
'pre_transit' => 'created',
'pretransit' => 'created',
'transit' => 'in_transit', 'transit' => 'in_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',
'delivered' => 'delivered', 'delivered' => 'delivered',
'failure' => 'failed', 'failure' => 'failed',
'failed' => 'failed',
'returned' => 'returned', 'returned' => 'returned',
'exception' => 'exception', 'exception' => 'exception',
]; ];
return $statusMap[$dhlStatus] ?? 'unknown'; return $statusMap[strtolower($dhlStatus)] ?? 'unknown';
} }
/** /**

View file

@ -49,6 +49,8 @@ return [
'print_format' => env('DHL_PRINT_FORMAT', 'A4'), 'print_format' => env('DHL_PRINT_FORMAT', 'A4'),
'retoure_print_format' => env('DHL_RETOURE_PRINT_FORMAT', 'A4'), 'retoure_print_format' => env('DHL_RETOURE_PRINT_FORMAT', 'A4'),
'profile' => env('DHL_PROFILE', 'STANDARD_GRUPPENPROFIL'), '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' => [ 'account_numbers' => [
'default' => env('DHL_ACCOUNT_NUMBER_DEFAULT', '63144073550101'), 'default' => env('DHL_ACCOUNT_NUMBER_DEFAULT', '63144073550101'),
'V01PAK' => env('DHL_ACCOUNT_NUMBER_V01PAK', '63144073550101'), // DHL Paket National '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 'V53PAK' => env('DHL_ACCOUNT_NUMBER_V53PAK', '63144073555301'), // DHL Paket International
'V07PAK' => env('DHL_ACCOUNT_NUMBER_V07PAK', '63144073550701'), // DHL Retoure Online 'V07PAK' => env('DHL_ACCOUNT_NUMBER_V07PAK', '63144073550701'), // DHL Retoure Online
], ],
@ -101,7 +103,7 @@ return [
'dimensions' => [ 'dimensions' => [
'default' => ['length' => 120, 'width' => 60, 'height' => 60], 'default' => ['length' => 120, 'width' => 60, 'height' => 60],
'V01PAK' => ['length' => 120, 'width' => 60, 'height' => 60], // DHL Paket National '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 'V53PAK' => ['length' => 120, 'width' => 60, 'height' => 60], // DHL Paket International
'V07PAK' => ['length' => 120, 'width' => 60, 'height' => 60], // DHL Retoure Online 'V07PAK' => ['length' => 120, 'width' => 60, 'height' => 60], // DHL Retoure Online
], ],

View file

@ -52,7 +52,7 @@ return [
// 'ewo' => ['name' => 'Ewondo', 'script' => 'Latn', 'native' => 'ewondo', 'regional' => ''], // 'ewo' => ['name' => 'Ewondo', 'script' => 'Latn', 'native' => 'ewondo', 'regional' => ''],
// 'ee' => ['name' => 'Ewe', 'script' => 'Latn', 'native' => 'eʋegbe', 'regional' => ''], // 'ee' => ['name' => 'Ewe', 'script' => 'Latn', 'native' => 'eʋegbe', 'regional' => ''],
// 'fil' => ['name' => 'Filipino', 'script' => 'Latn', 'native' => 'Filipino', 'regional' => 'fil_PH'], // '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'], // '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'], // 'fy' => ['name' => 'Western Frisian', 'script' => 'Latn', 'native' => 'frysk', 'regional' => 'fy_DE'],
// 'fur' => ['name' => 'Friulian', 'script' => 'Latn', 'native' => 'furlan', 'regional' => 'fur_IT'], // 'fur' => ['name' => 'Friulian', 'script' => 'Latn', 'native' => 'furlan', 'regional' => 'fur_IT'],

View file

@ -35,4 +35,11 @@ return [
'secret' => env('STRIPE_SECRET'), '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),
],
]; ];

View file

@ -0,0 +1,32 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Support\Facades\DB;
return new class extends Migration
{
/**
* Run the migrations.
*/
public function up(): void
{
DB::table('trans_languages')->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();
}
};

View file

@ -0,0 +1,40 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
/**
* Run the migrations.
*/
public function up(): void
{
Schema::create('backoffice_statistics_snapshots', function (Blueprint $table) {
$table->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');
}
};

View file

@ -0,0 +1,29 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
/**
* Run the migrations.
*/
public function up(): void
{
Schema::table('shopping_orders', function (Blueprint $table) {
$table->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']);
});
}
};

View file

@ -0,0 +1,32 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
/**
* Run the migrations.
*/
public function up(): void
{
if (! Schema::hasColumn('dhl_package_shipments', 'reference')) {
Schema::table('dhl_package_shipments', function (Blueprint $table) {
$table->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');
});
}
}
};

View file

@ -0,0 +1,479 @@
# 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 vorhandener Linie 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 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.
### 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.
### 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:
- `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 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
### 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
- 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
- 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.
- 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.
- 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?
//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?
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
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
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.

File diff suppressed because it is too large Load diff

View file

@ -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
1 Bereich Thema Referenz Anzahl Befund Einordnung Status
2 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
3 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
4 Abgleichstatus Rueckmeldezeilen steuerberater.csv 66 Gesamtzahl Rueckmeldezeilen Basis fuer Abgleich DATEV vs. Steuerberaterliste Info
5 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
6 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
7 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
8 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
9 Kategorie Testdaten unvollstaendig steuerberater.csv 10 6 im August fehlend, 4 trotz #NV vorhanden Mehrere Faelle sind September oder abweichender Datenstand In Arbeit
10 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
11 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
12 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
13 Fachlich Doppelzahlungen/Payone Folgeticket 1 DATEV-Export belegt keine echte Doppelabbuchung Pruefung ueber payment_transactions (txid/reference) + PAYONE Portal/API erforderlich Offen
14 Logikfall 8125 korrekt 202537251 1 ES-USt-ID vorhanden, reverse_charge=1, Export auf 8125/BU 1 Entspricht Rechnungs-/Systemlogik zum Zeitpunkt Geklaert
15 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
16 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
17 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

View file

@ -0,0 +1,310 @@
<!DOCTYPE html>
<html lang="de">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Steuerberater-Abgleich DATEV - Status</title>
<style>
:root {
--bg: #f6f8fb;
--card: #ffffff;
--text: #1f2937;
--muted: #6b7280;
--border: #e5e7eb;
--ok-bg: #ecfdf3;
--ok-text: #065f46;
--warn-bg: #fffbeb;
--warn-text: #92400e;
--open-bg: #eff6ff;
--open-text: #1e3a8a;
--danger-bg: #fef2f2;
--danger-text: #991b1b;
}
* {
box-sizing: border-box;
}
body {
margin: 0;
font-family: Arial, Helvetica, sans-serif;
background: var(--bg);
color: var(--text);
line-height: 1.45;
}
.container {
max-width: 1240px;
margin: 24px auto;
padding: 0 16px 24px;
}
.card {
background: var(--card);
border: 1px solid var(--border);
border-radius: 10px;
padding: 20px;
margin-bottom: 16px;
}
h1 {
margin: 0 0 8px;
font-size: 26px;
}
h2 {
margin: 0 0 14px;
font-size: 20px;
}
.meta {
color: var(--muted);
font-size: 14px;
}
.kpis {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(180px, 1fr));
gap: 12px;
}
.kpi {
border: 1px solid var(--border);
border-radius: 8px;
padding: 12px;
background: #fcfdff;
}
.kpi .value {
font-size: 24px;
font-weight: 700;
}
.kpi .label {
color: var(--muted);
font-size: 13px;
}
.callout {
border-radius: 8px;
padding: 12px 14px;
margin-top: 10px;
border: 1px solid transparent;
font-size: 14px;
}
.callout strong {
display: block;
margin-bottom: 4px;
font-size: 14px;
}
.callout.warn {
background: var(--warn-bg);
color: var(--warn-text);
border-color: #fde68a;
}
.callout.ok {
background: var(--ok-bg);
color: var(--ok-text);
border-color: #a7f3d0;
}
table {
width: 100%;
border-collapse: collapse;
font-size: 13px;
}
th, td {
border: 1px solid var(--border);
padding: 8px 10px;
vertical-align: top;
text-align: left;
}
th {
background: #f9fafb;
font-weight: 600;
}
.status {
display: inline-block;
padding: 3px 8px;
border-radius: 999px;
font-size: 12px;
font-weight: 700;
white-space: nowrap;
}
.status.ok {
background: var(--ok-bg);
color: var(--ok-text);
}
.status.warn {
background: var(--warn-bg);
color: var(--warn-text);
}
.status.open {
background: var(--open-bg);
color: var(--open-text);
}
.status.danger {
background: var(--danger-bg);
color: var(--danger-text);
}
</style>
</head>
<body>
<div class="container">
<section class="card">
<h1>Steuerberater-Abgleich DATEV (Stand nach Korrekturen)</h1>
<div class="meta">
Basis: <code>steuerberater.csv</code> vs. August-Export DATEV plus DB-Abgleich
(<code>user_invoices</code>, <code>shopping_orders</code>, <code>datev_export_lines</code>).
</div>
<div class="kpis" style="margin-top: 14px;">
<div class="kpi">
<div class="value">66</div>
<div class="label">Rueckmeldezeilen Steuerberater</div>
</div>
<div class="kpi">
<div class="value">30</div>
<div class="label">Nicht im August-Export enthalten</div>
</div>
<div class="kpi">
<div class="value">27</div>
<div class="label">Davon DB-Periode September 2025</div>
</div>
<div class="kpi">
<div class="value">3</div>
<div class="label">Unter Rechnungsnummer nicht in DB</div>
</div>
</div>
<div class="callout warn">
<strong>Auffaelliges Muster</strong>
24 Rueckmeldefaelle entsprechen dem Schema <em>Betrag Test + 19%</em>. Neben Netto/Brutto-Missverstaendnis gab es
echte technische Split-Fehler bei Sammelrechnungen und Homeparty.
</div>
<div class="callout ok">
<strong>Nach Umsetzung</strong>
Sammelrechnungen nutzen jetzt Netto+Steuer korrekt, Homeparty liest <code>ek_tax/ek_net</code>.
Kritische Beispielbelege laufen auf die Rechnungstotals.
</div>
</section>
<section class="card">
<h2>Umsetzung und offene Themen</h2>
<table>
<thead>
<tr>
<th>Thema</th>
<th>Ist-Zustand / Ursache</th>
<th>Aenderung / Entscheidung</th>
<th>Status</th>
</tr>
</thead>
<tbody>
<tr>
<td>Sammelrechnungen/API</td>
<td>Export enthielt nur Steueranteil, weil <code>net_split</code> auf <code>ShoppingOrder</code> fehlte.</td>
<td><code>net_split</code> wird gespeichert; historischer Fallback auf <code>ShoppingCollectOrder.net_split</code>.</td>
<td><span class="status ok">Umgesetzt</span></td>
</tr>
<tr>
<td>Homeparty</td>
<td>Export las <code>vk_*</code>; Rechnungstotal basiert auf <code>ek_*</code>.</td>
<td>DATEV-Service nutzt fuer Homeparty nun <code>ek_tax/ek_net</code>.</td>
<td><span class="status ok">Umgesetzt</span></td>
</tr>
<tr>
<td>USt-ID pro Bestellung</td>
<td>Validierung erfolgt aktuell beim Eintragen, nicht bei jeder Bestellung.</td>
<td>Folgeticket: pro Bestellung pruefen; bei ungueltig Warnung + USt-Berechnung.</td>
<td><span class="status open">Offen</span></td>
</tr>
<tr>
<td>Storno in August</td>
<td>Vom Steuerberater als Storno markiert, aber keine Stornorechnungslogik aktiv gewesen.</td>
<td>Fuer August kein DATEV-Storno ableiten, solange keine echte Stornorechnung existiert.</td>
<td><span class="status warn">Fachlich geklaert</span></td>
</tr>
<tr>
<td>Doppelzahlung / Payone</td>
<td>DATEV-Export belegt keine echte Doppelabbuchung.</td>
<td>Abgleich ueber <code>payment_transactions</code> plus PAYONE Portal/API erforderlich.</td>
<td><span class="status open">Offen</span></td>
</tr>
</tbody>
</table>
</section>
<section class="card">
<h2>Logik-Hinweise 8125 vs. 8400</h2>
<table>
<thead>
<tr>
<th>Rechnung</th>
<th>Exportkonto</th>
<th>Systembefund</th>
<th>Einordnung</th>
</tr>
</thead>
<tbody>
<tr>
<td>202536848 / 202537290 / 202537832</td>
<td>8400</td>
<td>AT-Faelle ohne verifizierte USt-ID, <code>taxable_sales=2</code></td>
<td>8400 ist aus Systemdaten plausibel; fuer 8125 fehlt USt-ID-Grundlage.</td>
</tr>
<tr>
<td>202537251</td>
<td>8125 / BU 1</td>
<td>ES-USt-ID vorhanden, <code>reverse_charge=1</code></td>
<td>Export folgt Rechnungsstand und ist fachlich konsistent.</td>
</tr>
<tr>
<td>202538445 / 202538446 / 202538774 / 202539011 / 202539399</td>
<td>Nicht in August-Datei</td>
<td>DB-Periode September 2025</td>
<td>Kein August-Exportfehler; separat gegen September-Export pruefen.</td>
</tr>
</tbody>
</table>
</section>
<section class="card">
<h2>Fehlende Belege im August-Export</h2>
<table>
<thead>
<tr>
<th>Rechnung</th>
<th>DB-Befund</th>
<th>Einordnung</th>
</tr>
</thead>
<tbody>
<tr>
<td>202506145 / 202506147 / 202538333</td>
<td><span class="status danger">Nicht in DB gefunden</span></td>
<td>Separat klaeren: Datenstand, Rechnungsformat oder extern erzeugte Rechnung.</td>
</tr>
<tr>
<td>
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
</td>
<td><span class="status warn">DB-Periode September 2025</span></td>
<td>Kein August-Exportfehler.</td>
</tr>
</tbody>
</table>
</section>
</div>
</body>
</html>

View file

@ -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`

View file

@ -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)
1 Rechnung Betrag Test Betrag gebucht Delta_1 Delta Fehler Kategorie Bemerkung
2 202506145 #NV 265,8 -265,80 Fehler Test Testdaten unvollständig Testdaten unvollständig, Rechnung liegt uns vor und wurde korrekt verbucht
3 202506147 #NV 115,5 -115,50 Fehler Test Testdaten unvollständig Testdaten unvollständig, Rechnung liegt uns vor und wurde korrekt verbucht
4 202536737 85,1 533,51 -448,41 -448,41 Test Testdaten fehlerhaft: Betrag Testdaten fehlerhaft, Rechnung liegt uns vor und wurde korrekt verbucht
5 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)
6 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)
7 202536821 #NV 444,54 -444,54 Fehler Test Testdaten unvollständig Testdaten unvollständig, Rechnung liegt uns vor und wurde korrekt verbucht
8 202536841 #NV 93,43 -93,43 Fehler Test Testdaten unvollständig Testdaten unvollständig, Rechnung liegt uns vor und wurde korrekt verbucht
9 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)
10 202536848 233,55 #NV 233,55 Fehler Test Testdaten fehlerhaft: Logik Rechnung korrekterweise auf 8125 gebucht nicht 8400
11 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)
12 202536968 167,67 1044,69 -877,02 -877,02 Test Testdaten fehlerhaft: Betrag Testdaten fehlerhaft, Rechnung liegt uns vor und wurde korrekt verbucht
13 202536978 95,29 0 95,29 95,29 Testdaten fehlerhaft: Storno Rg. Wurde storniert wegen Doppeltbestellung
14 202537144 146,97 0 146,97 146,97 Testdaten fehlerhaft: Storno Rg. Wurde storniert wegen Falschbestellung
15 202537208 122,52 101,68 20,84 20,84 Test Testdaten fehlerhaft: Betrag Testdaten fehlerhaft, Rechnung liegt uns vor und wurde korrekt verbucht
16 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
17 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)
18 202537290 226,44 #NV 226,44 Fehler Test Testdaten fehlerhaft: Logik Rechnung korrekterweise auf 8125 gebucht nicht 8400
19 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)
20 202537450 72,57 454,54 -381,97 -381,97 Test Testdaten fehlerhaft: Betrag Testdaten fehlerhaft, Rechnung liegt uns vor und wurde korrekt verbucht
21 202537460 #NV 221,09 -221,09 Fehler Test Testdaten unvollständig Testdaten unvollständig, Rechnung liegt uns vor und wurde korrekt verbucht
22 202537513 101,68 22,27 79,41 79,41 Testdaten fehlerhaft: Storno Rg. Wurde teilweise storniert
23 202537532 172,55 1075,04 -902,49 -902,49 Test Testdaten fehlerhaft: Betrag Testdaten fehlerhaft, Rechnung liegt uns vor und wurde korrekt verbucht
24 202537543 #NV 791,8 -791,80 Fehler Test Testdaten unvollständig Testdaten unvollständig, Rechnung liegt uns vor und wurde korrekt verbucht
25 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)
26 202537567 18,32 86,72 -68,40 -68,40 Test Testdaten fehlerhaft: Betrag Testdaten fehlerhaft, Rechnung liegt uns vor und wurde korrekt verbucht
27 202537572 177,06 90,34 86,72 86,72 Test Testdaten fehlerhaft: Betrag Testdaten fehlerhaft, Rechnung liegt uns vor und wurde korrekt verbucht
28 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)
29 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)
30 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)
31 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)
32 202537832 129,35 #NV 129,35 Fehler Test Testdaten fehlerhaft: Logik Rechnung korrekterweise auf 8125 gebucht nicht 8400
33 202537840 86,43 541,73 -455,30 -455,30 Test Testdaten fehlerhaft: Betrag Testdaten fehlerhaft, Rechnung liegt uns vor und wurde korrekt verbucht
34 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)
35 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)
36 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)
37 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)
38 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)
39 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)
40 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)
41 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)
42 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)
43 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)
44 202538332 40,84 4,12 36,72 36,72 Test Testdaten fehlerhaft: Betrag Testdaten fehlerhaft, Rechnung liegt uns vor und wurde korrekt verbucht
45 202538333 #NV 36,72 -36,72 Fehler Test Testdaten unvollständig Testdaten unvollständig, Rechnung liegt uns vor und wurde korrekt verbucht (Anderes Rechnungsformat)
46 202538398 #NV 142,57 -142,57 Fehler Test Testdaten unvollständig Testdaten unvollständig, Rechnung liegt uns vor und wurde korrekt verbucht
47 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)
48 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)
49 202538445 227,39 #NV 227,39 Fehler Test Testdaten fehlerhaft: Logik Rechnung korrekterweise auf 8125 gebucht nicht 8400
50 202538446 358,96 #NV 358,96 Fehler Test Testdaten fehlerhaft: Logik Rechnung korrekterweise auf 8125 gebucht nicht 8400
51 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)
52 202538728 58,18 251,86 -193,68 -193,68 Test Testdaten unvollständig Testdaten unvollständig, Rechnung liegt uns vor und wurde korrekt verbucht
53 202538744 #NV 87,9 -87,90 Fehler Test Testdaten unvollständig Testdaten unvollständig, Rechnung liegt uns vor und wurde korrekt verbucht
54 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)
55 202538774 87,9 #NV 87,90 Fehler Test Testdaten fehlerhaft: Logik Rechnung korrekterweise auf 8125 gebucht nicht 8400 (Beleg nicht angehängt)
56 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)
57 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)
58 202539010 27,63 118,16 -90,53 -90,53 Test Testdaten fehlerhaft: Betrag Testdaten fehlerhaft, Rechnung liegt uns vor und wurde korrekt verbucht
59 202539011 239,65 #NV 239,65 Fehler Test Testdaten fehlerhaft: Logik Rechnung korrekterweise auf 8125 gebucht nicht 8400 (Beleg nicht angehängt)
60 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)
61 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)
62 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)
63 202539162 37,13 160,82 -123,69 -123,69 Test Testdaten fehlerhaft: Betrag Testdaten fehlerhaft, Rechnung liegt uns vor und wurde korrekt verbucht
64 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)
65 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)
66 202539399 248 #NV 248,00 Fehler Test Testdaten fehlerhaft: Logik Rechnung korrekterweise auf 8125 gebucht nicht 8400
67 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)

View file

@ -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.

View file

@ -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

View file

@ -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.

View file

@ -0,0 +1,8 @@
<?php
namespace Acme\Dhl\Exceptions;
class DhlAddressValidationException extends DhlValidationException
{
//
}

View file

@ -18,6 +18,7 @@ class DhlShipment extends Model
'order_id', 'order_id',
'dhl_shipment_no', 'dhl_shipment_no',
'routing_code', 'routing_code',
'reference',
'type', 'type',
'related_shipment_id', 'related_shipment_id',
'product_code', 'product_code',
@ -60,6 +61,15 @@ class DhlShipment extends Model
'unknown' => 'unknown', 'unknown' => '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 * Get the tracking events for this shipment
*/ */
@ -164,7 +174,9 @@ class DhlShipment extends Model
*/ */
public function getStatusTranslation(): string 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 public static function getStatusTranslationFor(string $status): string
{ {
$status = self::normalizeStatus($status);
return __('dhl.status.'.$status, [], $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 * Get translated type for current locale
*/ */
@ -234,23 +257,92 @@ class DhlShipment extends Model
return $this->tracking_email_sent_at !== null; return $this->tracking_email_sent_at !== null;
} }
/**
* @return array<int, array<string, mixed>>
*/
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 * 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([ $this->update([
'tracking_email_sent_at' => now(), 'tracking_email_sent_at' => now(),
'tracking_email_type' => $type, 'tracking_email_type' => $type,
'api_response_data' => $apiResponseData,
]); ]);
} }
/**
* @return array<int, int|string>
*/
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 * Get status badge class for Bootstrap
*/ */
public function getStatusBadgeClass(): string 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', 'created', 'pending' => 'secondary',
'in_transit' => 'info', 'in_transit' => 'info',
'out_for_delivery' => 'primary', 'out_for_delivery' => 'primary',
@ -266,13 +358,13 @@ class DhlShipment extends Model
*/ */
public function scopeActive($query) 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 * 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). * Tracking interval per status (in hours).
@ -345,7 +437,7 @@ class DhlShipment extends Model
*/ */
public function scopeNeedsTrackingEmail($query) public function scopeNeedsTrackingEmail($query)
{ {
return $query->where('status', 'in_transit') return $query->whereIn('status', self::TRACKING_EMAIL_TRIGGER_STATUSES)
->whereNull('tracking_email_sent_at'); ->whereNull('tracking_email_sent_at');
} }
} }

View file

@ -2,9 +2,13 @@
namespace Acme\Dhl\Services; namespace Acme\Dhl\Services;
use Acme\Dhl\Exceptions\DhlAddressValidationException;
use Acme\Dhl\Exceptions\DhlValidationException;
use Acme\Dhl\Jobs\CreateShipmentJob; use Acme\Dhl\Jobs\CreateShipmentJob;
use Acme\Dhl\Models\DhlShipment; use Acme\Dhl\Models\DhlShipment;
use Acme\Dhl\Support\DhlClient; use Acme\Dhl\Support\DhlClient;
use App\Services\DhlProductResolver;
use App\Services\DhlShipmentWeightCalculator;
use Exception; use Exception;
use Illuminate\Support\Facades\DB; use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Log; use Illuminate\Support\Facades\Log;
@ -53,6 +57,7 @@ class ShippingService
$query = array_filter([ $query = array_filter([
'printFormat' => $validatedData['print_format'] ?? null, 'printFormat' => $validatedData['print_format'] ?? null,
'retourePrintFormat' => $validatedData['retoure_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); $response = $this->client->request('post', '/parcel/de/shipping/v2/orders', $payload, $query);
@ -60,6 +65,13 @@ class ShippingService
Log::info('[DHL API] Response received', [ Log::info('[DHL API] Response received', [
'response' => $response, '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) { } catch (Exception $e) {
Log::error('[DHL API] Request failed', [ Log::error('[DHL API] Request failed', [
'error' => $e->getMessage(), 'error' => $e->getMessage(),
@ -112,7 +124,7 @@ class ShippingService
'shipmentNumber' => $shipmentNumber, 'shipmentNumber' => $shipmentNumber,
'shipment_id' => $shipment->id, 'shipment_id' => $shipment->id,
'status' => $shipment->status, 'status' => $shipment->status,
'endpoint' => "/parcel/de/shipping/v2/orders/{$shipmentNumber}" 'endpoint' => "/parcel/de/shipping/v2/orders/{$shipmentNumber}",
]); ]);
try { try {
@ -120,28 +132,78 @@ class ShippingService
Log::info('[DHL Package] Shipment cancellation response', [ Log::info('[DHL Package] Shipment cancellation response', [
'shipmentNumber' => $shipmentNumber, 'shipmentNumber' => $shipmentNumber,
'response' => $response 'response' => $response,
]); ]);
$shipment->update(['status' => 'canceled']); $this->recordCancellationSuccess($shipment, $response);
Log::info('[DHL Package] Canceled shipment successfully', [ Log::info('[DHL Package] Canceled shipment successfully', [
'shipmentNumber' => $shipmentNumber, 'shipmentNumber' => $shipmentNumber,
'shipment_id' => $shipment->id 'shipment_id' => $shipment->id,
]); ]);
return true; return true;
} catch (\Exception $e) { } catch (\Exception $e) {
$this->recordCancellationFailure($shipment, $e);
Log::error('[DHL Package] Shipment cancellation failed', [ Log::error('[DHL Package] Shipment cancellation failed', [
'shipmentNumber' => $shipmentNumber, 'shipmentNumber' => $shipmentNumber,
'shipment_id' => $shipment->id, 'shipment_id' => $shipment->id,
'error' => $e->getMessage(), 'error' => $e->getMessage(),
'error_class' => get_class($e) 'error_class' => get_class($e),
]); ]);
throw $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 * Validate required order data according to DHL API v2 specification
*/ */
@ -153,10 +215,11 @@ class ShippingService
$validator = Validator::make($data, [ $validator = Validator::make($data, [
'order_id' => 'nullable|integer', 'order_id' => 'nullable|integer',
'weight_kg' => 'required|numeric|min:0.1|max:31.5', // DHL weight limit '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', 'label_format' => 'nullable|string|in:PDF,ZPL',
'print_format' => 'nullable|string', // Values like A4, 910-300-700 etc. 'print_format' => 'nullable|string', // Values like A4, 910-300-700 etc.
'retoure_print_format' => 'nullable|string', 'retoure_print_format' => 'nullable|string',
'print_only_if_codeable' => 'nullable|boolean',
// Shipper validation (sender) // Shipper validation (sender)
'shipper' => 'required|array', 'shipper' => 'required|array',
@ -198,7 +261,81 @@ class ShippingService
throw new InvalidArgumentException($validator->errors()->first()); 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 private function buildShipmentPayload(array $orderData): array
{ {
$productCode = $orderData['product_code'] ?? config('dhl.default_product', 'V01PAK'); $resolver = new DhlProductResolver;
$billingNumber = $this->getBillingNumberForProduct($productCode); $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 = [ $payload = [
'profile' => config('dhl.profile', 'STANDARD_GRUPPENPROFIL'), 'profile' => config('dhl.profile', 'STANDARD_GRUPPENPROFIL'),
@ -319,7 +462,7 @@ class ShippingService
'addressHouse' => $orderData['shipper']['houseNumber'] ?? null, 'addressHouse' => $orderData['shipper']['houseNumber'] ?? null,
'postalCode' => $orderData['shipper']['postalCode'] ?? '', 'postalCode' => $orderData['shipper']['postalCode'] ?? '',
'city' => $orderData['shipper']['city'] ?? '', '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, 'email' => ! empty($orderData['shipper']['email']) ? $orderData['shipper']['email'] : null,
'phone' => ! empty($orderData['shipper']['phone']) ? $orderData['shipper']['phone'] : null, 'phone' => ! empty($orderData['shipper']['phone']) ? $orderData['shipper']['phone'] : null,
], function ($value) { ], function ($value) {
@ -381,7 +524,7 @@ class ShippingService
'addressHouse' => $consignee['houseNumber'] ?? null, 'addressHouse' => $consignee['houseNumber'] ?? null,
'postalCode' => $consignee['postalCode'] ?? '', 'postalCode' => $consignee['postalCode'] ?? '',
'city' => $consignee['city'] ?? '', 'city' => $consignee['city'] ?? '',
'country' => $this->convertCountryCode($consignee['country'] ?? 'DE'), 'country' => $this->convertCountryCode($consignee['country'] ?? ''),
'email' => ! empty($consignee['email']) ? $consignee['email'] : null, 'email' => ! empty($consignee['email']) ? $consignee['email'] : null,
'phone' => ! empty($consignee['phone']) ? $consignee['phone'] : null, 'phone' => ! empty($consignee['phone']) ? $consignee['phone'] : null,
], function ($value) { ], function ($value) {
@ -464,7 +607,7 @@ class ShippingService
'postNumber' => $postNumber, 'postNumber' => $postNumber,
'postalCode' => $consignee['postalCode'] ?? '', 'postalCode' => $consignee['postalCode'] ?? '',
'city' => $consignee['city'] ?? '', 'city' => $consignee['city'] ?? '',
'country' => $this->convertCountryCode($consignee['country'] ?? 'DE'), 'country' => $this->convertCountryCode($consignee['country'] ?? ''),
], function ($value) { ], function ($value) {
return $value !== null && $value !== ''; return $value !== null && $value !== '';
}); });
@ -534,25 +677,7 @@ class ShippingService
*/ */
private function convertCountryCode(string $countryCode): string private function convertCountryCode(string $countryCode): string
{ {
$countryMap = [ return (new DhlProductResolver)->toDhlCountryCode($countryCode);
'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';
} }
/** /**
@ -576,8 +701,9 @@ class ShippingService
} }
// Try to get from admin settings via Setting model first (database settings override config) // Try to get from admin settings via Setting model first (database settings override config)
try {
$settingKey = 'dhl_account_'.strtolower($productCode); $settingKey = 'dhl_account_'.strtolower($productCode);
try {
$accountNumber = \App\Models\Setting::getContentBySlug($settingKey); $accountNumber = \App\Models\Setting::getContentBySlug($settingKey);
if ($accountNumber) { if ($accountNumber) {
Log::info('Using DHL account number from database settings', [ Log::info('Using DHL account number from database settings', [
@ -692,6 +818,7 @@ class ShippingService
'order_id' => $orderData['order_id'] ?? null, 'order_id' => $orderData['order_id'] ?? null,
'dhl_shipment_no' => $shipmentNumber, 'dhl_shipment_no' => $shipmentNumber,
'routing_code' => $this->extractRoutingCode($response), 'routing_code' => $this->extractRoutingCode($response),
'reference' => $payload['shipments'][0]['refNo'] ?? null,
'type' => 'outbound', 'type' => 'outbound',
'product_code' => $payload['shipments'][0]['product'], 'product_code' => $payload['shipments'][0]['product'],
'billing_number' => $payload['shipments'][0]['billingNumber'], 'billing_number' => $payload['shipments'][0]['billingNumber'],

Binary file not shown.

After

Width:  |  Height:  |  Size: 90 KiB

View file

@ -32,6 +32,7 @@ return [
'abo_copy_active' => 'Wenn das Abonnement nicht aktiv ist, erfolgt keine automatische Ausführung.', '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_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.', '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' => '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.', '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).', '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.', 'abo_error_basis_product' => 'Fehler: Bitte wählen Sie mindestens ein Basis-Produkt aus.',
'cancel_abo' => 'Abo kündigen', 'cancel_abo' => 'Abo kündigen',
'confirm_cancel' => 'Möchten Sie das Abo wirklich 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_subscriptions' => 'Team Abos',
'team_customer_abos' => 'Team Kunden-Abos', 'team_customer_abos' => 'Team Kunden-Abos',
'chart_monthly_abos' => 'Abos pro Monat', 'chart_monthly_abos' => 'Abos pro Monat',

View file

@ -12,6 +12,7 @@ return [
'returned' => 'Retourniert', 'returned' => 'Retourniert',
'failed' => 'Fehler', 'failed' => 'Fehler',
'unknown' => 'Unbekannt', 'unknown' => 'Unbekannt',
'canceled' => 'Storniert',
'cancelled' => 'Storniert', 'cancelled' => 'Storniert',
'shipped' => 'Versendet', 'shipped' => 'Versendet',
], ],
@ -26,7 +27,8 @@ return [
'V53WPAK' => 'DHL Paket International', 'V53WPAK' => 'DHL Paket International',
'V54EPAK' => 'DHL Paket International', 'V54EPAK' => 'DHL Paket International',
'V55PAK' => 'DHL Paket International', 'V55PAK' => 'DHL Paket International',
'V62WP' => 'DHL Paket International', 'V62KP' => 'DHL Kleinpaket',
'V62WP' => 'DHL Warenpost (Legacy)',
'V66WPI' => 'DHL Paket International', 'V66WPI' => 'DHL Paket International',
], ],

View file

@ -47,6 +47,7 @@ return [
'settings' => 'Einstellungen', 'settings' => 'Einstellungen',
'shipping_costs' => 'Versandkosten', 'shipping_costs' => 'Versandkosten',
'start_site' => 'Startseite Shop', 'start_site' => 'Startseite Shop',
'statistics' => 'Statistik',
'structure' => 'Struktur', 'structure' => 'Struktur',
'system_settings' => 'System-E.', 'system_settings' => 'System-E.',
'translate' => 'Übersetzungen', 'translate' => 'Übersetzungen',

View file

@ -32,6 +32,7 @@ return [
'abo_copy_active' => 'If the subscription is not active, it will not be executed automatically', '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_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.', '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_abo_interval' => 'The subscription interval is not correct',
'error_next_date' => 'The date for the next execution is not correct', 'error_next_date' => 'The date for the next execution is not correct',
'checkout_mail_abo_hl' => 'Your subscription / regular delivery', '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.', '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', 'cancel_abo' => 'Cancel subscription',
'confirm_cancel' => 'Do you really want to cancel the 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', 'back' => 'back',
'team_subscriptions' => 'Team subscriptions', 'team_subscriptions' => 'Team subscriptions',
'team_customer_abos' => 'Team Customer Subscriptions', 'team_customer_abos' => 'Team Customer Subscriptions',

View file

@ -12,6 +12,7 @@ return [
'returned' => 'Returned', 'returned' => 'Returned',
'failed' => 'Failed', 'failed' => 'Failed',
'unknown' => 'Unknown', 'unknown' => 'Unknown',
'canceled' => 'Canceled',
'cancelled' => 'Cancelled', 'cancelled' => 'Cancelled',
'shipped' => 'Shipped', 'shipped' => 'Shipped',
], ],
@ -26,7 +27,8 @@ return [
'V53WPAK' => 'DHL Package International', 'V53WPAK' => 'DHL Package International',
'V54EPAK' => 'DHL Package International', 'V54EPAK' => 'DHL Package International',
'V55PAK' => 'DHL Package International', 'V55PAK' => 'DHL Package International',
'V62WP' => 'DHL Package International', 'V62KP' => 'DHL Kleinpaket',
'V62WP' => 'DHL Warenpost (Legacy)',
'V66WPI' => 'DHL Package International', 'V66WPI' => 'DHL Package International',
], ],

View file

@ -56,6 +56,7 @@ return [
'settings' => 'settings', 'settings' => 'settings',
'shipping_costs' => 'shipping', 'shipping_costs' => 'shipping',
'start_site' => 'home page', 'start_site' => 'home page',
'statistics' => 'Statistics',
'structure' => 'structure', 'structure' => 'structure',
'system_settings' => 'system E.', 'system_settings' => 'system E.',
'tags' => 'Tags', 'tags' => 'Tags',

View file

@ -32,6 +32,7 @@ return [
'abo_copy_active' => 'Si la suscripción no está activa, no se ejecutará automáticamente', '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_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.', '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_abo_interval' => 'El intervalo de suscripción no es correcto',
'error_next_date' => 'La fecha de la siguiente ejecución no es correcta', 'error_next_date' => 'La fecha de la siguiente ejecución no es correcta',
'checkout_mail_abo_hl' => 'Su suscripción / entrega regular', '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.', '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', 'cancel_abo' => 'Cancelar suscripción',
'confirm_cancel' => '¿Realmente desea cancelar la 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', 'back' => 'atrás',
'team_subscriptions' => 'Suscripciones de equipo', 'team_subscriptions' => 'Suscripciones de equipo',
'team_customer_abos' => 'Suscripciones de clientes del equipo', 'team_customer_abos' => 'Suscripciones de clientes del equipo',

View file

@ -12,6 +12,7 @@ return [
'returned' => 'Devuelto', 'returned' => 'Devuelto',
'failed' => 'Fallido', 'failed' => 'Fallido',
'unknown' => 'Desconocido', 'unknown' => 'Desconocido',
'canceled' => 'Cancelado',
'cancelled' => 'Cancelado', 'cancelled' => 'Cancelado',
'shipped' => 'Enviado', 'shipped' => 'Enviado',
], ],
@ -26,7 +27,8 @@ return [
'V53WPAK' => 'DHL Paquete Internacional', 'V53WPAK' => 'DHL Paquete Internacional',
'V54EPAK' => 'DHL Paquete Internacional', 'V54EPAK' => 'DHL Paquete Internacional',
'V55PAK' => 'DHL Paquete Internacional', 'V55PAK' => 'DHL Paquete Internacional',
'V62WP' => 'DHL Paquete Internacional', 'V62KP' => 'DHL Kleinpaket',
'V62WP' => 'DHL Warenpost (Legacy)',
'V66WPI' => 'DHL Paquete Internacional', 'V66WPI' => 'DHL Paquete Internacional',
], ],

View file

@ -56,6 +56,7 @@ return [
'settings' => 'ajustes', 'settings' => 'ajustes',
'shipping_costs' => 'envío', 'shipping_costs' => 'envío',
'start_site' => 'pagina de inicio', 'start_site' => 'pagina de inicio',
'statistics' => 'Estadísticas',
'structure' => 'estructura', 'structure' => 'estructura',
'system_settings' => 'sistema E.', 'system_settings' => 'sistema E.',
'tags' => 'Etiquetas', 'tags' => 'Etiquetas',

170
resources/lang/fr.json Normal file
View file

@ -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",
"": ""
}

135
resources/lang/fr/abo.php Normal file
View file

@ -0,0 +1,135 @@
<?php
return [
'abo' => 'Abonnement',
'payment_for_abo' => 'Mode de paiement pour labonnement',
'abo_delivery' => 'Abonnement - livraison régulière',
'abo_are_for_me_and_shipped' => 'Labonnement est pour moi et sera envoyé à mon adresse',
'abo_are_for_customer_and_shipped' => 'Labonnement est pour un client et sera envoyé au client',
'abo_delivery_to_me' => 'Livraison dabonnement à moi-même',
'abo_delivery_to_the_customer' => 'Livraison dabonnement 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 labonnement, 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 labonnement. Ensuite, lexpé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. <strong>Labonnement a une durée minimale de :abo-min-duration mois.</strong> Ensuite, il peut être mis en pause, modifié ou résilié à tout moment.',
'abo_order_info_checkbox' => 'Oui, jai compris les conditions de labonnement !',
'abo_order_info_checkbox_required' => 'Veuillez confirmer les conditions de labonnement pour continuer.',
'abo_infos' => 'Infos abonnement',
'abo_delivery_infos' => 'Infos livraison abonnement',
'abo_start_date' => 'Début de labonnement',
'abo_delivery_intervall' => 'Jour de livraison de labonnement',
'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 labonnement',
'abo_details' => 'Détails de labonnement',
'abo_is_active' => 'Labonnement est actif',
'abo_copy_active' => 'Si labonnement nest pas actif, aucune exécution automatique na lieu.',
'abo_copy_next_date' => 'Le prochain jour dexécution peut être fixé au plus tôt au lendemain.',
'abo_copy_abo_interval' => 'Ladaptation du jour de livraison de labonnement impacte la prochaine date dexécution si labonnement est actif.',
'admin_abo_copy_next_date' => 'Les admins peuvent définir directement la prochaine date dexécution via le mois et le jour de livraison.',
'error_abo_interval' => 'Lintervalle dabonnement est incorrect',
'error_abo_interval_in_the_past' => 'Labonnement na 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 labonnement est déjà dans :days jours (:date).',
'warning_next_date_soon_select' => 'Remarque : la prochaine exécution de labonnement est déjà dans :placeholder_days jours (:placeholder_date).',
'warning_next_date_info' => 'La prochaine exécution de labonnement 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 nest quà :days jours. Veuillez choisir un jour de livraison situé au moins 10 jours dans le futur.',
'error_cancel_locked' => 'Une résiliation nest 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' => 'Labonnement 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' => 'Labonnement ne pourra être modifié, complété, mis en pause ou résilié quaprès encore :count exécutions.',
'pros_hl' => 'Les avantages dun abonnement',
'pros_list' => ' <li><b>Conclusion dabonnement pour conseillers et clients :</b> 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.</li>
<li><b>Livraison mensuelle :</b> Une nouvelle livraison est envoyée directement à votre porte une fois par mois.</li>
<li><b>Adaptable avec flexibilité :</b> Labonnement peut être adapté individuellement, p. ex. en termes de produits, quantités ou dates de livraison.</li>
<li><b>Large choix de produits :</b> Différents produits peuvent être inclus dans labonnement.</li>
<li><b>Durée :</b> Labonnement a une durée minimale de <strong>:abo-min-duration mois</strong>, puis il peut être mis en pause ou résilié.</li>
<li><b>Avantage prix :</b> Les produits dabonnement bénéficient souvent de remises ou offres spéciales.</li>
<li><b>Démarrer maintenant :</b> Choisissez vos produits, adaptez labonnement à vos besoins, payez la première commande et activez ainsi votre abonnement pour les prochaines livraisons.</li>',
'abo_pros' => 'Avantages abonnement',
'abo_order_hl' => 'Composition de labonnement',
'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 <strong>:abo-min-duration mois</strong>.',
'abo_order_info_block_team' => 'La composition de labonnement 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 à labonnement de votre client. La suppression de produits nest possible quaprès la durée minimale de <strong>:abo-min-duration mois</strong>.',
'abo_order_info_add_only' => 'Vous pouvez à tout moment ajouter de nouveaux produits à votre abonnement. La suppression de produits nest possible quaprès la durée minimale de <strong>:abo-min-duration mois</strong>.',
'error_add_only_no_remove' => 'La suppression de produits nest pas possible pendant la durée minimale.',
'confirm_add_title' => 'Confirmer lajout du produit',
'confirm_add_title_normal' => 'Ajouter le produit à labonnement',
'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 <strong>:user_level_name</strong> moins <strong>:user_level_margin %</strong> 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 <strong>:user_level_name</strong>, commission <strong>:user_level_margin %</strong>.',
'error_email_has_abo' => 'Un abonnement existe déjà pour ladresse e-mail :email.',
'abo_assigned' => 'Abonnement actif',
'base' => 'Base',
'upgrade' => 'Upgrade',
'abo_type_info' => 'Remarque : chaque abonnement se compose au minimum dun produit de base :base ! <br>Les produits upgrade :upgrade sont optionnels et peuvent être ajoutés à volonté.<br> <strong>Labonnement a une durée minimale de <strong>:abo-min-duration mois</strong>, puis il peut être mis en pause ou résilié.</strong>',
'abo_type_info_base' => 'Labonnement 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 dabord ajouter un nouveau produit de base puis supprimer lancien !',
'abo_item_not_found' => 'Position dabonnement 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 navez pas encore enregistré dadresse de facturation et de livraison. Sans celles-ci, vous ne pouvez pas créer dabonnement ; veuillez les créer.',
'abo_error_basis_product' => 'Erreur : veuillez sélectionner au moins un produit de base.',
'cancel_abo' => 'Résilier labonnement',
'confirm_cancel' => 'Voulez-vous vraiment résilier labonnement ?',
'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 nest possible que pour les abonnements en pause.',
'retry_only_active' => 'La nouvelle tentative de paiement nest possible que pour les abonnements actifs.',
'retry_already_paid_today' => 'Un paiement réussi a déjà été enregistré aujourdhui pour cet abonnement.',
'retry_error_shopping_user' => 'Le Shopping-User na pas pu être créé pour la nouvelle tentative de paiement.',
'retry_error_order' => 'La commande na 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 na 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 dabonnements',
'customer_privacy_info' => 'Pour des raisons de protection des données, aucune donnée personnelle client nest affichée.',
'every_month_on' => 'mensuellement le :day.',
'back' => 'retour',
];

View file

@ -0,0 +1,41 @@
<?php
return [
'initial_composition' => 'Composition initiale',
'change_history' => 'Historique des modifications',
'no_initial_data' => 'Aucune donnée initiale disponible (abonnement créé avant lactivation de lhistorique)',
'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é à lorigine',
'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 à lorigine',
'rollback_confirm' => 'Voulez-vous vraiment réinitialiser labonnement à son état initial ? Tous les produits actuels seront remplacés par la composition initiale.',
'rollback_success' => 'Labonnement a été réinitialisé avec succès à son état initial.',
'rollback_no_data' => 'Rollback impossible : aucune donnée initiale disponible.',
];

View file

@ -0,0 +1,48 @@
<?php
return [
'' => '',
'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 daffaires 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 dautoliquidation',
'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 dautoliquidation et le numéro de TVA',
'reverse_charge_action_2' => 'Valider le numéro de TVA et activer la procédure dautoliquidation',
'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 lactivation.',
'reverse_charge_procedure' => 'Procédure dautoliquidation',
'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.',
];

View file

@ -0,0 +1,24 @@
<?php
return [
'' => '',
'cancel' => 'annuler',
'confirm' => 'confirmer',
'file_is_too_big' => 'Le fichier est trop volumineux<br>max. $0 Mo',
'image_too_small ' => 'Image trop petite<br>min. $0 pixels',
'invalid_file' => 'Fichier invalide<br>uniquement : $0',
'really_delete_picture' => 'Supprimer vraiment limage ?',
'rotate' => 'pivoter',
'save_image' => 'Enregistrer limage',
'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.',
];

View file

@ -0,0 +1,8 @@
<?php
return [
'failed' => 'Cette combinaison didentifiants est introuvable dans notre base de données.',
'not_found' => 'Cette adresse e-mail nest 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.',
];

View file

@ -0,0 +1,34 @@
<?php
return [
'dashboard_news' => 'Actualités du tableau de bord',
'add_news' => 'Ajouter une actualité',
'edit_news' => 'Modifier lactualité',
'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' => 'Lactualité 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 lactivation, 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. <strong>, <ul>, <li>, <br>, <a>).',
'display_date' => 'Date daffichage',
'display_date_hint' => 'Date affichée dans le tableau de bord (par défaut : date du jour)',
'file_links' => 'Liens de fichiers vers le centre de téléchargement',
'file_links_hint' => 'Ajoutez des liens vers des fichiers du centre de téléchargement qui seront mis en avant dans lactualité.',
'link_label' => 'Libellé du lien (p. ex. "Télécharger la liste de prix")',
'select_file' => 'Sélectionner un fichier',
'add_file_link' => 'Ajouter un lien de fichier',
];

59
resources/lang/fr/cal.php Normal file
View file

@ -0,0 +1,59 @@
<?php
return [
'months' => [
'April' => 'Avril',
'August' => 'Août',
'December' => 'Décembre',
'February' => 'Février',
'January' => 'Janvier',
'July' => 'Juillet',
'June' => 'Juin',
'March' => 'Mars',
'May' => 'Mai',
'November' => 'Novembre',
'October' => 'Octobre',
'September' => 'Septembre',
],
'months_short' => [
'Apr' => 'Avr',
'Aug' => 'Aoû',
'Dec' => 'Déc',
'Feb' => 'Fév',
'Jan' => 'Jan',
'Jul' => 'Juil',
'Jun' => 'Juin',
'Mar' => 'Mar',
'May' => 'Mai',
'Nov' => 'Nov',
'Oct' => 'Oct',
'Sep' => 'Sep',
],
'weekdays' => [
'Friday' => 'Vendredi',
'Monday' => 'Lundi',
'Saturday' => 'Samedi',
'Sunday' => 'Dimanche',
'Thursday' => 'Jeudi',
'Tuesday' => 'Mardi',
'Wednesday' => 'Mercredi',
],
'weekdays_min' => [
'Fr' => 'Ve',
'Mo' => 'Lu',
'Sa' => 'Sa',
'Su' => 'Di',
'Th' => 'Je',
'Tu' => 'Ma',
'We' => 'Me',
],
'weekdays_short' => [
'Fri' => 'Ven.',
'Mon' => 'Lun.',
'Sat' => 'Sam.',
'Sun' => 'Dim.',
'Thu' => 'Jeu.',
'Tue' => 'Mar.',
'Wed' => 'Mer.',
],
];

View file

@ -0,0 +1,38 @@
<?php
return [
'about_shop' => 'À propos de la boutique',
'add_customer_without_email' => 'Ajouter un client sans e-mail',
'add_customer_without_email_info1' => 'Remarque : Vous pouvez aussi créer vos clients sans adresse e-mail. Le système compare alors le nom et le code postal. En cas de correspondance, le support MIVITA vérifie les fiches clients et les attribue aux conseillers correspondants. Nous recommandons donc, dans la mesure du possible, dindiquer une adresse e-mail. Elle est unique et permet dattribuer clairement un client.',
'assigned' => 'attribué',
'assigned_counsellor' => 'Conseiller attribué',
'check' => 'vérifier',
'check_and_next' => 'vérifier et continuer',
'check_and_save' => 'vérifier et enregistrer',
'client_sovereignty' => 'Droit client',
'counsellor_allocate' => 'Attribuer un conseiller',
'created' => 'Créé',
'customer_add' => 'Ajouter des clients',
'customer_billing_address' => 'Adresse client / facturation',
'customer_data' => 'Numéro client',
'customer_details' => 'Détails client',
'customer_has_already_buy' => 'Je certifie que ce client a déjà acheté chez moi',
'customer_has_already_purchased' => 'Le client a déjà acheté.',
'customer_has_not_yet_purchased' => 'Le client na pas encore acheté.',
'customer_is_not_subscribed_to_newsletter' => 'Le client nest pas abonné à la newsletter.',
'customer_is_subscribed_to_newsletter' => 'Le client est abonné à la newsletter.',
'customer_sovereignty_info1' => 'Droit client : seuls les clients qui ne sont pas encore présents dans le Salescenter mivita peuvent être ajoutés.',
'date' => 'Date',
'delete_customer' => 'Supprimer le client',
'deleted_successfully' => 'Le client a été supprimé avec succès',
'delete_customer_confirm' => 'Êtes-vous sûr de vouloir supprimer ce client ?',
'edit' => 'modifier',
'edit_customer_data' => 'Modifier les données client',
'enter' => 'saisir',
'is_counsellor' => 'est conseiller',
'newsletter_subscribed_copy1' => 'Abonné à la newsletter. Vous ou MIVITA êtes autorisés à envoyer une newsletter au client si celui-ci a déjà effectué un achat actif. Si le client a explicitement indiqué ne pas souhaiter de newsletter, retirez impérativement la coche.',
'next_without_email' => 'continuer sans e-mail',
'under_review' => 'en vérification',
'select' => 'sélectionner',
'language_hint' => 'Les factures et documents sont créés dans cette langue.',
];

View file

@ -0,0 +1,192 @@
<?php
return [
'data_protect' => 'Politique de confidentialité',
'data_protect_copy1' => 'Notre site peut en principe être utilisé sans fournir de données personnelles. Si vous souhaitez utiliser certains services via notre site, le traitement de données personnelles peut toutefois être nécessaire.
<br>
Le traitement de vos données personnelles seffectue toujours conformément au Règlement général sur la protection des données (RGPD) et aux dispositions nationales applicables en matière de protection des données.
<br>
Nous avons pris des mesures techniques et organisationnelles appropriées conformément à lart. 32 RGPD afin de garantir un niveau de protection adéquat. Nous avons également mis en place des procédures permettant lexercice de vos droits, la suppression de données et la réaction aux risques pour les données. La protection de vos données personnelles a été prise en compte dès le développement et le choix du matériel et des logiciels utilisés. Nous respectons ainsi le principe de protection des données dès la conception et par défaut, art. 25 RGPD. Nos mesures de sécurité comprennent notamment la transmission chiffrée (SSL) des données entre votre navigateur et notre serveur.
<br><br>',
'data_protect_copy2' => '<strong>I. Définitions</strong>
<br>
Afin de rendre notre politique de confidentialité plus compréhensible, nous expliquons dabord certains termes utilisés. Au sens de cette politique :
<br><br>
1) « données à caractère personnel » désigne toute information se rapportant à une personne physique identifiée ou identifiable ; est réputée identifiable une personne pouvant être identifiée directement ou indirectement, notamment par un nom, un numéro didentification, des données de localisation, un identifiant en ligne ou des caractéristiques propres à son identité physique, physiologique, génétique, psychique, économique, culturelle ou sociale ;
<br><br>
2) « traitement » désigne toute opération ou tout ensemble dopérations appliquées à des données personnelles, automatisées ou non, telles que collecte, enregistrement, organisation, structuration, conservation, adaptation, consultation, utilisation, communication par transmission, diffusion, rapprochement, limitation, effacement ou destruction ;
<br><br>
3) « limitation du traitement » désigne le marquage de données personnelles conservées afin de limiter leur traitement futur ;
<br><br>
4) « profilage » désigne toute forme de traitement automatisé de données personnelles consistant à utiliser ces données pour évaluer certains aspects personnels dune personne physique, notamment concernant son rendement au travail, sa situation économique, sa santé, ses préférences, intérêts, fiabilité, comportement, localisation ou déplacements ;
<br><br>
5) « pseudonymisation » désigne le traitement de données personnelles de telle façon quelles ne puissent plus être attribuées à une personne concernée précise sans informations supplémentaires, pour autant que ces informations soient conservées séparément et protégées par des mesures techniques et organisationnelles ;
<br><br>
6) « responsable du traitement » désigne la personne physique ou morale, lautorité publique, le service ou autre organisme qui détermine seul ou conjointement les finalités et moyens du traitement ;
<br><br>
7) « sous-traitant » désigne la personne physique ou morale, lautorité publique, le service ou autre organisme qui traite des données personnelles pour le compte du responsable ;
<br><br>
8) « destinataire » désigne la personne physique ou morale, lautorité publique, le service ou autre organisme auquel des données personnelles sont communiquées ;
<br><br>
9) « tiers » désigne toute personne autre que la personne concernée, le responsable, le sous-traitant et les personnes autorisées à traiter les données sous leur autorité directe ;
<br><br>
10) « consentement » désigne toute manifestation de volonté libre, spécifique, éclairée et univoque par laquelle la personne concernée accepte le traitement de ses données personnelles ;
<br><br>
11) « violation de données personnelles » désigne une violation de sécurité entraînant destruction, perte, altération, divulgation non autorisée ou accès non autorisé à des données personnelles.
<br><br>',
'data_protect_copy3' => '<strong>II. Responsable</strong>
<br>
Le responsable au sens du RGPD, des lois applicables dans les États membres de lUnion européenne et des autres dispositions relatives à la protection des données est :
<br><br>
<strong>mivita care gmbh</strong><br>
Herr Alois Ried<br>
Leinfeld 2<br>
87755 Kirchhaslach<br>
Téléphone : +49 (0) 8333 94 61 767<br>
E-mail : info@mivita.care<br>
<br><br>',
'data_protect_copy4' => '<strong>III. Cookies</strong>',
'data_protect_copy5' => ' <br>
Notre site utilise des cookies. Les cookies sont de petits fichiers texte déposés et enregistrés sur un système informatique via un navigateur Internet.
<br>
De nombreux sites et serveurs utilisent des cookies. Beaucoup contiennent un identifiant de cookie unique permettant dassocier les sites et serveurs au navigateur dans lequel le cookie a été enregistré. Cela permet de distinguer le navigateur individuel de la personne concernée dautres navigateurs.
<br>
Les cookies nous permettent de fournir des services plus conviviaux qui ne seraient pas possibles sans leur utilisation.
<br>
Grâce aux cookies, les informations et offres de notre site peuvent être optimisées pour lutilisateur. Les cookies nous permettent notamment de reconnaître les utilisateurs de notre site afin de leur faciliter lutilisation, par exemple en évitant de devoir ressaisir les données de connexion à chaque visite.
<br>
Les données traitées par les cookies sont nécessaires aux fins mentionnées pour préserver nos intérêts légitimes selon lart. 6 al. 1 phrase 1 let. f RGPD.
<br>
Vous pouvez empêcher à tout moment linstallation de cookies par notre site en configurant votre navigateur et ainsi vous opposer durablement à leur dépôt. Les cookies déjà installés peuvent également être supprimés à tout moment via le navigateur ou dautres logiciels. Si vous désactivez les cookies, certaines fonctions de notre site peuvent ne pas être pleinement utilisables.
<br><br>
IV. Données et informations enregistrées lors de laccès à notre site
<br>
À chaque accès à notre site, des données et informations générales sont collectées et enregistrées dans les fichiers journaux du serveur.
<br><br>
<strong>Les données/informations suivantes peuvent être collectées :</strong>
<br>
- types et versions de navigateurs utilisés
- système dexploitation utilisé
- site depuis lequel vous arrivez sur notre site
- sous-pages consultées
- date et heure de laccès
- adresse IP
- fournisseur daccès Internet
- autres données similaires servant à la prévention des risques en cas dattaque de nos systèmes informatiques.
<br><br>
Nous avons besoin de ces informations pour fournir correctement les contenus du site, garantir le fonctionnement durable de notre système informatique et fournir aux autorités les informations nécessaires en cas de cyberattaque. Nous nen tirons aucune conclusion sur la personne concernée et conservons les données anonymes des logs séparément des données personnelles.
V. Contact électronique avec nous
<br><br>
Vous pouvez nous contacter par e-mail via ladresse indiquée dans les mentions légales ou par les formulaires de contact du site. Les données personnelles transmises par e-mail ou formulaire sont enregistrées automatiquement pour traiter votre demande et vous contacter.
<br><br>
En envoyant votre message, vous consentez au traitement des données transmises. Le traitement repose sur lart. 6 al. 1 let. a RGPD. Vous pouvez révoquer ce consentement à tout moment. Votre adresse e-mail est utilisée uniquement pour traiter votre demande, puis supprimée sauf consentement à un traitement ultérieur.
<br><br>',
'data_protect_copy6' => '<strong>VI. Bases juridiques du traitement</strong>
<br>
Lorsque nous obtenons votre consentement pour une finalité déterminée, lart. 6 I let. a RGPD sert de base juridique.
<br><br>
Si le traitement est nécessaire à lexécution dun contrat ou de mesures précontractuelles, il repose sur lart. 6 I let. b RGPD.
<br><br>
Si une obligation légale nécessite le traitement, par exemple des obligations fiscales, la base juridique est lart. 6 I let. c RGPD.
<br><br>
Si le traitement est nécessaire à la sauvegarde dintérêts vitaux, il repose sur lart. 6 I let. d RGPD.
<br><br>
Enfin, lart. 6 I let. f RGPD peut servir de base lorsquun intérêt légitime de notre entreprise ou dun tiers exige le traitement et que les intérêts, droits et libertés fondamentaux de la personne concernée ne prévalent pas.
<br><br>',
'data_protect_copy7' => '<strong>VII. Dispositions légales ou contractuelles relatives à la fourniture des données personnelles</strong>
<br>
La fourniture de données personnelles peut être légalement obligatoire (p. ex. règles fiscales) ou résulter de dispositions contractuelles. Si vous souhaitez conclure un contrat avec nous, certaines données personnelles doivent nous être fournies afin dêtre traitées. Sans ces données, aucun contrat ne peut être conclu.
<br><br>',
'data_protect_copy8' => ' <strong>VIII. Suppression/blocage régulier des données personnelles</strong>
<br>
Nous traitons et conservons les données personnelles uniquement pendant la durée nécessaire à la finalité de conservation ou lorsque le législateur européen ou un autre législateur le prévoit. Lorsque la finalité disparaît ou quun délai légal expire, les données sont bloquées ou supprimées conformément aux prescriptions légales.
<br><br>
Après expiration des délais légaux de conservation, les données sont supprimées régulièrement, sauf si elles restent nécessaires à lexécution ou à la préparation dun contrat. En Allemagne, les délais sont notamment de 10 ans selon les §§ 147 AO, 257 HGB et de 6 ans pour les lettres commerciales.
<br><br>',
'data_protect_copy9' => ' <strong>IX. Vos droits</strong>
<br>
<strong>1) Droit de confirmation et daccès</strong>
<br>
Vous avez le droit de nous demander confirmation que des données personnelles vous concernant sont traitées et dobtenir gratuitement des informations sur ces données ainsi quune copie.
<br><br>
Ces informations comprennent notamment les finalités du traitement, les catégories de données, les destinataires, la durée de conservation prévue, lexistence de droits de rectification, suppression, limitation ou opposition, le droit de réclamation auprès dune autorité de contrôle, lorigine des données lorsque celles-ci ne sont pas collectées auprès de vous, ainsi que lexistence dune prise de décision automatisée y compris le profilage.
<br><br>
Vous avez également le droit de savoir si des données ont été transférées vers un pays tiers ou une organisation internationale et dêtre informé des garanties appropriées.
<br><br>
<strong>2) Droit de rectification</strong>
<br>
Vous pouvez demander la rectification immédiate de données personnelles inexactes et, compte tenu des finalités du traitement, le complément de données incomplètes.
<br><br>
<strong>3) Droit à leffacement (« droit à loubli »)</strong>
<br>
Vous pouvez demander leffacement immédiat de vos données personnelles lorsque lun des motifs légaux sapplique, notamment lorsque les données ne sont plus nécessaires, lorsque vous révoquez votre consentement, lorsque vous vous opposez au traitement, lorsque les données ont été traitées illégalement ou lorsque leffacement est nécessaire au respect dune obligation légale.
<br><br>',
'data_protect_copy10' => 'Si lun des motifs ci-dessus sapplique et que vous souhaitez faire supprimer des données personnelles enregistrées chez nous, vous pouvez nous contacter à tout moment. Nous veillerons à ce que la demande soit exécutée sans délai.
<br>
Si nous avons rendu les données publiques et sommes tenus de les supprimer, nous prendrons, compte tenu de la technologie disponible et des coûts, des mesures appropriées pour informer les autres responsables traitant ces données que vous demandez la suppression de tout lien, copie ou réplication, dans la mesure le traitement nest pas nécessaire.
<br><br>
<strong>4) Droit à la limitation du traitement</strong>
<br>
Vous pouvez demander la limitation du traitement si vous contestez lexactitude des données, si le traitement est illicite et que vous refusez leffacement, si nous navons plus besoin des données mais que vous en avez besoin pour des droits en justice, ou si vous vous êtes opposé au traitement tant que la pondération des intérêts na pas été effectuée.
<br><br>
<strong>5) Droit à la portabilité des données</strong>
<br>
Vous avez le droit de recevoir les données personnelles que vous nous avez fournies dans un format structuré, courant et lisible par machine, et de les transmettre à un autre responsable lorsque les conditions légales sont réunies.
<br><br>
<strong>6) Droit dopposition</strong>
<br>
Vous avez le droit, pour des raisons tenant à votre situation particulière, de vous opposer à tout moment au traitement fondé sur lart. 6 al. 1 let. e ou f RGPD, y compris le profilage. En cas dopposition, nous ne traiterons plus les données sauf motifs légitimes impérieux ou nécessité pour faire valoir, exercer ou défendre des droits en justice. Vous pouvez également vous opposer à tout moment au traitement à des fins de publicité directe.
<br><br>',
'data_protect_copy11' => '<strong>7) Décisions automatisées individuelles, y compris le profilage</strong>
<br>
Vous avez le droit de ne pas faire lobjet dune décision fondée exclusivement sur un traitement automatisé, y compris le profilage, produisant des effets juridiques ou vous affectant de manière similaire, sauf exceptions prévues par la loi, le contrat ou votre consentement explicite.
<br><br>
<strong>8) Droit de retirer un consentement</strong>
<br>
Vous pouvez retirer à tout moment votre consentement au traitement de données personnelles.
<br><br>
<strong>9) Droit de réclamation auprès de lautorité de contrôle</strong>
<br>
Conformément à lart. 77 RGPD, vous avez le droit de déposer une réclamation auprès dune autorité de contrôle si vous estimez que le traitement de vos données nest pas conforme à la loi.
<br><br>
<strong>X. Collaboration avec des sous-traitants/tiers</strong>
<br>
Lorsque nous communiquons, transmettons ou rendons accessibles des données à des tiers, cela se fait uniquement sur la base dune autorisation légale, de votre consentement, dune obligation légale ou de nos intérêts légitimes. Les sous-traitants sont mandatés sur la base de lart. 28 RGPD.
<br><br>
<strong>XI. Transferts vers des pays tiers</strong>
<br>
Les traitements dans un pays tiers ou dans le cadre de services tiers nont lieu que si les conditions légales sont réunies, notamment les garanties spéciales des art. 44 et suivants RGPD.
<br><br>
<strong>XII. Intégration de services et contenus de tiers</strong>
<br>
Sur la base de nos intérêts légitimes, nous intégrons des offres de tiers afin dafficher leurs contenus. Ces fournisseurs peuvent prendre connaissance de votre adresse IP, nécessaire à la transmission des contenus. Des technologies telles que pixel tags ou web beacons peuvent également être utilisées à des fins statistiques ou marketing.
<br><br>
Exemples : polices externes Google Fonts et vidéos YouTube de Google LLC. Les politiques de confidentialité correspondantes sont disponibles sur https://policies.google.com/privacy.
<br><br>',
'data_protect_copy12' => '<strong>XIII. Outils danalyse et publicité Facebook Pixel</strong>
<br>
Ce site peut utiliser le pixel Facebook pour mesurer les conversions. Le fournisseur est Facebook Ireland Limited, 4 Grand Canal Square, Dublin 2, Irlande. Selon Facebook, les données peuvent également être transférées vers les États-Unis et dautres pays tiers. Le comportement des visiteurs peut ainsi être suivi après clic sur une annonce Facebook afin dévaluer et doptimiser les mesures publicitaires. Lutilisation repose sur lart. 6 al. 1 let. f RGPD ou, si un consentement est requis, sur lart. 6 al. 1 let. a RGPD.
<br><br>
Lorsque des données personnelles sont collectées et transmises à Facebook, nous sommes responsables conjointement avec Facebook Ireland Limited pour la collecte et la transmission. Les traitements ultérieurs par Facebook ne relèvent pas de cette responsabilité conjointe. Vous trouverez davantage dinformations dans la politique de confidentialité de Facebook.
<br><br>
<strong>Collecte de données via Google Analytics et cookies</strong>
<br>
Notre site peut utiliser Google Analytics, un service danalyse web de Google Inc. Google Analytics utilise des cookies et crée des profils dutilisation pseudonymisés. Les données collectées peuvent comprendre le système dexploitation, le navigateur, ladresse IP, lURL de référence ainsi que la date et lheure de la requête. Les informations sont transmises à un serveur Google et utilisées pour analyser lutilisation du site et établir des rapports.
<br>
Vous pouvez empêcher linstallation de cookies via les paramètres de votre navigateur ou en installant le module complémentaire de navigateur proposé par Google. Les données traitées par cookies sont nécessaires à nos intérêts légitimes selon lart. 6 al. 1 phrase 1 let. f RGPD.
<br><br>
<strong>Google utilise le cookie DoubleClick DART</strong>
<br>
Les utilisateurs peuvent désactiver lutilisation du cookie DART via la politique de confidentialité du réseau publicitaire et de contenu Google. Les cookies peuvent être refusés dans les paramètres du navigateur ; cela peut toutefois limiter certaines fonctions du site.
<br><br>',
'data_protect_copy13' => '<strong>Google Adwords Conversion Tracking</strong>
<br>
Nous utilisons Google Conversion Tracking pour enregistrer statistiquement lutilisation de notre site et loptimiser pour vous. Google place un cookie sur votre ordinateur si vous arrivez sur notre site via une annonce Google.<br>
Ces cookies expirent après 30 jours et ne servent pas à lidentification personnelle. Si lutilisateur visite certaines pages et que le cookie est encore valide, Google et le client peuvent reconnaître que lutilisateur a cliqué sur lannonce et a été redirigé vers cette page.<br>
Les informations collectées servent à établir des statistiques de conversion. Aucune information permettant didentifier personnellement les utilisateurs nest transmise.<br>
Vous pouvez refuser le cookie nécessaire via les paramètres de votre navigateur ou bloquer les cookies du domaine « www.googleadservices.com ».<br>
La politique de confidentialité Google relative au conversion tracking est disponible ici (https://services.google.com/sitestats/de.html)',
'data_protect_stand_shop' => 'Version : 18.02.2022',
'data_protect_stand_site' => 'Version : 05.10.2018',
'data_protect' => 'Protection des données',
];

92
resources/lang/fr/dhl.php Normal file
View file

@ -0,0 +1,92 @@
<?php
return [
'dhl' => 'DHL',
'status' => [
'pending' => 'En attente',
'created' => 'Créé',
'in_transit' => 'En cours',
'out_for_delivery' => 'En livraison',
'delivered' => 'Livré',
'exception' => 'Problème',
'returned' => 'Retourné',
'failed' => 'Erreur',
'unknown' => 'Inconnu',
'canceled' => 'Annulé',
'cancelled' => 'Annulé',
'shipped' => 'Expédié',
],
'type' => [
'outbound' => 'Sortant',
'return' => 'Retour',
],
'product_codes' => [
'V01PAK' => 'DHL Paket (national)',
'V53WPAK' => 'DHL Paket International',
'V54EPAK' => 'DHL Paket International',
'V55PAK' => 'DHL Paket International',
'V62KP' => 'DHL Kleinpaket',
'V62WP' => 'DHL Warenpost (Legacy)',
'V66WPI' => 'DHL Paket International',
],
'labels' => [
'shipment_info' => 'Informations dexpédition',
'recipient_info' => 'Informations du destinataire',
'order_info' => 'Informations de commande',
'tracking_info' => 'Informations de suivi',
'related_shipments' => 'Retours liés',
'additional_services' => 'Services supplémentaires',
'api_response' => 'Réponse API (debug)',
],
'actions' => [
'download_label' => 'Télécharger létiquette',
'cancel_shipment' => 'Annuler lexpédition',
'create_return' => 'Créer une étiquette de retour',
'update_tracking' => 'Mettre à jour le suivi',
'view_details' => 'Afficher les détails',
'track_at_dhl' => 'Suivre chez DHL',
'local_tracking' => 'Suivi local',
],
'fields' => [
'id' => 'ID',
'dhl_shipment_no' => 'Numéro dexpédition DHL',
'routing_code' => 'Code de routage',
'billing_number' => 'Numéro de facturation',
'type' => 'Type',
'product_code' => 'Code produit',
'label_format' => 'Format détiquette',
'weight' => 'Poids',
'status' => 'Statut',
'tracking_status' => 'Statut de suivi',
'created_at' => 'Créé',
'updated_at' => 'Dernière modification',
'last_tracked_at' => 'Dernier suivi',
'firstname' => 'Prénom',
'lastname' => 'Nom',
'company' => 'Entreprise',
'street' => 'Rue',
'postal_code' => 'Code postal',
'city' => 'Ville',
'country' => 'Pays',
'email' => 'E-mail',
'phone' => 'Téléphone',
],
'messages' => [
'shipment_created' => 'Expédition créée avec succès !',
'shipment_cancelled' => 'Expédition annulée avec succès !',
'return_label_created' => 'Étiquette de retour créée avec succès !',
'tracking_updated' => 'Informations de suivi mises à jour !',
'label_downloaded' => 'Étiquette téléchargée avec succès !',
'no_shipments_found' => 'Aucune expédition trouvée.',
'shipment_already_exists' => 'Une expédition existe déjà pour cette commande.',
'cannot_cancel_delivered' => 'Les expéditions livrées ne peuvent pas être annulées.',
'tracking_not_available' => 'Informations de suivi non disponibles.',
'label_not_available' => 'Étiquette dexpédition non disponible.',
],
];

130
resources/lang/fr/email.php Normal file
View file

@ -0,0 +1,130 @@
<?php
return [
'account_active' => 'Compte activé',
'account_active_copy1line' => 'Votre compte a été activé après vérification réussie. Vous trouverez votre contrat conseiller sur la page daccueil dans la zone de connexion de my.mivita.care. Connectez-vous avec vos identifiants pour effectuer les prochaines étapes.',
'account_incomplete_copy1line' => 'Votre compte na pas été activé, vos données ne sont pas complètes. Veuillez suivre le lien ci-dessous pour compléter vos données.',
'activate_copy' => 'Veuillez confirmer votre e-mail et activer votre compte via ce lien :',
'active_copy1line' => 'Vous recevez cet e-mail parce que nous avons reçu une demande de modification de votre adresse e-mail pour votre compte.',
'button_account' => 'vers le compte',
'change_e_mail' => 'Modifier ladresse e-mail',
'checkout_copy1line' => 'merci pour votre commande chez mivita.care. Vous trouverez ci-dessous un récapitulatif de votre commande pour contrôle.',
'checkout_copy3line' => 'Nous sommes à votre disposition pour toute question.',
'checkout_copy3line_extern' => 'Commande via conseiller :',
'checkout_mail_bank_bic' => 'BIC :',
'checkout_mail_bank_code' => 'Référence de paiement',
'checkout_mail_bank_holder' => 'Titulaire du compte :',
'checkout_mail_bank_iban' => 'IBAN :',
'checkout_mail_bank_name' => 'Banque',
'checkout_mail_bank_total' => 'Montant total',
'checkout_mail_deliver_addess' => 'Votre adresse de livraison :',
'checkout_mail_deliver_customer' => 'Adresse de livraison du client :',
'checkout_mail_hl1' => 'Vous avez commandé les articles suivants :',
'checkout_mail_invoice_addess' => 'Votre adresse de facturation :',
'checkout_mail_order_for_extern' => 'Commande client via boutique externe :',
'checkout_mail_order_for_me' => 'Commande conseiller pour vous :',
'checkout_mail_order_for_membership' => 'Commande conseiller pour votre adhésion :',
'checkout_mail_order_for_ot' => 'Commande conseiller pour votre client :',
'checkout_mail_order_for_wizard' => 'Commande conseiller pour votre inscription :',
'checkout_mail_pay_approved' => 'Votre paiement a été approuvé, une confirmation de paiement sera créée automatiquement.',
'checkout_mail_pay_error' => 'Le paiement a échoué !',
'checkout_mail_pay_info' => 'Info paiement :',
'checkout_mail_pay_invoice_open' => 'Votre paiement sur facture a été approuvé.',
'checkout_mail_pay_pre' => 'Paiement anticipé :',
'checkout_mail_pay_pre_c1' => 'Veuillez virer',
'checkout_mail_pay_pre_c2' => 'EUR sur le compte suivant afin de finaliser lachat.',
'checkout_mail_pay_ref' => 'Référence de paiement :',
'checkout_mail_pay_success' => 'Le paiement est confirmé !',
'checkout_mail_pay_with' => 'Paiement avec :',
'checkout_mail_same_address' => 'Ladresse de livraison est identique à ladresse de facturation',
'checkout_mail_shipping' => 'Frais demballage et de livraison',
'checkout_mail_status_info' => 'Info statut :',
'checkout_mail_subtotal_ws' => 'Somme hors TVA',
'checkout_mail_system_status' => 'Statut système :',
'checkout_mail_tax' => 'plus TVA',
'checkout_mail_tax_info' => 'Prix TVA incluse',
'checkout_mail_total' => 'Prix total',
'checkout_mail_your_mail' => 'Votre e-mail :',
'checkout_subject' => 'Votre commande chez',
'checkout_subject_extern' => 'Nouvelle commande',
'checkout_subject_paid' => 'Confirmation de paiement - votre commande chez',
'copy2line' => 'Ou copiez ce lien dans la barre dadresse de votre navigateur.',
'copy3line' => 'Nous restons volontiers à votre disposition pour toute question.',
'copy_to_browser' => 'Ou copiez ce lien dans la barre dadresse de votre navigateur.',
'credit_copy1line' => 'Votre avoir est arrivé et vous lavez bien mérité !
Nous transférerons le montant à verser sur votre compte dans les 5 prochains jours ouvrés. Veuillez vérifier que vos coordonnées bancaires actuelles sont enregistrées dans votre compte Salescenter (Mon compte -> Mes données). Nous vous souhaitons encore beaucoup de succès chez MIVITA.
Vous souhaitez aller encore plus loin ? Parlez-en à votre sponsor ou à dautres responsables déquipe. Ils élaboreront avec vous, sur la base de notre plan marketing, votre voie personnelle vers le succès.
Cordialement,
Votre équipe MIVITA',
'credit_title' => 'Votre avoir sur mivita.care',
'dear_mrs' => 'Bonjour',
'dear_sir' => 'Bonjour',
'email' => 'E-mail',
'email_incomplete' => 'Vos données ne sont pas complètes.',
'email_subject' => 'Message de mivita.care',
'email_verify' => 'Confirmez votre adresse e-mail',
'email_verify_copy1line' => 'Merci pour votre inscription. Veuillez suivre le lien ci-dessous pour confirmer votre adresse e-mail.',
'first_name' => 'Prénom',
'footer_copy1' => 'mivita care gmbh | Leinfeld 2 | 87755 Kirchhaslach | Téléphone : +49 (0) 8333 94 61 767 | E-mail : info@mivita.care',
'footer_copy2' => 'Gérant : Alois Ried | Tribunal denregistrement : Memmingen | Numéro denregistrement : HRB 21591 | N° TVA : DE 453867883',
'footer_copy3' => '© 2020 All Rights Reserved',
'greetings' => 'Cordialement',
'hello' => 'Bonjour',
'invoice_copy1line' => 'merci pour votre commande chez mivita.care. Vous trouverez ci-joint la facture relative à votre commande : ',
'invoice_title' => 'Facture relative à votre commande sur mivita.care',
'invoice_subject' => 'Facture relative à votre commande',
'cancellation_invoice_copy1line' => 'vous trouverez ci-joint la facture dannulation relative à votre commande : ',
'cancellation_invoice_title' => 'Facture dannulation relative à votre commande sur mivita.care',
'cancellation_invoice_subject' => 'Facture dannulation relative à votre commande',
'last_name' => 'Nom',
'mail_confirm' => 'Confirmer le-mail',
'message' => 'Message',
'phone' => 'Téléphone',
'request_from' => 'Demande de mivita.care',
'reset_pass_copy1line' => 'Vous recevez cet e-mail parce que nous avons reçu une demande de réinitialisation du mot de passe de votre compte.',
'reset_passwort' => 'Réinitialiser le mot de passe',
'sales_partnership' => 'Partenariat commercial',
'sales_partnership_message' => 'Remarque',
'salutation' => 'Civilité',
'sender' => 'Votre équipe mivita.care',
'status_copy1line' => 'Statut de votre commande sur mivita.care',
'subject' => 'Objet',
'subject_activate' => 'Activer le compte',
'subject_reset' => 'Réinitialiser le mot de passe',
'update_level_copy1line' => '<b>Félicitations !</b><br><br>Nous sommes incroyablement fiers de vous ! Le mois dernier, avec vos <b>:tp TP</b>, vous navez pas seulement atteint votre qualification, vous avez accompli quelque chose de formidable !<br><br>Votre engagement constant a porté ses fruits. À partir de maintenant, vous êtes :<br><br><h2 style="color: #6b7758; margin: 20px 0;"><b> :to </b></h2><br>Cest une étape majeure dans votre carrière chez MIVITA !',
'update_level_title' => 'Vous avez atteint votre prochain niveau de carrière !',
'update_level_copy2line' => 'Votre succès est aussi notre succès, et nous sommes ravis de pouvoir vous accompagner dans ce parcours passionnant. Avec ce nouveau niveau, encore plus de possibilités souvrent à vous :<br><br>- Commissions plus élevées et meilleures marges<br>- Possibilités étendues pour votre business<br>- Plus de reconnaissance et de statut dans léquipe',
'update_level_copy3line' => 'Ce nest que le début ! <br><br>Votre succès prouve que le travail continu et la passion mènent au but. Restez motivé, inspirez votre équipe et atteignez avec nous des objectifs encore plus grands.<br><br>Votre prochain niveau vous attend déjà, et nous savons que vous y arriverez !',
'verify_copy1line' => 'Nous avons enregistré des données vous concernant dans notre système. Veuillez suivre le lien ci-dessous pour confirmer votre adresse e-mail. Vous pouvez également modifier ou supprimer vos données.',
'verify_e_mail' => 'Confirmez vos données et votre adresse e-mail',
'your_request_from' => 'Votre demande de',
'your_custom_payout' => 'Votre conseiller mivita personnel - :name a préparé une commande individuelle pour vous. Il ne vous reste plus quà payer votre commande. Cliquez sur le lien pour accéder au checkout de mivita.care.',
'your_custom_abo_payout' => 'Votre conseiller mivita personnel - :name a préparé une commande dabonnement individuelle pour vous. Il ne vous reste plus quà payer votre commande dabonnement. Cliquez sur le lien pour accéder au checkout de mivita.care.',
'subject_custom_payout' => 'Votre commande individuelle de :name sur mivita.care',
'subject_custom_abo_payout' => 'Votre commande dabonnement individuelle de :name sur mivita.care',
'button_custom_payout' => 'vers le paiement',
'checkout_custom_payout' => 'Votre conseiller a composé les produits suivants pour vous',
'user_restore_subject' => 'Votre compte a été restauré',
'user_restore_title' => 'Restauration du compte réussie',
'user_restore_greeting' => 'Bonjour :name,',
'user_restore_copy1line' => 'nous sommes heureux de vous informer que votre compte chez mivita.care a été restauré avec succès.',
'user_restore_copy2line' => 'Comme votre mot de passe a été réinitialisé pour des raisons de sécurité lors de la suppression, vous devez définir un nouveau mot de passe. Cliquez sur le bouton suivant pour réinitialiser votre mot de passe :',
'user_restore_copy3line' => '• Votre mot de passe a été réinitialisé - veuillez définir un nouveau mot de passe via le lien ci-dessus.<br>• Si vous aviez une boutique : le nom de boutique (sous-domaine) a été libéré lors de la suppression et doit être attribué à nouveau.',
'user_restore_button' => 'Définir un nouveau mot de passe',
'user_restore_account_valid' => 'Votre adhésion conseiller est valable jusquau :',
'user_restore_important' => 'Remarques importantes :',
'dhl_tracking_subject' => 'Votre envoi est en route - mivita.care',
'dhl_tracking_subject_multiple' => 'Vos :count envois sont en route - mivita.care',
'dhl_tracking_title' => 'Votre envoi est en route !',
'dhl_tracking_preview' => 'Numéro denvoi : :number - Suivez votre colis chez DHL',
'dhl_tracking_message' => 'bonne nouvelle ! Votre commande a été expédiée et est en route vers vous.',
'dhl_tracking_message_multiple' => 'bonne nouvelle ! Votre commande a été expédiée en :count colis et est en route vers vous.',
'dhl_tracking_package_label' => 'Colis :number',
'dhl_tracking_number_label' => 'Votre numéro denvoi',
'dhl_tracking_order_ref' => 'Numéro de commande : :order',
'dhl_tracking_button' => 'Suivre lenvoi chez DHL',
'dhl_tracking_info' => 'Vous pouvez consulter à tout moment le statut actuel de votre envoi chez DHL via le bouton ci-dessus.',
];

71
resources/lang/fr/gtc.php Normal file
View file

@ -0,0 +1,71 @@
<?php
return [
'gtc' => 'Conditions générales de vente',
'gtc_copy1' => '<strong>Principes généraux</strong><br>
1) Les présentes conditions générales sappliquent à tous les contrats que vous concluez avec nous en tant que fournisseur (mivita care gmbh) via le site Internet www.mivita.care. Loffre de produits de notre boutique en ligne sadresse exclusivement aux clients majeurs. Lintégration de conditions générales de clients qui contredisent les présentes conditions est dores et déjà refusée. La langue du contrat est lallemand. Vous pouvez consulter et imprimer ces conditions générales sur notre site.
<br>
<br>
2) Nous ne conservons pas le texte complet du contrat. Avant lenvoi de votre commande, vous pouvez imprimer les données contractuelles via le système de panier en ligne ou les sauvegarder électroniquement. Après réception de votre commande, nous vous envoyons à nouveau par e-mail les données de commande, les informations légalement requises pour les contrats à distance et les conditions générales.
<br><br>',
'gtc_copy2' => '<strong>Conclusion du contrat</strong><br>
1) Nos offres de produits sur Internet sont sans engagement et ne constituent pas une offre ferme de conclusion de contrat. Vous pouvez passer une commande, cest-à-dire une offre dachat ferme, via notre système de panier en ligne. Pour cela, placez les marchandises que vous souhaitez acheter dans le « panier ». Le bouton correspondant dans la barre de navigation vous permet daccéder au panier et dy effectuer des modifications à tout moment. Après lappel de la page « Caisse » et la saisie de vos données personnelles ainsi que des conditions de paiement et de livraison, toutes les données de commande sont à nouveau affichées dans un récapitulatif. Vous pouvez alors vérifier toutes les informations avant lenvoi, les modifier (également via la fonction « retour » du navigateur) ou annuler lachat. En envoyant la commande via le bouton « Acheter », vous faites une offre ferme de conclusion du contrat. Vous recevez ensuite une confirmation automatique de réception par e-mail, qui ne constitue pas encore lacceptation du contrat.
<br><br>
(2) Nous pouvons accepter votre offre dans un délai de 2 jours par lenvoi dune confirmation de commande par e-mail. Si vous ne recevez pas de message correspondant dans ce délai, vous nêtes plus lié à votre commande. Les prestations déjà fournies vous seront alors remboursées sans délai.
<br><br>
(3) Le traitement de la commande et la transmission de toutes les informations nécessaires à la conclusion du contrat seffectuent en partie automatiquement par e-mail. Vous devez donc vous assurer que ladresse e-mail indiquée est correcte, que la réception des e-mails est techniquement garantie et quelle nest notamment pas empêchée par des filtres anti-spam.
<br><br>',
'gtc_copy3' => '<strong>§ 3 Prix/modalités de paiement</strong><br>
(1) Les prix indiqués dans nos offres ainsi que les frais de livraison sont des prix totaux. Ils comprennent tous les éléments de prix, y compris toutes les taxes applicables.
<br><br>
(2) Les frais de livraison ne sont pas inclus dans le prix dachat. Ils peuvent être consultés via un bouton identifié sur notre site ou dans la description de larticle concerné. Ils sont également indiqués séparément au cours du processus de commande et sont à votre charge en plus du prix, sauf si une livraison gratuite est promise.
<br><br>
(3) Nous vous proposons différents modes de paiement. Ceux-ci sont indiqués sous un bouton correspondant sur notre site ou dans la description de larticle concerné. Sauf indication contraire, les créances de paiement issues du contrat conclu sont immédiatement exigibles.
<br><br>',
'gtc_copy4' => '<strong>§ 4 Conditions de livraison</strong><br>
(1) Les conditions de livraison, les délais de livraison et les éventuelles restrictions de livraison peuvent être consultés via un bouton correspondant sur notre site ou dans la description de larticle concerné.
<br><br>
(2) Pour les consommateurs, le risque de perte accidentelle et de détérioration accidentelle de la chose vendue pendant lexpédition ne passe à ceux-ci quau moment de la remise de la marchandise. Cela sapplique indépendamment du fait que lexpédition soit assurée ou non. Cela ne sapplique toutefois pas si vous avez chargé de votre propre initiative un transporteur non désigné par lentrepreneur ou une autre personne chargée de lexécution de lexpédition.
<br><br>
(3) Pour les entrepreneurs, la livraison et lexpédition se font à leurs propres risques.
<br><br>',
'gtc_copy5' => '<strong>§ 5 Droit de rétention, réserve de propriété</strong><br>
(1) Vous ne pouvez exercer un droit de rétention que dans la mesure il sagit de créances issues du même rapport contractuel.
<br><br>
(2) La marchandise reste notre propriété jusquau paiement intégral du prix dachat.
<br><br>
(3) Pour les entrepreneurs, les dispositions suivantes sappliquent en complément :
<br><br>
- Nous nous réservons la propriété de la marchandise jusquau règlement complet de toutes les créances issues de la relation commerciale en cours. Avant le transfert de propriété de la marchandise réservée, tout nantissement ou transfert à titre de garantie est interdit.
<br>
- Vous pouvez revendre la marchandise dans le cadre dune activité commerciale régulière. Dans ce cas, vous nous cédez dès à présent toutes les créances à hauteur du montant de la facture issues de la revente. Nous acceptons cette cession ; vous restez toutefois autorisé à recouvrer les créances. Si vous ne remplissez pas correctement vos obligations de paiement, nous nous réservons le droit de recouvrer nous-mêmes les créances.
<br>
- En cas de combinaison ou mélange de la marchandise réservée, nous acquérons une copropriété sur la nouvelle chose proportionnellement à la valeur facturée de la marchandise réservée par rapport aux autres objets transformés au moment de la transformation.
<br>
- Nous nous engageons à libérer, à votre demande, les garanties qui nous reviennent dans la mesure leur valeur réalisable dépasse de plus de 10 % les créances à garantir. Le choix des garanties à libérer nous appartient.
<br><br>',
'gtc_copy6' => '<strong>§ 6 Garantie</strong><br>
(1) Les droits légaux en matière de défauts sappliquent.
<br><br>
(2) Pour les entrepreneurs, les dispositions suivantes dérogent à lalinéa 1 :
<br><br>
- En tant quentrepreneur, vous devez examiner la marchandise immédiatement et avec le soin requis quant aux écarts de qualité et de quantité et nous signaler par écrit les défauts apparents dans les 7 jours suivant la réception. Lenvoi dans les délais suffit. Cela vaut également pour les défauts cachés découverts ultérieurement. Si vous ne respectez pas votre obligation dexamen et de réclamation, toute revendication de droits de garantie est exclue.
<br>
- En cas de défaut, nous pouvons, à notre choix, assurer la garantie par réparation ou livraison de remplacement. Si la suppression du défaut échoue, vous pouvez à votre choix demander une réduction ou résilier le contrat. La suppression du défaut est réputée avoir échoué après une deuxième tentative infructueuse, sauf si la nature de la chose, du défaut ou dautres circonstances indiquent autre chose. En cas de réparation, nous ne supportons pas les coûts supplémentaires résultant du transport de la marchandise vers un lieu autre que le lieu dexécution, si ce transport ne correspond pas à lutilisation prévue de la marchandise.
<br>
- Le délai de garantie est dun an à compter de la livraison. Le délai raccourci ne sapplique pas aux dommages causés fautivement par nous résultant dune atteinte à la vie, au corps ou à la santé, ni aux dommages causés par négligence grave ou intentionnellement, en cas de dol, ainsi quaux recours conformément aux §§ 478, 479 BGB.
<br><br>',
'gtc_copy7' => '<strong>§ 7 Responsabilité</strong><br>
(1) Nous sommes responsables sans limitation des dommages résultant dune atteinte à la vie, au corps ou à la santé. Nous sommes également responsables sans limitation dans tous les cas de faute intentionnelle et de négligence grave, en cas de dissimulation dolosive dun défaut, en cas de prise en charge dune garantie quant à la qualité de lobjet acheté et dans tous les autres cas prévus par la loi.
<br>
(2) Si des obligations contractuelles essentielles sont concernées, notre responsabilité en cas de négligence légère est limitée au dommage prévisible typique du contrat. Les obligations contractuelles essentielles sont celles qui résultent de la nature du contrat et dont la violation compromettrait la réalisation de lobjet du contrat, ainsi que les obligations que le contrat nous impose selon son contenu pour atteindre lobjet du contrat, dont lexécution rend possible la bonne réalisation du contrat et au respect desquelles vous pouvez régulièrement vous fier.
<br><br>',
'gtc_copy8' => '<strong>§ 8 Dispositions finales</strong><br>
(1) Le droit allemand sapplique. Pour les consommateurs, ce choix de loi ne vaut que dans la mesure il ne retire pas la protection accordée par les dispositions impératives du droit de lÉtat de résidence habituelle du consommateur (principe de faveur). Les dispositions de la Convention des Nations Unies sur les contrats de vente internationale de marchandises sont expressément exclues.
<br><br>
(2) Le lieu dexécution de toutes les prestations issues des relations commerciales existant avec nous ainsi que le tribunal compétent sont ceux de notre siège, dans la mesure vous nêtes pas consommateur mais commerçant, personne morale de droit public ou patrimoine spécial de droit public. Il en va de même si vous navez pas de tribunal compétent général en Allemagne ou dans lUE ou si votre domicile ou lieu de résidence habituel nest pas connu au moment de lintroduction de laction. La faculté de saisir également le tribunal dun autre ressort légal reste inchangée.
<br><br>',
'gtc_copy9' => 'Version : 05.10.2018
<br><br>',
'' => '',
];

View file

@ -0,0 +1,61 @@
<?php
return [
'' => '',
'MIVITA_Consultancy_agreement' => 'Contrat_conseiller_MIVITA',
'active_role' => 'Rôle actif',
'activities' => 'Activités',
'adjust_data' => 'Adapter les données',
'adviser_membership_active' => 'Adhésion conseiller active',
'adviser_onlineshop_active' => 'Boutique en ligne conseiller active',
'adviser_onlineshop_inactive' => 'Boutique conseiller inactive',
'advisor_account_inactive' => 'Compte conseiller inactif',
'at' => 'le',
'change_your_email_address' => 'Modifiez votre adresse e-mail.',
'change_your_personal_data' => 'Modifiez vos données personnelles.',
'change_your_personal_password' => 'Modifiez votre mot de passe personnel.',
'create_your_personal_password' => [
'' => 'Créez votre mot de passe personnel.',
],
'current_points_for' => 'Points actuels pour',
'data' => 'Données',
'data_complete_unlocked' => 'Données complètes, activées',
'declaration_of_consent' => 'Déclaration de consentement',
'email_verified' => 'E-mail vérifié',
'expired_on' => 'expiré le',
'log_out_and_see_you_soon' => 'Se déconnecter et à bientôt.',
'login' => 'Connexion',
'manage_membership' => 'Gérer ladhésion',
'manage_membership_now_here' => 'Gérer ladhésion ici maintenant',
'membership' => 'Adhésion',
'news_updates' => 'Actualités & mises à jour',
'news_archive' => 'Archive des actualités',
'news_archive_title' => 'Toutes les actualités & mises à jour',
'news_archive_current' => 'Actualités actuelles',
'news_archive_older' => 'Messages plus anciens',
'news_archive_empty' => 'Aucun message plus ancien disponible.',
'news_archive_link' => 'Voir toutes les actualités',
'news_back_to_dashboard' => 'Retour au tableau de bord',
'open_since' => 'Ouvert depuis',
'open_your_shop' => 'Ouvrez votre propre boutique mivita',
'read_less' => 'Afficher moins',
'read_more' => 'Lire la suite',
'privacy_policy_approved' => 'Politique de confidentialité acceptée',
'security' => 'Sécurité',
'settings_your_shop' => 'Paramètres de votre boutique',
'shop_not_booked' => 'Boutique non réservée',
'today_is' => 'Nous sommes le',
'until' => 'jusquau',
'welcome_back' => 'Bon retour',
'your_shop' => 'Votre boutique',
'monthly_statistics' => 'Statistique mensuelle',
'customer_turnover_points' => 'Points chiffre daffaires clients',
'team_turnover_points' => 'Points chiffre daffaires équipe',
'direct_new_partners' => 'Nouveaux partenaires directs',
'team_new_partners' => 'Nouveaux partenaires dans léquipe',
'customer_subscriptions' => 'Abonnements clients',
'team_subscriptions' => 'Abonnements équipe',
'own' => 'Propres',
'live_calculation_hint' => 'Calcul en direct (pas encore terminé)',
'live_calculation_hint_text' => 'Ne sera calculé quà la fin du mois.',
];

View file

@ -0,0 +1,74 @@
<?php
return [
'' => '',
'acceptect_data_protection' => 'Jaccepte par la présente le traitement des données mentionné dans :datenschutz ',
'add_as_guest' => 'sinscrire comme invité',
'add_product' => 'Ajouter un produit',
'completed' => 'terminé',
'confirm_my_data_is_correct_and_complete' => 'Je confirme que mes données sont exactes et complètes. Après lenvoi, toute modification ne sera possible que via mon conseiller MIVITA personnel.*',
'copy' => 'Copié !',
'copy_link' => 'Copier le lien',
'copy_link_info' => 'Partage ce lien avec les invités (p. ex. WhatsApp ou SMS). Ils accéderont ainsi à une page où ils pourront saisir eux-mêmes leurs données. Utilise licône de copie pour copier immédiatement le lien dans ton presse-papiers.',
'country_can_no_longer_be_changed_after_created' => 'Le pays de livraison ne peut plus être modifié après la création. Toutes les adresses de livraison doivent se trouver dans ce pays.',
'create_delivery_address_host_info' => 'Ladresse de livraison et lhôte/hôtesse ne sont pas encore créés.',
'create_guest' => 'Créer un invité',
'create_guests' => 'Créer des invités',
'create_guests_info' => 'Ensuite, des invités pourront être créés et des commandes passées.',
'create_new_homeparty' => 'Créer une nouvelle Auszeitparty',
'credit_bonus' => 'Avoir bonus',
'credit_homeparty_voucher' => 'Avoir bon Auszeitparty',
'current_bonus_view' => 'Vue bonus actuelle',
'data_protection' => 'Politique de confidentialité',
'data_protection_reasons_your_personal_data_will_not_be_shown' => 'Remarque : pour des raisons de protection des données, vos données personnelles ne sont pas affichées ici aux autres participants. Après lenvoi, elles ne pourront plus être modifiées. Si vous souhaitez effectuer une modification ultérieure, veuillez vous adresser à votre conseiller MIVITA personnel.',
'deduct_points_by_voucher' => 'Déduction de points par bon',
'delivery_address_homeparty' => 'Adresse de livraison Auszeitparty',
'delivery_directly_to_the_guest' => 'Livraison directement à linvité',
'delivery_to_host' => 'Livraison à lhôte/hôtesse',
'description_welcome_text' => 'Description / texte de bienvenue',
'enter_your_personal_data_for_homeparty' => 'Saisie de vos données personnelles pour lAuszeitparty',
'event_date' => 'Date de lévénement',
'event_place' => 'Lieu de lévénement',
'from' => 'à partir de',
'general_overview' => 'Vue densemble',
'guest' => 'Invité',
'guest_delete_really' => 'Supprimer vraiment linvité ?',
'guest_lists' => 'Liste des invités',
'guest_order_sent_directly_info' => 'Si un invité souhaite que sa commande soit envoyée directement à son adresse personnelle, tu peux lindiquer séparément dans le processus de commande.',
'guests' => 'Invités',
'homeparty' => 'Auszeitparty',
'homeparty_add_host_address_info' => 'Saisis ici ladresse de lhôte/hôtesse où lAuszeitparty est organisée.',
'homeparty_delete_really' => 'Supprimer vraiment lAuszeitparty ?',
'homeparty_invoice_info' => 'La facture mivita est établie à ton adresse (conseiller/ère). Tu gères toi-même le règlement avec lhôte/hôtesse et les invités.',
'homeparty_manage' => 'Gérer lAuszeitparty',
'host' => 'Hôte/hôtesse',
'host_address_save' => 'Enregistrer ladresse de lhôte/hôtesse',
'host_can_not_delete' => 'Lhôte/hôtesse ne peut pas être supprimé(e)',
'host_homeparty' => 'Hôte/hôtesse Auszeitparty',
'host_organiser_event' => 'Hôte/hôtesse / organisateur/trice',
'invitation' => 'Invitation',
'invitation_link_for_guests' => 'Lien dinvitation pour les invités',
'invoice_address' => 'Adresse de facturation',
'let_your_guests_fill_in_their_own_details' => 'laisse tes invités remplir eux-mêmes leurs données',
'manage' => 'Gérer',
'missing' => 'manquent encore',
'next_bonus' => 'Prochain bonus',
'order' => 'Commande',
'order_can_be_send_delivery_address_info' => 'La commande ne peut être envoyée quune fois ladresse de livraison de lhôte/hôtesse créée.',
'order_create' => 'Créer une commande',
'order_host' => 'Commande hôte/hôtesse',
'order_show' => 'Afficher la commande',
'please_enter_delivery_address_info' => 'Saisis ici ladresse centrale de livraison pour les commandes de cette Auszeitparty. Il peut sagir de ta propre adresse ou de celle de lhôte, selon la manière dont tu souhaites organiser la distribution des produits avec tes invités.',
'revoke_consent_at_any_time' => 'Jai été informé(e) que je peux révoquer ce consentement à tout moment.*',
'shipping_costs_host' => 'Frais de livraison hôte/hôtesse',
'target_turnover' => 'Chiffre daffaires cible',
'voucher' => 'Bon',
'voucher_bonus' => 'Bonus bon',
'voucher_bonus_cannot_be_applied' => 'Le bon / bonus ne peut pas être appliqué, car aucun produit na été ajouté chez lhôte/hôtesse.',
'voucher_total' => 'Total bon',
'your_MIVITA_advice' => 'Votre conseil MIVITA',
'your_data_has_been_successfully_created_have_fun' => 'Vos données ont été créées avec succès. Amusez-vous bien à lAuszeitparty !',
'your_host' => 'Votre hôte',
'your_hostess' => 'Votre hôtesse',
'welcome_copy' => 'Bienvenue à notre Auszeitparty autour de lAloe Vera bio et de la cosmétique naturelle. Nous vous expliquons ce que signifie réellement la qualité premium en cosmétique naturelle, vous montrons de nombreux exemples dutilisation pour les problèmes de peau et dintestin et, bien sûr, vous pourrez tester largement nos produits. Nous nous réjouissons de votre venue !',
];

View file

@ -0,0 +1,159 @@
<?php
return [
'incentives' => 'Incentives',
'incentive' => 'Incentive',
'name' => 'Nom',
'status' => 'Statut',
'period' => 'Période',
'actions' => 'Actions',
'participants' => 'Participants',
'save' => 'Enregistrer',
'cancel' => 'Annuler',
'yes' => 'Oui',
'no' => 'Non',
'you' => 'Vous',
'status_draft' => 'Brouillon',
'status_active' => 'Actif',
'status_closed' => 'Terminé',
'create' => 'Créer un nouvel incentive',
'edit' => 'Modifier',
'created' => 'Lincentive a été créé avec succès.',
'updated' => 'Lincentive a été mis à jour avec succès.',
'configuration' => 'Configuration',
'qualification_start' => 'Début de qualification',
'qualification_end' => 'Fin de qualification',
'calculation_end' => 'Fin du calcul',
'points_partner_onetime' => 'Points uniques par partenaire',
'points_abo_onetime' => 'Points uniques par abonnement',
'min_direct_partners' => 'Min. partenaires directs',
'min_customer_abos' => 'Min. abonnements clients',
'max_winners' => 'Max. gagnants',
'image' => 'Image',
'image_help' => 'Nom du fichier image dans le dossier public/img/incentive/ (p. ex. montenegro-2026.jpg)',
'description' => 'Description / texte publicitaire',
'description_help' => 'Texte dintroduction motivant affiché sur la page teaser.',
'terms' => 'Conditions de participation',
'terms_help' => 'Texte complet des conditions de participation. Affiché comme zone dépliable sur la page.',
'name_help' => 'Nom interne de lincentive (également affiché comme titre de page).',
'subtitle' => 'Sous-titre',
'subtitle_placeholder' => 'p. ex. Votre pause exclusive sur lAdriatique !',
'subtitle_help' => 'Court slogan publicitaire affiché dans la zone hero sous le titre.',
'content_lang_de' => 'Allemand',
'default_language' => 'Standard',
'lang_fallback_hint' => 'Laisser vide = lallemand est utilisé comme fallback.',
'ranking' => 'Classement',
'rank' => 'Rang',
'consultant' => 'Conseiller',
'total_points' => 'Points totaux',
'partners' => 'Partenaires',
'abos' => 'Abonnements',
'qualified' => 'Qualifié',
'open' => 'Ouvert',
'winner' => 'Gagnant',
'no_participants' => 'Aucun participant pour le moment.',
'no_participants_with_points' => 'Aucun participant avec des points pour le moment.',
'anonymous_consultant' => 'Conseiller anonyme',
'ranking_all_active' => 'Tous les actifs',
'vip_view_notice' => 'Vue VIP : les noms réels de tous les participants sont affichés.',
'vip_terms_accepted' => 'Conditions de participation acceptées',
'vip_terms_pending' => 'Conditions de participation pas encore acceptées',
'ranking_anonymous_hint' => 'Les noms napparaissent quaprès confirmation de la participation à lincentive.',
'ranking_extended_hint' => 'La liste affiche tous les conseillers avec plus de 0 point. Les :n meilleurs conseillers qualifiés (mis en évidence) gagnent ; les places suivantes montrent qui peut encore progresser.',
'calculation_details' => 'Détails du calcul',
'close' => 'Fermer',
'recalculate' => 'Recalcul',
'recalculate_confirm' => 'Le recalcul doit-il être lancé ?',
'force_recalculate' => 'Recalculer complètement',
'force_recalculate_confirm' => 'ATTENTION : tous les logs existants seront supprimés et recalculés entièrement. Continuer ?',
'recalculated' => 'Recalcul terminé. :participants participants traités, :errors erreurs.',
'admin_terms_accepted' => 'Participation (conditions)',
'admin_terms_pending' => 'En attente',
'admin_terms_accepted_at_tooltip' => 'Moment de la confirmation',
'participate_title' => 'Participer maintenant !',
'accept_terms' => 'Jaccepte les conditions de participation',
'show_terms' => 'Afficher les conditions',
'participate_now' => 'Participer maintenant',
'not_active' => 'Cet incentive nest actuellement pas actif.',
'terms_required' => 'Veuillez accepter les conditions de participation.',
'already_participating' => 'Vous participez déjà.',
'participation_confirmed' => 'Votre participation a été confirmée !',
'teaser_hero_subtitle' => 'Votre pause exclusive sur lAdriatique vous attend !',
'teaser_intro_bold' => 'Préparez vos valises, mivita récompense vos meilleures performances !',
'teaser_intro_text' => 'Vivez des journées inoubliables sur la côte pittoresque, échangez avec les top leaders et célébrez votre succès avec nous !',
'teaser_intro_cta' => 'Faites-vous partie des :n meilleurs partenaires ? Alors vous êtes de la partie !',
'teaser_until' => 'jusquau',
'teaser_partner_onetime_text' => 'une fois par nouveau partenaire directement sponsorisé pendant la période de qualification.',
'teaser_abo_onetime_text' => 'une fois par nouvel abonnement client conclu pendant la période de qualification.',
'teaser_cta_ready' => 'Êtes-vous prêt pour le challenge ?',
'teaser_cta_text' => 'Inscrivez-vous maintenant pour être listé dans le classement officiel. Seuls les :n meilleurs conseillers qualifiés gagnent !',
'teaser_cta_button' => 'Vers le classement & participation',
'teaser_cta_to_ranking' => 'Vers le classement live',
'teaser_cta_already_in' => 'Vous êtes déjà inscrit. Suivez votre rang actuel dans le classement live.',
'teaser_pending_title' => 'Vos points sont déjà comptabilisés',
'teaser_pending_text' => 'Confirmez votre participation afin que votre nom apparaisse dans le classement et que vous puissiez utiliser la vue détaillée.',
'teaser_cta_confirm' => 'Confirmer la participation',
'teaser_cta_coming_soon' => 'Ça commence bientôt !',
'section_period' => 'La période de qualification',
'qualification_period' => 'Période de qualification',
'calculation_period' => 'Sprint final (fin du calcul)',
'calculation_period_hint' => 'Les points accumulés sont calculés jusquau :date inclus.',
'section_min_qual' => 'Votre ticket : la qualification minimale',
'min_qual_intro' => 'Pour être listé dans le classement officiel et entrer en ligne de compte pour le gain, les objectifs de base suivants doivent être atteints pendant la période de qualification :',
'min_partners_label' => 'nouveaux partenaires directs (chacun uniquement avec un pack de démarrage)',
'min_abos_label' => 'nouveaux abonnements clients conclus',
'min_qual_ranking_hint' => 'Dans le classement live, votre nom nest mis en gras quune fois cette qualification minimale atteinte avec succès.',
'section_points' => 'Comment collecter vos points incentive',
'points_partners_title' => 'Points pour nouveaux partenaires',
'points_abos_title' => 'Points pour abonnements clients',
'points_short' => 'pts',
'points_onetime_label' => 'une fois par nouveau partenaire/abonnement',
'points_starter_package_label' => 'chacun avec un pack de démarrage commandé directement ; les nouveaux partenaires avec seulement une adhésion ne comptent malheureusement pas.',
'points_partner_boost' => 'Boost supplémentaire : vous recevez tous les points de chiffre daffaires clients et personnel de votre nouveau partenaire à partir de sa date de départ, pendant la période de qualification.',
'points_abo_direct' => 'Votre propre abonnement compte également, y compris les abonnements existants.',
'points_abo_boost' => 'Boost supplémentaire : vous recevez les points mensuels dabonnement à partir du mois de conclusion, pendant la période de qualification.',
'section_ranking' => 'Le classement live',
'ranking_winners_hint' => 'Seuls les :n meilleurs conseillers qualifiés gagnent.',
'dashboard_btn_teaser' => 'Vers lincentive',
'dashboard_btn_ranking' => 'Vers le classement live',
'read_more' => 'Lire la suite',
'read_less' => 'Lire moins',
'you_participate' => 'Vous participez !',
'your_rank' => 'Votre rang actuel',
'participate_intro' => 'Êtes-vous prêt pour le challenge ? Inscrivez-vous une fois pour être listé dans le classement officiel.',
'dash_notice_unregistered_title' => 'Pas encore inscrit',
'dash_notice_unregistered_body' => 'Vous ne participez pas encore officiellement à lincentive. Sans confirmation, vos points ne seront pas pris en compte et vous napparaîtrez pas dans le classement.',
'dash_notice_unconfirmed_title' => 'Participation pas encore confirmée',
'dash_notice_unconfirmed_body' => 'Vos points sont déjà comptabilisés, mais sans confirmation des conditions de participation, vous serez affiché anonymement dans le classement et ne pourrez pas gagner.',
'dash_notice_btn' => 'Confirmer la participation maintenant',
'dash_modal_title' => 'Confirmer la participation',
'dash_modal_intro' => 'Veuillez lire attentivement les informations et conditions de participation, puis confirmez votre participation.',
'dash_modal_cancel' => 'Fermer',
'pending_confirmation_banner' => 'Vos points sont déjà comptabilisés pendant la période de qualification. Veuillez confirmer la participation afin que votre nom soit visible dans le classement et que vous puissiez utiliser toutes les fonctions.',
'details_requires_confirmation' => 'La vue détaillée nest disponible quaprès confirmation de la participation.',
'participate_abo_hint' => 'Il existe au moins un abonnement pertinent pour lévaluation (abonnement conseiller actif ou abonnement client pendant la période de qualification). En participant, les points correspondants sont repris directement selon les règles actuelles.',
'my_details' => 'Mon calcul',
'my_calculation' => 'Mon aperçu de calcul',
'back_to_ranking' => 'Retour au classement',
'section_partners' => 'A. Points nouveaux partenaires',
'section_abos' => 'B. Points abonnements clients',
'new_partner' => 'Nouveau partenaire',
'entry_date' => 'Entrée',
'customer_abo' => 'Abonnement client',
'abo_date' => 'Conclusion',
'onetime' => 'Unique',
'sum' => 'Total',
'subtotal' => 'Sous-total',
'no_partners_yet' => 'Aucun nouveau partenaire saisi.',
'no_abos_yet' => 'Aucun abonnement client saisi.',
'not_yet_qualified' => 'Pas encore qualifié',
'transaction_date' => 'Date',
'transaction_description' => 'Description',
'transaction_period' => 'Période',
'transaction_type' => 'Type',
'transaction_points' => 'Points',
'onetime_registration' => 'Unique : inscription',
'onetime_abo_activation' => 'Unique : activation abonnement',
'accumulated' => 'Chiffre daffaires',
'gallery_title' => 'Impressions',
];

View file

@ -0,0 +1,63 @@
<?php
return [
'title' => 'Plan de carrière Mivita',
'subtitle' => 'Découvrez vos possibilités dévolution et atteignez le niveau suivant',
'current_level' => 'Votre niveau actuel :',
'qualification' => 'Qualification',
'kp_points' => 'Points KU',
'tp_points' => 'Points TP',
'customer_points_full' => 'Points de chiffre daffaires clients',
'team_points_full' => 'Points équipe',
'provisions' => 'Commissions',
'standard' => 'Standard',
'shop' => 'Boutique',
'provision_rates' => 'Taux de commission',
'team_provisions' => 'Commissions déquipe',
'team_provisions_by_lines' => 'Commissions déquipe par lignes',
'growth_bonus' => 'Bonus de profondeur',
'next_level' => 'Niveau suivant :',
'your_current_level' => 'Votre niveau actuel',
'your_next_goal' => 'Votre prochain objectif',
'legend' => 'Légende',
'legend_ku_description' => 'Points de chiffre daffaires clients',
'legend_tp_description' => 'Points équipe',
'legend_lines_description' => 'Commissions déquipe par lignes',
'legend_percentage_description' => 'Taux de commission',
'no_levels_available' => 'Aucun niveau de carrière disponible',
'no_levels_configured' => 'Aucun niveau de carrière nest actuellement configuré.',
'loading_error' => 'Erreur lors du chargement du plan marketing :',
'loading_time' => 'Temps de chargement :',
'show_details' => 'Afficher les détails',
'back_to_overview' => 'Retour à laperçu',
'level_position' => 'Position',
'level_name' => 'Nom du niveau',
'level_requirements' => 'Exigences',
'level_benefits' => 'Avantages',
'paylines' => 'Paylines',
'line_1' => 'Ligne 1',
'line_2' => 'Ligne 2',
'line_3' => 'Ligne 3',
'line_4' => 'Ligne 4',
'line_5' => 'Ligne 5',
'line_6' => 'Ligne 6',
'line_7' => 'Ligne 7',
'line_8' => 'Ligne 8',
'points' => 'Points',
'percent' => 'Pourcentage',
'euro' => 'Euro',
'active' => 'Actif',
'inactive' => 'Inactif',
'achieved' => 'Atteint',
'not_achieved' => 'Non atteint',
'in_progress' => 'En cours',
'congratulations' => 'Félicitations !',
'keep_going' => 'Continuez comme ça !',
'almost_there' => 'Vous y êtes presque !',
'next_step' => 'Prochaine étape',
'goal_reached' => 'Objectif atteint',
'help_kp_points' => 'Les points KU (chiffre daffaires clients) sont générés par vos ventes directes.',
'help_tp_points' => 'Les points TP (points équipe) sont générés par les ventes de votre équipe.',
'help_provisions' => 'Les commissions sont les pourcentages que vous recevez sur les ventes.',
'help_growth_bonus' => 'Les bonus de croissance sont des récompenses supplémentaires pour la croissance de votre équipe.',
];

View file

@ -0,0 +1,60 @@
<?php
return [
'' => '',
'MIVITA_BUSINESS_Paket' => 'Package MIVITA BUSINESS',
'abo_copy_1' => 'Désactiver le renouvellement automatique et révoquer le mandat SEPA.',
'abo_copy_2' => 'Le prochain prélèvement aura lieu le : <strong> :date </strong>',
'abo_copy_3' => 'Je confirme la suppression de mon SEPA et la désactivation du renouvellement automatique.',
'active' => 'actif',
'active_package' => 'package actif',
'booked_package' => 'package réservé',
'change' => 'adapter',
'change_copy_1' => 'Vous pouvez modifier votre adhésion jusquau prochain renouvellement du contrat, le :date.',
'change_copy_2' => 'Les durées restantes sont conservées ; le package modifié ne devient actif quau renouvellement.',
'consultant_membership' => 'Adhésion conseiller',
'consultant_online_shop' => 'Boutique en ligne conseiller',
'contract_renewal' => 'Renouvellement du contrat',
'deactivate' => 'désactiver',
'details' => 'Détails',
'downgrade_membership_is_not_possible' => 'Un déclassement de votre adhésion conseiller nest plus possible.',
'end' => 'terminer',
'end_button' => 'Terminer ladhésion ici',
'end_checkbox' => 'Oui, je souhaite mettre fin à ladhésion MIVITA, je renonce à toute réclamation envers MIVITA et souhaite que mon compte soit supprimé !',
'end_copy_1' => 'Vous ne souhaitez plus être conseiller MIVITA et voulez mettre fin à votre adhésion ?',
'end_copy_2' => 'Vous renoncez ainsi à toutes les commissions possibles, avantages, accès au Salescenter et bien plus encore. En envoyant cette option, nous recevons un message et nous occupons de la suppression de votre compte. Cette opération peut prendre quelques jours.',
'end_copy_3' => 'La résiliation et suppression de votre adhésion MIVITA a été demandée.',
'expired_on' => 'expiré le',
'home_copy_SEPA_32' => 'Comme vous nous avez accordé un mandat de prélèvement SEPA, vous navez rien dautre à faire. Conformément à ce mandat, nous prélèverons le montant de la facture :price le :pay_date sur votre compte.',
'home_copy_SEPA_33' => 'Le montant de facture :price du :pay_date na pas encore pu être prélevé sur votre compte via le mandat SEPA que vous nous avez accordé.',
'home_copy_SEPA_36' => 'Malheureusement, nous navons pas pu prélever le montant dû de la cotisation par mandat SEPA sur le compte indiqué.',
'home_copy_alert_31' => 'Votre adhésion expire le :datetime',
'home_copy_alert_35' => 'Votre adhésion expire aujourdhui !',
'home_copy_alert_36' => 'Votre adhésion a expiré il y a :days jours, le :datetime !',
'home_copy_alert_36_today' => 'Votre adhésion a expiré aujourdhui à :datetime !',
'home_copy_last_31' => 'Il vous reste encore :days jours pour renouveler votre adhésion. Si vous laissez toutefois passer ce délai, votre compte sera automatiquement mis en pause jusquau renouvellement.',
'home_copy_last_33' => 'Il vous reste encore :days jours pour renouveler votre adhésion. Si vous laissez toutefois passer ce délai, votre compte sera automatiquement mis en pause jusquau renouvellement.',
'home_copy_last_34' => 'Il est vraiment temps dagir ! Votre adhésion expire déjà dans :days jours, exactement le :datetime. Si vous laissez toutefois passer ce délai, votre compte sera automatiquement mis en pause jusquau renouvellement.',
'home_copy_last_35' => 'Il est vraiment temps dagir ! Votre adhésion expire aujourdhui, exactement le :datetime. Si vous laissez toutefois passer ce délai, votre compte sera automatiquement mis en pause jusquau renouvellement.',
'home_copy_last_36' => 'Comme annoncé, nous avons mis votre compte conseiller en pause car vous navez pas renouvelé votre adhésion à temps. Si vous souhaitez profiter à nouveau des avantages et fonctionnalités de MIVITA, vous pouvez réactiver votre compte à tout moment.',
'home_hl' => 'Adhésion / cotisation annuelle',
'inactive' => 'inactif',
'is_no_longer_possible_to_change_package' => 'Il nest plus possible de modifier votre package conseiller.',
'membership' => 'Adhésion',
'membership_was_renewed' => 'Votre contrat a été renouvelé le :date.',
'open_payment_options' => 'Ouvrir les options de paiement',
'payment_copy_1' => 'Vous avez également la possibilité de payer maintenant directement votre adhésion avec dautres modes de paiement.',
'payment_copy_2' => 'Si ladhésion est payée avant le :date, le mandat SEPA ne sera pas exécuté !',
'payment_has_been_made' => 'Un paiement a été effectué.',
'renewal_is_active_membership_fee_automatic' => 'Votre renouvellement automatique est actif ; nous prélèverons automatiquement votre cotisation par mandat SEPA le <strong>:date</strong>.',
'status' => 'Statut',
'until' => 'jusquau',
'upgrade' => 'Upgrade',
'upgrade_copy_1' => 'Passez maintenant votre adhésion au package MIVITA BUSINESS !',
'upgrade_copy_2' => 'Les mois restants jusquau renouvellement de ladhésion conseiller sont calculés. Ensuite, le prix du package MIVITA BUSINESS sera facturé annuellement.',
'upgrade_package_and_proceed_payment' => 'Vous pouvez upgrader votre package : sélectionnez simplement le package étendu et passez au paiement.',
'we_do_not_collect_membership_fee' => 'Nous navons <strong>pas</strong> pu prélever automatiquement votre cotisation le <strong>:date</strong> par mandat SEPA.',
'your_booked_package' => 'Votre package réservé',
'info_contract_renewal' => 'Votre contrat est automatiquement renouvelé :days jours avant lexpiration de votre adhésion conseiller. Après le renouvellement, il nest plus possible de modifier le package conseiller et vous devez effectuer le paiement de votre adhésion.',
'alert_contract_renewal' => 'Pour renouveler votre adhésion conseiller, veuillez effectuer le paiement de votre package MIVITA réservé.',
];

40
resources/lang/fr/msg.php Normal file
View file

@ -0,0 +1,40 @@
<?php
return [
'shipping_country_was_not_found' => 'Erreur : le pays de livraison est introuvable',
'shipping_country_was_not_correctly' => 'Erreur : le pays de livraison na pas été correctement traité dans le panier',
'shopping_cart_was_shipping_free' => 'Erreur : le panier a été indiqué comme exempt de frais de livraison',
'shipping_cost_cannot_be_0' => 'Erreur : les frais de livraison ne peuvent pas être 0',
'shipping_costs_were_not_calculated_correctly' => 'Erreur : les frais de livraison nont pas été correctement calculés',
'compensation_products_cannot_be_0' => 'Erreur : les produits de compensation ne peuvent pas être 0.',
'link_for_homeparty_not_found' => 'Le lien pour lAuszeitparty est introuvable ou nest plus actif.',
'contact_delete' => 'Contact supprimé',
'error_occurred_with_order' => 'Une erreur est survenue lors de la commande',
'abo_deaktivert' => 'Option dabonnement désactivée',
'error_checkbox_not_confirm' => 'Erreur : case non confirmée',
'no_change_made' => 'aucune modification effectuée',
'booked_package_has_been_changed' => 'le package réservé a été modifié.',
'cancel_membership_is_requested' => 'La résiliation de ladhésion a été demandée',
'file_uploaded' => 'Fichier téléversé',
'file_empty' => 'Fichier vide"',
'file_deleted' => 'Fichier supprimé',
'file_not_found' => 'Fichier introuvable',
'country_account_has_been_changed__cost_has_been_reset' => 'Le pays de facturation a été modifié et le panier a été réinitialisé',
'your_shopping_cart_is_empty_please_add_products_first.' => 'Votre panier est vide, veuillez dabord ajouter des produits',
'homeparty_guest_delete' => 'Invité Auszeitparty supprimé',
'homeparty_delete' => 'Auszeitparty supprimée',
'VATID_could_not_be_validated' => 'Le numéro de TVA na pas pu être validé, veuillez vérifier la saisie',
'VATID_successfully_entered' => 'Numéro de TVA saisi avec succès',
'reverse_charge_procedure_and_VATID_deleted' => 'Procédure dautoliquidation et numéro de TVA supprimés',
'no_id_card_deposited_please_upload_first' => 'Aucune pièce didentité déposée, veuillez dabord la téléverser',
'no_trade_licence_deposited_please_upload_first' => 'Aucun extrait dactivité déposé, veuillez dabord le téléverser',
'please_enter_reason_why_you_not_need_trade_licence' => 'Veuillez indiquer pourquoi vous navez pas besoin dun extrait dactivité',
'please_select_compensation_product' => 'Veuillez sélectionner un produit de compensation',
'please_select_count_compensation_products' => 'Veuillez sélectionner :count produits de compensation',
'user_not_found' => 'Le conseiller est introuvable.<br>Le compte a été désactivé ou supprimé.',
'shopping_cart_was_not_user_shop' => 'Erreur : le conseiller na pas de boutique, la commande ne peut pas continuer',
'shopping_instance_not_found' => 'Erreur : aucune ShoppingInstance trouvée',
'shopping_user_not_found' => 'Erreur : aucun ShoppingUser trouvé',
'account_released' => 'Compte activé',
'cart_product_not_allowed_for_order_type' => 'Le panier contient des articles qui ne sont pas prévus pour ce type de commande. Veuillez vider le panier et sélectionner uniquement les produits adaptés à cette commande.',
];

View file

@ -0,0 +1,92 @@
<?php
return [
'documents' => 'Documents',
'add' => 'ajouter',
'attribute' => 'Attributs',
'business' => 'Business',
'career_level' => 'Niveau de carrière',
'categories' => 'Catégories',
'clients' => 'Clients',
'commissions' => 'Commissions',
'contents' => 'Contenus',
'countries' => 'Pays',
'credit' => 'Avoirs',
'do_order' => 'Passer une commande',
'edit' => 'modifier',
'export' => 'Export',
'general' => 'Général',
'home' => 'Accueil',
'ingredients' => 'Ingrédients',
'invoice' => 'Factures',
'language' => 'Langue',
'languages' => 'Langues',
'logout' => 'Déconnexion',
'manage' => 'gérer',
'member' => 'Conseiller',
'new_member' => 'nouveau conseiller',
'member_register' => 'Enregistrer un conseiller',
'membership' => 'Adhésion',
'modules' => 'Modules',
'my_account' => 'Mon compte',
'my_clients' => 'Mes clients',
'my_data' => 'Mes données',
'my_homeparty' => 'Mon Auszeitparty',
'my_membership' => 'Mon adhésion',
'my_orders' => 'Mes commandes',
'my_shop' => 'Ma boutique',
'my_team' => 'Mon équipe',
'order' => 'Commande',
'orders' => 'Commandes',
'overview' => 'Aperçu',
'payment_methods' => 'Modes de paiement',
'payments' => 'Finances',
'points' => 'Points',
'products' => 'Produits',
'sales_volumes' => 'Volumes de vente',
'settings' => 'Paramètres',
'shipping_costs' => 'Frais de livraison',
'start_site' => 'Page daccueil boutique',
'statistics' => 'Statistiques',
'structure' => 'Structure',
'system_settings' => 'Param. système',
'translate' => 'Traductions',
'translation' => 'Traduction',
'trigger' => 'déclencher',
'user_roles' => 'Droits utilisateur',
'user_cleanup' => 'Nettoyage utilisateurs',
'user_restore' => 'Restaurer utilisateur',
'tax_advisor' => 'Conseiller fiscal',
'downloadcenter' => 'Centre de téléchargement',
'files' => 'Fichiers',
'tags' => 'Tags',
'myabos' => 'Mes abonnements',
'customerabos' => 'Abonnements clients',
'myabo' => 'Mon abonnement',
'customerabo' => 'Abonnement client',
'abo' => 'Abonnement',
'abos' => 'Abonnements',
'payment_links' => 'Liens de paiement',
'dashboard' => 'Tableau de bord',
'shop' => 'Boutique',
'to_shop' => 'Vers la boutique',
'marketingplan' => 'Plan marketing',
'dhl_cockpit' => 'Cockpit DHL',
'revenue' => 'Chiffre daffaires',
'level_reports' => 'Rapports de niveau',
'dashboard_news' => 'Actualités du tableau de bord',
'teamabos' => 'Abonnements équipe',
'team_customer_abos' => 'Abonnements clients de léquipe',
'customer_orders' => 'Commandes clients',
'external_orders' => 'Commandes externes',
'tools' => 'Outils',
'news_archive' => 'Archive des actualités',
'incentive' => 'Incentive',
'incentives' => 'Incentives',
'create' => 'Créer',
'my_abo' => 'Mon abonnement',
'my_subscriptions' => 'Mes abonnements',
'team_customers' => 'Clients de léquipe',
'payment_monitor' => 'Moniteur de paiement',
'payment_monitor_management' => 'Moniteur de paiement direction',
];

125
resources/lang/fr/order.php Normal file
View file

@ -0,0 +1,125 @@
<?php
return [
'add_customer' => 'Ajouter un client',
'advertising_material' => 'Matériel publicitaire',
'adviser_collective_invoice' => 'Facture collective conseiller',
'adviser_order_for_membership' => 'Commande conseiller pour adhésion',
'adviser_order_for_registration' => 'Commande conseiller pour inscription',
'art_no' => 'N° art.',
'article' => 'Article',
'article_remove' => 'Supprimer larticle',
'assigned_advisor' => 'Attribuer un conseiller',
'assigned_counsellor' => 'Conseiller attribué',
'billing_address_of_client' => 'Adresse de facturation du client',
'billing_address_of_the_advisor' => 'Adresse de facturation du conseiller',
'client_order_via_shop' => 'Commande client via boutique',
'collective_invoice' => 'Facture collective',
'collective_invoice_contains_orders' => 'La facture collective contient les commandes clients suivantes',
'compensation_product' => 'Produit de compensation',
'confirm_and_proceed_to_checkout' => 'confirmer et passer à la caisse',
'confirm_and_proceed_to_order' => 'confirmer et passer à la commande',
'consultant_order_for_home_party' => 'Commande conseiller pour une Homeparty',
'content' => 'Contenu',
'contents' => 'Contenus',
'create_invoice' => 'Créer la facture',
'date' => 'Date',
'delivery_address_of_the_client' => 'Adresse de livraison du client',
'delivery_address_of_the_consultant' => 'Adresse de livraison du conseiller',
'delivery_country_can_no_longer_be_changed' => 'Le pays de livraison ne peut plus être modifié',
'delivery_country_changed_customer_info' => 'Le pays de livraison ne peut être modifié que chez le client sous <a href=":link">Mes clients</a>.',
'delivery_country_changed_info' => 'Le pays de livraison ne peut être modifié que sous <a href=":link">Mes données</a>.',
'delivery_note' => 'Bon de livraison',
'delivery_to_me' => 'Livraison à moi-même',
'delivery_to_the_customer' => 'Livraison au client',
'deliverydata' => 'Données de livraison',
'different_delivery_address' => 'Adresse de livraison différente',
'error_no_address_data_found' => 'Erreur : aucune donnée dadresse trouvée !',
'external_orders' => 'Commandes externes',
'external_orders_info_pay' => 'payer = créer une facture collective et passer au checkout',
'external_orders_info_remove' => 'supprimer = les commandes sont déplacées vers supprimé, pour annulations etc.',
'external_orders_info_reset' => 'réinitialiser = les commandes sont seulement remises sur ouvert, les factures collectives ne sont pas modifiées',
'goods_are_for_customer_and_shipped' => 'La marchandise est destinée à un client et sera envoyée au client',
'goods_are_for_me_and_shipped' => 'La marchandise est pour moi et sera envoyée à mon adresse',
'gross' => 'brut',
'gross_price' => 'Prix brut',
'incentives' => 'Incentives',
'included_VAT' => 'TVA incluse',
'invoice' => 'Facture',
'invoice_address' => 'Adresse de facturation',
'land_can_no_longer_be_changed' => 'Le pays ne peut plus être modifié',
'email_can_not_be_changed' => 'Votre adresse e-mail ne peut plus être modifiée',
'link_to_the_invoice' => 'Lien vers la facture',
'my_delivery_address' => 'Mon adresse de livraison',
'net' => 'net',
'net_price' => 'Prix net',
'no_address_created' => 'Aucune adresse créée',
'no_career_level_info' => 'Remarque : aucun niveau de carrière ne vous a encore été attribué. Veuillez contacter info@mivita.care',
'no_delivery_address' => 'Aucune adresse de livraison créée',
'no_order' => 'Aucune commande',
'number_of_items' => 'Nombre darticles',
'order' => 'Commande',
'order_consultant' => 'Commande conseiller',
'order_date' => 'Date de commande',
'order_for_client' => 'Commande pour client',
'order_for_consultant' => 'Commande pour conseiller',
'order_number' => 'Numéro de commande',
'order_via_external_shop' => 'Commande via boutique externe',
'plus_VAT' => 'plus TVA',
'points' => 'Points',
'points_total' => 'Points total',
'points_turnover_assigned' => 'Points / chiffre daffaires attribués',
'product' => 'Produit',
'product_prices_career_level_info' => 'Les prix produits (mon prix) sont affichés et calculés selon votre niveau de carrière <strong>:user_level_name</strong> moins <strong>:user_level_margin %</strong> de marge.<br>Remarque : si vous quittez la page, le panier sera réinitialisé.',
'product_prices_career_level_cpay_info' => 'Les prix produits sont affichés comme prix de vente clients ; après finalisation du paiement client, vous recevez votre commission selon votre niveau de carrière <strong>:user_level_name</strong>, commission <strong>:user_level_margin %</strong>.<br>Remarque : si vous quittez la page, le panier sera réinitialisé.',
'purchased_from_shop' => 'Acheté dans la boutique',
'quantity' => 'Quantité',
'ship_to_existing_customer_select_customer' => 'Envoyer à un client existant | sélectionner le client',
'ship_to_new_customer' => 'Envoyer à un nouveau client',
'ship_to_this_customer' => 'Envoyer à ce client',
'ship_to_this_customer_check' => 'Je sais que ce processus de commande est uniquement destiné aux commandes clients et non à mes propres commandes.',
'ship_to_this_customer_info' => 'Si ladresse de facturation ou le pays de livraison nest pas correct, veuillez le modifier avant la commande sous',
'ship_to_this_customer_info_2' => 'Ladresse de livraison, à lexception du pays de livraison, peut aussi être adaptée dans le checkout.',
'ship_to_this_email_info' => 'Le lien de commande sera envoyé à votre client à ladresse e-mail suivante :',
'shipping' => 'Livraison',
'shipping_compensation_product' => 'Produit de compensation livraison',
'shipping_costs' => 'Frais de livraison',
'shopping_cart' => 'Panier',
'shopping_cart_delete' => 'Supprimer le panier',
'shopping_cart_update' => 'Mettre à jour le panier',
'subtotal' => 'Sous-total',
'sum' => 'Somme',
'sums' => 'Sommes',
'total' => 'Total',
'total_gross' => 'Total brut',
'total_net' => 'Total net',
'total_price' => 'Prix total',
'total_shipping_costs' => 'Frais de livraison totaux',
'total_sum' => 'Somme totale',
'total_sums' => 'Sommes totales',
'total_without_VAT' => 'Somme hors TVA',
'turnover' => 'Chiffre daffaires',
'unit_price' => 'Prix unitaire',
'weight' => 'Poids',
'you_has_article_in_shopping_cart' => 'Vous avez :num article(s) dans votre panier',
'excl' => 'plus',
'ipay' => 'je paie',
'cpay' => 'le client paie',
'ipay_text' => 'Je paie moins ma marge et je facture le prix de vente à mon client',
'cpay_text' => 'Mon client reçoit un lien de paiement et je reçois ma commission',
'sum_net' => 'Somme nette',
'confirm_and_send_order' => 'Finaliser la commande et envoyer le lien de paiement',
'confirm_send_order_info' => 'En finalisant la commande, un lien de paiement est créé et envoyé par e-mail à votre client.<br>Votre client peut alors finaliser le paiement et la marchandise sera expédiée ; votre commission sera créditée à la finalisation du paiement.',
'order_was_placed_successfully' => 'La commande a été créée avec succès.',
'order_abo_was_placed_successfully' => 'La commande dabonnement a été créée avec succès.',
'payment_link_for_your_customer' => 'Le lien de paiement pour votre client',
'delivery' => 'Livraison',
'number' => 'N° art.',
'reorder' => 'Commander à nouveau',
'reorder_info' => 'Voulez-vous commander à nouveau cet article ?<br>En cliquant sur le bouton, les articles seront à nouveau placés dans le panier et vous serez redirigé vers la page panier.',
'reorder_info_2' => 'Votre pays de livraison est : :country<br>Si vous souhaitez faire livrer votre commande dans un autre pays, modifiez votre adresse de facturation ou de livraison sous <a class="text-primary" href=":link">Mes données</a>',
'reorder_abo_not_allowed' => 'Les commandes dabonnement ne peuvent pas être répétées via « Commander à nouveau ». Veuillez utiliser votre gestion dabonnement ou la boutique pour les commandes individuelles.',
'free_shipping' => 'Livraison gratuite',
'free_shipping_reached' => 'Livraison gratuite à partir de :amount €',
'free_shipping_info' => 'Encore :missing € jusquà la livraison gratuite (à partir de :amount €)',
];

View file

@ -0,0 +1,6 @@
<?php
return [
'previous' => '&laquo; Précédent',
'next' => 'Suivant &raquo;',
];

View file

@ -0,0 +1,9 @@
<?php
return [
'password' => 'Les mots de passe doivent contenir au moins 6 caractères et être correctement confirmés.',
'reset' => 'Le mot de passe a été réinitialisé !',
'sent' => 'Le-mail de réinitialisation de votre mot de passe a été envoyé.',
'token' => 'La clé de récupération du mot de passe est invalide ou a expiré.',
'user' => 'Aucun utilisateur avec cette adresse e-mail na pu être trouvé.',
];

View file

@ -0,0 +1,192 @@
<?php
return [
'' => '',
'BIC' => 'BIC',
'IBAN' => 'IBAN',
'Mastercard' => 'Mastercard',
'American Express' => 'American Express',
'VAT' => 'TVA',
'VISA' => 'VISA',
'accepted_data_checkbox_customer' => 'En cliquant sur "Acheter maintenant", jaccepte les :gtc, la :c_policy et la :p_policy afin que mes données puissent être traitées pour la commande.',
'accepted_data_checkbox_user' => 'En cliquant sur "Acheter maintenant", jaccepte les conditions générales et la politique de confidentialité afin que mes données puissent être traitées pour la commande.',
'account_holder' => 'Titulaire du compte',
'agree_SEPA_complete_purchase' => 'Accepter le mandat de prélèvement SEPA et finaliser lachat',
'auto_renewal_hl' => 'Renouvellement automatique',
'auto_renewal_line_1' => 'Mieux vaut prévenir ! Nous recommandons dactiver cette option si vous souhaitez maintenir durablement votre adhésion annuelle. Si un paiement manuel narrive pas à temps après lexpiration de ladhésion annuelle, le système désactive votre boutique en ligne ou votre accès au Salescenter. Vous ne pourrez alors plus passer de commandes ni consulter vos statistiques jusquà ce quun nouveau paiement réactive votre compte. Nous vous conseillons donc de payer par prélèvement SEPA. Nous enregistrons ce mandat et débitons automatiquement votre cotisation annuelle 14 jours avant lexpiration. Vous profitez ainsi dun processus fluide, sans devoir y penser.',
'auto_renewal_line_2' => 'Vous pouvez bien entendu annuler cette option à tout moment dans votre compte ou résilier votre compte / partenariat commercial chez MIVITA.',
'automatic_SEPA_mandate_type_was_selected' => 'Le renouvellement automatique a été sélectionné. Votre mandat SEPA sera enregistré et le paiement annuel sera exécuté automatiquement.',
'back_to_shop' => 'retour à la boutique',
'back_to_shop_shopping_cart' => 'retour au panier',
'bank' => 'Banque',
'billing_address' => 'Adresse de facturation',
'billing_address_can_only_changed_in_salescentre' => 'Votre adresse de facturation ne peut être modifiée que dans le Salescenter.',
'buy_now' => 'Acheter maintenant',
'buy_now_copy' => 'Lorsque toutes vos informations sont complètes, cliquez sur "Acheter maintenant". Vous serez redirigé vers notre prestataire de paiement ; la connexion est chiffrée SSL.',
'c_policy' => 'Politique de rétractation',
'checkout_ssl_server' => 'Vous serez redirigé vers notre serveur de checkout, la connexion est chiffrée SSL.',
'country_of_delivery' => 'Pays de livraison',
'credit_card' => 'Carte de crédit',
'credit_card_number' => 'Numéro de carte de crédit',
'delivery_country_can_only_changed_in_salescentre' => 'Le pays de livraison ne peut être modifié que dans le Salescenter.',
'delivery_country_cannot_change' => 'Le pays de livraison ne peut pas être modifié.',
'excl' => 'plus',
'firstname_lastname' => 'Prénom Nom',
'gtc' => 'conditions générales',
'month' => 'Mois',
'months' => 'Mois',
'ordering_country' => 'Pays de commande',
'owner' => 'Titulaire',
'p_policy' => 'Politique de confidentialité',
'payment_by_SEPA' => 'Paiement par SEPA',
'payment_by_SEPA_info' => 'Votre paiement est en cours de traitement, une confirmation du traitement de paiement sera créée automatiquement.',
'payment_by_credit_card' => 'Paiement par carte de crédit :',
'payment_by_credit_card_info' => 'Votre paiement a été approuvé, une confirmation de paiement sera créée automatiquement.',
'payment_by_invoice' => 'Paiement sur facture',
'payment_by_invoice_info' => 'Votre commande est en cours de traitement, une confirmation du traitement de commande sera créée automatiquement',
'payment_in_advance' => 'Paiement anticipé',
'payment_method' => 'Mode de paiement',
'payment_method_not_enabled_please_contact' => 'Mode de paiement non activé, veuillez contacter',
'paypal' => 'PayPal',
'please_check_form_and_complete' => 'Veuillez vérifier le formulaire et compléter toutes les informations.',
'please_transfer_amount_following_account' => 'Veuillez virer :amount EUR sur le compte suivant afin de finaliser lachat.',
'prepayment' => 'Paiement anticipé',
'purchase_on_account' => 'Achat sur facture',
'reason_for_payment' => 'Référence de paiement',
'remaining_time' => 'Durée restante',
'reverse_charge_procedure' => 'Procédure dautoliquidation',
'select_and_proceed_to_checkout' => 'sélectionner et passer à la caisse',
'select_and_save' => 'sélectionner et enregistrer',
'sepa_direct_debit' => 'Prélèvement SEPA',
'sofort_bank_transfer' => 'Virement SOFORT',
'status' => [
'checkout_cancel' => 'Paiement annulé',
'checkout_error' => 'Erreur de paiement',
'checkout_payment' => 'Checkout',
'payment_approved' => 'Paiement approuvé - veuillez attendre le-mail',
'payment_error' => 'Erreur de paiement',
'payment_not_found' => 'Paiement introuvable',
'payment_redirect' => 'Paiement en cours - veuillez attendre le-mail',
'store_payment' => 'Paiement enregistré',
'success' => 'Exécuté avec succès',
'success_payment' => 'Paiement réussi',
'txaction_appointed' => 'Paiement approuvé - e-mail envoyé, veuillez attendre la confirmation',
'txaction_failed' => 'Paiement échoué - e-mail envoyé',
'txaction_paid' => 'Paiement confirmé - e-mail envoyé',
],
'thank_you_very_much' => 'Merci beaucoup,',
'total_amount' => 'Montant total',
'valid until' => 'Valable jusquau',
'valid' => 'Valable',
'verification_no' => 'N° de vérification',
'we_have_received_your_order_get_email' => 'Votre commande nous est parvenue. Vous recevrez prochainement une confirmation de commande par e-mail.',
'your_mivita_team' => 'Votre équipe mivita.care',
'your_order_number_is' => 'Votre numéro de commande est',
'open' => 'ouvert',
'openly' => 'ouvert',
'paid' => 'payé',
'check' => 'vérifier',
'cancelled' => 'annulé',
'link_sent' => 'Lien de paiement envoyé',
'link_openly' => 'Lien de paiement ouvert',
'link_check' => 'Paiement en vérification',
'link_pending' => 'Paiement en cours',
'link_failed' => 'Paiement échoué',
'link_canceled' => 'Paiement annulé',
'link_appointed' => 'Paiement ordonné',
'link_paid' => 'Paiement réussi',
'alert_link_sent' => 'Lien de paiement envoyé',
'alert_link_openly' => 'Lien de paiement ouvert',
'alert_link_check' => 'Paiement en vérification',
'alert_link_pending' => 'Paiement en cours',
'alert_link_failed' => 'Paiement échoué',
'alert_link_canceled' => 'Paiement annulé',
'alert_link_appointed' => 'Paiement ordonné',
'alert_link_paid' => 'Le paiement a réussi',
'failed' => 'annulation',
'no_payment' => 'aucun paiement',
'link_was_paid' => 'Le paiement a été finalisé avec succès.',
'paymend_paid' => 'Paiement payé',
'paymend_open' => 'Paiement ouvert',
'paymend_failed' => 'Paiement annulé',
'extern_open' => 'Externe ouvert',
'extern_paid' => 'Externe payé',
'invoice_open' => 'Facture ouverte',
'invoice_paid' => 'Facture payée',
'invoice_no_payment' => 'Facture sans paiement',
'to_sales_tax_de' => 'assujetti à la TVA / DE',
'not_to_sales_tax_de' => 'non assujetti à la TVA / DE',
'not_to_sales_tax_foreign' => 'non assujetti à la TVA / étranger',
'ordered' => 'commandé',
'removed' => 'supprimé',
'registration' => 'Inscription',
'not_assigned' => 'non attribué',
'advisor_order' => 'Commande conseiller',
'credit' => 'Avoir',
'shoporder' => 'Commande boutique',
'shoporder_pending' => 'Commande boutique / pending',
'membership' => 'Adhésion',
'order' => 'Commande',
'customer_order' => 'Commande client',
'homeparty' => 'Auszeitparty',
'shop' => 'Boutique',
'external' => 'externe',
'collective_invoice' => 'Facture collective',
'in_process' => 'en cours',
'shipped' => 'expédié',
'completed' => 'terminé',
'trade_fair' => 'Salon',
'commission_shop' => 'Commission boutique',
'commission_payline' => 'Commission Payline',
'commission_growth_bonus' => 'Commission bonus de profondeur',
'commission_team' => 'Commission équipe',
'credit_added' => 'Crédit ajouté',
'commission' => 'Commission',
'unknown' => 'inconnu',
'prepayment_important_notice' => 'Remarque importante concernant le paiement anticipé',
'prepayment_reference_notice' => 'IMPORTANT : veuillez indiquer exclusivement le numéro suivant comme référence de paiement :',
'prepayment_reference_only' => 'Cest la seule manière dattribuer automatiquement votre paiement.',
'prepayment_bank_details' => 'Coordonnées bancaires pour paiement anticipé',
'prepayment_waiting_payment' => 'Nous attendons la réception de votre paiement. Après réception, votre commande sera traitée.',
'prepayment_txid' => 'Référence de paiement',
'payment_not_found' => 'Paiement introuvable',
'payment_not_found_description' => 'Le paiement avec la référence :reference est introuvable. Veuillez nous contacter si vous avez déjà payé.',
'payment_canceled' => 'Paiement annulé',
'payment_canceled_description' => 'Vous avez annulé le processus de paiement. Votre commande na pas été exécutée et rien na été débité.',
'payment_canceled_hint' => 'Vous pouvez lancer une nouvelle tentative de paiement à tout moment.',
'payment_error' => 'Paiement échoué',
'payment_error_description' => 'Le paiement na malheureusement pas pu être finalisé.',
'payment_error_hint' => 'Veuillez vérifier vos données de paiement et réessayer, ou choisir un autre mode de paiement.',
'payment_error_retry' => 'Réessayer',
'payment_error_code' => 'Code derreur',
'payment_error_what_to_do' => 'Que puis-je faire ?',
'payment_unknown_status' => 'Statut de paiement inconnu',
'payment_unknown_status_description' => 'Le statut de paiement na pas pu être déterminé. Veuillez nous contacter pour plus dinformations.',
'contact_support_if_needed' => 'Pour toute autre question, veuillez contacter notre service client.',
'try_again' => 'Réessayer',
'choose_different_payment' => 'Choisir un autre mode de paiement',
'nothing_was_charged' => 'Aucun montant na été débité de votre compte.',
'payment_error_reasons' => [
'card_expired' => 'Votre carte a expiré. Veuillez utiliser une carte valide ou choisir un autre mode de paiement.',
'card_blocked' => 'Votre carte est bloquée. Veuillez contacter votre banque ou choisir un autre mode de paiement.',
'card_invalid' => 'Les données de la carte sont invalides. Veuillez vérifier votre saisie.',
'card_declined' => 'Votre banque a refusé le paiement. Veuillez contacter votre banque ou choisir un autre mode de paiement.',
'insufficient_funds' => 'La limite de la carte a été dépassée. Veuillez contacter votre banque ou choisir un autre mode de paiement.',
'cvv_invalid' => 'Le cryptogramme (CVV) est incorrect. Veuillez vérifier les 3 chiffres au dos de votre carte.',
'3ds_failed' => 'Lauthentification 3D Secure a échoué. Veuillez réessayer ou choisir un autre mode de paiement.',
'timeout' => 'La connexion à la banque a été interrompue (timeout). Veuillez réessayer dans quelques minutes.',
'fraud' => 'Le paiement a été refusé pour des raisons de sécurité. Veuillez contacter votre banque.',
'general' => 'Veuillez vérifier vos données de paiement et réessayer. Si le problème persiste, choisissez un autre mode de paiement.',
],
'packstation_delivery' => 'Livraison en Packstation/Paketbox',
'packstation_info' => 'Pour une livraison dans une DHL Packstation ou Paketbox, nous avons besoin de votre numéro postal DHL.',
'dhl_postnumber' => 'Numéro postal DHL',
'packstation_address_hint' => 'Saisissez le numéro de Packstation dans le champ dadresse (rue n° *) (p. ex. "Packstation 145"). Le code postal et la ville se réfèrent à lemplacement de la Packstation.',
'packstation_alert_title' => 'Important : livraison en Packstation',
'packstation_alert_intro' => 'Si vous indiquez un numéro postal DHL, ladresse de livraison doit être renseignée comme suit :',
'packstation_alert_street' => 'Packstation [numéro à 3 chiffres]',
'packstation_alert_street_example' => 'p. ex. "Packstation 145" - PAS "12345" !',
'packstation_alert_location' => 'Emplacement de la Packstation',
'packstation_alert_not_home' => 'pas votre adresse personnelle !',
'packstation_alert_footer' => 'Le NUMÉRO de Packstation comporte 3 chiffres (100-999, indiqué sur le panneau jaune). Le numéro postal DHL comporte 6 à 10 chiffres et doit être saisi dans le champ séparé ci-dessus.',
];

65
resources/lang/fr/pdf.php Normal file
View file

@ -0,0 +1,65 @@
<?php
return [
'address_top' => 'mivita care gmbh • Leinfeld 2 • 87755 Kirchhaslach',
'adviser_id' => 'ID conseiller',
'date' => 'Date',
'credit_no' => 'N° davoir',
'tax_no' => 'N° fiscal',
'vat_no' => 'N° TVA',
'credit_note' => 'AVOIR',
'credit_note_from' => 'Avoir de',
'amount' => 'Montant',
'vat_text' => 'TVA',
'amount_paid_out_gross' => 'Montant versé (brut)',
'net_amount' => 'Montants nets',
'as_a_small_entrepreneur_info' => 'En tant que petite entreprise au sens du § 19 al. 1 UStG, aucune TVA nest facturée.',
'reverse_charge_procedure_info' => 'Procédure dautoliquidation, inversion de la dette fiscale.',
'delivery_note_no' => 'N° bon de livraison',
'order_no' => 'N° commande',
'delivery_note' => 'BON DE LIVRAISON',
'we_are_always_there_for_questions' => 'Nous sommes toujours à votre disposition pour toute question.',
'your_advisor' => 'Votre conseiller',
'eprice' => 'Prix unitaire',
'off' => 'de',
'net' => 'net',
'total_incl_VAT' => 'Total TTC légal',
'ek' => 'PA',
'invoice_nr' => 'N° facture',
'points' => 'Points',
'points_order' => 'Points commande',
'points_shop' => 'Points boutique',
'invoice' => 'FACTURE',
'payment_type' => 'Mode de paiement',
'status_of_invoice' => 'Statut de la facture',
'delivery_date_is_invoice_date' => 'La date de livraison correspond à la date de facture.',
'prices_net' => 'Prix nets',
'vat_id_of_the_recipient_of_the_service' => 'N° TVA du bénéficiaire de la prestation',
'invoice_does_not_include_vat' => 'La facture est émise sans TVA, car la procédure dautoliquidation sapplique.',
'vat_is_declared_and_paid_by_recipient' => 'La TVA doit être déclarée et acquittée par le bénéficiaire de la prestation.',
'tax_free_export_delivery' => 'Livraison à lexportation exonérée de taxe',
'invoice_copy' => 'Copie de facture',
'delivery_copy' => 'Copie de bon de livraison',
'credit_copy' => 'Copie davoir',
'contract_filename' => 'Contrat conseiller',
'cancellation_invoice' => 'FACTURE DANNULATION',
'cancellation_nr' => 'N° annulation',
'cancellation_for' => 'Annulation pour facture',
'from' => 'du',
'attention' => 'ATTENTION',
'cancellation_invoice_info' => 'Cette facture dannulation annule la facture initiale avec des montants négatifs. Les points ont été corrigés en conséquence.',
'cancelled' => 'Annulé',
'cancellation_delivery' => 'BON DE LIVRAISON DANNULATION',
'cancellation_delivery_info' => 'Ce bon de livraison dannulation annule le bon de livraison initial.',
'cancellation_delivery_note' => 'Remarque : les marchandises ne doivent pas être retournées sauf accord contraire.',
'pos' => 'Pos.',
'article' => 'Article',
'quantity' => 'Quantité',
'single_price' => 'Prix unitaire',
'total_price' => 'Prix total',
'plus' => 'plus',
'vat' => 'TVA',
'subtotal_net' => 'Sous-total (net)',
'shipping_costs' => 'Frais de livraison',
'total_amount' => 'Montant total',
];

View file

@ -0,0 +1,39 @@
<?php
return [
'login' => 'Connexion',
'login_title' => 'Connexion à votre compte client',
'login_email' => 'Votre adresse e-mail',
'login_password' => 'Mot de passe',
'login_button' => 'Connexion',
'login_forgot_password' => 'Mot de passe oublié ?',
'login_register' => 'Sinscrire',
'mail_subject' => 'Votre mot de passe à usage unique pour votre compte client mivita.care',
'mail_hello' => 'Bonjour !',
'mail_otp' => 'Votre mot de passe à usage unique est : ',
'mail_otp_valid' => 'Ce mot de passe est valable 10 minutes.',
'mail_otp_not_requested' => 'Si vous navez pas demandé de mot de passe, vous pouvez ignorer cet e-mail.',
'mail_greetings' => 'Cordialement',
'mail_sender' => 'mivita.care',
'verify_otp_title' => 'Connexion à deux facteurs',
'verify_otp_description' => 'Veuillez saisir le code que nous vous avons envoyé par e-mail.',
'verify_otp_resend' => 'Si vous ne recevez aucun e-mail dans les prochaines minutes, vous pouvez demander un nouveau code. Vérifiez également votre dossier spam.',
'verify_otp_resend_link' => 'Demander un nouveau code',
'login_send_otp_description' => 'Veuillez saisir votre adresse e-mail. Nous vous enverrons à létape suivante un mot de passe à usage unique à cette adresse.',
'login_send_otp_description2' => 'Pour créer un compte client, utilisez tout de même cette connexion ; vous pourrez ensuite renseigner vos données dans le portail.',
'back_to_shop' => 'Retour à la boutique',
'guest' => 'Invité',
'change_login_title' => 'Passer au compte client ?',
'change_login_title2' => 'Passer au compte conseiller ?',
'logout_button' => 'Se déconnecter et passer à la connexion',
'change_login_description' => 'Vous êtes connecté en tant que conseiller. Voulez-vous passer au compte client ?',
'change_login_description2' => 'Vous êtes connecté en tant que client. Voulez-vous passer au compte conseiller ?',
'change_login_description3' => 'Vous pouvez vous déconnecter ici puis vous reconnecter avec votre e-mail client.',
'change_my_data' => 'Modifiez ici votre adresse de facturation et de livraison.',
'change_my_data_empty' => 'Vous navez pas encore enregistré dadresse de facturation ni de livraison. Renseignez vos données maintenant.',
'my_orders_empty' => 'Vous navez encore passé aucune commande.',
'my_orders_info' => 'Vous pouvez consulter et gérer vos commandes ici.',
'change_my_data_empty_button' => 'Créer une adresse de facturation et de livraison',
'my_subscriptions_empty' => 'Vous navez pas encore créé dabonnement.',
'my_subscriptions_info' => 'Vous pouvez créer et gérer votre abonnement ici.',
];

Some files were not shown because too many files have changed in this diff Show more