2026-05-13 10:07:03 +03:00

289 lines
11 KiB
PHP

<?php
declare(strict_types=1);
namespace App\Repositories;
use App\Models\Product;
use Illuminate\Contracts\Pagination\LengthAwarePaginator;
use Illuminate\Support\Facades\DB;
class ProductRepository extends BaseRepository implements ProductRepositoryInterface
{
public function __construct(Product $model)
{
parent::__construct($model);
}
/**
* Get paginated products with filters
*/
public function paginate(int $perPage = 15, array $filters = []): LengthAwarePaginator
{
$query = $this->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();
}
}