Widerruf: Gesetzliche Online-Widerrufsfunktion (Formular, Bestätigungsmail, Admin-Verwaltung)
- Öffentliches Widerrufsformular /widerruf mit Bestell-Lookup (throttled) - Bestätigungsmail an Kunde + Weiterleitung an bestellungen@ (config app.withdrawal_mail) - Admin-Übersicht/-Detail unter /admin/withdrawals inkl. Sidenav-Eintrag - Widerruf-Button in Portal-Bestellansicht und Footer-Links - Model OrderWithdrawal + Migration + Factory + Tests Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
parent
9dc138e45b
commit
83ef5f1e52
24 changed files with 1607 additions and 92 deletions
187
app/Http/Controllers/Web/WithdrawalController.php
Normal file
187
app/Http/Controllers/Web/WithdrawalController.php
Normal file
|
|
@ -0,0 +1,187 @@
|
|||
<?php
|
||||
|
||||
namespace App\Http\Controllers\Web;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Http\Requests\Withdrawal\WithdrawalLookupRequest;
|
||||
use App\Http\Requests\Withdrawal\WithdrawalStoreRequest;
|
||||
use App\Mail\MailWithdrawalConfirmation;
|
||||
use App\Models\OrderWithdrawal;
|
||||
use App\Models\ShoppingOrder;
|
||||
use App\Services\Shop;
|
||||
use App\Services\Util;
|
||||
use Illuminate\Contracts\View\View;
|
||||
use Illuminate\Http\RedirectResponse;
|
||||
use Illuminate\Support\Facades\Mail;
|
||||
|
||||
class WithdrawalController extends Controller
|
||||
{
|
||||
/**
|
||||
* Bestelltyp einer B2C-Shop-Bestellung (Verbraucher mit Warenkorb/Checkout).
|
||||
*/
|
||||
private const SHOP_PAYMENT_FOR = 6;
|
||||
|
||||
public function create(): View
|
||||
{
|
||||
return view('web.templates.widerruf', $this->viewData([
|
||||
'order' => null,
|
||||
'prefill_order' => request('order'),
|
||||
'prefill_email' => request('email'),
|
||||
]));
|
||||
}
|
||||
|
||||
public function lookup(WithdrawalLookupRequest $request): View|RedirectResponse
|
||||
{
|
||||
$order = $this->findOrder((string) $request->string('order_number'), (string) $request->string('email'));
|
||||
|
||||
if (! $order) {
|
||||
return back()
|
||||
->withInput()
|
||||
->with('alert-error', __('withdrawal.not_found'));
|
||||
}
|
||||
|
||||
return view('web.templates.widerruf', $this->viewData([
|
||||
'order' => $order,
|
||||
'prefill_order' => (string) $request->string('order_number'),
|
||||
'prefill_email' => (string) $request->string('email'),
|
||||
]));
|
||||
}
|
||||
|
||||
public function store(WithdrawalStoreRequest $request): View|RedirectResponse
|
||||
{
|
||||
$order = $this->findOrder((string) $request->string('order_number'), (string) $request->string('email'));
|
||||
|
||||
if (! $order) {
|
||||
return redirect()
|
||||
->route('withdrawal.create')
|
||||
->with('alert-error', __('withdrawal.not_found'));
|
||||
}
|
||||
|
||||
$isPartial = $request->input('scope') === OrderWithdrawal::TYPE_PARTIAL;
|
||||
$selectedItems = $this->collectSelectedItems($order, $isPartial ? (array) $request->input('items', []) : []);
|
||||
|
||||
if ($isPartial && empty($selectedItems)) {
|
||||
return back()
|
||||
->withInput()
|
||||
->with('alert-error', __('withdrawal.no_items_selected'));
|
||||
}
|
||||
|
||||
$withdrawal = OrderWithdrawal::create([
|
||||
'shopping_order_id' => $order->id,
|
||||
'user_shop_id' => $order->user_shop_id,
|
||||
'type' => $isPartial ? OrderWithdrawal::TYPE_PARTIAL : OrderWithdrawal::TYPE_FULL,
|
||||
'status' => OrderWithdrawal::STATUS_RECEIVED,
|
||||
'order_reference' => (string) $order->id,
|
||||
'customer_email' => mb_strtolower((string) $request->string('email')),
|
||||
'customer_name' => trim(($order->shopping_user->billing_firstname ?? '').' '.($order->shopping_user->billing_lastname ?? '')) ?: null,
|
||||
'reason' => $request->input('reason'),
|
||||
'items' => $isPartial ? $selectedItems : $this->collectSelectedItems($order, $this->allItemQuantities($order)),
|
||||
'ip_address' => $request->ip(),
|
||||
'user_agent' => (string) $request->userAgent(),
|
||||
'declared_at' => now(),
|
||||
]);
|
||||
|
||||
$this->sendConfirmation($withdrawal, $order);
|
||||
|
||||
return view('web.templates.widerruf-final', $this->viewData([
|
||||
'withdrawal' => $withdrawal,
|
||||
]));
|
||||
}
|
||||
|
||||
/**
|
||||
* Findet eine Verbraucher-Shop-Bestellung anhand der vom Kunden bekannten Nummer
|
||||
* (Bestell-ID, Zahlungsreferenz oder Rechnungsnummer) in Kombination mit der E-Mail-Adresse.
|
||||
*/
|
||||
private function findOrder(string $orderNumber, string $email): ?ShoppingOrder
|
||||
{
|
||||
$orderNumber = trim($orderNumber);
|
||||
$email = mb_strtolower(trim($email));
|
||||
|
||||
if ($orderNumber === '' || $email === '') {
|
||||
return null;
|
||||
}
|
||||
|
||||
return ShoppingOrder::query()
|
||||
->where('payment_for', self::SHOP_PAYMENT_FOR)
|
||||
->whereHas('shopping_user', function ($query) use ($email): void {
|
||||
$query->whereRaw('LOWER(billing_email) = ?', [$email]);
|
||||
})
|
||||
->where(function ($query) use ($orderNumber): void {
|
||||
if (ctype_digit($orderNumber)) {
|
||||
$query->orWhere('id', (int) $orderNumber);
|
||||
}
|
||||
$query->orWhereHas('shopping_payments', function ($payment) use ($orderNumber): void {
|
||||
$payment->where('reference', $orderNumber);
|
||||
});
|
||||
$query->orWhereHas('user_invoices', function ($invoice) use ($orderNumber): void {
|
||||
$invoice->where('full_number', $orderNumber);
|
||||
});
|
||||
})
|
||||
->with(['shopping_order_items.product', 'shopping_user'])
|
||||
->latest('id')
|
||||
->first();
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<int, int>
|
||||
*/
|
||||
private function allItemQuantities(ShoppingOrder $order): array
|
||||
{
|
||||
return $order->shopping_order_items
|
||||
->mapWithKeys(fn ($item): array => [$item->id => (int) $item->qty])
|
||||
->all();
|
||||
}
|
||||
|
||||
/**
|
||||
* Erstellt einen Snapshot der widerrufenen Positionen.
|
||||
*
|
||||
* @param array<int|string, mixed> $quantities
|
||||
* @return array<int, array{shopping_order_item_id: int, product_id: int|null, name: string, qty: int}>
|
||||
*/
|
||||
private function collectSelectedItems(ShoppingOrder $order, array $quantities): array
|
||||
{
|
||||
$selected = [];
|
||||
|
||||
foreach ($order->shopping_order_items as $item) {
|
||||
$requestedQty = (int) ($quantities[$item->id] ?? 0);
|
||||
|
||||
if ($requestedQty <= 0) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$qty = min($requestedQty, (int) $item->qty);
|
||||
|
||||
$selected[] = [
|
||||
'shopping_order_item_id' => $item->id,
|
||||
'product_id' => $item->product_id,
|
||||
'name' => $item->product?->getLang('name') ?? ('#'.$item->product_id),
|
||||
'qty' => $qty,
|
||||
];
|
||||
}
|
||||
|
||||
return $selected;
|
||||
}
|
||||
|
||||
private function sendConfirmation(OrderWithdrawal $withdrawal, ShoppingOrder $order): void
|
||||
{
|
||||
$bcc = config('app.withdrawal_mail');
|
||||
|
||||
Mail::to($withdrawal->customer_email)
|
||||
->bcc($bcc)
|
||||
->locale($order->getLocale())
|
||||
->send(new MailWithdrawalConfirmation($withdrawal, $order));
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, mixed> $extra
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
private function viewData(array $extra = []): array
|
||||
{
|
||||
return array_merge([
|
||||
'user_shop' => Util::getUserShop(),
|
||||
'mylangs' => Shop::getLangChange('webshop'),
|
||||
'yard_instance' => 'webshop',
|
||||
], $extra);
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue