Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
53.92% covered (warning)
53.92%
55 / 102
12.50% covered (danger)
12.50%
1 / 8
CRAP
0.00% covered (danger)
0.00%
0 / 1
InventoryService
53.92% covered (warning)
53.92%
55 / 102
12.50% covered (danger)
12.50%
1 / 8
233.41
0.00% covered (danger)
0.00%
0 / 1
 __construct
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 resolve
72.22% covered (warning)
72.22%
13 / 18
0.00% covered (danger)
0.00%
0 / 1
9.37
 assertAvailable
75.00% covered (warning)
75.00%
3 / 4
0.00% covered (danger)
0.00%
0 / 1
2.06
 quantity
66.67% covered (warning)
66.67%
2 / 3
0.00% covered (danger)
0.00%
0 / 1
4.59
 adjust
0.00% covered (danger)
0.00%
0 / 26
0.00% covered (danger)
0.00%
0 / 1
56
 deduct
76.67% covered (warning)
76.67%
23 / 30
0.00% covered (danger)
0.00%
0 / 1
17.86
 restoreOrder
92.86% covered (success)
92.86%
13 / 14
0.00% covered (danger)
0.00%
0 / 1
5.01
 movement
0.00% covered (danger)
0.00%
0 / 6
0.00% covered (danger)
0.00%
0 / 1
6
1<?php
2
3namespace App\Services\Stock;
4
5use App\Models\admin\Product;
6use App\Models\admin\Stock;
7use App\Models\Order;
8use App\Models\OrderDetails;
9use App\Models\ProductVariation;
10use App\Models\StockMovement;
11use App\Service\StockService;
12use Illuminate\Support\Facades\DB;
13use Illuminate\Validation\ValidationException;
14
15/** Product/variation quantities are on-hand balances. stocks is a POS projection. */
16class InventoryService
17{
18    public function __construct(private StockResolver $resolver) {}
19
20    /** Resolve both existing storefront cart keys and POS stock row IDs. */
21    public function resolve(string|int $key, bool $lock = false): array
22    {
23        $variantId = null;
24        $stock = null;
25        if (preg_match('/^p_(\d+)$/', (string) $key, $m)) {
26            $productId = $m[1];
27        } elseif (preg_match('/^var_(\d+)$/', (string) $key, $m)) {
28            $variantId = (int) $m[1];
29            $productId = ProductVariation::findOrFail($variantId)->product_id;
30        } elseif (ctype_digit((string) $key)) {
31            $stock = Stock::findOrFail($key);
32            $productId = $stock->product_id;
33            if (preg_match('/^VAR-(\d+)$/', (string) $stock->sku, $m)) {
34                $variantId = (int) $m[1];
35            }
36        } else {
37            throw ValidationException::withMessages(['stock' => 'This cart item is no longer available.']);
38        }
39        // All inventory writers acquire the parent lock first, including variant writers.
40        $product = Product::query()->when($lock, fn ($q) => $q->lockForUpdate())->findOrFail($productId);
41        $variant = $variantId ? $product->variations()->when($lock, fn ($q) => $q->lockForUpdate())->findOrFail($variantId) : null;
42        if (! $variant && $product->variations()->exists()) {
43            throw ValidationException::withMessages(['stock' => 'Please choose a product variation.']);
44        }
45
46        return [$product, $variant, $stock];
47    }
48
49    public function assertAvailable(string|int $key, mixed $qty): void
50    {
51        $qty = $this->quantity($qty);
52        [$product, $variant] = $this->resolve($key);
53        if (! $this->resolver->canSell($product, $qty, $variant)) {
54            throw ValidationException::withMessages(['stock' => "Not enough stock for {$product->name}, or it is out of stock."]);
55        }
56    }
57
58    public function quantity(mixed $qty): int
59    {
60        if (filter_var($qty, FILTER_VALIDATE_INT) === false || (int) $qty < 1 || (int) $qty > 2147483647) {
61            throw ValidationException::withMessages(['qty' => 'Quantity must be a positive whole number.']);
62        }
63
64        return (int) $qty;
65    }
66
67    public function adjust(string|int $key, int $delta, ?int $warehouseId = null): void
68    {
69        $this->quantity(abs($delta));
70        DB::transaction(function () use ($key, $delta, $warehouseId) {
71            [$product, $variant] = $this->resolve($key, true);
72            if (! $this->resolver->isTracked($product)) {
73                throw ValidationException::withMessages(['stock' => 'Enable global and product stock management to adjust quantity.']);
74            }
75            $target = $variant ?? $product;
76            $field = $variant ? 'stock_quantity' : 'stock';
77            if (feature('multi_warehouse')) {
78                if (! $warehouseId) {
79                    throw ValidationException::withMessages(['warehouse_id' => 'Choose the warehouse for this stock adjustment.']);
80                }
81                $reference = $stock ?? $target;
82                $location = app(LocationInventoryService::class);
83                if ($delta > 0) {
84                    $location->receive($product, $variant, $delta, $warehouseId, $reference, 'adjustment');
85                } else {
86                    $location->deduct($product, $variant, abs($delta), $warehouseId, $reference, 'adjustment');
87                }
88
89                return;
90            }
91            if ((int) $target->$field + $delta < 0) {
92                throw ValidationException::withMessages(['stock' => 'Stock out cannot exceed available quantity.']);
93            }
94            $target->$field = (int) $target->$field + $delta;
95            $target->save();
96            StockMovement::create([
97                'product_id' => $product->id, 'qty' => $delta, 'type' => 'adjustment',
98                'reference_type' => $target::class, 'reference_id' => $target->id, 'created_by' => auth()->id(),
99            ]);
100            StockService::syncStockForProduct($product->fresh());
101        });
102    }
103
104    /** The line and deduction commit together; repeated calls cannot deduct twice. */
105    public function deduct(OrderDetails $line): void
106    {
107        DB::transaction(function () use ($line) {
108            $line = OrderDetails::lockForUpdate()->findOrFail($line->id);
109            if ($line->inventory_product_id !== null) {
110                return;
111            }
112            $qty = $this->quantity($line->qty);
113            [$product, $variant, $stock] = $this->resolve($line->stock_id, true);
114            if (! $this->resolver->canSell($product, $qty, $variant)) {
115                throw ValidationException::withMessages(['stock' => "Not enough stock for {$product->name}, or it is out of stock."]);
116            }
117            $tracked = $this->resolver->isTracked($product);
118            if ($tracked) {
119                // Synthetic storefront keys also use a canonical POS row for in/out reports.
120                if (! $stock) {
121                    StockService::syncStockForProduct($product);
122                    $stock = Stock::where('product_id', $product->id)
123                        ->where('sku', $variant ? 'VAR-' . $variant->id : 'BASE-' . $product->id)->firstOrFail();
124                }
125                if ($stock) {
126                    $stock->increment('stock_out', $qty);
127                }
128                $preferredWarehouse = $line->warehouse_id ?: $line->order?->warehouse_id ?: request()->input('warehouse_id');
129                $warehouse = app(LocationInventoryService::class)->deduct(
130                    $product, $variant, $qty, $preferredWarehouse ? (int) $preferredWarehouse : null, $line
131                );
132                if ($warehouse) {
133                    $line->forceFill(['warehouse_id' => $warehouse->id, 'branch_id' => $warehouse->branch_id])->save();
134                }
135                if ($warehouse && $line->order && ! $line->order->warehouse_id) {
136                    $line->order->forceFill(['warehouse_id' => $warehouse->id, 'branch_id' => $warehouse->branch_id])->save();
137                }
138            }
139            $line->forceFill([
140                'inventory_product_id' => $product->id,
141                'inventory_variation_id' => $variant?->id,
142                'inventory_deducted' => $tracked ? $qty : 0,
143            ])->save();
144        });
145    }
146
147    /** Restore only what THIS line deducted, even if tracking was later disabled. */
148    public function restoreOrder(Order $order): void
149    {
150        DB::transaction(function () use ($order) {
151            Order::lockForUpdate()->findOrFail($order->id);
152            foreach ($order->details()->orderBy('id')->lockForUpdate()->get() as $line) {
153                $qty = (int) $line->inventory_deducted - (int) $line->inventory_restored;
154                if ($qty <= 0) {
155                    continue;
156                }
157                $product = Product::lockForUpdate()->findOrFail($line->inventory_product_id);
158                $variant = $line->inventory_variation_id
159                    ? $product->variations()->lockForUpdate()->findOrFail($line->inventory_variation_id) : null;
160                app(LocationInventoryService::class)->restore(
161                    $product, $variant, $qty, $line->warehouse_id ? (int) $line->warehouse_id : null, $line
162                );
163                $line->forceFill(['inventory_restored' => $line->inventory_deducted])->save();
164            }
165        });
166    }
167
168    private function movement(Product $product, int $qty, string $type, OrderDetails $line): void
169    {
170        StockMovement::create([
171            'product_id' => $product->id, 'qty' => $qty, 'type' => $type,
172            'reference_type' => OrderDetails::class, 'reference_id' => $line->id,
173            'note' => 'Order #' . $line->order_id . ($line->stock_id ? ' / item ' . $line->stock_id : ''),
174            'created_by' => auth()->id(),
175        ]);
176    }
177}