Gestion des bon de receptions dans front

This commit is contained in:
kevin 2026-02-03 15:30:27 +03:00
parent d8927580e7
commit 31090d12ba
38 changed files with 2708 additions and 113 deletions

View File

@ -0,0 +1,133 @@
<?php
declare(strict_types=1);
namespace App\Http\Controllers\Api;
use App\Http\Controllers\Controller;
use App\Http\Requests\StoreGoodsReceiptRequest;
use App\Http\Requests\UpdateGoodsReceiptRequest;
use App\Http\Resources\GoodsReceiptResource;
use App\Repositories\GoodsReceiptRepositoryInterface;
use Illuminate\Http\JsonResponse;
use Illuminate\Support\Facades\Log;
class GoodsReceiptController extends Controller
{
public function __construct(
private readonly GoodsReceiptRepositoryInterface $goodsReceiptRepository
) {
}
/**
* Display a listing of goods receipts.
*/
public function index(): JsonResponse
{
try {
$goodsReceipts = $this->goodsReceiptRepository->all();
return response()->json([
'data' => GoodsReceiptResource::collection($goodsReceipts),
'status' => 'success'
]);
} catch (\Exception $e) {
Log::error('Error fetching goods receipts: ' . $e->getMessage());
return response()->json([
'message' => 'Une erreur est survenue lors de la récupération des réceptions de marchandises.',
'error' => config('app.debug') ? $e->getMessage() : null,
], 500);
}
}
/**
* Store a newly created goods receipt.
*/
public function store(StoreGoodsReceiptRequest $request): JsonResponse
{
try {
$goodsReceipt = $this->goodsReceiptRepository->create($request->validated());
return response()->json([
'data' => new GoodsReceiptResource($goodsReceipt),
'message' => 'Réception de marchandise créée avec succès.',
'status' => 'success'
], 201);
} catch (\Exception $e) {
Log::error('Error creating goods receipt: ' . $e->getMessage());
return response()->json([
'message' => 'Une erreur est survenue lors de la création de la réception de marchandise.',
'error' => config('app.debug') ? $e->getMessage() : null,
], 500);
}
}
/**
* Display the specified goods receipt.
*/
public function show(string $id): JsonResponse
{
try {
$goodsReceipt = $this->goodsReceiptRepository->find((int) $id);
if (!$goodsReceipt) {
return response()->json(['message' => 'Réception de marchandise non trouvée.'], 404);
}
return response()->json([
'data' => new GoodsReceiptResource($goodsReceipt),
'status' => 'success'
]);
} catch (\Exception $e) {
Log::error('Error fetching goods receipt: ' . $e->getMessage());
return response()->json([
'message' => 'Une erreur est survenue lors de la récupération de la réception de marchandise.',
'error' => config('app.debug') ? $e->getMessage() : null,
], 500);
}
}
/**
* Update the specified goods receipt.
*/
public function update(UpdateGoodsReceiptRequest $request, string $id): JsonResponse
{
try {
$updated = $this->goodsReceiptRepository->update((int) $id, $request->validated());
if (!$updated) {
return response()->json(['message' => 'Réception de marchandise non trouvée ou échec de la mise à jour.'], 404);
}
$goodsReceipt = $this->goodsReceiptRepository->find((int) $id);
return response()->json([
'data' => new GoodsReceiptResource($goodsReceipt),
'message' => 'Réception de marchandise mise à jour avec succès.',
'status' => 'success'
]);
} catch (\Exception $e) {
Log::error('Error updating goods receipt: ' . $e->getMessage());
return response()->json([
'message' => 'Une erreur est survenue lors de la mise à jour de la réception de marchandise.',
'error' => config('app.debug') ? $e->getMessage() : null,
], 500);
}
}
/**
* Remove the specified goods receipt.
*/
public function destroy(string $id): JsonResponse
{
try {
$deleted = $this->goodsReceiptRepository->delete((int) $id);
if (!$deleted) {
return response()->json(['message' => 'Réception de marchandise non trouvée ou échec de la suppression.'], 404);
}
return response()->json([
'message' => 'Réception de marchandise supprimée avec succès.',
'status' => 'success'
]);
} catch (\Exception $e) {
Log::error('Error deleting goods receipt: ' . $e->getMessage());
return response()->json([
'message' => 'Une erreur est survenue lors de la suppression de la réception de marchandise.',
'error' => config('app.debug') ? $e->getMessage() : null,
], 500);
}
}
}

View File

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

View File

@ -62,7 +62,7 @@ class WarehouseController extends Controller
}
/**
* Display the specified warehouse.
* Display specified warehouse.
*/
public function show(string $id): JsonResponse
{
@ -85,7 +85,7 @@ class WarehouseController extends Controller
}
/**
* Update the specified warehouse.
* Update specified warehouse.
*/
public function update(UpdateWarehouseRequest $request, string $id): JsonResponse
{
@ -110,7 +110,7 @@ class WarehouseController extends Controller
}
/**
* Remove the specified warehouse.
* Remove specified warehouse.
*/
public function destroy(string $id): JsonResponse
{
@ -131,4 +131,44 @@ class WarehouseController extends Controller
], 500);
}
}
/**
* Search warehouses by name.
*/
public function searchBy(Request $request): JsonResponse
{
try {
$name = $request->query('name');
$exactMatch = $request->query('exact_match', false);
if (empty($name)) {
return response()->json([
'data' => [],
'count' => 0,
'message' => 'Le paramètre de recherche est requis.'
], 400);
}
$warehouses = $this->warehouseRepository->all();
$filtered = $warehouses->filter(function ($warehouse) use ($name, $exactMatch) {
if ($exactMatch) {
return strtolower($warehouse->name) === strtolower($name);
}
return stripos($warehouse->name, $name) !== false;
});
return response()->json([
'data' => WarehouseResource::collection($filtered),
'count' => $filtered->count(),
'message' => 'Recherche effectuée avec succès.'
]);
} catch (\Exception $e) {
Log::error('Error searching warehouses: ' . $e->getMessage());
return response()->json([
'message' => 'Une erreur est survenue lors de la recherche des entrepôts.',
'error' => config('app.debug') ? $e->getMessage() : null,
], 500);
}
}
}

View File

@ -0,0 +1,79 @@
<?php
namespace App\Http\Requests;
use Illuminate\Foundation\Http\FormRequest;
class StoreGoodsReceiptRequest 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 [
'purchase_order_id' => 'required|exists:purchase_orders,id',
'warehouse_id' => 'required|exists:warehouses,id',
'receipt_number' => 'required|string|max:191',
'receipt_date' => 'required|date',
'status' => 'nullable|in:draft,posted',
'notes' => 'nullable|string',
'lines' => 'nullable|array',
'lines.*.product_id' => 'required_with:lines|exists:products,id',
'lines.*.packaging_id' => 'nullable|exists:product_packagings,id',
'lines.*.packages_qty_received' => 'nullable|numeric|min:0',
'lines.*.units_qty_received' => 'nullable|numeric|min:0',
'lines.*.qty_received_base' => 'nullable|numeric|min:0',
'lines.*.unit_price' => 'nullable|numeric|min:0',
'lines.*.unit_price_per_package' => 'nullable|numeric|min:0',
'lines.*.tva_rate_id' => 'nullable|exists:tva_rates,id',
];
}
/**
* Get the error messages for the defined validation rules.
*
* @return array<string, string>
*/
public function messages(): array
{
return [
'purchase_order_id.required' => 'La commande fournisseur est requise.',
'purchase_order_id.exists' => 'La commande fournisseur spécifiée n\'existe pas.',
'warehouse_id.required' => 'L\'entrepôt est requis.',
'warehouse_id.exists' => 'L\'entrepôt spécifié n\'existe pas.',
'receipt_number.required' => 'Le numéro de réception est requis.',
'receipt_number.string' => 'Le numéro de réception doit être une chaîne de caractères.',
'receipt_number.max' => 'Le numéro de réception ne peut pas dépasser 191 caractères.',
'receipt_date.required' => 'La date de réception est requise.',
'receipt_date.date' => 'La date de réception doit être une date valide.',
'status.in' => 'Le statut doit être "draft" ou "posted".',
'notes.string' => 'Les notes doivent être une chaîne de caractères.',
'lines.array' => 'Les lignes doivent être un tableau.',
'lines.*.product_id.required_with' => 'Le produit est requis pour chaque ligne.',
'lines.*.product_id.exists' => 'Le produit spécifié dans une ligne n\'existe pas.',
'lines.*.packaging_id.exists' => 'Le conditionnement spécifié dans une ligne n\'existe pas.',
'lines.*.packages_qty_received.numeric' => 'La quantité de colis doit être un nombre.',
'lines.*.packages_qty_received.min' => 'La quantité de colis ne peut pas être négative.',
'lines.*.units_qty_received.numeric' => 'La quantité d\'unités doit être un nombre.',
'lines.*.units_qty_received.min' => 'La quantité d\'unités ne peut pas être négative.',
'lines.*.qty_received_base.numeric' => 'La quantité de base doit être un nombre.',
'lines.*.qty_received_base.min' => 'La quantité de base ne peut pas être négative.',
'lines.*.unit_price.numeric' => 'Le prix unitaire doit être un nombre.',
'lines.*.unit_price.min' => 'Le prix unitaire ne peut pas être négatif.',
'lines.*.unit_price_per_package.numeric' => 'Le prix par colis doit être un nombre.',
'lines.*.unit_price_per_package.min' => 'Le prix par colis ne peut pas être négatif.',
'lines.*.tva_rate_id.exists' => 'Le taux de TVA spécifié dans une ligne n\'existe pas.',
];
}
}

View File

@ -0,0 +1,47 @@
<?php
namespace App\Http\Requests;
use Illuminate\Foundation\Http\FormRequest;
class StoreTvaRateRequest 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:50',
'rate' => 'required|numeric|min:0|max:999.99',
];
}
/**
* Get the error messages for the defined validation rules.
*
* @return array<string, string>
*/
public function messages(): array
{
return [
'name.required' => 'Le nom du taux de TVA est requis.',
'name.string' => 'Le nom doit être une chaîne de caractères.',
'name.max' => 'Le nom ne peut pas dépasser 50 caractères.',
'rate.required' => 'Le taux de TVA est requis.',
'rate.numeric' => 'Le taux doit être un nombre.',
'rate.min' => 'Le taux ne peut pas être négatif.',
'rate.max' => 'Le taux ne peut pas dépasser 999.99.',
];
}
}

View File

@ -0,0 +1,75 @@
<?php
namespace App\Http\Requests;
use Illuminate\Foundation\Http\FormRequest;
class UpdateGoodsReceiptRequest 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 [
'purchase_order_id' => 'sometimes|exists:purchase_orders,id',
'warehouse_id' => 'sometimes|exists:warehouses,id',
'receipt_number' => 'sometimes|string|max:191',
'receipt_date' => 'sometimes|date',
'status' => 'nullable|in:draft,posted',
'notes' => 'nullable|string',
'lines' => 'nullable|array',
'lines.*.product_id' => 'required_with:lines|exists:products,id',
'lines.*.packaging_id' => 'nullable|exists:product_packagings,id',
'lines.*.packages_qty_received' => 'nullable|numeric|min:0',
'lines.*.units_qty_received' => 'nullable|numeric|min:0',
'lines.*.qty_received_base' => 'nullable|numeric|min:0',
'lines.*.unit_price' => 'nullable|numeric|min:0',
'lines.*.unit_price_per_package' => 'nullable|numeric|min:0',
'lines.*.tva_rate_id' => 'nullable|exists:tva_rates,id',
];
}
/**
* Get the error messages for the defined validation rules.
*
* @return array<string, string>
*/
public function messages(): array
{
return [
'purchase_order_id.exists' => 'La commande fournisseur spécifiée n\'existe pas.',
'warehouse_id.exists' => 'L\'entrepôt spécifié n\'existe pas.',
'receipt_number.string' => 'Le numéro de réception doit être une chaîne de caractères.',
'receipt_number.max' => 'Le numéro de réception ne peut pas dépasser 191 caractères.',
'receipt_date.date' => 'La date de réception doit être une date valide.',
'status.in' => 'Le statut doit être "draft" ou "posted".',
'notes.string' => 'Les notes doivent être une chaîne de caractères.',
'lines.array' => 'Les lignes doivent être un tableau.',
'lines.*.product_id.required_with' => 'Le produit est requis pour chaque ligne.',
'lines.*.product_id.exists' => 'Le produit spécifié dans une ligne n\'existe pas.',
'lines.*.packaging_id.exists' => 'Le conditionnement spécifié dans une ligne n\'existe pas.',
'lines.*.packages_qty_received.numeric' => 'La quantité de colis doit être un nombre.',
'lines.*.packages_qty_received.min' => 'La quantité de colis ne peut pas être négative.',
'lines.*.units_qty_received.numeric' => 'La quantité d\'unités doit être un nombre.',
'lines.*.units_qty_received.min' => 'La quantité d\'unités ne peut pas être négative.',
'lines.*.qty_received_base.numeric' => 'La quantité de base doit être un nombre.',
'lines.*.qty_received_base.min' => 'La quantité de base ne peut pas être négative.',
'lines.*.unit_price.numeric' => 'Le prix unitaire doit être un nombre.',
'lines.*.unit_price.min' => 'Le prix unitaire ne peut pas être négatif.',
'lines.*.unit_price_per_package.numeric' => 'Le prix par colis doit être un nombre.',
'lines.*.unit_price_per_package.min' => 'Le prix par colis ne peut pas être négatif.',
'lines.*.tva_rate_id.exists' => 'Le taux de TVA spécifié dans une ligne n\'existe pas.',
];
}
}

View File

@ -0,0 +1,45 @@
<?php
namespace App\Http\Requests;
use Illuminate\Foundation\Http\FormRequest;
class UpdateTvaRateRequest 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|string|max:50',
'rate' => 'sometimes|numeric|min:0|max:999.99',
];
}
/**
* Get the error messages for the defined validation rules.
*
* @return array<string, string>
*/
public function messages(): array
{
return [
'name.string' => 'Le nom doit être une chaîne de caractères.',
'name.max' => 'Le nom ne peut pas dépasser 50 caractères.',
'rate.numeric' => 'Le taux doit être un nombre.',
'rate.min' => 'Le taux ne peut pas être négatif.',
'rate.max' => 'Le taux ne peut pas dépasser 999.99.',
];
}
}

View File

@ -0,0 +1,35 @@
<?php
namespace App\Http\Resources;
use Illuminate\Http\Request;
use Illuminate\Http\Resources\Json\JsonResource;
class GoodsReceiptLineResource extends JsonResource
{
/**
* Transform the resource into an array.
*
* @return array<string, mixed>
*/
public function toArray(Request $request): array
{
return [
'id' => $this->id,
'goods_receipt_id' => $this->goods_receipt_id,
'product_id' => $this->product_id,
'product' => new ProductResource($this->whenLoaded('product')),
'packaging_id' => $this->packaging_id,
'packaging' => new ProductPackagingResource($this->whenLoaded('packaging')),
'packages_qty_received' => $this->packages_qty_received,
'units_qty_received' => $this->units_qty_received,
'qty_received_base' => $this->qty_received_base,
'unit_price' => $this->unit_price,
'unit_price_per_package' => $this->unit_price_per_package,
'tva_rate_id' => $this->tva_rate_id,
'tva_rate' => new TvaRateResource($this->whenLoaded('tvaRate')),
'created_at' => $this->created_at,
'updated_at' => $this->updated_at,
];
}
}

View File

@ -0,0 +1,32 @@
<?php
namespace App\Http\Resources;
use Illuminate\Http\Request;
use Illuminate\Http\Resources\Json\JsonResource;
class GoodsReceiptResource extends JsonResource
{
/**
* Transform the resource into an array.
*
* @return array<string, mixed>
*/
public function toArray(Request $request): array
{
return [
'id' => $this->id,
'purchase_order_id' => $this->purchase_order_id,
'purchase_order' => new PurchaseOrderResource($this->whenLoaded('purchaseOrder')),
'warehouse_id' => $this->warehouse_id,
'warehouse' => new WarehouseResource($this->whenLoaded('warehouse')),
'receipt_number' => $this->receipt_number,
'receipt_date' => $this->receipt_date,
'status' => $this->status,
'notes' => $this->notes,
'lines' => GoodsReceiptLineResource::collection($this->whenLoaded('lines')),
'created_at' => $this->created_at,
'updated_at' => $this->updated_at,
];
}
}

View File

@ -0,0 +1,25 @@
<?php
namespace App\Http\Resources;
use Illuminate\Http\Request;
use Illuminate\Http\Resources\Json\JsonResource;
class TvaRateResource 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,
'rate' => $this->rate,
'created_at' => $this->created_at,
'updated_at' => $this->updated_at,
];
}
}

View File

@ -0,0 +1,41 @@
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
use Illuminate\Database\Eloquent\Relations\HasMany;
class GoodsReceipt extends Model
{
use HasFactory;
protected $fillable = [
'purchase_order_id',
'warehouse_id',
'receipt_number',
'receipt_date',
'status',
'notes',
];
protected $casts = [
'receipt_date' => 'date',
];
public function purchaseOrder(): BelongsTo
{
return $this->belongsTo(PurchaseOrder::class);
}
public function warehouse(): BelongsTo
{
return $this->belongsTo(Warehouse::class);
}
public function lines(): HasMany
{
return $this->hasMany(GoodsReceiptLine::class);
}
}

View File

@ -0,0 +1,53 @@
<?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 GoodsReceiptLine extends Model
{
use HasFactory;
protected $fillable = [
'goods_receipt_id',
'product_id',
'packaging_id',
'packages_qty_received',
'units_qty_received',
'qty_received_base',
'unit_price',
'unit_price_per_package',
'tva_rate_id',
];
protected $casts = [
'packages_qty_received' => 'decimal:3',
'units_qty_received' => 'decimal:3',
'qty_received_base' => 'decimal:3',
'unit_price' => 'decimal:2',
'unit_price_per_package' => 'decimal:2',
];
public function goodsReceipt(): BelongsTo
{
return $this->belongsTo(GoodsReceipt::class);
}
public function product(): BelongsTo
{
return $this->belongsTo(Product::class);
}
public function packaging(): BelongsTo
{
return $this->belongsTo(ProductPackaging::class);
}
public function tvaRate(): BelongsTo
{
return $this->belongsTo(TvaRate::class);
}
}

View File

@ -123,6 +123,14 @@ class Product extends Model
return $this->hasMany(StockMove::class);
}
/**
* Get the goods receipt lines for the product.
*/
public function goodsReceiptLines(): \Illuminate\Database\Eloquent\Relations\HasMany
{
return $this->hasMany(GoodsReceiptLine::class);
}
/**
* Boot the model
*/

View File

@ -66,4 +66,12 @@ class PurchaseOrder extends Model
{
return $this->hasMany(PurchaseOrderLine::class);
}
/**
* Get the goods receipts for this purchase order.
*/
public function goodsReceipts(): HasMany
{
return $this->hasMany(GoodsReceipt::class);
}
}

View File

@ -0,0 +1,20 @@
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
class TvaRate extends Model
{
use HasFactory;
protected $fillable = [
'name',
'rate',
];
protected $casts = [
'rate' => 'decimal:2',
];
}

View File

@ -41,4 +41,12 @@ class Warehouse extends Model
{
return $this->hasMany(StockMove::class, 'to_warehouse_id');
}
/**
* Get the goods receipts for this warehouse.
*/
public function goodsReceipts(): HasMany
{
return $this->hasMany(GoodsReceipt::class);
}
}

View File

@ -28,6 +28,8 @@ class RepositoryServiceProvider extends ServiceProvider
$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);
$this->app->bind(\App\Repositories\TvaRateRepositoryInterface::class, \App\Repositories\TvaRateRepository::class);
$this->app->bind(\App\Repositories\GoodsReceiptRepositoryInterface::class, \App\Repositories\GoodsReceiptRepository::class);
}
/**

View File

@ -0,0 +1,87 @@
<?php
declare(strict_types=1);
namespace App\Repositories;
use App\Models\GoodsReceipt;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Log;
use Illuminate\Support\Collection;
class GoodsReceiptRepository extends BaseRepository implements GoodsReceiptRepositoryInterface
{
public function __construct(GoodsReceipt $model)
{
parent::__construct($model);
}
public function all(array $columns = ['*']): Collection
{
return $this->model->with(['purchaseOrder', 'warehouse', 'lines.product', 'lines.packaging', 'lines.tvaRate'])->get($columns);
}
public function find(int|string $id, array $columns = ['*']): ?GoodsReceipt
{
return $this->model->with(['purchaseOrder', 'warehouse', 'lines.product', 'lines.packaging', 'lines.tvaRate'])->find($id, $columns);
}
public function create(array $attributes): GoodsReceipt
{
return DB::transaction(function () use ($attributes) {
try {
$lines = $attributes['lines'] ?? [];
unset($attributes['lines']);
$goodsReceipt = parent::create($attributes);
if (!empty($lines)) {
foreach ($lines as $line) {
$goodsReceipt->lines()->create($line);
}
}
return $goodsReceipt->load('lines.product', 'lines.packaging', 'lines.tvaRate');
} catch (\Exception $e) {
Log::error('Error creating GoodsReceipt with lines: ' . $e->getMessage(), [
'attributes' => $attributes,
'exception' => $e
]);
throw $e;
}
});
}
public function update(int|string $id, array $attributes): bool
{
return DB::transaction(function () use ($id, $attributes) {
try {
$goodsReceipt = $this->find($id);
if (!$goodsReceipt) {
return false;
}
$lines = $attributes['lines'] ?? null;
unset($attributes['lines']);
$updated = parent::update($id, $attributes);
if ($lines !== null && $updated) {
$goodsReceipt->lines()->delete();
foreach ($lines as $line) {
$goodsReceipt->lines()->create($line);
}
}
return $updated;
} catch (\Exception $e) {
Log::error('Error updating GoodsReceipt with lines: ' . $e->getMessage(), [
'id' => $id,
'attributes' => $attributes,
'exception' => $e
]);
throw $e;
}
});
}
}

View File

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

View File

@ -0,0 +1,26 @@
<?php
declare(strict_types=1);
namespace App\Repositories;
use App\Models\TvaRate;
use Illuminate\Support\Collection;
class TvaRateRepository extends BaseRepository implements TvaRateRepositoryInterface
{
public function __construct(TvaRate $model)
{
parent::__construct($model);
}
public function all(array $columns = ['*']): Collection
{
return $this->model->get($columns);
}
public function find(int|string $id, array $columns = ['*']): ?TvaRate
{
return $this->model->find($id, $columns);
}
}

View File

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

View File

@ -4,8 +4,7 @@ use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
return new class extends Migration {
/**
* Run the migrations.
*/
@ -55,40 +54,7 @@ return new class extends Migration
$table->foreign('product_id', 'fk_pol_product')->references('id')->on('products')->onDelete('set null');
});
// Goods Receipts (Réceptions de marchandises)
Schema::create('goods_receipts', function (Blueprint $table) {
$table->id();
$table->unsignedBigInteger('purchase_order_id');
$table->string('receipt_number', 191);
$table->date('receipt_date')->useCurrent();
$table->enum('status', ['brouillon', 'valide', 'annule'])->default('brouillon');
$table->text('notes')->nullable();
$table->timestamps();
$table->unique(['purchase_order_id', 'receipt_number'], 'uq_gr_po_number');
$table->foreign('purchase_order_id', 'fk_gr_po')->references('id')->on('purchase_orders')->onDelete('cascade');
});
// Goods Receipt Lines
Schema::create('goods_receipt_lines', function (Blueprint $table) {
$table->id();
$table->unsignedBigInteger('goods_receipt_id');
$table->unsignedBigInteger('product_id');
$table->unsignedBigInteger('purchase_order_line_id')->nullable(); // Link to original order line
$table->decimal('quantity_received', 14, 3);
$table->decimal('unit_price', 12, 2)->nullable();
$table->text('notes')->nullable();
$table->timestamps();
$table->foreign('goods_receipt_id', 'fk_grl_gr')->references('id')->on('goods_receipts')->onDelete('cascade');
$table->foreign('product_id', 'fk_grl_product')->references('id')->on('products');
$table->foreign('purchase_order_line_id', 'fk_grl_pol')->references('id')->on('purchase_order_lines')->onDelete('set null');
});
// Supplier Invoices (Factures Fournisseurs)
Schema::create('supplier_invoices', function (Blueprint $table) {
@ -144,8 +110,7 @@ return new class extends Migration
{
Schema::dropIfExists('supplier_invoice_lines');
Schema::dropIfExists('supplier_invoices');
Schema::dropIfExists('goods_receipt_lines');
Schema::dropIfExists('goods_receipts');
Schema::dropIfExists('purchase_order_lines');
Schema::dropIfExists('purchase_orders');
}

View File

@ -0,0 +1,82 @@
<?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
{
// TVA Rates (Taux de TVA)
if (!Schema::hasTable('tva_rates')) {
Schema::create('tva_rates', function (Blueprint $table) {
$table->id();
$table->string('name', 50);
$table->decimal('rate', 5, 2);
$table->timestamps();
});
}
// Goods Receipts (Réceptions de marchandises)
Schema::create('goods_receipts', function (Blueprint $table) {
$table->id();
$table->unsignedBigInteger('purchase_order_id');
$table->unsignedBigInteger('warehouse_id'); // entrepôt de réception
$table->string('receipt_number', 191);
$table->date('receipt_date')->useCurrent(); // Default handled by DB if possible or model
$table->enum('status', ['draft', 'posted'])->default('draft');
$table->timestamp('created_at')->useCurrent();
// Note: Laravel default timestamps are created_at and updated_at.
// The user asked for created_at DEFAULT CURRENT_TIMESTAMP.
// Laravel's $table->timestamps() creates both created_at and updated_at nullable.
// We will stick to standard Laravel behavior but can add default if strictly needed.
// For now, let's use standard timestamps + specific columns requested.
$table->timestamp('updated_at')->nullable();
$table->unique(['purchase_order_id', 'receipt_number'], 'uq_gr_po_number');
$table->foreign('purchase_order_id', 'fk_gr_po')->references('id')->on('purchase_orders')->onDelete('cascade');
$table->foreign('warehouse_id', 'fk_gr_wh')->references('id')->on('warehouses');
});
// Goods Receipt Lines
Schema::create('goods_receipt_lines', function (Blueprint $table) {
$table->id();
$table->unsignedBigInteger('goods_receipt_id');
$table->unsignedBigInteger('product_id');
$table->unsignedBigInteger('packaging_id')->nullable();
$table->decimal('packages_qty_received', 14, 3)->nullable(); // nb de colis reçus
$table->decimal('units_qty_received', 14, 3)->nullable(); // nb dunités reçues (si pas de colis)
$table->decimal('qty_received_base', 14, 3)->nullable(); // quantité base reçue (calculable)
$table->decimal('unit_price', 12, 2)->nullable(); // par unité produit
$table->decimal('unit_price_per_package', 12, 2)->nullable();// par colis
$table->unsignedBigInteger('tva_rate_id')->nullable();
$table->timestamps();
$table->foreign('goods_receipt_id', 'fk_grl_gr')->references('id')->on('goods_receipts')->onDelete('cascade');
$table->foreign('product_id', 'fk_grl_product')->references('id')->on('products');
$table->foreign('packaging_id', 'fk_grl_packaging')->references('id')->on('product_packagings')->onDelete('set null');
$table->foreign('tva_rate_id', 'fk_grl_tva')->references('id')->on('tva_rates')->onDelete('set null');
});
}
/**
* Reverse the migrations.
*/
public function down(): void
{
Schema::dropIfExists('goods_receipt_lines');
Schema::dropIfExists('goods_receipts');
// We do not drop tva_rates here blindly as it might have been created elsewhere if we decide to separate later.
// But since we created it conditionally, we can leave it or drop it if we are sure we own it.
// Given the instructions, I'll drop it if it's safe, but usually safe to leave shared tables or manage in separate migration.
// For this task, I'll only drop what I definitely created exclusively.
Schema::dropIfExists('tva_rates');
}
};

View File

@ -21,6 +21,8 @@ use App\Http\Controllers\Api\FileAttachmentController;
use App\Http\Controllers\Api\QuoteController;
use App\Http\Controllers\Api\ClientActivityTimelineController;
use App\Http\Controllers\Api\PurchaseOrderController;
use App\Http\Controllers\Api\TvaRateController;
use App\Http\Controllers\Api\GoodsReceiptController;
/*
@ -99,12 +101,19 @@ Route::middleware('auth:sanctum')->group(function () {
Route::patch('/products/{id}/stock', [ProductController::class, 'updateStock']);
// Warehouse management
Route::get('/warehouses/searchBy', [\App\Http\Controllers\Api\WarehouseController::class, 'searchBy']);
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);
// TVA Rates management
Route::apiResource('tva-rates', TvaRateController::class);
// Goods Receipts management
Route::apiResource('goods-receipts', GoodsReceiptController::class);
// Product Category management
Route::get('/product-categories/search', [ProductCategoryController::class, 'search']);
Route::get('/product-categories/active', [ProductCategoryController::class, 'active']);

View File

@ -0,0 +1,112 @@
<template>
<div class="container-fluid py-4">
<div class="d-sm-flex justify-content-between mb-4">
<div>
<h5 class="mb-0">Réceptions de Marchandises</h5>
<p class="text-sm mb-0">Gestion des réceptions de marchandises en provenance des fournisseurs.</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> Nouvelle Réception
</soft-button>
</div>
</div>
<!-- Filters -->
<div class="row mb-4">
<div class="col-md-3">
<div class="form-group">
<input
type="text"
class="form-control"
placeholder="Rechercher..."
v-model="searchQuery"
@input="handleSearch"
/>
</div>
</div>
<div class="col-md-3">
<select class="form-control" v-model="statusFilter" @change="handleFilter">
<option value="">Tous les statuts</option>
<option value="draft">Brouillon</option>
<option value="posted">Validée</option>
</select>
</div>
</div>
<div class="row">
<div class="col-12">
<goods-receipt-table
:data="goodsReceipts"
:loading="loading"
@view="handleView"
@edit="handleEdit"
@delete="handleDelete"
/>
</div>
</div>
</div>
</template>
<script setup>
import { onMounted, ref } from "vue";
import { storeToRefs } from "pinia";
import { useRouter } from "vue-router";
import GoodsReceiptTable from "@/components/molecules/Tables/Stock/GoodsReceiptTable.vue";
import SoftButton from "@/components/SoftButton.vue";
import { useGoodsReceiptStore } from "@/stores/goodsReceiptStore";
const router = useRouter();
const goodsReceiptStore = useGoodsReceiptStore();
const { goodsReceipts, loading } = storeToRefs(goodsReceiptStore);
const searchQuery = ref("");
const statusFilter = ref("");
let searchTimeout = null;
const handleCreate = () => {
router.push("/stock/receptions/new");
};
const handleView = (id) => {
router.push(`/stock/receptions/${id}`);
};
const handleEdit = (id) => {
router.push(`/stock/receptions/${id}/edit`);
};
const handleDelete = async (id) => {
if (confirm("Êtes-vous sûr de vouloir supprimer cette réception de marchandise ?")) {
try {
await goodsReceiptStore.deleteGoodsReceipt(id);
} catch (error) {
console.error("Failed to delete goods receipt", error);
}
}
};
const handleSearch = () => {
clearTimeout(searchTimeout);
searchTimeout = setTimeout(() => {
loadGoodsReceipts();
}, 300);
};
const handleFilter = () => {
loadGoodsReceipts();
};
const loadGoodsReceipts = () => {
const params = {
search: searchQuery.value || undefined,
status: statusFilter.value || undefined,
};
goodsReceiptStore.fetchGoodsReceipts(params);
};
onMounted(() => {
loadGoodsReceipts();
});
</script>

View File

@ -0,0 +1,71 @@
<template>
<div class="container-fluid py-4">
<div class="row">
<div class="col-lg-8 mx-auto">
<div class="card">
<div class="card-header pb-0 p-3">
<div class="d-flex justify-content-between align-items-center">
<h6 class="mb-0">Nouvelle Réception de Marchandise</h6>
<soft-button
color="secondary"
variant="outline"
size="sm"
@click="goBack"
>
<i class="fas fa-arrow-left me-2"></i>Retour
</soft-button>
</div>
</div>
<div class="card-body p-3">
<new-reception-form
:loading="loading"
:purchase-orders="purchaseOrders"
@submit="handleSubmit"
@cancel="goBack"
/>
</div>
</div>
</div>
</div>
</div>
</template>
<script setup>
import { ref, onMounted } from "vue";
import { useRouter } from "vue-router";
import { storeToRefs } from "pinia";
import NewReceptionForm from "@/components/molecules/Stock/NewReceptionForm.vue";
import SoftButton from "@/components/SoftButton.vue";
import { useGoodsReceiptStore } from "@/stores/goodsReceiptStore";
import { usePurchaseOrderStore } from "@/stores/purchaseOrderStore";
const router = useRouter();
const goodsReceiptStore = useGoodsReceiptStore();
const purchaseOrderStore = usePurchaseOrderStore();
const { loading } = storeToRefs(goodsReceiptStore);
const purchaseOrders = ref([]);
const goBack = () => {
router.back();
};
const handleSubmit = async (formData) => {
try {
await goodsReceiptStore.createGoodsReceipt(formData);
router.push("/stock/receptions");
} catch (error) {
console.error("Failed to create goods receipt", error);
}
};
onMounted(async () => {
try {
await purchaseOrderStore.fetchPurchaseOrders();
purchaseOrders.value = purchaseOrderStore.purchaseOrders;
} catch (error) {
console.error("Failed to load purchase orders", error);
}
});
</script>

View File

@ -0,0 +1,189 @@
<template>
<div class="container-fluid py-4" v-if="goodsReceipt">
<div class="d-sm-flex justify-content-between mb-4">
<div>
<h5 class="mb-0">Réception de Marchandise: {{ goodsReceipt.receipt_number }}</h5>
<p class="text-sm mb-0">
Créée le {{ formatDate(goodsReceipt.created_at) }} -
<soft-badge :color="getStatusColor(goodsReceipt.status)" variant="gradient">
{{ getStatusLabel(goodsReceipt.status) }}
</soft-badge>
</p>
</div>
<div class="mt-sm-0 mt-3">
<soft-button color="secondary" variant="gradient" class="me-2" @click="handleBack">
<i class="fas fa-arrow-left me-2"></i> Retour
</soft-button>
<soft-button color="info" variant="gradient" class="me-2" @click="handleEdit">
<i class="fas fa-edit me-2"></i> Modifier
</soft-button>
<soft-button color="danger" variant="gradient" @click="handleDelete">
<i class="fas fa-trash me-2"></i> Supprimer
</soft-button>
</div>
</div>
<div class="row">
<div class="col-md-4">
<div class="card">
<div class="card-header pb-0">
<h6>Informations Générales</h6>
</div>
<div class="card-body">
<div class="mb-3">
<label class="text-xs font-weight-bold">Numéro de Réception</label>
<p class="text-sm">{{ goodsReceipt.receipt_number }}</p>
</div>
<div class="mb-3">
<label class="text-xs font-weight-bold">Date de Réception</label>
<p class="text-sm">{{ formatDate(goodsReceipt.receipt_date) }}</p>
</div>
<div class="mb-3">
<label class="text-xs font-weight-bold">Commande Fournisseur</label>
<p class="text-sm">
{{ goodsReceipt.purchase_order?.po_number || goodsReceipt.purchase_order_id }}
</p>
</div>
<div class="mb-3">
<label class="text-xs font-weight-bold">Entrepôt de Destination</label>
<p class="text-sm">{{ goodsReceipt.warehouse?.name || '-' }}</p>
</div>
<div class="mb-3">
<label class="text-xs font-weight-bold">Notes</label>
<p class="text-sm">{{ goodsReceipt.notes || 'Aucune note' }}</p>
</div>
</div>
</div>
</div>
<div class="col-md-8">
<div class="card">
<div class="card-header pb-0">
<h6>Lignes de Réception</h6>
</div>
<div class="card-body">
<div class="table-responsive">
<table class="table table-flush">
<thead class="thead-light">
<tr>
<th>Produit</th>
<th>Conditionnement</th>
<th>Colis</th>
<th>Unités</th>
<th>Prix Unitaire</th>
<th>TVA</th>
</tr>
</thead>
<tbody>
<tr v-for="line in goodsReceipt.lines" :key="line.id">
<td>
<div class="d-flex align-items-center">
<div>
<p class="text-xs font-weight-bold mb-0">
{{ line.product?.nom || 'Produit ' + line.product_id }}
</p>
<p class="text-xs text-secondary mb-0">
{{ line.product?.reference || '' }}
</p>
</div>
</div>
</td>
<td class="text-xs">
{{ line.packaging?.name || 'Unité' }}
</td>
<td class="text-xs">
{{ line.packages_qty_received || '-' }}
</td>
<td class="text-xs">
{{ line.units_qty_received || '-' }}
</td>
<td class="text-xs">
{{ line.unit_price ? formatCurrency(line.unit_price) : '-' }}
</td>
<td class="text-xs">
{{ line.tva_rate ? line.tva_rate.name + ' (' + line.tva_rate.rate + '%)' : '-' }}
</td>
</tr>
<tr v-if="!goodsReceipt.lines || goodsReceipt.lines.length === 0">
<td colspan="6" class="text-center text-muted py-4">
Aucune ligne dans cette réception.
</td>
</tr>
</tbody>
</table>
</div>
</div>
</div>
</div>
</div>
</div>
<div v-else class="container-fluid py-4">
<div class="d-flex justify-content-center">
<div class="spinner-border" role="status">
<span class="visually-hidden">Chargement...</span>
</div>
</div>
</div>
</template>
<script setup>
import { onMounted, computed } from "vue";
import { useRoute, useRouter } from "vue-router";
import { storeToRefs } from "pinia";
import SoftBadge from "@/components/SoftBadge.vue";
import { useGoodsReceiptStore } from "@/stores/goodsReceiptStore";
const route = useRoute();
const router = useRouter();
const goodsReceiptStore = useGoodsReceiptStore();
const { currentGoodsReceipt: goodsReceipt, loading } = storeToRefs(goodsReceiptStore);
const formatDate = (dateString) => {
if (!dateString) return '-';
const date = new Date(dateString);
return date.toLocaleDateString('fr-FR');
};
const formatCurrency = (value) => {
return new Intl.NumberFormat('fr-FR', { style: 'currency', currency: 'EUR' }).format(value);
};
const getStatusColor = (status) => {
switch (status) {
case 'draft': return 'secondary';
case 'posted': return 'success';
default: return 'info';
}
};
const getStatusLabel = (status) => {
switch (status) {
case 'draft': return 'Brouillon';
case 'posted': return 'Validée';
default: return status;
}
};
const handleBack = () => {
router.push("/stock/receptions");
};
const handleEdit = () => {
router.push(`/stock/receptions/${route.params.id}/edit`);
};
const handleDelete = async () => {
if (confirm("Êtes-vous sûr de vouloir supprimer cette réception de marchandise ?")) {
try {
await goodsReceiptStore.deleteGoodsReceipt(parseInt(route.params.id));
router.push("/stock/receptions");
} catch (error) {
console.error("Failed to delete goods receipt", error);
}
}
};
onMounted(async () => {
await goodsReceiptStore.fetchGoodsReceipt(parseInt(route.params.id));
});
</script>

View File

@ -0,0 +1,723 @@
<template>
<form @submit.prevent="submitForm" class="reception-form">
<!-- Header Section -->
<div class="form-section">
<div class="section-title">
<i class="fas fa-file-invoice"></i>
Informations générales
</div>
<!-- Row 1: Commande Fournisseur, Entrepôt -->
<div class="row g-3 mb-3">
<div class="col-md-6">
<label class="form-label">Commande Fournisseur <span class="text-danger">*</span></label>
<select
v-model="formData.purchase_order_id"
class="form-select custom-select"
:class="{ 'is-invalid': errors.purchase_order_id }"
>
<option value="">Sélectionner une commande</option>
<option v-for="po in purchaseOrders" :key="po.id" :value="po.id">
{{ po.po_number }} - {{ po.fournisseur?.nom || 'Fournisseur ' + po.fournisseur_id }}
</option>
</select>
<div v-if="errors.purchase_order_id" class="invalid-feedback">
{{ errors.purchase_order_id }}
</div>
</div>
<div class="col-md-6 position-relative warehouse-search-container">
<label class="form-label">Entrepôt de Destination <span class="text-danger">*</span></label>
<div class="search-input-wrapper">
<i class="fas fa-search search-icon"></i>
<soft-input
v-model="warehouseSearchQuery"
type="text"
placeholder="Rechercher un entrepôt..."
@input="handleWarehouseSearch"
@focus="showWarehouseResults = true"
required
class="search-input"
/>
</div>
<!-- Search Results Dropdown -->
<div
v-if="showWarehouseResults && (warehouseSearchResults.length > 0 || isSearchingWarehouses)"
class="search-dropdown"
>
<div v-if="isSearchingWarehouses" class="dropdown-loading">
<div class="spinner-border spinner-border-sm text-primary" role="status">
<span class="visually-hidden">Chargement...</span>
</div>
</div>
<template v-else>
<button
v-for="warehouse in warehouseSearchResults"
:key="warehouse.id"
type="button"
class="dropdown-item"
@click="selectWarehouse(warehouse)"
>
<span class="item-name">{{ warehouse.name }}</span>
<span class="item-details">
{{ warehouse.city || 'Ville non spécifiée' }} {{ warehouse.country_code || '' }}
</span>
</button>
</template>
</div>
<div v-if="errors.warehouse_id" class="invalid-feedback">
{{ errors.warehouse_id }}
</div>
</div>
</div>
<!-- Row 2: Numéro Réception, Date, Statut -->
<div class="row g-3 mb-3">
<div class="col-md-4">
<label class="form-label">Numéro de Réception</label>
<soft-input
v-model="formData.receipt_number"
type="text"
placeholder="Numéro auto-généré si laissé vide"
/>
</div>
<div class="col-md-4">
<label class="form-label">Date de Réception <span class="text-danger">*</span></label>
<soft-input
v-model="formData.receipt_date"
type="date"
required
/>
</div>
<div class="col-md-4">
<label class="form-label">Statut</label>
<div class="select-wrapper">
<select v-model="formData.status" class="form-select custom-select">
<option value="draft">📝 Brouillon</option>
<option value="posted"> Validée</option>
</select>
<i class="fas fa-chevron-down select-arrow"></i>
</div>
</div>
</div>
<!-- Notes -->
<div class="mb-0">
<label class="form-label">Notes</label>
<textarea
v-model="formData.notes"
class="form-control notes-textarea"
placeholder="Notes facultatives..."
rows="2"
></textarea>
</div>
</div>
<!-- Articles Section -->
<div class="form-section">
<div class="section-header">
<div class="section-title">
<i class="fas fa-boxes"></i>
Lignes de Réception
</div>
<soft-button
type="button"
color="primary"
size="sm"
@click="addLine"
class="add-btn"
>
<i class="fas fa-plus"></i> Ajouter ligne
</soft-button>
</div>
<div class="lines-container">
<div
v-for="(line, index) in formData.lines"
:key="index"
class="line-item"
>
<div class="row g-2 align-items-end">
<div class="col-md-3 position-relative product-search-container">
<label class="form-label text-xs">Produit <span class="text-danger">*</span></label>
<div class="search-input-wrapper">
<i class="fas fa-search search-icon"></i>
<soft-input
v-model="line.searchQuery"
type="text"
placeholder="Rechercher un produit..."
@input="handleProductSearch(index)"
@focus="activeLineIndex = index; showProductResults = true"
required
class="search-input"
/>
</div>
<!-- Product Search Results Dropdown -->
<div
v-show="showProductResults && activeLineIndex === index"
class="search-dropdown product-dropdown"
>
<div v-if="isSearchingProducts" class="dropdown-loading">
<div class="spinner-border spinner-border-sm text-primary" role="status">
<span class="visually-hidden">Chargement...</span>
</div>
</div>
<div v-else-if="productSearchResults.length === 0" class="dropdown-empty">
Aucun produit trouvé
</div>
<template v-else>
<button
v-for="product in productSearchResults"
:key="product.id"
type="button"
class="dropdown-item"
@mousedown.prevent="selectProduct(index, product)"
>
<span class="item-name">{{ product.nom }}</span>
<span class="item-details">
Réf: {{ product.reference }} Stock: {{ product.stock_actuel }} {{ product.unite }}
</span>
</button>
</template>
</div>
</div>
<div class="col-md-2">
<label class="form-label text-xs">Conditionnement</label>
<select
class="form-select form-select-sm"
v-model="line.packaging_id"
>
<option :value="null">Unité</option>
<option v-for="pkg in getPackagings(line.product_id)" :key="pkg.id" :value="pkg.id">
{{ pkg.name }} ({{ pkg.qty_base }} unités)
</option>
</select>
</div>
<div class="col-md-2">
<label class="form-label text-xs">Colis Reçus</label>
<soft-input
v-model.number="line.packages_qty_received"
type="number"
placeholder="0"
min="0"
step="0.001"
/>
</div>
<div class="col-md-2">
<label class="form-label text-xs">Unités Reçues</label>
<soft-input
v-model.number="line.units_qty_received"
type="number"
placeholder="0"
min="0"
step="0.001"
/>
</div>
<div class="col-md-2">
<label class="form-label text-xs">Prix Unitaire</label>
<soft-input
v-model.number="line.unit_price"
type="number"
placeholder="0.00"
min="0"
step="0.01"
/>
</div>
<div class="col-md-1 d-flex flex-column align-items-end gap-2">
<button
type="button"
class="btn-delete"
@click="removeLine(index)"
:disabled="formData.lines.length === 1"
title="Supprimer la ligne"
>
<i class="fas fa-trash-alt"></i>
</button>
</div>
</div>
</div>
</div>
</div>
<!-- Action Buttons -->
<div class="action-buttons">
<soft-button
type="button"
color="secondary"
variant="outline"
@click="cancelForm"
class="btn-cancel"
>
<i class="fas fa-times"></i> Annuler
</soft-button>
<soft-button
type="submit"
color="success"
class="btn-submit"
:loading="loading"
>
<i class="fas fa-check"></i> Enregistrer
</soft-button>
</div>
</form>
</template>
<script setup>
import { ref, defineEmits, defineProps, onMounted, onUnmounted } from "vue";
import SoftInput from "@/components/SoftInput.vue";
import SoftButton from "@/components/SoftButton.vue";
import WarehouseService from "@/services/warehouse";
import ProductService from "@/services/product";
import { useNotificationStore } from "@/stores/notification";
const props = defineProps({
loading: {
type: Boolean,
default: false,
},
purchaseOrders: {
type: Array,
default: () => [],
},
});
const emit = defineEmits(["submit", "cancel"]);
const notificationStore = useNotificationStore();
// Warehouse Search States
const warehouseSearchQuery = ref("");
const warehouseSearchResults = ref([]);
const isSearchingWarehouses = ref(false);
const showWarehouseResults = ref(false);
let searchTimeout = null;
const handleWarehouseSearch = () => {
if (warehouseSearchQuery.value.length < 2) {
warehouseSearchResults.value = [];
if (searchTimeout) clearTimeout(searchTimeout);
isSearchingWarehouses.value = false;
return;
}
if (searchTimeout) clearTimeout(searchTimeout);
searchTimeout = setTimeout(async () => {
if (warehouseSearchQuery.value.trim() === "") return;
isSearchingWarehouses.value = true;
showWarehouseResults.value = true;
try {
const results = await WarehouseService.searchWarehouses(warehouseSearchQuery.value);
warehouseSearchResults.value = results.filter(w => w && w.id);
} catch (error) {
console.error("Error searching warehouses:", error);
} finally {
isSearchingWarehouses.value = false;
}
}, 300);
};
const selectWarehouse = (warehouse) => {
if (!warehouse || !warehouse.id) return;
if (searchTimeout) clearTimeout(searchTimeout);
formData.value.warehouse_id = warehouse.id;
warehouseSearchQuery.value = warehouse.name;
showWarehouseResults.value = false;
};
// Product Search States
const productSearchResults = ref([]);
const isSearchingProducts = ref(false);
const showProductResults = ref(false);
const activeLineIndex = ref(null);
const productPackagings = ref({});
let productSearchTimeout = null;
const handleProductSearch = (index) => {
activeLineIndex.value = index;
const query = formData.value.lines[index].searchQuery;
if (query.length < 2) {
productSearchResults.value = [];
if (productSearchTimeout) clearTimeout(productSearchTimeout);
isSearchingProducts.value = false;
return;
}
if (productSearchTimeout) clearTimeout(productSearchTimeout);
productSearchTimeout = setTimeout(async () => {
if (formData.value.lines[index].searchQuery !== query) return;
isSearchingProducts.value = true;
showProductResults.value = true;
try {
const response = await ProductService.searchProducts(query);
if (activeLineIndex.value === index &&
formData.value.lines[index] &&
formData.value.lines[index].searchQuery === query) {
let results = [];
if (response && response.data) {
if (Array.isArray(response.data)) {
results = response.data;
} else if (response.data.data && Array.isArray(response.data.data)) {
results = response.data.data;
}
}
productSearchResults.value = results.filter(p => p && p.id);
}
} catch (error) {
console.error("Error searching products:", error);
} finally {
if (activeLineIndex.value === index) {
isSearchingProducts.value = false;
}
}
}, 300);
};
const selectProduct = (index, product) => {
if (!product || !product.id) return;
if (productSearchTimeout) clearTimeout(productSearchTimeout);
const line = formData.value.lines[index];
if (!line) return;
line.product_id = product.id;
line.searchQuery = product.nom;
// Load packagings for this product
loadProductPackagings(product.id);
showProductResults.value = false;
activeLineIndex.value = null;
};
const loadProductPackagings = async (productId) => {
try {
const response = await ProductService.getProduct(productId);
if (response && response.data) {
// Store packagings for this product
productPackagings.value[productId] = response.data.conditionnement ? [{
id: 1,
name: response.data.conditionnement_nom || 'Conditionnement',
qty_base: response.data.conditionnement_quantite || 1
}] : [];
}
} catch (error) {
console.error("Error loading product packagings:", error);
}
};
const getPackagings = (productId) => {
return productPackagings.value[productId] || [];
};
// Close dropdowns on click outside
const handleClickOutside = (event) => {
const warehouseContainer = document.querySelector('.warehouse-search-container');
if (warehouseContainer && !warehouseContainer.contains(event.target)) {
showWarehouseResults.value = false;
}
const productContainers = document.querySelectorAll('.product-search-container');
let clickedInsideAnyProduct = false;
productContainers.forEach(container => {
if (container.contains(event.target)) {
clickedInsideAnyProduct = true;
}
});
if (!clickedInsideAnyProduct) {
showProductResults.value = false;
}
};
onMounted(() => {
document.addEventListener('click', handleClickOutside);
});
onUnmounted(() => {
document.removeEventListener('click', handleClickOutside);
});
const formData = ref({
purchase_order_id: "",
warehouse_id: "",
receipt_number: "",
receipt_date: new Date().toISOString().split("T")[0],
status: "draft",
notes: "",
lines: [
{
product_id: "",
searchQuery: "",
packaging_id: null,
packages_qty_received: null,
units_qty_received: null,
unit_price: null,
},
],
});
const errors = ref({});
const addLine = () => {
formData.value.lines.push({
product_id: "",
searchQuery: "",
packaging_id: null,
packages_qty_received: null,
units_qty_received: null,
unit_price: null,
});
};
const removeLine = (index) => {
if (formData.value.lines.length > 1) {
formData.value.lines.splice(index, 1);
}
};
const cancelForm = () => {
emit("cancel");
};
const submitForm = () => {
errors.value = {};
// Validation
if (!formData.value.purchase_order_id) {
errors.value.purchase_order_id = "La commande fournisseur est requise.";
}
if (!formData.value.warehouse_id) {
errors.value.warehouse_id = "L'entrepôt est requis.";
}
if (Object.keys(errors.value).length > 0) {
return;
}
const payload = {
purchase_order_id: parseInt(formData.value.purchase_order_id),
warehouse_id: parseInt(formData.value.warehouse_id),
receipt_number: formData.value.receipt_number || undefined,
receipt_date: formData.value.receipt_date,
status: formData.value.status,
notes: formData.value.notes || undefined,
lines: formData.value.lines.map(line => ({
product_id: parseInt(line.product_id),
packaging_id: line.packaging_id ? parseInt(line.packaging_id) : null,
packages_qty_received: line.packages_qty_received,
units_qty_received: line.units_qty_received,
unit_price: line.unit_price,
})).filter(line => line.product_id),
};
emit("submit", payload);
};
</script>
<style scoped>
.reception-form {
display: flex;
flex-direction: column;
gap: 1.5rem;
}
.form-section {
background: #f8f9fa;
border-radius: 0.75rem;
padding: 1.5rem;
}
.section-title {
font-weight: 600;
color: #344767;
margin-bottom: 1rem;
display: flex;
align-items: center;
gap: 0.5rem;
}
.section-title i {
color: #cb0c9f;
}
.section-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 1rem;
}
.search-input-wrapper {
position: relative;
}
.search-icon {
position: absolute;
left: 12px;
top: 50%;
transform: translateY(-50%);
color: #adb5bd;
pointer-events: none;
}
.search-input {
padding-left: 2.5rem !important;
}
.search-dropdown {
position: absolute;
top: 100%;
left: 0;
right: 0;
background: white;
border: 1px solid #dee2e6;
border-radius: 0.5rem;
box-shadow: 0 0.5rem 1rem rgba(0, 0, 0, 0.15);
max-height: 300px;
overflow-y: auto;
z-index: 1000;
margin-top: 0.25rem;
}
.dropdown-item {
width: 100%;
padding: 0.75rem 1rem;
text-align: left;
background: none;
border: none;
cursor: pointer;
display: flex;
flex-direction: column;
gap: 0.25rem;
transition: background-color 0.2s;
}
.dropdown-item:hover {
background-color: #f8f9fa;
}
.item-name {
font-weight: 600;
color: #344767;
}
.item-details {
font-size: 0.875rem;
color: #6c757d;
}
.dropdown-loading,
.dropdown-empty {
padding: 1rem;
text-align: center;
color: #6c757d;
}
.select-wrapper {
position: relative;
}
.select-arrow {
position: absolute;
right: 12px;
top: 50%;
transform: translateY(-50%);
color: #adb5bd;
pointer-events: none;
}
.custom-select {
appearance: none;
padding-right: 2.5rem;
}
.notes-textarea {
border-radius: 0.5rem;
border: 1px solid #dee2e6;
resize: vertical;
}
.lines-container {
display: flex;
flex-direction: column;
gap: 1rem;
}
.line-item {
background: white;
border-radius: 0.5rem;
padding: 1rem;
border: 1px solid #dee2e6;
}
.btn-delete {
background: none;
border: none;
color: #f5365c;
cursor: pointer;
padding: 0.25rem;
transition: color 0.2s;
}
.btn-delete:hover:not(:disabled) {
color: #d63384;
}
.btn-delete:disabled {
color: #adb5bd;
cursor: not-allowed;
}
.action-buttons {
display: flex;
justify-content: flex-end;
gap: 0.75rem;
}
.btn-cancel {
min-width: 100px;
}
.btn-submit {
min-width: 120px;
}
.form-label {
font-weight: 600;
margin-bottom: 0.5rem;
color: #344767;
}
.text-xs {
font-size: 0.875rem;
}
.text-danger {
color: #f5365c;
}
.invalid-feedback {
display: block;
color: #f5365c;
font-size: 0.875rem;
margin-top: 0.25rem;
}
.is-invalid {
border-color: #f5365c !important;
}
</style>

View File

@ -0,0 +1,198 @@
<template>
<div class="card mt-4">
<div class="table-responsive">
<table id="goods-receipt-list" class="table table-flush">
<thead class="thead-light">
<tr>
<th>Numéro</th>
<th>Date</th>
<th>Commande</th>
<th>Entrepôt</th>
<th>Statut</th>
<th>Actions</th>
</tr>
</thead>
<tbody>
<tr v-for="receipt in data" :key="receipt.id">
<!-- Receipt Number -->
<td class="text-xs font-weight-bold">
<span class="my-2 text-xs">{{ receipt.receipt_number }}</span>
</td>
<!-- Receipt Date -->
<td class="text-xs font-weight-bold">
<span class="my-2 text-xs">{{ formatDate(receipt.receipt_date) }}</span>
</td>
<!-- Purchase Order -->
<td class="text-xs font-weight-bold">
<span class="my-2 text-xs">
{{ receipt.purchase_order?.po_number || receipt.purchase_order_id }}
</span>
</td>
<!-- Warehouse -->
<td class="text-xs font-weight-bold">
<span class="my-2 text-xs">{{ receipt.warehouse?.name || '-' }}</span>
</td>
<!-- Status -->
<td class="text-xs font-weight-bold">
<soft-badge :color="getStatusColor(receipt.status)" variant="gradient">
{{ getStatusLabel(receipt.status) }}
</soft-badge>
</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="receipt.id"
data-action="view"
title="Voir la réception"
>
<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="receipt.id"
data-action="edit"
title="Modifier la réception"
>
<i class="fas fa-edit text-xs" aria-hidden="true"></i>
</button>
<button
class="btn btn-link text-danger mb-0 px-2"
:data-id="receipt.id"
data-action="delete"
title="Supprimer la réception"
>
<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 SoftBadge from "@/components/SoftBadge.vue";
const emit = defineEmits(["view", "edit", "delete"]);
const props = defineProps({
data: {
type: Array,
default: () => [],
},
loading: {
type: Boolean,
default: false,
},
});
const dataTableInstance = ref(null);
const formatDate = (dateString) => {
if (!dateString) return '-';
const date = new Date(dateString);
return date.toLocaleDateString('fr-FR');
};
const getStatusColor = (status) => {
switch (status) {
case 'draft':
return 'secondary';
case 'posted':
return 'success';
default:
return 'info';
}
};
const getStatusLabel = (status) => {
switch (status) {
case 'draft':
return 'Brouillon';
case 'posted':
return 'Validée';
default:
return status;
}
};
const initializeDataTable = () => {
if (dataTableInstance.value) {
dataTableInstance.value.destroy();
dataTableInstance.value = null;
}
const dataTableEl = document.getElementById("goods-receipt-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("goods-receipt-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

@ -588,7 +588,27 @@ const routes = [
name: "Statistiques ventes",
component: () => import("@/views/pages/Ventes/Statistiques.vue"),
},
// Stock
// Stock - Reception
{
path: "/stock/receptions",
name: "Réceptions de marchandises",
component: () => import("@/views/pages/Stock/Reception.vue"),
},
{
path: "/stock/receptions/new",
name: "Nouvelle Réception",
component: () => import("@/views/pages/Stock/NewReception.vue"),
},
{
path: "/stock/receptions/:id",
name: "Détails Réception",
component: () => import("@/views/pages/Stock/ReceptionDetail.vue"),
},
{
path: "/stock/receptions/:id/edit",
name: "Modifier Réception",
component: () => import("@/views/pages/Stock/EditReception.vue"),
},
{
path: "/stock/reception",
name: "Reception stock",

View File

@ -4,25 +4,50 @@ export interface GoodsReceiptLine {
id: number;
goods_receipt_id: number;
product_id: number;
purchase_order_line_id: number | null;
quantity_received: number;
packaging_id: number | null;
packages_qty_received: number | null;
units_qty_received: number | null;
qty_received_base: number | null;
unit_price: number | null;
notes: string | null;
unit_price_per_package: number | null;
tva_rate_id: number | null;
created_at: string;
updated_at: string;
product?: any;
product?: {
id: number;
nom: string;
reference: string;
};
packaging?: {
id: number;
name: string;
qty_base: number;
};
tva_rate?: {
id: number;
name: string;
rate: number;
};
}
export interface GoodsReceipt {
id: number;
purchase_order_id: number;
warehouse_id: number;
receipt_number: string;
receipt_date: string;
status: 'brouillon' | 'valide' | 'annule';
status: 'draft' | 'posted';
notes: string | null;
created_at: string;
updated_at: string;
purchase_order?: any;
purchase_order?: {
id: number;
po_number: string;
};
warehouse?: {
id: number;
name: string;
};
lines?: GoodsReceiptLine[];
}
@ -42,14 +67,18 @@ export interface GoodsReceiptResponse {
export interface CreateGoodsReceiptLinePayload {
product_id: number;
purchase_order_line_id?: number | null;
quantity_received: number;
packaging_id?: number | null;
packages_qty_received?: number | null;
units_qty_received?: number | null;
qty_received_base?: number | null;
unit_price?: number | null;
notes?: string | null;
unit_price_per_package?: number | null;
tva_rate_id?: number | null;
}
export interface CreateGoodsReceiptPayload {
purchase_order_id: number;
warehouse_id: number;
receipt_number?: string;
receipt_date?: string;
status?: string;
@ -111,14 +140,6 @@ export const GoodsReceiptService = {
});
return response;
},
async getByPurchaseOrder(purchaseOrderId: number): Promise<GoodsReceiptListResponse> {
const response = await request<GoodsReceiptListResponse>({
url: `/api/purchase-orders/${purchaseOrderId}/goods-receipts`,
method: "get",
});
return response;
},
};
export default GoodsReceiptService;

View File

@ -0,0 +1,73 @@
import { request } from "./http";
export interface TvaRate {
id: number;
name: string;
rate: number;
created_at: string;
updated_at: string;
}
export interface TvaRateListResponse {
data: TvaRate[];
}
export interface TvaRateResponse {
data: TvaRate;
}
export interface CreateTvaRatePayload {
name: string;
rate: number;
}
export interface UpdateTvaRatePayload extends Partial<CreateTvaRatePayload> {
id: number;
}
export const TvaRateService = {
async getAllTvaRates(): Promise<TvaRateListResponse> {
const response = await request<TvaRateListResponse>({
url: "/api/tva-rates",
method: "get",
});
return response;
},
async getTvaRate(id: number): Promise<TvaRateResponse> {
const response = await request<TvaRateResponse>({
url: `/api/tva-rates/${id}`,
method: "get",
});
return response;
},
async createTvaRate(payload: CreateTvaRatePayload): Promise<TvaRateResponse> {
const response = await request<TvaRateResponse>({
url: "/api/tva-rates",
method: "post",
data: payload,
});
return response;
},
async updateTvaRate(payload: UpdateTvaRatePayload): Promise<TvaRateResponse> {
const { id, ...updateData } = payload;
const response = await request<TvaRateResponse>({
url: `/api/tva-rates/${id}`,
method: "put",
data: updateData,
});
return response;
},
async deleteTvaRate(id: number): Promise<{ success: boolean; message: string }> {
const response = await request<{ success: boolean; message: string }>({
url: `/api/tva-rates/${id}`,
method: "delete",
});
return response;
},
};
export default TvaRateService;

View File

@ -58,6 +58,30 @@ class WarehouseService {
method: "delete",
});
}
/**
* Search warehouses by name
*/
async searchWarehouses(
query: string,
params?: {
exact_match?: boolean;
}
): Promise<Warehouse[]> {
const response = await request<{
data: Warehouse[];
count: number;
message: string;
}>({
url: "/api/warehouses/searchBy",
method: "get",
params: {
name: query,
exact_match: params?.exact_match || false,
},
});
return response.data;
}
}
export default WarehouseService;

View File

@ -1,8 +1,6 @@
import { defineStore } from "pinia";
import { ref, computed } from "vue";
import GoodsReceiptService from "@/services/goodsReceipt";
import type {
import GoodsReceiptService, {
GoodsReceipt,
CreateGoodsReceiptPayload,
UpdateGoodsReceiptPayload,
@ -23,13 +21,10 @@ export const useGoodsReceiptStore = defineStore("goodsReceipt", () => {
const allGoodsReceipts = computed(() => goodsReceipts.value);
const draftReceipts = computed(() =>
goodsReceipts.value.filter((receipt) => receipt.status === "brouillon")
goodsReceipts.value.filter((receipt) => receipt.status === "draft")
);
const validatedReceipts = computed(() =>
goodsReceipts.value.filter((receipt) => receipt.status === "valide")
);
const cancelledReceipts = computed(() =>
goodsReceipts.value.filter((receipt) => receipt.status === "annule")
const postedReceipts = computed(() =>
goodsReceipts.value.filter((receipt) => receipt.status === "posted")
);
const isLoading = computed(() => loading.value);
const hasError = computed(() => error.value !== null);
@ -91,7 +86,7 @@ export const useGoodsReceiptStore = defineStore("goodsReceipt", () => {
const errorMessage =
err.response?.data?.message ||
err.message ||
"Erreur lors de la récupération des réceptions";
"Erreur lors de la récupération des réceptions de marchandises";
setError(errorMessage);
throw err;
} finally {
@ -111,7 +106,7 @@ export const useGoodsReceiptStore = defineStore("goodsReceipt", () => {
const errorMessage =
err.response?.data?.message ||
err.message ||
"Erreur lors de la récupération de la réception";
"Erreur lors de la récupération de la réception de marchandise";
setError(errorMessage);
throw err;
} finally {
@ -132,7 +127,7 @@ export const useGoodsReceiptStore = defineStore("goodsReceipt", () => {
const errorMessage =
err.response?.data?.message ||
err.message ||
"Erreur lors de la création de la réception";
"Erreur lors de la création de la réception de marchandise";
setError(errorMessage);
throw err;
} finally {
@ -167,7 +162,7 @@ export const useGoodsReceiptStore = defineStore("goodsReceipt", () => {
const errorMessage =
err.response?.data?.message ||
err.message ||
"Erreur lors de la mise à jour de la réception";
"Erreur lors de la mise à jour de la réception de marchandise";
setError(errorMessage);
throw err;
} finally {
@ -195,27 +190,7 @@ export const useGoodsReceiptStore = defineStore("goodsReceipt", () => {
const errorMessage =
err.response?.data?.message ||
err.message ||
"Erreur lors de la suppression de la réception";
setError(errorMessage);
throw err;
} finally {
setLoading(false);
}
};
const fetchByPurchaseOrder = async (purchaseOrderId: number) => {
setLoading(true);
setError(null);
try {
const response = await GoodsReceiptService.getByPurchaseOrder(purchaseOrderId);
setGoodsReceipts(response.data);
return response.data;
} catch (err: any) {
const errorMessage =
err.response?.data?.message ||
err.message ||
"Erreur lors de la récupération des réceptions de la commande";
"Erreur lors de la suppression de la réception de marchandise";
setError(errorMessage);
throw err;
} finally {
@ -247,8 +222,7 @@ export const useGoodsReceiptStore = defineStore("goodsReceipt", () => {
allGoodsReceipts,
draftReceipts,
validatedReceipts,
cancelledReceipts,
postedReceipts,
isLoading,
hasError,
getError,
@ -260,7 +234,6 @@ export const useGoodsReceiptStore = defineStore("goodsReceipt", () => {
createGoodsReceipt,
updateGoodsReceipt,
deleteGoodsReceipt,
fetchByPurchaseOrder,
clearCurrentGoodsReceipt,
clearStore,
clearError,

View File

@ -0,0 +1,210 @@
<template>
<div class="container-fluid py-4">
<div class="d-sm-flex justify-content-between mb-4">
<div>
<h5 class="mb-0">Modifier la Réception de Marchandise</h5>
<p class="text-sm mb-0">Modifier les informations d'une réception existante.</p>
</div>
<div class="mt-sm-0 mt-3">
<soft-button color="secondary" variant="gradient" class="me-2" @click="handleCancel">
<i class="fas fa-times me-2"></i> Annuler
</soft-button>
<soft-button color="info" variant="gradient" @click="handleSubmit" :loading="loading">
<i class="fas fa-save me-2"></i> Enregistrer
</soft-button>
</div>
</div>
<div class="row" v-if="goodsReceipt">
<div class="col-12">
<div class="card">
<div class="card-body">
<form @submit.prevent="handleSubmit">
<!-- Main Info -->
<div class="row">
<div class="col-md-6">
<div class="form-group">
<label for="purchase_order_id" class="form-control-label">Commande Fournisseur</label>
<select
id="purchase_order_id"
class="form-control"
v-model="form.purchase_order_id"
:class="{ 'is-invalid': errors.purchase_order_id }"
>
<option value="">Sélectionner une commande</option>
<option v-for="po in purchaseOrders" :key="po.id" :value="po.id">
{{ po.po_number }} - {{ po.fournisseur?.nom || 'Fournisseur ' + po.fournisseur_id }}
</option>
</select>
<div class="invalid-feedback">{{ errors.purchase_order_id }}</div>
</div>
</div>
<div class="col-md-6">
<div class="form-group">
<label for="warehouse_id" class="form-control-label">Entrepôt de Destination</label>
<select
id="warehouse_id"
class="form-control"
v-model="form.warehouse_id"
:class="{ 'is-invalid': errors.warehouse_id }"
>
<option value="">Sélectionner un entrepôt</option>
<option v-for="wh in warehouses" :key="wh.id" :value="wh.id">
{{ wh.name }}
</option>
</select>
<div class="invalid-feedback">{{ errors.warehouse_id }}</div>
</div>
</div>
</div>
<div class="row">
<div class="col-md-4">
<div class="form-group">
<label for="receipt_number" class="form-control-label">Numéro de Réception</label>
<input
id="receipt_number"
class="form-control"
type="text"
v-model="form.receipt_number"
/>
</div>
</div>
<div class="col-md-4">
<div class="form-group">
<label for="receipt_date" class="form-control-label">Date de Réception</label>
<input
id="receipt_date"
class="form-control"
type="date"
v-model="form.receipt_date"
/>
</div>
</div>
<div class="col-md-4">
<div class="form-group">
<label for="status" class="form-control-label">Statut</label>
<select id="status" class="form-control" v-model="form.status">
<option value="draft">Brouillon</option>
<option value="posted">Validée</option>
</select>
</div>
</div>
</div>
<div class="row">
<div class="col-12">
<div class="form-group">
<label for="notes" class="form-control-label">Notes</label>
<textarea
id="notes"
class="form-control"
rows="3"
v-model="form.notes"
></textarea>
</div>
</div>
</div>
</form>
</div>
</div>
</div>
</div>
<div v-else class="d-flex justify-content-center py-4">
<div class="spinner-border" role="status">
<span class="visually-hidden">Chargement...</span>
</div>
</div>
</div>
</template>
<script setup>
import { ref, reactive, onMounted } from "vue";
import { useRoute, useRouter } from "vue-router";
import { storeToRefs } from "pinia";
import SoftButton from "@/components/SoftButton.vue";
import { useGoodsReceiptStore } from "@/stores/goodsReceiptStore";
import { useWarehouseStore } from "@/stores/warehouseStore";
import { usePurchaseOrderStore } from "@/stores/purchaseOrderStore";
const route = useRoute();
const router = useRouter();
const goodsReceiptStore = useGoodsReceiptStore();
const warehouseStore = useWarehouseStore();
const purchaseOrderStore = usePurchaseOrderStore();
const { currentGoodsReceipt: goodsReceipt, loading } = storeToRefs(goodsReceiptStore);
const warehouses = ref([]);
const purchaseOrders = ref([]);
const errors = ref({});
const form = reactive({
purchase_order_id: "",
warehouse_id: "",
receipt_number: "",
receipt_date: "",
status: "draft",
notes: "",
});
const handleCancel = () => {
router.push(`/stock/receptions/${route.params.id}`);
};
const handleSubmit = async () => {
errors.value = {};
if (!form.purchase_order_id) {
errors.value.purchase_order_id = "La commande fournisseur est requise.";
}
if (!form.warehouse_id) {
errors.value.warehouse_id = "L'entrepôt est requis.";
}
if (Object.keys(errors.value).length > 0) {
return;
}
try {
const payload = {
id: parseInt(route.params.id),
purchase_order_id: parseInt(form.purchase_order_id),
warehouse_id: parseInt(form.warehouse_id),
receipt_number: form.receipt_number,
receipt_date: form.receipt_date,
status: form.status,
notes: form.notes || undefined,
};
await goodsReceiptStore.updateGoodsReceipt(payload);
router.push(`/stock/receptions/${route.params.id}`);
} catch (error) {
console.error("Failed to update goods receipt", error);
}
};
onMounted(async () => {
try {
await Promise.all([
warehouseStore.fetchWarehouses(),
purchaseOrderStore.fetchPurchaseOrders(),
goodsReceiptStore.fetchGoodsReceipt(parseInt(route.params.id)),
]);
warehouses.value = warehouseStore.warehouses;
purchaseOrders.value = purchaseOrderStore.purchaseOrders;
if (goodsReceipt.value) {
form.purchase_order_id = goodsReceipt.value.purchase_order_id;
form.warehouse_id = goodsReceipt.value.warehouse_id;
form.receipt_number = goodsReceipt.value.receipt_number;
form.receipt_date = goodsReceipt.value.receipt_date;
form.status = goodsReceipt.value.status;
form.notes = goodsReceipt.value.notes || "";
}
} catch (error) {
console.error("Failed to load data", error);
}
});
</script>

View File

@ -0,0 +1,7 @@
<template>
<new-reception-presentation />
</template>
<script setup>
import NewReceptionPresentation from "@/components/Organism/Stock/NewReceptionPresentation.vue";
</script>

View File

@ -1,11 +1,7 @@
<template>
<div>
<h1>Reception stock</h1>
</div>
<goods-receipt-list-presentation />
</template>
<script>
export default {
name: "ReceptionStock",
};
<script setup>
import GoodsReceiptListPresentation from "@/components/Organism/Stock/GoodsReceiptListPresentation.vue";
</script>

View File

@ -0,0 +1,7 @@
<template>
<reception-detail-presentation />
</template>
<script setup>
import ReceptionDetailPresentation from "@/components/Organism/Stock/ReceptionDetailPresentation.vue";
</script>