Feature: Warehouse et aussi les API bon de reception et Stock, mouvement stock, Warehouse stock

This commit is contained in:
kevin 2026-02-02 17:02:23 +03:00
parent 4af8ea2c60
commit d8927580e7
55 changed files with 2464 additions and 2 deletions

View File

@ -0,0 +1,134 @@
<?php
declare(strict_types=1);
namespace App\Http\Controllers\Api;
use App\Http\Controllers\Controller;
use App\Http\Requests\StoreStockItemRequest;
use App\Http\Requests\UpdateStockItemRequest;
use App\Http\Resources\StockItemResource;
use App\Repositories\StockItemRepositoryInterface;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Log;
class StockItemController extends Controller
{
public function __construct(
private readonly StockItemRepositoryInterface $stockItemRepository
) {
}
/**
* Display a listing of stock items.
*/
public function index(): JsonResponse
{
try {
$items = $this->stockItemRepository->all();
return response()->json([
'data' => StockItemResource::collection($items),
'status' => 'success'
]);
} catch (\Exception $e) {
Log::error('Error fetching stock items: ' . $e->getMessage());
return response()->json([
'message' => 'Une erreur est survenue lors de la récupération des stocks.',
'error' => config('app.debug') ? $e->getMessage() : null,
], 500);
}
}
/**
* Store a newly created stock item.
*/
public function store(StoreStockItemRequest $request): JsonResponse
{
try {
$item = $this->stockItemRepository->create($request->validated());
return response()->json([
'data' => new StockItemResource($item),
'message' => 'Stock initialisé avec succès.',
'status' => 'success'
], 201);
} catch (\Exception $e) {
Log::error('Error creating stock item: ' . $e->getMessage());
return response()->json([
'message' => 'Une erreur est survenue lors de l\'initialisation du stock.',
'error' => config('app.debug') ? $e->getMessage() : null,
], 500);
}
}
/**
* Display the specified stock item.
*/
public function show(string $id): JsonResponse
{
try {
$item = $this->stockItemRepository->find((int) $id);
if (!$item) {
return response()->json(['message' => 'Stock non trouvé.'], 404);
}
return response()->json([
'data' => new StockItemResource($item),
'status' => 'success'
]);
} catch (\Exception $e) {
Log::error('Error fetching stock item: ' . $e->getMessage());
return response()->json([
'message' => 'Une erreur est survenue lors de la récupération du stock.',
'error' => config('app.debug') ? $e->getMessage() : null,
], 500);
}
}
/**
* Update the specified stock item.
*/
public function update(UpdateStockItemRequest $request, string $id): JsonResponse
{
try {
$updated = $this->stockItemRepository->update((int) $id, $request->validated());
if (!$updated) {
return response()->json(['message' => 'Stock non trouvé ou échec de la mise à jour.'], 404);
}
$item = $this->stockItemRepository->find((int) $id);
return response()->json([
'data' => new StockItemResource($item),
'message' => 'Stock mis à jour avec succès.',
'status' => 'success'
]);
} catch (\Exception $e) {
Log::error('Error updating stock item: ' . $e->getMessage());
return response()->json([
'message' => 'Une erreur est survenue lors de la mise à jour du stock.',
'error' => config('app.debug') ? $e->getMessage() : null,
], 500);
}
}
/**
* Remove the specified stock item.
*/
public function destroy(string $id): JsonResponse
{
try {
$deleted = $this->stockItemRepository->delete((int) $id);
if (!$deleted) {
return response()->json(['message' => 'Stock non trouvé ou échec de la suppression.'], 404);
}
return response()->json([
'message' => 'Stock supprimé avec succès.',
'status' => 'success'
]);
} catch (\Exception $e) {
Log::error('Error deleting stock item: ' . $e->getMessage());
return response()->json([
'message' => 'Une erreur est survenue lors de la suppression du stock.',
'error' => config('app.debug') ? $e->getMessage() : null,
], 500);
}
}
}

View File

@ -0,0 +1,85 @@
<?php
declare(strict_types=1);
namespace App\Http\Controllers\Api;
use App\Http\Controllers\Controller;
use App\Http\Requests\StoreStockMoveRequest;
use App\Http\Resources\StockMoveResource;
use App\Repositories\StockMoveRepositoryInterface;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Log;
class StockMoveController extends Controller
{
public function __construct(
private readonly StockMoveRepositoryInterface $stockMoveRepository
) {
}
/**
* Display a listing of stock moves.
*/
public function index(Request $request): JsonResponse
{
try {
$moves = $this->stockMoveRepository->all();
return response()->json([
'data' => StockMoveResource::collection($moves),
'status' => 'success'
]);
} catch (\Exception $e) {
Log::error('Error fetching stock moves: ' . $e->getMessage());
return response()->json([
'message' => 'Une erreur est survenue lors de la récupération des mouvements de stock.',
'error' => config('app.debug') ? $e->getMessage() : null,
], 500);
}
}
/**
* Store a newly created stock move.
*/
public function store(StoreStockMoveRequest $request): JsonResponse
{
try {
$move = $this->stockMoveRepository->create($request->validated());
return response()->json([
'data' => new StockMoveResource($move),
'message' => 'Mouvement de stock enregistré avec succès.',
'status' => 'success'
], 201);
} catch (\Exception $e) {
Log::error('Error creating stock move: ' . $e->getMessage());
return response()->json([
'message' => 'Une erreur est survenue lors de l\'enregistrement du mouvement de stock.',
'error' => config('app.debug') ? $e->getMessage() : null,
], 500);
}
}
/**
* Display the specified stock move.
*/
public function show(string $id): JsonResponse
{
try {
$move = $this->stockMoveRepository->find((int) $id);
if (!$move) {
return response()->json(['message' => 'Mouvement de stock non trouvé.'], 404);
}
return response()->json([
'data' => new StockMoveResource($move),
'status' => 'success'
]);
} catch (\Exception $e) {
Log::error('Error fetching stock move: ' . $e->getMessage());
return response()->json([
'message' => 'Une erreur est survenue lors de la récupération du mouvement de stock.',
'error' => config('app.debug') ? $e->getMessage() : null,
], 500);
}
}
}

View File

