diff --git a/.devcontainer/devcontainer.json b/.devcontainer/devcontainer.json index a912347..a47958e 100644 --- a/.devcontainer/devcontainer.json +++ b/.devcontainer/devcontainer.json @@ -30,7 +30,8 @@ "LARAVEL_SAIL": "1" }, "mounts": [ - "source=${localWorkspaceFolder},target=/var/www/html,type=bind,consistency=cached" + "source=${localWorkspaceFolder},target=/var/www/html,type=bind,consistency=cached", + "source=/Users/pandora/Library/Mobile Documents/iCloud~md~obsidian/Documents/DEV-Vault/mivita,target=/var/www/html/docs,type=bind", ], // WICHTIG: Nur noch der Vite-Port muss weitergeleitet werden, den Rest macht das Mutterschiff. "forwardPorts": [ diff --git a/.env b/.env index 4e1ce7d..9fbc49f 100644 --- a/.env +++ b/.env @@ -177,3 +177,8 @@ DHL_SENDER_PHONE="+49 123 456789" DHL_API_TYPE=developer DHL_API_SECRET=OyoeePEbYmY1EuOG +# OpenAI API Key +OPENAI_API_KEY=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 +OPENAI_API_URL=https://api.openai.com/v1/chat/completions +OPENAI_MODEL=gpt-5.4-mini +OPENAI_TIMEOUT=60 diff --git a/app/Console/Commands/BackfillFrenchDatabaseTranslations.php b/app/Console/Commands/BackfillFrenchDatabaseTranslations.php new file mode 100644 index 0000000..548f247 --- /dev/null +++ b/app/Console/Commands/BackfillFrenchDatabaseTranslations.php @@ -0,0 +1,551 @@ +option('driver'); + $sourceLanguage = Str::lower((string) $this->option('source')); + $targetLanguage = Str::lower((string) $this->option('target')); + $dryRun = (bool) $this->option('dry-run'); + $overwrite = (bool) $this->option('overwrite'); + $limit = $this->option('limit') !== null ? (int) $this->option('limit') : null; + + if (! in_array($driver, ['openai', 'copy-source'], true)) { + $this->error('Unsupported driver. Use openai or copy-source.'); + + return self::FAILURE; + } + + if ($driver === 'openai' && blank(config('services.openai.api_key'))) { + $this->error('OPENAI_API_KEY is missing. Set it on the live server or use --driver=copy-source for a dry run.'); + + return self::FAILURE; + } + + if ((bool) $this->option('test-api')) { + return $this->runApiTest($driver, $sourceLanguage, $targetLanguage); + } + + $models = $this->selectedModels(); + + if ($models === []) { + $this->error('No valid models selected.'); + + return self::FAILURE; + } + + $this->ensureTargetLanguage($targetLanguage, $dryRun); + + $summary = [ + 'created' => 0, + 'updated' => 0, + 'skipped' => 0, + 'empty' => 0, + ]; + + foreach ($models as $modelName => $spec) { + $this->info("Processing {$modelName}..."); + + try { + $modelSummary = $this->backfillModel($modelName, $spec, $driver, $sourceLanguage, $targetLanguage, $overwrite, $dryRun, $limit); + } catch (RequestException $exception) { + $this->reportOpenAiException($exception); + + return self::FAILURE; + } + + foreach ($summary as $key => $value) { + $summary[$key] = $value + $modelSummary[$key]; + } + } + + $this->newLine(); + $this->info("Created: {$summary['created']}"); + $this->info("Updated: {$summary['updated']}"); + $this->info("Skipped existing: {$summary['skipped']}"); + $this->info("Skipped empty source: {$summary['empty']}"); + + return self::SUCCESS; + } + + private function runApiTest(string $driver, string $sourceLanguage, string $targetLanguage): int + { + if ($driver !== 'openai') { + $this->error('The API test requires --driver=openai.'); + + return self::FAILURE; + } + + $this->info('OpenAI translation API test'); + $this->line('Model: '.config('services.openai.model')); + $this->line("Language: {$sourceLanguage} -> {$targetLanguage}"); + $this->newLine(); + + foreach ($this->apiTestSamples() as $index => $sourceValue) { + try { + $translatedValue = $this->translateWithOpenAI($sourceValue, $sourceLanguage, $targetLanguage); + } catch (RequestException $exception) { + $this->reportOpenAiException($exception); + + return self::FAILURE; + } + + $this->line('['.($index + 1).'] DE: '.$sourceValue); + $this->line('['.($index + 1).'] FR: '.$translatedValue); + $this->newLine(); + } + + $this->info('API test completed.'); + + return self::SUCCESS; + } + + /** + * @return array + */ + private function apiTestSamples(): array + { + return [ + 'Aloe Vera Gel für die tägliche Pflege der Haut.', + 'Der Berater kann seinem Kunden ein passendes Abo empfehlen.', + 'MIVITA Produktbeschreibung mit :amount ml Inhalt und PayPal Zahlung.', + ]; + } + + private function reportOpenAiException(RequestException $exception): void + { + $response = $exception->response; + $status = $response->status(); + $errorCode = (string) $response->json('error.code'); + $errorType = (string) $response->json('error.type'); + $message = (string) ($response->json('error.message') ?: $exception->getMessage()); + + $this->error("OpenAI API request failed with HTTP {$status}."); + + if ($errorCode !== '') { + $this->line("Code: {$errorCode}"); + } + + if ($errorType !== '') { + $this->line("Type: {$errorType}"); + } + + $this->line("Message: {$message}"); + + if ($status === 429 || $errorCode === 'insufficient_quota') { + $this->warn('Bitte prüfe im OpenAI Dashboard das Billing, das Projekt-Budget, Usage-Limits und ob der OPENAI_API_KEY zum richtigen Projekt gehört.'); + } + } + + /** + * @param array $spec + * @return array{created: int, updated: int, skipped: int, empty: int} + */ + private function backfillModel( + string $modelName, + array $spec, + string $driver, + string $sourceLanguage, + string $targetLanguage, + bool $overwrite, + bool $dryRun, + ?int $limit + ): array { + $summary = [ + 'created' => 0, + 'updated' => 0, + 'skipped' => 0, + 'empty' => 0, + ]; + + $query = DB::table($spec['source_table']) + ->select(array_merge(['id'], $spec['fields'])) + ->orderBy('id'); + + if (Schema::hasColumn($spec['source_table'], 'deleted_at')) { + $query->whereNull('deleted_at'); + } + + if ($limit !== null && $limit > 0) { + $query->limit($limit); + } + + $totalRows = $this->countRows($spec, $limit); + $totalFields = count($spec['fields']); + $currentRow = 0; + + $this->line("Status {$modelName}: {$totalRows} Datensätze, {$totalFields} Felder."); + + foreach ($query->cursor() as $row) { + $currentRow++; + $this->line("Datensatz {$currentRow}/{$totalRows}: {$modelName}#{$row->id}"); + + foreach ($spec['fields'] as $field) { + $statusTarget = "{$modelName}#{$row->id}.{$field}"; + $sourceValue = trim((string) ($row->{$field} ?? '')); + + if ($sourceValue === '') { + $summary['empty']++; + $this->line(" - {$statusTarget}: Quelle leer, übersprungen."); + + continue; + } + + $existingValue = $this->existingTranslationValue($spec, (int) $row->id, $field, $targetLanguage); + + if (! $overwrite && filled($existingValue)) { + $summary['skipped']++; + $this->line(" - {$statusTarget}: vorhandene Übersetzung, übersprungen."); + + continue; + } + + $this->line(" - {$statusTarget}: ".$this->translationStatusText($field, $spec, $driver).'...'); + + $translatedValue = $this->translateValue($sourceValue, $field, $spec, $driver, $sourceLanguage, $targetLanguage); + + if ($dryRun) { + $action = $existingValue === null ? 'create' : 'update'; + $this->line(" - {$statusTarget}: [dry-run] würde {$action}."); + } else { + $this->storeTranslationValue($spec, (int) $row->id, $field, $targetLanguage, $translatedValue); + $action = $existingValue === null ? 'erstellt' : 'aktualisiert'; + $this->line(" - {$statusTarget}: gespeichert ({$action})."); + } + + if ($existingValue === null) { + $summary['created']++; + } else { + $summary['updated']++; + } + } + } + + return $summary; + } + + /** + * @param array $spec + */ + private function existingTranslationValue(array $spec, int $sourceId, string $field, string $targetLanguage): ?string + { + if (($spec['storage'] ?? 'table') === 'json') { + $translations = $this->jsonTranslations($spec, $sourceId, $field); + $value = $translations[$targetLanguage] ?? null; + + return $value === null ? null : trim((string) $value); + } + + return DB::table($spec['translation_table']) + ->where([ + 'language' => $targetLanguage, + $spec['foreign_key'] => $sourceId, + 'key' => $field, + ]) + ->value('value'); + } + + /** + * @param array $spec + */ + private function storeTranslationValue(array $spec, int $sourceId, string $field, string $targetLanguage, string $translatedValue): void + { + if (($spec['storage'] ?? 'table') === 'json') { + $translationColumn = 'trans_'.$field; + $translations = $this->jsonTranslations($spec, $sourceId, $field); + $translations[$targetLanguage] = $translatedValue; + + $data = [ + $translationColumn => json_encode($translations, JSON_UNESCAPED_UNICODE), + ]; + + if (Schema::hasColumn($spec['source_table'], 'updated_at')) { + $data['updated_at'] = now(); + } + + DB::table($spec['source_table']) + ->where('id', $sourceId) + ->update($data); + + return; + } + + DB::table($spec['translation_table'])->updateOrInsert( + [ + 'language' => $targetLanguage, + $spec['foreign_key'] => $sourceId, + 'key' => $field, + ], + [ + 'value' => $translatedValue, + 'created_at' => now(), + 'updated_at' => now(), + ] + ); + } + + /** + * @param array $spec + * @return array + */ + private function jsonTranslations(array $spec, int $sourceId, string $field): array + { + $translationColumn = 'trans_'.$field; + $value = DB::table($spec['source_table']) + ->where('id', $sourceId) + ->value($translationColumn); + + if (is_array($value)) { + return $value; + } + + $decodedValue = json_decode((string) $value, true); + + return is_array($decodedValue) ? $decodedValue : []; + } + + /** + * @param array $spec + */ + private function countRows(array $spec, ?int $limit): int + { + $query = DB::table($spec['source_table']); + + if (Schema::hasColumn($spec['source_table'], 'deleted_at')) { + $query->whereNull('deleted_at'); + } + + $count = $query->count(); + + if ($limit !== null && $limit > 0) { + return min($count, $limit); + } + + return $count; + } + + /** + * @param array $spec + */ + private function translationStatusText(string $field, array $spec, string $driver): string + { + if ($driver === 'copy-source' || in_array($field, $spec['copy_fields'] ?? [], true)) { + return 'übernehme Quelle'; + } + + return 'übersetze via OpenAI'; + } + + /** + * @param array $spec + */ + private function translateValue(string $sourceValue, string $field, array $spec, string $driver, string $sourceLanguage, string $targetLanguage): string + { + if ($driver === 'copy-source' || in_array($field, $spec['copy_fields'] ?? [], true)) { + return $sourceValue; + } + + return $this->translateWithOpenAI($sourceValue, $sourceLanguage, $targetLanguage); + } + + private function translateWithOpenAI(string $sourceValue, string $sourceLanguage, string $targetLanguage): string + { + [$preparedValue, $protectedValues] = $this->protectTerms($sourceValue); + + $payload = [ + 'model' => config('services.openai.model'), + 'temperature' => 0.1, + 'messages' => [ + [ + 'role' => 'system', + 'content' => implode(' ', [ + 'You translate ecommerce and MLM CRM content for mivita.care.', + 'Translate from German to French unless another source/target language is requested.', + 'Return only the translated text, without quotes, notes, markdown, explanations, or alternative variants.', + 'Preserve HTML tags, URLs, numbers, units, placeholders, and tokens like __MIVITA_TRANSLATION_TOKEN_0__ exactly.', + 'Keep brand names and protected product terms unchanged.', + 'Use consistent terminology: Berater = conseiller, Kunde = client, Abo = abonnement.', + ]), + ], + [ + 'role' => 'user', + 'content' => "Source language: {$sourceLanguage}\nTarget language: {$targetLanguage}\nText:\n{$preparedValue}", + ], + ], + ]; + + $response = Http::withToken((string) config('services.openai.api_key')) + ->acceptJson() + ->timeout((int) config('services.openai.timeout', 60)) + ->retry(2, 1000, function ($exception): bool { + if ($exception instanceof RequestException && $exception->response->status() === 429) { + return false; + } + + return true; + }) + ->post((string) config('services.openai.url'), $payload) + ->throw() + ->json('choices.0.message.content'); + + return $this->restoreTerms(trim((string) $response), $protectedValues); + } + + /** + * @return array{0: string, 1: array} + */ + private function protectTerms(string $value): array + { + $protectedValues = []; + $patterns = [ + '/(:[A-Za-z_][A-Za-z0-9_-]*)/u', + '/(\{\{\s*[^}]+\s*\}\})/u', + '/\b(MIVITA|PAYONE|PayPal|DHL|INCI|CBD|GRÜNE SEELE|Aloe Vera)\b/u', + ]; + + foreach ($patterns as $pattern) { + $value = preg_replace_callback($pattern, function (array $matches) use (&$protectedValues): string { + $token = '__MIVITA_TRANSLATION_TOKEN_'.count($protectedValues).'__'; + $protectedValues[$token] = $matches[1]; + + return $token; + }, $value); + } + + return [$value, $protectedValues]; + } + + /** + * @param array $protectedValues + */ + private function restoreTerms(string $value, array $protectedValues): string + { + return str_replace(array_keys($protectedValues), array_values($protectedValues), $value); + } + + private function ensureTargetLanguage(string $targetLanguage, bool $dryRun): void + { + if ($dryRun) { + $this->line("[dry-run] ensure trans_languages.{$targetLanguage}"); + + return; + } + + DB::table('trans_languages')->updateOrInsert( + ['language' => $targetLanguage], + [ + 'name' => $targetLanguage === 'fr' ? 'Französisch' : Str::upper($targetLanguage), + 'created_at' => now(), + 'updated_at' => now(), + ] + ); + } + + /** + * @return array> + */ + private function selectedModels(): array + { + $availableModels = $this->translationModels(); + $selectedModels = $this->option('models'); + + if (blank($selectedModels)) { + return $availableModels; + } + + return collect(explode(',', (string) $selectedModels)) + ->map(fn (string $model): string => trim($model)) + ->filter() + ->mapWithKeys(fn (string $model): array => [$model => $availableModels[$model] ?? null]) + ->filter() + ->all(); + } + + /** + * @return array> + */ + private function translationModels(): array + { + return [ + 'products' => [ + 'source_table' => 'products', + 'translation_table' => 'trans_products', + 'foreign_key' => 'product_id', + 'fields' => ['name', 'copy', 'description', 'usage', 'ingredients'], + ], + 'ingredients' => [ + 'source_table' => 'ingredients', + 'translation_table' => 'trans_ingredients', + 'foreign_key' => 'ingredient_id', + 'fields' => ['name', 'inci', 'effect'], + 'copy_fields' => ['inci'], + ], + 'categories' => [ + 'source_table' => 'categories', + 'translation_table' => 'trans_categories', + 'foreign_key' => 'categorie_id', + 'fields' => ['name', 'headline'], + ], + 'shippings' => [ + 'source_table' => 'shippings', + 'translation_table' => 'trans_shippings', + 'foreign_key' => 'shipping_id', + 'fields' => ['name'], + ], + 'user_levels' => [ + 'source_table' => 'user_levels', + 'translation_table' => 'trans_user_levels', + 'foreign_key' => 'user_level_id', + 'fields' => ['name'], + ], + 'dashboard_news' => [ + 'storage' => 'json', + 'source_table' => 'dashboard_news', + 'fields' => ['title', 'teaser', 'content'], + ], + ]; + } +} diff --git a/config/localization.php b/config/localization.php index ee1f25a..1de5f2b 100644 --- a/config/localization.php +++ b/config/localization.php @@ -52,7 +52,7 @@ return [ // 'ewo' => ['name' => 'Ewondo', 'script' => 'Latn', 'native' => 'ewondo', 'regional' => ''], // 'ee' => ['name' => 'Ewe', 'script' => 'Latn', 'native' => 'eʋegbe', 'regional' => ''], // 'fil' => ['name' => 'Filipino', 'script' => 'Latn', 'native' => 'Filipino', 'regional' => 'fil_PH'], - // 'fr' => ['name' => 'French', 'script' => 'Latn', 'native' => 'français', 'regional' => 'fr_FR'], + 'fr' => ['name' => 'French', 'script' => 'Latn', 'native' => 'français', 'regional' => 'fr_FR'], // 'fr-CA' => ['name' => 'Canadian French', 'script' => 'Latn', 'native' => 'français canadien', 'regional' => 'fr_CA'], // 'fy' => ['name' => 'Western Frisian', 'script' => 'Latn', 'native' => 'frysk', 'regional' => 'fy_DE'], // 'fur' => ['name' => 'Friulian', 'script' => 'Latn', 'native' => 'furlan', 'regional' => 'fur_IT'], diff --git a/config/services.php b/config/services.php index aa1f7f8..d5219f2 100755 --- a/config/services.php +++ b/config/services.php @@ -35,4 +35,11 @@ return [ 'secret' => env('STRIPE_SECRET'), ], + 'openai' => [ + 'api_key' => env('OPENAI_API_KEY'), + 'url' => env('OPENAI_API_URL', 'https://api.openai.com/v1/chat/completions'), + 'model' => env('OPENAI_MODEL', 'gpt-5.4-mini'), + 'timeout' => env('OPENAI_TIMEOUT', 60), + ], + ]; diff --git a/database/migrations/2026_05_12_113939_add_french_locale_to_trans_languages.php b/database/migrations/2026_05_12_113939_add_french_locale_to_trans_languages.php new file mode 100644 index 0000000..cf576d9 --- /dev/null +++ b/database/migrations/2026_05_12_113939_add_french_locale_to_trans_languages.php @@ -0,0 +1,32 @@ +updateOrInsert( + ['language' => 'fr'], + [ + 'name' => 'Französisch', + 'created_at' => now(), + 'updated_at' => now(), + ] + ); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + DB::table('trans_languages') + ->where('language', 'fr') + ->delete(); + } +}; diff --git a/dev/2026-05-13-backoffice/ENTWICKLUNGSKONZEPT-BACKOFFICE.md b/dev/2026-05-13-backoffice/ENTWICKLUNGSKONZEPT-BACKOFFICE.md new file mode 100644 index 0000000..503f52f --- /dev/null +++ b/dev/2026-05-13-backoffice/ENTWICKLUNGSKONZEPT-BACKOFFICE.md @@ -0,0 +1,417 @@ +# Entwicklungskonzept: Backoffice Dashboard & Interaktivität + +Datum: 13.05.2026 + +Quelle: `docs/salescenter/Todos Backoffice.md` + +## Zielbild + +Das Partner-Backoffice soll von einer statischen Monats-Kachelansicht zu einer klickbaren, linienbasierten Statistik ausgebaut werden. Führungskräfte sollen von der Gesamtübersicht über Linien und Generationen bis zu einzelnen Kunden, Partnern und Abos navigieren können. + +Kernziel ist eine einheitliche Datenbasis für: + +- Dashboard-Kennzahlen pro Linie 1 bis 8 inklusive Summenzeile +- Detailansichten je Linie, Firstline und Kennzahl +- Kundenabos, Teamabos, Kundenabos im Team, Umsatz und Punkte +- neue Spezial-Kennzahl "1000 Punkte Shop" +- saubere Begrifflichkeit und rechtssichere Sichtbarkeit in Incentive-Ranglisten + +## Bestandsaufnahme im System + +### Dashboard + +Aktueller Einstieg: + +- Route: `GET /home` +- Controller: `App\Http\Controllers\HomeController@show` +- View: `resources/views/home.blade.php` +- Statistik-Partial: `resources/views/dashboard/_statistics.blade.php` + +Die aktuelle Statistik wird direkt im Blade-Partial berechnet. Sie nutzt unter anderem: + +- `App\Models\UserBusiness` für gespeicherte Monatswerte und Payline-Punkte +- `App\Models\UserSalesVolume` für Kunden-/Shop-Punkte +- `App\Models\UserAbo` für eigene Abos, Kundenabos und Teamabos +- rekursive Sponsor-Abfragen über `users.m_sponsor` + +Bewertung: + +- Die vorhandene Ansicht ist ein guter Einstieg, aber fachlich zu grob. +- Es gibt noch keine Linien 1 bis 8, keine Summenzeile und keine Drill-down-Routen. +- Die Kennzahl "Kundenabos" vermischt aktuell eigene Abos und Kundenabos. +- "Team-Abos" zählt aktuell Beraterabos im Team, aber nicht die separat geforderten Kundenabos im Team. + +### Team- und Abo-Bereich + +Relevante Routen und Controller: + +- `User\TeamController@structure` für Strukturansicht +- `User\TeamController@show` und `datatableOptimized` für Teamliste +- `User\TeamController@showAbos` für Team-Beraterabos +- `User\TeamController@showTeamCustomerAbos` für Kundenabos im Team +- `User\TeamController@detailAbo` für Abo-Details + +Relevante Daten: + +- `UserAbo::is_for = 'me'` bedeutet Berater-/Eigenabo +- `UserAbo::is_for = 'ot'` bedeutet Kundenabo +- `UserAbo::user_id` ist der Bestell-/Abo-User +- `UserAbo::member_id` ist der zugeordnete Berater +- `UserAbo::next_date` liefert die nächste Abo-Ausführung +- `UserAbo::getTotalPoints()` berechnet Punkte aus Abo-Items und Produkten +- `AboHelper::getTeamUserIds()` liefert Downline-User-IDs +- `AboHelper::getMonthlyAboCounts()` kennt bereits die Scopes `team_abos` und `team_cust_abos` + +Bewertung: + +- Viele Datenquellen für die geforderten Listen existieren bereits. +- Die Logik ist aber auf mehrere Controller, Blades und Helper verteilt. +- Für das neue Dashboard sollte die Logik zentralisiert werden, statt weitere Berechnungen in Blade-Dateien zu ergänzen. + +### Business- und Punkteberechnung + +Relevante Bausteine: + +- `App\Services\BusinessPlan\TreeCalcBotOptimized` +- `App\Models\UserBusiness` +- `App\Models\UserSalesVolume` +- `App\Services\BusinessPlan\SalesPointsVolume` + +Bewertung: + +- Für Monats- und Teamwerte sollte bevorzugt auf gespeicherte Businessdaten zurückgegriffen werden. +- Live-Berechnung über TreeCalcBot ist als Fallback sinnvoll, darf aber nicht bei jedem Seitenaufruf ungefiltert große Strukturen neu berechnen. +- Die neue Drill-down-Logik benötigt klare Regeln, ob sie Live-Daten oder gespeicherte Monats-Snapshots zeigt. + +### Incentives + +Relevante Dateien: + +- `App\Http\Controllers\User\IncentiveController` +- `resources/views/user/incentive/show.blade.php` +- `App\Models\IncentiveParticipant` +- `App\Services\Incentive\IncentiveTracker` + +Status: + +- Teilnahme-Opt-in existiert über `accepted_terms_at`. +- Ranking wird aktuell mit `paginate(100)` angezeigt. +- Nicht zustimmende Teilnehmer werden für normale User anonymisiert. +- VIP-Ansicht sieht zusätzliche Hinweise zur Zustimmung. + +Lücke zum Briefing: + +- Es gibt noch keine separate Zustimmung "Name/Fotos/Land in Rangliste sichtbar". +- Foto und Land sind in der aktuellen Rangliste nicht als sichtbare Standardspalten umgesetzt. +- Rechtliche Freigabe muss fachlich vor der technischen Umsetzung geklärt werden. + +### Checkout-Herkunft + +Relevante Dateien: + +- `App\Http\Controllers\Web\CheckoutController` +- `App\Repositories\CheckoutRepository` +- `App\Models\ShoppingUser` + +Status: + +- `CheckoutController::validateCheckoutData()` validiert Basisdaten, aber keine Pflichtfrage "Von wem hast du von Mivita erfahren?" +- `ShoppingUser` nutzt `is_from` für den technischen Ursprung wie `shopping`, `homeparty` oder `collection`. +- Ein fachliches Herkunfts-/Empfehlungsfeld existiert nicht als sauber getrennte Datenquelle. + +### Storno und Punkterückführung + +Relevante Dateien: + +- `App\Repositories\InvoiceRepository` +- `App\Services\BusinessPlan\SalesPointsVolume` +- `App\Services\Incentive\IncentiveTracker` + +Status: + +- Beim Erstellen einer Stornorechnung ruft `InvoiceRepository::createCancellation()` die Punktekorrektur auf. +- `SalesPointsVolume::cancelSalesPointsVolume()` erstellt einen negativen `UserSalesVolume`-Eintrag mit Status `6`. +- Danach wird der Monat neu berechnet und `IncentiveTracker::trackStorno()` informiert. + +Risiken: + +- Wenn zur Originalrechnung kein `UserSalesVolume` existiert, wird nur geloggt und keine Punktekorrektur erstellt. +- Der negative Storno-Eintrag wird aktuell dem aktuellen Monat zugeordnet, nicht zwingend dem ursprünglichen Umsatzmonat. +- Fachlich muss entschieden werden, ob Stornos im Stornomonat oder im Ursprungsmonat wirken sollen. + +## Fachliche Prüfung der To-dos + +### 1. Überarbeitetes Dashboard & KPI-Übersicht + +Umsetzung sinnvoll, aber nicht als Erweiterung des bestehenden Blade-Partials. Benötigt wird ein dedizierter Service, der die Kennzahlen pro Linie liefert. + +Vorgeschlagene Tabelle Stufe 1: + +- Linie +- Anzahl Berater +- Umsatz gesamt +- Eigen-/Beraterabos im Team +- eigene Kundenabos +- Kundenabos im Team +- Neupartner +- 1000 Punkte Shop +- Summe + +Wichtig: + +- "Teamkundenabos" sollte als Begriff für Kundenabos der Downline verwendet werden. +- "Teamabos" sollte nur für Berater-/Eigenabos im Team verwendet werden. +- Eigene Abos dürfen nicht in Kundenabos eingerechnet werden. + +### 2. Interaktivität & Deep Dive + +Umsetzung über eigene Backoffice-Statistik-Routen statt über große Modals im Dashboard. + +Vorgeschlagene Stufen: + +- Stufe 1: Linienübersicht `/user/backoffice/statistics` +- Stufe 2: Linien-/Firstline-Detail `/user/backoffice/statistics/line/{line}` +- Stufe 3: Kennzahlenliste `/user/backoffice/statistics/details?line=...&metric=...&user=...` + +Jede Zahl erhält einen Link auf eine gefilterte Detailansicht. Die Detailansicht sollte die Query-Parameter sichtbar halten, damit Support und Fachbereich Ergebnisse reproduzieren können. + +### 3. Spezial-Kennzahl "1000 Punkte Shop" + +Definition muss vor Umsetzung final geklärt werden. + +Vorschlag für Version 1: + +- Zeitraum: gewählter Monat/Jahr des Dashboards +- Personenkreis: Downline des eingeloggten Users +- Schwelle: `UserSalesVolume` Shop-/KP-Summe pro Partner >= 1000 Punkte +- Sortierung: Volumen absteigend +- Detailspalten: Name, Account, Linie/Generation, Shop-Punkte, Gesamt-KP, Umsatz netto + +Offene Fachfrage: + +- Meint "Kundenumsatz" nur Shop-Umsatz (`month_shop_points`) oder alle Kundenpunkte inklusive Abo-/Beraterkontext? + +### 4. Bestellformular: "Von wem hast du von Mivita erfahren?" + +Technisch sollte ein neues fachliches Feld eingeführt werden, nicht `is_from` zweckentfremdet werden. + +Vorschlag: + +- Migration für `shopping_users.referral_source_name` oder ähnliches Feld +- optional zusätzlich auf `shopping_orders`, wenn die Information revisionssicher pro Bestellung eingefroren werden soll +- Pflichtvalidierung in `CheckoutController::validateCheckoutData()` +- Anzeige im Checkout-Formular und optional im Admin-/Bestelldetail +- später exportierbar für Marketingauswertung + +Zu klären: + +- Freitext oder Auswahl plus Freitext? +- Pflicht nur im Webshop/Bestelllink oder auch bei Salescenter-, Homeparty- und Collection-Flows? +- Soll eine konkrete Beraterzuordnung daraus entstehen oder nur Tracking? + +### 5. Stornoprozess + +Die Grundlogik existiert. Vor einer fachlichen Freigabe braucht es Tests und eine Periodenentscheidung. + +Prüfpunkte: + +- Storno einer Beraterbestellung erzeugt negative Punkte beim richtigen Berater. +- Storno einer Kunden-/Shopbestellung erzeugt negative Punkte im richtigen Umsatztyp. +- Storno einer Abo-Bestellung wirkt korrekt auf Incentive-Logs. +- Storno ohne vorhandenen `UserSalesVolume` ist sichtbar und lösbar, nicht nur ein Log-Eintrag. +- Businessdaten nach Storno werden für betroffenen Monat/Jahr aktualisiert oder als neu zu berechnen markiert. + +### 6. Rechtliches & Sichtbarkeit in Incentives + +Die bestehende Teilnahmezustimmung sollte nicht automatisch als Freigabe für öffentliche Namens-/Fotoanzeige interpretiert werden. + +Vorschlag: + +- eigenes Feld auf `incentive_participants`, z. B. `ranking_visibility_accepted_at` +- Button/Checkbox in der Incentive-Seite: "Ich stimme zu, dass mein Name, Foto und Land in der Rangliste sichtbar sind" +- Anzeige von Name, Foto und Land nur, wenn die Zustimmung vorliegt oder ein Admin/VIP die Ansicht nutzt +- nach juristischer Klärung kann die Regel angepasst werden + +Technische Ergänzungen: + +- Ranking um Foto und Land erweitern +- Pagination/Limit bewusst definieren: alle Teilnehmer anzeigen, aber paginiert +- Übersetzungen in `resources/lang/*/incentive.php` ergänzen + +### 7. Multimedia-Bereich / Event-Archiv + +Es gibt bereits Dashboard-News und ein News-Archiv. Für Events sind zwei Varianten möglich: + +Variante A: `DashboardNews` um Typ "event" erweitern + +- weniger Aufwand +- bestehendes Admin-Modul kann wiederverwendet werden +- geeignet, wenn Events im gleichen Format wie News funktionieren + +Variante B: eigenes Event-Modul + +- sauberere Trennung +- eigene Felder für Galerie, Eventdatum, Call-/Foto-Typ +- sinnvoll, wenn Uploads, mehrere Bilder pro Event oder Kategorien benötigt werden + +Empfehlung: Variante A als MVP, falls keine komplexe Galerieverwaltung benötigt wird. + +## Technisches Zielkonzept + +### Neue zentrale Services + +Empfohlen: + +- `App\Services\Backoffice\BackofficeDashboardService` +- `App\Services\Backoffice\BackofficeDrilldownService` + +Aufgaben `BackofficeDashboardService`: + +- Zeitraum normalisieren +- Downline pro Linie aufbauen +- Summen pro Linie berechnen +- Daten für Stufe 1 und Stufe 2 liefern +- Caching-/Snapshot-Strategie kapseln + +Aufgaben `BackofficeDrilldownService`: + +- Kennzahlenfilter aus Request validieren +- Personen-/Abo-/Umsatzlisten erzeugen +- Berechtigungen gegen Downline prüfen +- einheitliche Summary für Listen liefern + +### Controller und Views + +Empfohlen: + +- `App\Http\Controllers\User\BackofficeStatisticsController` +- Views unter `resources/views/user/backoffice/statistics/` + +Geplante Actions: + +- `index()` für Stufe 1 +- `line(int $line)` für Stufe 2 +- `details()` für Stufe 3 +- optional `export()` für CSV/Excel später + +Die vorhandenen Team-Views bleiben bestehen. Das neue Dashboard verweist für Detailseiten aber auf eigene, schlankere Statistik-Views. + +### Datenmodell und Definitionen + +Einheitliche Metriken: + +- `consultants`: aktive Berater in Linie +- `own_abos`: eigene Beraterabos des eingeloggten Users +- `team_partner_abos`: Beraterabos im Team (`is_for = 'me'`) +- `direct_customer_abos`: Kundenabos des betrachteten Beraters (`member_id = user_id`, `is_for = 'ot'`) +- `team_customer_abos`: Kundenabos der Downline-Berater (`member_id in teamUserIds`, `is_for = 'ot'`) +- `new_partners`: neue aktive Partner im Zeitraum +- `turnover_points`: Punkte aus `UserSalesVolume`/`UserBusiness` +- `turnover_net`: Netto-Umsatz aus `UserSalesVolume`/`UserBusiness` +- `shop_1000`: Partner mit Kunden-/Shop-Punkten >= 1000 + +### Berechtigungen + +Jede Detailansicht muss sicherstellen: + +- Der eingeloggte User sieht nur eigene Downline-Daten. +- Ein direkt angefragter `user_id` muss per Sponsor-Hierarchie im Team liegen. +- Kundenabos werden nur in dem Umfang angezeigt, der fachlich für Berater vorgesehen ist. +- Datenschutzrelevante Kundendaten sollten auf das notwendige Minimum reduziert werden. + +## Umsetzungsphasen + +### Phase 1: Begriffe, Datenbasis und MVP Dashboard + +- Fachliche Definitionen finalisieren +- `BackofficeDashboardService` erstellen +- Stufe-1-Linienübersicht mit Linien 1 bis 8 und Summenzeile bauen +- bestehende Dashboard-Kachel durch Link auf neue Statistikseite ergänzen oder neue Seite im Menü aufnehmen +- Kennzahlen noch ohne vollständigen Deep Dive, aber bereits sauber berechnet + +### Phase 2: Drill-down Stufe 2 und Stufe 3 + +- Linien-Detailansicht pro Firstline bauen +- Detailansichten je Kennzahl bauen +- Abo-Listen mit Name, Punktewert, nächster Ausführung und Anzahl Lieferungen anzeigen +- Links aus jeder Kennzahl setzen +- leere Zustände und Summenzeilen ergänzen + +### Phase 3: 1000 Punkte Shop + +- fachliche Definition final bestätigen +- Query und Summary implementieren +- Widget in Übersicht ergänzen +- Detailansicht mit Sortierung nach Volumen absteigend bauen + +### Phase 4: Herkunftsabfrage im Checkout + +- Migration und Model-Fillable ergänzen +- Checkout-Formular erweitern +- Validierung und Speicherung ergänzen +- Admin-/Bestelldetail oder Export um Feld erweitern + +### Phase 5: Storno-Qualitätssicherung + +- Tests für vorhandene Storno-Punktepfade ergänzen +- Fachentscheidung zur Periodenlogik dokumentieren +- Fehlerfall ohne Original-`UserSalesVolume` sichtbar machen +- ggf. Businessdaten-Neuberechnung nach Storno anstoßen + +### Phase 6: Incentive-Sichtbarkeit und Event-Archiv + +- rechtliche Entscheidung einarbeiten +- separates Ranking-Sichtbarkeits-Opt-in ergänzen +- Foto/Land im Ranking anzeigen +- Event-Archiv als `DashboardNews`-Typ oder eigenes Modul umsetzen + +## Teststrategie + +Feature-Tests: + +- Dashboard zeigt nur Daten der eigenen Downline. +- Linien 1 bis 8 werden korrekt gruppiert. +- Summenzeile entspricht Summe der Linien. +- Klick auf Teamabos zeigt nur `is_for = 'me'` in der Downline. +- Klick auf Kundenabos im Team zeigt nur `is_for = 'ot'` mit `member_id` in der Downline. +- 1000-Punkte-Shop listet nur Partner über Schwelle und sortiert absteigend. +- Checkout verlangt die Herkunftsabfrage und speichert sie. +- Storno erzeugt negativen `UserSalesVolume`-Eintrag und aktualisiert Monatswerte. +- Incentive-Ranking zeigt Name/Foto/Land nur nach passender Zustimmung. + +Unit-Tests: + +- Service aggregiert Linien korrekt. +- Service verhindert Zugriff auf fremde Team-User. +- Metrikdefinitionen liefern stabile Counts bei aktiven, gekündigten und zukünftigen Abos. + +Regressionsprüfung: + +- bestehende Teamseiten `user.team.*` +- bestehende Abo-Seiten +- bestehendes Incentive-Ranking +- Checkout für Webshop, Bestelllink und Salescenter-Flows + +## Offene Fachfragen + +1. Soll die neue Statistik die aktuelle Monatslogik nutzen oder standardmäßig den letzten abgeschlossenen Monat zeigen? +2. Sollen Stornos im Stornomonat oder im ursprünglichen Umsatzmonat gegengerechnet werden? +3. Wie genau wird "1000 Punkte Shop" definiert: nur Shop-Punkte, alle Kundenpunkte oder Kundenabos plus Einzelbestellungen? +4. Welche Kundendaten dürfen Berater in Deep-Dive-Listen sehen? +5. Ist die Herkunftsabfrage Freitext, Auswahlfeld oder Kombination? +6. Gilt die Herkunftsabfrage für alle Checkout-Flows oder nur für externe Kundenbestellungen? +7. Darf eine Incentive-Teilnahme bereits Name/Foto/Land freigeben oder braucht es ein separates Opt-in? +8. Soll das Event-Archiv nur Bilder und Texte enthalten oder eine echte Galerie mit Mehrfachuploads? + +## Empfehlung + +Die Backoffice-Statistik sollte als eigenes kleines Modul im User-Bereich umgesetzt werden, nicht als weitere Logik in `dashboard/_statistics.blade.php`. Die vorhandenen Datenquellen sind ausreichend für ein MVP, aber sie müssen zentral aggregiert, fachlich sauber benannt und über berechtigte Drill-down-Routen zugänglich gemacht werden. + +Priorität für die erste Umsetzung: + +1. Daten- und Begriffsdefinitionen finalisieren +2. zentrale Services und Stufe-1-Linienübersicht +3. Drill-down für Abos und Neupartner +4. 1000-Punkte-Shop +5. Checkout-Herkunft und Storno-Tests +6. Incentive-Sichtbarkeit und Event-Archiv diff --git a/dev/2026-05-13-dhl-modul/ENTWICKLUNGSKONZEPT-DHL-MODUL.md b/dev/2026-05-13-dhl-modul/ENTWICKLUNGSKONZEPT-DHL-MODUL.md new file mode 100644 index 0000000..ffdbf87 --- /dev/null +++ b/dev/2026-05-13-dhl-modul/ENTWICKLUNGSKONZEPT-DHL-MODUL.md @@ -0,0 +1,190 @@ +# Entwicklungskonzept DHL Modul + +Stand: 13.05.2026 + +## Ziel + +Diese Datei ist die aktuelle Arbeitsgrundlage fuer die Weiterentwicklung des DHL Moduls. Die bisherigen Markdown-Dateien in diesem Ordner dokumentieren abgeschlossene oder ueberholte Zwischenstaende, insbesondere den frueheren SDK-Ansatz, Paketinstallation, SSL/cURL-Fixes und einzelne abgeschlossene Entwicklungsschritte. + +Aktuelle Anforderungen kommen aus `docs/dhl/Anpassung DHL Modul.md`: + +- Internationaler Versand ausserhalb Deutschlands, insbesondere Oesterreich und Spanien. +- Freies Feld fuer Sendungsreferenz oder interne Hinweise wie "Nachlieferung". +- Adressvalidierung vor Labelerstellung, damit fehlerhafte Labels und kostenpflichtige Stornos vermieden werden. +- Storno im DHL Cockpit pruefen und stabilisieren. +- Gewicht von Kompensationsprodukten in das DHL-Paketgewicht einrechnen. +- Tracking-Mails auf Rhythmus, Ausloeser und Mehrfachversand pruefen. +- Tracking-Codes in Admin, User-Portal und User-N-Portal sichtbar machen. +- Tracking-Mail-Versand nur bei Statusaenderung. +- DHL-Umstellung von `V62WP` Warenpost auf `V62KP` DHL Kleinpaket bis spaetestens 31.05.2026. + +## Aktueller technischer Stand + +Das produktive Modul basiert auf dem Paket `packages/acme-laravel-dhl` und verwendet die DHL REST API ueber `Acme\Dhl\Support\DhlClient`. Die zentrale Tabelle ist `dhl_package_shipments`. + +Wichtige produktive Einstiegspunkte: + +- `app/Http/Controllers/DhlShipmentController.php` +- `app/Services/DhlShipmentService.php` +- `app/Services/DhlModalService.php` +- `app/Services/DhlDataHelper.php` +- `app/Services/DhlTrackingService.php` +- `packages/acme-laravel-dhl/src/Services/ShippingService.php` +- `packages/acme-laravel-dhl/src/Services/ReturnsService.php` +- `packages/acme-laravel-dhl/src/Models/DhlShipment.php` +- `config/dhl.php` + +Historische Dokumente erwaehnen teilweise alte Strukturen wie `App\Models\DhlShipment`, `dhl_shipments` oder einen konsolidierten `DhlApiService`. Diese Angaben sind nicht mehr massgeblich, ausser sie werden explizit in aktuellem Code noch referenziert. Aktuell gibt es genau dort noch Altlasten, die bereinigt werden muessen. + +## Offensichtliche Befunde + +### 1. Produktkuerzel `V62WP` ist veraltet + +`V62WP` ist weiterhin in Konfiguration, Admin-Settings, Validierung, Produktauswahl und Sprachdateien vorhanden. DHL verlangt die Umstellung auf `V62KP`. + +Betroffene Stellen: + +- `config/dhl.php` +- `app/Http/Controllers/SettingController.php` +- `app/Services/DhlModalService.php` +- `packages/acme-laravel-dhl/src/Services/ShippingService.php` +- `resources/lang/*/dhl.php` + +Risiko: Kleinpaket/Warenpost-Labels koennen nach der DHL-Frist abgelehnt werden. + +### 2. Async Tracking verwendet veraltetes Model + +`app/Jobs/TrackShipmentJob.php` importiert `App\Models\DhlShipment`, dieses Model existiert im aktuellen System nicht mehr. Die produktive DHL-Integration verwendet `Acme\Dhl\Models\DhlShipment`. + +Risiko: Asynchrones Tracking bricht zur Laufzeit, sobald Queue-Tracking genutzt wird. + +### 3. Statuswerte fuer Storno sind inkonsistent + +Im Paket wird bei erfolgreichem Storno `canceled` gesetzt. Andere Stellen, Uebersetzungen und historische Dokumente verwenden `cancelled`. + +Risiko: Filter, Badges, Terminal-Status, Uebersetzungen und Storno-Buttons verhalten sich uneinheitlich. + +### 4. Storno ist technisch vorhanden, aber nicht robust genug + +Storno laeuft ueber `DELETE /parcel/de/shipping/v2/orders/{shipmentNumber}`. Der Code prueft `canCancel()`, speichert aber Fehler nur begrenzt fachlich verwertbar. Produktspezifische Einschraenkungen wie Warenpost/Kleinpaket sind nicht sauber modelliert. + +Risiko: Anwender sehen generische Fehler und koennen nicht erkennen, ob ein Storno produktbedingt, statusbedingt, API-bedingt oder wegen Sandbox/Production-Mismatch scheitert. + +### 5. Internationaler Versand ist nur teilweise vorbereitet + +`V53PAK` ist als internationales Produkt vorhanden, und einige Laender werden in 3-stellige ISO-Codes konvertiert. Dennoch gibt es keinen zentralen Produktentscheid je Zielland, keine harte Validierung nicht unterstuetzter Laender und einen gefaehrlichen Fallback auf `DEU`. + +Risiko: Sendungen nach Oesterreich, Spanien oder weitere Laender koennen falsche Produktcodes, falsche Abrechnungsnummern oder falsche Laendercodes erhalten. + +### 6. Adressvalidierung ist nur formal + +Aktuell prueft das Modul Pflichtfelder, Hausnummern und einfache PLZ-Regeln. Eine echte Pruefung, ob Strasse, PLZ und Ort postalisch existieren, findet nicht statt. + +Empfohlene Loesung: DHL/Post & DHL `DATAFACTORY AUTOCOMPLETE 2.0` fuer DE/AT/CH pruefen und integrieren. Alternativen fuer breiteren Laenderumfang: Loqate, Google Address Validation oder HERE. Wichtig ist eine harte Sperre bei nicht versandfaehigen Adressen vor Labelerstellung. + +### 7. Referenzfeld ist API-seitig vorhanden, aber nicht im Cockpit nutzbar + +`ShippingService` kann `reference` nach `refNo` mappen. `DhlDataHelper` setzt aktuell automatisch `Order-{id}`. + +Risiko: Admins koennen Hinweise wie "Nachlieferung" nicht strukturiert am Label/API-Auftrag hinterlegen. + +### 8. Gewicht von Kompensationsprodukten fehlt + +Kompensationsprodukte werden im Warenkorb mit Gewicht `0` abgelegt, damit die Kompensationslogik nicht beeinflusst wird. Das DHL-Gewicht kommt aus `ShoppingOrder->weight` und enthaelt dieses Produktgewicht dadurch nicht. + +Risiko: DHL-Label wird mit zu geringem Paketgewicht erstellt. + +### 9. Tracking-Mail-Logik ist grundsaetzlich brauchbar, muss aber abgesichert werden + +Der Scheduler ruft stuendlich `dhl:update-tracking --days=30 --send-emails` auf. Statusabhaengige Intervalle verhindern zu viele API-Calls. Automatische Mails werden nur gesendet, wenn eine Sendung neu auf `in_transit` wechselt und noch keine Mail markiert wurde. + +Risiko: Statusspruenge direkt auf `out_for_delivery` oder besondere DHL-Statuscodes koennen ohne Mail bleiben. Mehrere Sendungen einer Bestellung werden teils zusammengefasst, aber die fachliche Regel muss final bestaetigt werden. + +## Entwicklungskonzept + +### Phase 1: Pflichtkorrekturen vor DHL-Frist + +1. `V62WP` vollstaendig auf `V62KP` migrieren. +2. Neue Konfigurationskeys fuer `DHL_ACCOUNT_NUMBER_V62KP`, Admin-Setting `dhl_account_v62kp`, Dimensionen und Uebersetzungen einfuehren. +3. `ShippingService` Validierung um `V62KP` erweitern und `V62WP` entfernen oder nur noch als Legacy-Mapping fuer Altdaten anzeigen. +4. Bestehende Sendungen mit `V62WP` historisch lesbar lassen, aber neue Labelerstellung blockieren. +5. Tests fuer Produktcode-Auswahl, Validierung und Payload-Erstellung schreiben. + +### Phase 2: Stabilisierung von Tracking und Storno + +1. `TrackShipmentJob` auf aktuelles Model und aktuellen `DhlTrackingService` umstellen. +2. Statuswerte vereinheitlichen. Empfehlung: intern `canceled` verwenden, Uebersetzung auf Deutsch "Storniert". +3. `TERMINAL_STATUSES`, Badges, Filter und Sprachdateien entsprechend angleichen. +4. Storno-Fehler strukturiert speichern: HTTP-Status, DHL-Fehlercode, DHL-Detailtext, Zeitpunkt. +5. Admin-Feedback verbessern: nicht stornierbar wegen Status, Produkt, API-Antwort oder nicht auffindbarer DHL-Sendung. +6. Tests fuer erfolgreiche Stornierung, bereits stornierte Sendung, nicht stornierbare Sendung und API-Fehler. + +### Phase 3: Internationalisierung Versand + +1. Zentralen Service fuer Produkt-/Billing-Entscheidung einfuehren, z. B. `DhlProductResolver`. +2. Zielland, Produktcode, Abrechnungsnummer und erlaubte Services dort validieren. +3. Regeln initial: + - `DE`: `V01PAK` oder `V62KP` + - `AT`, `ES` und weitere aktivierte Laender: `V53PAK` +4. Fallback auf `DEU` entfernen. Unbekannte Laender muessen mit klarer Fehlermeldung abbrechen. +5. Cockpit-Formular: Produkt anhand Zielland vorschlagen, aber Admin-Korrektur erlauben. + +### Phase 4: Adressvalidierung vor Labelerstellung + +1. Neuen serverseitigen Validierungsendpunkt fuer DHL-Adressen schaffen. +2. Basisvalidierung behalten: Pflichtfelder, Land, PLZ-Format, Hausnummer, Packstation/Postnummer. +3. DHL DATAFACTORY AUTOCOMPLETE 2.0 fuer DE/AT/CH evaluieren. +4. Falls DHL fuer alle benoetigten Laender nicht ausreicht, externen Provider evaluieren. +5. UI-Status einfuehren: + - gueltig: Labelerstellung erlaubt + - Warnung: Admin kann bewusst fortfahren + - Fehler: Labelerstellung gesperrt +6. Validierungsergebnis optional am Shipment/Order protokollieren. + +### Phase 5: Referenzfeld und Admin-UX + +1. Neues Formularfeld `reference` oder `shipment_reference` im DHL Cockpit. +2. Wert an `DhlDataHelper::prepareOrderData()` uebergeben. +3. `ShippingService` nutzt vorhandenes Mapping nach `refNo`. +4. Referenz im Shipment speichern, damit spaeter nachvollziehbar ist, warum eine Sendung erstellt wurde. +5. Laengenlimit der DHL API beachten, aktuell maximal 35 Zeichen. + +### Phase 6: DHL-Gewicht korrekt berechnen + +1. Separaten Gewichtsdienst fuer DHL einfuehren, z. B. `DhlShipmentWeightCalculator`. +2. Basis: `ShoppingOrder->weight`. +3. Fuer `shopping_order_items` mit `comp > 0` das Produktgewicht aus `Product->weight` nachladen und addieren. +4. Nur DHL-Gewicht anpassen, nicht die bestehende Warenkorb-/Versandkostenlogik. +5. Rundung und DHL-Grenzwerte je Produkt testen. + +### Phase 7: Tracking-Codes und Mails fachlich finalisieren + +1. Bestehende Admin-Anzeige pruefen und bei Bedarf vereinheitlichen. +2. Tracking-Code-Anzeige in User-Portal und User-N-Portal ergaenzen. +3. Mail-Regel final definieren: + - automatisch nur einmal pro Sendung + - nur bei relevanter Statusaenderung + - mehrere Sendungen einer Bestellung sinnvoll zusammenfassen +4. Statusspruenge wie `created` direkt nach `out_for_delivery` abdecken. +5. Command `dhl:update-tracking` Tests fuer Mailausloeser und Nicht-Ausloeser ergaenzen. + +## Empfohlene Reihenfolge + +1. `V62WP` -> `V62KP`, Statuswerte und `TrackShipmentJob` korrigieren. +2. Storno stabilisieren und bessere Fehlermeldungen im Cockpit anzeigen. +3. Internationalen Produktresolver einbauen. +4. Referenzfeld und Gewichtskorrektur umsetzen. +5. Adressvalidierung integrieren. +6. Tracking-Anzeigen und Mailregeln final testen. + +## Teststrategie + +- Feature-Tests fuer Controller-Endpunkte: Label erstellen, Storno, Tracking-Mail, Tracking-Update. +- Unit-Tests fuer Produktresolver, Gewichtskalkulation und Adressvalidierung. +- HTTP-Fakes fuer DHL API Responses inklusive Fehlerfaelle. +- Regression-Test fuer `V62KP` Payload. +- Command-Test fuer `dhl:update-tracking --send-emails`. + +## Legacy-Dokumentation + +Die bisherigen Markdown-Dateien wurden nach `dev/dhl-modul/legacy` verschoben. Sie bleiben als Historie erhalten, sind aber nicht mehr die aktuelle Arbeitsgrundlage. diff --git a/dev/dhl-modul/dhl_test.txt b/dev/2026-05-13-dhl-modul/dhl_test.txt similarity index 100% rename from dev/dhl-modul/dhl_test.txt rename to dev/2026-05-13-dhl-modul/dhl_test.txt diff --git a/dev/dhl-modul/AKTUALISIERUNG-PAKET-ANSATZ.md b/dev/2026-05-13-dhl-modul/legacy/AKTUALISIERUNG-PAKET-ANSATZ.md similarity index 100% rename from dev/dhl-modul/AKTUALISIERUNG-PAKET-ANSATZ.md rename to dev/2026-05-13-dhl-modul/legacy/AKTUALISIERUNG-PAKET-ANSATZ.md diff --git a/dev/dhl-modul/DHL_CURL_781_EXTREME_FIX.md b/dev/2026-05-13-dhl-modul/legacy/DHL_CURL_781_EXTREME_FIX.md similarity index 100% rename from dev/dhl-modul/DHL_CURL_781_EXTREME_FIX.md rename to dev/2026-05-13-dhl-modul/legacy/DHL_CURL_781_EXTREME_FIX.md diff --git a/dev/dhl-modul/DHL_LEGACY_CURL_CONFIG.md b/dev/2026-05-13-dhl-modul/legacy/DHL_LEGACY_CURL_CONFIG.md similarity index 100% rename from dev/dhl-modul/DHL_LEGACY_CURL_CONFIG.md rename to dev/2026-05-13-dhl-modul/legacy/DHL_LEGACY_CURL_CONFIG.md diff --git a/dev/dhl-modul/DHL_LIVE_SERVER_FIX.md b/dev/2026-05-13-dhl-modul/legacy/DHL_LIVE_SERVER_FIX.md similarity index 100% rename from dev/dhl-modul/DHL_LIVE_SERVER_FIX.md rename to dev/2026-05-13-dhl-modul/legacy/DHL_LIVE_SERVER_FIX.md diff --git a/dev/dhl-modul/DHL_LIVE_SERVER_SOLUTION.md b/dev/2026-05-13-dhl-modul/legacy/DHL_LIVE_SERVER_SOLUTION.md similarity index 100% rename from dev/dhl-modul/DHL_LIVE_SERVER_SOLUTION.md rename to dev/2026-05-13-dhl-modul/legacy/DHL_LIVE_SERVER_SOLUTION.md diff --git a/dev/dhl-modul/DHL_SSL_FIX_README.md b/dev/2026-05-13-dhl-modul/legacy/DHL_SSL_FIX_README.md similarity index 100% rename from dev/dhl-modul/DHL_SSL_FIX_README.md rename to dev/2026-05-13-dhl-modul/legacy/DHL_SSL_FIX_README.md diff --git a/dev/dhl-modul/OPTIMIERUNGEN.md b/dev/2026-05-13-dhl-modul/legacy/OPTIMIERUNGEN.md similarity index 100% rename from dev/dhl-modul/OPTIMIERUNGEN.md rename to dev/2026-05-13-dhl-modul/legacy/OPTIMIERUNGEN.md diff --git a/dev/dhl-modul/PAKET-INSTALLATION.md b/dev/2026-05-13-dhl-modul/legacy/PAKET-INSTALLATION.md similarity index 100% rename from dev/dhl-modul/PAKET-INSTALLATION.md rename to dev/2026-05-13-dhl-modul/legacy/PAKET-INSTALLATION.md diff --git a/dev/dhl-modul/PLAN-OPTIMIERT.md b/dev/2026-05-13-dhl-modul/legacy/PLAN-OPTIMIERT.md similarity index 100% rename from dev/dhl-modul/PLAN-OPTIMIERT.md rename to dev/2026-05-13-dhl-modul/legacy/PLAN-OPTIMIERT.md diff --git a/dev/dhl-modul/README.md b/dev/2026-05-13-dhl-modul/legacy/README.md similarity index 100% rename from dev/dhl-modul/README.md rename to dev/2026-05-13-dhl-modul/legacy/README.md diff --git a/dev/dhl-modul/SCHRITT-3-COMPLETED.md b/dev/2026-05-13-dhl-modul/legacy/SCHRITT-3-COMPLETED.md similarity index 100% rename from dev/dhl-modul/SCHRITT-3-COMPLETED.md rename to dev/2026-05-13-dhl-modul/legacy/SCHRITT-3-COMPLETED.md diff --git a/dev/dhl-modul/parcel-de-shipping-v2_2.yaml b/dev/2026-05-13-dhl-modul/parcel-de-shipping-v2_2.yaml similarity index 100% rename from dev/dhl-modul/parcel-de-shipping-v2_2.yaml rename to dev/2026-05-13-dhl-modul/parcel-de-shipping-v2_2.yaml diff --git a/docs/BusinessUpdateCalculatedFields-Examples.sh b/dev/BusinessUpdateCalculatedFields-Examples.sh similarity index 100% rename from docs/BusinessUpdateCalculatedFields-Examples.sh rename to dev/BusinessUpdateCalculatedFields-Examples.sh diff --git a/docs/BusinessUpdateCalculatedFields.md b/dev/BusinessUpdateCalculatedFields.md similarity index 100% rename from docs/BusinessUpdateCalculatedFields.md rename to dev/BusinessUpdateCalculatedFields.md diff --git a/resources/lang/fr.json b/resources/lang/fr.json new file mode 100644 index 0000000..7b53731 --- /dev/null +++ b/resources/lang/fr.json @@ -0,0 +1,170 @@ +{ + "MR": "Monsieur", + "MS": "Madame", + "DIV": "Divers", + "please select": "Veuillez sélectionner", + "please specify": "Veuillez préciser.", + "further countries": "autres pays", + "none": "Aucun", + "no": "Non", + "yes": "OUI", + "Company data": "Données de l'entreprise", + "Company name": "Nom de l'entreprise", + "Company": "Entreprise", + "Street": "Rue", + "Addition": "Complément", + "House number": "Numéro", + "Address": "Adresse", + "no.": "N°", + "City": "Ville", + "Postcode": "Code postal", + "Country": "Pays", + "Delivery country": "Pays de livraison", + "Delivery address": "Adresse de livraison", + "Delivery addresses": "Adresses de livraison", + "Phone": "Téléphone", + "optional": "optionnel", + "Phone code": "Indicatif téléphonique", + "Country code": "Code pays", + "E-Mail": "E-mail", + "Homepage": "Page d'accueil", + "Industry": "Secteur", + "Industries": "Secteurs", + "Main Industry": "Secteur principal", + "Personal Data": "Données personnelles", + "Data": "Données", + "Function": "Fonction", + "Salutation": "Civilité", + "Title": "Titre", + "First name": "Prénom", + "Last name": "Nom", + "Mobile Phone": "Téléphone mobile", + "Name": "Nom", + "Date of birth": "Date de naissance", + "Comments": "Remarques", + "Flat Building optional": "Appartement / bâtiment (optionnel)", + "Shipping to the same address": "Livraison à la même adresse", + "Consent & Privacy": "Consentement et confidentialité", + "Product": "Produit", + "Products": "Produits", + "New Password": "Nouveau mot de passe", + "Old Password": "Ancien mot de passe", + "Create Password": "Créer un mot de passe", + "Change password": "Modifier le mot de passe", + "Confirm new Password": "Répéter le nouveau mot de passe", + "Confirm Password": "Confirmer le mot de passe", + "Confirm E-Mail": "Répéter l'e-mail", + "E-Mail Address": "Adresse e-mail", + "Confirm E-Mail Address": "Répéter l'adresse e-mail", + "Forgot your Password?": "Mot de passe oublié ?", + "Login": "Connexion", + "Logout": "Déconnexion", + "Password": "Mot de passe", + "Register": "S'inscrire", + "Remember Me": "Rester connecté", + "Reset Password": "Réinitialiser le mot de passe", + "Send Password Reset Link": "Envoyer le lien de réinitialisation du mot de passe", + "save": "enregistrer", + "save and next": "enregistrer et continuer", + "save changes": "enregistrer les modifications", + "sended": "envoyer", + "abort": "annuler", + "add": "ajouter", + "This field is required.": "Ce champ est obligatoire.", + "Please enter a valid email address.": "Veuillez saisir une adresse e-mail valide.", + "This E-mail is already in use.": "Cette adresse e-mail est déjà utilisée.", + "Please enter the same value again.": "Les adresses e-mail ne correspondent pas.", + "a valid e-mail address": "Veuillez saisir une adresse e-mail valide.", + "Already have an account?": "Vous avez déjà un compte ?", + "Login to your account": "Connectez-vous à votre compte", + "now register data": "enregistrer maintenant les données", + "Required fields": "Champs obligatoires", + "Industry sectors": "Secteurs", + "Interests": "Centres d'intérêt", + "Leads": "Contacts", + "Your Data": "Vos données", + "Edit your data": "Modifier vos données", + "saved": "Enregistré", + "The changes have been saved.": "Les modifications ont été enregistrées.", + "error": "Erreur", + "Here you can adjust your data.": "Vous pouvez adapter vos données ici.", + "Overview": "Aperçu", + "Data, Login & Security": "Données, connexion et sécurité", + "Sign in with your e-mail:": "Connexion avec votre e-mail :", + "delete": "supprimer", + "confirm_delete": "Supprimer vraiment ?", + "imprint": "Mentions légales", + "data protections": "Protection des données", + "Thank you for your registration!": "Merci pour votre inscription !", + "We have sent you an e-mail with a link to activate your data.": "Nous vous avons envoyé un e-mail avec un lien pour activer votre compte.", + "Please check your emails and confirm the link.": "Veuillez consulter vos e-mails et confirmer le lien.", + "back to the homepage": "retour à la page d'accueil", + "You have successfully verified your account!": "Votre compte a été vérifié avec succès !", + "Now check your data and release the data.": "Vérifiez maintenant vos données et validez-les.", + "Check and release data": "Vérifier et valider les données", + "Check data": "Vérifier les données", + "Page not available": "Page non disponible", + "Data released": "Données validées", + "Data released now": "Valider les données maintenant", + "E-Mail verified": "E-mail vérifié", + "E-Mail not verified": "E-mail non vérifié", + "Privacy policy approved": "Politique de confidentialité acceptée", + "Consent for further information": "Consentement pour recevoir d'autres informations", + "at": "le", + "If you have checked your data, share your data here!": "Si vous avez vérifié vos données, validez-les ici !", + "Contacts all": "tous les contacts", + "Contacts verify": "contacts vérifiés", + "Contacts active": "contacts activés", + "This website uses cookies": "Ce site utilise des cookies afin de vous garantir le meilleur service possible. En visitant cette page, vous acceptez l'utilisation de cookies.", + "OK": "OK", + "Contacts": "Contacts", + "activ": "actif", + "active": "activé", + "inactive": "désactivé", + "verified": "vérifié", + "'E-Mail": "'E-mail", + "create new Contact": "Créer un nouveau contact", + "Create/Edit Contact": "Créer/modifier un contact", + "Pos": "Pos", + "Description": "Désignation", + "Translate": "Traduction", + "Status": "Statut", + "back": "retour", + "back_to_overview": "retour à l'aperçu", + "create/edit": "créer/modifier", + "close": "fermer", + "take over": "reprendre", + "Number to move the position if necessary": "Nombre pour déplacer la position si nécessaire", + "Create new interest": "Créer un nouveau centre d'intérêt", + "Really delete entry?": "Supprimer vraiment cette entrée ?", + "Create a new industry": "Créer un nouveau secteur", + "Your e-mail has been changed.": "Votre e-mail a été modifié.", + "We sent you an activation code. Check your email!": "Nous vous avons envoyé un code d'activation. Vérifiez vos e-mails !", + "An activation code was sent to the account by e-mail!": "Un code d'activation a été envoyé au contact par e-mail !", + "New E-Mail Address": "Nouvelle adresse e-mail", + "Confirm new E-Mail": "Répéter la nouvelle adresse e-mail", + "business": "professionnel", + "private": "privé", + "business or private": "professionnel ou privé", + "use": "Utilisation", + "Contact": "Contact", + "waiting for activation since": "en attente d'activation depuis", + "edit": "modifier", + "your mivita.care team": "Votre équipe mivita.care", + "create new password": "créer un nouveau mot de passe", + "Now assign a password.": "Définissez maintenant votre mot de passe pour accéder à votre compte.", + "to your data": "vers vos données", + "Your registration has already been completed.": "Votre inscription est déjà terminée.", + "The link to register is no longer active.": "Le lien d'inscription n'est plus actif ou l'inscription est déjà terminée.", + "go to login": "aller à la connexion", + "Please confirm your data first.": "Veuillez d'abord vérifier et confirmer vos données.", + "yes, data checked and share": "Oui, données vérifiées et valider maintenant", + "Copy link": "Copier le lien", + "ml": "ml", + "g": "g", + "liter": "litre", + "kg": "kg", + "search_for": "Rechercher ....", + "show_all_filters": "Afficher tous les filtres", + "": "" +} diff --git a/resources/lang/fr/abo.php b/resources/lang/fr/abo.php new file mode 100644 index 0000000..15c5c81 --- /dev/null +++ b/resources/lang/fr/abo.php @@ -0,0 +1,135 @@ + 'Abonnement', + 'payment_for_abo' => 'Mode de paiement pour l’abonnement', + 'abo_delivery' => 'Abonnement - livraison régulière', + 'abo_are_for_me_and_shipped' => 'L’abonnement est pour moi et sera envoyé à mon adresse', + 'abo_are_for_customer_and_shipped' => 'L’abonnement est pour un client et sera envoyé au client', + 'abo_delivery_to_me' => 'Livraison d’abonnement à moi-même', + 'abo_delivery_to_the_customer' => 'Livraison d’abonnement au client', + 'every_week' => 'chaque semaine', + 'every_weeks' => 'toutes les :num semaines', + 'of_month' => 'du mois', + 'delivery_intervall' => 'Adapter le jour de livraison', + 'abo_order_info_check' => 'À la conclusion de l’abonnement, une livraison régulière est mise en place. Elle est automatiquement expédiée et facturée au jour de livraison choisi.', + 'abo_order_info_check_2' => 'La première livraison et facturation a lieu le jour de la création de l’abonnement. Ensuite, l’expédition se fait automatiquement au jour de livraison choisi du mois suivant.', + 'abo_order_info_check_3' => 'PayPal et carte de crédit sont disponibles comme modes de paiement. L’abonnement a une durée minimale de :abo-min-duration mois. Ensuite, il peut être mis en pause, modifié ou résilié à tout moment.', + 'abo_order_info_checkbox' => 'Oui, j’ai compris les conditions de l’abonnement !', + 'abo_order_info_checkbox_required' => 'Veuillez confirmer les conditions de l’abonnement pour continuer.', + 'abo_infos' => 'Infos abonnement', + 'abo_delivery_infos' => 'Infos livraison abonnement', + 'abo_start_date' => 'Début de l’abonnement', + 'abo_delivery_intervall' => 'Jour de livraison de l’abonnement', + 'abo_first_execution_date' => 'Première exécution', + 'abo_next_execution_date' => 'Prochaine exécution', + 'delivery_day' => 'Adapter le jour de livraison', + 'abo_settings' => 'Paramètres abonnement', + 'add_new_abo' => 'Créer un nouvel abonnement', + 'abo_edit' => 'Modifier l’abonnement', + 'abo_details' => 'Détails de l’abonnement', + 'abo_is_active' => 'L’abonnement est actif', + 'abo_copy_active' => 'Si l’abonnement n’est pas actif, aucune exécution automatique n’a lieu.', + 'abo_copy_next_date' => 'Le prochain jour d’exécution peut être fixé au plus tôt au lendemain.', + 'abo_copy_abo_interval' => 'L’adaptation du jour de livraison de l’abonnement impacte la prochaine date d’exécution si l’abonnement est actif.', + 'admin_abo_copy_next_date' => 'Les admins peuvent définir directement la prochaine date d’exécution via le mois et le jour de livraison.', + 'error_abo_interval' => 'L’intervalle d’abonnement est incorrect', + 'error_abo_interval_in_the_past' => 'L’abonnement n’a pas encore été exécuté ce mois-ci. Un changement vers un jour passé ferait sauter le mois en cours.', + 'warning_next_date_soon' => 'Remarque : la prochaine exécution de l’abonnement est déjà dans :days jours (:date).', + 'warning_next_date_soon_select' => 'Remarque : la prochaine exécution de l’abonnement est déjà dans :placeholder_days jours (:placeholder_date).', + 'warning_next_date_info' => 'La prochaine exécution de l’abonnement est dans :days jours, le :date.', + 'info_next_execution_select' => 'Prochaine exécution : dans :placeholder_days jours, le :placeholder_date.', + 'error_change_locked' => 'Les modifications ne sont plus possibles. La prochaine exécution est dans :days jours. Les modifications doivent être effectuées au moins 10 jours avant.', + 'error_abo_interval_too_soon' => 'Le jour de livraison choisi n’est qu’à :days jours. Veuillez choisir un jour de livraison situé au moins 10 jours dans le futur.', + 'error_cancel_locked' => 'Une résiliation n’est plus possible. La prochaine exécution est dans :days jours. Les résiliations doivent être effectuées au moins 3 jours avant.', + 'error_pause_locked' => 'L’abonnement ne peut plus être mis en pause. La prochaine exécution est dans :days jours. La pause doit être effectuée au moins 3 jours avant.', + 'error_next_date' => 'La date de la prochaine exécution est incorrecte', + 'checkout_mail_abo_hl' => 'Votre abonnement / livraison régulière.', + 'checkout_mail_abo_start' => 'Votre abonnement a été créé avec succès avec les paramètres suivants :', + 'checkout_mail_abo_info' => 'Vous trouverez les paramètres de votre abonnement dans votre compte sous "Mes abonnements" et pourrez également les y modifier.', + 'abo_new' => 'nouveau', + 'abo_okay' => 'ok', + 'abo_hold' => 'en pause', + 'abo_cancel' => 'annulé', + 'abo_finish' => 'terminé', + 'abo_inactive' => 'inactif', + 'abo_grace' => 'geste commercial', + 'abo_info' => 'Informations abonnement', + 'info_min_duration_reached' => 'Votre abonnement pourra être modifié, complété, mis en pause ou résilié au plus tôt à partir du :date.', + 'info_min_duration_orders_left' => 'L’abonnement ne pourra être modifié, complété, mis en pause ou résilié qu’après encore :count exécutions.', + 'pros_hl' => 'Les avantages d’un abonnement', + 'pros_list' => '
  • Conclusion d’abonnement pour conseillers et clients : Chaque conseiller ou client peut conclure un abonnement exécuté à un jour fixe du mois afin de garantir une livraison régulière et planifiable.
  • +
  • Livraison mensuelle : Une nouvelle livraison est envoyée directement à votre porte une fois par mois.
  • +
  • Adaptable avec flexibilité : L’abonnement peut être adapté individuellement, p. ex. en termes de produits, quantités ou dates de livraison.
  • +
  • Large choix de produits : Différents produits peuvent être inclus dans l’abonnement.
  • +
  • Durée : L’abonnement a une durée minimale de :abo-min-duration mois, puis il peut être mis en pause ou résilié.
  • +
  • Avantage prix : Les produits d’abonnement bénéficient souvent de remises ou offres spéciales.
  • +
  • Démarrer maintenant : Choisissez vos produits, adaptez l’abonnement à vos besoins, payez la première commande et activez ainsi votre abonnement pour les prochaines livraisons.
  • ', + 'abo_pros' => 'Avantages abonnement', + 'abo_order_hl' => 'Composition de l’abonnement', + 'abo_order_info_2' => 'Vous pouvez adapter les produits de votre abonnement à tout moment ; lors de la prochaine exécution, vos produits composés vous seront envoyés.', + 'abo_order_info_block' => 'Vous pouvez adapter la composition de votre abonnement après la durée minimale de :abo-min-duration mois.', + 'abo_order_info_block_team' => 'La composition de l’abonnement pour un membre de l’équipe ne peut être adaptée que par lui-même.', + 'abo_order_info_block_customer' => 'Vous pouvez à tout moment ajouter de nouveaux produits à l’abonnement de votre client. La suppression de produits n’est possible qu’après la durée minimale de :abo-min-duration mois.', + 'abo_order_info_add_only' => 'Vous pouvez à tout moment ajouter de nouveaux produits à votre abonnement. La suppression de produits n’est possible qu’après la durée minimale de :abo-min-duration mois.', + 'error_add_only_no_remove' => 'La suppression de produits n’est pas possible pendant la durée minimale.', + 'confirm_add_title' => 'Confirmer l’ajout du produit', + 'confirm_add_title_normal' => 'Ajouter le produit à l’abonnement', + 'confirm_add_warning' => 'Pendant la durée minimale, les produits ajoutés ne peuvent pas être retirés. Veuillez vérifier soigneusement votre sélection.', + 'confirm_add_warning_normal' => 'Voulez-vous vraiment ajouter ce produit à votre abonnement ?', + 'confirm_add_cancel' => 'Annuler', + 'confirm_add_ok' => 'Oui, ajouter', + 'add_product' => 'Ajouter le produit', + 'product_prices_career_level_info' => 'Les prix produits sont affichés et calculés selon votre niveau de carrière :user_level_name moins :user_level_margin % de marge.', + 'product_prices_career_level_cpay_info' => 'Les prix produits sont affichés comme prix de vente client ; après finalisation du paiement client, vous recevez votre commission selon votre niveau de carrière :user_level_name, commission :user_level_margin %.', + 'error_email_has_abo' => 'Un abonnement existe déjà pour l’adresse e-mail :email.', + 'abo_assigned' => 'Abonnement actif', + 'base' => 'Base', + 'upgrade' => 'Upgrade', + 'abo_type_info' => 'Remarque : chaque abonnement se compose au minimum d’un produit de base :base !
    Les produits upgrade :upgrade sont optionnels et peuvent être ajoutés à volonté.
    L’abonnement a une durée minimale de :abo-min-duration mois, puis il peut être mis en pause ou résilié.', + 'abo_type_info_base' => 'L’abonnement nécessite au moins un produit de base :base !', + 'need_basis_product' => 'Vous devez avoir au moins un produit de base dans votre abonnement. Veuillez d’abord ajouter un nouveau produit de base puis supprimer l’ancien !', + 'abo_item_not_found' => 'Position d’abonnement introuvable', + 'product_not_found' => 'Produit introuvable', + 'create_abo' => 'Créer un abonnement', + 'info' => 'Info', + 'data' => 'Données', + 'check' => 'Vérifier', + 'choose' => 'Sélectionner', + 'order' => 'commander', + 'basis_product' => 'Produit de base', + 'upgrade_products' => 'Produits upgrade', + 'base_product' => 'Produit de base', + 'upgrade_product' => 'Produit upgrade', + 'my_address' => 'Mon adresse', + 'my_address_check' => 'Vérifier mon adresse', + 'my_address_check_info' => 'Veuillez vérifier votre adresse afin de garantir que la marchandise sera envoyée à la bonne adresse.', + 'edit' => 'modifier', + 'confirm_and_next' => 'confirmer et continuer', + 'understood_and_next' => 'compris et continuer', + 'change_my_data_empty' => 'Vous n’avez pas encore enregistré d’adresse de facturation et de livraison. Sans celles-ci, vous ne pouvez pas créer d’abonnement ; veuillez les créer.', + 'abo_error_basis_product' => 'Erreur : veuillez sélectionner au moins un produit de base.', + 'cancel_abo' => 'Résilier l’abonnement', + 'confirm_cancel' => 'Voulez-vous vraiment résilier l’abonnement ?', + 'retry_payment' => 'Relancer le paiement', + 'retry_payment_confirm_title' => 'Veuillez confirmer consciemment', + 'retry_payment_confirm_copy' => 'La tentative de paiement démarre immédiatement et peut déclencher un débit PayPal ou carte de crédit. À exécuter uniquement si la cause a été vérifiée.', + 'retry_payment_confirm_button' => 'Réessayer le paiement maintenant', + 'retry_only_hold' => 'La nouvelle tentative de paiement n’est possible que pour les abonnements en pause.', + 'retry_only_active' => 'La nouvelle tentative de paiement n’est possible que pour les abonnements actifs.', + 'retry_already_paid_today' => 'Un paiement réussi a déjà été enregistré aujourd’hui pour cet abonnement.', + 'retry_error_shopping_user' => 'Le Shopping-User n’a pas pu être créé pour la nouvelle tentative de paiement.', + 'retry_error_order' => 'La commande n’a pas pu être créée pour la nouvelle tentative de paiement.', + 'retry_success' => 'La nouvelle tentative de paiement a réussi. Commande #:order créée.', + 'retry_failed' => 'La nouvelle tentative de paiement a échoué. Commande #:order créée. Erreur : :error', + 'retry_exception' => 'La nouvelle tentative de paiement n’a pas pu être finalisée : :error', + 'team_subscriptions' => 'Abonnements équipe', + 'team_customer_abos' => 'Abonnements clients de l’équipe', + 'chart_monthly_abos' => 'Abonnements par mois', + 'chart_active_abos' => 'Abonnements actifs', + 'chart_abos_label' => 'Abonnements', + 'abo_count' => 'Nombre d’abonnements', + 'customer_privacy_info' => 'Pour des raisons de protection des données, aucune donnée personnelle client n’est affichée.', + 'every_month_on' => 'mensuellement le :day.', + 'back' => 'retour', +]; diff --git a/resources/lang/fr/abo_history.php b/resources/lang/fr/abo_history.php new file mode 100644 index 0000000..f4ac1c2 --- /dev/null +++ b/resources/lang/fr/abo_history.php @@ -0,0 +1,41 @@ + 'Composition initiale', + 'change_history' => 'Historique des modifications', + 'no_initial_data' => 'Aucune donnée initiale disponible (abonnement créé avant l’activation de l’historique)', + 'no_changes' => 'Aucune modification disponible', + 'col_date' => 'Date', + 'col_action' => 'Action', + 'col_product' => 'Article', + 'col_details' => 'Détails', + 'col_changed_by' => 'Modifié par', + 'col_channel' => 'Zone', + 'action_initial' => 'Origine', + 'action_added' => 'Ajouté', + 'action_removed' => 'Supprimé', + 'action_qty_changed' => 'Quantité modifiée', + 'action_comp_changed' => 'Comp remplacé', + 'action_comp_added' => 'Comp ajouté', + 'action_comp_removed' => 'Comp supprimé', + 'action_rollback' => 'Réinitialisé', + 'desc_initial' => 'Quantité : :qty', + 'desc_added' => 'Quantité : :qty', + 'desc_removed' => 'Article supprimé', + 'desc_qty_changed' => 'Quantité modifiée de :from à :to', + 'desc_comp_changed' => 'Remplacé : :old', + 'desc_comp_added' => 'Ajouté par le système', + 'desc_comp_removed' => 'Supprimé par le système', + 'desc_rollback' => 'Réinitialisé à l’origine', + 'channel_admin' => 'Admin', + 'channel_user_me' => 'Conseiller', + 'channel_user_ot' => 'Conseiller (client)', + 'channel_portal' => 'Portail client', + 'channel_system' => 'Système', + 'price_net' => 'net', + 'price_gross' => 'brut', + 'rollback_btn' => 'Réinitialiser à l’origine', + 'rollback_confirm' => 'Voulez-vous vraiment réinitialiser l’abonnement à son état initial ? Tous les produits actuels seront remplacés par la composition initiale.', + 'rollback_success' => 'L’abonnement a été réinitialisé avec succès à son état initial.', + 'rollback_no_data' => 'Rollback impossible : aucune donnée initiale disponible.', +]; diff --git a/resources/lang/fr/account.php b/resources/lang/fr/account.php new file mode 100644 index 0000000..0c9225f --- /dev/null +++ b/resources/lang/fr/account.php @@ -0,0 +1,48 @@ + '', + 'BIC' => 'BIC', + 'IBAN' => 'IBAN', + 'VAT_ID_number' => 'Numéro de TVA intracommunautaire', + 'VAT_copy_1' => 'Choisissez petite entreprise si votre activité ne génère pas plus de 22 000 EUR de chiffre d’affaires par an.', + 'VAT_liability' => 'Assujettissement à la TVA', + 'account_holder' => 'Titulaire du compte', + 'bank_data' => 'Coordonnées bancaires', + 'delivery_address' => 'Adresse de livraison', + 'firstname_lastname' => 'Prénom Nom', + 'invoice_address' => 'Adresse de facturation', + 'my_credit' => 'Mon crédit', + 'vat_data' => 'Données fiscales', + 'info_vat_numbers' => 'Remarque : veuillez indiquer le numéro fiscal et/ou le numéro de TVA !*', + 'new_vat_validate' => 'Valider le nouveau numéro de TVA et activer la procédure d’autoliquidation', + 'btn_vat_validate' => 'Valider le numéro de TVA', + 'phone_need_error' => 'Erreur : veuillez indiquer un téléphone et/ou un mobile !*', + 'phone_need_note' => 'Remarque : veuillez indiquer un téléphone et/ou un mobile !*', + 'required_for_commission_payments' => 'Nécessaire pour le versement des commissions', + 'reverse_charge_action_1' => 'Supprimer la procédure d’autoliquidation et le numéro de TVA', + 'reverse_charge_action_2' => 'Valider le numéro de TVA et activer la procédure d’autoliquidation', + 'reverse_charge_copy_1' => 'Autoliquidation de la taxe. Dans ce cas particulier, le destinataire de la prestation (conseiller) et non le prestataire (mivita) doit acquitter la TVA.', + 'reverse_charge_note_1' => 'Le numéro de TVA est nécessaire et validé lors de l’activation.', + 'reverse_charge_procedure' => 'Procédure d’autoliquidation', + 'tax_number' => 'Numéro fiscal', + 'taxable_sales_1' => 'assujetti à la TVA (je dépose une déclaration de TVA mensuelle / trimestrielle / annuelle auprès du fisc)', + 'taxable_sales_2' => 'non assujetti à la TVA (petite entreprise au sens du § 19', + 'validator_creditcard' => 'Veuillez saisir un numéro de carte de crédit valide', + 'validator_date' => 'Veuillez saisir une date valide.', + 'validator_digits' => 'Veuillez saisir uniquement des chiffres.', + 'validator_email' => 'Veuillez saisir une adresse e-mail valide.', + 'validator_equalTo' => 'Veuillez saisir à nouveau la même valeur.', + 'validator_max' => 'Veuillez saisir une valeur inférieure ou égale à {0}.', + 'validator_maxlength' => 'Veuillez saisir au maximum {0} caractères.', + 'validator_min' => 'Veuillez saisir une valeur supérieure ou égale à {0}.', + 'validator_minlength' => 'Veuillez saisir au moins {0} caractères.', + 'validator_number' => 'Veuillez saisir un nombre.', + 'validator_range' => 'Veuillez saisir une valeur entre {0} et {1}.', + 'validator_rangelength' => 'Veuillez saisir entre {0} et {1} caractères.', + 'validator_required' => 'Ce champ est obligatoire.', + 'validator_url' => 'Veuillez saisir une URL valide.', + 'language_settings' => 'Paramètres de langue', + 'preferred_language' => 'Langue préférée', + 'language_hint' => 'Cette langue est utilisée pour vos factures, relevés de commissions et bons de livraison.', +]; diff --git a/resources/lang/fr/actions.php b/resources/lang/fr/actions.php new file mode 100644 index 0000000..ee91354 --- /dev/null +++ b/resources/lang/fr/actions.php @@ -0,0 +1,24 @@ + '', + 'cancel' => 'annuler', + 'confirm' => 'confirmer', + 'file_is_too_big' => 'Le fichier est trop volumineux
    max. $0 Mo', + 'image_too_small ' => 'Image trop petite
    min. $0 pixels', + 'invalid_file' => 'Fichier invalide
    uniquement : $0', + 'really_delete_picture' => 'Supprimer vraiment l’image ?', + 'rotate' => 'pivoter', + 'save_image' => 'Enregistrer l’image', + 'search_file_or_drag_drop' => 'Rechercher un fichier ou glisser-déposer', + 'upload_photo' => 'Téléchargement de photo', + 'dictDefaultMessage' => 'Déposez les fichiers ici pour les téléverser', + 'dictFallbackMessage' => 'Votre navigateur ne prend pas en charge le téléversement par glisser-déposer.', + 'dictFallbackText' => 'Veuillez utiliser le formulaire de secours ci-dessous pour téléverser vos fichiers comme auparavant.', + 'dictFileTooBig' => 'Le fichier est trop volumineux ({{filesize}}MiB). Taille max. : {{maxFilesize}}MiB.', + 'dictInvalidFileType' => 'Vous ne pouvez pas téléverser des fichiers de ce type.', + 'dictResponseError' => 'Le serveur a répondu avec le code {{statusCode}}.', + 'dictCancelUpload' => 'Annuler le téléversement', + 'dictRemoveFile' => 'Supprimer le fichier', + 'dictMaxFilesExceeded' => 'Vous ne pouvez plus téléverser de fichiers.', +]; diff --git a/resources/lang/fr/auth.php b/resources/lang/fr/auth.php new file mode 100644 index 0000000..371b084 --- /dev/null +++ b/resources/lang/fr/auth.php @@ -0,0 +1,8 @@ + 'Cette combinaison d’identifiants est introuvable dans notre base de données.', + 'not_found' => 'Cette adresse e-mail n’est pas enregistrée.', + 'failed_customer' => 'Cette adresse e-mail est introuvable dans notre base de données.', + 'throttle' => 'Trop de tentatives de connexion. Veuillez réessayer dans :seconds secondes.', +]; diff --git a/resources/lang/fr/backend.php b/resources/lang/fr/backend.php new file mode 100644 index 0000000..c20ceb1 --- /dev/null +++ b/resources/lang/fr/backend.php @@ -0,0 +1,34 @@ + 'Actualités du tableau de bord', + 'add_news' => 'Ajouter une actualité', + 'edit_news' => 'Modifier l’actualité', + 'title' => 'Titre', + 'teaser' => 'Teaser', + 'content' => 'Contenu', + 'status' => 'Statut', + 'active' => 'Actif', + 'inactive' => 'Inactif', + 'created_at' => 'Créé le', + 'actions' => 'Actions', + 'delete' => 'Supprimer', + 'confirm_delete' => 'Supprimer vraiment ?', + 'no_news_yet' => 'Aucune actualité disponible', + 'cancel' => 'Annuler', + 'general_settings' => 'Paramètres généraux', + 'news_active' => 'L’actualité est active', + 'news_active_hint' => 'Une seule actualité active est affichée dans le tableau de bord', + 'news_active_single' => 'Remarque : une seule actualité peut être active à la fois. Lors de l’activation, toutes les autres sont automatiquement désactivées.', + 'german' => 'Allemand', + 'default_language' => 'Langue par défaut', + 'teaser_hint' => 'Texte court directement visible (max. 2-3 phrases)', + 'content_hint' => 'Contenu plus long affiché après "Lire la suite". Les balises HTML sont autorisées (p. ex. , ', + 'business_environment_hl' => 'Environnement professionnel', + 'business_environment_list' => '
      +
    • Cosmétique / cosmétique naturelle
    • +
    • Coiffure
    • +
    • Nutrition
    • +
    • Soin des ongles et des pieds
    • +
    • Naturopathes & médecine alternative
    • +
    • Médecine (peau, physiothérapie, général, etc.)
    • +
    • Autour des animaux
    • +
    • Yoga & méditation
    • +
    • Gastronomie
    • +
    • Sport & fitness
    • +
    • etc.
    • +
    ', + 'social_environment_hl' => 'Dans les réseaux sociaux', + 'social_environment_list' => '
      +
    • Facebook
    • +
    • Youtube
    • +
    • Instagram
    • +
    • Snapchat
    • +
    • Famille
    • +
    • entre autres ...
    • +
    ', + 'direct_sales_advantages_hl' => 'La vente directe et ses avantages', + 'direct_sales_advantages_copy' => '

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

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

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

    +

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

    +

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

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

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

    +

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

    +

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

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

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

    +
    +

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

    +

    Alois Ried - propriétaire MIVITA

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

    Ausführlicher Inhalt für Berater.

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

    Ausführlicher Inhalt für Berater.

    ', + ]); +}); + +it('updates dashboard news json translations when overwrite is enabled', function () { + DB::table('dashboard_news')->insert([ + 'id' => 1, + 'title' => 'Aktualisierte Aktion', + 'teaser' => null, + 'content' => null, + 'trans_title' => json_encode(['fr' => 'Ancien titre']), + 'trans_teaser' => null, + 'trans_content' => null, + 'active' => true, + 'created_at' => now(), + 'updated_at' => now(), + ]); + + $this->artisan('translation:backfill-french-db', [ + '--driver' => 'copy-source', + '--models' => 'dashboard_news', + '--overwrite' => true, + ]) + ->expectsOutput(' - dashboard_news#1.title: gespeichert (aktualisiert).') + ->assertSuccessful(); + + $translations = json_decode(DB::table('dashboard_news')->where('id', 1)->value('trans_title'), true); + + expect($translations['fr'])->toBe('Aktualisierte Aktion'); +}); + +it('prints sample translations for the api test mode', function () { + config()->set('services.openai.api_key', 'test-key'); + config()->set('services.openai.model', 'gpt-5.4-mini'); + + Http::fakeSequence() + ->push(['choices' => [['message' => ['content' => 'Gel d’Aloe Vera pour les soins quotidiens de la peau.']]]]) + ->push(['choices' => [['message' => ['content' => 'Le conseiller peut recommander un abonnement adapté à son client.']]]]) + ->push(['choices' => [['message' => ['content' => 'Description du produit MIVITA avec :amount ml de contenu et paiement PayPal.']]]]); + + $this->artisan('translation:backfill-french-db', [ + '--test-api' => true, + ]) + ->expectsOutput('OpenAI translation API test') + ->expectsOutput('Model: gpt-5.4-mini') + ->expectsOutput('Language: de -> fr') + ->expectsOutput('[1] DE: Aloe Vera Gel für die tägliche Pflege der Haut.') + ->expectsOutput('[1] FR: Gel d’Aloe Vera pour les soins quotidiens de la peau.') + ->expectsOutput('[2] DE: Der Berater kann seinem Kunden ein passendes Abo empfehlen.') + ->expectsOutput('[2] FR: Le conseiller peut recommander un abonnement adapté à son client.') + ->expectsOutput('[3] DE: MIVITA Produktbeschreibung mit :amount ml Inhalt und PayPal Zahlung.') + ->expectsOutput('[3] FR: Description du produit MIVITA avec :amount ml de contenu et paiement PayPal.') + ->expectsOutput('API test completed.') + ->assertSuccessful(); + + Http::assertSentCount(3); +}); + +it('prints a helpful message when openai quota is exceeded', function () { + config()->set('services.openai.api_key', 'test-key'); + config()->set('services.openai.model', 'gpt-5.4-mini'); + + Http::fake([ + '*' => Http::response([ + 'error' => [ + 'message' => 'You exceeded your current quota, please check your plan and billing details.', + 'type' => 'insufficient_quota', + 'code' => 'insufficient_quota', + ], + ], 429), + ]); + + $this->artisan('translation:backfill-french-db', [ + '--test-api' => true, + ]) + ->expectsOutput('OpenAI translation API test') + ->expectsOutput('Model: gpt-5.4-mini') + ->expectsOutput('Language: de -> fr') + ->expectsOutput('OpenAI API request failed with HTTP 429.') + ->expectsOutput('Code: insufficient_quota') + ->expectsOutput('Type: insufficient_quota') + ->expectsOutput('Message: You exceeded your current quota, please check your plan and billing details.') + ->expectsOutput('Bitte prüfe im OpenAI Dashboard das Billing, das Projekt-Budget, Usage-Limits und ob der OPENAI_API_KEY zum richtigen Projekt gehört.') + ->assertFailed(); + + Http::assertSentCount(1); +}); diff --git a/tests/Feature/FrenchLocalizationTest.php b/tests/Feature/FrenchLocalizationTest.php new file mode 100644 index 0000000..438d0cc --- /dev/null +++ b/tests/Feature/FrenchLocalizationTest.php @@ -0,0 +1,48 @@ +toHaveKey('fr') + ->and(LocaleGuard::normalize('FR')) + ->toBe('fr'); +}); + +it('keeps french translation files in sync with the german source keys', function () { + foreach (glob(resource_path('lang/de/*.php')) as $germanFile) { + $fileName = basename($germanFile); + $frenchFile = resource_path('lang/fr/'.$fileName); + + expect($frenchFile)->toBeFile(); + expect(frenchLocalizationFlattenTranslationKeys(require $frenchFile)) + ->toEqualCanonicalizing(frenchLocalizationFlattenTranslationKeys(require $germanFile)); + } +}); + +/** + * @return array + */ +function frenchLocalizationFlattenTranslationKeys(array $translations, string $prefix = ''): array +{ + $keys = []; + + foreach ($translations as $key => $value) { + $translationKey = $prefix.(string) $key; + + if (is_array($value)) { + $keys = [ + ...$keys, + ...frenchLocalizationFlattenTranslationKeys($value, $translationKey.'.'), + ]; + + continue; + } + + $keys[] = $translationKey; + } + + return $keys; +} diff --git a/tests/Unit/Services/LocaleGuardTest.php b/tests/Unit/Services/LocaleGuardTest.php index 358d7da..39d1932 100644 --- a/tests/Unit/Services/LocaleGuardTest.php +++ b/tests/Unit/Services/LocaleGuardTest.php @@ -8,6 +8,7 @@ uses(TestCase::class); it('normalizes supported locales', function () { expect(LocaleGuard::normalize('DE'))->toBe('de'); expect(LocaleGuard::normalize('en'))->toBe('en'); + expect(LocaleGuard::normalize('FR'))->toBe('fr'); }); it('returns null for unsupported or invalid locale strings', function () {