442 lines
15 KiB
PHP
442 lines
15 KiB
PHP
<?php
|
|
|
|
namespace App\Http\Controllers;
|
|
|
|
use App\Models\Item;
|
|
use App\Models\Warehouse;
|
|
use App\Models\InventoryTransaction;
|
|
use App\Models\Project;
|
|
use App\Models\Currency;
|
|
use App\Helpers\CurrencyHelper;
|
|
use Illuminate\Http\Request;
|
|
use Illuminate\Support\Facades\Auth;
|
|
use Illuminate\Support\Facades\DB;
|
|
|
|
class InventoryController extends Controller
|
|
{
|
|
// ==========================================
|
|
// DASHBOARD
|
|
// ==========================================
|
|
|
|
public function index()
|
|
{
|
|
$totalItems = Item::count();
|
|
$activeItems = Item::where('is_active', true)->count();
|
|
$totalWarehouses = Warehouse::where('is_active', true)->count();
|
|
$recentTransactions = InventoryTransaction::with(['item', 'warehouse', 'createdBy'])
|
|
->orderBy('created_at', 'desc')
|
|
->limit(10)
|
|
->get();
|
|
|
|
// Low stock items (below min_stock or zero)
|
|
$lowStockItems = Item::where('is_active', true)
|
|
->with(['inventoryTransactions'])
|
|
->get()
|
|
->filter(function ($item) {
|
|
$stock = $this->getCurrentStockForItem($item->id);
|
|
$minStock = $item->min_stock ?? 0;
|
|
return $stock <= $minStock;
|
|
})
|
|
->take(10);
|
|
|
|
return view('inventory.index', compact(
|
|
'totalItems', 'activeItems', 'totalWarehouses',
|
|
'recentTransactions', 'lowStockItems'
|
|
));
|
|
}
|
|
|
|
// ==========================================
|
|
// ITEMS CRUD
|
|
// ==========================================
|
|
|
|
public function items(Request $request)
|
|
{
|
|
$query = Item::with(['inventoryTransactions.currency', 'inventoryTransactions.warehouse']);
|
|
|
|
if ($request->filled('search')) {
|
|
$search = $request->search;
|
|
$query->where(function ($q) use ($search) {
|
|
$q->where('name', 'like', "%{$search}%")
|
|
->orWhere('code', 'like', "%{$search}%")
|
|
->orWhere('category', 'like', "%{$search}%");
|
|
});
|
|
}
|
|
if ($request->filled('category')) {
|
|
$query->where('category', $request->category);
|
|
}
|
|
if ($request->filled('is_active')) {
|
|
$query->where('is_active', $request->boolean('is_active'));
|
|
}
|
|
|
|
$items = $query->orderBy('name')->paginate(20)->appends($request->query());
|
|
$categories = Item::distinct()->pluck('category')->filter()->values();
|
|
|
|
// Calculate current stock for each item
|
|
$items->through(function ($item) {
|
|
$item->current_stock = $this->getCurrentStockForItem($item->id);
|
|
return $item;
|
|
});
|
|
|
|
return view('inventory.items', compact('items', 'categories'));
|
|
}
|
|
|
|
public function createItem()
|
|
{
|
|
$this->authorize('create', Item::class);
|
|
|
|
$item = null;
|
|
$categories = Item::distinct()->pluck('category')->filter()->values();
|
|
|
|
return view('inventory.item-form', compact('item', 'categories'));
|
|
}
|
|
|
|
public function storeItem(Request $request)
|
|
{
|
|
$this->authorize('create', Item::class);
|
|
|
|
$validated = $request->validate([
|
|
'name' => 'required|string|max:255',
|
|
'code' => 'nullable|string|max:50|unique:items,code',
|
|
'unit' => 'required|string|max:50',
|
|
'category' => 'nullable|string|max:100',
|
|
'description' => 'nullable|string',
|
|
'min_stock' => 'nullable|numeric|min:0',
|
|
'is_active' => 'boolean',
|
|
]);
|
|
|
|
$validated['tenant_id'] = $this->getTenantId();
|
|
$validated['is_active'] = $request->boolean('is_active', true);
|
|
|
|
$item = Item::create($validated);
|
|
|
|
return redirect()
|
|
->route('inventory.items')
|
|
->with('success', __('inventory.item_created'));
|
|
}
|
|
|
|
public function editItem(Item $item)
|
|
{
|
|
$this->authorize('update', $item);
|
|
|
|
$categories = Item::distinct()->pluck('category')->filter()->values();
|
|
|
|
return view('inventory.item-form', compact('item', 'categories'));
|
|
}
|
|
|
|
public function updateItem(Request $request, Item $item)
|
|
{
|
|
$this->authorize('update', $item);
|
|
|
|
$validated = $request->validate([
|
|
'name' => 'required|string|max:255',
|
|
'code' => 'nullable|string|max:50|unique:items,code,' . $item->id,
|
|
'unit' => 'required|string|max:50',
|
|
'category' => 'nullable|string|max:100',
|
|
'description' => 'nullable|string',
|
|
'min_stock' => 'nullable|numeric|min:0',
|
|
'is_active' => 'boolean',
|
|
]);
|
|
|
|
$validated['is_active'] = $request->boolean('is_active');
|
|
|
|
$item->update($validated);
|
|
|
|
return redirect()
|
|
->route('inventory.items')
|
|
->with('success', __('inventory.item_updated'));
|
|
}
|
|
|
|
public function destroyItem(Item $item)
|
|
{
|
|
$this->authorize('delete', $item);
|
|
|
|
// Check if item has transactions
|
|
if ($item->inventoryTransactions()->count() > 0) {
|
|
return back()->withErrors(['item' => __('inventory.item_has_transactions')]);
|
|
}
|
|
|
|
$item->delete();
|
|
|
|
return redirect()
|
|
->route('inventory.items')
|
|
->with('success', __('inventory.item_deleted'));
|
|
}
|
|
|
|
// ==========================================
|
|
// WAREHOUSES CRUD
|
|
// ==========================================
|
|
|
|
public function warehouses(Request $request)
|
|
{
|
|
$query = Warehouse::with(['project']);
|
|
|
|
if ($request->filled('project_id')) {
|
|
$query->where('project_id', $request->project_id);
|
|
}
|
|
if ($request->filled('is_active')) {
|
|
$query->where('is_active', $request->boolean('is_active'));
|
|
}
|
|
|
|
$warehouses = $query->orderBy('name')->paginate(20)->appends($request->query());
|
|
$projects = Project::all();
|
|
|
|
return view('inventory.warehouses', compact('warehouses', 'projects'));
|
|
}
|
|
|
|
public function createWarehouse()
|
|
{
|
|
$this->authorize('create', Warehouse::class);
|
|
|
|
$warehouse = null;
|
|
$projects = Project::all();
|
|
|
|
return view('inventory.warehouse-form', compact('warehouse', 'projects'));
|
|
}
|
|
|
|
public function storeWarehouse(Request $request)
|
|
{
|
|
$this->authorize('create', Warehouse::class);
|
|
|
|
$validated = $request->validate([
|
|
'name' => 'required|string|max:255',
|
|
'location' => 'nullable|string|max:255',
|
|
'project_id' => 'nullable|exists:projects,id',
|
|
'is_active' => 'boolean',
|
|
]);
|
|
|
|
$validated['tenant_id'] = $this->getTenantId();
|
|
$validated['is_active'] = $request->boolean('is_active', true);
|
|
|
|
Warehouse::create($validated);
|
|
|
|
return redirect()
|
|
->route('inventory.warehouses')
|
|
->with('success', __('inventory.warehouse_created'));
|
|
}
|
|
|
|
public function editWarehouse(Warehouse $warehouse)
|
|
{
|
|
$this->authorize('update', $warehouse);
|
|
|
|
$projects = Project::all();
|
|
|
|
return view('inventory.warehouse-form', compact('warehouse', 'projects'));
|
|
}
|
|
|
|
public function updateWarehouse(Request $request, Warehouse $warehouse)
|
|
{
|
|
$this->authorize('update', $warehouse);
|
|
|
|
$validated = $request->validate([
|
|
'name' => 'required|string|max:255',
|
|
'location' => 'nullable|string|max:255',
|
|
'project_id' => 'nullable|exists:projects,id',
|
|
'is_active' => 'boolean',
|
|
]);
|
|
|
|
$validated['is_active'] = $request->boolean('is_active');
|
|
|
|
$warehouse->update($validated);
|
|
|
|
return redirect()
|
|
->route('inventory.warehouses')
|
|
->with('success', __('inventory.warehouse_updated'));
|
|
}
|
|
|
|
public function destroyWarehouse(Warehouse $warehouse)
|
|
{
|
|
$this->authorize('delete', $warehouse);
|
|
|
|
if ($warehouse->inventoryTransactions()->count() > 0) {
|
|
return back()->withErrors(['warehouse' => __('inventory.warehouse_has_transactions')]);
|
|
}
|
|
|
|
$warehouse->delete();
|
|
|
|
return redirect()
|
|
->route('inventory.warehouses')
|
|
->with('success', __('inventory.warehouse_deleted'));
|
|
}
|
|
|
|
// ==========================================
|
|
// TRANSACTIONS
|
|
// ==========================================
|
|
|
|
public function transactions(Request $request)
|
|
{
|
|
$query = InventoryTransaction::with(['item', 'warehouse', 'project', 'currency', 'createdBy']);
|
|
|
|
if ($request->filled('item_id')) { $query->where('item_id', $request->item_id); }
|
|
if ($request->filled('warehouse_id')) { $query->where('warehouse_id', $request->warehouse_id); }
|
|
if ($request->filled('type')) { $query->byType($request->type); }
|
|
if ($request->filled('project_id')) { $query->where('project_id', $request->project_id); }
|
|
if ($request->filled('from_date')) { $query->where('created_at', '>=', $request->from_date); }
|
|
if ($request->filled('to_date')) { $query->where('created_at', '<=', $request->to_date); }
|
|
|
|
$transactions = $query->orderBy('created_at', 'desc')->paginate(20)->appends($request->query());
|
|
$items = Item::where('is_active', true)->orderBy('name')->get();
|
|
$warehouses = Warehouse::where('is_active', true)->orderBy('name')->get();
|
|
$projects = Project::all();
|
|
$currencies = Currency::where('is_active', true)->get();
|
|
$types = ['in', 'out', 'transfer', 'return', 'adjustment'];
|
|
|
|
return view('inventory.transactions', compact(
|
|
'transactions', 'items', 'warehouses', 'projects', 'currencies', 'types'
|
|
));
|
|
}
|
|
|
|
public function stockIn(Request $request)
|
|
{
|
|
$validated = $request->validate([
|
|
'item_id' => 'required|exists:items,id',
|
|
'warehouse_id' => 'required|exists:warehouses,id',
|
|
'project_id' => 'nullable|exists:projects,id',
|
|
'quantity' => 'required|numeric|min:0.01',
|
|
'unit_price' => 'nullable|numeric|min:0',
|
|
'currency_id' => 'nullable|exists:currencies,id',
|
|
'reference_number' => 'nullable|string|max:100',
|
|
'notes' => 'nullable|string',
|
|
]);
|
|
|
|
$validated['tenant_id'] = $this->getTenantId();
|
|
$validated['type'] = 'in';
|
|
$validated['created_by'] = Auth::id();
|
|
|
|
if (!empty($validated['unit_price']) && !empty($validated['quantity'])) {
|
|
$validated['total_price'] = $validated['unit_price'] * $validated['quantity'];
|
|
}
|
|
|
|
InventoryTransaction::create($validated);
|
|
|
|
return redirect()
|
|
->route('inventory.transactions')
|
|
->with('success', __('inventory.stock_in_success'));
|
|
}
|
|
|
|
public function stockOut(Request $request)
|
|
{
|
|
$validated = $request->validate([
|
|
'item_id' => 'required|exists:items,id',
|
|
'warehouse_id' => 'required|exists:warehouses,id',
|
|
'project_id' => 'nullable|exists:projects,id',
|
|
'quantity' => 'required|numeric|min:0.01',
|
|
'unit_price' => 'nullable|numeric|min:0',
|
|
'currency_id' => 'nullable|exists:currencies,id',
|
|
'reference_number' => 'nullable|string|max:100',
|
|
'notes' => 'nullable|string',
|
|
]);
|
|
|
|
$currentStock = $this->getCurrentStockForItem($validated['item_id'], $validated['warehouse_id']);
|
|
|
|
if ($currentStock < $validated['quantity']) {
|
|
return back()
|
|
->withErrors(['quantity' => __('inventory.insufficient_stock')])
|
|
->withInput();
|
|
}
|
|
|
|
$validated['tenant_id'] = $this->getTenantId();
|
|
$validated['type'] = 'out';
|
|
$validated['created_by'] = Auth::id();
|
|
|
|
if (!empty($validated['unit_price']) && !empty($validated['quantity'])) {
|
|
$validated['total_price'] = $validated['unit_price'] * $validated['quantity'];
|
|
}
|
|
|
|
InventoryTransaction::create($validated);
|
|
|
|
return redirect()
|
|
->route('inventory.transactions')
|
|
->with('success', __('inventory.stock_out_success'));
|
|
}
|
|
|
|
public function stockTransfer(Request $request)
|
|
{
|
|
$validated = $request->validate([
|
|
'item_id' => 'required|exists:items,id',
|
|
'from_warehouse_id' => 'required|exists:warehouses,id',
|
|
'to_warehouse_id' => 'required|exists:warehouses,id|different:from_warehouse_id',
|
|
'project_id' => 'nullable|exists:projects,id',
|
|
'quantity' => 'required|numeric|min:0.01',
|
|
'notes' => 'nullable|string',
|
|
]);
|
|
|
|
// Check source stock
|
|
$currentStock = $this->getCurrentStockForItem($validated['item_id'], $validated['from_warehouse_id']);
|
|
if ($currentStock < $validated['quantity']) {
|
|
return back()
|
|
->withErrors(['quantity' => __('inventory.insufficient_stock_source')])
|
|
->withInput();
|
|
}
|
|
|
|
DB::transaction(function () use ($validated) {
|
|
$tenantId = $this->getTenantId();
|
|
$userId = Auth::id();
|
|
|
|
// Out from source
|
|
InventoryTransaction::create([
|
|
'tenant_id' => $tenantId,
|
|
'item_id' => $validated['item_id'],
|
|
'warehouse_id' => $validated['from_warehouse_id'],
|
|
'project_id' => $validated['project_id'] ?? null,
|
|
'type' => 'transfer',
|
|
'quantity' => $validated['quantity'],
|
|
'reference_number' => 'TRF-' . time(),
|
|
'notes' => ($validated['notes'] ?? '') . ' — انتقال به انبار مقصد',
|
|
'created_by' => $userId,
|
|
]);
|
|
|
|
// In to destination
|
|
InventoryTransaction::create([
|
|
'tenant_id' => $tenantId,
|
|
'item_id' => $validated['item_id'],
|
|
'warehouse_id' => $validated['to_warehouse_id'],
|
|
'project_id' => $validated['project_id'] ?? null,
|
|
'type' => 'transfer',
|
|
'quantity' => $validated['quantity'],
|
|
'reference_number' => 'TRF-' . time(),
|
|
'notes' => ($validated['notes'] ?? '') . ' — انتقال از انبار مبدأ',
|
|
'created_by' => $userId,
|
|
]);
|
|
});
|
|
|
|
return redirect()
|
|
->route('inventory.transactions')
|
|
->with('success', __('inventory.transfer_success'));
|
|
}
|
|
|
|
public function currentStock(Request $request)
|
|
{
|
|
$request->validate([
|
|
'item_id' => 'required|exists:items,id',
|
|
'warehouse_id' => 'nullable|exists:warehouses,id',
|
|
]);
|
|
|
|
$stock = $this->getCurrentStockForItem($request->item_id, $request->warehouse_id);
|
|
|
|
return response()->json([
|
|
'item_id' => $request->item_id,
|
|
'warehouse_id' => $request->warehouse_id,
|
|
'current_stock' => $stock,
|
|
]);
|
|
}
|
|
|
|
// ==========================================
|
|
// STOCK HELPERS
|
|
// ==========================================
|
|
|
|
private function getCurrentStockForItem(int $itemId, ?int $warehouseId = null): float
|
|
{
|
|
$query = InventoryTransaction::where('item_id', $itemId);
|
|
|
|
if ($warehouseId) {
|
|
$query->where('warehouse_id', $warehouseId);
|
|
}
|
|
|
|
$totalIn = (clone $query)->whereIn('type', ['in', 'return'])->sum('quantity');
|
|
$totalOut = (clone $query)->where('type', 'out')->sum('quantity');
|
|
$adjustments = (clone $query)->where('type', 'adjustment')->sum('quantity');
|
|
|
|
return (float) ($totalIn - $totalOut + $adjustments);
|
|
}
|
|
}
|