feat: Implement full CRUD API for quotes with dedicated model, repository, requests, resource, and database migration.

This commit is contained in:
kevin 2026-01-06 13:59:35 +03:00
parent 5d93f9d39a
commit 19b592720e
10 changed files with 430 additions and 0 deletions

View File

@ -0,0 +1,158 @@
<?php
declare(strict_types=1);
namespace App\Http\Controllers\Api;
use App\Http\Controllers\Controller;
use App\Http\Requests\StoreQuoteRequest;
use App\Http\Requests\UpdateQuoteRequest;
use App\Http\Resources\QuoteResource;
use App\Models\Quote;
use App\Repositories\QuoteRepositoryInterface;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Resources\Json\AnonymousResourceCollection;
use Illuminate\Support\Facades\Log;
use Illuminate\Http\Request;
class QuoteController extends Controller
{
public function __construct(
protected QuoteRepositoryInterface $quoteRepository
) {
}
/**
* Display a listing of quotes.
*/
public function index(): AnonymousResourceCollection|JsonResponse
{
try {
$quotes = $this->quoteRepository->all();
return QuoteResource::collection($quotes);
} catch (\Exception $e) {
Log::error('Error fetching quotes: ' . $e->getMessage(), [
'exception' => $e,
'trace' => $e->getTraceAsString(),
]);
return response()->json([
'message' => 'Une erreur est survenue lors de la récupération des devis.',
'error' => config('app.debug') ? $e->getMessage() : null,
], 500);
}
}
/**
* Store a newly created quote.
*/
public function store(StoreQuoteRequest $request): QuoteResource|JsonResponse
{
try {
$quote = $this->quoteRepository->create($request->validated());
return new QuoteResource($quote);
} catch (\Exception $e) {
Log::error('Error creating quote: ' . $e->getMessage(), [
'exception' => $e,
'trace' => $e->getTraceAsString(),
'data' => $request->validated(),
]);
return response()->json([
'message' => 'Une erreur est survenue lors de la création du devis.',
'error' => config('app.debug') ? $e->getMessage() : null,
], 500);
}
}
/**
* Display the specified quote.
*/
public function show(string $id): QuoteResource|JsonResponse
{
try {
$quote = $this->quoteRepository->find($id);
if (! $quote) {
return response()->json([
'message' => 'Devis non trouvé.',
], 404);
}
return new QuoteResource($quote);
} catch (\Exception $e) {
Log::error('Error fetching quote: ' . $e->getMessage(), [
'exception' => $e,
'trace' => $e->getTraceAsString(),
'quote_id' => $id,
]);
return response()->json([
'message' => 'Une erreur est survenue lors de la récupération du devis.',
'error' => config('app.debug') ? $e->getMessage() : null,
], 500);
}
}
/**
* Update the specified quote.
*/
public function update(UpdateQuoteRequest $request, string $id): QuoteResource|JsonResponse
{
try {
$updated = $this->quoteRepository->update($id, $request->validated());
if (! $updated) {
return response()->json([
'message' => 'Devis non trouvé ou échec de la mise à jour.',
], 404);
}
$quote = $this->quoteRepository->find($id);
return new QuoteResource($quote);
} catch (\Exception $e) {
Log::error('Error updating quote: ' . $e->getMessage(), [
'exception' => $e,
'trace' => $e->getTraceAsString(),
'quote_id' => $id,
'data' => $request->validated(),
]);
return response()->json([
'message' => 'Une erreur est survenue lors de la mise à jour du devis.',
'error' => config('app.debug') ? $e->getMessage() : null,
], 500);
}
}
/**
* Remove the specified quote.
*/
public function destroy(string $id): JsonResponse
{
try {
$deleted = $this->quoteRepository->delete($id);
if (! $deleted) {
return response()->json([
'message' => 'Devis non trouvé ou échec de la suppression.',
], 404);
}
return response()->json([
'message' => 'Devis supprimé avec succès.',
], 200);
} catch (\Exception $e) {
Log::error('Error deleting quote: ' . $e->getMessage(), [
'exception' => $e,
'trace' => $e->getTraceAsString(),
'quote_id' => $id,
]);
return response()->json([
'message' => 'Une erreur est survenue lors de la suppression du devis.',
'error' => config('app.debug') ? $e->getMessage() : null,
], 500);
}
}
}

