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:
Kevin 2026-06-11 15:40:16 +00:00
parent 8593dbe65d
commit 090debf1b4
13 changed files with 609 additions and 59 deletions

View file

@ -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()
{

View file

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

View file

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

View file

@ -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';
}
}

View 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);
}
}