mivita/app/Services/AboExternalCompletionService.php
Kevin 4709dfde55 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>
2026-06-12 08:11:45 +00:00

133 lines
4.5 KiB
PHP

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