Abo: Bestellung extern als erledigt markieren (ohne Rechnung/Inkasso)
Neue Admin-Funktion fuer angehaltene Abo-Bestellungen, die der Kunde ausserhalb des Systems geliefert und verrechnet hat. Aktion (AboExternalCompletionService): - Punkte gutschreiben wie bei eingegangener Zahlung (SalesPointsVolume, idempotent gegen Doppelbuchung) - UserAboOrder auf neuen gruenen Status 8 "extern erledigt"; paid bleibt false - Bestellung: shipped=2 (Lieferung bestaetigt), keine Rechnung/kein Lieferschein - Blockade aufheben: Abo zurueck auf status=2 (okay), aktiv, next_date neu, damit der naechste Termin regulaer laeuft - wie bei regulaerer Ausfuehrung: IncentiveTracker + AboOneTimeService-Purge Einstiegspunkte: Button + Bestaetigungs-Modal auf der Kunden-Bestelldetailseite (SalesController) und auf der Abo-Detailseite (Admin\AboController@externDone, Route admin_abos_extern_done). Status 8 (gruen) in UserAbo/UserAboOrder, Labels de/en. Tests: tests/Feature/AboExternalCompletionTest (Happy Path, Idempotenz, bezahlt-/Nicht-Abo-Guards, Abo-Detail-Einstieg) - 5 gruen. Zusaetzlich: docs/2026-06-11-test-suite-vorbestehende-fehler.md - Diagnose der 93 vorbestehenden (von master geerbten) Test-Suite-Fehler. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
parent
8593dbe65d
commit
090debf1b4
13 changed files with 609 additions and 59 deletions
|
|
@ -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()
|
||||
{
|
||||
|
||||
|
|
|
|||
|
|
@ -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.';
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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 '<span class="badge badge-pill badge-'.$this->getStatusColor().'">'.$this->getStatusType().'</span>';
|
||||
}
|
||||
|
||||
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 '<span class="badge badge-pill badge-' . $this->getStatusColor() . '">' . $this->getStatusType() . '</span>';
|
||||
}
|
||||
|
||||
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';
|
||||
}
|
||||
}
|
||||
|
|
|
|||
133
app/Services/AboExternalCompletionService.php
Normal file
133
app/Services/AboExternalCompletionService.php
Normal file
|
|
@ -0,0 +1,133 @@
|
|||
<?php
|
||||
|
||||
namespace App\Services;
|
||||
|
||||
use App\Models\ShoppingOrder;
|
||||
use App\Models\UserAbo;
|
||||
use App\Models\UserAboOrder;
|
||||
use App\Models\UserSalesVolume;
|
||||
use App\Services\BusinessPlan\SalesPointsVolume;
|
||||
use App\Services\Incentive\IncentiveTracker;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
|
||||
/**
|
||||
* Markiert eine angehaltene Abo-Bestellung als "extern erledigt":
|
||||
* Lieferung und Verrechnung erfolgten ausserhalb des Systems.
|
||||
*
|
||||
* Es wird KEINE Rechnung und KEIN Lieferschein erzeugt, der Status bleibt
|
||||
* unbezahlt. Die Abo-Punkte werden gutgeschrieben (als waere die Zahlung
|
||||
* eingegangen) und die Blockade des Abos wird aufgehoben, sodass es beim
|
||||
* naechsten Termin wieder regulaer ausgefuehrt wird.
|
||||
*/
|
||||
class AboExternalCompletionService
|
||||
{
|
||||
public const STATUS_EXTERN_DONE = 8;
|
||||
|
||||
private const STATUS_ABO_HOLD = 3;
|
||||
|
||||
private const STATUS_ABO_OKAY = 2;
|
||||
|
||||
/**
|
||||
* @return array{success: bool, message: string}
|
||||
*/
|
||||
public function complete(ShoppingOrder $shoppingOrder): array
|
||||
{
|
||||
if (! $shoppingOrder->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);
|
||||
}
|
||||
}
|
||||
80
docs/2026-06-11-test-suite-vorbestehende-fehler.md
Normal file
80
docs/2026-06-11-test-suite-vorbestehende-fehler.md
Normal file
|
|
@ -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).
|
||||
|
|
@ -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',
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
|
|
|
|||
|
|
@ -31,6 +31,10 @@
|
|||
data-target="#modal-retry-abo-payment">
|
||||
<span class="fa fa-redo"></span> {{ __('abo.retry_payment') }}
|
||||
</button>
|
||||
<button type="button" class="btn btn-sm btn-success mt-3" data-toggle="modal"
|
||||
data-target="#modal-abo-extern-done">
|
||||
<span class="fa fa-check"></span> {{ __('abo.extern_done') }}
|
||||
</button>
|
||||
@endif
|
||||
</div>
|
||||
<div class="col-md-3 mb-3">
|
||||
|
|
|
|||
|
|
@ -124,6 +124,28 @@
|
|||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="modal fade" id="modal-abo-extern-done" tabindex="-1" role="dialog" aria-labelledby="modal-abo-extern-done-label" aria-hidden="true">
|
||||
<div class="modal-dialog" role="document">
|
||||
<div class="modal-content">
|
||||
{!! Form::open(['action' => route('admin_abos_extern_done', [$user_abo->id])]) !!}
|
||||
<div class="modal-header">
|
||||
<h5 class="modal-title" id="modal-abo-extern-done-label">{{ __('abo.extern_done_confirm_title') }}</h5>
|
||||
<button type="button" class="close" data-dismiss="modal" aria-label="Close">
|
||||
<span aria-hidden="true">×</span>
|
||||
</button>
|
||||
</div>
|
||||
<div class="modal-body">
|
||||
<div class="alert alert-warning">{{ __('abo.extern_done_confirm_copy') }}</div>
|
||||
</div>
|
||||
<div class="modal-footer">
|
||||
<button type="button" class="btn btn-default" data-dismiss="modal">{{ __('close') }}</button>
|
||||
<button type="submit" class="btn btn-success">{{ __('abo.extern_done_confirm_button') }}</button>
|
||||
</div>
|
||||
{!! Form::close() !!}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@endif
|
||||
@endsection
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
<div class="card">
|
||||
<!-- Status -->
|
||||
<div class="card-body">
|
||||
|
|
@ -38,6 +45,23 @@
|
|||
@endif
|
||||
</div>
|
||||
</div>
|
||||
@if ($isAdmin && $aboOrder)
|
||||
<div class="col-md-3 mb-1">
|
||||
<strong class="mr-2">{{ __('navigation.abo') }}:</strong>
|
||||
<div class="d-flex flex-wrap align-items-center mt-1" style="gap: 6px;">
|
||||
@if ($isExternDone)
|
||||
<span class="badge badge-pill badge-success">
|
||||
<span class="fa fa-check"></span> {{ __('abo.abo_extern_done') }}
|
||||
</span>
|
||||
@elseif ($canExternDone)
|
||||
<button type="button" class="btn btn-sm btn-success" data-toggle="modal"
|
||||
data-target="#modals-abo-extern-done">
|
||||
<span class="fa fa-check"></span> {{ __('abo.extern_done') }}
|
||||
</button>
|
||||
@endif
|
||||
</div>
|
||||
</div>
|
||||
@endif
|
||||
<div class="col-md-4 mb-2">
|
||||
<strong class="mr-2">{{ __('order.invoice') }}:</strong>
|
||||
<div class="d-flex flex-wrap align-items-center mt-1" style="gap: 6px;">
|
||||
|
|
@ -732,6 +756,30 @@
|
|||
</div>
|
||||
</div>
|
||||
|
||||
@if ($isAdmin && $canExternDone)
|
||||
<div class="modal fade" id="modals-abo-extern-done">
|
||||
<div class="modal-dialog">
|
||||
<form class="modal-content" action="{{ route('admin_sales_customers_detail', [$shopping_order->id]) }}"
|
||||
method="post">
|
||||
@csrf
|
||||
<input type="hidden" name="id" value="{{ $shopping_order->id }}">
|
||||
<input type="hidden" name="action" value="shopping-order-abo-extern-done">
|
||||
<div class="modal-header">
|
||||
<h5 class="modal-title">{{ __('abo.extern_done_confirm_title') }}</h5>
|
||||
<button type="button" class="close" data-dismiss="modal" aria-label="Close">×</button>
|
||||
</div>
|
||||
<div class="modal-body">
|
||||
<div class="alert alert-warning">{{ __('abo.extern_done_confirm_copy') }}</div>
|
||||
</div>
|
||||
<div class="modal-footer">
|
||||
<button type="button" class="btn btn-default" data-dismiss="modal">{{ __('close') }}</button>
|
||||
<button type="submit" class="btn btn-success">{{ __('abo.extern_done_confirm_button') }}</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
@endif
|
||||
|
||||
@if ($isAdmin)
|
||||
<div class="modal fade" id="modals-shipped">
|
||||
<div class="modal-dialog">
|
||||
|
|
|
|||
|
|
@ -361,6 +361,7 @@ Route::domain(config('app.pre_url_crm').config('app.domain').config('app.tld_car
|
|||
Route::post('/admin/abos/update/{id}', 'Admin\AboController@update')->name('admin_abos_update');
|
||||
Route::post('/admin/abos/rollback/{id}', 'Admin\AboController@rollback')->name('admin_abos_rollback');
|
||||
Route::post('/admin/abos/retry-payment/{id}', 'Admin\AboController@retryPayment')->name('admin_abos_retry_payment');
|
||||
Route::post('/admin/abos/extern-done/{id}', 'Admin\AboController@externDone')->name('admin_abos_extern_done');
|
||||
Route::get('/admin/abos/datatable', 'Admin\AboController@datatable')->name('admin_abos_datatable');
|
||||
|
||||
// incentives
|
||||
|
|
|
|||
219
tests/Feature/AboExternalCompletionTest.php
Normal file
219
tests/Feature/AboExternalCompletionTest.php
Normal file
|
|
@ -0,0 +1,219 @@
|
|||
<?php
|
||||
|
||||
use App\Models\Country;
|
||||
use App\Models\Incentive;
|
||||
use App\Models\IncentiveNewAbo;
|
||||
use App\Models\ShoppingOrder;
|
||||
use App\Models\ShoppingUser;
|
||||
use App\Models\UserAbo;
|
||||
use App\Models\UserAboOrder;
|
||||
use App\Models\UserAccount;
|
||||
use App\Models\UserSalesVolume;
|
||||
use App\Services\AboExternalCompletionService;
|
||||
use App\User;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
use Tests\TestCase;
|
||||
|
||||
uses(TestCase::class, RefreshDatabase::class);
|
||||
|
||||
function heldAboSeed(): array
|
||||
{
|
||||
$country = Country::create([
|
||||
'code' => 'DE',
|
||||
'phone' => '49',
|
||||
'en' => 'Germany',
|
||||
'de' => 'Deutschland',
|
||||
'es' => 'Alemania',
|
||||
'fr' => 'Allemagne',
|
||||
'it' => 'Germania',
|
||||
'ru' => 'Германия',
|
||||
'eu_country' => true,
|
||||
]);
|
||||
|
||||
$account = UserAccount::create([
|
||||
'first_name' => 'Klaus',
|
||||
'last_name' => 'Kunde',
|
||||
'address' => 'Hauptstrasse 1',
|
||||
'zipcode' => '10115',
|
||||
'city' => 'Berlin',
|
||||
'country_id' => $country->id,
|
||||
]);
|
||||
|
||||
$user = User::forceCreate([
|
||||
'account_id' => $account->id,
|
||||
'email' => 'klaus@example.com',
|
||||
'password' => bcrypt('secret'),
|
||||
'lang' => 'de',
|
||||
]);
|
||||
|
||||
$shoppingUser = ShoppingUser::create([
|
||||
'auth_user_id' => $user->id,
|
||||
'billing_firstname' => 'Klaus',
|
||||
'billing_lastname' => 'Kunde',
|
||||
'billing_address' => 'Hauptstrasse 1',
|
||||
'billing_zipcode' => '10115',
|
||||
'billing_city' => 'Berlin',
|
||||
'billing_country_id' => $country->id,
|
||||
'billing_email' => $user->email,
|
||||
'same_as_billing' => true,
|
||||
'shipping_firstname' => 'Klaus',
|
||||
'shipping_lastname' => 'Kunde',
|
||||
'shipping_address' => 'Hauptstrasse 1',
|
||||
'shipping_zipcode' => '10115',
|
||||
'shipping_city' => 'Berlin',
|
||||
'shipping_country_id' => $country->id,
|
||||
'is_for' => 'me',
|
||||
'is_from' => 'user_order',
|
||||
'mode' => 'live',
|
||||
]);
|
||||
|
||||
$userAbo = UserAbo::create([
|
||||
'user_id' => $user->id,
|
||||
'shopping_user_id' => $shoppingUser->id,
|
||||
'is_for' => 'me',
|
||||
'email' => $user->email,
|
||||
'payone_userid' => 654321,
|
||||
'clearingtype' => 'cc',
|
||||
'active' => true,
|
||||
'status' => 3, // abo_hold (Blockade)
|
||||
'abo_interval' => 5,
|
||||
'next_date' => now()->subDays(2), // ueberfaellig
|
||||
]);
|
||||
|
||||
$order = ShoppingOrder::create([
|
||||
'shopping_user_id' => $shoppingUser->id,
|
||||
'auth_user_id' => $user->id,
|
||||
'country_id' => $country->id,
|
||||
'language' => 'de',
|
||||
'user_shop_id' => 1,
|
||||
'payment_for' => 6,
|
||||
'total' => 50.00,
|
||||
'subtotal' => 50.00,
|
||||
'points' => 25,
|
||||
'paid' => false,
|
||||
'shipped' => 0,
|
||||
'is_abo' => true,
|
||||
'abo_interval' => 5,
|
||||
'txaction' => 'error',
|
||||
'mode' => 'live',
|
||||
]);
|
||||
|
||||
$aboOrder = UserAboOrder::create([
|
||||
'user_abo_id' => $userAbo->id,
|
||||
'shopping_order_id' => $order->id,
|
||||
'status' => 3, // abo_hold
|
||||
'paid' => false,
|
||||
]);
|
||||
|
||||
return [$userAbo, $order, $aboOrder, $user];
|
||||
}
|
||||
|
||||
it('markiert eine angehaltene Abo-Bestellung als extern erledigt, schreibt Punkte gut und hebt die Blockade auf', function () {
|
||||
[$userAbo, $order, $aboOrder, $user] = heldAboSeed();
|
||||
|
||||
$result = app(AboExternalCompletionService::class)->complete($order);
|
||||
|
||||
expect($result['success'])->toBeTrue();
|
||||
|
||||
$order->refresh();
|
||||
$aboOrder->refresh();
|
||||
$userAbo->refresh();
|
||||
|
||||
// Bestellung bleibt unbezahlt, keine Rechnung
|
||||
expect((bool) $order->paid)->toBeFalse()
|
||||
->and((int) $order->shipped)->toBe(2)
|
||||
->and($order->isInvoice())->toBeFalse();
|
||||
|
||||
// Abo-Bestellung: gruener Status 8, weiterhin unbezahlt
|
||||
expect((int) $aboOrder->status)->toBe(8)
|
||||
->and((bool) $aboOrder->paid)->toBeFalse();
|
||||
|
||||
// Blockade aufgehoben: zurueck auf okay, aktiv, next_date in der Zukunft
|
||||
expect((int) $userAbo->status)->toBe(2)
|
||||
->and($userAbo->active)->toBeTrue();
|
||||
expect(\Carbon\Carbon::parse($userAbo->getRawOriginal('next_date'))->isFuture())->toBeTrue();
|
||||
|
||||
// Punkte gutgeschrieben - mit heutigem Datum (Markierungsdatum), nicht dem
|
||||
// vergangenen Bestelldatum, damit sie in der laufenden Monatsauswertung landen.
|
||||
$volume = UserSalesVolume::where('shopping_order_id', $order->id)->first();
|
||||
expect($volume)->not->toBeNull()
|
||||
->and((float) $volume->points)->toBe(25.0)
|
||||
->and((int) $volume->user_id)->toBe($user->id)
|
||||
->and((int) $volume->month)->toBe((int) date('m'))
|
||||
->and((int) $volume->year)->toBe((int) date('Y'))
|
||||
->and($volume->date)->toBe(date('d.m.Y'));
|
||||
});
|
||||
|
||||
it('trackt Incentive-Punkte mit dem Bestelldatum (Ausfuehrungsmonat), nicht mit heute', function () {
|
||||
\Carbon\Carbon::setTestNow('2026-06-15');
|
||||
|
||||
[$userAbo, $order, , $user] = heldAboSeed();
|
||||
|
||||
// Die vorgesehene Ausfuehrung lag im April; das Incentive-Fenster deckt nur
|
||||
// April ab (nicht Juni/heute). Wuerde der Tracker "heute" verwenden, faende
|
||||
// er das Incentive nicht und legte keinen IncentiveNewAbo-Eintrag an.
|
||||
$executionDate = \Carbon\Carbon::parse('2026-04-10');
|
||||
ShoppingOrder::where('id', $order->id)->update(['created_at' => $executionDate]);
|
||||
|
||||
Incentive::factory()->active()->create([
|
||||
'qualification_start' => '2026-04-01',
|
||||
'qualification_end' => '2026-04-30',
|
||||
'calculation_end' => '2026-05-31',
|
||||
]);
|
||||
|
||||
app(AboExternalCompletionService::class)->complete($order->fresh());
|
||||
|
||||
// Incentive-Abo wurde dem April-Fenster zugeordnet, activated_at = Bestelldatum.
|
||||
$newAbo = IncentiveNewAbo::where('user_abo_id', $userAbo->id)->first();
|
||||
expect($newAbo)->not->toBeNull()
|
||||
->and($newAbo->activated_at->toDateString())->toBe('2026-04-10');
|
||||
|
||||
// Reguläre Provisionspunkte hingegen mit heutigem Datum (Juni).
|
||||
$volume = UserSalesVolume::where('shopping_order_id', $order->id)->first();
|
||||
expect((int) $volume->month)->toBe(6)
|
||||
->and((int) $volume->year)->toBe(2026);
|
||||
|
||||
\Carbon\Carbon::setTestNow();
|
||||
});
|
||||
|
||||
it('ist idempotent und schreibt Punkte nicht doppelt gut', function () {
|
||||
[, $order] = heldAboSeed();
|
||||
|
||||
$service = app(AboExternalCompletionService::class);
|
||||
$first = $service->complete($order);
|
||||
$second = $service->complete($order->fresh());
|
||||
|
||||
expect($first['success'])->toBeTrue()
|
||||
->and($second['success'])->toBeFalse();
|
||||
|
||||
expect(UserSalesVolume::where('shopping_order_id', $order->id)->count())->toBe(1);
|
||||
});
|
||||
|
||||
it('verweigert die Aktion fuer eine bereits bezahlte Abo-Bestellung', function () {
|
||||
[, $order, $aboOrder] = heldAboSeed();
|
||||
$aboOrder->update(['paid' => true]);
|
||||
|
||||
$result = app(AboExternalCompletionService::class)->complete($order->fresh());
|
||||
|
||||
expect($result['success'])->toBeFalse();
|
||||
expect(UserSalesVolume::where('shopping_order_id', $order->id)->exists())->toBeFalse();
|
||||
});
|
||||
|
||||
it('verweigert die Aktion fuer eine Nicht-Abo-Bestellung', function () {
|
||||
[, $order] = heldAboSeed();
|
||||
$order->update(['is_abo' => false]);
|
||||
|
||||
$result = app(AboExternalCompletionService::class)->complete($order->fresh());
|
||||
|
||||
expect($result['success'])->toBeFalse();
|
||||
});
|
||||
|
||||
it('erledigt ueber die Abo-Detailseite die juengste angehaltene Bestellung', function () {
|
||||
[$userAbo, $order] = heldAboSeed();
|
||||
|
||||
$result = app(AboExternalCompletionService::class)->completeForAbo($userAbo);
|
||||
|
||||
expect($result['success'])->toBeTrue();
|
||||
expect((int) UserAboOrder::where('shopping_order_id', $order->id)->value('status'))->toBe(8);
|
||||
expect((int) $userAbo->fresh()->status)->toBe(2);
|
||||
});
|
||||
Loading…
Add table
Add a link
Reference in a new issue