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
ccc6f85809
commit
4709dfde55
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);
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue