model->newQuery()->with(['fournisseur', 'category']); // Apply filters if (!empty($filters['search'])) { $query->where(function ($q) use ($filters) { $q->where('nom', 'like', '%' . $filters['search'] . '%') ->orWhere('reference', 'like', '%' . $filters['search'] . '%') ->orWhere('fabricant', 'like', '%' . $filters['search'] . '%'); }); } if (!empty($filters['categorie'])) { $query->where('categorie_id', $filters['categorie']); } if (!empty($filters['fournisseur_id'])) { $query->where('fournisseur_id', $filters['fournisseur_id']); } if (isset($filters['low_stock'])) { $query->whereRaw('stock_actuel <= stock_minimum'); } if (isset($filters['expiring_soon'])) { $query->where('date_expiration', '<=', now()->addDays(30)->toDateString()) ->where('date_expiration', '>=', now()->toDateString()); } if (isset($filters['is_intervention'])) { $query->whereHas('category', function ($q) use ($filters) { $q->where('intervention', filter_var($filters['is_intervention'], FILTER_VALIDATE_BOOLEAN)); }); } // Apply sorting $sortField = $filters['sort_by'] ?? 'created_at'; $sortDirection = $filters['sort_direction'] ?? 'desc'; $query->orderBy($sortField, $sortDirection); return $query->paginate($perPage); } /** * Get products with low stock */ public function getLowStockProducts(int $perPage = 15): LengthAwarePaginator { return $this->model->newQuery() ->with(['fournisseur', 'category']) ->whereRaw('stock_actuel <= stock_minimum') ->orderBy('stock_actuel', 'asc') ->paginate($perPage); } /** * Search products by name */ public function searchByName(string $name, int $perPage = 15, bool $exactMatch = false) { $query = $this->model->newQuery()->with(['fournisseur', 'category']); if ($exactMatch) { $query->where('nom', $name); } else { $query->where('nom', 'like', '%' . $name . '%'); } return $query->get(); } /** * Get products by category */ public function getByCategory(int $categoryId, int $perPage = 15): LengthAwarePaginator { return $this->model->newQuery() ->with(['fournisseur', 'category']) ->where('categorie_id', $categoryId) ->orderBy('nom') ->paginate($perPage); } /** * Get products by fournisseur */ public function getProductsByFournisseur(int $fournisseurId): LengthAwarePaginator { return $this->model->newQuery() ->where('fournisseur_id', $fournisseurId) ->orderBy('nom') ->paginate(15); } /** * Update stock quantity */ public function updateStock(int $productId, float $newQuantity): bool { return $this->model->where('id', $productId) ->update(['stock_actuel' => $newQuantity]) > 0; } /** * Get product statistics */ public function getStatistics(): array { $totalProducts = $this->model->count(); $lowStockProducts = $this->model->whereRaw('stock_actuel <= stock_minimum')->count(); $expiringProducts = $this->model->where('date_expiration', '<=', now()->addDays(30)->toDateString()) ->where('date_expiration', '>=', now()->toDateString()) ->count(); $stockItems = DB::table('stock_items') ->join('products', 'stock_items.product_id', '=', 'products.id'); $totalValue = $stockItems ->selectRaw('COALESCE(SUM(stock_items.qty_on_hand_base * products.prix_unitaire), 0) as total_value') ->value('total_value'); $stockAlertCount = $stockItems ->whereRaw('stock_items.qty_on_hand_base <= stock_items.safety_stock_base') ->count(); $stockRotation = $this->getRotationByProduct(); $warehouseMovements = $this->getWarehouseMovements(); $stockByWarehouse = $this->getStockByWarehouse(); return [ 'total_products' => $totalProducts, 'low_stock_products' => $lowStockProducts, 'expiring_products' => $expiringProducts, 'total_value' => $totalValue, 'stock_alerts_count' => $stockAlertCount, 'stock_rotation_by_product' => $stockRotation, 'warehouse_movements' => $warehouseMovements, 'stock_by_warehouse' => $stockByWarehouse, ]; } private function getRotationByProduct(): array { $oneYearAgo = now()->subYear(); $movementSubquery = DB::table('stock_moves') ->selectRaw('product_id, SUM(qty_base) as moved_qty') ->where('moved_at', '>=', $oneYearAgo) ->groupBy('product_id'); $stockOnHandSubquery = DB::table('stock_items') ->selectRaw('product_id, SUM(qty_on_hand_base) as qty_on_hand') ->groupBy('product_id'); $rotation = DB::table('products') ->leftJoinSub($movementSubquery, 'movement', function ($join) { $join->on('products.id', '=', 'movement.product_id'); }) ->leftJoinSub($stockOnHandSubquery, 'stock', function ($join) { $join->on('products.id', '=', 'stock.product_id'); }) ->whereNotNull('movement.moved_qty') ->selectRaw( 'products.id as product_id, products.nom as product_name, ' . 'movement.moved_qty, COALESCE(stock.qty_on_hand, 0) as qty_on_hand, ' . 'CASE WHEN COALESCE(stock.qty_on_hand, 0) > 0 THEN ROUND(movement.moved_qty / stock.qty_on_hand, 2) ELSE null END as rotation_rate' ) ->orderByDesc('rotation_rate') ->limit(10) ->get() ->map(function ($row) { return [ 'product_id' => (int) $row->product_id, 'product_name' => $row->product_name, 'moved_qty_last_12_months' => (float) $row->moved_qty, 'qty_on_hand' => (float) $row->qty_on_hand, 'rotation_rate' => $row->rotation_rate !== null ? (float) $row->rotation_rate : null, ]; }) ->toArray(); return $rotation; } private function getWarehouseMovements(): array { $incoming = DB::table('stock_moves') ->selectRaw('to_warehouse_id as warehouse_id, COUNT(*) as incoming_moves, SUM(qty_base) as incoming_qty') ->whereNotNull('to_warehouse_id') ->groupBy('to_warehouse_id'); $outgoing = DB::table('stock_moves') ->selectRaw('from_warehouse_id as warehouse_id, COUNT(*) as outgoing_moves, SUM(qty_base) as outgoing_qty') ->whereNotNull('from_warehouse_id') ->groupBy('from_warehouse_id'); $warehouses = DB::table('warehouses') ->leftJoinSub($incoming, 'incoming', function ($join) { $join->on('warehouses.id', '=', 'incoming.warehouse_id'); }) ->leftJoinSub($outgoing, 'outgoing', function ($join) { $join->on('warehouses.id', '=', 'outgoing.warehouse_id'); }) ->select([ 'warehouses.id as warehouse_id', 'warehouses.name as warehouse_name', DB::raw('COALESCE(incoming.incoming_moves, 0) as incoming_moves'), DB::raw('COALESCE(incoming.incoming_qty, 0) as incoming_qty'), DB::raw('COALESCE(outgoing.outgoing_moves, 0) as outgoing_moves'), DB::raw('COALESCE(outgoing.outgoing_qty, 0) as outgoing_qty'), DB::raw('COALESCE(COALESCE(incoming.incoming_moves, 0) + COALESCE(outgoing.outgoing_moves, 0), 0) as total_moves'), ]) ->get() ->map(function ($row) { return [ 'warehouse_id' => (int) $row->warehouse_id, 'warehouse_name' => $row->warehouse_name, 'incoming_moves' => (int) $row->incoming_moves, 'incoming_qty' => (float) $row->incoming_qty, 'outgoing_moves' => (int) $row->outgoing_moves, 'outgoing_qty' => (float) $row->outgoing_qty, 'total_moves' => (int) $row->total_moves, ]; }) ->toArray(); return $warehouses; } private function getStockByWarehouse(): array { $warehouses = DB::table('warehouses') ->leftJoin('stock_items', 'warehouses.id', '=', 'stock_items.warehouse_id') ->leftJoin('products', 'stock_items.product_id', '=', 'products.id') ->selectRaw( 'warehouses.id as warehouse_id, warehouses.name as warehouse_name, ' . 'COALESCE(SUM(stock_items.qty_on_hand_base), 0) as qty_on_hand, ' . 'COALESCE(SUM(stock_items.qty_on_hand_base * products.prix_unitaire), 0) as stock_value, ' . 'SUM(CASE WHEN stock_items.qty_on_hand_base <= stock_items.safety_stock_base THEN 1 ELSE 0 END) as alert_count' ) ->groupBy('warehouses.id', 'warehouses.name') ->get() ->map(function ($row) { return [ 'warehouse_id' => (int) $row->warehouse_id, 'warehouse_name' => $row->warehouse_name, 'qty_on_hand' => (float) $row->qty_on_hand, 'stock_value' => (float) $row->stock_value, 'alert_count' => (int) $row->alert_count, ]; }) ->toArray(); return $warehouses; } /** * Find a default intervention product (where category has intervention=true) */ public function findInterventionProduct(): ?Product { return $this->model->newQuery() ->whereHas('category', function ($query) { $query->where('intervention', true); }) ->first(); } }