mivita/app/Services/AboOneTimeService.php
Kevin ee04146217 Abo Einmalprodukte: Phase 4 - Ausfuehrung, Purge & User-Retry
- UserMakeOrder: bestaetigte Einmal-Artikel in den Yard, is_abo_addon
  auf ShoppingOrderItem; amount bleibt reiner Abo-Betrag (Reihenfolge)
- AboOneTimeService::purgeAfterExecution: loescht alle Einmal-Artikel
  und rechnet Comp-Produkte neu - nur im Erfolgszweig (Cron + Retry)
- User-Retry in Sales Center und Portal mit Berechtigungspruefung,
  gemeinsames Confirm-Modal; Admin-Retry unveraendert
- Tests: AboMakeOrderOneTimeTest, AboUserRetryTest; Plan-Doku Phase 4

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-08 15:32:27 +00:00

253 lines
8.4 KiB
PHP
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

<?php
namespace App\Services;
use App\Models\Product;
use App\Models\UserAbo;
use App\Models\UserAboOneTimeItem;
class AboOneTimeService
{
/**
* Verarbeitet eine Einmal-Artikel-Aktion (add/update/remove) für ein Abo.
*
* Erwartet einen bereits initialisierten Yard (für die Snapshot-Berechnung beim
* Hinzufügen). Gibt eine Fehlermeldung zurück oder null bei Erfolg.
*
* @param array<string, mixed> $data
*/
public function handleAction(UserAbo $userAbo, array $data): ?string
{
$action = $data['action'] ?? null;
return match ($action) {
'add' => $this->add($userAbo, (int) ($data['product_id'] ?? 0)),
'update' => $this->update($userAbo, (int) ($data['one_time_item_id'] ?? 0), (int) ($data['qty'] ?? 1)),
'remove' => $this->remove($userAbo, (int) ($data['one_time_item_id'] ?? 0)),
'confirm' => $this->confirm($userAbo),
'discard' => $this->discard($userAbo),
default => __('abo.onetime_action_invalid'),
};
}
private function add(UserAbo $userAbo, int $productId): ?string
{
$product = Product::find($productId);
$allowedIds = ProductOrderContext::allowedShowOnIds(false, $userAbo->is_for === 'me' ? 'me' : 'ot-customer');
if (! $product || ! $product->active || ! ProductOrderContext::productMatchesShowOn($product, $allowedIds)) {
return __('abo.onetime_product_not_allowed');
}
if (AboOrderCart::exceedsMaxWeight($userAbo, (int) $product->weight)) {
return __('msg.cart_max_weight_reached');
}
$existing = UserAboOneTimeItem::withTrashed()
->where('user_abo_id', $userAbo->id)
->where('product_id', $product->id)
->first();
if ($existing) {
if ($existing->trashed()) {
$existing->restore();
$existing->qty = $existing->confirmed_at ? max(1, (int) $existing->confirmed_qty) + 1 : 1;
} else {
$existing->qty = min(100, $existing->qty + 1);
}
$existing->qty = min(100, $existing->qty);
$existing->status = 0;
$existing->save();
return null;
}
$snapshot = AboOrderCart::buildOneTimeSnapshot($product, $userAbo);
UserAboOneTimeItem::create(array_merge($snapshot, [
'user_abo_id' => $userAbo->id,
'product_id' => $product->id,
'comp' => 0,
'qty' => 1,
'status' => 0,
]));
return null;
}
private function update(UserAbo $userAbo, int $itemId, int $qty): ?string
{
$item = UserAboOneTimeItem::where('user_abo_id', $userAbo->id)->find($itemId);
if (! $item) {
return __('abo.abo_item_not_found');
}
$targetQty = max(1, min(100, $qty));
$product = Product::find($item->product_id);
if ($product) {
$additionalWeight = (int) $product->weight * ($targetQty - (int) $item->qty);
if ($additionalWeight > 0 && AboOrderCart::exceedsMaxWeight($userAbo, $additionalWeight)) {
return __('msg.cart_max_weight_reached');
}
}
$item->qty = $targetQty;
$item->status = $item->isConfirmed() ? 1 : 0;
$item->save();
return null;
}
private function remove(UserAbo $userAbo, int $itemId): ?string
{
$item = UserAboOneTimeItem::where('user_abo_id', $userAbo->id)->find($itemId);
if (! $item) {
return __('abo.abo_item_not_found');
}
if ($item->confirmed_at) {
$item->delete();
} else {
$item->forceDelete();
}
return null;
}
private function confirm(UserAbo $userAbo): ?string
{
UserAboOneTimeItem::withTrashed()
->where('user_abo_id', $userAbo->id)
->get()
->each(function (UserAboOneTimeItem $item): void {
if ($item->trashed()) {
$item->forceDelete();
return;
}
$item->confirmed_qty = $item->qty;
$item->confirmed_at = now();
$item->status = 1;
$item->save();
});
return null;
}
private function discard(UserAbo $userAbo): ?string
{
UserAboOneTimeItem::withTrashed()
->where('user_abo_id', $userAbo->id)
->get()
->each(function (UserAboOneTimeItem $item): void {
if (! $item->confirmed_at) {
$item->forceDelete();
return;
}
if ($item->trashed()) {
$item->restore();
}
$item->qty = max(1, (int) $item->confirmed_qty);
$item->status = 1;
$item->save();
});
return null;
}
/**
* Verbindlich bestätigte Einmal-Artikel (werden mit der nächsten Lieferung versandt).
*
* @return \Illuminate\Support\Collection<int, UserAboOneTimeItem>
*/
public static function confirmedItems(UserAbo $userAbo): \Illuminate\Support\Collection
{
return $userAbo->one_time_items()
->with('product')
->get()
->filter(fn (UserAboOneTimeItem $item): bool => $item->isConfirmed())
->values();
}
/**
* Noch nicht bestätigte Einmal-Artikel (Entwurf, nicht verbindlich).
*
* @return \Illuminate\Support\Collection<int, UserAboOneTimeItem>
*/
public static function pendingItems(UserAbo $userAbo): \Illuminate\Support\Collection
{
return $userAbo->one_time_items()
->with('product')
->get()
->reject(fn (UserAboOneTimeItem $item): bool => $item->isConfirmed())
->values();
}
public static function pendingGross(UserAbo $userAbo): float
{
return round(self::pendingItems($userAbo)
->sum(fn (UserAboOneTimeItem $item): float => (float) $item->price * (int) $item->qty), 2);
}
/**
* Entfernt nach einer erfolgreichen Ausführung ALLE Einmal-Artikel des Abos
* (bestätigte und offene Entwürfe, inkl. soft-deleted) und bewertet anschließend
* die Kompensationsprodukte des reinen Abos neu (das durch Einmal-Artikel
* verursachte Zusatzgewicht ist nun weg → überzählige Comp-Artikel werden entfernt).
*
* Wird ausschließlich im Erfolgszweig aufgerufen. Bei Zahlungsfehler bleiben die
* Einmal-Artikel erhalten (Entscheidung #2/#3). Schlägt etwas fehl, wird der bereits
* erfolgreiche Abo-Lauf NICHT zurückgerollt Fehler werden nur protokolliert.
*/
public static function purgeAfterExecution(UserAbo $userAbo): void
{
try {
UserAboOneTimeItem::withTrashed()
->where('user_abo_id', $userAbo->id)
->get()
->each(fn (UserAboOneTimeItem $item) => $item->forceDelete());
} catch (\Throwable $e) {
\Log::channel('abo_order')->error('AboOneTimeService::purgeAfterExecution: Löschen der Einmal-Artikel fehlgeschlagen', [
'abo_id' => $userAbo->id,
'error' => $e->getMessage(),
]);
return;
}
try {
AboOrderCart::initYard($userAbo);
AboOrderCart::makeOrderYard($userAbo);
AboOrderCart::checkNumOfCompProducts($userAbo);
} catch (\Throwable $e) {
\Log::channel('abo_order')->warning('AboOneTimeService::purgeAfterExecution: Comp-Neuberechnung übersprungen', [
'abo_id' => $userAbo->id,
'error' => $e->getMessage(),
]);
}
}
public static function hasUnconfirmedChanges(UserAbo $userAbo): bool
{
return UserAboOneTimeItem::withTrashed()
->where('user_abo_id', $userAbo->id)
->get()
->contains(function (UserAboOneTimeItem $item): bool {
if ($item->trashed()) {
return $item->confirmed_at !== null;
}
return ! $item->isConfirmed();
});
}
public static function hasConfirmedItems(UserAbo $userAbo): bool
{
return UserAboOneTimeItem::where('user_abo_id', $userAbo->id)
->whereNotNull('confirmed_at')
->exists();
}
}