<?php
namespace App\Services;
use App\Exceptions\InsufficientStockException;
use App\Exceptions\StockException;
use App\Models\Order;
use App\Models\Product;
use App\Models\Setting;
use App\Models\StockMovement;
use App\Models\StockReservation;
use App\Models\VariantStockMovement;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Log;
use Illuminate\Support\Str;
class InventoryService
{
protected int $defaultReservationTtl = 900;
public function checkAvailability(int $productId, int $quantity): bool
{
$product = Product::find($productId);
if (!$product) {
return false;
}
$availableStock = $this->getAvailableStock($productId);
return $availableStock >= $quantity;
}
public function getAvailableStock(int $productId): int
{
$product = Product::find($productId);
if (!$product) {
return 0;
}
$reservedQuantity = StockReservation::where('product_id', $productId)
->pending()
->sum('quantity');
$currentStock = $product->current_stock;
return max(0, $currentStock - $reservedQuantity);
}
public function reserve(int $productId, int $quantity, ?int $ttl = null, ?int $orderId = null): string
{
if ($ttl === null) {
$configured = Setting::get('stock_reservation_ttl_seconds');
$ttl = $configured !== '' && $configured !== null ? (int) $configured : $this->defaultReservationTtl;
$ttl = max(60, min(3600, $ttl));
}
return DB::transaction(function () use ($productId, $quantity, $ttl, $orderId) {
$product = Product::lockForUpdate()->find($productId);
if (!$product) {
throw new StockException("Product not found: {$productId}");
}
$reservedQuantity = StockReservation::where('product_id', $productId)
->pending()
->sum('quantity');
$currentStock = $product->current_stock;
$availableStock = max(0, $currentStock - $reservedQuantity);
if ($availableStock < $quantity) {
throw new InsufficientStockException(
$product->name,
$quantity,
$availableStock
);
}
$reservation = StockReservation::create([
'product_id' => $productId,
'order_id' => $orderId,
'quantity' => $quantity,
'reservation_token' => Str::random(64),
'expires_at' => now()->addSeconds($ttl),
'status' => 'pending',
]);
Log::info('Stock reserved', [
'product_id' => $productId,
'quantity' => $quantity,
'reservation_token' => $reservation->reservation_token,
'expires_at' => $reservation->expires_at,
]);
return $reservation->reservation_token;
});
}
public function confirmReservation(string $reservationToken): bool
{
return DB::transaction(function () use ($reservationToken) {
$reservation = StockReservation::where('reservation_token', $reservationToken)
->where('status', 'pending')
->lockForUpdate()
->first();
if (!$reservation) {
throw new StockException("Reservation not found or already processed: {$reservationToken}");
}
if ($reservation->isExpired()) {
$reservation->update(['status' => 'expired']);
throw new StockException("Reservation expired: {$reservationToken}");
}
$product = Product::lockForUpdate()->find($reservation->product_id);
$currentStock = $product->current_stock;
if (!$product || $currentStock < $reservation->quantity) {
$reservation->update(['status' => 'released']);
throw new InsufficientStockException(
$product->name ?? 'Unknown',
$reservation->quantity,
$currentStock ?? 0
);
}
$notes = $reservation->order_id !== null
? "Order #{$reservation->order_id}"
: "Reservation confirmed: {$reservationToken}";
if ($product->isVariant()) {
\App\Models\VariantStockMovement::create([
'product_id' => $product->id,
'type' => \App\Models\VariantStockMovement::TYPE_SALE,
'quantity' => -$reservation->quantity,
'notes' => $notes,
'created_by' => 1,
]);
} else {
\App\Models\StockMovement::create([
'product_id' => $product->id,
'type' => \App\Models\StockMovement::TYPE_SALE,
'quantity' => -$reservation->quantity,
'notes' => $notes,
'created_by' => 1,
]);
}
$product->recalculateStock();
$reservation->update(['status' => 'confirmed']);
Log::info('Stock reservation confirmed and decremented', [
'reservation_token' => $reservationToken,
'product_id' => $reservation->product_id,
'quantity' => $reservation->quantity,
]);
return true;
});
}
public function release(string $reservationToken): bool
{
$reservation = StockReservation::where('reservation_token', $reservationToken)
->where('status', 'pending')
->first();
if (!$reservation) {
return false;
}
$reservation->update(['status' => 'released']);
Log::info('Stock reservation released', [
'reservation_token' => $reservationToken,
'product_id' => $reservation->product_id,
'quantity' => $reservation->quantity,
]);
return true;
}
public function releaseByOrderId(int $orderId, bool $cancelAwaitingPayment = false): int
{
$locationInventory = app(LocationInventoryService::class);
if ($locationInventory->useLocationInventory()) {
$count = $locationInventory->releaseByOrderId($orderId);
if ($cancelAwaitingPayment) {
$this->cancelAwaitingPaymentOrder($orderId, 'reservation_released');
}
return $count;
}
$count = StockReservation::where('order_id', $orderId)
->where('status', 'pending')
->update(['status' => 'released']);
if ($count > 0) {
Log::info('Stock reservations released by order ID', [
'order_id' => $orderId,
'count' => $count,
]);
}
if ($cancelAwaitingPayment) {
$this->cancelAwaitingPaymentOrder($orderId, 'reservation_released');
}
return $count;
}
public function decrement(int $productId, int $quantity): bool
{
return DB::transaction(function () use ($productId, $quantity) {
$product = Product::lockForUpdate()->find($productId);
if (!$product) {
throw new StockException("Product not found: {$productId}");
}
$availableStock = $this->getAvailableStock($productId);
if ($availableStock < $quantity) {
throw new InsufficientStockException(
$product->name,
$quantity,
$availableStock
);
}
$currentStock = $product->current_stock;
if ($currentStock < $quantity) {
throw new InsufficientStockException(
$product->name,
$quantity,
$currentStock
);
}
\App\Models\StockMovement::create([
'product_id' => $productId,
'type' => \App\Models\StockMovement::TYPE_SALE,
'quantity' => -$quantity,
'notes' => 'Direct stock decrement',
'created_by' => 1,
]);
$product->recalculateStock();
$affected = 1;
if ($affected > 0) {
Log::info('Inventory decremented', [
'product_id' => $productId,
'quantity' => $quantity,
]);
return true;
}
throw new InsufficientStockException(
$product->name,
$quantity,
$product->current_stock
);
});
}
public function increment(int $productId, int $quantity): void
{
$product = Product::find($productId);
if ($product) {
\App\Models\StockMovement::create([
'product_id' => $productId,
'type' => \App\Models\StockMovement::TYPE_RESTOCK,
'quantity' => $quantity,
'notes' => 'Direct stock increment',
'created_by' => 1,
]);
$product->recalculateStock();
}
Log::info('Inventory incremented', [
'product_id' => $productId,
'quantity' => $quantity,
]);
}
public function handleOrderStatusChange(Order $order, string $previousStatus, string $newStatus): void
{
if ($previousStatus === $newStatus) {
return;
}
if ($newStatus === Order::STATUS_CANCELLED) {
if ($previousStatus === Order::STATUS_AWAITING_PAYMENT) {
$this->releaseByOrderId($order->id);
return;
}
if ($this->shouldRestockOnCancel($previousStatus)) {
$this->restockOrder($order, 'cancelled');
}
return;
}
if (
$newStatus === Order::STATUS_RETURNED
&& in_array($previousStatus, [Order::STATUS_SHIPPED, Order::STATUS_DELIVERED], true)
) {
$this->restockOrder($order, 'returned');
}
}
public function orderHasPendingRestock(Order $order): bool
{
$order->loadMissing('items');
foreach ($order->items as $item) {
if (!$item->product_id) {
continue;
}
$soldByTable = $this->getSoldQuantitiesByTable($item->product_id, $order->id);
$restockedByTable = $this->getRestockedQuantitiesByTable($item->product_id, $order->id);
$qtyToRestock = max(0, $soldByTable['stock'] - $restockedByTable['stock'])
+ max(0, $soldByTable['variant'] - $restockedByTable['variant']);
if ($qtyToRestock > 0) {
return true;
}
}
return false;
}
public function restockOrder(Order $order, string $reason): void
{
$locationInventory = app(LocationInventoryService::class);
if ($locationInventory->useLocationInventory()) {
app(OrderLocationRestock::class)->restock($order, $reason);
return;
}
$order->loadMissing('items');
$restockNote = "Order #{$order->id} {$reason}";
$createdBy = auth()->id() ?? 1;
foreach ($order->items as $item) {
if (!$item->product_id) {
continue;
}
$product = Product::lockForUpdate()->find($item->product_id);
if (!$product) {
continue;
}
$soldByTable = $this->getSoldQuantitiesByTable($product->id, $order->id);
$restockedByTable = $this->getRestockedQuantitiesByTable($product->id, $order->id);
$qtyToRestock = max(0, $soldByTable['stock'] - $restockedByTable['stock'])
+ max(0, $soldByTable['variant'] - $restockedByTable['variant']);
if ($qtyToRestock <= 0) {
continue;
}
if ($soldByTable['stock'] > $restockedByTable['stock']) {
StockMovement::create([
'product_id' => $product->id,
'type' => StockMovement::TYPE_RESTOCK,
'quantity' => $soldByTable['stock'] - $restockedByTable['stock'],
'notes' => $restockNote,
'created_by' => $createdBy,
]);
}
if ($soldByTable['variant'] > $restockedByTable['variant']) {
VariantStockMovement::create([
'product_id' => $product->id,
'type' => VariantStockMovement::TYPE_RESTOCK,
'quantity' => $soldByTable['variant'] - $restockedByTable['variant'],
'notes' => $restockNote,
'created_by' => $createdBy,
]);
}
$product->recalculateStock();
Log::info('Order restock movement created', [
'order_id' => $order->id,
'product_id' => $product->id,
'quantity' => $qtyToRestock,
'reason' => $reason,
]);
}
}
private function shouldRestockOnCancel(string $previousStatus): bool
{
return in_array($previousStatus, [
Order::STATUS_PLACED,
Order::STATUS_CONFIRMED,
Order::STATUS_PACKED,
Order::STATUS_AWAITING_PICKUP,
Order::STATUS_SHIPPED,
Order::STATUS_DELIVERED,
Order::STATUS_RETURNED,
], true);
}
private function saleNotePatterns(int $orderId): array
{
return [
"Order #{$orderId}",
"POS Order #{$orderId}",
"POS Order #{$orderId} (Synced)",
];
}
private function getSoldQuantitiesByTable(int $productId, int $orderId): array
{
$patterns = $this->saleNotePatterns($orderId);
$stockSale = (int) StockMovement::where('product_id', $productId)
->where('type', StockMovement::TYPE_SALE)
->whereIn('notes', $patterns)
->sum('quantity');
$variantSale = (int) VariantStockMovement::where('product_id', $productId)
->where('type', VariantStockMovement::TYPE_SALE)
->whereIn('notes', $patterns)
->sum('quantity');
return [
'stock' => abs($stockSale),
'variant' => abs($variantSale),
];
}
private function getRestockedQuantitiesByTable(int $productId, int $orderId): array
{
$prefix = "Order #{$orderId}";
return [
'stock' => (int) StockMovement::where('product_id', $productId)
->where('type', StockMovement::TYPE_RESTOCK)
->where('notes', 'like', $prefix . '%')
->sum('quantity'),
'variant' => (int) VariantStockMovement::where('product_id', $productId)
->where('type', VariantStockMovement::TYPE_RESTOCK)
->where('notes', 'like', $prefix . '%')
->sum('quantity'),
];
}
public function cleanupExpiredReservations(): int
{
$expiredOrderIds = StockReservation::expired()
->whereNotNull('order_id')
->pluck('order_id')
->unique()
->filter()
->values()
->all();
$count = StockReservation::expired()
->update(['status' => 'expired']);
if ($count > 0) {
Log::info('Expired stock reservations cleaned up', [
'count' => $count,
]);
}
foreach ($expiredOrderIds as $orderId) {
$this->cancelAwaitingPaymentOrder((int) $orderId, 'reservation_expired');
}
return $count;
}
public function cancelAwaitingPaymentOrder(int $orderId, string $reason = 'system'): bool
{
$updated = DB::transaction(function () use ($orderId, $reason) {
$order = Order::where('id', $orderId)
->where('status', Order::STATUS_AWAITING_PAYMENT)
->where('payment_method', 'online')
->whereIn('payment_status', [Order::PAYMENT_PENDING, Order::PAYMENT_FAILED])
->lockForUpdate()
->first();
if (!$order) {
return 0;
}
if (!empty($order->razorpay_payment_id)) {
$gatewayState = app(RazorpayService::class)->resolvePaymentState($order->razorpay_payment_id);
$status = $gatewayState['status'] ?? RazorpayService::PAYMENT_STATUS_UNKNOWN;
if ($status === RazorpayService::PAYMENT_STATUS_CAPTURED) {
$order->update([
'payment_status' => Order::PAYMENT_PAID,
'status' => Order::STATUS_PLACED,
'placed_at' => $order->placed_at ?? now(),
]);
Log::warning('Awaiting payment auto-cancel skipped: gateway already captured', [
'order_id' => $orderId,
'reason' => $reason,
]);
return 0;
}
if ($status === RazorpayService::PAYMENT_STATUS_AUTHORIZED) {
Log::info('Awaiting payment auto-cancel skipped: gateway authorization in progress', [
'order_id' => $orderId,
'reason' => $reason,
]);
return 0;
}
}
return Order::whereKey($order->id)->update([
'status' => Order::STATUS_CANCELLED,
'payment_status' => Order::PAYMENT_FAILED,
]);
});
if ($updated > 0) {
Log::info('Awaiting payment order auto-cancelled', [
'order_id' => $orderId,
'reason' => $reason,
]);
return true;
}
return false;
}
}
