add api produit categorie, et page categori produit

This commit is contained in:
Nyavokevin 2025-11-04 16:43:59 +03:00
parent 638af78e1f
commit aa306f5d19
18 changed files with 1210 additions and 10 deletions

View File

@ -0,0 +1,349 @@
<?php
declare(strict_types=1);
namespace App\Http\Controllers\Api;
use App\Http\Controllers\Controller;
use App\Http\Requests\StoreProductCategoryRequest;
use App\Http\Requests\UpdateProductCategoryRequest;
use App\Http\Resources\ProductCategory\ProductCategoryResource;
use App\Http\Resources\ProductCategory\ProductCategoryCollection;
use App\Repositories\ProductCategoryRepositoryInterface;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Resources\Json\AnonymousResourceCollection;
use Illuminate\Support\Facades\Log;
use Illuminate\Http\Request;
class ProductCategoryController extends Controller
{
public function __construct(
private readonly ProductCategoryRepositoryInterface $productCategoryRepository
) {
}
/**
* Display a listing of product categories.
*/
public function index(Request $request): ProductCategoryCollection|JsonResponse
{
try {
$perPage = $request->get('per_page', 15);
$filters = [
'search' => $request->get('search'),
'active' => $request->get('active'),
'parent_id' => $request->get('parent_id'),
'sort_by' => $request->get('sort_by', 'name'),
'sort_direction' => $request->get('sort_direction', 'asc'),
];
// Remove null filters
$filters = array_filter($filters, function ($value) {
return $value !== null && $value !== '';
});
$categories = $this->productCategoryRepository->paginate($perPage, $filters);
return new ProductCategoryCollection($categories);
} catch (\Exception $e) {
Log::error('Error fetching product categories: ' . $e->getMessage(), [
'exception' => $e,
'trace' => $e->getTraceAsString(),
]);
return response()->json([
'message' => 'Une erreur est survenue lors de la récupération des catégories de produits.',
'error' => config('app.debug') ? $e->getMessage() : null,
], 500);
}
}
/**
* Store a newly created product category.
*/
public function store(StoreProductCategoryRequest $request): ProductCategoryResource|JsonResponse
{
try {
$category = $this->productCategoryRepository->create($request->validated());
return new ProductCategoryResource($category);
} catch (\Exception $e) {
Log::error('Error creating product category: ' . $e->getMessage(), [
'exception' => $e,
'trace' => $e->getTraceAsString(),
'data' => $request->validated(),
]);
return response()->json([
'message' => 'Une erreur est survenue lors de la création de la catégorie.',
'error' => config('app.debug') ? $e->getMessage() : null,
], 500);
}
}
/**
* Display the specified product category.
*/
public function show(string $id): ProductCategoryResource|JsonResponse
{
try {
$category = $this->productCategoryRepository->find($id);
if (!$category) {
return response()->json([
'message' => 'Catégorie non trouvée.',
], 404);
}
return new ProductCategoryResource($category);
} catch (\Exception $e) {
Log::error('Error fetching product category: ' . $e->getMessage(), [
'exception' => $e,
'trace' => $e->getTraceAsString(),
'category_id' => $id,
]);
return response()->json([
'message' => 'Une erreur est survenue lors de la récupération de la catégorie.',
'error' => config('app.debug') ? $e->getMessage() : null,
], 500);
}
}
/**
* Update the specified product category.
*/
public function update(UpdateProductCategoryRequest $request, string $id): ProductCategoryResource|JsonResponse
{
try {
$updated = $this->productCategoryRepository->update($id, $request->validated());
if (!$updated) {
return response()->json([
'message' => 'Catégorie non trouvée ou échec de la mise à jour.',
], 404);
}
$category = $this->productCategoryRepository->find($id);
return new ProductCategoryResource($category);
} catch (\Exception $e) {
Log::error('Error updating product category: ' . $e->getMessage(), [
'exception' => $e,
'trace' => $e->getTraceAsString(),
'category_id' => $id,
'data' => $request->validated(),
]);
return response()->json([
'message' => 'Une erreur est survenue lors de la mise à jour de la catégorie.',
'error' => config('app.debug') ? $e->getMessage() : null,
], 500);
}
}
/**
* Remove the specified product category.
*/
public function destroy(string $id): JsonResponse
{
try {
// Check if category can be deleted
if (!$this->productCategoryRepository->canDelete($id)) {
return response()->json([
'message' => 'Impossible de supprimer cette catégorie. Elle peut avoir des sous-catégories ou des produits associés.',
], 422);
}
$deleted = $this->productCategoryRepository->delete($id);
if (!$deleted) {
return response()->json([
'message' => 'Catégorie non trouvée ou échec de la suppression.',
], 404);
}
return response()->json([
'message' => 'Catégorie supprimée avec succès.',
], 200);
} catch (\Exception $e) {
Log::error('Error deleting product category: ' . $e->getMessage(), [
'exception' => $e,
'trace' => $e->getTraceAsString(),
'category_id' => $id,
]);
return response()->json([
'message' => 'Une erreur est survenue lors de la suppression de la catégorie.',
'error' => config('app.debug') ? $e->getMessage() : null,
], 500);
}
}
/**
* Get active categories only
*/
public function active(): ProductCategoryCollection|JsonResponse
{
try {
$categories = $this->productCategoryRepository->getActive();
return new ProductCategoryCollection(collect(['data' => $categories]));
} catch (\Exception $e) {
Log::error('Error fetching active product categories: ' . $e->getMessage(), [
'exception' => $e,
'trace' => $e->getTraceAsString(),
]);
return response()->json([
'message' => 'Une erreur est survenue lors de la récupération des catégories actives.',
'error' => config('app.debug') ? $e->getMessage() : null,
], 500);
}
}
/**
* Get root categories (no parent)
*/
public function roots(): ProductCategoryCollection|JsonResponse
{
try {
$categories = $this->productCategoryRepository->getRoots();
return new ProductCategoryCollection(collect(['data' => $categories]));
} catch (\Exception $e) {
Log::error('Error fetching root product categories: ' . $e->getMessage(), [
'exception' => $e,
'trace' => $e->getTraceAsString(),
]);
return response()->json([
'message' => 'Une erreur est survenue lors de la récupération des catégories racine.',
'error' => config('app.debug') ? $e->getMessage() : null,
], 500);
}
}
/**
* Get categories with their children (hierarchical structure)
*/
public function hierarchical(): ProductCategoryCollection|JsonResponse
{
try {
$categories = $this->productCategoryRepository->getWithChildren();
return new ProductCategoryCollection(collect(['data' => $categories]));
} catch (\Exception $e) {
Log::error('Error fetching hierarchical product categories: ' . $e->getMessage(), [
'exception' => $e,
'trace' => $e->getTraceAsString(),
]);
return response()->json([
'message' => 'Une erreur est survenue lors de la récupération de la structure hiérarchique.',
'error' => config('app.debug') ? $e->getMessage() : null,
], 500);
}
}
/**
* Search categories by name, code or description
*/
public function search(Request $request): ProductCategoryCollection|JsonResponse
{
try {
$term = $request->get('term', '');
$perPage = $request->get('per_page', 15);
if (empty($term)) {
return response()->json([
'message' => 'Le paramètre "term" est requis pour la recherche.',
], 400);
}
$categories = $this->productCategoryRepository->search($term, $perPage);
return new ProductCategoryCollection($categories);
} catch (\Exception $e) {
Log::error('Error searching product categories: ' . $e->getMessage(), [
'exception' => $e,
'trace' => $e->getTraceAsString(),
'search_term' => $term,
]);
return response()->json([
'message' => 'Une erreur est survenue lors de la recherche des catégories.',
'error' => config('app.debug') ? $e->getMessage() : null,
], 500);
}
}
/**
* Get category statistics
*/
public function statistics(): JsonResponse
{
try {
$stats = $this->productCategoryRepository->getStatistics();
return response()->json([
'data' => $stats,
'message' => 'Statistiques des catégories récupérées avec succès.',
], 200);
} catch (\Exception $e) {
Log::error('Error fetching product category statistics: ' . $e->getMessage(), [
'exception' => $e,
'trace' => $e->getTraceAsString(),
]);
return response()->json([
'message' => 'Une erreur est survenue lors de la récupération des statistiques.',
'error' => config('app.debug') ? $e->getMessage() : null,
], 500);
}
}
/**
* Toggle category active status
*/
public function toggleActive(Request $request, string $id): ProductCategoryResource|JsonResponse
{
try {
$request->validate([
'active' => 'required|boolean',
]);
$category = $this->productCategoryRepository->find($id);
if (!$category) {
return response()->json([
'message' => 'Catégorie non trouvée.',
], 404);
}
$updated = $this->productCategoryRepository->update($id, [
'active' => $request->boolean('active')
]);
if (!$updated) {
return response()->json([
'message' => 'Échec de la mise à jour du statut.',
], 422);
}
$category = $this->productCategoryRepository->find($id);
return new ProductCategoryResource($category);
} catch (\Exception $e) {
Log::error('Error toggling product category status: ' . $e->getMessage(), [
'exception' => $e,
'trace' => $e->getTraceAsString(),
'category_id' => $id,
'data' => $request->all(),
]);
return response()->json([
'message' => 'Une erreur est survenue lors de la mise à jour du statut.',
'error' => config('app.debug') ? $e->getMessage() : null,
], 500);
}
}
}

View File

@ -67,7 +67,22 @@ class ProductController extends Controller
public function store(StoreProductRequest $request): ProductResource|JsonResponse
{
try {
$product = $this->productRepository->create($request->validated());
$validatedData = $request->validated();
// Handle image upload
if ($request->hasFile('image')) {
// Create product without image first
$product = $this->productRepository->create($validatedData);
// Upload and attach image
$imagePath = $product->uploadImage($request->file('image'));
// Refresh product to get updated data
$product = $this->productRepository->find($product->id);
} else {
$product = $this->productRepository->create($validatedData);
}
return new ProductResource($product);
} catch (\Exception $e) {
Log::error('Error creating product: ' . $e->getMessage(), [
@ -240,7 +255,29 @@ class ProductController extends Controller
public function update(UpdateProductRequest $request, string $id): ProductResource|JsonResponse
{
try {
$updated = $this->productRepository->update($id, $request->validated());
$validatedData = $request->validated();
$product = $this->productRepository->find($id);
if (!$product) {
return response()->json([
'message' => 'Produit non trouvé.',
], 404);
}
// Handle image upload/removal
if ($request->boolean('remove_image')) {
// Remove existing image
$product->deleteImage();
} elseif ($request->hasFile('image')) {
// Upload new image
$product->uploadImage($request->file('image'));
}
// Remove image-related fields from validated data before updating other fields
unset($validatedData['image'], $validatedData['remove_image']);
// Update other product fields
$updated = $this->productRepository->update($id, $validatedData);
if (!$updated) {
return response()->json([

View File

@ -0,0 +1,48 @@
<?php
namespace App\Http\Requests;
use Illuminate\Foundation\Http\FormRequest;
class StoreProductCategoryRequest extends FormRequest
{
/**
* Determine if the user is authorized to make this request.
*/
public function authorize(): bool
{
return true;
}
/**
* Get the validation rules that apply to the request.
*
* @return array<string, \Illuminate\Contracts\Validation\ValidationRule|array<mixed>|string>
*/
public function rules(): array
{
return [
'parent_id' => 'nullable|exists:product_categories,id',
'code' => 'required|string|max:64|unique:product_categories,code',
'name' => 'required|string|max:191',
'description' => 'nullable|string',
'active' => 'boolean',
];
}
public function messages(): array
{
return [
'parent_id.exists' => 'La catégorie parente sélectionnée n\'existe pas.',
'code.required' => 'Le code de la catégorie est obligatoire.',
'code.string' => 'Le code de la catégorie doit être une chaîne de caractères.',
'code.max' => 'Le code de la catégorie ne peut pas dépasser 64 caractères.',
'code.unique' => 'Ce code de catégorie existe déjà.',
'name.required' => 'Le nom de la catégorie est obligatoire.',
'name.string' => 'Le nom de la catégorie doit être une chaîne de caractères.',
'name.max' => 'Le nom de la catégorie ne peut pas dépasser 191 caractères.',
'description.string' => 'La description doit être une chaîne de caractères.',
'active.boolean' => 'Le statut actif doit être un booléen.',
];
}
}

View File

@ -35,7 +35,8 @@ class StoreProductRequest extends FormRequest
'conditionnement_nom' => 'nullable|string|max:191',
'conditionnement_quantite' => 'nullable|numeric|min:0',
'conditionnement_unite' => 'nullable|string|max:50',
'photo_url' => 'nullable|url|max:500',
'image' => 'nullable|image|mimes:jpeg,png,jpg,gif,svg|max:2048',
'remove_image' => 'nullable|boolean',
'fiche_technique_url' => 'nullable|url|max:500',
'fournisseur_id' => 'nullable|exists:fournisseurs,id',
];
@ -78,8 +79,9 @@ class StoreProductRequest extends FormRequest
'conditionnement_quantite.min' => 'La quantité du conditionnement doit être supérieure ou égal à 0.',
'conditionnement_unite.string' => 'L\'unité du conditionnement doit être une chaîne de caractères.',
'conditionnement_unite.max' => 'L\'unité du conditionnement ne peut pas dépasser 50 caractères.',
'photo_url.url' => 'L\'URL de la photo doit être une URL valide.',
'photo_url.max' => 'L\'URL de la photo ne peut pas dépasser 500 caractères.',
'image.image' => 'Le fichier doit être une image valide.',
'image.mimes' => 'L\'image doit être de type: jpeg, png, jpg, gif ou svg.',
'image.max' => 'L\'image ne peut pas dépasser 2MB.',
'fiche_technique_url.url' => 'L\'URL de la fiche technique doit être une URL valide.',
'fiche_technique_url.max' => 'L\'URL de la fiche technique ne peut pas dépasser 500 caractères.',
'fournisseur_id.exists' => 'Le fournisseur sélectionné n\'existe pas.',

View File

@ -0,0 +1,48 @@
<?php
namespace App\Http\Requests;
use Illuminate\Foundation\Http\FormRequest;
class UpdateProductCategoryRequest extends FormRequest
{
/**
* Determine if the user is authorized to make this request.
*/
public function authorize(): bool
{
return true;
}
/**
* Get the validation rules that apply to the request.
*
* @return array<string, \Illuminate\Contracts\Validation\ValidationRule|array<mixed>|string>
*/
public function rules(): array
{
$categoryId = $this->route('id');
return [
'parent_id' => 'nullable|exists:product_categories,id',
'code' => "nullable|string|max:64|unique:product_categories,code,{$categoryId}",
'name' => 'nullable|string|max:191',
'description' => 'nullable|string',
'active' => 'nullable|boolean',
];
}
public function messages(): array
{
return [
'parent_id.exists' => 'La catégorie parente sélectionnée n\'existe pas.',
'code.string' => 'Le code de la catégorie doit être une chaîne de caractères.',
'code.max' => 'Le code de la catégorie ne peut pas dépasser 64 caractères.',
'code.unique' => 'Ce code de catégorie existe déjà.',
'name.string' => 'Le nom de la catégorie doit être une chaîne de caractères.',
'name.max' => 'Le nom de la catégorie ne peut pas dépasser 191 caractères.',
'description.string' => 'La description doit être une chaîne de caractères.',
'active.boolean' => 'Le statut actif doit être un booléen.',
];
}
}

View File

@ -21,9 +21,11 @@ class UpdateProductRequest extends FormRequest
*/
public function rules(): array
{
$productId = $this->route('id');
return [
'nom' => 'required|string|max:255',
'reference' => 'required|string|max:100|unique:products,reference,' . $this->product?->id,
'reference' => "nullable",
'categorie' => 'required|string|max:191',
'fabricant' => 'nullable|string|max:191',
'stock_actuel' => 'required|numeric|min:0',
@ -35,7 +37,8 @@ class UpdateProductRequest extends FormRequest
'conditionnement_nom' => 'nullable|string|max:191',
'conditionnement_quantite' => 'nullable|numeric|min:0',
'conditionnement_unite' => 'nullable|string|max:50',
'photo_url' => 'nullable|url|max:500',
'image' => 'nullable|image|mimes:jpeg,png,jpg,gif,svg|max:2048',
'remove_image' => 'nullable|boolean',
'fiche_technique_url' => 'nullable|url|max:500',
'fournisseur_id' => 'nullable|exists:fournisseurs,id',
];
@ -78,8 +81,9 @@ class UpdateProductRequest extends FormRequest
'conditionnement_quantite.min' => 'La quantité du conditionnement doit être supérieure ou égal à 0.',
'conditionnement_unite.string' => 'L\'unité du conditionnement doit être une chaîne de caractères.',
'conditionnement_unite.max' => 'L\'unité du conditionnement ne peut pas dépasser 50 caractères.',
'photo_url.url' => 'L\'URL de la photo doit être une URL valide.',
'photo_url.max' => 'L\'URL de la photo ne peut pas dépasser 500 caractères.',
'image.image' => 'Le fichier doit être une image valide.',
'image.mimes' => 'L\'image doit être de type: jpeg, png, jpg, gif ou svg.',
'image.max' => 'L\'image ne peut pas dépasser 2MB.',
'fiche_technique_url.url' => 'L\'URL de la fiche technique doit être une URL valide.',
'fiche_technique_url.max' => 'L\'URL de la fiche technique ne peut pas dépasser 500 caractères.',
'fournisseur_id.exists' => 'Le fournisseur sélectionné n\'existe pas.',

View File

@ -0,0 +1,36 @@
<?php
namespace App\Http\Resources\ProductCategory;
use Illuminate\Http\Resources\Json\ResourceCollection;
class ProductCategoryCollection extends ResourceCollection
{
/**
* Transform the resource collection into an array.
*
* @param \Illuminate\Http\Request $request
* @return array
*/
public function toArray($request)
{
if ($this->resource instanceof \Illuminate\Pagination\LengthAwarePaginator) {
return [
'data' => ProductCategoryResource::collection($this->collection),
'pagination' => [
'current_page' => $this->currentPage(),
'from' => $this->firstItem(),
'last_page' => $this->lastPage(),
'per_page' => $this->perPage(),
'to' => $this->lastItem(),
'total' => $this->total(),
],
];
}
return [
'data' => ProductCategoryResource::collection($this->collection),
'count' => $this->collection->count(),
];
}
}

View File

@ -0,0 +1,45 @@
<?php
namespace App\Http\Resources\ProductCategory;
use Illuminate\Http\Resources\Json\JsonResource;
class ProductCategoryResource extends JsonResource
{
/**
* Transform the resource into an array.
*
* @param \Illuminate\Http\Request $request
* @return array
*/
public function toArray($request)
{
return [
'id' => $this->id,
'parent_id' => $this->parent_id,
'code' => $this->code,
'name' => $this->name,
'description' => $this->description,
'active' => $this->active,
'path' => $this->path,
'has_children' => $this->hasChildren(),
'has_products' => $this->hasProducts(),
'children_count' => $this->children()->count(),
'products_count' => $this->products()->count(),
// Parent information
'parent' => $this->whenLoaded('parent', function () {
return new ProductCategoryResource($this->parent);
}),
// Children information
'children' => $this->whenLoaded('children', function () {
return ProductCategoryResource::collection($this->children);
}),
// Relationships
'created_at' => $this->created_at,
'updated_at' => $this->updated_at,
];
}
}

View File

@ -4,6 +4,7 @@ namespace App\Models;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
use Illuminate\Support\Facades\Storage;
class Product extends Model
{
@ -21,7 +22,7 @@ class Product extends Model
'conditionnement_nom',
'conditionnement_quantite',
'conditionnement_unite',
'photo_url',
'image',
'fiche_technique_url',
'fournisseur_id',
];
@ -41,4 +42,73 @@ class Product extends Model
{
return $this->belongsTo(Fournisseur::class);
}
/**
* Get the category that owns the product.
*/
public function category(): BelongsTo
{
return $this->belongsTo(ProductCategory::class, 'categorie', 'name');
}
/**
* Handle image upload
*/
public function uploadImage($image)
{
if ($image) {
// Delete old image if exists
if ($this->image) {
$this->deleteImage();
}
// Store the new image
$imageName = time() . '_' . uniqid() . '.' . $image->getClientOriginalExtension();
$imagePath = $image->storeAs('products', $imageName, 'public');
$this->image = $imagePath;
$this->save();
return $imagePath;
}
return null;
}
/**
* Delete the product image
*/
public function deleteImage()
{
if ($this->image) {
Storage::disk('public')->delete($this->image);
$this->image = null;
$this->save();
}
}
/**
* Get the full URL of the image
*/
public function getImageUrlAttribute()
{
if ($this->image) {
return Storage::url($this->image);
}
return null;
}
/**
* Boot the model
*/
protected static function boot()
{
parent::boot();
static::deleting(function ($product) {
// Delete the image when the product is deleted
$product->deleteImage();
});
}
}

View File

@ -0,0 +1,102 @@
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
use Illuminate\Database\Eloquent\Relations\HasMany;
class ProductCategory extends Model
{
protected $fillable = [
'parent_id',
'code',
'name',
'description',
'active',
];
protected $casts = [
'active' => 'boolean',
];
/**
* Get the parent category
*/
public function parent(): BelongsTo
{
return $this->belongsTo(ProductCategory::class, 'parent_id');
}
/**
* Get the child categories
*/
public function children(): HasMany
{
return $this->hasMany(ProductCategory::class, 'parent_id');
}
/**
* Get all products in this category
*/
public function products(): HasMany
{
return $this->hasMany(Product::class, 'categorie', 'name');
}
/**
* Get all descendant categories (recursive)
*/
public function descendants(): HasMany
{
return $this->children()->with('descendants');
}
/**
* Get the hierarchical path of the category
*/
public function getPathAttribute(): string
{
$path = $this->name;
$parent = $this->parent;
while ($parent) {
$path = $parent->name . ' > ' . $path;
$parent = $parent->parent;
}
return $path;
}
/**
* Scope to get only active categories
*/
public function scopeActive($query)
{
return $query->where('active', true);
}
/**
* Scope to get root categories (no parent)
*/
public function scopeRoots($query)
{
return $query->whereNull('parent_id');
}
/**
* Check if category has children
*/
public function hasChildren(): bool
{
return $this->children()->exists();
}
/**
* Check if category has products
*/
public function hasProducts(): bool
{
return $this->products()->exists();
}
}

View File

@ -39,6 +39,10 @@ class AppServiceProvider extends ServiceProvider
$this->app->bind(\App\Repositories\ProductRepositoryInterface::class, function ($app) {
return new \App\Repositories\ProductRepository($app->make(\App\Models\Product::class));
});
$this->app->bind(\App\Repositories\ProductCategoryRepositoryInterface::class, function ($app) {
return new \App\Repositories\ProductCategoryRepository($app->make(\App\Models\ProductCategory::class));
});
}
/**

View File

@ -0,0 +1,195 @@
<?php
namespace App\Repositories;
use App\Models\ProductCategory;
use Illuminate\Database\Eloquent\Collection;
use Illuminate\Contracts\Pagination\LengthAwarePaginator;
class ProductCategoryRepository implements ProductCategoryRepositoryInterface
{
protected $model;
public function __construct(ProductCategory $model)
{
$this->model = $model;
}
/**
* Get all product categories with pagination
*/
public function paginate(int $perPage = 15, array $filters = []): LengthAwarePaginator
{
$query = $this->model->newQuery();
// Apply filters
if (isset($filters['search']) && !empty($filters['search'])) {
$search = $filters['search'];
$query->where(function ($q) use ($search) {
$q->where('name', 'like', "%{$search}%")
->orWhere('code', 'like', "%{$search}%")
->orWhere('description', 'like', "%{$search}%");
});
}
if (isset($filters['active']) && $filters['active'] !== null) {
$query->where('active', $filters['active']);
}
if (isset($filters['parent_id']) && $filters['parent_id'] !== null) {
$query->where('parent_id', $filters['parent_id']);
}
// Sorting
$sortBy = $filters['sort_by'] ?? 'name';
$sortDirection = $filters['sort_direction'] ?? 'asc';
$query->orderBy($sortBy, $sortDirection);
return $query->paginate($perPage);
}
/**
* Get all product categories
*/
public function all(array $filters = []): Collection
{
$query = $this->model->newQuery();
// Apply filters
if (isset($filters['active']) && $filters['active'] !== null) {
$query->where('active', $filters['active']);
}
if (isset($filters['parent_id']) && $filters['parent_id'] !== null) {
$query->where('parent_id', $filters['parent_id']);
}
$sortBy = $filters['sort_by'] ?? 'name';
$sortDirection = $filters['sort_direction'] ?? 'asc';
$query->orderBy($sortBy, $sortDirection);
return $query->get();
}
/**
* Find a product category by ID
*/
public function find(string $id): ?ProductCategory
{
return $this->model->find($id);
}
/**
* Create a new product category
*/
public function create(array $data): ProductCategory
{
return $this->model->create($data);
}
/**
* Update a product category
*/
public function update(string $id, array $data): bool
{
$category = $this->find($id);
if ($category) {
return $category->update($data);
}
return false;
}
/**
* Delete a product category
*/
public function delete(string $id): bool
{
$category = $this->find($id);
if ($category) {
return $category->delete();
}
return false;
}
/**
* Get active categories only
*/
public function getActive(): Collection
{
return $this->model->active()->orderBy('name')->get();
}
/**
* Get root categories (no parent)
*/
public function getRoots(): Collection
{
return $this->model->roots()->orderBy('name')->get();
}
/**
* Get categories with their children
*/
public function getWithChildren(): Collection
{
return $this->model->with('children')
->roots()
->orderBy('name')
->get();
}
/**
* Search categories by name or code
*/
public function search(string $term, int $perPage = 15): LengthAwarePaginator
{
return $this->model->where('name', 'like', "%{$term}%")
->orWhere('code', 'like', "%{$term}%")
->orWhere('description', 'like', "%{$term}%")
->orderBy('name')
->paginate($perPage);
}
/**
* Get category statistics
*/
public function getStatistics(): array
{
$total = $this->model->count();
$active = $this->model->active()->count();
$inactive = $total - $active;
$withChildren = $this->model->has('children')->count();
$rootCategories = $this->model->roots()->count();
return [
'total_categories' => $total,
'active_categories' => $active,
'inactive_categories' => $inactive,
'categories_with_children' => $withChildren,
'root_categories' => $rootCategories,
];
}
/**
* Check if category can be deleted
*/
public function canDelete(string $id): bool
{
$category = $this->find($id);
if (!$category) {
return false;
}
// Cannot delete if has children
if ($category->hasChildren()) {
return false;
}
// Cannot delete if has products
if ($category->hasProducts()) {
return false;
}
return true;
}
}

View File

@ -0,0 +1,70 @@
<?php
namespace App\Repositories;
use App\Models\ProductCategory;
use Illuminate\Database\Eloquent\Collection;
use Illuminate\Contracts\Pagination\LengthAwarePaginator;
interface ProductCategoryRepositoryInterface
{
/**
* Get all product categories with pagination
*/
public function paginate(int $perPage = 15, array $filters = []): LengthAwarePaginator;
/**
* Get all product categories
*/
public function all(array $filters = []): Collection;
/**
* Find a product category by ID
*/
public function find(string $id): ?ProductCategory;
/**
* Create a new product category
*/
public function create(array $data): ProductCategory;
/**
* Update a product category
*/
public function update(string $id, array $data): bool;
/**
* Delete a product category
*/
public function delete(string $id): bool;
/**
* Get active categories only
*/
public function getActive(): Collection;
/**
* Get root categories (no parent)
*/
public function getRoots(): Collection;
/**
* Get categories with their children
*/
public function getWithChildren(): Collection;
/**
* Search categories by name or code
*/
public function search(string $term, int $perPage = 15): LengthAwarePaginator;
/**
* Get category statistics
*/
public function getStatistics(): array;
/**
* Check if category can be deleted
*/
public function canDelete(string $id): bool;
}

View File

@ -0,0 +1,36 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
/**
* Run the migrations.
*/
public function up(): void
{
Schema::table('products', function (Blueprint $table) {
// Remove the old photo_url column
$table->dropColumn('photo_url');
// Add new image column for file uploads
$table->string('image')->nullable()->after('conditionnement_unite');
});
}
/**
* Reverse the migrations.
*/
public function down(): void
{
Schema::table('products', function (Blueprint $table) {
// Remove the image column
$table->dropColumn('image');
// Restore the old photo_url column
$table->string('photo_url')->nullable();
});
}
};

View File

@ -0,0 +1,41 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
/**
* Run the migrations.
*/
public function up(): void
{
Schema::create('product_categories', function (Blueprint $table) {
$table->id();
$table->unsignedBigInteger('parent_id')->nullable();
$table->string('code', 64);
$table->string('name', 191);
$table->text('description')->nullable();
$table->boolean('active')->default(true);
$table->timestamps();
$table->foreign('parent_id')
->references('id')
->on('product_categories')
->onDelete('set null');
$table->index('code');
$table->index('active');
$table->index('parent_id');
});
}
/**
* Reverse the migrations.
*/
public function down(): void
{
Schema::dropIfExists('product_categories');
}
};

View File

@ -19,5 +19,7 @@ class DatabaseSeeder extends Seeder
'name' => 'Test User',
'email' => 'test@example.com',
]);
$this->call(ProductCategorySeeder::class);
}
}