@ -0,0 +1,134 @@
<?php
declare(strict_types=1);
namespace App\Http\Controllers\Api;
use App\Http\Controllers\Controller;
use App\Http\Requests\StoreWarehouseRequest;
use App\Http\Requests\UpdateWarehouseRequest;
use App\Http\Resources\WarehouseResource;
use App\Repositories\WarehouseRepositoryInterface;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Log;
class WarehouseController extends Controller
{
public function __construct(
private readonly WarehouseRepositoryInterface $warehouseRepository
) {
}
/**
* Display a listing of warehouses.
*/
public function index(): JsonResponse
{
try {
$warehouses = $this->warehouseRepository->all();
return response()->json([
'data' => WarehouseResource::collection($warehouses),
'status' => 'success'
]);
} catch (\Exception $e) {
Log::error('Error fetching warehouses: ' . $e->getMessage());
return response()->json([
'message' => 'Une erreur est survenue lors de la récupération des entrepôts.',
'error' => config('app.debug') ? $e->getMessage() : null,
], 500);
}
}
/**
* Store a newly created warehouse.
*/
public function store(StoreWarehouseRequest $request): JsonResponse
{
try {
$warehouse = $this->warehouseRepository->create($request->validated());
return response()->json([
'data' => new WarehouseResource($warehouse),
'message' => 'Entrepôt créé avec succès.',
'status' => 'success'
], 201);
} catch (\Exception $e) {
Log::error('Error creating warehouse: ' . $e->getMessage());
return response()->json([
'message' => 'Une erreur est survenue lors de la création de l\'entrepôt.',
'error' => config('app.debug') ? $e->getMessage() : null,
], 500);
}
}
/**
* Display the specified warehouse.
*/
public function show(string $id): JsonResponse
{
try {
$warehouse = $this->warehouseRepository->find((int) $id);
if (!$warehouse) {
return response()->json(['message' => 'Entrepôt non trouvé.'], 404);
}
return response()->json([
'data' => new WarehouseResource($warehouse),
'status' => 'success'
]);
} catch (\Exception $e) {
Log::error('Error fetching warehouse: ' . $e->getMessage());
return response()->json([
'message' => 'Une erreur est survenue lors de la récupération de l\'entrepôt.',
'error' => config('app.debug') ? $e->getMessage() : null,
], 500);
}
}
/**
* Update the specified warehouse.
*/
public function update(UpdateWarehouseRequest $request, string $id): JsonResponse
{
try {
$updated = $this->warehouseRepository->update((int) $id, $request->validated());
if (!$updated) {
return response()->json(['message' => 'Entrepôt non trouvé ou échec de la mise à jour.'], 404);
}
$warehouse = $this->warehouseRepository->find((int) $id);
return response()->json([
'data' => new WarehouseResource($warehouse),
'message' => 'Entrepôt mis à jour avec succès.',
'status' => 'success'
]);
} catch (\Exception $e) {
Log::error('Error updating warehouse: ' . $e->getMessage());
return response()->json([
'message' => 'Une erreur est survenue lors de la mise à jour de l\'entrepôt.',
'error' => config('app.debug') ? $e->getMessage() : null,
], 500);
}
}
/**
* Remove the specified warehouse.
*/
public function destroy(string $id): JsonResponse
{
try {
$deleted = $this->warehouseRepository->delete((int) $id);
if (!$deleted) {
return response()->json(['message' => 'Entrepôt non trouvé ou échec de la suppression.'], 404);
}
return response()->json([
'message' => 'Entrepôt supprimé avec succès.',
'status' => 'success'
]);
} catch (\Exception $e) {
Log::error('Error deleting warehouse: ' . $e->getMessage());
return response()->json([
'message' => 'Une erreur est survenue lors de la suppression de l\'entrepôt.',
'error' => config('app.debug') ? $e->getMessage() : null,
], 500);
}
}
}

View File

@ -0,0 +1,48 @@
<?php
namespace App\Http\Requests;
use Illuminate\Foundation\Http\FormRequest;
class StoreStockItemRequest 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 [
'product_id' => 'required|exists:products,id',
'warehouse_id' => 'required|exists:warehouses,id',
'qty_on_hand_base' => 'nullable|numeric|min:0',
'safety_stock_base' => 'nullable|numeric|min:0',
];
}
/**
* Get the error messages for the defined validation rules.
*
* @return array<string, string>
*/
public function messages(): array
{
return [
'product_id.required' => 'Le produit est requis.',
'product_id.exists' => 'Le produit sélectionné est invalide.',
'warehouse_id.required' => 'L\'entrepôt est requis.',
'warehouse_id.exists' => 'L\'entrepôt sélectionné est invalide.',
'qty_on_hand_base.numeric' => 'La quantité en stock doit être un nombre.',
'safety_stock_base.numeric' => 'Le stock de sécurité doit être un nombre.',
];
}
}

View File

@ -0,0 +1,58 @@
<?php
namespace App\Http\Requests;
use Illuminate\Foundation\Http\FormRequest;
class StoreStockMoveRequest 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 [
'product_id' => 'required|exists:products,id',
'from_warehouse_id' => 'nullable|exists:warehouses,id',
'to_warehouse_id' => 'nullable|exists:warehouses,id',
'packaging_id' => 'nullable|exists:product_packagings,id',
'packages_qty' => 'nullable|numeric|min:0',
'units_qty' => 'nullable|numeric|min:0',
'qty_base' => 'required|numeric',
'move_type' => 'required|string|max:64',
'ref_type' => 'nullable|string|max:64',
'ref_id' => 'nullable|integer',
'moved_at' => 'nullable|date',
];
}
/**
* Get the error messages for the defined validation rules.
*
* @return array<string, string>
*/
public function messages(): array
{
return [
'product_id.required' => 'Le produit est requis.',
'product_id.exists' => 'Le produit sélectionné est invalide.',
'from_warehouse_id.exists' => 'L\'entrepôt de départ est invalide.',
'to_warehouse_id.exists' => 'L\'entrepôt d\'arrivée est invalide.',
'packaging_id.exists' => 'Le conditionnement sélectionné est invalide.',
'qty_base.required' => 'La quantité de base est requise.',
'qty_base.numeric' => 'La quantité de base doit être un nombre.',
'move_type.required' => 'Le type de mouvement est requis.',
'moved_at.date' => 'La date du mouvement n\'est pas valide.',
];
}
}

View File

@ -0,0 +1,52 @@
<?php
namespace App\Http\Requests;
use Illuminate\Foundation\Http\FormRequest;
class StoreWarehouseRequest 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 [
'name' => 'required|string|max:191',
'address_line1' => 'nullable|string|max:255',
'address_line2' => 'nullable|string|max:255',
'postal_code' => 'nullable|string|max:20',
'city' => 'nullable|string|max:191',
'country_code' => 'nullable|string|size:2',
];
}
/**
* Get the error messages for the defined validation rules.
*
* @return array<string, string>
*/
public function messages(): array
{
return [
'name.required' => 'Le nom de l\'entrepôt est requis.',
'name.string' => 'Le nom doit être une chaîne de caractères.',
'name.max' => 'Le nom ne peut pas dépasser 191 caractères.',
'address_line1.max' => 'L\'adresse ne peut pas dépasser 255 caractères.',
'address_line2.max' => 'Le complément d\'adresse ne peut pas dépasser 255 caractères.',
'postal_code.max' => 'Le code postal ne peut pas dépasser 20 caractères.',
'city.max' => 'La ville ne peut pas dépasser 191 caractères.',
'country_code.size' => 'Le code pays doit comporter exactement 2 caractères.',
];
}
}

View File

@ -0,0 +1,42 @@
<?php
namespace App\Http\Requests;
use Illuminate\Foundation\Http\FormRequest;
class UpdateStockItemRequest 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 [
'qty_on_hand_base' => 'sometimes|numeric|min:0',
'safety_stock_base' => 'sometimes|numeric|min:0',
];
}
/**
* Get the error messages for the defined validation rules.
*
* @return array<string, string>
*/
public function messages(): array
{
return [
'qty_on_hand_base.numeric' => 'La quantité en stock doit être un nombre.',
'safety_stock_base.numeric' => 'Le stock de sécurité doit être un nombre.',
];
}
}

View File

@ -0,0 +1,52 @@
<?php
namespace App\Http\Requests;
use Illuminate\Foundation\Http\FormRequest;
class UpdateWarehouseRequest 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 [
'name' => 'sometimes|required|string|max:191',
'address_line1' => 'nullable|string|max:255',
'address_line2' => 'nullable|string|max:255',
'postal_code' => 'nullable|string|max:20',
'city' => 'nullable|string|max:191',
'country_code' => 'nullable|string|size:2',
];
}
/**
* Get the error messages for the defined validation rules.
*
* @return array<string, string>
*/
public function messages(): array
{
return [
'name.required' => 'Le nom de l\'entrepôt est requis.',
'name.string' => 'Le nom doit être une chaîne de caractères.',
'name.max' => 'Le nom ne peut pas dépasser 191 caractères.',
'address_line1.max' => 'L\'adresse ne peut pas dépasser 255 caractères.',
'address_line2.max' => 'Le complément d\'adresse ne peut pas dépasser 255 caractères.',
'postal_code.max' => 'Le code postal ne peut pas dépasser 20 caractères.',
'city.max' => 'La ville ne peut pas dépasser 191 caractères.',
'country_code.size' => 'Le code pays doit comporter exactement 2 caractères.',
];
}
}

