Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
78.01% covered (warning)
78.01%
110 / 141
50.00% covered (danger)
50.00%
6 / 12
CRAP
0.00% covered (danger)
0.00%
0 / 1
LocationInventoryService
78.01% covered (warning)
78.01%
110 / 141
50.00% covered (danger)
50.00%
6 / 12
58.86
0.00% covered (danger)
0.00%
0 / 1
 reconcileAllToAggregate
80.00% covered (warning)
80.00%
8 / 10
0.00% covered (danger)
0.00%
0 / 1
4.13
 defaultWarehouse
100.00% covered (success)
100.00%
5 / 5
100.00% covered (success)
100.00%
1 / 1
1
 receive
100.00% covered (success)
100.00%
12 / 12
100.00% covered (success)
100.00%
1 / 1
3
 deduct
89.47% covered (warning)
89.47%
17 / 19
0.00% covered (danger)
0.00%
0 / 1
5.03
 restore
100.00% covered (success)
100.00%
12 / 12
100.00% covered (success)
100.00%
1 / 1
4
 removeReceived
41.18% covered (danger)
41.18%
7 / 17
0.00% covered (danger)
0.00%
0 / 1
13.33
 transfer
84.62% covered (warning)
84.62%
11 / 13
0.00% covered (danger)
0.00%
0 / 1
3.03
 resolveFulfilmentWarehouse
100.00% covered (success)
100.00%
11 / 11
100.00% covered (success)
100.00%
1 / 1
3
 lockedRow
100.00% covered (success)
100.00%
9 / 9
100.00% covered (success)
100.00%
1 / 1
2
 reconcileItem
26.67% covered (danger)
26.67%
4 / 15
0.00% covered (danger)
0.00%
0 / 1
14.86
 syncAggregate
50.00% covered (danger)
50.00%
4 / 8
0.00% covered (danger)
0.00%
0 / 1
2.50
 movement
