Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
56.71% covered (warning)
56.71%
207 / 365
16.67% covered (danger)
16.67%
2 / 12
CRAP
0.00% covered (danger)
0.00%
0 / 1
ProductController
56.71% covered (warning)
56.71%
207 / 365
16.67% covered (danger)
16.67%
2 / 12
700.95
0.00% covered (danger)
0.00%
0 / 1
 index
68.42% covered (warning)
68.42%
13 / 19
0.00% covered (danger)
0.00%
0 / 1
2.13
 create
100.00% covered (success)
100.00%
17 / 17
100.00% covered (success)
100.00%
1 / 1
2
 store
80.00% covered (warning)
80.00%
84 / 105
0.00% covered (danger)
0.00%
0 / 1
30.00
 edit
100.00% covered (success)
100.00%
6 / 6
100.00% covered (success)
100.00%
1 / 1
2
 update
59.32% covered (warning)
59.32%
70 / 118
0.00% covered (danger)
0.00%
0 / 1
106.30
 destroy
0.00% covered (danger)
0.00%
0 / 3
0.00% covered (danger)
0.00%
0 / 1
2
 storeAttributeAjax
0.00% covered (danger)
0.00%
0 / 14
0.00% covered (danger)
0.00%
0 / 1
6
 storeCategoryAjax
0.00% covered (danger)
0.00%
0 / 13
0.00% covered (danger)
0.00%
0 / 1
6
 storeBrandAjax
0.00% covered (danger)
0.00%
0 / 11
0.00% covered (danger)
0.00%
0 / 1
2
 showAjax
0.00% covered (danger)
0.00%
0 / 4
0.00% covered (danger)
0.00%
0 / 1
2
 updateStockAjax
40.48% covered (danger)
40.48%
17 / 42
0.00% covered (danger)
0.00%
0 / 1
48.64
 updatePositions