View File

@ -0,0 +1,26 @@
<?php
namespace App\Http\Resources;
use Illuminate\Http\Request;
use Illuminate\Http\Resources\Json\JsonResource;
class ProductPackagingResource extends JsonResource
{
/**
* Transform the resource into an array.
*
* @return array<string, mixed>
*/
public function toArray(Request $request): array
{
return [
'id' => $this->id,
'product_id' => $this->product_id,
'name' => $this->name,
'qty_base' => $this->qty_base,
'created_at' => $this->created_at,
'updated_at' => $this->updated_at,
];
}
}

View File

@ -0,0 +1,30 @@
<?php
namespace App\Http\Resources;
use App\Http\Resources\Product\ProductResource;
use Illuminate\Http\Request;
use Illuminate\Http\Resources\Json\JsonResource;
class StockItemResource extends JsonResource
{
/**
* Transform the resource into an array.
*
* @return array<string, mixed>
*/
public function toArray(Request $request): array
{
return [
'id' => $this->id,
'product_id' => $this->product_id,
'warehouse_id' => $this->warehouse_id,
'qty_on_hand_base' => $this->qty_on_hand_base,
'safety_stock_base' => $this->safety_stock_base,
'product' => new ProductResource($this->whenLoaded('product')),
'warehouse' => new WarehouseResource($this->whenLoaded('warehouse')),
'created_at' => $this->created_at,
'updated_at' => $this->updated_at,
];
}
}

View File

@ -0,0 +1,38 @@
<?php
namespace App\Http\Resources;
use App\Http\Resources\Product\ProductResource;
use Illuminate\Http\Request;
use Illuminate\Http\Resources\Json\JsonResource;
class StockMoveResource extends JsonResource
{
/**
* Transform the resource into an array.
*
* @return array<string, mixed>
*/
public function toArray(Request $request): array
{
return [
'id' => $this->id,
'product_id' => $this->product_id,
'from_warehouse_id' => $this->from_warehouse_id,
'to_warehouse_id' => $this->to_warehouse_id,
'packaging_id' => $this->packaging_id,
'packages_qty' => $this->packages_qty,
'units_qty' => $this->units_qty,
'qty_base' => $this->qty_base,
'move_type' => $this->move_type,
'ref_type' => $this->ref_type,
'ref_id' => $this->ref_id,
'moved_at' => $this->moved_at,
'product' => new ProductResource($this->whenLoaded('product')),
'from_warehouse' => new WarehouseResource($this->whenLoaded('fromWarehouse')),
'to_warehouse' => new WarehouseResource($this->whenLoaded('toWarehouse')),
'created_at' => $this->created_at,
'updated_at' => $this->updated_at,
];
}
}

View File

@ -0,0 +1,29 @@
<?php
namespace App\Http\Resources;
use Illuminate\Http\Request;
use Illuminate\Http\Resources\Json\JsonResource;
class WarehouseResource extends JsonResource
{
/**
* Transform the resource into an array.
*
* @return array<string, mixed>
*/
public function toArray(Request $request): array
{
return [
'id' => $this->id,
'name' => $this->name,
'address_line1' => $this->address_line1,
'address_line2' => $this->address_line2,
'postal_code' => $this->postal_code,
'city' => $this->city,
'country_code' => $this->country_code,
'created_at' => $this->created_at,
'updated_at' => $this->updated_at,
];
}
}

View File

@ -99,6 +99,30 @@ class Product extends Model
return null;
}
/**
* Get the stock items for the product.
*/
public function stockItems(): \Illuminate\Database\Eloquent\Relations\HasMany
{
return $this->hasMany(StockItem::class);
}
/**
* Get the packagings for the product.
*/
public function packagings(): \Illuminate\Database\Eloquent\Relations\HasMany
{
return $this->hasMany(\App\Models\Stock\ProductPackaging::class);
}
/**
* Get the stock moves for the product.
*/
public function stockMoves(): \Illuminate\Database\Eloquent\Relations\HasMany
{
return $this->hasMany(StockMove::class);
}
/**
* Boot the model
*/

View File

@ -0,0 +1,30 @@
<?php
namespace App\Models\Stock;
use App\Models\Product;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
class ProductPackaging extends Model
{
use HasFactory;
protected $fillable = [
'product_id',
'name',
'qty_base',
];
protected $casts = [
'qty_base' => 'decimal:3',
];
/**
* Get the product that owns the packaging.
*/
public function product(): BelongsTo
{
return $this->belongsTo(Product::class);
}
}

View File

@ -0,0 +1,39 @@
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
class StockItem extends Model
{
use HasFactory;
protected $fillable = [
'product_id',
'warehouse_id',
'qty_on_hand_base',
'safety_stock_base',
];
protected $casts = [
'qty_on_hand_base' => 'decimal:3',
'safety_stock_base' => 'decimal:3',
];
/**
* Get the product associated with this stock item.
*/
public function product(): BelongsTo
{
return $this->belongsTo(Product::class);
}
/**
* Get the warehouse where this stock item is located.
*/
public function warehouse(): BelongsTo
{
return $this->belongsTo(Warehouse::class);
}
}

View File

@ -0,0 +1,65 @@
<?php
namespace App\Models;
use App\Models\Stock\ProductPackaging;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
class StockMove extends Model
{
use HasFactory;
protected $fillable = [
'product_id',
'from_warehouse_id',
'to_warehouse_id',
'packaging_id',
'packages_qty',
'units_qty',
'qty_base',
'move_type',
'ref_type',
'ref_id',
'moved_at',
];
protected $casts = [
'packages_qty' => 'decimal:3',
'units_qty' => 'decimal:3',
'qty_base' => 'decimal:3',
'moved_at' => 'datetime',
];
/**
* Get the product being moved.
*/
public function product(): BelongsTo
{
return $this->belongsTo(Product::class);
}
/**
* Get the source warehouse.
*/
public function fromWarehouse(): BelongsTo
{
return $this->belongsTo(Warehouse::class, 'from_warehouse_id');
}
/**
* Get the destination warehouse.
*/
public function toWarehouse(): BelongsTo
{
return $this->belongsTo(Warehouse::class, 'to_warehouse_id');
}
/**
* Get the packaging used for this move.
*/
public function packaging(): BelongsTo
{
return $this->belongsTo(ProductPackaging::class, 'packaging_id');
}
}

View File

@ -0,0 +1,44 @@
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\HasMany;
class Warehouse extends Model
{
use HasFactory;
protected $fillable = [
'name',
'address_line1',
'address_line2',
'postal_code',
'city',
'country_code',
];
/**
* Get the stock items for the warehouse.
*/
public function stockItems(): HasMany
{
return $this->hasMany(StockItem::class);
}
/**
* Get the stock moves from this warehouse.
*/
public function movesFrom(): HasMany
{
return $this->hasMany(StockMove::class, 'from_warehouse_id');
}
/**
* Get the stock moves to this warehouse.
*/
public function movesTo(): HasMany
{
return $this->hasMany(StockMove::class, 'to_warehouse_id');
}
}

View File

