289 lines
11 KiB
PHP
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();
|
|
}
|
|
} |