0.00% covered (danger)
0.00%
0 / 13
0.00% covered (danger)
0.00%
0 / 1
12
1<?php
2
3namespace App\Http\Controllers\admin;
4
5use App\Http\Controllers\Controller;
6use App\Models\admin\Product;
7use App\Models\admin\ProductImage;
8use App\Models\Attribute;
9use App\Models\Brand;
10use App\Models\Category;
11use App\Models\ProductVariation;
12use App\Models\StockMovement;
13use App\Models\Vendor;
14use App\Service\CacheService;
15use App\Service\PosService;
16use App\Service\StockService;
17use App\Traits\PosTrait;
18use Illuminate\Http\Request;
19use Illuminate\Http\Response;
20use Illuminate\Support\Facades\Auth;
21use Illuminate\Support\Facades\DB;
22use Illuminate\Support\Facades\Log;
23use Illuminate\Support\Str;
24use Illuminate\Validation\Rule;
25use Illuminate\Validation\ValidationException;
26
27class ProductController extends Controller
28{
29    use PosTrait;
30
31    /**
32     * Display a listing of the resource.
33     *
34     * @return Response
35     */
36    public function index(Request $request)
37    {
38        $data['categories'] = Category::query()->orderByDesc('id')->pluck('name', 'id');
39
40        $query = Product::query()->orderBy('position', 'ASC')->latest();
41
42        if ($request->filled('search')) {
43            $term = trim($request->search);
44            $query->where(function ($q) use ($term) {
45                $q->where('name', 'LIKE', "%{$term}%")
46                    ->orWhere('product_code', 'LIKE', "%{$term}%")
47                    ->orWhere('sku_no', 'LIKE', "%{$term}%");
48            });
49        }
50
51        $data['search'] = $request->search;
52        $data['data'] = $query->paginate(15)->withQueryString();
53
54        // Summary Statistics for the redesign
55        $data['stat_total_products'] = Product::count();
56        $data['stat_in_stock'] = Product::where('stock', '>', 0)->count();
57        $data['stat_out_of_stock'] = Product::where(function ($q) {
58            $q->whereNull('stock')->orWhere('stock', '<=', 0);
59        })->count();
60        // Was Product::all()->sum(...) — loaded the entire catalog into PHP on
61        // every visit to this page just to add up two columns.
62        $data['stat_total_value'] = Product::where('stock', '>', 0)
63            ->selectRaw('COALESCE(SUM(stock * selling_price), 0) as value')->value('value');
64
65        return view('admin.product.product-manage')->with($data);
66    }
67
68    /**
69     * Show the form for creating a new resource.
70     *
71     * @return Response
72     */
73    public function create()
74    {
75        // Create and edit intentionally render the same form so their layout,
76        // fields and client-side behaviour cannot drift apart again.
77        $data['product'] = new Product([
78            'manage_stock' => feature('inventory_tracking'),
79            'stock' => 0,
80            'stock_status' => 'in_stock',
81            'unit' => 'pcs',
82            'alert_quantity' => 5,
83            'is_featured' => false,
84            'has_warranty' => false,
85        ]);
86        $data['product']->setRelation('categories', collect());
87        $data['product']->setRelation('images', collect());
88        $data['product']->setRelation('variations', collect());
89        // Load only top-level categories with their immediate children
90        $data['categories'] = Category::with('children')->whereNull('parent_id')->orderBy('name')->get();
91        $data['allCategories'] = Category::orderBy('name')->get(); // for parent dropdown in quick-create
92        $data['attributes'] = Attribute::with('values')->get();
93        // Marketplace: vendor list for the ownership picker (empty when the flag is off).
94        $data['vendors'] = feature('multi_vendor') ? Vendor::orderBy('name')->get() : collect();
95
96        return view('admin.product.edit')->with($data);
97    }
98
99    /**
100     * Store a newly created resource in storage.
101     *
102     * @return Response
103     */
104    public function store(Request $request)
105    {
106
107        $data = $request->validate([
108            'name' => ['required', 'string', 'max:255', Rule::unique('products', 'name')->whereNull('deleted_at')],
109            // SVG dropped: it can carry <script> (stored XSS if ever opened
110            // directly), and Intervention/GD can't resize a vector anyway so it
111            // threw inside PosTrait::FileProcessing and surfaced as a generic error.
112            'thumbnail' => 'image|mimes:jpg,jpeg,png,webp',
113            'price' => 'required|numeric|min:0',
114            'details' => 'nullable',
115            'short_details' => 'nullable',
116            'content' => 'nullable|string',
117            'slug' => ['nullable', 'string', 'max:255', Rule::unique('products', 'slug')->whereNull('deleted_at')],
118            'cost_per_item' => 'nullable|numeric|min:0',
119            'product_selling_price' => 'nullable|numeric|min:0',
120            'barcode' => ['nullable', 'string', 'max:255', Rule::unique('products', 'barcode')],
121            'video_url' => 'nullable|url|max:2048',
122            'unit' => 'nullable|in:pcs,kg,box',
123            'alert_quantity' => 'nullable|integer|min:0',
124            'has_warranty' => 'nullable|boolean',
125            'is_featured' => 'nullable|boolean',
126            'meta_title' => 'nullable|string|max:60',
127            'meta_description' => 'nullable|string|max:160',
128            'keywords' => 'nullable|string|max:255',
129            'og_image' => 'nullable|url|max:2048',
130            'stock' => 'nullable|numeric|min:0',
131            'brand_id' => 'nullable|exists:brands,id',
132            'section_id' => 'nullable|exists:sections,id',
133            'category_ids' => 'required|array|min:1',
134            'category_ids.*' => 'exists:categories,id',
135            // BUG FIX: the create-page form actually submits these three as
136            // `manage_stock`/`stock_quantity`/`stock_status` (see the "With
137            // storehouse management" toggle) but none of them were validated
138            // or saved before — the whole stock section was silently
139            // discarded on every product save, so Product.stock never got
140            // set from the form at all. Now genuinely persisted below.
141            'manage_stock' => 'nullable|boolean',
142            'variations' => 'nullable|array',
143            // Accept a signed integer here and clamp it below. This keeps an
144            // imported/edited negative value from breaking the whole product.
145            'variations.*.stock_qty' => 'nullable|integer',
146            'variations.*.stock_status' => 'nullable|in:in_stock,out_of_stock',
147            'stock_quantity' => 'nullable|integer|min:0',
148            'stock_status' => 'nullable|in:in_stock,out_of_stock',
149            // BUG FIX: the SKU field was collected but never saved — a few
150            // lines below this always overwrote it with an auto-generated
151            // one regardless of what was typed. Now the typed SKU is kept
152            // if one was given, and auto-generation is only a fallback.
153            'sku_no' => ['nullable', 'string', 'max:255', Rule::unique('products', 'sku_no')->whereNull('deleted_at')],
154            // Marketplace: which vendor owns this product. Only meaningful with
155            // multi_vendor on; the value is dropped below when the flag is off so
156            // a crafted request can't stamp ownership on a single-store install.
157            'vendor_id' => 'nullable|exists:vendors,id',
158        ]);
159
160        if (! feature('multi_vendor')) {
161            unset($data['vendor_id']);
162        }
163
164        try {
165            DB::beginTransaction();
166            $data['slug'] = Str::slug($request->input('slug') ?: $request->name);
167            $data['created_by'] = Auth::user()->id;
168            $data['category_id'] = $request->category_ids[0]; // Set primary category for SKU
169            $data['product_code'] = 'P-' . today()->format('dmY');
170            $data['selling_price'] = $request->product_selling_price;
171            $data['cost_price'] = $request->cost_per_item;
172            $data['details'] = $request->input('content', $request->input('details'));
173            $data['discount'] = $request->product_discount;
174            $data['is_featured'] = $request->boolean('is_featured');
175            $data['has_warranty'] = $request->boolean('has_warranty');
176            $data['manage_stock'] = $request->boolean('manage_stock');
177            $data['stock'] = $request->stock_quantity ?? 0;
178            $data['alert_quantity'] = $request->integer('alert_quantity', 5);
179            $data['stock_status'] = (feature('inventory_tracking') && $data['manage_stock']) ? 'in_stock' : ($request->stock_status ?? 'in_stock');
180            unset($data['stock_quantity'], $data['cost_per_item'], $data['product_selling_price']);
181            // sku_no is NOT NULL with no DB default, so this always needs a value —
182            // a temporary placeholder here, overwritten below once $product->id is
183            // known (with the user's own SKU honored if they typed one).
184            $data['sku_no'] = $request->filled('sku_no') ? trim($request->sku_no) : ('TEMP-' . Str::random(8));
185
186            $product = Product::query()->create($data);
187
188            // Handle Media Library URLs
189            if ($request->filled('featured_image_url')) {
190                // If URL starts with full app URL, we can store it as is, or strip the domain.
191                // Assuming it's already a suitable path or URL
192                $product->update(['thumbnail' => $request->featured_image_url]);
193            } elseif ($request->hasFile('thumbnail')) {
194                $data['thumbnail'] = $this->FileProcessing($request->file('thumbnail'), PosService::PRODUCT_THUMBNAIL, 1500, 1165);
195                $product->update(['thumbnail' => $data['thumbnail']]);
196            }
197
198            if ($request->filled('video_url')) {
199                $product->update(['video_url' => $request->video_url]);
200            }
201
202            if ($request->filled('gallery_image_urls')) {
203                foreach ($request->gallery_image_urls as $url) {
204                    ProductImage::create(['product_id' => $product->id, 'picture' => $url]);
205                }
206            } elseif ($request->hasFile('images')) {
207                $image = new ProductImage;
208                foreach ($request['images'] as $file) {
209                    $data['picture'] = $this->FileProcessing($file, PosService::PRODUCT_IMAGE, 1500, 1165);
210                    $image->create(['product_id' => $product->id, 'picture' => $data['picture']]);
211                }
212            }
213            $data['product_code'] = 'P-' . $product->id . today()->format('dmY');
214
215            if ($request->filled('sku_no')) {
216                // User typed their own SKU — keep it, just finalize product_code.
217                $product->update(['product_code' => $data['product_code']]);
218            } else {
219                // Auto-generate, same rule as before, replacing the temporary placeholder.
220                $sku = ''; // Initialize the SKU variable
221                $cat = Category::query()->findOrFail($data['category_id']);
222                $sku .= substr($request->name, 0, 3); // Example: First 3 letters from the product name
223                $sku .= str_pad($product->id, 4, '0', STR_PAD_LEFT); // Example: Padded product ID with zeros to a length of 4
224                $product->update(['sku_no' => $sku, 'product_code' => $data['product_code']]);
225            }
226
227            // Sync multiple categories to pivot table
228            if ($request->has('category_ids')) {
229                $product->categories()->sync($request->category_ids);
230            }
231
232            // NOTE: StockService::createStock() only does anything when the request
233            // has a legacy `color_id[]` array, which this form never sends — it's
234            // a no-op here (kept for the old flow that still calls it, if any).
235            // The real inventory sync for THIS form's data (manage_stock/stock,
236            // and each variation below) happens via StockService::syncStockForProduct()
237            // right after variations are created, further down.
238            StockService::createStock($product);
239
240            // Handle Generated Variations
241            if ($request->has('variations') && is_array($request->variations)) {
242                foreach ($request->variations as $varData) {
243                    $variation = ProductVariation::create([
244                        'product_id' => $product->id,
245                        'sku' => $varData['sku'] ?? null,
246                        'price' => max(0, (float) ($varData['price'] ?? 0)),
247                        // A negative stock_quantity fails syncStockForProduct's
248                        // `> 0` check and falls through to UNLIMITED_STOCK — i.e.
249                        // a data-entry slip made a variant *unlimited* in the POS
250                        // instead of unsellable. Clamp at 0.
251                        'stock_quantity' => max(0, (int) ($varData['stock_qty'] ?? 0)),
252                        'stock_status' => $varData['stock_status'] ?? 'in_stock',
253                    ]);
254
255                    if (! empty($varData['attrs'])) {
256                        $attrs = json_decode($varData['attrs'], true);
257                        if (is_array($attrs)) {
258                            $valIds = [];
259                            foreach ($attrs as $attr) {
260                                if (isset($attr['val_id']) && ! empty($attr['val_id'])) {
261                                    $valIds[] = $attr['val_id'];
262                                }
263                            }
264                            if (count($valIds) > 0) {
265                                $variation->attributeValues()->sync($valIds);
266                            }
267                        }
268                    }
269                }
270            }
271
272            // BUG FIX: this is the actual fix for products "not showing up in the
273            // POS" — the POS only ever reads from the `stocks` table (it has to;
274            // order_details.stock_id has a real DB foreign key to stocks.id), but
275            // nothing in this form's save path ever wrote to `stocks` at all. This
276            // creates/updates the matching Stock row(s) — one per variation, or one
277            // base row for a simple product — from what was actually just saved.
278            StockService::syncStockForProduct($product->fresh());
279
280            DB::commit();
281
282            return redirect()->back()->with('success', 'Product Successfully Stored');
283        } catch (\Throwable $e) {
284            DB::rollBack();
285
286            Log::error('Product store failed: ' . $e->getMessage(), ['exception' => $e]);
287
288            return redirect()->back()->withInput()->with('error', 'Something went wrong while saving the product. Please try again.');
289        }
290    }
291
292    /**
293     * Display the specified resource.
294     *
295     * @param  int  $id
296     * @return Response
297     */
298    public function edit($id)
299    {
300        $data['product'] = Product::with(['variations.attributeValues.attribute', 'images'])->findOrFail($id);
301        $data['categories'] = Category::with('children')->whereNull('parent_id')->orderBy('name')->get();
302        $data['allCategories'] = Category::orderBy('name')->get(); // for parent dropdown in quick-create
303        $data['attributes'] = Attribute::with('values')->get();
304        // Marketplace: vendor list for the ownership picker (empty when the flag is off).
305        $data['vendors'] = feature('multi_vendor') ? Vendor::orderBy('name')->get() : collect();
306
307        return view('admin.product.edit')->with($data);
308    }
309
310    /**
311     * Update the specified resource in storage.
312     *
313     * @param  int  $id
314     * @return Response
315     */
316    public function update(Request $request, $id)
317    {
318        $product = Product::findOrFail($id);
319
320        $data = $request->validate([
321            'name' => ['required', 'string', 'max:255', Rule::unique('products', 'name')->ignore($product->id)->whereNull('deleted_at')],
322            'thumbnail' => 'image|mimes:jpg,jpeg,png,webp',
323            'price' => 'required|numeric|min:0',
324            'details' => 'nullable',
325            'short_details' => 'nullable',
326            'content' => 'nullable|string',
327            'slug' => ['nullable', 'string', 'max:255', Rule::unique('products', 'slug')->ignore($product->id)->whereNull('deleted_at')],
328            'cost_per_item' => 'nullable|numeric|min:0',
329            'product_selling_price' => 'nullable|numeric|min:0',
330            'barcode' => ['nullable', 'string', 'max:255', Rule::unique('products', 'barcode')->ignore($product->id)],
331            'video_url' => 'nullable|url|max:2048',
332            'unit' => 'nullable|in:pcs,kg,box',
333            'alert_quantity' => 'nullable|integer|min:0',
334            'has_warranty' => 'nullable|boolean',
335            'is_featured' => 'nullable|boolean',
336            'meta_title' => 'nullable|string|max:60',
337            'meta_description' => 'nullable|string|max:160',
338            'keywords' => 'nullable|string|max:255',
339            'og_image' => 'nullable|url|max:2048',
340            'stock' => 'nullable|numeric|min:0',
341            'brand_id' => 'nullable|exists:brands,id',
342            'section_id' => 'nullable|exists:sections,id',
343            'category_ids' => 'required|array|min:1',
344            'category_ids.*' => 'exists:categories,id',
345            // Same fix as store() — these three were never validated/saved here either.
346            'manage_stock' => 'nullable|boolean',
347            'variations' => 'nullable|array',
348            'variations.*.stock_qty' => 'nullable|integer',
349            'variations.*.stock_status' => 'nullable|in:in_stock,out_of_stock',
350            'stock_quantity' => 'nullable|integer|min:0',
351            'stock_status' => 'nullable|in:in_stock,out_of_stock',
352            'sku_no' => ['nullable', 'string', 'max:255', Rule::unique('products', 'sku_no')->ignore($product->id)->whereNull('deleted_at')],
353            // Marketplace: owning vendor (see store()). Dropped when the flag is
354            // off so an update never wipes or forges vendor ownership.
355            'vendor_id' => 'nullable|exists:vendors,id',
356        ]);
357
358        if (! feature('multi_vendor')) {
359            unset($data['vendor_id']);
360        }
361
362        try {
363            DB::beginTransaction();
364            $product = Product::lockForUpdate()->findOrFail($id);
365            if ($request->has('original_stock') && (int) $request->original_stock !== (int) $product->stock) {
366                throw ValidationException::withMessages(['stock_quantity' => 'Stock changed while this form was open. Reload before saving.']);
367            }
368            $data['updated_by'] = Auth::user()->id;
369            $data['category_id'] = $request->category_ids[0];
370            $data['selling_price'] = $request->product_selling_price;
371            $data['cost_price'] = $request->cost_per_item;
372            $data['details'] = $request->input('content', $request->input('details'));
373            $data['discount'] = $request->product_discount;
374            $data['slug'] = Str::slug($request->input('slug') ?: $request->name);
375            $data['is_featured'] = $request->boolean('is_featured');
376            $data['has_warranty'] = $request->boolean('has_warranty');
377            $data['manage_stock'] = $request->boolean('manage_stock');
378            $data['stock'] = $request->stock_quantity ?? $product->stock ?? 0;
379            $data['alert_quantity'] = $request->integer('alert_quantity', $product->alert_quantity ?? 5);
380            $data['stock_status'] = (feature('inventory_tracking') && $data['manage_stock']) ? 'in_stock' : ($request->stock_status ?? 'in_stock');
381            unset($data['stock_quantity'], $data['cost_per_item'], $data['product_selling_price']);
382            if (! $request->filled('sku_no')) {
383                unset($data['sku_no']); // sku_no is NOT NULL — don't wipe the existing one with a blank submit
384            }
385            $product->update($data);
386
387            if ($request->filled('featured_image_url')) {
388                $product->update(['thumbnail' => $request->featured_image_url]);
389            } elseif ($request->hasFile('thumbnail')) {
390                $data['thumbnail'] = $this->FileProcessing($request->file('thumbnail'), PosService::PRODUCT_THUMBNAIL, 1500, 1165);
391                $product->update(['thumbnail' => $data['thumbnail']]);
392            }
393            if ($request->has('gallery_image_urls')) {
394                $product->images()->delete();
395                foreach ($request->gallery_image_urls as $url) {
396                    ProductImage::create(['product_id' => $product->id, 'picture' => $url]);
397                }
398            } elseif ($request->hasFile('pictures')) {
399                $product->images()->delete();
400                foreach ($request->file('pictures') as $file) {
401                    $data['picture'] = $this->FileProcessing($file, PosService::PRODUCT_IMAGE, 1500, 1165);
402                    ProductImage::create(['product_id' => $product->id, 'picture' => $data['picture']]);
403                }
404            }
405
406            $data['product_code'] = 'P-' . $product->id . today()->format('dmY');
407
408            if ($request->filled('sku_no')) {
409                // Same fix as store(): honor an SKU the user actually edited
410                // instead of always overwriting it with an auto-generated one.
411                // (The typed sku_no was already applied by $product->update($data)
412                // above — this just still needs to finalize product_code.)
413                $product->update(['product_code' => $data['product_code']]);
414            } else {
415                // Auto-generate, same rule as before.
416                $sku = ''; // Initialize the SKU variable
417                $cat = Category::query()->findOrFail($data['category_id']);
418                $sku .= substr($cat->name, 0, 3); // Example: First 3 letters from the category name
419                $sku .= str_pad($product->id, 4, '0', STR_PAD_LEFT); // Example: Padded product ID with zeros to a length of 4
420                $product->update(['sku_no' => $sku, 'product_code' => $data['product_code']]);
421            }
422
423            // Sync multiple categories to pivot table
424            if ($request->has('category_ids')) {
425                $product->categories()->sync($request->category_ids);
426            }
427
428            // Handle Generated Variations
429            if ($request->has('variations') && is_array($request->variations)) {
430                // Delete existing variations to sync
431                $keptVariationIds = [];
432
433                foreach ($request->variations as $varData) {
434                    $variation = ! empty($varData['id'])
435                        ? $product->variations()->lockForUpdate()->findOrFail($varData['id'])
436                        : new ProductVariation;
437                    if ($variation->exists && isset($varData['original_stock']) && (int) $varData['original_stock'] !== (int) $variation->stock_quantity) {
438                        throw ValidationException::withMessages(['variations' => 'Variant stock changed. Reload before saving.']);
439                    }
440                    $variation->fill([
441                        'product_id' => $product->id,
442                        'sku' => $varData['sku'] ?? null,
443                        'price' => max(0, (float) ($varData['price'] ?? 0)),
444                        // A negative stock_quantity fails syncStockForProduct's
445                        // `> 0` check and falls through to UNLIMITED_STOCK — i.e.
446                        // a data-entry slip made a variant *unlimited* in the POS
447                        // instead of unsellable. Clamp at 0.
448                        'stock_quantity' => max(0, (int) ($varData['stock_qty'] ?? 0)),
449                        'stock_status' => $varData['stock_status'] ?? 'in_stock',
450                    ]);
451
452                    $variation->save();
453                    $keptVariationIds[] = $variation->id;
454
455                    if (! empty($varData['attrs'])) {
456                        $attrs = json_decode($varData['attrs'], true);
457                        if (is_array($attrs)) {
458                            $valIds = [];
459                            foreach ($attrs as $attr) {
460                                if (isset($attr['val_id']) && ! empty($attr['val_id'])) {
461                                    $valIds[] = $attr['val_id'];
462                                }
463                            }
464                            if (count($valIds) > 0) {
465                                $variation->attributeValues()->sync($valIds);
466                            }
467                        }
468                    }
469                }
470            }
471
472            // Never silently destroy stock or identities referenced by order history.
473            if (isset($keptVariationIds)) {
474                if ($product->variations()->whereNotIn('id', $keptVariationIds)->exists()) {
475                    throw ValidationException::withMessages(['variations' => 'Keep existing variations to preserve stock history. Set their quantity to zero or mark them out of stock instead.']);
476                }
477            }
478
479            // Same fix as store() — keep the Stock row(s) this product sells
480            // from in sync with whatever was just saved (base stock, or one
481            // row per variation).
482            StockService::syncStockForProduct($product->fresh());
483
484            DB::commit();
485
486            return redirect()->route('admin.product.index')->with('success', 'Product Successfully Updated');
487        } catch (\Throwable $e) {
488            DB::rollBack();
489
490            Log::error('Product update failed: ' . $e->getMessage(), ['exception' => $e]);
491
492            return redirect()->back()->withInput()->with('error', $e instanceof ValidationException ? collect($e->errors())->flatten()->first() : 'Something went wrong while updating the product. Please try again.');
493        }
494    }
495
496    /**
497     * Remove the specified resource from storage.
498     *
499     * @param  int  $id
500     * @return Response
501     */
502    public function destroy($id)
503    {
504        $product = Product::findOrFail($id);
505        $product->delete();
506
507        return redirect()->back()->with('success', 'Successfully Product deleted');
508    }
509
510    public function storeAttributeAjax(Request $request)
511    {
512        $request->validate([
513            'attribute' => 'required|string|max:100',
514            'values' => 'required|string',
515        ]);
516
517        $attribute = Attribute::firstOrCreate(['name' => trim($request->attribute)]);
518
519        // values can be comma-separated: "40,41,42,43"
520        $valueNames = array_filter(array_map('trim', explode(',', $request->values)));
521        $savedValues = [];
522        foreach ($valueNames as $v) {
523            $savedValues[] = $attribute->values()->firstOrCreate(['value' => $v]);
524        }
525
526        return response()->json([
527            'success' => true,
528            'attribute' => $attribute,
529            'values' => $savedValues,
530        ]);
531    }
532
533    /**
534     * Quick-create a Category via AJAX from the product form sidebar.
535     */
536    public function storeCategoryAjax(Request $request)
537    {
538        $request->validate([
539            'name' => 'required|string|max:255',
540            'parent_id' => 'nullable|exists:categories,id',
541        ]);
542
543        $category = Category::create([
544            'name' => trim($request->name),
545            'slug' => Str::slug($request->name),
546            'parent_id' => $request->parent_id ?: null,
547        ]);
548
549        return response()->json([
550            'success' => true,
551            'category' => $category,
552        ]);
553    }
554
555    /**
556     * Quick-create a Brand via AJAX from the product form sidebar.
557     */
558    public function storeBrandAjax(Request $request)
559    {
560        $request->validate([
561            'name' => 'required|string|max:255',
562        ]);
563
564        $brand = Brand::create([
565            'name' => trim($request->name),
566            'slug' => Str::slug($request->name),
567        ]);
568
569        return response()->json([
570            'success' => true,
571            'brand' => $brand,
572        ]);
573    }
574
575    public function showAjax($id)
576    {
577        $product = Product::with(['categories', 'images'])->findOrFail($id);
578        $variations = ProductVariation::where('product_id', $id)->with('attributeValues.attribute')->get();
579        $html = view('admin.product.modal.details-modal', compact('product', 'variations'))->render();
580
581        return response()->json(['html' => $html]);
582    }
583
584    public function updateStockAjax(Request $request, $id)
585    {
586        $request->validate([
587            'type' => 'required|in:in,out,status',
588            'qty' => 'required_if:type,in,out|nullable|integer|min:1',
589            'status' => 'required_if:type,status|nullable|in:in_stock,out_of_stock',
590        ]);
591
592        try {
593            DB::beginTransaction();
594            $variation = ProductVariation::findOrFail($id);
595            $product = Product::lockForUpdate()->findOrFail($variation->product_id);
596            $variation = $product->variations()->lockForUpdate()->findOrFail($id);
597            $qty = (int) $request->qty;
598
599            if ($request->type !== 'status' && ! $product->isStockTracked()) {
600                throw new \RuntimeException('Enable global and product stock management before adjusting quantity.');
601            }
602            if ($request->type === 'status' && $product->isStockTracked()) {
603                throw new \RuntimeException('Availability is calculated from quantity while stock management is on.');
604            }
605            if ($request->type === 'out' && $qty > (int) $variation->stock_quantity) {
606                throw new \RuntimeException('Stock out cannot exceed available quantity.');
607            }
608
609            if ($request->type == 'in') {
610                $variation->stock_quantity = max(0, $variation->stock_quantity + $qty);
611            } elseif ($request->type == 'out') {
612                $variation->stock_quantity = max(0, $variation->stock_quantity - $qty);
613            } elseif ($request->type == 'status') {
614                $variation->stock_status = $request->status;
615            }
616            $variation->save();
617
618            if ($request->type !== 'status') {
619                StockMovement::create([
620                    'product_id' => $product->id, 'qty' => $request->type === 'in' ? $qty : -$qty,
621                    'type' => 'adjustment', 'reference_type' => ProductVariation::class,
622                    'reference_id' => $variation->id, 'created_by' => auth()->id(),
623                ]);
624            }
625
626            $product = Product::findOrFail($variation->product_id);
627            $product->stock = max(0, (int) $product->variations()->sum('stock_quantity'));
628            $product->save();
629
630            // Without this, flipping a variant in/out of stock from the product
631            // list changed what the admin *sees* but not the derived `stocks`
632            // row the POS actually sells from — a silent desync.
633            StockService::syncStockForProduct($product->fresh());
634
635            DB::commit();
636
637            return response()->json([
638                'success' => true,
639                'message' => 'Stock updated successfully',
640                'current_stock' => $variation->stock_quantity,
641            ]);
642        } catch (\Exception $e) {
643            DB::rollBack();
644
645            return response()->json(['success' => false, 'message' => $e->getMessage()], 422);
646        }
647    }
648
649    public function updatePositions(Request $request)
650    {
651        $request->validate([
652            'positions' => 'required|array',
653            'positions.*' => 'integer',
654        ]);
655
656        try {
657            DB::beginTransaction();
658            foreach ($request->positions as $id => $position) {
659                Product::where('id', $id)->update(['position' => $position]);
660            }
661            DB::commit();
662            // CACHE FIX: Product::where(...)->update(...) above is a query-builder
663            // mass update, so it never fires Eloquent's saved/deleted events —
664            // Product::boot()'s cache-busting hook doesn't run for it. The
665            // cached products() list (used on the home page) is ordered by
666            // `position`, so it needs to be forgotten explicitly here or a
667            // reordered home page would keep showing the old order.
668            CacheService::forgetProductsList();
669
670            return response()->json(['success' => true, 'message' => 'Positions updated successfully']);
671        } catch (\Exception $e) {
672            DB::rollBack();
673
674            return response()->json(['success' => false, 'message' => $e->getMessage()]);
675        }
676    }
677}