View File

@ -0,0 +1,67 @@
<?php
namespace App\Http\Requests;
use Illuminate\Foundation\Http\FormRequest;
class StoreQuoteRequest 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 [
'client_id' => 'required|exists:clients,id',
'group_id' => 'nullable|exists:client_groups,id',
'quote_number' => 'required|string|max:191|unique:quotes,quote_number',
'status' => 'required|in:brouillon,envoye,accepte,refuse,expire,annule',
'quote_date' => 'required|date',
'valid_until' => 'nullable|date|after_or_equal:quote_date',
'currency' => 'required|string|size:3',
'total_ht' => 'required|numeric|min:0',
'total_tva' => 'required|numeric|min:0',
'total_ttc' => 'required|numeric|min:0',
];
}
public function messages(): array
{
return [
'client_id.required' => 'Le client est obligatoire.',
'client_id.exists' => 'Le client sélectionné est invalide.',
'group_id.exists' => 'Le groupe sélectionné est invalide.',
'quote_number.required' => 'Le numéro de devis est obligatoire.',
'quote_number.string' => 'Le numéro de devis doit être une chaîne de caractères.',
'quote_number.max' => 'Le numéro de devis ne doit pas dépasser 191 caractères.',
'quote_number.unique' => 'Ce numéro de devis existe déjà.',
'status.required' => 'Le statut est obligatoire.',
'status.in' => 'Le statut sélectionné est invalide.',
'quote_date.required' => 'La date du devis est obligatoire.',
'quote_date.date' => 'La date du devis n\'est pas valide.',
'valid_until.date' => 'La date de validité n\'est pas valide.',
'valid_until.after_or_equal' => 'La date de validité doit être postérieure ou égale à la date du devis.',
'currency.required' => 'La devise est obligatoire.',
'currency.size' => 'La devise doit comporter 3 caractères.',
'total_ht.required' => 'Le total HT est obligatoire.',
'total_ht.numeric' => 'Le total HT doit être un nombre.',
'total_ht.min' => 'Le total HT ne peut pas être négatif.',
'total_tva.required' => 'Le total TVA est obligatoire.',
'total_tva.numeric' => 'Le total TVA doit être un nombre.',
'total_tva.min' => 'Le total TVA ne peut pas être négatif.',
'total_ttc.required' => 'Le total TTC est obligatoire.',
'total_ttc.numeric' => 'Le total TTC doit être un nombre.',
'total_ttc.min' => 'Le total TTC ne peut pas être négatif.',
];
}
}

View File

@ -0,0 +1,59 @@
<?php
namespace App\Http\Requests;
use Illuminate\Foundation\Http\FormRequest;
class UpdateQuoteRequest 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 [
'client_id' => 'sometimes|exists:clients,id',
'group_id' => 'nullable|exists:client_groups,id',
'quote_number' => 'sometimes|string|max:191|unique:quotes,quote_number,' . $this->quote->id,
'status' => 'sometimes|in:brouillon,envoye,accepte,refuse,expire,annule',
'quote_date' => 'sometimes|date',
'valid_until' => 'nullable|date|after_or_equal:quote_date',
'currency' => 'sometimes|string|size:3',
'total_ht' => 'sometimes|numeric|min:0',
'total_tva' => 'sometimes|numeric|min:0',
'total_ttc' => 'sometimes|numeric|min:0',
];
}
public function messages(): array
{
return [
'client_id.exists' => 'Le client sélectionné est invalide.',
'group_id.exists' => 'Le groupe sélectionné est invalide.',
'quote_number.string' => 'Le numéro de devis doit être une chaîne de caractères.',
'quote_number.max' => 'Le numéro de devis ne doit pas dépasser 191 caractères.',
'quote_number.unique' => 'Ce numéro de devis existe déjà.',
'status.in' => 'Le statut sélectionné est invalide.',
'quote_date.date' => 'La date du devis n\'est pas valide.',
'valid_until.date' => 'La date de validité n\'est pas valide.',
'valid_until.after_or_equal' => 'La date de validité doit être postérieure ou égale à la date du devis.',
'currency.size' => 'La devise doit comporter 3 caractères.',
'total_ht.numeric' => 'Le total HT doit être un nombre.',
'total_ht.min' => 'Le total HT ne peut pas être négatif.',
'total_tva.numeric' => 'Le total TVA doit être un nombre.',
'total_tva.min' => 'Le total TVA ne peut pas être négatif.',
'total_ttc.numeric' => 'Le total TTC doit être un nombre.',
'total_ttc.min' => 'Le total TTC ne peut pas être négatif.',
];
}
}