View File

@ -0,0 +1,101 @@
<?php
namespace Database\Seeders;
use Illuminate\Database\Seeder;
use App\Models\ProductCategory;
class ProductCategorySeeder extends Seeder
{
/**
* Run the database seeds.
*/
public function run(): void
{
// Create root categories
$electronique = ProductCategory::create([
'code' => 'ELECTRONIQUE',
'name' => 'Électronique',
'description' => 'Produits électroniques et technologiques',
'active' => true,
]);
$alimentaire = ProductCategory::create([
'code' => 'ALIMENTAIRE',
'name' => 'Alimentaire',
'description' => 'Produits alimentaires et boissons',
'active' => true,
]);
$medical = ProductCategory::create([
'code' => 'MEDICAL',
'name' => 'Médical',
'description' => 'Produits médicaux et pharmaceutiques',
'active' => true,
]);
// Create subcategories
ProductCategory::create([
'parent_id' => $electronique->id,
'code' => 'TELEPHONE',
'name' => 'Téléphones',
'description' => 'Smartphones et téléphones mobiles',
'active' => true,
]);
ProductCategory::create([
'parent_id' => $electronique->id,
'code' => 'ORDINATEUR',
'name' => 'Ordinateurs',
'description' => 'Ordinateurs portables et de bureau',
'active' => true,
]);
ProductCategory::create([
'parent_id' => $alimentaire->id,
'code' => 'FRUITS',
'name' => 'Fruits',
'description' => 'Fruits frais et transformés',
'active' => true,
]);
ProductCategory::create([
'parent_id' => $alimentaire->id,
'code' => 'LEGUMES',
'name' => 'Légumes',
'description' => 'Légumes frais et transformés',
'active' => true,
]);
ProductCategory::create([
'parent_id' => $medical->id,
'code' => 'MEDICAMENT',
'name' => 'Médicaments',
'description' => 'Médicaments et traitements',
'active' => true,
]);
ProductCategory::create([
'parent_id' => $medical->id,
'code' => 'MATERIEL',
'name' => 'Matériel Médical',
'description' => 'Équipements et instruments médicaux',
'active' => true,
]);
// Create some inactive categories for testing
ProductCategory::create([
'code' => 'COSMETIQUE',
'name' => 'Cosmétique',
'description' => 'Produits cosmétiques et de beauté',
'active' => false,
]);
ProductCategory::create([
'code' => 'MENAGE',
'name' => 'Ménage',
'description' => 'Produits d\'entretien et de nettoyage',
'active' => false,
]);
}
}

View File

@ -9,6 +9,7 @@ use App\Http\Controllers\Api\ContactController;
use App\Http\Controllers\Api\ClientCategoryController;
use App\Http\Controllers\Api\FournisseurController;
use App\Http\Controllers\Api\ProductController;
use App\Http\Controllers\Api\ProductCategoryController;
/*
|--------------------------------------------------------------------------
@ -63,4 +64,13 @@ Route::middleware('auth:sanctum')->group(function () {
Route::get('/products/statistics', [ProductController::class, 'statistics']);
Route::apiResource('products', ProductController::class);
Route::patch('/products/{id}/stock', [ProductController::class, 'updateStock']);
// Product Category management
Route::get('/product-categories/search', [ProductCategoryController::class, 'search']);
Route::get('/product-categories/active', [ProductCategoryController::class, 'active']);
Route::get('/product-categories/roots', [ProductCategoryController::class, 'roots']);
Route::get('/product-categories/hierarchical', [ProductCategoryController::class, 'hierarchical']);
Route::get('/product-categories/statistics', [ProductCategoryController::class, 'statistics']);
Route::apiResource('product-categories', ProductCategoryController::class);
Route::patch('/product-categories/{id}/toggle-active', [ProductCategoryController::class, 'toggleActive']);
});