90 lines
3.9 KiB
PHP
90 lines
3.9 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\Services\CartLifecycleService;
|
|
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.
|
|
*
|
|
* Both queries require meta->recovery_consent = true — CartAbandoned/
|
|
* CheckoutAbandoned exist specifically to drive future recovery-email
|
|
* sends (Checkout\Services\CheckoutService::setRecoveryConsent() is where
|
|
* that consent is actually recorded), and a non-consenting cart's
|
|
* abandonment must never be dispatched at all, not merely filtered later
|
|
* at send time — see docs referenced above for the legal reasoning. This
|
|
* consent filter stays here rather than on Modules\Core\Cart\Services\
|
|
* CartLifecycleService, whose two "abandoned" queries this command builds
|
|
* on — dispatch eligibility is this command's own concern, not part of
|
|
* what "abandoned" means to a staff member browsing the admin panel. (The
|
|
* non-empty-lines requirement, by contrast, IS part of what "abandoned"
|
|
* means either way, so it lives on CartLifecycleService::abandonedCarts()
|
|
* itself, not here.)
|
|
*/
|
|
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(CartLifecycleService $lifecycle): void
|
|
{
|
|
$cartsAbandoned = 0;
|
|
$checkoutsAbandoned = 0;
|
|
|
|
$lifecycle->abandonedCarts(Cart::query())
|
|
->where('meta->recovery_consent', true)
|
|
->chunkById(200, function ($carts) use (&$cartsAbandoned) {
|
|
foreach ($carts as $cart) {
|
|
Event::dispatch(new CartAbandoned($cart));
|
|
|
|
$cartsAbandoned++;
|
|
}
|
|
});
|
|
|
|
$lifecycle->abandonedCheckouts(Cart::query())
|
|
->where('meta->recovery_consent', true)
|
|
->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).");
|
|
}
|
|
}
|