@ -24,7 +24,10 @@ class RepositoryServiceProvider extends ServiceProvider
$this->app->bind(InterventionRepositoryInterface::class, InterventionRepository::class);
$this->app->bind(FileRepositoryInterface::class, FileRepository::class);
$this->app->bind(\App\Repositories\PurchaseOrderRepositoryInterface::class, \App\Repositories\PurchaseOrderRepository::class);
$this->app->bind(\App\Repositories\WarehouseRepositoryInterface::class, \App\Repositories\WarehouseRepository::class);
$this->app->bind(\App\Repositories\StockItemRepositoryInterface::class, \App\Repositories\StockItemRepository::class);
$this->app->bind(\App\Repositories\StockMoveRepositoryInterface::class, \App\Repositories\StockMoveRepository::class);
$this->app->bind(\App\Repositories\ProductPackagingRepositoryInterface::class, \App\Repositories\ProductPackagingRepository::class);
}
/**

View File

@ -0,0 +1,15 @@
<?php
declare(strict_types=1);
namespace App\Repositories;
use App\Models\Stock\ProductPackaging;
class ProductPackagingRepository extends BaseRepository implements ProductPackagingRepositoryInterface
{
public function __construct(ProductPackaging $model)
{
parent::__construct($model);
}
}

View File

@ -0,0 +1,9 @@
<?php
declare(strict_types=1);
namespace App\Repositories;
interface ProductPackagingRepositoryInterface extends BaseRepositoryInterface
{
}

View File

@ -0,0 +1,15 @@
<?php
declare(strict_types=1);
namespace App\Repositories;
use App\Models\StockItem;
class StockItemRepository extends BaseRepository implements StockItemRepositoryInterface
{
public function __construct(StockItem $model)
{
parent::__construct($model);
}
}

View File

@ -0,0 +1,9 @@
<?php
declare(strict_types=1);
namespace App\Repositories;
interface StockItemRepositoryInterface extends BaseRepositoryInterface
{
}

View File

@ -0,0 +1,15 @@
<?php
declare(strict_types=1);
namespace App\Repositories;
use App\Models\StockMove;
class StockMoveRepository extends BaseRepository implements StockMoveRepositoryInterface
{
public function __construct(StockMove $model)
{
parent::__construct($model);
}
}

View File

@ -0,0 +1,9 @@
<?php
declare(strict_types=1);
namespace App\Repositories;
interface StockMoveRepositoryInterface extends BaseRepositoryInterface
{
}

View File

@ -0,0 +1,15 @@
<?php
declare(strict_types=1);
namespace App\Repositories;
use App\Models\Warehouse;
class WarehouseRepository extends BaseRepository implements WarehouseRepositoryInterface
{
public function __construct(Warehouse $model)
{
parent::__construct($model);
}
}

View File

@ -0,0 +1,9 @@
<?php
declare(strict_types=1);
namespace App\Repositories;
interface WarehouseRepositoryInterface extends BaseRepositoryInterface
{
}

View File

@ -2,4 +2,5 @@
return [
App\Providers\AppServiceProvider::class,
App\Providers\RepositoryServiceProvider::class,
];

View File

@ -0,0 +1,29 @@
<?php
namespace Database\Factories;
use App\Models\Product;
use App\Models\Stock\ProductPackaging;
use Illuminate\Database\Eloquent\Factories\Factory;
/**
* @extends \Illuminate\Database\Eloquent\Factories\Factory<\App\Models\Stock\ProductPackaging>
*/
class ProductPackagingFactory extends Factory
{
protected $model = ProductPackaging::class;
/**
* Define the model's default state.
*
* @return array<string, mixed>
*/
public function definition(): array
{
return [
'product_id' => Product::factory(),
'name' => $this->faker->word(),
'qty_base' => $this->faker->randomFloat(3, 1, 100),
];
}
}

View File

@ -0,0 +1,31 @@
<?php
namespace Database\Factories;
use App\Models\Product;
use App\Models\StockItem;
use App\Models\Warehouse;
use Illuminate\Database\Eloquent\Factories\Factory;
/**
* @extends \Illuminate\Database\Eloquent\Factories\Factory<\App\Models\StockItem>
*/
class StockItemFactory extends Factory
{
protected $model = StockItem::class;
/**
* Define the model's default state.
*
* @return array<string, mixed>
*/
public function definition(): array
{
return [
'product_id' => Product::factory(),
'warehouse_id' => Warehouse::factory(),
'qty_on_hand_base' => $this->faker->randomFloat(3, 0, 1000),
'safety_stock_base' => $this->faker->randomFloat(3, 0, 100),
];
}
}

View File

@ -0,0 +1,33 @@
<?php
namespace Database\Factories;
use App\Models\Product;
use App\Models\StockMove;
use App\Models\Warehouse;
use Illuminate\Database\Eloquent\Factories\Factory;
/**
* @extends \Illuminate\Database\Eloquent\Factories\Factory<\App\Models\StockMove>
*/
class StockMoveFactory extends Factory
{
protected $model = StockMove::class;
/**
* Define the model's default state.
*
* @return array<string, mixed>
*/
public function definition(): array
{
return [
'product_id' => Product::factory(),
'from_warehouse_id' => Warehouse::factory(),
'to_warehouse_id' => Warehouse::factory(),
'qty_base' => $this->faker->randomFloat(3, 1, 100),
'move_type' => $this->faker->randomElement(['receipt', 'issue', 'transfer', 'adjustment', 'consumption']),
'moved_at' => now(),
];
}
}

View File

@ -0,0 +1,31 @@
<?php
namespace Database\Factories;
use App\Models\Warehouse;
use Illuminate\Database\Eloquent\Factories\Factory;
/**
* @extends \Illuminate\Database\Eloquent\Factories\Factory<\App\Models\Warehouse>
*/
class WarehouseFactory extends Factory
{
protected $model = Warehouse::class;
/**
* Define the model's default state.
*
* @return array<string, mixed>
*/
public function definition(): array
{
return [
'name' => $this->faker->company() . ' Warehouse',
'address_line1' => $this->faker->streetAddress(),
'address_line2' => $this->faker->secondaryAddress(),
'postal_code' => $this->faker->postcode(),
'city' => $this->faker->city(),
'country_code' => 'FR',
];
}
}

View File

@ -0,0 +1,33 @@
<?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('warehouses', function (Blueprint $table) {
$table->id();
$table->string('name', 191);
$table->string('address_line1', 255)->nullable();
$table->string('address_line2', 255)->nullable();
$table->string('postal_code', 20)->nullable();
$table->string('city', 191)->nullable();
$table->char('country_code', 2)->default('FR');
$table->timestamps();
});
}
/**
* Reverse the migrations.
*/
public function down(): void
{
Schema::dropIfExists('warehouses');
}
};

View File

@ -0,0 +1,29 @@
<?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_packagings', function (Blueprint $table) {
$table->id();
$table->foreignId('product_id')->constrained()->onDelete('cascade');
$table->string('name', 191);
$table->decimal('qty_base', 14, 3);
$table->timestamps();
});
}
/**
* Reverse the migrations.
*/
public function down(): void
{
Schema::dropIfExists('product_packagings');
}
};

View File

@ -0,0 +1,32 @@
<?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('stock_items', function (Blueprint $table) {
$table->id();
$table->foreignId('product_id')->constrained()->onDelete('cascade');
$table->foreignId('warehouse_id')->constrained()->onDelete('cascade');
$table->decimal('qty_on_hand_base', 14, 3)->default(0);
$table->decimal('safety_stock_base', 14, 3)->default(0);
$table->timestamps();
$table->unique(['product_id', 'warehouse_id'], 'uq_stock_item');
});
}
/**
* Reverse the migrations.
*/
public function down(): void
{
Schema::dropIfExists('stock_items');
}
};

View File

@ -0,0 +1,37 @@
<?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('stock_moves', function (Blueprint $table) {
$table->id();
$table->foreignId('product_id')->constrained()->onDelete('cascade');
$table->foreignId('from_warehouse_id')->nullable()->constrained('warehouses')->onDelete('set null');
$table->foreignId('to_warehouse_id')->nullable()->constrained('warehouses')->onDelete('set null');
$table->foreignId('packaging_id')->nullable()->constrained('product_packagings')->onDelete('set null');
$table->decimal('packages_qty', 14, 3)->nullable();
$table->decimal('units_qty', 14, 3)->nullable();
$table->decimal('qty_base', 14, 3);
$table->string('move_type', 64);
$table->string('ref_type', 64)->nullable();
$table->unsignedBigInteger('ref_id')->nullable();
$table->timestamp('moved_at')->useCurrent();
$table->timestamps();
});
}
/**
* Reverse the migrations.
*/
public function down(): void
{
Schema::dropIfExists('stock_moves');
}
};

