Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
54.27% covered (warning)
54.27%
127 / 234
30.00% covered (danger)
30.00%
3 / 10
CRAP
0.00% covered (danger)
0.00%
0 / 1
PurchaseController
54.27% covered (warning)
54.27%
127 / 234
30.00% covered (danger)
30.00%
3 / 10
429.53
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
 index
0.00% covered (danger)
0.00%
0 / 3
0.00% covered (danger)
0.00%
0 / 1
2
 create
0.00% covered (danger)
0.00%
0 / 11
0.00% covered (danger)
0.00%
0 / 1
6
 searchProducts
0.00% covered (danger)
0.00%
0 / 52
0.00% covered (danger)
0.00%
0 / 1
156
 store
89.42% covered (warning)
89.42%
93 / 104
0.00% covered (danger)
0.00%
0 / 1
24.68
 autoPostExpense
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 purchase_details
100.00% covered (success)
100.00%
5 / 5
100.00% covered (success)
100.00%
1 / 1
1
 productDetails
0.00% covered (danger)
0.00%
0 / 6
0.00% covered (danger)
0.00%
0 / 1
12
 receive
0.00% covered (danger)
0.00%
0 / 19
0.00% covered (danger)
0.00%
0 / 1
56
 destroy
84.38% covered (warning)
84.38%
27 / 32
0.00% covered (danger)
0.00%
0 / 1
10.38
1<?php
2
3namespace App\Http\Controllers\admin;
4
5use App\Http\Controllers\Controller;
6use App\Models\Account;
7use App\Models\admin\Product;
8use App\Models\admin\Purchase;
9use App\Models\admin\PurchaseDetails;
10use App\Models\admin\PurchasePayment;
11use App\Models\admin\Supplier;
12use App\Models\Brand;
13use App\Models\Category;
14use App\Models\Color;
15use App\Models\Origin;
16use App\Models\PaymentType;
17use App\Models\Size;
18use App\Models\StockMovement;
19use App\Models\Transaction;
20use App\Models\Warehouse;
21use App\Repository\PurchaseRepository;
22use App\Service\StockService;
23use App\Services\Accounting\AccountingService;
24use App\Services\Location\BranchContext;
25use App\Services\Stock\InventoryService;
26use App\Services\Stock\LocationInventoryService;
27use App\Traits\PosTrait;
28use Illuminate\Http\Request;
29use Illuminate\Support\Facades\DB;
30use Illuminate\Support\Facades\Schema;
31use Illuminate\Validation\ValidationException;
32
33class PurchaseController extends Controller
34{
35    public $repositoy;
36
37    public function __construct(PurchaseRepository $repository)
38    {
39        $this->repositoy = $repository;
40    }
41
42    use PosTrait;
43
44    public function index()
45    {
46        $query = Purchase::query()->with(['supplier', 'purchase_payment']);
47        $data['purchases'] = app(BranchContext::class)->scope($query)->orderBy('id', 'DESC')->get();
48
49        return view('admin.purchase.index')->with($data);
50    }
51
52    public function create()
53    {
54        $data['suppliers'] = Supplier::getAll(true);
55        $data['categories'] = Category::getAll(true);
56        $data['brands'] = Brand::getAll(true);
57        $data['sizes'] = Size::getAll(true);
58        $data['colors'] = Color::getAll(true);
59        $data['paymentTypes'] = PaymentType::getAll(true);
60        $data['origins'] = Origin::getAll(true);
61        // Warehouse selector only shows when multi_warehouse is on; the default
62        // is still passed so the (hidden) single-location fallback has a value.
63        $data['warehouses'] = app(BranchContext::class)->warehouses()->active()->get();
64        $data['defaultWh'] = $data['warehouses']->firstWhere('is_default', true) ?? $data['warehouses']->first();
65        $data['accounts'] = feature('accounting') ? Account::where('is_active', true)->with('branch')->orderByDesc('is_default')->get() : collect();
66
67        return view('admin.purchase.manage-purchase')->with($data);
68    }
69
70    /** Lightweight type-ahead search for the purchase item picker. */
71    public function searchProducts(Request $request)
72    {
73        $term = trim((string) $request->query('q', ''));
74        if ($term === '' || mb_strlen($term) > 100) {
75            return response()->json(['results' => [], 'count' => 0]);
76        }
77
78        $products = Product::query()
79            ->select(['id', 'name', 'sku_no', 'product_code', 'price', 'selling_price'])
80            ->with(['variations.attributeValues.attribute'])
81            ->where(function ($query) use ($term) {
82                $like = '%' . $term . '%';
83                $query->where('name', 'like', $like)
84                    ->orWhere('sku_no', 'like', $like)
85                    ->orWhere('product_code', 'like', $like)
86                    ->orWhereHas('variations', fn ($variation) => $variation->where('sku', 'like', $like));
87            })
88            ->orderBy('name')
89            ->limit(25)
90            ->get();
91
92        $needle = mb_strtolower($term);
93        $results = $products->flatMap(function (Product $product) use ($needle) {
94            if ($product->variations->isEmpty()) {
95                return [[
96                    'key' => 'p_' . $product->id,
97                    'product_id' => $product->id,
98                    'variation_id' => null,
99                    'name' => $product->name,
100                    'sku' => $product->sku_no ?: $product->product_code,
101                    'purchase_price' => (float) $product->price,
102                    'selling_price' => (float) ($product->selling_price ?: $product->price),
103                ]];
104            }
105
106            $parentMatches = str_contains(mb_strtolower(implode(' ', [
107                $product->name, $product->sku_no, $product->product_code,
108            ])), $needle);
109
110            return $product->variations
111                ->filter(fn ($variation) => $parentMatches || str_contains(mb_strtolower((string) $variation->sku), $needle))
112                ->map(function ($variation) use ($product) {
113                    $attributes = $variation->attributeValues
114                        ->map(fn ($value) => ($value->attribute->name ?? 'Option') . ': ' . $value->value)
115                        ->implode(' / ');
116
117                    return [
118                        'key' => 'var_' . $variation->id,
119                        'product_id' => $product->id,
120                        'variation_id' => $variation->id,
121                        'name' => $product->name . ($attributes ? ' — ' . $attributes : ''),
122                        'sku' => $variation->sku ?: $product->sku_no ?: $product->product_code,
123                        'purchase_price' => (float) $product->price,
124                        'selling_price' => (float) ($variation->price ?: $product->selling_price ?: $product->price),
125                    ];
126                });
127        })->values();
128
129        return response()->json([
130            'results' => $results->take(20)->values(),
131            'count' => $results->count(),
132        ]);
133    }
134
135    public function store(Request $request)
136    {
137        $request->validate([
138            // Warehouse is only meaningful (and required) when multi_warehouse
139            // is on. A default single-location shop has no warehouses at all,
140            // so requiring one here is what blocked every purchase before.
141            'warehouse_id' => feature('multi_warehouse') ? 'required|exists:warehouses,id' : 'nullable',
142            'supplier_id' => 'required|exists:suppliers,id',
143            'payment_type_id' => 'required|exists:payment_types,id',
144            'status' => 'required|in:Received,Pending',
145            'paid' => 'required|numeric|min:0',
146            'account_id' => feature('accounting') ? 'nullable|exists:accounts,id' : 'nullable',
147            'date' => 'required|date',
148            'note' => 'nullable|string',
149            'discount_amount' => 'nullable|numeric|min:0',
150            'tax_amount' => 'nullable|numeric|min:0',
151            'shipping_charge' => 'nullable|numeric|min:0',
152            'items' => 'required|array|min:1',
153            'items.*.product_id' => 'required|exists:products,id',
154            'items.*.variation_id' => 'nullable|exists:product_variations,id',
155            'items.*.qty' => 'required|integer|min:1',
156            'items.*.purchase_price' => 'required|numeric|min:0',
157            'items.*.selling_price' => 'required|numeric|min:0',
158        ]);
159
160        try {
161            DB::beginTransaction();
162
163            $subtotal = 0;
164            // First pass to calculate subtotal
165            foreach ($request->items as $item) {
166                if ((float) $item['qty'] > 0) {
167                    $subtotal += ((float) $item['qty'] * (float) $item['purchase_price']);
168                }
169            }
170
171            $discount = (float) $request->discount_amount;
172            $tax = (float) $request->tax_amount;
173            $shipping = (float) $request->shipping_charge;
174            $grand_total = $subtotal - $discount + $tax + $shipping;
175
176            // Ignore any posted warehouse_id in a single-location shop.
177            $warehouseId = feature('multi_warehouse') ? $request->warehouse_id : null;
178            app(BranchContext::class)->ensureWarehouse($warehouseId ? (int) $warehouseId : null);
179            $branchId = $warehouseId ? Warehouse::find($warehouseId)?->branch_id : null;
180
181            // 1. Create Purchase Record
182            $purchaseData = [
183                'warehouse_id' => $warehouseId,
184                'supplier_id' => $request->supplier_id,
185                'status' => $request->status,
186                'date' => $request->date,
187                'subtotal' => $subtotal,
188                'discount_amount' => $discount,
189                'tax_amount' => $tax,
190                'shipping_charge' => $shipping,
191                'grand_total' => $grand_total,
192                'note' => $request->note,
193                'created_by' => auth()->id(),
194            ];
195            if (Schema::hasColumn('purchases', 'branch_id')) {
196                $purchaseData['branch_id'] = $branchId;
197            }
198            $purchase = Purchase::query()->create($purchaseData);
199
200            $purchase->update([
201                'ref' => 'pu-' . $purchase->id . ($request->ref_no ? '-' . $request->ref_no : ''),
202            ]);
203
204            // 2. Loop through Alpine Cart Items
205            foreach ($request->items as $item) {
206                $qty = (float) $item['qty'];
207                if ($qty <= 0) {
208                    continue;
209                }
210
211                $product = Product::lockForUpdate()->findOrFail($item['product_id']);
212                $variation = ! empty($item['variation_id'])
213                    ? $product->variations()->lockForUpdate()->findOrFail($item['variation_id']) : null;
214                if (! $variation && $product->variations()->exists()) {
215                    throw ValidationException::withMessages(['items' => 'Select the variation being received.']);
216                }
217                $received = $product->isStockTracked() && $request->status === 'Received' ? (int) $qty : 0;
218
219                // Create Purchase Details
220                $detail = PurchaseDetails::query()->create([
221                    'purchase_id' => $purchase->id,
222                    'product_id' => $item['product_id'],
223                    'variation_id' => $variation?->id,
224                    'inventory_received' => $received,
225                    'qty' => $qty,
226                    'category_id' => $product?->category_id,
227                    'purchase_price' => $item['purchase_price'],
228                    'selling_price' => $item['selling_price'],
229                    'total' => $qty * (float) $item['purchase_price'],
230                ]);
231
232                // Canonical stock: Product.stock is the single source of truth
233                // the POS/storefront read (via StockService::syncStockForProduct).
234                // Received units add to it and are logged to stock_movements —
235                // the exact pattern ProductionService uses (which is why we do
236                // NOT set warehouse_id here: the canonical stock_movements table
237                // has no such column, the receiving warehouse is recorded on the
238                // purchase itself, and nothing on the sell path reads it). We
239                // also deliberately don't write the dead `stocks.quantity`
240                // column or touch the product's price.
241                if ($received > 0) {
242                    app(LocationInventoryService::class)->receive(
243                        $product, $variation, $received, $purchase->warehouse_id, $detail
244                    );
245                }
246            }
247
248            // 5. Payment and Expenses
249            // We'll pass the calculated grand_total to PurchasePayment
250            $paid = (float) $request->paid;
251            $paymentData = [
252                'purchase_id' => $purchase->id,
253                'payment_type_id' => $request->payment_type_id,
254                'total' => $grand_total,
255                'paid' => $paid,
256                'due' => max(0, $grand_total - $paid),
257                'status' => $paid >= $grand_total ? 'Paid' : ($paid > 0 ? 'Partial' : 'Due'),
258                'note' => $request->note,
259            ];
260            if (Schema::hasColumn('purchase_payments', 'account_id')) {
261                $paymentData['account_id'] = feature('accounting') ? $request->account_id : null;
262            }
263            $payment = PurchasePayment::query()->create($paymentData);
264
265            $this->autoPostExpense($purchase, $payment);
266
267            DB::commit();
268
269            return response()->json([
270                'success' => true,
271                'redirect' => route('admin.purchase.details', $purchase->id),
272                'message' => 'Purchase successfully recorded.',
273            ]);
274        } catch (\Throwable $e) {
275            DB::rollBack();
276            if ($e instanceof ValidationException) {
277                return response()->json(['success' => false, 'message' => collect($e->errors())->flatten()->first()], 422);
278            }
279            report($e);
280
281            return response()->json([
282                'success' => false,
283                'message' => 'Could not save the purchase. Please try again.',
284            ], 500);
285        }
286    }
287
288    /** Records the supplier payment as Expense once, on the default account, under a "Purchase" category. */
289    private function autoPostExpense(Purchase $purchase, PurchasePayment $payment): void
290    {
291        app(AccountingService::class)->autoPostPurchasePayment($purchase, $payment);
292    }
293
294    public function purchase_details($id)
295    {
296        $data['purchase'] = Purchase::query()->where('id', $id)
297            ->with(['supplier', 'purchase_details', 'purchase_payment'])
298            ->firstOrFail();
299        $data['total'] = PurchaseDetails::query()->where('purchase_id', $id)->sum('total');
300
301        return view('admin.purchase.details')->with($data);
302    }
303
304    public function productDetails(Request $request)
305    {
306        if ($request->ajax()) {
307            if ($request->filled('key')) {
308                [$product, $variation] = app(InventoryService::class)->resolve($request->key);
309
310                return response()->json(['product_id' => $product->id, 'variation_id' => $variation?->id, 'price' => $product->price]);
311            }
312            $product = Product::query()->findOrFail($request->id);
313
314            return \response()->json($product->price);
315        }
316    }
317
318    public function receive(Purchase $purchase)
319    {
320        DB::transaction(function () use ($purchase) {
321            $purchase = Purchase::lockForUpdate()->findOrFail($purchase->id);
322            if ($purchase->status === 'Received') {
323                return;
324            }
325            abort_unless($purchase->status === 'Pending', 422, 'Only Pending purchases can be received.');
326            foreach (PurchaseDetails::where('purchase_id', $purchase->id)->orderBy('product_id')->lockForUpdate()->get() as $detail) {
327                $product = Product::lockForUpdate()->findOrFail($detail->product_id);
328                $variation = $detail->variation_id ? $product->variations()->lockForUpdate()->findOrFail($detail->variation_id) : null;
329                abort_if(! $variation && $product->variations()->exists(), 422, 'This purchase needs a specific variation before it can be received.');
330                // Legacy pending purchases may already have changed stock. Require reconciliation.
331                abort_if($detail->inventory_received === null, 422, 'Reconcile this historical purchase before receiving it.');
332                $qty = $product->isStockTracked() ? (int) $detail->qty : 0;
333                if ($qty > 0) {
334                    app(LocationInventoryService::class)->receive(
335                        $product, $variation, $qty, $purchase->warehouse_id, $detail
336                    );
337                }
338                $detail->update(['inventory_received' => $qty]);
339            }
340            $purchase->update(['status' => 'Received']);
341        });
342
343        return back()->with('success', 'Purchase received. Stock updated once.');
344    }
345
346    public function destroy(Purchase $purchase)
347    {
348        try {
349            DB::beginTransaction();
350
351            // 1. Reverse the stock this purchase added — mirror of store()'s
352            // increment. Never drop a product below zero (some of the received
353            // units may already have been sold since).
354            $purchase = Purchase::lockForUpdate()->findOrFail($purchase->id);
355            $purchase->loadMissing('purchase_details');
356            foreach ($purchase->purchase_details as $detail) {
357                $product = Product::lockForUpdate()->find($detail->product_id);
358                if (! $product) {
359                    continue;
360                }
361
362                // Reverse only recorded receipts, never Pending or untracked purchases.
363                $reverseQty = $detail->inventory_received !== null ? (float) $detail->inventory_received : (float) StockMovement::where('reference_type', Purchase::class)
364                    ->where('reference_id', $purchase->id)->where('product_id', $product->id)
365                    ->sum('qty');
366                if ($reverseQty <= 0) {
367                    continue;
368                }
369                $variation = $detail->variation_id ? $product->variations()->lockForUpdate()->findOrFail($detail->variation_id) : null;
370                app(LocationInventoryService::class)->removeReceived(
371                    $product, $variation, (int) $reverseQty, $purchase->warehouse_id, $purchase
372                );
373            }
374
375            // 2. Reverse the auto-posted expense with a contra entry (the posted
376            // transaction row is never edited or hard-deleted).
377            $payment = $purchase->purchase_payment;
378            if ($payment) {
379                $txn = Transaction::where('source', 'purchase_payment')
380                    ->where('source_id', $payment->id)
381                    ->first();
382                if ($txn && ! $txn->isReversed()) {
383                    app(AccountingService::class)->reverse($txn, "Reversal for deleted purchase {$purchase->ref}");
384                }
385            }
386
387            // 3. Now remove the purchase itself.
388            $purchase->purchase_details()->delete();
389            $purchase->purchase_payment()->delete();
390            $purchase->delete();
391
392            DB::commit();
393
394            return redirect()->route('admin.purchase.index')->with('success', 'Purchase deleted — stock and accounting reversed.');
395        } catch (\Throwable $e) {
396            DB::rollBack();
397            report($e);
398
399            return back()->with('error', 'Could not delete this purchase. Please try again.');
400        }
401    }
402}