- 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>
253 lines
8.4 KiB
PHP
253 lines
8.4 KiB
PHP
<?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();
|
||
}
|
||
}
|