View File

@ -0,0 +1,35 @@
<?php
namespace App\Http\Resources;
use Illuminate\Http\Request;
use Illuminate\Http\Resources\Json\JsonResource;
class QuoteResource extends JsonResource
{
/**
* Transform the resource into an array.
*
* @return array<string, mixed>
*/
public function toArray(Request $request): array
{
return [
'id' => $this->id,
'client_id' => $this->client_id,
'group_id' => $this->group_id,
'quote_number' => $this->quote_number,
'status' => $this->status,
'quote_date' => $this->quote_date,
'valid_until' => $this->valid_until,
'currency' => $this->currency,
'total_ht' => $this->total_ht,
'total_tva' => $this->total_tva,
'total_ttc' => $this->total_ttc,
'created_at' => $this->created_at,
'updated_at' => $this->updated_at,
'client' => $this->whenLoaded('client'),
'group' => $this->whenLoaded('group'),
];
}
}

View File

@ -0,0 +1,42 @@
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
class Quote extends Model
{
use HasFactory;
protected $fillable = [
'client_id',
'group_id',
'quote_number',
'status',
'quote_date',
'valid_until',
'currency',
'total_ht',
'total_tva',
'total_ttc',
];
protected $casts = [
'quote_date' => 'date',
'valid_until' => 'date',
'total_ht' => 'decimal:2',
'total_tva' => 'decimal:2',
'total_ttc' => 'decimal:2',
];
public function client()
{
return $this->belongsTo(Client::class);
}
public function group()
{
return $this->belongsTo(ClientGroup::class);
}
}

View File

@ -23,6 +23,7 @@ class RepositoryServiceProvider extends ServiceProvider
$this->app->bind(DeceasedDocumentRepositoryInterface::class, DeceasedDocumentRepository::class);
$this->app->bind(InterventionRepositoryInterface::class, InterventionRepository::class);
$this->app->bind(FileRepositoryInterface::class, FileRepository::class);
$this->app->bind(\App\Repositories\QuoteRepositoryInterface::class, \App\Repositories\QuoteRepository::class);
}
/**

View File

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

View File

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

View File

@ -0,0 +1,40 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
/**
* Run the migrations.
*/
public function up(): void
{
Schema::create('quotes', function (Blueprint $table) {
$table->id();
$table->unsignedBigInteger('client_id');
$table->unsignedBigInteger('group_id')->nullable();
$table->string('quote_number', 191);
$table->enum('status', ['brouillon', 'envoye', 'accepte', 'refuse', 'expire', 'annule'])->default('brouillon')->index('idx_quotes_status');
$table->date('quote_date')->default(now());
$table->date('valid_until')->nullable();
$table->char('currency', 3)->default('EUR');
$table->decimal('total_ht', 14, 2)->default(0);
$table->decimal('total_tva', 14, 2)->default(0);
$table->decimal('total_ttc', 14, 2)->default(0);
$table->timestamps();
$table->foreign('client_id', 'fk_quotes_client')->references('id')->on('clients');
$table->foreign('group_id', 'fk_quotes_group')->references('id')->on('client_groups')->onDelete('set null');
});
}
/**
* Reverse the migrations.
*/
public function down(): void
{
Schema::dropIfExists('quotes');
}
};

View File

@ -18,6 +18,7 @@ use App\Http\Controllers\Api\DeceasedDocumentController;
use App\Http\Controllers\Api\InterventionController;
use App\Http\Controllers\Api\FileController;
use App\Http\Controllers\Api\FileAttachmentController;
use App\Http\Controllers\Api\QuoteController;
/*
@ -61,6 +62,9 @@ Route::middleware('auth:sanctum')->group(function () {
Route::apiResource('client-categories', ClientCategoryController::class);
// Quote management
Route::apiResource('quotes', QuoteController::class);
// Fournisseur management
Route::get('/fournisseurs/searchBy', [FournisseurController::class, 'searchBy']);
Route::apiResource('fournisseurs', FournisseurController::class);