View File

@ -98,6 +98,13 @@ Route::middleware('auth:sanctum')->group(function () {
Route::apiResource('products', ProductController::class);
Route::patch('/products/{id}/stock', [ProductController::class, 'updateStock']);
// Warehouse management
Route::apiResource('warehouses', \App\Http\Controllers\Api\WarehouseController::class);
// Stock management
Route::apiResource('stock-items', \App\Http\Controllers\Api\StockItemController::class);
Route::apiResource('stock-moves', \App\Http\Controllers\Api\StockMoveController::class);
// Product Category management
Route::get('/product-categories/search', [ProductCategoryController::class, 'search']);
Route::get('/product-categories/active', [ProductCategoryController::class, 'active']);
@ -124,7 +131,7 @@ Route::middleware('auth:sanctum')->group(function () {
Route::apiResource('practitioner-documents', PractitionerDocumentController::class);
Route::patch('/practitioner-documents/{id}/verify', [PractitionerDocumentController::class, 'verifyDocument']);
// Deceased Routes
// Deceased Routes
Route::prefix('deceased')->group(function () {
Route::get('/searchBy', [DeceasedController::class, 'searchBy']);
Route::get('/', [DeceasedController::class, 'index']);

View File

@ -0,0 +1,65 @@
<?php
namespace Tests\Feature;
use App\Models\Product;
use App\Models\User;
use App\Models\Warehouse;
use App\Models\StockMove;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Laravel\Sanctum\Sanctum;
use Tests\TestCase;
class StockMoveApiTest extends TestCase
{
use RefreshDatabase;
protected function setUp(): void
{
parent::setUp();
Sanctum::actingAs(User::factory()->create());
}
public function test_can_list_stock_moves(): void
{
StockMove::factory()->count(2)->create();
$response = $this->getJson('/api/stock-moves');
$response->assertStatus(200)
->assertJsonCount(2, 'data');
}
public function test_can_create_stock_move(): void
{
$product = Product::factory()->create();
$warehouse = Warehouse::factory()->create();
$data = [
'product_id' => $product->id,
'to_warehouse_id' => $warehouse->id,
'qty_base' => 10,
'move_type' => 'receipt',
'moved_at' => now()->toDateTimeString(),
];
$response = $this->postJson('/api/stock-moves', $data);
$response->assertStatus(201)
->assertJsonPath('data.qty_base', 10);
$this->assertDatabaseHas('stock_moves', [
'product_id' => $product->id,
'qty_base' => 10
]);
}
public function test_valide_french_messages_for_stock_move(): void
{
$response = $this->postJson('/api/stock-moves', []);
$response->assertStatus(422)
->assertJsonValidationErrors(['product_id', 'qty_base', 'move_type'])
->assertJsonFragment(['product_id' => ['Le produit est requis.']]);
}
}

View File

@ -0,0 +1,89 @@
<?php
namespace Tests\Feature;
use App\Models\User;
use App\Models\Warehouse;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Laravel\Sanctum\Sanctum;
use Tests\TestCase;
class WarehouseApiTest extends TestCase
{
use RefreshDatabase;
protected function setUp(): void
{
parent::setUp();
Sanctum::actingAs(User::factory()->create());
}
public function test_can_list_warehouses(): void
{
Warehouse::factory()->count(3)->create();
$response = $this->getJson('/api/warehouses');
$response->assertStatus(200)
->assertJsonCount(3, 'data');
}
public function test_can_create_warehouse(): void
{
$data = [
'name' => 'Main Warehouse',
'city' => 'Paris',
'country_code' => 'FR',
];
$response = $this->postJson('/api/warehouses', $data);
$response->assertStatus(201)
->assertJsonPath('data.name', 'Main Warehouse');
$this->assertDatabaseHas('warehouses', ['name' => 'Main Warehouse']);
}
public function test_can_show_warehouse(): void
{
$warehouse = Warehouse::factory()->create();
$response = $this->getJson('/api/warehouses/' . $warehouse->id);
$response->assertStatus(200)
->assertJsonPath('data.id', $warehouse->id);
}
public function test_can_update_warehouse(): void
{
$warehouse = Warehouse::factory()->create(['name' => 'Old Name']);
$response = $this->putJson('/api/warehouses/' . $warehouse->id, [
'name' => 'New Name'
]);
$response->assertStatus(200)
->assertJsonPath('data.name', 'New Name');
$this->assertDatabaseHas('warehouses', ['id' => $warehouse->id, 'name' => 'New Name']);
}
public function test_can_delete_warehouse(): void
{
$warehouse = Warehouse::factory()->create();
$response = $this->deleteJson('/api/warehouses/' . $warehouse->id);
$response->assertStatus(200);
$this->assertDatabaseMissing('warehouses', ['id' => $warehouse->id]);
}
public function test_valide_french_messages(): void
{
$response = $this->postJson('/api/warehouses', []);
$response->assertStatus(422)
->assertJsonValidationErrors(['name'])
->assertJsonFragment(['name' => ['Le nom de l\'entrepôt est requis.']]);
}
}

View File

@ -0,0 +1,95 @@
<template>
<div v-if="loading" class="text-center py-5">
<div class="spinner-border text-primary" role="status">
<span class="visually-hidden">Loading...</span>
</div>
</div>
<div v-else-if="error" class="text-center py-5 text-danger">
{{ error }}
</div>
<div v-else-if="warehouse" class="container-fluid py-4">
<div class="row">
<div class="col-lg-8 mx-auto">
<div class="card mb-4">
<div class="card-header pb-0 p-3">
<div class="row">
<div class="col-md-8 d-flex align-items-center">
<h6 class="mb-0">Détails de l'entrepôt: {{ warehouse.name }}</h6>
</div>
<div class="col-md-4 text-end">
<soft-button color="info" variant="outline" size="sm" @click="handleEdit">
<i class="fas fa-user-edit me-2"></i> Modifier
</soft-button>
</div>
</div>
</div>
<div class="card-body p-3">
<warehouse-detail-info :warehouse="warehouse" />
<hr class="horizontal dark my-4" />
<div class="d-flex justify-content-between">
<soft-button color="secondary" variant="gradient" @click="handleBack">
<i class="fas fa-arrow-left me-2"></i> Retour à la liste
</soft-button>
<soft-button color="danger" variant="outline" @click="handleDelete">
<i class="fas fa-trash me-2"></i> Supprimer
</soft-button>
</div>
</div>
</div>
</div>
</div>
</div>
</template>
<script setup>
import { ref, onMounted, defineProps } from "vue";
import { useRouter } from "vue-router";
import { useWarehouseStore } from "@/stores/warehouseStore";
import SoftButton from "@/components/SoftButton.vue";
import WarehouseDetailInfo from "@/components/molecules/Stock/WarehouseDetailInfo.vue";
const props = defineProps({
warehouseId: {
type: [String, Number],
required: true,
},
});
const router = useRouter();
const warehouseStore = useWarehouseStore();
const warehouse = ref(null);
const loading = ref(true);
const error = ref(null);
onMounted(async () => {
loading.value = true;
try {
const fetchedWarehouse = await warehouseStore.fetchWarehouse(props.warehouseId);
warehouse.value = fetchedWarehouse;
} catch (e) {
error.value = "Impossible de charger l'entrepôt.";
console.error(e);
} finally {
loading.value = false;
}
});
const handleEdit = () => {
router.push(`/stock/warehouses/${props.warehouseId}/edit`);
};
const handleBack = () => {
router.push("/stock/warehouses");
};
const handleDelete = async () => {
if (confirm("Êtes-vous sûr de vouloir supprimer cet entrepôt ?")) {
try {
await warehouseStore.deleteWarehouse(props.warehouseId);
router.push("/stock/warehouses");
} catch (e) {
console.error("Failed to delete warehouse", e);
}
}
};
</script>

View File

@ -0,0 +1,96 @@
<template>
<div class="container-fluid py-4">
<div class="row">
<div class="col-lg-8 mx-auto">
<div class="card mb-4">
<div class="card-header pb-0 p-3">
<h6 class="mb-0">{{ isEdit ? 'Modifier' : 'Nouvel' }} Entrepôt</h6>
</div>
<div class="card-body p-3">
<warehouse-form
v-model="form"
:submitting="submitting"
:error="error"
:submit-label="isEdit ? 'Mettre à jour' : 'Créer'"
@submit="handleSubmit"
@cancel="handleCancel"
/>
</div>
</div>
</div>
</div>
</div>
</template>
<script setup>
import { ref, onMounted, defineProps, computed } from "vue";
import { useRouter } from "vue-router";
import { useWarehouseStore } from "@/stores/warehouseStore";
import WarehouseForm from "@/components/molecules/Stock/WarehouseForm.vue";
const props = defineProps({
warehouseId: {
type: [String, Number],
default: null,
},
});
const router = useRouter();
const warehouseStore = useWarehouseStore();
const isEdit = computed(() => !!props.warehouseId);
const form = ref({
name: "",
address_line1: "",
address_line2: "",
postal_code: "",
city: "",
country_code: "FR",
});
const submitting = ref(false);
const error = ref(null);
onMounted(async () => {
if (isEdit.value) {
try {
const warehouse = await warehouseStore.fetchWarehouse(props.warehouseId);
if (warehouse) {
form.value = {
name: warehouse.name,
address_line1: warehouse.address_line1 || "",
address_line2: warehouse.address_line2 || "",
postal_code: warehouse.postal_code || "",
city: warehouse.city || "",
country_code: warehouse.country_code || "FR",
};
}
} catch (e) {
error.value = "Impossible de charger les données de l'entrepôt.";
console.error(e);
}
}
});
const handleSubmit = async () => {
submitting.value = true;
error.value = null;
try {
if (isEdit.value) {
await warehouseStore.updateWarehouse(props.warehouseId, form.value);
} else {
await warehouseStore.createWarehouse(form.value);
}
router.push("/stock/warehouses");
} catch (e) {
error.value = e.response?.data?.message || e.message || "Une erreur est survenue lors de l'enregistrement.";
console.error(e);
} finally {
submitting.value = false;
}
};
const handleCancel = () => {
router.back();
};
</script>

View File

@ -0,0 +1,65 @@
<template>
<div class="container-fluid py-4">
<div class="d-sm-flex justify-content-between mb-4">
<div>
<h5 class="mb-0">Entrepôts</h5>
<p class="text-sm mb-0">Gestion des lieux de stockage et dépôts.</p>
</div>
<div class="mt-sm-0 mt-3">
<soft-button color="info" variant="gradient" @click="handleCreate">
<i class="fas fa-plus me-2"></i> Nouvel Entrepôt
</soft-button>
</div>
</div>
<div class="row">
<div class="col-12">
<warehouse-table
:data="warehouses"
:loading="loading"
@view="handleView"
@edit="handleEdit"
@delete="handleDelete"
/>
</div>
</div>
</div>
</template>
<script setup>
import { onMounted } from "vue";
import { storeToRefs } from "pinia";
import { useRouter } from "vue-router";
import WarehouseTable from "@/components/molecules/Tables/Stock/WarehouseTable.vue";
import SoftButton from "@/components/SoftButton.vue";
import { useWarehouseStore } from "@/stores/warehouseStore";
const router = useRouter();
const warehouseStore = useWarehouseStore();
const { warehouses, loading } = storeToRefs(warehouseStore);
const handleCreate = () => {
router.push("/stock/warehouses/new");
};
const handleView = (id) => {
router.push(`/stock/warehouses/${id}`);
};
const handleEdit = (id) => {
router.push(`/stock/warehouses/${id}/edit`);
};
const handleDelete = async (id) => {
if (confirm("Êtes-vous sûr de vouloir supprimer cet entrepôt ?")) {
try {
await warehouseStore.deleteWarehouse(id);
} catch (error) {
console.error("Failed to delete warehouse", error);
}
}
};
onMounted(() => {
warehouseStore.fetchWarehouses();
});
</script>

View File

@ -0,0 +1,33 @@
<template>
<ul class="list-group">
<li class="list-group-item border-0 ps-0 pt-0 text-sm">
<strong class="text-dark">Nom de l'entrepôt:</strong> &nbsp; {{ warehouse.name }}
</li>
<li class="list-group-item border-0 ps-0 text-sm">
<strong class="text-dark">Adresse 1:</strong> &nbsp; {{ warehouse.address_line1 || '-' }}
</li>
<li class="list-group-item border-0 ps-0 text-sm">
<strong class="text-dark">Adresse 2:</strong> &nbsp; {{ warehouse.address_line2 || '-' }}
</li>
<li class="list-group-item border-0 ps-0 text-sm">
<strong class="text-dark">Code Postal:</strong> &nbsp; {{ warehouse.postal_code || '-' }}
</li>
<li class="list-group-item border-0 ps-0 text-sm">
<strong class="text-dark">Ville:</strong> &nbsp; {{ warehouse.city || '-' }}
</li>
<li class="list-group-item border-0 ps-0 text-sm">
<strong class="text-dark">Pays:</strong> &nbsp; {{ warehouse.country_code }}
</li>
</ul>
</template>
<script setup>
import { defineProps } from "vue";
defineProps({
warehouse: {
type: Object,
required: true,
},
});
</script>

View File

@ -0,0 +1,111 @@
<template>
<form @submit.prevent="$emit('submit')">
<div class="row">
<div class="col-md-12 mb-3">
<label class="form-label">Nom de l'entrepôt *</label>
<input
v-model="modelValue.name"
type="text"
class="form-control"
placeholder="Ex: Entrepôt Principal"
required
/>
</div>
<div class="col-md-6 mb-3">
<label class="form-label">Adresse 1</label>
<input
v-model="modelValue.address_line1"
type="text"
class="form-control"
placeholder="Numéro et rue"
/>
</div>
<div class="col-md-6 mb-3">
<label class="form-label">Adresse 2</label>
<input
v-model="modelValue.address_line2"
type="text"
class="form-control"
placeholder="Appartement, lot, etc."
/>
</div>
<div class="col-md-4 mb-3">
<label class="form-label">Code Postal</label>
<input
v-model="modelValue.postal_code"
type="text"
class="form-control"
placeholder="Ex: 75001"
/>
</div>
<div class="col-md-4 mb-3">
<label class="form-label">Ville</label>
<input
v-model="modelValue.city"
type="text"
class="form-control"
placeholder="Ex: Paris"
/>
</div>
<div class="col-md-4 mb-3">
<label class="form-label">Code Pays</label>
<input
v-model="modelValue.country_code"
type="text"
class="form-control"
placeholder="Ex: FR"
/>
</div>
</div>
<div v-if="error" class="alert alert-danger text-white text-sm mt-3" role="alert">
{{ error }}
</div>
<div class="d-flex justify-content-end mt-4">
<soft-button
color="secondary"
variant="gradient"
class="me-2"
@click="$emit('cancel')"
type="button"
>
Annuler
</soft-button>
<soft-button
color="info"
variant="gradient"
:loading="submitting"
type="submit"
>
{{ submitLabel }}
</soft-button>
</div>
</form>
</template>
<script setup>
import { defineProps, defineEmits } from "vue";
import SoftButton from "@/components/SoftButton.vue";
const props = defineProps({
modelValue: {
type: Object,
required: true,
},
submitting: {
type: Boolean,
default: false,
},
error: {
type: String,
default: null,
},
submitLabel: {
type: String,
default: "Enregistrer",
},
});
defineEmits(["update:modelValue", "submit", "cancel"]);
</script>

View File

@ -0,0 +1,168 @@
<template>
<div class="card mt-4">
<div class="table-responsive">
<table id="warehouse-list" class="table table-flush">
<thead class="thead-light">
<tr>
<th>Nom</th>
<th>Ville</th>
<th>Code Postal</th>
<th>Adresse</th>
<th>Actions</th>
</tr>
</thead>
<tbody>
<tr v-for="warehouse in data" :key="warehouse.id">
<!-- Name -->
<td>
<div class="d-flex align-items-center">
<soft-checkbox class="me-2" />
<p class="text-xs font-weight-bold ms-2 mb-0">
{{ warehouse.name }}
</p>
</div>
</td>
<!-- City -->
<td class="text-xs font-weight-bold">
<span class="my-2 text-xs">{{ warehouse.city || '-' }}</span>
</td>
<!-- Postal Code -->
<td class="text-xs font-weight-bold">
<span class="my-2 text-xs">{{ warehouse.postal_code || '-' }}</span>
</td>
<!-- Address -->
<td class="text-xs font-weight-bold">
<span class="my-2 text-xs">
{{ warehouse.address_line1 }}
<span v-if="warehouse.address_line2" class="d-block text-secondary">{{ warehouse.address_line2 }}</span>
</span>
</td>
<!-- Actions -->
<td class="text-xs font-weight-bold">
<div class="d-flex align-items-center">
<button
class="btn btn-link text-secondary mb-0 px-2"
:data-id="warehouse.id"
data-action="view"
title="Voir l'entrepôt"
>
<i class="fas fa-eye text-xs" aria-hidden="true"></i>
</button>
<button
class="btn btn-link text-info mb-0 px-2"
:data-id="warehouse.id"
data-action="edit"
title="Modifier l'entrepôt"
>
<i class="fas fa-user-edit text-xs" aria-hidden="true"></i>
</button>
<button
class="btn btn-link text-danger mb-0 px-2"
:data-id="warehouse.id"
data-action="delete"
title="Supprimer l'entrepôt"
>
<i class="fas fa-trash text-xs" aria-hidden="true"></i>
</button>
</div>
</td>
</tr>
</tbody>
</table>
</div>
</div>
</template>
<script setup>
import {
ref,
onMounted,
watch,
onUnmounted,
defineProps,
defineEmits,
} from "vue";
import { DataTable } from "simple-datatables";
import SoftCheckbox from "@/components/SoftCheckbox.vue";
const emit = defineEmits(["view", "edit", "delete"]);
const props = defineProps({
data: {
type: Array,
default: () => [],
},
loading: {
type: Boolean,
default: false,
},
});
const dataTableInstance = ref(null);
const initializeDataTable = () => {
if (dataTableInstance.value) {
dataTableInstance.value.destroy();
dataTableInstance.value = null;
}
const dataTableEl = document.getElementById("warehouse-list");
if (dataTableEl) {
dataTableInstance.value = new DataTable(dataTableEl, {
searchable: true,
fixedHeight: false,
perPageSelect: false,
});
}
};
onMounted(() => {
if (!props.loading && props.data.length > 0) {
initializeDataTable();
}
// Event delegation
const table = document.getElementById("warehouse-list");
if (table) {
table.addEventListener("click", (event) => {
const btn = event.target.closest("button");
if (!btn) return;
const id = btn.getAttribute("data-id");
const action = btn.getAttribute("data-action");
if (id && action) {
if (action === "view") {
emit("view", parseInt(id));
} else if (action === "edit") {
emit("edit", parseInt(id));
} else if (action === "delete") {
emit("delete", parseInt(id));
}
}
});
}
});
watch(
() => props.data,
() => {
if (!props.loading) {
setTimeout(() => {
initializeDataTable();
}, 100);
}
},
{ deep: true }
);
onUnmounted(() => {
if (dataTableInstance.value) {
dataTableInstance.value.destroy();
}
});
</script>

View File

@ -319,6 +319,12 @@ export default {
miniIcon: "S",
text: "Stock",
},
{
id: "warehouses",
route: { name: "Entrepôts" },
miniIcon: "E",
text: "Entrepôts",
},
],
},
{

View File

@ -614,6 +614,27 @@ const routes = [
name: "Product details",
component: () => import("@/views/pages/Stock/ProductDetails.vue"),
},
// Warehouses
{
path: "/stock/warehouses",
name: "Entrepôts",
component: () => import("@/views/pages/Stock/WarehouseList.vue"),
},
{
path: "/stock/warehouses/new",
name: "Nouvel Entrepôt",
component: () => import("@/views/pages/Stock/NewWarehouse.vue"),
},
{
path: "/stock/warehouses/:id",
name: "Détails Entrepôt",
component: () => import("@/views/pages/Stock/WarehouseDetail.vue"),
},
{
path: "/stock/warehouses/:id/edit",
name: "Modifier Entrepôt",
component: () => import("@/views/pages/Stock/EditWarehouse.vue"),
},
// Employés
{
path: "/employes",

View File

@ -0,0 +1,51 @@
import { request } from "./http";
export interface ProductPackaging {
id: number;
product_id: number;
name: string;
qty_base: number;
created_at: string;
updated_at: string;
}
class ProductPackagingService {
async getAllPackagings(): Promise<{ data: ProductPackaging[] }> {
return await request<{ data: ProductPackaging[] }>({
url: "/api/product-packagings",
method: "get",
});
}
async getPackaging(id: number): Promise<{ data: ProductPackaging }> {
return await request<{ data: ProductPackaging }>({
url: `/api/product-packagings/${id}`,
method: "get",
});
}
async createPackaging(data: any): Promise<{ data: ProductPackaging }> {
return await request<{ data: ProductPackaging }>({
url: "/api/product-packagings",
method: "post",
data,
});
}
async updatePackaging(id: number, data: any): Promise<{ data: ProductPackaging }> {
return await request<{ data: ProductPackaging }>({
url: `/api/product-packagings/${id}`,
method: "put",
data,
});
}
async deletePackaging(id: number): Promise<{ message: string }> {
return await request<{ message: string }>({
url: `/api/product-packagings/${id}`,
method: "delete",
});
}
}
export default ProductPackagingService;

View File

@ -0,0 +1,91 @@
import { request } from "./http";
export interface StockItem {
id: number;
product_id: number;
warehouse_id: number;
qty_on_hand_base: number;
safety_stock_base: number;
product?: any;
warehouse?: any;
created_at: string;
updated_at: string;
}
export interface StockMove {
id: number;
product_id: number;
from_warehouse_id: number | null;
to_warehouse_id: number | null;
packaging_id: number | null;
packages_qty: number | null;
units_qty: number | null;
qty_base: number;
move_type: string;
ref_type: string | null;
ref_id: number | null;
moved_at: string;
product?: any;
from_warehouse?: any;
to_warehouse?: any;
created_at: string;
updated_at: string;
}
class StockService {
// Stock Items
async getAllStockItems(): Promise<{ data: StockItem[] }> {
return await request<{ data: StockItem[] }>({
url: "/api/stock-items",
method: "get",
});
}
async getStockItem(id: number): Promise<{ data: StockItem }> {
return await request<{ data: StockItem }>({
url: `/api/stock-items/${id}`,
method: "get",
});
}
async createStockItem(data: any): Promise<{ data: StockItem }> {
return await request<{ data: StockItem }>({
url: "/api/stock-items",
method: "post",
data,
});
}
async updateStockItem(id: number, data: any): Promise<{ data: StockItem }> {
return await request<{ data: StockItem }>({
url: `/api/stock-items/${id}`,
method: "put",
data,
});
}
// Stock Moves
async getAllStockMoves(): Promise<{ data: StockMove[] }> {
return await request<{ data: StockMove[] }>({
url: "/api/stock-moves",
method: "get",
});
}
async getStockMove(id: number): Promise<{ data: StockMove }> {
return await request<{ data: StockMove }>({
url: `/api/stock-moves/${id}`,
method: "get",
});
}
async createStockMove(data: any): Promise<{ data: StockMove }> {
return await request<{ data: StockMove }>({
url: "/api/stock-moves",
method: "post",
data,
});
}
}
export default StockService;

View File

@ -0,0 +1,63 @@
import { request } from "./http";
export interface Warehouse {
id: number;
name: string;
address_line1: string | null;
address_line2: string | null;
postal_code: string | null;
city: string | null;
country_code: string;
created_at: string;
updated_at: string;
}
export interface WarehouseFormData {
name: string;
address_line1?: string | null;
address_line2?: string | null;
postal_code?: string | null;
city?: string | null;
country_code?: string;
}
class WarehouseService {
async getAllWarehouses(): Promise<{ data: Warehouse[] }> {
return await request<{ data: Warehouse[] }>({
url: "/api/warehouses",
method: "get",
});
}
async getWarehouse(id: number): Promise<{ data: Warehouse }> {
return await request<{ data: Warehouse }>({
url: `/api/warehouses/${id}`,
method: "get",
});
}
async createWarehouse(data: WarehouseFormData): Promise<{ data: Warehouse }> {
return await request<{ data: Warehouse }>({
url: "/api/warehouses",
method: "post",
data,
});
}
async updateWarehouse(id: number, data: WarehouseFormData): Promise<{ data: Warehouse }> {
return await request<{ data: Warehouse }>({
url: `/api/warehouses/${id}`,
method: "put",
data,
});
}
async deleteWarehouse(id: number): Promise<{ message: string }> {
return await request<{ message: string }>({
url: `/api/warehouses/${id}`,
method: "delete",
});
}
}
export default WarehouseService;

View File

@ -0,0 +1,80 @@
import { defineStore } from "pinia";
import StockService, { StockItem, StockMove } from "@/services/stock";
const stockService = new StockService();
export const useStockStore = defineStore("stock", {
state: () => ({
stockItems: [] as StockItem[],
stockMoves: [] as StockMove[],
loading: false,
error: null as string | null,
}),
actions: {
// Stock Items
async fetchStockItems() {
this.loading = true;
this.error = null;
try {
const response = await stockService.getAllStockItems();
this.stockItems = response.data;
return this.stockItems;
} catch (error: any) {
this.error = error.message || "Erreur lors du chargement des stocks";
throw error;
} finally {
this.loading = false;
}
},
async updateStockItem(id: number, data: any) {
this.loading = true;
this.error = null;
try {
const response = await stockService.updateStockItem(id, data);
const index = this.stockItems.findIndex((item) => item.id === id);
if (index !== -1) {
this.stockItems[index] = response.data;
}
return response.data;
} catch (error: any) {
this.error = error.message || "Erreur lors de la mise à jour du stock";
throw error;
} finally {
this.loading = false;
}
},
// Stock Moves
async fetchStockMoves() {
this.loading = true;
this.error = null;
try {
const response = await stockService.getAllStockMoves();
this.stockMoves = response.data;
return this.stockMoves;
} catch (error: any) {
this.error = error.message || "Erreur lors du chargement des mouvements de stock";
throw error;
} finally {
this.loading = false;
}
},
async createStockMove(data: any) {
this.loading = true;
this.error = null;
try {
const response = await stockService.createStockMove(data);
this.stockMoves.unshift(response.data);
return response.data;
} catch (error: any) {
this.error = error.message || "Erreur lors de l'enregistrement du mouvement de stock";
throw error;
} finally {
this.loading = false;
}
},
},
});

View File

@ -0,0 +1,98 @@
import { defineStore } from "pinia";
import WarehouseService, { Warehouse, WarehouseFormData } from "@/services/warehouse";
const warehouseService = new WarehouseService();
export const useWarehouseStore = defineStore("warehouse", {
state: () => ({
warehouses: [] as Warehouse[],
currentWarehouse: null as Warehouse | null,
loading: false,
error: null as string | null,
}),
actions: {
async fetchWarehouses() {
this.loading = true;
this.error = null;
try {
const response = await warehouseService.getAllWarehouses();
this.warehouses = response.data;
return this.warehouses;
} catch (error: any) {
this.error = error.message || "Erreur lors du chargement des entrepôts";
throw error;
} finally {
this.loading = false;
}
},
async fetchWarehouse(id: number) {
this.loading = true;
this.error = null;
try {
const response = await warehouseService.getWarehouse(id);
this.currentWarehouse = response.data;
return this.currentWarehouse;
} catch (error: any) {
this.error = error.message || "Erreur lors du chargement de l'entrepôt";
throw error;
} finally {
this.loading = false;
}
},
async createWarehouse(data: WarehouseFormData) {
this.loading = true;
this.error = null;
try {
const response = await warehouseService.createWarehouse(data);
this.warehouses.push(response.data);
return response.data;
} catch (error: any) {
this.error = error.message || "Erreur lors de la création de l'entrepôt";
throw error;
} finally {
this.loading = false;
}
},
async updateWarehouse(id: number, data: WarehouseFormData) {
this.loading = true;
this.error = null;
try {
const response = await warehouseService.updateWarehouse(id, data);
const index = this.warehouses.findIndex((w) => w.id === id);
if (index !== -1) {
this.warehouses[index] = response.data;
}
if (this.currentWarehouse?.id === id) {
this.currentWarehouse = response.data;
}
return response.data;
} catch (error: any) {
this.error = error.message || "Erreur lors de la mise à jour de l'entrepôt";
throw error;
} finally {
this.loading = false;
}
},
async deleteWarehouse(id: number) {
this.loading = true;
this.error = null;
try {
await warehouseService.deleteWarehouse(id);
this.warehouses = this.warehouses.filter((w) => w.id !== id);
if (this.currentWarehouse?.id === id) {
this.currentWarehouse = null;
}
} catch (error: any) {
this.error = error.message || "Erreur lors de la suppression de l'entrepôt";
throw error;
} finally {
this.loading = false;
}
},
},
});

View File

@ -0,0 +1,12 @@
<template>
<warehouse-form-presentation :warehouse-id="warehouseId" />
</template>
<script setup>
import { computed } from "vue";
import { useRoute } from "vue-router";
import WarehouseFormPresentation from "@/components/Organism/Stock/WarehouseFormPresentation.vue";
const route = useRoute();
const warehouseId = computed(() => route.params.id);
</script>

View File

@ -0,0 +1,7 @@
<template>
<warehouse-form-presentation />
</template>
<script setup>
import WarehouseFormPresentation from "@/components/Organism/Stock/WarehouseFormPresentation.vue";
</script>

View File

@ -0,0 +1,12 @@
<template>
<warehouse-detail-presentation :warehouse-id="warehouseId" />
</template>
<script setup>
import { computed } from "vue";
import { useRoute } from "vue-router";
import WarehouseDetailPresentation from "@/components/Organism/Stock/WarehouseDetailPresentation.vue";
const route = useRoute();
const warehouseId = computed(() => route.params.id);
</script>

View File

@ -0,0 +1,7 @@
<template>
<warehouse-list-presentation />
</template>
<script setup>
import WarehouseListPresentation from "@/components/Organism/Stock/WarehouseListPresentation.vue";
</script>