Gestion des bon de receptions dans front
This commit is contained in:
parent
d8927580e7
commit
31090d12ba
@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
133
thanasoft-back/app/Http/Controllers/Api/TvaRateController.php
Normal file
133
thanasoft-back/app/Http/Controllers/Api/TvaRateController.php
Normal 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);
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -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.',
|
||||
];
|
||||
}
|
||||
}
|
||||
47
thanasoft-back/app/Http/Requests/StoreTvaRateRequest.php
Normal file
47
thanasoft-back/app/Http/Requests/StoreTvaRateRequest.php
Normal 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.',
|
||||
];
|
||||
}
|
||||
}
|
||||
@ -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.',
|
||||
];
|
||||
}
|
||||
}
|
||||
45
thanasoft-back/app/Http/Requests/UpdateTvaRateRequest.php
Normal file
45
thanasoft-back/app/Http/Requests/UpdateTvaRateRequest.php
Normal 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.',
|
||||
];
|
||||
}
|
||||
}
|
||||
@ -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,
|
||||
];
|
||||
}
|
||||
}
|
||||
32
thanasoft-back/app/Http/Resources/GoodsReceiptResource.php
Normal file
32
thanasoft-back/app/Http/Resources/GoodsReceiptResource.php
Normal 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,
|
||||
];
|
||||
}
|
||||
}
|
||||
25
thanasoft-back/app/Http/Resources/TvaRateResource.php
Normal file
25
thanasoft-back/app/Http/Resources/TvaRateResource.php
Normal 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,
|
||||
];
|
||||
}
|
||||
}
|
||||
41
thanasoft-back/app/Models/GoodsReceipt.php
Normal file
41
thanasoft-back/app/Models/GoodsReceipt.php
Normal 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);
|
||||
}
|
||||
}
|
||||
53
thanasoft-back/app/Models/GoodsReceiptLine.php
Normal file
53
thanasoft-back/app/Models/GoodsReceiptLine.php
Normal 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);
|
||||
}
|
||||
}
|
||||
@ -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
|
||||
*/
|
||||
|
||||
@ -30,7 +30,7 @@ class PurchaseOrder extends Model
|
||||
$newNumber = 1;
|
||||
}
|
||||
|
||||
$purchaseOrder->po_number = $prefix . str_pad((string)$newNumber, 4, '0', STR_PAD_LEFT);
|
||||
$purchaseOrder->po_number = $prefix . str_pad((string) $newNumber, 4, '0', STR_PAD_LEFT);
|
||||
}
|
||||
});
|
||||
}
|
||||
@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
20
thanasoft-back/app/Models/TvaRate.php
Normal file
20
thanasoft-back/app/Models/TvaRate.php
Normal 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',
|
||||
];
|
||||
}
|
||||
@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@ -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);
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
87
thanasoft-back/app/Repositories/GoodsReceiptRepository.php
Normal file
87
thanasoft-back/app/Repositories/GoodsReceiptRepository.php
Normal 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;
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,9 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Repositories;
|
||||
|
||||
interface GoodsReceiptRepositoryInterface extends BaseRepositoryInterface
|
||||
{
|
||||
}
|
||||
26
thanasoft-back/app/Repositories/TvaRateRepository.php
Normal file
26
thanasoft-back/app/Repositories/TvaRateRepository.php
Normal 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);
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,9 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Repositories;
|
||||
|
||||
interface TvaRateRepositoryInterface extends BaseRepositoryInterface
|
||||
{
|
||||
}
|
||||
@ -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');
|
||||
}
|
||||
|
||||
@ -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 d’unité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');
|
||||
}
|
||||
};
|
||||
@ -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']);
|
||||
|
||||
@ -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>
|
||||
@ -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>
|
||||
@ -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>
|
||||
@ -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>
|
||||
@ -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>
|
||||
@ -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",
|
||||
|
||||
@ -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;
|
||||
|
||||
73
thanasoft-front/src/services/tvaRate.ts
Normal file
73
thanasoft-front/src/services/tvaRate.ts
Normal 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;
|
||||
@ -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;
|
||||
|
||||
@ -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,
|
||||
|
||||
210
thanasoft-front/src/views/pages/Stock/EditReception.vue
Normal file
210
thanasoft-front/src/views/pages/Stock/EditReception.vue
Normal 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>
|
||||
7
thanasoft-front/src/views/pages/Stock/NewReception.vue
Normal file
7
thanasoft-front/src/views/pages/Stock/NewReception.vue
Normal file
@ -0,0 +1,7 @@
|
||||
<template>
|
||||
<new-reception-presentation />
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import NewReceptionPresentation from "@/components/Organism/Stock/NewReceptionPresentation.vue";
|
||||
</script>
|
||||
@ -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>
|
||||
|
||||
@ -0,0 +1,7 @@
|
||||
<template>
|
||||
<reception-detail-presentation />
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import ReceptionDetailPresentation from "@/components/Organism/Stock/ReceptionDetailPresentation.vue";
|
||||
</script>
|
||||
Loading…
x
Reference in New Issue
Block a user