From 503fb0d008eb4585efb9a64dc8030a4fd0e3cbe1 Mon Sep 17 00:00:00 2001 From: kevin Date: Fri, 9 Jan 2026 18:02:15 +0300 Subject: [PATCH] quotes: Generer des factures en acceptant un devis --- .../Controllers/Api/InvoiceController.php | 178 +++++++++++ .../app/Http/Requests/StoreInvoiceRequest.php | 78 +++++ .../Http/Requests/UpdateInvoiceRequest.php | 67 +++++ .../Http/Resources/InvoiceLineResource.php | 37 +++ .../app/Http/Resources/InvoiceResource.php | 41 +++ thanasoft-back/app/Models/Invoice.php | 89 ++++++ thanasoft-back/app/Models/InvoiceLine.php | 48 +++ .../app/Providers/AppServiceProvider.php | 7 + .../Providers/RepositoryServiceProvider.php | 3 +- .../Repositories/InvoiceLineRepository.php | 41 +++ .../InvoiceLineRepositoryInterface.php | 14 + .../app/Repositories/InvoiceRepository.php | 159 ++++++++++ .../InvoiceRepositoryInterface.php | 10 + .../app/Repositories/QuoteRepository.php | 17 +- ...26_01_09_000000_create_invoices_tables.php | 67 +++++ thanasoft-back/routes/api.php | 4 + .../Invoice/InvoiceDetailPresentation.vue | 195 ++++++++++++ .../Invoice/InvoiceListPresentation.vue | 46 +++ .../molecules/Invoice/InvoiceHeader.vue | 32 ++ .../molecules/Invoice/InvoiceLinesTable.vue | 73 +++++ .../molecules/Invoice/InvoiceListControls.vue | 104 +++++++ .../molecules/Invoice/InvoiceSummary.vue | 44 +++ .../molecules/Tables/Ventes/InvoiceTable.vue | 283 ++++++++++++++++++ .../Invoice/InvoiceDetailTemplate.vue | 46 +++ .../templates/Invoice/ListInvoiceTemplate.vue | 24 ++ .../src/examples/Sidenav/SidenavList.vue | 2 +- thanasoft-front/src/router/index.js | 21 +- thanasoft-front/src/services/invoice.ts | 176 +++++++++++ thanasoft-front/src/stores/invoiceStore.ts | 245 +++++++++++++++ .../src/views/pages/Ventes/InvoiceDetail.vue | 12 + .../src/views/pages/Ventes/InvoiceList.vue | 7 + .../src/views/pages/Ventes/NewInvoice.vue | 46 +++ 32 files changed, 2207 insertions(+), 9 deletions(-) create mode 100644 thanasoft-back/app/Http/Controllers/Api/InvoiceController.php create mode 100644 thanasoft-back/app/Http/Requests/StoreInvoiceRequest.php create mode 100644 thanasoft-back/app/Http/Requests/UpdateInvoiceRequest.php create mode 100644 thanasoft-back/app/Http/Resources/InvoiceLineResource.php create mode 100644 thanasoft-back/app/Http/Resources/InvoiceResource.php create mode 100644 thanasoft-back/app/Models/Invoice.php create mode 100644 thanasoft-back/app/Models/InvoiceLine.php create mode 100644 thanasoft-back/app/Repositories/InvoiceLineRepository.php create mode 100644 thanasoft-back/app/Repositories/InvoiceLineRepositoryInterface.php create mode 100644 thanasoft-back/app/Repositories/InvoiceRepository.php create mode 100644 thanasoft-back/app/Repositories/InvoiceRepositoryInterface.php create mode 100644 thanasoft-back/database/migrations/2026_01_09_000000_create_invoices_tables.php create mode 100644 thanasoft-front/src/components/Organism/Invoice/InvoiceDetailPresentation.vue create mode 100644 thanasoft-front/src/components/Organism/Invoice/InvoiceListPresentation.vue create mode 100644 thanasoft-front/src/components/molecules/Invoice/InvoiceHeader.vue create mode 100644 thanasoft-front/src/components/molecules/Invoice/InvoiceLinesTable.vue create mode 100644 thanasoft-front/src/components/molecules/Invoice/InvoiceListControls.vue create mode 100644 thanasoft-front/src/components/molecules/Invoice/InvoiceSummary.vue create mode 100644 thanasoft-front/src/components/molecules/Tables/Ventes/InvoiceTable.vue create mode 100644 thanasoft-front/src/components/templates/Invoice/InvoiceDetailTemplate.vue create mode 100644 thanasoft-front/src/components/templates/Invoice/ListInvoiceTemplate.vue create mode 100644 thanasoft-front/src/services/invoice.ts create mode 100644 thanasoft-front/src/stores/invoiceStore.ts create mode 100644 thanasoft-front/src/views/pages/Ventes/InvoiceDetail.vue create mode 100644 thanasoft-front/src/views/pages/Ventes/InvoiceList.vue create mode 100644 thanasoft-front/src/views/pages/Ventes/NewInvoice.vue diff --git a/thanasoft-back/app/Http/Controllers/Api/InvoiceController.php b/thanasoft-back/app/Http/Controllers/Api/InvoiceController.php new file mode 100644 index 0000000..e91c6e7 --- /dev/null +++ b/thanasoft-back/app/Http/Controllers/Api/InvoiceController.php @@ -0,0 +1,178 @@ +invoiceRepository->all(); + return InvoiceResource::collection($invoices); + } catch (\Exception $e) { + Log::error('Error fetching invoices: ' . $e->getMessage(), [ + 'exception' => $e, + 'trace' => $e->getTraceAsString(), + ]); + + return response()->json([ + 'message' => 'Une erreur est survenue lors de la récupération des factures.', + 'error' => config('app.debug') ? $e->getMessage() : null, + ], 500); + } + } + + /** + * Store a newly created invoice. + */ + public function store(StoreInvoiceRequest $request): InvoiceResource|JsonResponse + { + try { + $invoice = $this->invoiceRepository->create($request->validated()); + return new InvoiceResource($invoice); + } catch (\Exception $e) { + Log::error('Error creating invoice: ' . $e->getMessage(), [ + 'exception' => $e, + 'trace' => $e->getTraceAsString(), + 'data' => $request->validated(), + ]); + + return response()->json([ + 'message' => 'Une erreur est survenue lors de la création de la facture.', + 'error' => config('app.debug') ? $e->getMessage() : null, + ], 500); + } + } + + /** + * Display the specified invoice. + */ + public function show(string $id): InvoiceResource|JsonResponse + { + try { + $invoice = $this->invoiceRepository->find($id); + + if (! $invoice) { + return response()->json([ + 'message' => 'Facture non trouvée.', + ], 404); + } + + return new InvoiceResource($invoice); + } catch (\Exception $e) { + Log::error('Error fetching invoice: ' . $e->getMessage(), [ + 'exception' => $e, + 'trace' => $e->getTraceAsString(), + 'invoice_id' => $id, + ]); + + return response()->json([ + 'message' => 'Une erreur est survenue lors de la récupération de la facture.', + 'error' => config('app.debug') ? $e->getMessage() : null, + ], 500); + } + } + + /** + * Update the specified invoice. + */ + public function update(UpdateInvoiceRequest $request, string $id): InvoiceResource|JsonResponse + { + try { + $updated = $this->invoiceRepository->update($id, $request->validated()); + + if (! $updated) { + return response()->json([ + 'message' => 'Facture non trouvée ou échec de la mise à jour.', + ], 404); + } + + $invoice = $this->invoiceRepository->find($id); + return new InvoiceResource($invoice); + } catch (\Exception $e) { + Log::error('Error updating invoice: ' . $e->getMessage(), [ + 'exception' => $e, + 'trace' => $e->getTraceAsString(), + 'invoice_id' => $id, + 'data' => $request->validated(), + ]); + + return response()->json([ + 'message' => 'Une erreur est survenue lors de la mise à jour de la facture.', + 'error' => config('app.debug') ? $e->getMessage() : null, + ], 500); + } + } + + /** + * Remove the specified invoice. + */ + public function destroy(string $id): JsonResponse + { + try { + $deleted = $this->invoiceRepository->delete($id); + + if (! $deleted) { + return response()->json([ + 'message' => 'Facture non trouvée ou échec de la suppression.', + ], 404); + } + + return response()->json([ + 'message' => 'Facture supprimée avec succès.', + ], 200); + } catch (\Exception $e) { + Log::error('Error deleting invoice: ' . $e->getMessage(), [ + 'exception' => $e, + 'trace' => $e->getTraceAsString(), + 'invoice_id' => $id, + ]); + + return response()->json([ + 'message' => 'Une erreur est survenue lors de la suppression de la facture.', + 'error' => config('app.debug') ? $e->getMessage() : null, + ], 500); + } + } + + /** + * Create an invoice from a quote. + */ + public function createFromQuote(string $quoteId): InvoiceResource|JsonResponse + { + try { + $invoice = $this->invoiceRepository->createFromQuote($quoteId); + return new InvoiceResource($invoice); + } catch (\Exception $e) { + Log::error('Error creating invoice from quote: ' . $e->getMessage(), [ + 'exception' => $e, + 'trace' => $e->getTraceAsString(), + 'quote_id' => $quoteId, + ]); + + return response()->json([ + 'message' => 'Une erreur est survenue lors de la création de la facture depuis le devis.', + 'error' => config('app.debug') ? $e->getMessage() : null, + ], 500); + } + } +} diff --git a/thanasoft-back/app/Http/Requests/StoreInvoiceRequest.php b/thanasoft-back/app/Http/Requests/StoreInvoiceRequest.php new file mode 100644 index 0000000..45f489c --- /dev/null +++ b/thanasoft-back/app/Http/Requests/StoreInvoiceRequest.php @@ -0,0 +1,78 @@ +|string> + */ + public function rules(): array + { + return [ + 'client_id' => 'required|exists:clients,id', + 'group_id' => 'nullable|exists:client_groups,id', + 'source_quote_id' => 'nullable|exists:quotes,id', + 'status' => 'required|in:brouillon,emise,envoyee,partiellement_payee,payee,echue,annulee,avoir', + 'invoice_date' => 'required|date', + 'due_date' => 'nullable|date|after_or_equal:invoice_date', + 'currency' => 'required|string|size:3', + 'total_ht' => 'required|numeric|min:0', + 'total_tva' => 'required|numeric|min:0', + 'total_ttc' => 'required|numeric|min:0', + 'e_invoicing_channel_id' => 'nullable|exists:e_invoicing_channels,id', + 'e_invoice_status' => 'nullable|in:cree,transmis,accepte,refuse,en_litige,acquitte,archive', + 'lines' => 'required|array|min:1', + 'lines.*.product_id' => 'nullable|exists:products,id', + 'lines.*.packaging_id' => 'nullable|exists:product_packagings,id', + 'lines.*.packages_qty' => 'nullable|numeric|min:0', + 'lines.*.units_qty' => 'nullable|numeric|min:0', + 'lines.*.description' => 'required|string', + 'lines.*.qty_base' => 'nullable|numeric|min:0', + 'lines.*.unit_price' => 'required|numeric|min:0', + 'lines.*.unit_price_per_package' => 'nullable|numeric|min:0', + 'lines.*.tva_rate_id' => 'nullable|exists:tva_rates,id', + 'lines.*.discount_pct' => 'required|numeric|min:0|max:100', + 'lines.*.total_ht' => '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.', + 'status.required' => 'Le statut est obligatoire.', + 'status.in' => 'Le statut sélectionné est invalide.', + 'invoice_date.required' => 'La date de la facture est obligatoire.', + 'invoice_date.date' => 'La date de la facture n\'est pas valide.', + 'due_date.date' => 'La date d\'échéance n\'est pas valide.', + 'due_date.after_or_equal' => 'La date d\'échéance doit être postérieure ou égale à la date de la facture.', + '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.', + 'lines.required' => 'Veuillez ajouter au moins une ligne à la facture.', + ]; + } +} diff --git a/thanasoft-back/app/Http/Requests/UpdateInvoiceRequest.php b/thanasoft-back/app/Http/Requests/UpdateInvoiceRequest.php new file mode 100644 index 0000000..6bba608 --- /dev/null +++ b/thanasoft-back/app/Http/Requests/UpdateInvoiceRequest.php @@ -0,0 +1,67 @@ +|string> + */ + public function rules(): array + { + $invoiceId = $this->route('invoice'); + + return [ + 'client_id' => 'sometimes|exists:clients,id', + 'group_id' => 'nullable|exists:client_groups,id', + 'source_quote_id' => 'nullable|exists:quotes,id', + 'invoice_number' => 'sometimes|string|max:191|unique:invoices,invoice_number,' . $invoiceId, + 'status' => 'sometimes|in:brouillon,emise,envoyee,partiellement_payee,payee,echue,annulee,avoir', + 'invoice_date' => 'sometimes|date', + 'due_date' => 'nullable|date|after_or_equal:invoice_date', + 'currency' => 'sometimes|string|size:3', + 'total_ht' => 'sometimes|numeric|min:0', + 'total_tva' => 'sometimes|numeric|min:0', + 'total_ttc' => 'sometimes|numeric|min:0', + 'e_invoicing_channel_id' => 'nullable|exists:e_invoicing_channels,id', + 'e_invoice_status' => 'nullable|in:cree,transmis,accepte,refuse,en_litige,acquitte,archive', + ]; + } + + public function messages(): array + { + return [ + 'client_id.exists' => 'Le client sélectionné est invalide.', + 'group_id.exists' => 'Le groupe sélectionné est invalide.', + 'source_quote_id.exists' => 'Le devis source est invalide.', + 'invoice_number.string' => 'Le numéro de facture doit être une chaîne de caractères.', + 'invoice_number.max' => 'Le numéro de facture ne doit pas dépasser 191 caractères.', + 'invoice_number.unique' => 'Ce numéro de facture existe déjà.', + 'status.in' => 'Le statut sélectionné est invalide.', + 'invoice_date.date' => 'La date de la facture n\'est pas valide.', + 'due_date.date' => 'La date d\'échéance n\'est pas valide.', + 'due_date.after_or_equal' => 'La date d\'échéance doit être postérieure ou égale à la date de la facture.', + '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.', + 'e_invoicing_channel_id.exists' => 'Le canal de facturation électronique est invalide.', + 'e_invoice_status.in' => 'Le statut de facturation électronique est invalide.', + ]; + } +} diff --git a/thanasoft-back/app/Http/Resources/InvoiceLineResource.php b/thanasoft-back/app/Http/Resources/InvoiceLineResource.php new file mode 100644 index 0000000..9797565 --- /dev/null +++ b/thanasoft-back/app/Http/Resources/InvoiceLineResource.php @@ -0,0 +1,37 @@ + + */ + public function toArray(Request $request): array + { + return [ + 'id' => $this->id, + 'invoice_id' => $this->invoice_id, + 'product_id' => $this->product_id, + 'product_name' => $this->product ? $this->product->nom : null, + 'packaging_id' => $this->packaging_id, + 'packages_qty' => $this->packages_qty, + 'units_qty' => $this->units_qty, + 'description' => $this->description, + 'qty_base' => $this->qty_base, + 'unit_price' => $this->unit_price, + 'unit_price_per_package' => $this->unit_price_per_package, + 'tva_rate_id' => $this->tva_rate_id, + 'discount_pct' => $this->discount_pct, + 'total_ht' => $this->total_ht, + 'product' => $this->whenLoaded('product'), + 'packaging' => $this->whenLoaded('packaging'), + 'tva_rate' => $this->whenLoaded('tvaRate'), + ]; + } +} diff --git a/thanasoft-back/app/Http/Resources/InvoiceResource.php b/thanasoft-back/app/Http/Resources/InvoiceResource.php new file mode 100644 index 0000000..9c953c8 --- /dev/null +++ b/thanasoft-back/app/Http/Resources/InvoiceResource.php @@ -0,0 +1,41 @@ + + */ + public function toArray(Request $request): array + { + return [ + 'id' => $this->id, + 'client_id' => $this->client_id, + 'group_id' => $this->group_id, + 'source_quote_id' => $this->source_quote_id, + 'invoice_number' => $this->invoice_number, + 'status' => $this->status, + 'invoice_date' => $this->invoice_date, + 'due_date' => $this->due_date, + 'currency' => $this->currency, + 'total_ht' => $this->total_ht, + 'total_tva' => $this->total_tva, + 'total_ttc' => $this->total_ttc, + 'e_invoicing_channel_id' => $this->e_invoicing_channel_id, + 'e_invoice_status' => $this->e_invoice_status, + 'created_at' => $this->created_at, + 'updated_at' => $this->updated_at, + 'client' => $this->whenLoaded('client'), + 'group' => $this->whenLoaded('group'), + 'sourceQuote' => $this->whenLoaded('sourceQuote'), + 'lines' => InvoiceLineResource::collection($this->whenLoaded('lines')), + 'history' => DocumentStatusHistoryResource::collection($this->whenLoaded('history')), + ]; + } +} diff --git a/thanasoft-back/app/Models/Invoice.php b/thanasoft-back/app/Models/Invoice.php new file mode 100644 index 0000000..0a27cd9 --- /dev/null +++ b/thanasoft-back/app/Models/Invoice.php @@ -0,0 +1,89 @@ +invoice_number)) { + $prefix = 'FAC-' . now()->format('Ym') . '-'; + $lastInvoice = self::where('invoice_number', 'like', $prefix . '%') + ->orderBy('invoice_number', 'desc') + ->first(); + + if ($lastInvoice) { + $lastNumber = intval(substr($lastInvoice->invoice_number, -4)); + $newNumber = $lastNumber + 1; + } else { + $newNumber = 1; + } + + $invoice->invoice_number = $prefix . str_pad((string)$newNumber, 4, '0', STR_PAD_LEFT); + } + }); + } + + protected $casts = [ + 'invoice_date' => 'date', + 'due_date' => '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, 'group_id'); + } + + public function lines() + { + return $this->hasMany(InvoiceLine::class); + } + + public function sourceQuote() + { + return $this->belongsTo(Quote::class, 'source_quote_id'); + } + + public function eInvoicingChannel() + { + return $this->belongsTo(EInvoicingChannel::class); + } + + public function history() + { + return $this->hasMany(DocumentStatusHistory::class, 'document_id') + ->where('document_type', 'invoice') + ->orderBy('changed_at', 'desc'); + } +} diff --git a/thanasoft-back/app/Models/InvoiceLine.php b/thanasoft-back/app/Models/InvoiceLine.php new file mode 100644 index 0000000..c5b820d --- /dev/null +++ b/thanasoft-back/app/Models/InvoiceLine.php @@ -0,0 +1,48 @@ +belongsTo(Invoice::class); + } + + public function product() + { + return $this->belongsTo(Product::class); + } + + public function packaging() + { + return $this->belongsTo(\App\Models\Stock\ProductPackaging::class, 'packaging_id'); + } + + public function tvaRate() + { + return $this->belongsTo(\App\Models\TvaRate::class, 'tva_rate_id'); + } +} diff --git a/thanasoft-back/app/Providers/AppServiceProvider.php b/thanasoft-back/app/Providers/AppServiceProvider.php index 5f859b2..c61700f 100644 --- a/thanasoft-back/app/Providers/AppServiceProvider.php +++ b/thanasoft-back/app/Providers/AppServiceProvider.php @@ -67,11 +67,18 @@ class AppServiceProvider extends ServiceProvider $this->app->bind(\App\Repositories\ProductCategoryRepositoryInterface::class, \App\Repositories\ProductCategoryRepository::class); + $this->app->bind(\App\Repositories\InvoiceRepositoryInterface::class, \App\Repositories\InvoiceRepository::class); + + $this->app->bind(\App\Repositories\InvoiceLineRepositoryInterface::class, \App\Repositories\InvoiceLineRepository::class); + $this->app->bind(\App\Repositories\QuoteRepositoryInterface::class, \App\Repositories\QuoteRepository::class); $this->app->bind(\App\Repositories\QuoteLineRepositoryInterface::class, \App\Repositories\QuoteLineRepository::class); + } + + /** * Bootstrap any application services. */ diff --git a/thanasoft-back/app/Providers/RepositoryServiceProvider.php b/thanasoft-back/app/Providers/RepositoryServiceProvider.php index 15e4862..2c59e19 100644 --- a/thanasoft-back/app/Providers/RepositoryServiceProvider.php +++ b/thanasoft-back/app/Providers/RepositoryServiceProvider.php @@ -23,8 +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); - $this->app->bind(\App\Repositories\QuoteLineRepositoryInterface::class, \App\Repositories\QuoteLineRepository::class); + } /** diff --git a/thanasoft-back/app/Repositories/InvoiceLineRepository.php b/thanasoft-back/app/Repositories/InvoiceLineRepository.php new file mode 100644 index 0000000..5c1b445 --- /dev/null +++ b/thanasoft-back/app/Repositories/InvoiceLineRepository.php @@ -0,0 +1,41 @@ +find($id); + if ($line) { + return $line->update($data); + } + return false; + } + + public function delete(string $id): bool + { + $line = $this->find($id); + if ($line) { + return $line->delete(); + } + return false; + } + + public function find(string $id): ?InvoiceLine + { + return InvoiceLine::find($id); + } + + public function getByInvoiceId(string $invoiceId) + { + return InvoiceLine::where('invoice_id', $invoiceId)->get(); + } +} diff --git a/thanasoft-back/app/Repositories/InvoiceLineRepositoryInterface.php b/thanasoft-back/app/Repositories/InvoiceLineRepositoryInterface.php new file mode 100644 index 0000000..5473ce6 --- /dev/null +++ b/thanasoft-back/app/Repositories/InvoiceLineRepositoryInterface.php @@ -0,0 +1,14 @@ +find($quoteId); + + if (!$quote) { + throw new \Exception("Quote not found"); + } + + // Create Invoice + $invoiceData = [ + 'client_id' => $quote->client_id, + 'group_id' => $quote->group_id, + 'source_quote_id' => $quote->id, + 'status' => 'brouillon', // Start as draft + 'invoice_date' => now(), + 'currency' => $quote->currency, + 'total_ht' => $quote->total_ht, + 'total_tva' => $quote->total_tva, + 'total_ttc' => $quote->total_ttc, + ]; + + $invoice = parent::create($invoiceData); + + // Copy Lines + foreach ($quote->lines as $line) { + $this->invoiceLineRepository->create([ + 'invoice_id' => $invoice->id, + 'product_id' => $line->product_id, + 'packaging_id' => $line->packaging_id, + 'packages_qty' => $line->packages_qty, + 'units_qty' => $line->units_qty, + 'description' => $line->description, + 'qty_base' => $line->qty_base, + 'unit_price' => $line->unit_price, + 'unit_price_per_package' => $line->unit_price_per_package, + 'tva_rate_id' => $line->tva_rate_id, + 'discount_pct' => $line->discount_pct, + 'total_ht' => $line->total_ht, + ]); + } + + // Record history + $this->recordHistory($invoice->id, null, 'brouillon', 'Created from Quote ' . $quote->reference); + + return $invoice; + }); + } + + + + public function all(array $columns = ['*']): \Illuminate\Support\Collection + { + return $this->model->with(['client', 'lines.product'])->get($columns); + } + + public function create(array $data): Invoice + { + return DB::transaction(function () use ($data) { + try { + // Create the invoice + $invoice = parent::create($data); + + // Create the invoice lines + if (isset($data['lines']) && is_array($data['lines'])) { + foreach ($data['lines'] as $lineData) { + $lineData['invoice_id'] = $invoice->id; + $this->invoiceLineRepository->create($lineData); + } + } + + // Record initial status history + $this->recordHistory($invoice->id, null, $invoice->status, 'Invoice created'); + + return $invoice; + } catch (\Exception $e) { + Log::error('Error creating invoice with lines: ' . $e->getMessage(), [ + 'exception' => $e, + 'data' => $data, + ]); + throw $e; + } + }); + } + + public function update(int|string $id, array $attributes): bool + { + return DB::transaction(function () use ($id, $attributes) { + try { + $invoice = $this->find($id); + if (!$invoice) { + return false; + } + + $oldStatus = $invoice->status; + + // Update the invoice + $updated = parent::update($id, $attributes); + + if ($updated) { + $newStatus = $attributes['status'] ?? $oldStatus; + + // If status changed, record history + if ($oldStatus !== $newStatus) { + $this->recordHistory((int) $id, $oldStatus, $newStatus, 'Invoice status updated'); + } + } + + return $updated; + } catch (\Exception $e) { + Log::error('Error updating invoice: ' . $e->getMessage(), [ + 'id' => $id, + 'attributes' => $attributes, + 'exception' => $e, + ]); + throw $e; + } + }); + } + + public function find(int|string $id, array $columns = ['*']): ?Invoice + { + return $this->model->with(['client', 'lines.product', 'history.user'])->find($id, $columns); + } + + private function recordHistory(int $invoiceId, ?string $oldStatus, string $newStatus, ?string $comment = null): void + { + \App\Models\DocumentStatusHistory::create([ + 'document_type' => 'invoice', + 'document_id' => $invoiceId, + 'old_status' => $oldStatus, + 'new_status' => $newStatus, + 'changed_by' => auth()->id(), // Assuming authenticated user + 'comment' => $comment, + 'changed_at' => now(), + ]); + } +} diff --git a/thanasoft-back/app/Repositories/InvoiceRepositoryInterface.php b/thanasoft-back/app/Repositories/InvoiceRepositoryInterface.php new file mode 100644 index 0000000..5f61598 --- /dev/null +++ b/thanasoft-back/app/Repositories/InvoiceRepositoryInterface.php @@ -0,0 +1,10 @@ +recordHistory((int) $id, $oldStatus, $newStatus, 'Quote status updated'); + + // Auto-create invoice when status changes to 'accepte' + if ($newStatus === 'accepte' && $oldStatus !== 'accepte') { + try { + $this->invoiceRepository->createFromQuote($id); + Log::info('Invoice auto-created from quote', ['quote_id' => $id]); + } catch (\Exception $e) { + Log::error('Failed to auto-create invoice from quote: ' . $e->getMessage(), [ + 'quote_id' => $id, + 'exception' => $e, + ]); + // Don't throw - quote update should still succeed + } + } } } diff --git a/thanasoft-back/database/migrations/2026_01_09_000000_create_invoices_tables.php b/thanasoft-back/database/migrations/2026_01_09_000000_create_invoices_tables.php new file mode 100644 index 0000000..679e6fb --- /dev/null +++ b/thanasoft-back/database/migrations/2026_01_09_000000_create_invoices_tables.php @@ -0,0 +1,67 @@ +id(); + $table->unsignedBigInteger('client_id'); + $table->unsignedBigInteger('group_id')->nullable(); + $table->unsignedBigInteger('source_quote_id')->nullable(); + $table->string('invoice_number', 191); + $table->enum('status', ['brouillon', 'emise', 'envoyee', 'partiellement_payee', 'payee', 'echue', 'annulee', 'avoir'])->default('brouillon'); + $table->date('invoice_date')->useCurrent(); + $table->date('due_date')->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->enum('e_invoice_status', ['cree', 'transmis', 'accepte', 'refuse', 'en_litige', 'acquitte', 'archive'])->nullable()->default('cree'); + $table->timestamps(); + + $table->index(['status', 'due_date'], 'idx_invoices_status_due'); + + $table->foreign('client_id', 'fk_invoices_client')->references('id')->on('clients'); + $table->foreign('group_id', 'fk_invoices_group')->references('id')->on('client_groups')->onDelete('set null'); + $table->foreign('source_quote_id', 'fk_invoices_quote')->references('id')->on('quotes')->onDelete('set null'); + + }); + + Schema::create('invoice_lines', function (Blueprint $table) { + $table->id(); + $table->unsignedBigInteger('invoice_id'); + $table->unsignedBigInteger('product_id')->nullable(); + $table->unsignedBigInteger('packaging_id')->nullable(); + $table->decimal('packages_qty', 14, 3)->nullable(); + $table->decimal('units_qty', 14, 3)->nullable(); + $table->text('description'); + $table->decimal('qty_base', 14, 3)->nullable(); + $table->decimal('unit_price', 12, 2); + $table->decimal('unit_price_per_package', 12, 2)->nullable(); + $table->unsignedBigInteger('tva_rate_id')->nullable(); + $table->decimal('discount_pct', 5, 2)->default(0); + $table->decimal('total_ht', 14, 2); + + $table->foreign('invoice_id', 'fk_il_invoice')->references('id')->on('invoices')->onDelete('cascade'); + $table->foreign('product_id', 'fk_il_product')->references('id')->on('products')->onDelete('set null'); + // Note: packaging_id and tva_rate_id FK constraints removed - referenced tables don't exist + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::dropIfExists('invoice_lines'); + Schema::dropIfExists('invoices'); + } +}; diff --git a/thanasoft-back/routes/api.php b/thanasoft-back/routes/api.php index 7960317..9064fff 100644 --- a/thanasoft-back/routes/api.php +++ b/thanasoft-back/routes/api.php @@ -72,6 +72,10 @@ Route::middleware('auth:sanctum')->group(function () { // Quote management Route::apiResource('quotes', QuoteController::class); + // Invoice management + Route::post('/invoices/from-quote/{quoteId}', [\App\Http\Controllers\Api\InvoiceController::class, 'createFromQuote']); + Route::apiResource('invoices', \App\Http\Controllers\Api\InvoiceController::class); + // Fournisseur management Route::get('/fournisseurs/searchBy', [FournisseurController::class, 'searchBy']); Route::apiResource('fournisseurs', FournisseurController::class); diff --git a/thanasoft-front/src/components/Organism/Invoice/InvoiceDetailPresentation.vue b/thanasoft-front/src/components/Organism/Invoice/InvoiceDetailPresentation.vue new file mode 100644 index 0000000..34a15c6 --- /dev/null +++ b/thanasoft-front/src/components/Organism/Invoice/InvoiceDetailPresentation.vue @@ -0,0 +1,195 @@ + + + diff --git a/thanasoft-front/src/components/Organism/Invoice/InvoiceListPresentation.vue b/thanasoft-front/src/components/Organism/Invoice/InvoiceListPresentation.vue new file mode 100644 index 0000000..d29ffbf --- /dev/null +++ b/thanasoft-front/src/components/Organism/Invoice/InvoiceListPresentation.vue @@ -0,0 +1,46 @@ + + + diff --git a/thanasoft-front/src/components/molecules/Invoice/InvoiceHeader.vue b/thanasoft-front/src/components/molecules/Invoice/InvoiceHeader.vue new file mode 100644 index 0000000..111e4b1 --- /dev/null +++ b/thanasoft-front/src/components/molecules/Invoice/InvoiceHeader.vue @@ -0,0 +1,32 @@ + + + diff --git a/thanasoft-front/src/components/molecules/Invoice/InvoiceLinesTable.vue b/thanasoft-front/src/components/molecules/Invoice/InvoiceLinesTable.vue new file mode 100644 index 0000000..281e429 --- /dev/null +++ b/thanasoft-front/src/components/molecules/Invoice/InvoiceLinesTable.vue @@ -0,0 +1,73 @@ + + + diff --git a/thanasoft-front/src/components/molecules/Invoice/InvoiceListControls.vue b/thanasoft-front/src/components/molecules/Invoice/InvoiceListControls.vue new file mode 100644 index 0000000..849aad5 --- /dev/null +++ b/thanasoft-front/src/components/molecules/Invoice/InvoiceListControls.vue @@ -0,0 +1,104 @@ + + + diff --git a/thanasoft-front/src/components/molecules/Invoice/InvoiceSummary.vue b/thanasoft-front/src/components/molecules/Invoice/InvoiceSummary.vue new file mode 100644 index 0000000..ddca6d4 --- /dev/null +++ b/thanasoft-front/src/components/molecules/Invoice/InvoiceSummary.vue @@ -0,0 +1,44 @@ + + + diff --git a/thanasoft-front/src/components/molecules/Tables/Ventes/InvoiceTable.vue b/thanasoft-front/src/components/molecules/Tables/Ventes/InvoiceTable.vue new file mode 100644 index 0000000..6a0d628 --- /dev/null +++ b/thanasoft-front/src/components/molecules/Tables/Ventes/InvoiceTable.vue @@ -0,0 +1,283 @@ + + + diff --git a/thanasoft-front/src/components/templates/Invoice/InvoiceDetailTemplate.vue b/thanasoft-front/src/components/templates/Invoice/InvoiceDetailTemplate.vue new file mode 100644 index 0000000..5c0f915 --- /dev/null +++ b/thanasoft-front/src/components/templates/Invoice/InvoiceDetailTemplate.vue @@ -0,0 +1,46 @@ + + + diff --git a/thanasoft-front/src/components/templates/Invoice/ListInvoiceTemplate.vue b/thanasoft-front/src/components/templates/Invoice/ListInvoiceTemplate.vue new file mode 100644 index 0000000..ecbc701 --- /dev/null +++ b/thanasoft-front/src/components/templates/Invoice/ListInvoiceTemplate.vue @@ -0,0 +1,24 @@ + + + diff --git a/thanasoft-front/src/examples/Sidenav/SidenavList.vue b/thanasoft-front/src/examples/Sidenav/SidenavList.vue index f32afeb..c593b5d 100644 --- a/thanasoft-front/src/examples/Sidenav/SidenavList.vue +++ b/thanasoft-front/src/examples/Sidenav/SidenavList.vue @@ -274,7 +274,7 @@ export default { }, { id: "factures-ventes", - route: { name: "Factures ventes" }, + route: { name: "Liste Factures" }, miniIcon: "F", text: "Factures", }, diff --git a/thanasoft-front/src/router/index.js b/thanasoft-front/src/router/index.js index 2e2e5c4..b9e527b 100644 --- a/thanasoft-front/src/router/index.js +++ b/thanasoft-front/src/router/index.js @@ -487,16 +487,27 @@ const routes = [ name: "Quote Details", component: () => import("@/views/pages/Ventes/QuoteDetail.vue"), }, - { - path: "/ventes/factures", - name: "Factures ventes", - component: () => import("@/views/pages/Ventes/Factures.vue"), - }, { path: "/ventes/devis/new", name: "Nouveau Devis", component: () => import("@/views/pages/Ventes/NewQuote.vue"), }, + // Invoices + { + path: "/ventes/factures", + name: "Liste Factures", + component: () => import("@/views/pages/Ventes/InvoiceList.vue"), + }, + { + path: "/ventes/factures/new", + name: "Nouvelle Facture", + component: () => import("@/views/pages/Ventes/NewInvoice.vue"), + }, + { + path: "/ventes/factures/:id", + name: "Invoice Details", + component: () => import("@/views/pages/Ventes/InvoiceDetail.vue"), + }, // Client Groups { path: "/clients/groups", diff --git a/thanasoft-front/src/services/invoice.ts b/thanasoft-front/src/services/invoice.ts new file mode 100644 index 0000000..ea857a4 --- /dev/null +++ b/thanasoft-front/src/services/invoice.ts @@ -0,0 +1,176 @@ +import { request } from "./http"; +import { Client } from "./client"; + +export interface Invoice { + id: number; + client_id: number; + group_id: number | null; + source_quote_id: number | null; + invoice_number: string; + status: + | "brouillon" + | "emise" + | "envoyee" + | "partiellement_payee" + | "payee" + | "echue" + | "annulee" + | "avoir"; + invoice_date: string; + due_date: string | null; + currency: string; + total_ht: number; + total_tva: number; + total_ttc: number; + e_invoicing_channel_id: number | null; + e_invoice_status: string | null; + created_at: string; + updated_at: string; + client?: Client; +} + +export interface InvoiceListResponse { + data: Invoice[]; + meta?: { + current_page: number; + last_page: number; + per_page: number; + total: number; + }; +} + +export interface InvoiceResponse { + data: Invoice; +} + +export interface InvoiceLine { + product_id: number | null; + product_name: string; + packaging_id?: number | null; + packages_qty?: number | null; + units_qty?: number | null; + description?: string; + qty_base?: number | null; + unit_price: number; + unit_price_per_package?: number | null; + tva_rate_id?: number | null; + discount_pct?: number; + quantity: number; + tva?: number; + total_ht?: number; +} + +export interface CreateInvoicePayload { + client_id: number; + group_id?: number | null; + source_quote_id?: number | null; + status: + | "brouillon" + | "emise" + | "envoyee" + | "partiellement_payee" + | "payee" + | "echue" + | "annulee" + | "avoir"; + invoice_date: string; + due_date?: string | null; + currency: string; + total_ht: number; + total_tva: number; + total_ttc: number; + lines: InvoiceLine[]; +} + +export interface UpdateInvoicePayload extends Partial { + id: number; +} + +export const InvoiceService = { + /** + * Get all invoices with pagination + */ + async getAllInvoices(params?: { + page?: number; + per_page?: number; + search?: string; + status?: string; + client_id?: number; + }): Promise { + const response = await request({ + url: "/api/invoices", + method: "get", + params, + }); + + return response; + }, + + /** + * Get a specific invoice by ID + */ + async getInvoice(id: number): Promise { + const response = await request({ + url: `/api/invoices/${id}`, + method: "get", + }); + + return response; + }, + + /** + * Create a new invoice + */ + async createInvoice(payload: CreateInvoicePayload): Promise { + const response = await request({ + url: "/api/invoices", + method: "post", + data: payload, + }); + + return response; + }, + + /** + * Create an invoice from a quote + */ + async createFromQuote(quoteId: number): Promise { + const response = await request({ + url: `/api/invoices/from-quote/${quoteId}`, + method: "post", + }); + + return response; + }, + + /** + * Update an existing invoice + */ + async updateInvoice(payload: UpdateInvoicePayload): Promise { + const { id, ...updateData } = payload; + + const response = await request({ + url: `/api/invoices/${id}`, + method: "put", + data: updateData, + }); + + return response; + }, + + /** + * Delete an invoice + */ + async deleteInvoice( + id: number + ): Promise<{ success: boolean; message: string }> { + const response = await request<{ success: boolean; message: string }>({ + url: `/api/invoices/${id}`, + method: "delete", + }); + + return response; + }, +}; + +export default InvoiceService; diff --git a/thanasoft-front/src/stores/invoiceStore.ts b/thanasoft-front/src/stores/invoiceStore.ts new file mode 100644 index 0000000..c9cc0ca --- /dev/null +++ b/thanasoft-front/src/stores/invoiceStore.ts @@ -0,0 +1,245 @@ +import { defineStore } from "pinia"; +import { ref, computed } from "vue"; +import InvoiceService, { + Invoice, + CreateInvoicePayload, + UpdateInvoicePayload, +} from "@/services/invoice"; + +export const useInvoiceStore = defineStore("invoice", () => { + // State + const invoices = ref([]); + const currentInvoice = ref(null); + const loading = ref(false); + const error = ref(null); + + // Pagination state + const pagination = ref({ + current_page: 1, + last_page: 1, + per_page: 10, + total: 0, + }); + + // Getters + const allInvoices = computed(() => invoices.value); + const isLoading = computed(() => loading.value); + const hasError = computed(() => error.value !== null); + const getError = computed(() => error.value); + const getInvoiceById = computed(() => (id: number) => + invoices.value.find((invoice) => invoice.id === id) + ); + const getPagination = computed(() => pagination.value); + + // Actions + const setLoading = (isLoading: boolean) => { + loading.value = isLoading; + }; + + const setError = (err: string | null) => { + error.value = err; + }; + + const setInvoices = (newInvoices: Invoice[]) => { + invoices.value = newInvoices; + }; + + const setCurrentInvoice = (invoice: Invoice | null) => { + currentInvoice.value = invoice; + }; + + const setPagination = (meta: any) => { + if (meta) { + pagination.value = { + current_page: meta.current_page || 1, + last_page: meta.last_page || 1, + per_page: meta.per_page || 10, + total: meta.total || 0, + }; + } + }; + + /** + * Fetch all invoices with optional pagination and filters + */ + const fetchInvoices = async (params?: { + page?: number; + per_page?: number; + search?: string; + status?: string; + client_id?: number; + }) => { + setLoading(true); + setError(null); + + try { + const response = await InvoiceService.getAllInvoices(params); + setInvoices(response.data); + if (response.meta) { + setPagination(response.meta); + } + return response; + } catch (err: any) { + const errorMessage = + err.response?.data?.message || err.message || "Failed to fetch invoices"; + setError(errorMessage); + throw err; + } finally { + setLoading(false); + } + }; + + /** + * Fetch a single invoice by ID + */ + const fetchInvoice = async (id: number) => { + setLoading(true); + setError(null); + + try { + const response = await InvoiceService.getInvoice(id); + setCurrentInvoice(response.data); + return response.data; + } catch (err: any) { + const errorMessage = + err.response?.data?.message || err.message || "Failed to fetch invoice"; + setError(errorMessage); + throw err; + } finally { + setLoading(false); + } + }; + + /** + * Create a new invoice + */ + const createInvoice = async (payload: CreateInvoicePayload) => { + setLoading(true); + setError(null); + + try { + const response = await InvoiceService.createInvoice(payload); + invoices.value.push(response.data); + setCurrentInvoice(response.data); + return response.data; + } catch (err: any) { + const errorMessage = + err.response?.data?.message || err.message || "Failed to create invoice"; + setError(errorMessage); + throw err; + } finally { + setLoading(false); + } + }; + + /** + * Create an invoice from a quote + */ + const createFromQuote = async (quoteId: number) => { + setLoading(true); + setError(null); + + try { + const response = await InvoiceService.createFromQuote(quoteId); + invoices.value.push(response.data); + setCurrentInvoice(response.data); + return response.data; + } catch (err: any) { + const errorMessage = + err.response?.data?.message || err.message || "Failed to create invoice from quote"; + setError(errorMessage); + throw err; + } finally { + setLoading(false); + } + }; + + /** + * Update an existing invoice + */ + const updateInvoice = async (payload: UpdateInvoicePayload) => { + setLoading(true); + setError(null); + + try { + const response = await InvoiceService.updateInvoice(payload); + const updatedInvoice = response.data; + + // Update in the invoices list + const index = invoices.value.findIndex( + (invoice) => invoice.id === updatedInvoice.id + ); + if (index !== -1) { + invoices.value[index] = updatedInvoice; + } + + // Update current invoice if it's the one being edited + if (currentInvoice.value && currentInvoice.value.id === updatedInvoice.id) { + setCurrentInvoice(updatedInvoice); + } + + return updatedInvoice; + } catch (err: any) { + const errorMessage = + err.response?.data?.message || err.message || "Failed to update invoice"; + setError(errorMessage); + throw err; + } finally { + setLoading(false); + } + }; + + /** + * Delete an invoice + */ + const deleteInvoice = async (id: number) => { + setLoading(true); + setError(null); + + try { + const response = await InvoiceService.deleteInvoice(id); + + // Remove from the invoices list + invoices.value = invoices.value.filter((invoice) => invoice.id !== id); + + // Clear current invoice if it's the one being deleted + if (currentInvoice.value && currentInvoice.value.id === id) { + setCurrentInvoice(null); + } + + return response; + } catch (err: any) { + const errorMessage = + err.response?.data?.message || err.message || "Failed to delete invoice"; + setError(errorMessage); + throw err; + } finally { + setLoading(false); + } + }; + + return { + // State + invoices, + currentInvoice, + loading, + error, + pagination, + + // Getters + allInvoices, + isLoading, + hasError, + getError, + getInvoiceById, + getPagination, + + // Actions + fetchInvoices, + fetchInvoice, + createInvoice, + createFromQuote, + updateInvoice, + deleteInvoice, + }; +}); diff --git a/thanasoft-front/src/views/pages/Ventes/InvoiceDetail.vue b/thanasoft-front/src/views/pages/Ventes/InvoiceDetail.vue new file mode 100644 index 0000000..7944f77 --- /dev/null +++ b/thanasoft-front/src/views/pages/Ventes/InvoiceDetail.vue @@ -0,0 +1,12 @@ + + + diff --git a/thanasoft-front/src/views/pages/Ventes/InvoiceList.vue b/thanasoft-front/src/views/pages/Ventes/InvoiceList.vue new file mode 100644 index 0000000..e41481e --- /dev/null +++ b/thanasoft-front/src/views/pages/Ventes/InvoiceList.vue @@ -0,0 +1,7 @@ + + + diff --git a/thanasoft-front/src/views/pages/Ventes/NewInvoice.vue b/thanasoft-front/src/views/pages/Ventes/NewInvoice.vue new file mode 100644 index 0000000..4bac7ce --- /dev/null +++ b/thanasoft-front/src/views/pages/Ventes/NewInvoice.vue @@ -0,0 +1,46 @@ + + +