Files
core/src/Cart/Commands/DetectAbandonedCarts.php
T

85 lines
3.3 KiB
PHP

<?php
namespace Modules\Core\Cart\Commands;
use Illuminate\Console\Command;
use Illuminate\Support\Facades\Event;
use Lunar\Models\Cart;
use Modules\Core\Cart\Filament\Resources\CartResource;
use Modules\Core\Recovery\Events\CartAbandoned;
use Modules\Core\Recovery\Events\CheckoutAbandoned;
/**
* "Abandoned" is a derived state (Cart::updated_at older than
* config('core.cart.abandoned_after')) — nothing transitions a cart into it
* via a normal Eloquent write, so there's no model-event hook to dispatch
* CartAbandoned/CheckoutAbandoned from directly. This command is the only
* place that moment gets detected; run it on a schedule (see docs/cart.md).
*
* Splits Cart::scopeActive()'s two branches into their own events —
* see CartAbandoned/CheckoutAbandoned's docblocks for why they're distinct,
* not one combined "abandoned" state: a cart with no order at all is a much
* weaker purchase-intent signal than one with a draft order that was never
* placed.
*
* Deliberately does NOT write anything to Cart/Order — dispatch only. An
* earlier version recorded an "already notified" marker on Cart::meta/
* Order::meta, but that write bumped updated_at as an Eloquent side effect,
* which un-staled the very cart being marked abandoned (the same field
* abandonment staleness is computed from) — see docs/cart.md's former
* "Known bug" note. Cart/Checkout must have no way of writing abandonment
* state at all; every cart still matching the query below refires its event
* on every run until Recovery (not yet built — see
* docs/recovery-strategies.md) owns its own dedup/tracking table.
*/
class DetectAbandonedCarts extends Command
{
protected $signature = 'boboko:cart:detect-abandoned';
protected $description = 'Dispatch CartAbandoned/CheckoutAbandoned for carts that just crossed the abandonment threshold.';
public function handle(): void
{
$cutoff = CartResource::abandonedCutoff();
$cartsAbandoned = 0;
$checkoutsAbandoned = 0;
Cart::query()
->whereDoesntHave('orders')
->where('updated_at', '<=', $cutoff)
->with('lines')
->chunkById(200, function ($carts) use (&$cartsAbandoned) {
foreach ($carts as $cart) {
if ($cart->lines->isEmpty()) {
continue;
}
Event::dispatch(new CartAbandoned($cart));
$cartsAbandoned++;
}
});
Cart::query()
->whereHas('orders', fn ($query) => $query->whereNull('placed_at'))
->where('updated_at', '<=', $cutoff)
->with(['orders' => fn ($query) => $query->whereNull('placed_at')])
->chunkById(200, function ($carts) use (&$checkoutsAbandoned) {
foreach ($carts as $cart) {
$order = $cart->orders->first();
if ($order === null) {
continue;
}
Event::dispatch(new CheckoutAbandoned($cart, $order));
$checkoutsAbandoned++;
}
});
$this->components->info("Dispatched CartAbandoned for {$cartsAbandoned} cart(s), CheckoutAbandoned for {$checkoutsAbandoned} checkout(s).");
}
}