72 lines
3.0 KiB
PHP
72 lines
3.0 KiB
PHP
<?php
|
|
|
|
namespace Modules\Core\Catalog\Services;
|
|
|
|
use Illuminate\Support\Facades\DB;
|
|
use Lunar\Models\Order;
|
|
use Lunar\Models\Product;
|
|
use Lunar\Models\ProductVariant;
|
|
|
|
/**
|
|
* The one place ProductVariant::stock is written as a result of an order —
|
|
* previously this lived entirely inside Modules\Core\Order\Listeners\
|
|
* DecrementStockOnOrderPlaced, a listener with no Service behind it at
|
|
* all, even though stock (the column, its invariants — "never negative",
|
|
* "only in_stock variants") is fundamentally a Catalog concern, not an
|
|
* Order one. That listener is now a thin caller of this class, matching
|
|
* how every other module's event reaction delegates its actual write to
|
|
* a Service (e.g. Modules\Core\Order\Listeners\RecordPaymentTransaction
|
|
* -> Modules\Core\Order\Services\TransactionRecorder).
|
|
*
|
|
* Only decrements for `purchasable === 'in_stock'` variants — 'always' and
|
|
* 'backorder' variants are deliberately allowed to sell past (or without
|
|
* regard to) their stock count already (see ProductVariant::
|
|
* canBeFulfilledAtQuantity()), so decrementing their stock would just make
|
|
* that column an inaccurate, decreasingly-negative number with no purchasing
|
|
* consequence. Only `OrderLine::type === 'physical'` lines are considered —
|
|
* a digital line has no stock to decrement (ProductVariant::getType()).
|
|
*
|
|
* A single UPDATE per variant (`DB::table(...)->update()` with a raw
|
|
* expression), not a read-then-write on the Eloquent model — avoids a
|
|
* lost-update race between two orders decrementing the same variant
|
|
* concurrently, and skips Modules\Core\Catalog\Services\ProductIndexer::
|
|
* stock's staleness gap for the DB value itself even though the search
|
|
* index still only refreshes on the next reindex event/nightly job (see
|
|
* that class's own docblock).
|
|
*
|
|
* Never lets stock go negative (`GREATEST(stock - qty, 0)` via a raw
|
|
* expression) — an order can still be placed against a variant whose stock
|
|
* was already fully consumed by another concurrent order (Lunar has no
|
|
* stock-reservation step at cart/checkout time), so this is a best-effort
|
|
* count, not a hard inventory guarantee.
|
|
*/
|
|
class StockService
|
|
{
|
|
public function decrementForOrder(Order $order): void
|
|
{
|
|
$lines = $order->lines()
|
|
->where('type', 'physical')
|
|
->where('purchasable_type', ProductVariant::morphName())
|
|
->get(['purchasable_id', 'quantity']);
|
|
|
|
if ($lines->isEmpty()) {
|
|
return;
|
|
}
|
|
|
|
foreach ($lines as $line) {
|
|
DB::table((new ProductVariant())->getTable())
|
|
->where('id', $line->purchasable_id)
|
|
->where('purchasable', 'in_stock')
|
|
->update([
|
|
'stock' => DB::raw('GREATEST(stock - '.(int) $line->quantity.', 0)'),
|
|
]);
|
|
}
|
|
|
|
$productIds = ProductVariant::whereIn('id', $lines->pluck('purchasable_id'))
|
|
->pluck('product_id')
|
|
->unique();
|
|
|
|
Product::whereIn('id', $productIds)->get()->each->searchable();
|
|
}
|
|
}
|