middleware('check.permission:transfers.read')->only(['index']); $this->middleware('check.permission:transfers.create')->only(['create', 'store']); $this->middleware('check.permission:transfers.update')->only(['edit', 'update']); $this->middleware('check.permission:transfers.delete')->only(['destroy', 'deleteAll']); } public function index(Request $request) { $user = auth()->user(); $activeBranchId = $user->active_branch_id; $branches = Branch::withTrashed()->where('business_id', $user->business_id)->latest()->get(); $transfers = Transfer::with(['fromWarehouse:id,name', 'toWarehouse:id,name', 'toBranch:id,name', 'fromBranch:id,name', 'transferProducts']) ->where('business_id', $user->business_id) ->when($activeBranchId, function ($q) use ($activeBranchId) { $q->where(function ($query) use ($activeBranchId) { $query->where('from_branch_id', $activeBranchId) ->orWhere('to_branch_id', $activeBranchId); }); }) ->when($request->branch_id && !$activeBranchId, function ($q) use ($request) { $q->where(function ($query) use ($request) { $query->where('from_branch_id', $request->branch_id)->orWhere('to_branch_id', $request->branch_id); }); }) ->when($request->search, function ($q) use ($request) { $search = $request->search; $q->where(function ($q) use ($search) { $q->where('note', 'like', '%' . $search . '%') ->orWhere('status', 'like', '%' . $search . '%') ->orWhere('invoice_no', 'like', '%' . $search . '%') ->orWhereHas('fromWarehouse', function ($q) use ($search) { $q->where('name', 'like', '%' . $search . '%'); }) ->orWhereHas('toWarehouse', function ($q) use ($search) { $q->where('name', 'like', '%' . $search . '%'); }) ->orWhereHas('toBranch', function ($q) use ($search) { $q->where('name', 'like', '%' . $search . '%'); }) ->orWhereHas('fromBranch', function ($q) use ($search) { $q->where('name', 'like', '%' . $search . '%'); }); }); }) ->latest() ->paginate($request->per_page ?? 20)->appends($request->query()); if ($request->ajax()) { return response()->json([ 'data' => view('business::transfers.datas', compact('transfers','branches'))->render() ]); } return view('business::transfers.index', compact('transfers', 'branches')); } public function create() { $business_id = auth()->user()->business_id; $warehouses = Warehouse::where('business_id', $business_id)->latest()->get(); $branches = Branch::withTrashed()->where('business_id', auth()->user()->business_id)->latest()->get(); return view('business::transfers.create', compact('warehouses', 'branches')); } public function store(Request $request) { // Normalize serial_numbers if ($request->has('products')) { $products = $request->products; foreach ($products as $key => $item) { if (!empty($item['serial_numbers']) && is_string($item['serial_numbers'])) { $products[$key]['serial_numbers'] = json_decode($item['serial_numbers'], true); } } $request->merge(['products' => $products]); } $request->validate([ 'from_branch_id' => 'nullable|exists:branches,id', 'to_branch_id' => 'nullable|exists:branches,id|required_with:from_branch_id', 'from_warehouse_id' => 'nullable|exists:warehouses,id|required_with:to_warehouse_id', 'to_warehouse_id' => 'nullable|exists:warehouses,id|required_with:from_warehouse_id', 'transfer_date' => 'required|date', 'note' => 'nullable|string', 'status' => 'required|in:pending,completed,cancelled', 'shipping_charge' => 'nullable|numeric|min:0', 'products' => 'required|array|min:1', 'products.*.quantity' => 'required|integer|min:1', 'products.*.unit_price' => 'required|numeric|min:0', 'products.*.discount' => 'nullable|numeric|min:0', 'products.*.tax' => 'nullable|numeric|min:0', 'products.*.serial_numbers' => 'nullable|array', ]); $user = auth()->user(); $fromBranch = $request->from_branch_id ?? $user->branch_id ?? $user->active_branch_id; $toBranch = $request->to_branch_id; $fromWh = $request->from_warehouse_id; $toWh = $request->to_warehouse_id; // Transfer validation if ($user->active_branch_id && $toBranch && $toWh) { return response()->json(['message' => 'You cannot transfer to another branch warehouse.'], 400); } if (!$toBranch && !$toWh) { return response()->json(['message' => 'Please select a destination branch or warehouse.'], 400); } if ($fromBranch && !$fromWh && $fromBranch == $toBranch) { return response()->json(['message' => 'Same branch transfer is not allowed.'], 400); } if ($fromWh && $fromWh == $toWh) { return response()->json(['message' => 'Same warehouse transfer is not allowed.'], 400); } DB::beginTransaction(); try { $subTotal = $totalDiscount = $totalTax = 0; // products re-arrange $products = collect($request->products)->map(function ($item, $key) { return array_merge($item, ['stock_id' => $item['stock_id'] ?? $key]); }); // Calculate subtotal using serial count if available foreach ($products as $item) { $serials = !empty($item['serial_numbers']) ? (is_array($item['serial_numbers']) ? $item['serial_numbers'] : json_decode($item['serial_numbers'], true)) : []; $qty = !empty($serials) ? count($serials) : $item['quantity']; $subTotal += $qty * $item['unit_price']; $totalTax += $item['tax'] ?? 0; $totalDiscount += $item['discount'] ?? 0; } $shipping = $request->shipping_charge ?? 0; $grandTotal = $subTotal + $totalTax - $totalDiscount + $shipping; $transfer = Transfer::create([ 'business_id' => $user->business_id, 'user_id' => auth()->id(), 'from_branch_id' => $fromBranch, 'from_warehouse_id' => $fromWh, 'to_branch_id' => $toBranch, 'to_warehouse_id' => $toWh, 'transfer_date' => $request->transfer_date, 'status' => $request->status, 'note' => $request->note, 'shipping_charge' => $shipping, 'sub_total' => $subTotal, 'total_discount' => $totalDiscount, 'total_tax' => $totalTax, 'grand_total' => $grandTotal, ]); $transferProducts = []; foreach ($products as $item) { $stock = Stock::find($item['stock_id']); if (!$stock) { return response()->json(['message' => "Invalid stock ID: {$item['stock_id']}"], 400); } // Ensure serial_numbers is array $serials = !empty($item['serial_numbers']) ? (is_array($item['serial_numbers']) ? $item['serial_numbers'] : json_decode($item['serial_numbers'], true)) : []; $quantity = !empty($serials) ? count($serials) : $item['quantity']; $transferProducts[] = [ 'transfer_id' => $transfer->id, 'stock_id' => $stock->id, 'product_id' => $stock->product_id, 'quantity' => $quantity, 'unit_price' => $item['unit_price'], 'discount' => $item['discount'] ?? 0, 'tax' => $item['tax'] ?? 0, 'serial_numbers' => !empty($serials) ? json_encode($serials) : null, 'created_at' => now(), 'updated_at' => now(), ]; if ($request->status === 'completed') { // FROM stock $fromStock = Stock::where('id', $stock->id) ->when($fromBranch, fn($q) => $q->where('branch_id', $fromBranch)) ->when($fromWh, fn($q) => $q->where('warehouse_id', $fromWh)) ->first(); if (!$fromStock || $fromStock->productStock < $quantity) { return response()->json(['message' => 'Insufficient stock for product ID: ' . $stock->product_id], 400); } // Remove serials from source if (!empty($serials)) { $existingSerials = is_array($fromStock->serial_numbers) ? $fromStock->serial_numbers : json_decode($fromStock->serial_numbers ?? '[]', true); $fromStock->serial_numbers = array_values(array_diff($existingSerials, $serials)); } $fromStock->decrement('productStock', $quantity); $fromStock->save(); // TO stock $toStock = Stock::where('product_id', $fromStock->product_id) ->when($toBranch, fn($q) => $q->where('branch_id', $toBranch)) ->when($toWh, fn($q) => $q->where('warehouse_id', $toWh)) ->where(function ($q) use ($fromStock) { // if destination stock batch is NULL, skip batch compare $q->whereNull('batch_no') // otherwise match same batch ->orWhere('batch_no', $fromStock->batch_no); }) ->first(); if (!$toStock) { $toStock = new Stock([ 'business_id' => $user->business_id, 'product_id' => $fromStock->product_id, 'warehouse_id' => $toWh, 'productStock' => 0, 'productPurchasePrice' => $fromStock->productPurchasePrice, 'profit_percent' => $fromStock->profit_percent, 'productSalePrice' => $fromStock->productSalePrice, 'exclusive_price' => $fromStock->exclusive_price, 'productWholeSalePrice' => $fromStock->productWholeSalePrice, 'productDealerPrice' => $fromStock->productDealerPrice, 'mfg_date' => $fromStock->mfg_date, 'expire_date' => $fromStock->expire_date, 'serial_numbers' => !empty($serials) ? $serials : null, ]); // Safe branch assignment $toStock->branch_id = $toBranch ?? $user->active_branch_id; // Save without events/global scopes Stock::withoutEvents(function () use ($toStock) { $toStock->save(); }); } // Increment destination stock safely $toStock->increment('productStock', $quantity); // Merge serial numbers if exist if (!empty($serials)) { $toStockSerials = is_array($toStock->serial_numbers) ? $toStock->serial_numbers : json_decode($toStock->serial_numbers ?? '[]', true); $toStock->serial_numbers = array_values(array_unique(array_merge($toStockSerials, $serials))); $toStock->save(); } } } TransferProduct::insert($transferProducts); DB::commit(); return response()->json([ 'message' => __('Transfer saved successfully.'), 'data' => $transfer, 'redirect' => route('business.transfers.index'), ]); } catch (\Exception $e) { DB::rollBack(); return response()->json([ 'success' => false, 'message' => $e->getMessage() ], 500); } } public function edit($id) { $user = auth()->user(); $business_id = $user->business_id; $transfer = Transfer::with(['transferProducts.product', 'transferProducts.stock']) ->where('business_id', $business_id) ->findOrFail($id); // Determine the branch to filter warehouses $branchId = $user->branch_id ?? $user->active_branch_id; $warehousesQuery = Warehouse::where('business_id', $business_id); if ($branchId) { $warehousesQuery->where('branch_id', $branchId); } $warehouses = $warehousesQuery->latest()->get(); $branches = Branch::withTrashed() ->where('business_id', $business_id) ->latest() ->get(); return view('business::transfers.edit', compact('transfer', 'warehouses', 'branches')); } public function update(Request $request, $id) { // Normalize serial_numbers if ($request->has('products')) { $products = $request->products; foreach ($products as $key => $item) { if (!empty($item['serial_numbers']) && is_string($item['serial_numbers'])) { $products[$key]['serial_numbers'] = json_decode($item['serial_numbers'], true); } } $request->merge(['products' => $products]); } $request->validate([ 'from_branch_id' => 'nullable|exists:branches,id', 'to_branch_id' => 'nullable|exists:branches,id|required_with:from_branch_id', 'from_warehouse_id' => 'nullable|exists:warehouses,id|required_with:to_warehouse_id', 'to_warehouse_id' => 'nullable|exists:warehouses,id|required_with:from_warehouse_id', 'transfer_date' => 'required|date', 'note' => 'nullable|string', 'status' => 'required|in:pending,completed,cancelled', 'shipping_charge' => 'nullable|numeric|min:0', 'products' => 'required|array|min:1', 'products.*.quantity' => 'required|integer|min:1', 'products.*.unit_price' => 'required|numeric|min:0', 'products.*.discount' => 'nullable|numeric|min:0', 'products.*.tax' => 'nullable|numeric|min:0', 'products.*.serial_numbers' => 'nullable|array', ]); $transfer = Transfer::findOrFail($id); if ($request->status === 'cancelled') { $transfer->update(['status' => 'cancelled']); return response()->json([ 'message' => __('Transfer cancelled successfully.'), 'redirect' => route('business.transfers.index') ]); } $user = auth()->user(); $fromBranch = $request->from_branch_id ?? $user->branch_id ?? $user->active_branch_id; $toBranch = $request->to_branch_id; $fromWh = $request->from_warehouse_id; $toWh = $request->to_warehouse_id; DB::beginTransaction(); try { $oldStatus = $transfer->status; $subTotal = $totalDiscount = $totalTax = 0; // Calculate totals using serial count foreach ($request->products as $item) { $serials = !empty($item['serial_numbers']) ? $item['serial_numbers'] : []; $qty = !empty($serials) ? count($serials) : $item['quantity']; $subTotal += $qty * $item['unit_price']; $totalDiscount += $item['discount'] ?? 0; $totalTax += $item['tax'] ?? 0; } $shipping = $request->shipping_charge ?? 0; $grandTotal = $subTotal + $totalTax - $totalDiscount + $shipping; // Update transfer $transfer->update([ 'user_id' => auth()->id(), 'from_branch_id' => $fromBranch, 'to_branch_id' => $toBranch, 'from_warehouse_id' => $fromWh, 'to_warehouse_id' => $toWh, 'transfer_date' => $request->transfer_date, 'note' => $request->note, 'status' => $request->status, 'shipping_charge' => $shipping, 'sub_total' => $subTotal, 'total_discount' => $totalDiscount, 'total_tax' => $totalTax, 'grand_total' => $grandTotal, ]); // Delete old transfer products TransferProduct::where('transfer_id', $transfer->id)->delete(); foreach ($request->products as $stockId => $item) { $serials = !empty($item['serial_numbers']) ? $item['serial_numbers'] : []; $quantity = !empty($serials) ? count($serials) : $item['quantity']; // Insert updated transfer product TransferProduct::create([ 'transfer_id' => $transfer->id, 'stock_id' => $stockId, 'quantity' => $quantity, 'unit_price' => $item['unit_price'] ?? 0, 'discount' => $item['discount'] ?? 0, 'tax' => $item['tax'] ?? 0, 'serial_numbers' => !empty($serials) ? $serials : null, ]); $fromStock = Stock::where('id', $stockId) ->when($fromBranch, fn($q) => $q->where('branch_id', $fromBranch)) ->when($fromWh, fn($q) => $q->where('warehouse_id', $fromWh)) ->first(); if (!$fromStock) { return response()->json([ 'message' => "From stock not found for stock ID: {$stockId}" ], 400); } $toStock = Stock::where('product_id', $fromStock->product_id) ->when($toBranch, fn($q) => $q->where('branch_id', $toBranch)) ->when($toWh, fn($q) => $q->where('warehouse_id', $toWh)) ->where(function ($q) use ($fromStock) { // if destination stock batch is NULL, skip batch compare $q->whereNull('batch_no') // otherwise match same batch ->orWhere('batch_no', $fromStock->batch_no); }) ->first(); if (!$toStock) { $toStock = new Stock([ 'business_id' => $user->business_id, 'product_id' => $fromStock->product_id, 'warehouse_id' => $toWh, 'productStock' => 0, 'productPurchasePrice' => $fromStock->productPurchasePrice, 'exclusive_price' => $fromStock->exclusive_price, 'profit_percent' => $fromStock->profit_percent, 'productSalePrice' => $fromStock->productSalePrice, 'productWholeSalePrice' => $fromStock->productWholeSalePrice, 'productDealerPrice' => $fromStock->productDealerPrice, 'mfg_date' => $fromStock->mfg_date, 'expire_date' => $fromStock->expire_date, ]); $toStock->branch_id = $toBranch ?? $user->active_branch_id; Stock::withoutEvents(fn() => $toStock->save()); } // Only move stock if changing pending to completed if ($oldStatus === 'pending' && $request->status === 'completed') { // Handle serials for source if (!empty($serials)) { $existing = $fromStock->serial_numbers ?? []; foreach ($serials as $s) { if (!in_array($s, $existing)) { return response()->json([ 'message' => "Serial {$s} not found in source stock." ], 400); } } // Remove from source $fromStock->serial_numbers = array_values(array_diff($existing, $serials)); $fromStock->productStock = count($fromStock->serial_numbers); if ($fromStock->productStock === 0) { $fromStock->serial_numbers = null; } } else { $fromStock->decrement('productStock', $quantity); } $fromStock->save(); // Handle serials for destination if (!empty($serials)) { $toExisting = $toStock->serial_numbers ?? []; $toStock->serial_numbers = array_values(array_unique(array_merge($toExisting, $serials))); $toStock->productStock = count($toStock->serial_numbers); } else { $toStock->increment('productStock', $quantity); } $toStock->save(); } } DB::commit(); return response()->json([ 'message' => __('Transfer updated successfully.'), 'redirect' => route('business.transfers.index'), ]); } catch (\Exception $e) { DB::rollBack(); return response()->json([ 'message' => 'Error: ' . $e->getMessage(), ], 500); } } public function destroy(string $id) { $business_id = auth()->user()->business_id; $transfer = Transfer::with('transferProducts')->where('business_id', $business_id)->findOrFail($id); DB::beginTransaction(); try { if ($transfer->status == 'completed') { foreach ($transfer->transferProducts as $tp) { $stock = Stock::find($tp->stock_id); // check if destination has enough stock to rollback $toStock = Stock::where('product_id', $tp->product_id) ->where('warehouse_id', $transfer->to_warehouse_id) ->where('branch_id', $transfer->to_branch_id) ->where(function ($q) use ($stock) { $q->whereNull('batch_no') // if batch NULL → skip compare ->orWhere('batch_no', optional($stock)->batch_no); }) ->first(); if (!$toStock || $toStock->quantity < $tp->quantity) { return response()->json([ 'message' => __('Cannot delete. Destination stock not enough to reverse this transfer'), ], 422); } } foreach ($transfer->transferProducts as $tp) { $stock = Stock::find($tp->stock_id); // decrease destination stock $toStock = Stock::where('product_id', $tp->product_id) ->where('warehouse_id', $transfer->to_warehouse_id) ->where('branch_id', $transfer->to_branch_id) ->where(function ($q) use ($stock) { $q->whereNull('batch_no') ->orWhere('batch_no', optional($stock)->batch_no); }) ->first(); if ($toStock) { $toStock->decrement('quantity', $tp->quantity); } // increase source stock $fromStock = Stock::where('product_id', $tp->product_id) ->where('warehouse_id', $transfer->from_warehouse_id) ->where('branch_id', $transfer->from_branch_id) ->where(function ($q) use ($stock) { $q->whereNull('batch_no') ->orWhere('batch_no', optional($stock)->batch_no); }) ->first(); if ($fromStock) { $fromStock->increment('quantity', $tp->quantity); } } } $transfer->delete(); DB::commit(); return response()->json([ 'message' => __('Transfer deleted successfully'), 'redirect' => route('business.transfers.index'), ]); } catch (\Exception $e) { DB::rollBack(); return response()->json([ 'message' => __('Error while deleting transfer: ') . $e->getMessage(), ], 500); } } public function deleteAll(Request $request) { $business_id = auth()->user()->business_id; DB::beginTransaction(); try { $transfers = Transfer::with('transferProducts') ->where('business_id', $business_id) ->whereIn('id', $request->ids) ->get(); foreach ($transfers as $transfer) { if ($transfer->status === 'completed') { foreach ($transfer->transferProducts as $tp) { $stock = Stock::find($tp->stock_id); // check if destination has enough stock to rollback $toStock = Stock::where('product_id', $tp->product_id) ->where('warehouse_id', $transfer->to_warehouse_id) ->where('branch_id', $transfer->to_branch_id) ->where(function ($q) use ($stock) { $q->whereNull('batch_no') ->orWhere('batch_no', optional($stock)->batch_no); }) ->first(); if (!$toStock || $toStock->quantity < $tp->quantity) { return response()->json([ 'message' => __('Cannot delete. Destination stock not enough to reverse transfer ID: ') . $transfer->id, ], 422); } } } } foreach ($transfers as $transfer) { if ($transfer->status === 'completed') { foreach ($transfer->transferProducts as $tp) { $stock = Stock::find($tp->stock_id); // decrease destination stock $toStock = Stock::where('product_id', $tp->product_id) ->where('warehouse_id', $transfer->to_warehouse_id) ->where('branch_id', $transfer->to_branch_id) ->where(function ($q) use ($stock) { $q->whereNull('batch_no') ->orWhere('batch_no', optional($stock)->batch_no); }) ->first(); if ($toStock) { $toStock->decrement('quantity', $tp->quantity); } // increase source stock $fromStock = Stock::where('product_id', $tp->product_id) ->where('warehouse_id', $transfer->from_warehouse_id) ->where('branch_id', $transfer->from_branch_id) ->where(function ($q) use ($stock) { $q->whereNull('batch_no') ->orWhere('batch_no', optional($stock)->batch_no); }) ->first(); if ($fromStock) { $fromStock->increment('quantity', $tp->quantity); } } } $transfer->delete(); } DB::commit(); return response()->json([ 'message' => __('Selected transfers deleted successfully.'), 'redirect' => route('business.transfers.index'), ]); } catch (\Exception $e) { DB::rollBack(); return response()->json([ 'message' => __('Error while deleting transfers: ') . $e->getMessage(), ], 500); } } public function exportExcel() { return Excel::download(new ExportTransfer, 'transfer.xlsx'); } public function exportCsv() { return Excel::download(new ExportTransfer, 'transfer.csv'); } public function exportPdf(Request $request) { $user = auth()->user(); $activeBranchId = $user->active_branch_id; $transfers = Transfer::with(['fromWarehouse:id,name', 'toWarehouse:id,name', 'toBranch:id,name', 'fromBranch:id,name', 'transferProducts']) ->where('business_id', $user->business_id) ->when($activeBranchId, function ($q) use ($activeBranchId) { $q->where(function ($query) use ($activeBranchId) { $query->where('from_branch_id', $activeBranchId) ->orWhere('to_branch_id', $activeBranchId); }); }) ->when($request->branch_id && !$activeBranchId, function ($q) use ($request) { $q->where(function ($query) use ($request) { $query->where('from_branch_id', $request->branch_id)->orWhere('to_branch_id', $request->branch_id); }); }) ->when($request->search, function ($q) use ($request) { $search = $request->search; $q->where(function ($q) use ($search) { $q->where('note', 'like', '%' . $search . '%') ->orWhere('status', 'like', '%' . $search . '%') ->orWhere('invoice_no', 'like', '%' . $search . '%') ->orWhereHas('fromWarehouse', function ($q) use ($search) { $q->where('name', 'like', '%' . $search . '%'); }) ->orWhereHas('toWarehouse', function ($q) use ($search) { $q->where('name', 'like', '%' . $search . '%'); }) ->orWhereHas('toBranch', function ($q) use ($search) { $q->where('name', 'like', '%' . $search . '%'); }) ->orWhereHas('fromBranch', function ($q) use ($search) { $q->where('name', 'like', '%' . $search . '%'); }); }); }) ->latest() ->limit(request('per_page') ?? 20)->get(); return PdfService::render('business::transfers.pdf', compact('transfers'),'transfers.pdf'); } }