mivita/app/Services/AboExternalCompletionService.php
Kevin f6faad9ae7 Abo: extern-erledigt für gelöschte Abos + Lieferhistorie-Umzug (Ticket Kunz Mai-Punkte)
- AboExternalCompletionService: Bestellungen gelöschter/ersetzter Abos können
  nachträglich extern erledigt werden (Punkte + Status 8 + Versandstatus);
  das gelöschte Abo wird dabei nicht reaktiviert
- IncentiveTracker::trackSalesVolume: Abo-Zuordnung inkl. gelöschter Abos
  (withTrashed) - nachgebuchte Umsätze bereits getrackter Abos zählen weiter
- Neue Wartungs-Commands: abo:extern-done {shopping_order_id} und
  abo:move-orders {from_abo} {to_abo} (--dry-run, Guards: gleicher Kunde)
- Latent datumsabhängigen Alt-Test in AboExternalCompletionTest repariert
  (Buchung nutzt date(), nicht Carbon::setTestNow)

Fall Kunz (dev/2026-08-12-intensiv-tickets): Dannenhauers gelieferte, aber am
17.06. beim Nachtragen vergessene Mai-Order 53274 → abo:extern-done (+54 P);
Bockhackers Lieferhistorie von gelöschtem Abo 197 → 496 umgehängt (Zähler 4).
Bockhackers 24 P existieren bereits (Juni-Buchung der Mai-Lieferung).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-21 12:06:29 +00:00

141 lines
5.1 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();
// Auch geloeschte Abos zulassen: eine ausstehende Lieferung eines ersetzten/
// geloeschten Abos muss nachtraeglich erledigt werden koennen (Punkte + Zaehler).
$userAbo = $userAboOrder ? $userAboOrder->user_abo()->withTrashed()->first() : null;
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.
// Geloeschte (ersetzte) Abos bleiben unangetastet - dort werden nur
// Punkte und Lieferstatus nachgezogen, kein neuer Zyklus gestartet.
if (! $userAbo->trashed()) {
$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);
if (! $userAbo->trashed()) {
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);
}
}