diff --git a/app/Http/Controllers/Admin/AboController.php b/app/Http/Controllers/Admin/AboController.php index a8714bad..fe25c9df 100644 --- a/app/Http/Controllers/Admin/AboController.php +++ b/app/Http/Controllers/Admin/AboController.php @@ -5,6 +5,7 @@ namespace App\Http\Controllers\Admin; use App\Http\Controllers\Controller; use App\Models\UserAbo; use App\Repositories\AboRepository; +use App\Services\AboExternalCompletionService; use App\Services\AboItemHistoryService; use App\Services\AboOrderCart; use App\Services\AboRetryPaymentService; @@ -113,6 +114,16 @@ class AboController extends Controller return redirect(route('admin_abos_detail', [$id])); } + public function externDone($id, AboExternalCompletionService $externalCompletion) + { + $user_abo = UserAbo::findOrFail($id); + $result = $externalCompletion->completeForAbo($user_abo); + + \Session()->flash($result['success'] ? 'alert-success' : 'alert-error', $result['message']); + + return redirect(route('admin_abos_detail', [$id])); + } + public function datatable() { diff --git a/app/Http/Controllers/SalesController.php b/app/Http/Controllers/SalesController.php index 6606eac7..a44e40a3 100644 --- a/app/Http/Controllers/SalesController.php +++ b/app/Http/Controllers/SalesController.php @@ -8,6 +8,7 @@ use App\Models\ShoppingPayment; use App\Models\ShoppingUser; use App\Models\UserShop; use App\Repositories\InvoiceRepository; +use App\Services\AboExternalCompletionService; use App\Services\BusinessPlan\SalesPointsVolume; use App\Services\CustomerPriority; use App\Services\Payment; @@ -186,10 +187,17 @@ class SalesController extends Controller return view('admin.sales.customer_detail', $data); } - public function customersStore($id) + public function customersStore($id, AboExternalCompletionService $aboExternalCompletion) { $data = Request::all(); $change_member_error = false; + if (($data['action'] ?? null) === 'shopping-order-abo-extern-done') { + $shopping_order = ShoppingOrder::findOrFail($data['id']); + $result = $aboExternalCompletion->complete($shopping_order); + \Session()->flash($result['success'] ? 'alert-success' : 'alert-error', $result['message']); + + return redirect(route('admin_sales_customers_detail', [$shopping_order->id])); + } if ($data['action'] === 'shopping-order-change-member') { if (! isset($data['change_member_key']) || $data['change_member_key'] !== config('mivita.edit_data_pass')) { $change_member_error = 'Das Passwort ist falsch.'; diff --git a/app/Models/UserAbo.php b/app/Models/UserAbo.php index 7e82e7d9..78385f5f 100644 --- a/app/Models/UserAbo.php +++ b/app/Models/UserAbo.php @@ -136,6 +136,7 @@ class UserAbo extends Model 5 => 'abo_finish', 6 => 'abo_inactive', 7 => 'abo_grace', + 8 => 'abo_extern_done', ]; public static $statusColors = [ @@ -147,6 +148,7 @@ class UserAbo extends Model 5 => 'info', 6 => 'warning', 7 => 'danger', + 8 => 'success', ]; public function user() diff --git a/app/Models/UserAboOrder.php b/app/Models/UserAboOrder.php index 16382623..1e4915fa 100644 --- a/app/Models/UserAboOrder.php +++ b/app/Models/UserAboOrder.php @@ -6,7 +6,6 @@ namespace App\Models; -use App\User; use Carbon\Carbon; use Illuminate\Database\Eloquent\Model; @@ -21,7 +20,7 @@ use Illuminate\Database\Eloquent\Model; * @property Carbon|null $updated_at * @property ShoppingOrder $shopping_order * @property UserAbo $user_abo - * @package App\Models + * * @method static \Illuminate\Database\Eloquent\Builder|UserAboOrder newModelQuery() * @method static \Illuminate\Database\Eloquent\Builder|UserAboOrder newQuery() * @method static \Illuminate\Database\Eloquent\Builder|UserAboOrder query() @@ -31,73 +30,74 @@ use Illuminate\Database\Eloquent\Model; * @method static \Illuminate\Database\Eloquent\Builder|UserAboOrder whereStatus($value) * @method static \Illuminate\Database\Eloquent\Builder|UserAboOrder whereUpdatedAt($value) * @method static \Illuminate\Database\Eloquent\Builder|UserAboOrder whereUserAboId($value) + * * @mixin \Eloquent */ class UserAboOrder extends Model { - protected $table = 'user_abo_orders'; + protected $table = 'user_abo_orders'; - protected $casts = [ - 'user_abo_id' => 'int', - 'shopping_order_id' => 'int', - 'status' => 'int', - 'paid' => 'bool' - ]; + protected $casts = [ + 'user_abo_id' => 'int', + 'shopping_order_id' => 'int', + 'status' => 'int', + 'paid' => 'bool', + ]; - protected $fillable = [ - 'user_abo_id', - 'user_id', - 'shopping_order_id', - 'status', - 'paid' - ]; + protected $fillable = [ + 'user_abo_id', + 'user_id', + 'shopping_order_id', + 'status', + 'paid', + ]; - public static $statusTypes = [ - 0 => 'abo_new', - 1 => 'abo_new', - 2 => 'abo_okay', - 3 => 'abo_hold', - 4 => 'abo_cancel', - 5 => 'abo_finish', - 6 => 'abo_inactive', - 7 => 'abo_grace' - ]; + public static $statusTypes = [ + 0 => 'abo_new', + 1 => 'abo_new', + 2 => 'abo_okay', + 3 => 'abo_hold', + 4 => 'abo_cancel', + 5 => 'abo_finish', + 6 => 'abo_inactive', + 7 => 'abo_grace', + 8 => 'abo_extern_done', + ]; - public static $statusColors = [ - 0 => 'success', - 1 => 'success', - 2 => 'secondary', - 3 => 'warning', - 4 => 'danger', - 5 => 'info', - 6 => 'warning', - 7 => 'danger' - ]; + public static $statusColors = [ + 0 => 'success', + 1 => 'success', + 2 => 'secondary', + 3 => 'warning', + 4 => 'danger', + 5 => 'info', + 6 => 'warning', + 7 => 'danger', + 8 => 'success', + ]; + public function shopping_order() + { + return $this->belongsTo(ShoppingOrder::class); + } + public function user_abo() + { + return $this->belongsTo(UserAbo::class); + } - public function shopping_order() - { - return $this->belongsTo(ShoppingOrder::class); - } + public function getStatusFormated() + { + return ''.$this->getStatusType().''; + } - public function user_abo() - { - return $this->belongsTo(UserAbo::class); - } + public function getStatusType() + { + return isset(self::$statusTypes[$this->status]) ? __('abo.'.self::$statusTypes[$this->status]) : ''; + } - public function getStatusFormated() - { - return '' . $this->getStatusType() . ''; - } - - public function getStatusType() - { - return isset(self::$statusTypes[$this->status]) ? __('abo.' . self::$statusTypes[$this->status]) : ""; - } - - public function getStatusColor() - { - return isset(self::$statusColors[$this->status]) ? self::$statusColors[$this->status] : "default"; - } + public function getStatusColor() + { + return isset(self::$statusColors[$this->status]) ? self::$statusColors[$this->status] : 'default'; + } } diff --git a/app/Services/AboExternalCompletionService.php b/app/Services/AboExternalCompletionService.php new file mode 100644 index 00000000..26e7f973 --- /dev/null +++ b/app/Services/AboExternalCompletionService.php @@ -0,0 +1,133 @@ +is_abo) { + return ['success' => false, 'message' => __('abo.extern_done_only_abo')]; + } + + $userAboOrder = UserAboOrder::where('shopping_order_id', $shoppingOrder->id)->first(); + $userAbo = $userAboOrder?->user_abo; + + if (! $userAboOrder || ! $userAbo) { + return ['success' => false, 'message' => __('abo.extern_done_no_order')]; + } + + if ((int) $userAboOrder->status === self::STATUS_EXTERN_DONE) { + return ['success' => false, 'message' => __('abo.extern_done_already')]; + } + + if ($userAboOrder->paid || $shoppingOrder->paid) { + return ['success' => false, 'message' => __('abo.extern_done_already_paid')]; + } + + try { + DB::transaction(function () use ($shoppingOrder, $userAboOrder, $userAbo): void { + $this->creditPoints($shoppingOrder); + + $userAboOrder->update([ + 'status' => self::STATUS_EXTERN_DONE, + 'paid' => false, + ]); + + // Lieferung bestaetigen, Bestellung bleibt aber unbezahlt. + $shoppingOrder->shipped = 2; + $shoppingOrder->save(); + + // Blockade aufheben: Abo zurueck in den regulaeren Zyklus. + $userAbo->update([ + 'status' => self::STATUS_ABO_OKAY, + 'active' => true, + 'last_date' => now(), + 'next_date' => AboHelper::setNextDate(now(), $userAbo->abo_interval), + ]); + }); + + // Wie bei einer erfolgreichen Ausfuehrung: Incentive tracken und + // Einmal-Artikel entfernen. Beide Aufrufe sind idempotent. + IncentiveTracker::trackAboActivated($shoppingOrder); + AboOneTimeService::purgeAfterExecution($userAbo); + + \Log::channel('abo_order')->info('AboExternalCompletionService: Bestellung extern erledigt', [ + 'abo_id' => $userAbo->id, + 'order_id' => $shoppingOrder->id, + ]); + } catch (\Throwable $e) { + \Log::channel('abo_order')->error('AboExternalCompletionService: Fehler beim externen Erledigen', [ + 'order_id' => $shoppingOrder->id, + 'error' => $e->getMessage(), + ]); + + return ['success' => false, 'message' => __('abo.extern_done_error', ['error' => $e->getMessage()])]; + } + + return ['success' => true, 'message' => __('abo.extern_done_success')]; + } + + /** + * Erledigt die juengste angehaltene Bestellung eines Abos (Einstieg ueber die Abo-Detailseite). + * + * @return array{success: bool, message: string} + */ + public function completeForAbo(UserAbo $userAbo): array + { + $shoppingOrder = $this->resolveHeldOrder($userAbo); + + if (! $shoppingOrder) { + return ['success' => false, 'message' => __('abo.extern_done_no_order')]; + } + + return $this->complete($shoppingOrder); + } + + private function resolveHeldOrder(UserAbo $userAbo): ?ShoppingOrder + { + return $userAbo->user_abo_orders() + ->where('paid', false) + ->where('status', self::STATUS_ABO_HOLD) + ->orderByDesc('id') + ->first() + ?->shopping_order; + } + + private function creditPoints(ShoppingOrder $shoppingOrder): void + { + $alreadyCredited = UserSalesVolume::where('shopping_order_id', $shoppingOrder->id)->exists(); + + if ($alreadyCredited) { + return; + } + + SalesPointsVolume::User($shoppingOrder); + } +} diff --git a/docs/2026-06-11-test-suite-vorbestehende-fehler.md b/docs/2026-06-11-test-suite-vorbestehende-fehler.md new file mode 100644 index 00000000..45613807 --- /dev/null +++ b/docs/2026-06-11-test-suite-vorbestehende-fehler.md @@ -0,0 +1,80 @@ +# Vorbestehende Test-Suite-Fehler (Diagnose) + +**Datum:** 2026-06-11 +**Branch bei Aufnahme:** `payment/phase-r-payone-refactoring` +**Status:** Nur dokumentiert — keine Codeänderung vorgenommen. + +## Ausgangslage + +`php artisan test --compact` meldet aktuell **93 failed, 1 skipped, 448 passed**. +Diese Fehler sind **nicht** durch die Abo-„Extern erledigt"-Funktion oder durch +diesen Branch verursacht. + +### Beweis, dass die Fehler von `master` geerbt sind + +- Der einzige test-relevante Commit dieses Branches (`8593dbe`, Payment Phase R) + ändert an `phpunit.xml` nur **eine** Zeile: `PAYONE_KEY_HASH` (DB-Config + unberührt). +- `DB_DATABASE=":memory:"` mit `force="true"` steht bereits auf `master`. +- Legacy-Factory `database/factories/UserFactory.php` (`$factory->define`) und alle + betroffenen Testdateien existieren unverändert bereits auf `master`. +- Gegenprobe per `git stash`: dasselbe Test-Subset scheitert mit und ohne die + Änderungen dieses Branches identisch. + +## Vier unabhängige Ursachen + +### 1. Legacy-Factories + fehlendes `HasFactory` (~20 Tests) +- **Symptom:** `Call to undefined method App\User::factory()` +- **Ursache:** `App\User` nutzt das `HasFactory`-Trait nicht; `UserFactory.php` ist + im alten `$factory->define()`-Stil (Laravel ≤6) statt klassenbasiert + (`extends Factory`). Die Tests rufen die neue API `User::factory()`. +- **Betroffen:** `Unit/BusinessPlan/BusinessUserItemOptimizedTest`, + `Unit/BusinessPlan/BusinessUserRepositoryTest`, Teile von + `Unit/Incentive/IncentiveTrackerTest`. +- **Fix (sicher, hoher Hebel):** `App\User` → `use HasFactory` + `newFactory()` + (nötig, weil das Model unter `App\` statt `App\Models\` liegt); + `UserFactory` auf `class UserFactory extends Factory { protected $model = \App\User::class; … }` + umstellen. + +### 2. `DatabaseTransactions` auf `:memory:`-SQLite (~32 Tests) +- **Symptom:** `SQLSTATE … no such table: settings` (o. ä.) +- **Ursache:** Das Trait `DatabaseTransactions` führt **keine** Migrationen aus, + sondern setzt ein bereits migriertes Schema voraus. Bei `:memory:` startet jede + Connection leer → Tabellen fehlen. (`RefreshDatabase`-Tests migrieren und laufen + daher grün.) +- **Betroffen:** `Unit/Services/InvoiceServiceTest`, + `Feature/Payment/ConcurrentPaymentTest`, `Feature/Payment/PayoneRaceConditionTest`. +- **Fix (Entscheidung nötig):** entweder Trait auf `RefreshDatabase` umstellen, + **oder** Test-DB-Strategie ändern (persistente, vorab migrierte SQLite-Datei + statt `:memory:`). Architekturentscheidung — Race-Condition-/Concurrency-Tests + können bewusst eine persistente DB erwarten. + +### 3. Domain-aware Routen in Unit-Tests nicht geladen (~10 Tests) +- **Symptom:** `Route [modal_load] not defined.` +- **Ursache:** Die Anwendung lädt Routen host-abhängig (siehe CLAUDE.md, + Multi-Tenant Routing). In Unit-Tests ohne Host sind die `routes/domains/*`-Routen + nicht registriert; `TreeHtmlRenderer` erzeugt aber HTML mit `route('modal_load')`. +- **Betroffen:** `Unit/BusinessPlan/TreeHtmlRendererTest`. +- **Fix (mittel):** Routen im Test-Setup laden bzw. den Renderer von der + Route-Generierung entkoppeln (URL injizieren). + +### 4. Tests veraltet ggü. refaktoriertem Konstruktor (~19 Tests) +- **Symptom:** + `TreeCalcBotOptimized::__construct(): Argument #4 ($forceLiveCalculation) must be of type bool, …BusinessUserRepository given` +- **Ursache:** Die Konstruktor-Signatur von + `App\Services\BusinessPlan\TreeCalcBotOptimized` wurde geändert; der Test + (`TreeCalcBotOptimizedTest`) übergibt die Argumente noch in alter Reihenfolge. +- **Betroffen:** `Unit/BusinessPlan/TreeCalcBotOptimizedTest`. +- **Fix (mittel, reine Teständerung):** Konstruktor-Aufrufe im Test an die aktuelle + Signatur anpassen. + +### Rest +Einzelne Folgefehler/Sonderfälle: `Feature/ExampleTest`, +`Feature/FrenchLocalizationTest`, `Feature/AboOneTimeWindowTest`, +`Feature/Incentive/IncentiveTeaserPageTest`. + +## Empfehlung + +Eigenständige Aufräum-Aufgabe in separatem Branch, **nicht** in den Payment-Branch +mischen. Sinnvolle Reihenfolge nach Risiko/Nutzen: **1 → 4 → 3 → 2** +(Ursache 2 zuletzt, da Architekturentscheidung zur Test-DB nötig). diff --git a/resources/lang/de/abo.php b/resources/lang/de/abo.php index 18b4e712..332ff83f 100644 --- a/resources/lang/de/abo.php +++ b/resources/lang/de/abo.php @@ -54,6 +54,7 @@ return [ 'abo_finish' => 'beendet', 'abo_inactive' => 'inaktiv', 'abo_grace' => 'kulanz', + 'abo_extern_done' => 'extern erledigt', 'abo_info' => 'Abo Informationen', 'info_min_duration_reached' => 'Dein Abo kann frühestens ab dem :date geändert, ergänzt, pausiert oder gekündigt werden.', 'info_min_duration_orders_left' => 'Das Abo kann erst nach weiteren :count Ausführungen geändert, ergänzt, pausiert oder gekündigt werden.', @@ -162,6 +163,16 @@ return [ 'retry_success' => 'Der erneute Zahlungsversuch war erfolgreich. Bestellung #:order wurde angelegt.', 'retry_failed' => 'Der erneute Zahlungsversuch ist fehlgeschlagen. Bestellung #:order wurde angelegt. Fehler: :error', 'retry_exception' => 'Der erneute Zahlungsversuch konnte nicht abgeschlossen werden: :error', + 'extern_done' => 'Extern erledigt', + 'extern_done_confirm_title' => 'Bestellung extern als erledigt markieren', + 'extern_done_confirm_copy' => 'Die Bestellung wird als außerhalb des Systems geliefert und verrechnet markiert. Es wird KEINE Rechnung und KEIN Lieferschein erzeugt, der Status bleibt unbezahlt. Die Abo-Punkte werden gutgeschrieben und die Blockade des Abos wird aufgehoben, sodass es beim nächsten Termin wieder normal läuft.', + 'extern_done_confirm_button' => 'Als erledigt markieren', + 'extern_done_only_abo' => 'Diese Aktion ist nur für Abo-Bestellungen möglich.', + 'extern_done_no_order' => 'Es wurde keine angehaltene Abo-Bestellung gefunden, die erledigt werden kann.', + 'extern_done_already' => 'Diese Bestellung wurde bereits als extern erledigt markiert.', + 'extern_done_already_paid' => 'Diese Bestellung ist bereits bezahlt und kann nicht extern erledigt werden.', + 'extern_done_success' => 'Die Bestellung wurde als extern erledigt markiert. Punkte gutgeschrieben, Blockade aufgehoben.', + 'extern_done_error' => 'Die Bestellung konnte nicht als erledigt markiert werden: :error', 'team_subscriptions' => 'Team Abos', 'team_customer_abos' => 'Team Kunden-Abos', 'chart_monthly_abos' => 'Abos pro Monat', diff --git a/resources/lang/en/abo.php b/resources/lang/en/abo.php index 1fbcf024..b728525c 100644 --- a/resources/lang/en/abo.php +++ b/resources/lang/en/abo.php @@ -45,6 +45,7 @@ return [ 'abo_finish' => 'finished', 'abo_inactive' => 'inactive', 'abo_grace' => 'grace', + 'abo_extern_done' => 'externally completed', 'abo_info' => 'Subscription information', 'info_min_duration_reached' => 'Your subscription can be changed, supplemented, paused or canceled earliest from :date.', 'info_min_duration_orders_left' => 'The subscription can only be changed, supplemented, paused or canceled after another :count executions.', @@ -162,6 +163,16 @@ return [ 'retry_success' => 'The payment retry was successful. Order #:order was created.', 'retry_failed' => 'The payment retry failed. Order #:order was created. Error: :error', 'retry_exception' => 'The payment retry could not be completed: :error', + 'extern_done' => 'Externally completed', + 'extern_done_confirm_title' => 'Mark order as externally completed', + 'extern_done_confirm_copy' => 'The order will be marked as delivered and invoiced outside the system. NO invoice and NO delivery note will be created and the status stays unpaid. The subscription points will be credited and the subscription block will be lifted so it runs normally at the next date.', + 'extern_done_confirm_button' => 'Mark as completed', + 'extern_done_only_abo' => 'This action is only available for subscription orders.', + 'extern_done_no_order' => 'No held subscription order was found that can be completed.', + 'extern_done_already' => 'This order has already been marked as externally completed.', + 'extern_done_already_paid' => 'This order is already paid and cannot be completed externally.', + 'extern_done_success' => 'The order was marked as externally completed. Points credited, block lifted.', + 'extern_done_error' => 'The order could not be marked as completed: :error', 'back' => 'back', 'team_subscriptions' => 'Team subscriptions', 'team_customer_abos' => 'Team Customer Subscriptions', diff --git a/resources/views/admin/abo/_detail.blade.php b/resources/views/admin/abo/_detail.blade.php index c4a74b4c..acef9a2a 100644 --- a/resources/views/admin/abo/_detail.blade.php +++ b/resources/views/admin/abo/_detail.blade.php @@ -31,6 +31,10 @@ data-target="#modal-retry-abo-payment"> {{ __('abo.retry_payment') }} + @endif
diff --git a/resources/views/admin/abo/detail.blade.php b/resources/views/admin/abo/detail.blade.php index c6a01825..fb7e6981 100644 --- a/resources/views/admin/abo/detail.blade.php +++ b/resources/views/admin/abo/detail.blade.php @@ -124,6 +124,28 @@
+ + @endif @endsection diff --git a/resources/views/admin/sales/_detail.blade.php b/resources/views/admin/sales/_detail.blade.php index 4ee2346e..4de054d5 100644 --- a/resources/views/admin/sales/_detail.blade.php +++ b/resources/views/admin/sales/_detail.blade.php @@ -1,3 +1,10 @@ +@php + $aboOrder = $shopping_order->is_abo + ? \App\Models\UserAboOrder::where('shopping_order_id', $shopping_order->id)->first() + : null; + $isExternDone = $aboOrder && (int) $aboOrder->status === \App\Services\AboExternalCompletionService::STATUS_EXTERN_DONE; + $canExternDone = $aboOrder && ! $isExternDone && ! $aboOrder->paid && ! $shopping_order->paid; +@endphp
@@ -38,6 +45,23 @@ @endif
+ @if ($isAdmin && $aboOrder) +
+ {{ __('navigation.abo') }}: +
+ @if ($isExternDone) + + {{ __('abo.abo_extern_done') }} + + @elseif ($canExternDone) + + @endif +
+
+ @endif
{{ __('order.invoice') }}:
@@ -732,6 +756,30 @@
+@if ($isAdmin && $canExternDone) + +@endif + @if ($isAdmin)