100.00% covered (success)
100.00%
10 / 10
100.00% covered (success)
100.00%
1 / 1
3
1<?php
2
3namespace App\Services\Stock;
4
5use App\Models\admin\Product;
6use App\Models\Branch;
7use App\Models\ProductVariation;
8use App\Models\StockMovement;
9use App\Models\Warehouse;
10use App\Models\WarehouseStock;
11use App\Service\StockService;
12use Illuminate\Database\Eloquent\Model;
13use Illuminate\Support\Facades\DB;
14use Illuminate\Support\Facades\Schema;
15use Illuminate\Validation\ValidationException;
16
17class LocationInventoryService
18{
19    /** Reconcile hidden single-location quantities before multi-warehouse mode is first enabled. */
20    public function reconcileAllToAggregate(): void
21    {
22        $warehouse = $this->defaultWarehouse();
23        Product::query()->with('variations')->orderBy('id')->chunkById(100, function ($products) use ($warehouse) {
24            DB::transaction(function () use ($products, $warehouse) {
25                foreach ($products as $product) {
26                    if ($product->variations->isEmpty()) {
27                        $this->reconcileItem($warehouse->id, $product->id, null, max(0, (float) $product->stock));
28                    } else {
29                        foreach ($product->variations as $variation) {
30                            $this->reconcileItem($warehouse->id, $product->id, $variation->id, max(0, (float) $variation->stock_quantity));
31                        }
32                    }
33                }
34            });
35        });
36    }
37
38    public function defaultWarehouse(): Warehouse
39    {
40        $branch = Branch::default();
41
42        return Warehouse::active()
43            ->when($branch, fn ($q) => $q->where('branch_id', $branch->id))
44            ->orderByDesc('is_default')->orderBy('id')->first()
45            ?? Warehouse::active()->orderByDesc('is_default')->orderBy('id')->firstOrFail();
46    }
47
48    public function receive(Product $product, ?ProductVariation $variation, float $qty, ?int $warehouseId, Model $reference, string $type = 'purchase'): void
49    {
50        if (! feature('multi_warehouse')) {
51            $target = $variation ?? $product;
52            $target->increment($variation ? 'stock_quantity' : 'stock', $qty);
53            $this->movement($product, $qty, $type, $reference);
54            StockService::syncStockForProduct($product->fresh());
55
56            return;
57        }
58
59        $warehouse = Warehouse::active()->findOrFail($warehouseId);
60        $row = $this->lockedRow($warehouse->id, $product->id, $variation?->id);
61        $row->quantity = (float) $row->quantity + $qty;
62        $row->save();
63        $this->syncAggregate($product, $variation);
64        $this->movement($product, $qty, $type, $reference, $warehouse);
65    }
66
67    public function deduct(Product $product, ?ProductVariation $variation, float $qty, ?int $warehouseId, Model $reference, string $type = 'sale'): ?Warehouse
68    {
69        if (! feature('multi_warehouse')) {
70            $target = $variation ?? $product;
71            $field = $variation ? 'stock_quantity' : 'stock';
72            if ((float) $target->$field < $qty) {
73                throw ValidationException::withMessages(['stock' => "Not enough stock for {$product->name}."]);
74            }
75            $target->$field = (float) $target->$field - $qty;
76            $target->save();
77            $this->movement($product, -$qty, $type, $reference);
78            StockService::syncStockForProduct($product->fresh());
79
80            return null;
81        }
82
83        $warehouse = $this->resolveFulfilmentWarehouse($product, $variation, $qty, $warehouseId);
84        $row = $this->lockedRow($warehouse->id, $product->id, $variation?->id);
85        if ($row->available() < $qty) {
86            throw ValidationException::withMessages(['stock' => "Not enough stock for {$product->name} in {$warehouse->name}."]);
87        }
88        $row->quantity = (float) $row->quantity - $qty;
89        $row->save();
90        $this->syncAggregate($product, $variation);
91        $this->movement($product, -$qty, $type, $reference, $warehouse);
92
93        return $warehouse;
94    }
95
96    public function restore(Product $product, ?ProductVariation $variation, float $qty, ?int $warehouseId, Model $reference): void
97    {
98        if (feature('multi_warehouse') && $warehouseId) {
99            $warehouse = Warehouse::findOrFail($warehouseId);
100            $row = $this->lockedRow($warehouse->id, $product->id, $variation?->id);
101            $row->quantity = (float) $row->quantity + $qty;
102            $row->save();
103            $this->syncAggregate($product, $variation);
104            $this->movement($product, $qty, 'sale_return', $reference, $warehouse);
105
106            return;
107        }
108        $target = $variation ?? $product;
109        $target->increment($variation ? 'stock_quantity' : 'stock', $qty);
110        $this->movement($product, $qty, 'sale_return', $reference);
111        StockService::syncStockForProduct($product->fresh());
112    }
113
114    public function removeReceived(Product $product, ?ProductVariation $variation, float $qty, ?int $warehouseId, Model $reference): void
115    {
116        if (feature('multi_warehouse') && $warehouseId) {
117            $warehouse = Warehouse::findOrFail($warehouseId);
118            $row = $this->lockedRow($warehouse->id, $product->id, $variation?->id);
119            if ($row->available() < $qty) {
120                throw new \RuntimeException('Cannot reverse stock that has already been sold or transferred.');
121            }
122            $row->quantity = (float) $row->quantity - $qty;
123            $row->save();
124            $this->syncAggregate($product, $variation);
125            $this->movement($product, -$qty, 'purchase_return', $reference, $warehouse);
126
127            return;
128        }
129        $target = $variation ?? $product;
130        $field = $variation ? 'stock_quantity' : 'stock';
131        if ((float) $target->$field < $qty) {
132            throw new \RuntimeException('Cannot reverse stock that has already been sold.');
133        }
134        $target->decrement($field, $qty);
135        $this->movement($product, -$qty, 'purchase_return', $reference);
136        StockService::syncStockForProduct($product->fresh());
137    }
138
139    public function transfer(int $fromId, int $toId, Product $product, ?ProductVariation $variation, float $qty, Model $reference): void
140    {
141        if ($fromId === $toId) {
142            throw ValidationException::withMessages(['to_warehouse_id' => 'Choose a different destination warehouse.']);
143        }
144        $from = Warehouse::active()->findOrFail($fromId);
145        $to = Warehouse::active()->findOrFail($toId);
146        $source = $this->lockedRow($from->id, $product->id, $variation?->id);
147        if ($source->available() < $qty) {
148            throw ValidationException::withMessages(['quantity' => "Only {$source->available()} available in {$from->name}."]);
149        }
150        $destination = $this->lockedRow($to->id, $product->id, $variation?->id);
151        $source->decrement('quantity', $qty);
152        $destination->increment('quantity', $qty);
153        $this->movement($product, -$qty, 'transfer_out', $reference, $from);
154        $this->movement($product, $qty, 'transfer_in', $reference, $to);
155        $this->syncAggregate($product, $variation);
156    }
157
158    private function resolveFulfilmentWarehouse(Product $product, ?ProductVariation $variation, float $qty, ?int $preferred): Warehouse
159    {
160        $query = WarehouseStock::query()
161            ->where('product_id', $product->id)
162            ->where('variation_id', $variation?->id)
163            ->whereRaw('(quantity - reserved) >= ?', [$qty])
164            ->whereHas('warehouse', fn ($q) => $q->where('status', true));
165        if ($preferred) {
166            $query->where('warehouse_id', $preferred);
167        }
168        $row = $query->orderByDesc('quantity')->lockForUpdate()->first();
169        if (! $row) {
170            throw ValidationException::withMessages(['stock' => "No warehouse has enough stock for {$product->name}."]);
171        }
172
173        return $row->warehouse;
174    }
175
176    private function lockedRow(int $warehouseId, int $productId, ?int $variationId): WarehouseStock
177    {
178        return WarehouseStock::query()->lockForUpdate()->firstOrCreate([
179            'item_key' => $warehouseId . '-' . $productId . '-' . ($variationId ?: 0),
180        ], [
181            'warehouse_id' => $warehouseId,
182            'product_id' => $productId,
183            'variation_id' => $variationId,
184            'quantity' => 0,
185            'reserved' => 0,
186        ]);
187    }
188
189    private function reconcileItem(int $defaultWarehouseId, int $productId, ?int $variationId, float $target): void
190    {
191        $rows = WarehouseStock::query()->where('product_id', $productId)->where('variation_id', $variationId)->lockForUpdate()->get();
192        $difference = $target - (float) $rows->sum('quantity');
193        if (abs($difference) < 0.0001) {
194            return;
195        }
196        if ($difference > 0) {
197            $row = $this->lockedRow($defaultWarehouseId, $productId, $variationId);
198            $row->increment('quantity', $difference);
199
200            return;
201        }
202        $remaining = abs($difference);
203        foreach ($rows->sortByDesc(fn ($row) => $row->warehouse_id === $defaultWarehouseId) as $row) {
204            $take = min($remaining, (float) $row->quantity);
205            $row->decrement('quantity', $take);
206            $remaining -= $take;
207            if ($remaining < 0.0001) {
208                break;
209            }
210        }
211    }
212
213    private function syncAggregate(Product $product, ?ProductVariation $variation): void
214    {
215        if ($variation) {
216            $variation->stock_quantity = (float) WarehouseStock::where('variation_id', $variation->id)->sum('quantity');
217            $variation->save();
218            $product->stock = (float) WarehouseStock::where('product_id', $product->id)->sum('quantity');
219            $product->save();
220        } else {
221            $product->stock = (float) WarehouseStock::where('product_id', $product->id)->whereNull('variation_id')->sum('quantity');
222            $product->save();
223        }
224        StockService::syncStockForProduct($product->fresh());
225    }
226
227    private function movement(Product $product, float $qty, string $type, Model $reference, ?Warehouse $warehouse = null): void
228    {
229        $data = [
230            'product_id' => $product->id, 'qty' => $qty, 'type' => $type,
231            'reference_type' => $reference::class, 'reference_id' => $reference->getKey(),
232            'created_by' => auth()->id(),
233        ];
234        if (Schema::hasColumn('stock_movements', 'branch_id')) {
235            $data['branch_id'] = $warehouse?->branch_id;
236        }
237        if (Schema::hasColumn('stock_movements', 'warehouse_id')) {
238            $data['warehouse_id'] = $warehouse?->id;
239        }
240        StockMovement::create($data);
241    }
242}