186 lines
6.2 KiB
PHP
186 lines
6.2 KiB
PHP
<?php
|
|
|
|
namespace App\Domains\Catalog\Services;
|
|
|
|
use App\Domains\Cart\Models\Cart;
|
|
use App\Domains\Catalog\Models\StockReservation;
|
|
use App\Domains\Purchase\Models\Purchase;
|
|
use App\Domains\Purchase\Services\Checkout\ReleaseCheckoutService;
|
|
use Illuminate\Support\Facades\DB;
|
|
use Illuminate\Support\Facades\Log;
|
|
use RuntimeException;
|
|
use Throwable;
|
|
|
|
class ExpireStockReservationsService
|
|
{
|
|
private const BATCH_SIZE = 500;
|
|
|
|
public function __construct(
|
|
private readonly ReleaseCheckoutService $purchases,
|
|
private readonly StockReservationService $reservations,
|
|
) {}
|
|
|
|
/**
|
|
* @return array{purchases: int, cart_reservations: int, orphan_reservations: int, failed: int}
|
|
*/
|
|
public function expireOverdue(): array
|
|
{
|
|
$summary = [
|
|
'purchases' => 0,
|
|
'cart_reservations' => 0,
|
|
'orphan_reservations' => 0,
|
|
'failed' => 0,
|
|
];
|
|
$lastReservationId = 0;
|
|
|
|
do {
|
|
$reservationIds = StockReservation::query()
|
|
->where('status', StockReservation::STATUS_ACTIVE)
|
|
->whereNotNull('expires_at')
|
|
->where('expires_at', '<=', now())
|
|
->where('id', '>', $lastReservationId)
|
|
->orderBy('id')
|
|
->limit(self::BATCH_SIZE)
|
|
->pluck('id');
|
|
|
|
foreach ($reservationIds as $reservationId) {
|
|
$lastReservationId = (int) $reservationId;
|
|
|
|
try {
|
|
$owner = $this->expireReservation($lastReservationId);
|
|
if ($owner !== null) {
|
|
$summary[$owner]++;
|
|
}
|
|
} catch (Throwable $exception) {
|
|
$summary['failed']++;
|
|
Log::channel('commands')->error('Failed to expire overdue stock reservation.', [
|
|
'command' => 'reservations:expire',
|
|
'stock_reservation_id' => $lastReservationId,
|
|
'exception' => $exception,
|
|
]);
|
|
}
|
|
}
|
|
} while ($reservationIds->count() === self::BATCH_SIZE);
|
|
|
|
Log::channel('commands')->info('Stock reservation cleanup completed.', [
|
|
'command' => 'reservations:expire',
|
|
'expired_purchases' => $summary['purchases'],
|
|
'expired_cart_reservations' => $summary['cart_reservations'],
|
|
'expired_orphan_reservations' => $summary['orphan_reservations'],
|
|
'failed_reservations' => $summary['failed'],
|
|
'total_expired' => $summary['purchases']
|
|
+ $summary['cart_reservations']
|
|
+ $summary['orphan_reservations'],
|
|
]);
|
|
|
|
return $summary;
|
|
}
|
|
|
|
public function expireIfOverdue(int $reservationId): bool
|
|
{
|
|
/** @var StockReservation|null $reservation */
|
|
$reservation = StockReservation::query()->find($reservationId);
|
|
if ($reservation?->status === StockReservation::STATUS_EXPIRED) {
|
|
return true;
|
|
}
|
|
if (! $this->isOverdue($reservation)) {
|
|
return false;
|
|
}
|
|
|
|
return $this->expireReservation($reservationId) !== null;
|
|
}
|
|
|
|
/** @return 'purchases'|'cart_reservations'|'orphan_reservations'|null */
|
|
private function expireReservation(int $reservationId): ?string
|
|
{
|
|
$purchaseId = Purchase::query()
|
|
->where('stock_reservation_id', $reservationId)
|
|
->value('id');
|
|
if ($purchaseId !== null) {
|
|
return $this->expirePurchase((int) $purchaseId);
|
|
}
|
|
|
|
$cartId = Cart::query()
|
|
->where('current_stock_reservation_id', $reservationId)
|
|
->where('status', 'active')
|
|
->value('id');
|
|
if ($cartId !== null) {
|
|
return $this->expireCart((int) $cartId, $reservationId);
|
|
}
|
|
|
|
return $this->expireOrphan($reservationId);
|
|
}
|
|
|
|
/** @return 'purchases'|null */
|
|
private function expirePurchase(int $purchaseId): ?string
|
|
{
|
|
/** @var Purchase|null $purchase */
|
|
$purchase = Purchase::query()->find($purchaseId);
|
|
if ($purchase === null) {
|
|
return null;
|
|
}
|
|
|
|
$purchase = $this->purchases->expire($purchase);
|
|
if ($purchase->status === Purchase::STATUS_EXPIRED) {
|
|
return 'purchases';
|
|
}
|
|
|
|
$reservation = $purchase->stockReservation;
|
|
if ($this->isOverdue($reservation)) {
|
|
throw new RuntimeException('An overdue active reservation belongs to a purchase that cannot expire.');
|
|
}
|
|
|
|
return null;
|
|
}
|
|
|
|
/** @return 'cart_reservations'|null */
|
|
private function expireCart(int $cartId, int $reservationId): ?string
|
|
{
|
|
return DB::transaction(function () use ($cartId, $reservationId): ?string {
|
|
/** @var Cart|null $cart */
|
|
$cart = Cart::query()
|
|
->whereKey($cartId)
|
|
->where('current_stock_reservation_id', $reservationId)
|
|
->where('status', Cart::STATUS_ACTIVE)
|
|
->lockForUpdate()
|
|
->first();
|
|
if ($cart === null) {
|
|
return null;
|
|
}
|
|
|
|
/** @var StockReservation|null $reservation */
|
|
$reservation = StockReservation::query()->lockForUpdate()->find($reservationId);
|
|
if (! $this->isOverdue($reservation)) {
|
|
return null;
|
|
}
|
|
|
|
$this->reservations->expire($reservation);
|
|
$cart->update(['status' => Cart::STATUS_EXPIRED]);
|
|
|
|
return 'cart_reservations';
|
|
});
|
|
}
|
|
|
|
/** @return 'orphan_reservations'|null */
|
|
private function expireOrphan(int $reservationId): ?string
|
|
{
|
|
/** @var StockReservation|null $reservation */
|
|
$reservation = StockReservation::query()->find($reservationId);
|
|
if (! $this->isOverdue($reservation)) {
|
|
return null;
|
|
}
|
|
|
|
$this->reservations->expire($reservation);
|
|
|
|
return 'orphan_reservations';
|
|
}
|
|
|
|
private function isOverdue(?StockReservation $reservation): bool
|
|
{
|
|
return $reservation !== null
|
|
&& $reservation->status === StockReservation::STATUS_ACTIVE
|
|
&& $reservation->expires_at !== null
|
|
&& ! $reservation->expires_at->isFuture();
|
|
}
|
|
}
|